diff --git a/.travis/postgresql_settings.py b/.travis/postgresql_settings.py index 292cb9cc70..c12927af3a 100644 --- a/.travis/postgresql_settings.py +++ b/.travis/postgresql_settings.py @@ -40,7 +40,7 @@ SERVERNAME = "testing_mygame" DATABASES = { "default": { - "ENGINE": "django.db.backends.postgresql_psycopg2", + "ENGINE": "django.db.backends.postgresql", "NAME": "evennia", "USER": "evennia", "PASSWORD": "password", diff --git a/CHANGELOG.md b/CHANGELOG.md index 80c011be68..b355e4b6b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,10 @@ without arguments starts a full interactive Python console. `.get_command_info()` method for easier overloading and access. (Volund) - Removed unused `CYCLE_LOGFILES` setting. Added `SERVER_LOG_DAY_ROTATION` and `SERVER_LOG_MAX_SIZE` (and equivalent for PORTAL) to control log rotation. +- Addded `inside_rec` lockfunc - if room is locked, the normal `inside()` lockfunc will + fail e.g. for your inventory objs (since their loc is you), whereas this will pass. +- RPSystem contrib's CmdRecog will now list all recogs if no arg is given. Also multiple + bugfixes. ## Evennia 0.9 (2018-2019) diff --git a/INSTALL.md b/INSTALL.md index 8c45cb357e..926785ee5f 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,137 +1,5 @@ # Evennia installation -The latest and more detailed installation instructions can be found -[here](https://github.com/evennia/evennia/wiki/Getting-Started). - -## Installing Python - -First install [Python](https://www.python.org/). Linux users should -have it in their repositories, Windows/Mac users can get it from the -Python homepage. You need the 2.7.x version (Python 3 is not yet -supported). Windows users, make sure to select the option to make -Python available in your path - this is so you can call it everywhere -as `python`. Python 2.7.9 and later also includes the -[pip](https://pypi.python.org/pypi/pip/) installer out of the box, -otherwise install this separately (in linux it's usually found as the -`python-pip` package). - -### installing virtualenv - -This step is optional, but *highly* recommended. For installing -up-to-date Python packages we recommend using -[virtualenv](https://pypi.python.org/pypi/virtualenv), this makes it -easy to keep your Python packages up-to-date without interfering with -the defaults for your system. - -``` -pip install virtualenv -``` - -Go to the place where you want to make your virtual python library -storage. This does not need to be near where you plan to install -Evennia. Then do - -``` -virtualenv vienv -``` - -A new folder `vienv` will be created (you could also name it something -else if you prefer). Activate the virtual environment like this: - -``` -# for Linux/Unix/Mac: -source vienv/bin/activate -# for Windows: -vienv\Scripts\activate.bat -``` - -You should see `(vienv)` next to your prompt to show you the -environment is active. You need to activate it whenever you open a new -terminal, but you *don't* have to be inside the `vienv` folder henceforth. - - -## Get the developer's version of Evennia - -This is currently the only Evennia version available. First download -and install [Git](http://git-scm.com/) from the homepage or via the -package manager in Linux. Next, go to the place where you want the -`evennia` folder to be created and run - -``` -git clone https://github.com/evennia/evennia.git -``` - -If you have a github account and have [set up SSH -keys](https://help.github.com/articles/generating-ssh-keys/), you want -to use this instead: - -``` -git clone git@github.com:evennia/evennia.git -``` - -In the future you just enter the new `evennia` folder and do - -``` -git pull -``` - -to get the latest Evennia updates. - -## Evennia package install - -Stand at the root of your new `evennia` directory and run - -``` -pip install -e . -``` - -(note the period "." at the end, this tells pip to install from the -current directory). This will install Evennia and all its dependencies -(into your virtualenv if you are using that) and make the `evennia` -command available on the command line. You can find Evennia's -dependencies in `evennia/requirements.txt`. - -## Creating your game project - -To create your new game you need to initialize a new game project. -This should be done somewhere *outside* of your `evennia` folder. - - -``` -evennia --init mygame -``` - -This will create a new game project named "mygame" in a folder of the -same name. If you want to change the settings for your project, you -will need to edit `mygame/server/conf/settings.py`. - - -## Starting Evennia - -Enter your new game directory and run - -``` -evennia migrate -evennia start -``` - -Follow the instructions to create your superuser account. A lot of -information will scroll past as the database is created and the server -initializes. After this Evennia will be running. Use - -``` -evennia -h -``` - -for help with starting, stopping and other operations. - -Start up your MUD client of choice and point it to your server and -port *4000*. If you are just running locally the server name is -*localhost*. - -Alternatively, you can find the web interface and webclient by -pointing your web browser to *http://localhost:4001*. - -Finally, login with the superuser account and password you provided -earlier. Welcome to Evennia! +You can find the latest updated installation instructions and +requirements [here](https://github.com/evennia/evennia/wiki/Getting-Started). diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index f1bc45acf6..af0050ec9d 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -37,7 +37,7 @@ from evennia.scripts.scripthandler import ScriptHandler from evennia.commands.cmdsethandler import CmdSetHandler from evennia.utils.optionhandler import OptionHandler -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ from random import getrandbits __all__ = ("DefaultAccount",) @@ -828,7 +828,10 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): server. Args: - text (str, optional): text data to send + text (str or tuple, optional): The message to send. This + is treated internally like any send-command, so its + value can be a tuple if sending multiple arguments to + the `text` oob command. from_obj (Object or Account or list, optional): Object sending. If given, its at_msg_send() hook will be called. If iterable, call on all entities. session (Session or list, optional): Session object or a list of @@ -859,7 +862,13 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): kwargs["options"] = options if text is not None: - kwargs["text"] = to_str(text) + if not (isinstance(text, str) or isinstance(text, tuple)): + # sanitize text before sending across the wire + try: + text = to_str(text) + except Exception: + text = repr(text) + kwargs["text"] = text # session relay sessions = make_iter(session) if session else self.sessions.all() diff --git a/evennia/commands/cmdhandler.py b/evennia/commands/cmdhandler.py index 8ccbcbae93..6f8f247a72 100644 --- a/evennia/commands/cmdhandler.py +++ b/evennia/commands/cmdhandler.py @@ -48,7 +48,7 @@ from evennia.comms.channelhandler import CHANNELHANDLER from evennia.utils import logger, utils from evennia.utils.utils import string_suggestions -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ _IN_GAME_ERRORS = settings.IN_GAME_ERRORS diff --git a/evennia/commands/cmdset.py b/evennia/commands/cmdset.py index 5bb3ec8d28..f124bbbb06 100644 --- a/evennia/commands/cmdset.py +++ b/evennia/commands/cmdset.py @@ -27,7 +27,7 @@ Set theory. """ from weakref import WeakKeyDictionary -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ from evennia.utils.utils import inherits_from, is_iter __all__ = ("CmdSet",) diff --git a/evennia/commands/cmdsethandler.py b/evennia/commands/cmdsethandler.py index e855d551e9..395c9c2ba3 100644 --- a/evennia/commands/cmdsethandler.py +++ b/evennia/commands/cmdsethandler.py @@ -72,7 +72,7 @@ from evennia.utils import logger, utils from evennia.commands.cmdset import CmdSet from evennia.server.models import ServerConfig -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ __all__ = ("import_cmdset", "CmdSetHandler") diff --git a/evennia/commands/command.py b/evennia/commands/command.py index 8110a9ecb3..dba035b481 100644 --- a/evennia/commands/command.py +++ b/evennia/commands/command.py @@ -6,6 +6,7 @@ All commands in Evennia inherit from the 'Command' class in this module. """ import re import math +import inspect from django.conf import settings @@ -74,6 +75,13 @@ def _init_command(cls, **kwargs): cls.is_exit = False if not hasattr(cls, "help_category"): cls.help_category = "general" + # make sure to pick up the parent's docstring if the child class is + # missing one (important for auto-help) + if cls.__doc__ is None: + for parent_class in inspect.getmro(cls): + if parent_class.__doc__ is not None: + cls.__doc__ = parent_class.__doc__ + break cls.help_category = cls.help_category.lower() diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 47ebaa9296..1ab8e8714a 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -19,6 +19,7 @@ from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore from evennia.prototypes import spawner, prototypes as protlib, menus as olc_menus from evennia.utils.ansi import raw +from evennia.prototypes.menus import _format_diff_text_and_options COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -1912,8 +1913,8 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): Usage: typeclass[/switch] [= typeclass.path] - type '' - parent '' + typeclass/prototype = prototype_key + typeclass/list/show [typeclass.path] swap - this is a shorthand for using /force/reset flags. update - this is a shorthand for using the /force/reload flag. @@ -1930,9 +1931,12 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): list - show available typeclasses. Only typeclasses in modules actually imported or used from somewhere in the code will show up here (those typeclasses are still available if you know the path) + prototype - clean and overwrite the object with the specified + prototype key - effectively making a whole new object. Example: type button = examples.red_button.RedButton + type/prototype button=a red button If the typeclass_path is not given, the current object's typeclass is assumed. @@ -1954,7 +1958,7 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): key = "typeclass" aliases = ["type", "parent", "swap", "update"] - switch_options = ("show", "examine", "update", "reset", "force", "list") + switch_options = ("show", "examine", "update", "reset", "force", "list", "prototype") locks = "cmd:perm(typeclass) or perm(Builder)" help_category = "Building" @@ -2038,6 +2042,27 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): new_typeclass = self.rhs or obj.path + prototype = None + if "prototype" in self.switches: + key = self.rhs + prototype = protlib.search_prototype(key=key) + if len(prototype) > 1: + caller.msg( + "More than one match for {}:\n{}".format( + key, "\n".join(proto.get("prototype_key", "") for proto in prototype) + ) + ) + return + elif prototype: + # one match + prototype = prototype[0] + else: + # no match + caller.msg("No prototype '{}' was found.".format(key)) + return + new_typeclass = prototype["typeclass"] + self.switches.append("force") + if "show" in self.switches or "examine" in self.switches: string = "%s's current typeclass is %s." % (obj.name, obj.__class__) caller.msg(string) @@ -2070,11 +2095,34 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): hooks = "at_object_creation" if update else "all" old_typeclass_path = obj.typeclass_path + # special prompt for the user in cases where we want + # to confirm changes. + if "prototype" in self.switches: + diff, _ = spawner.prototype_diff_from_object(prototype, obj) + txt, options = _format_diff_text_and_options(diff, objects=[obj]) + prompt = ( + "Applying prototype '%s' over '%s' will cause the follow changes:\n%s\n" + % (prototype["key"], obj.name, "\n".join(txt)) + ) + if not reset: + prompt += "\n|yWARNING:|n Use the /reset switch to apply the prototype over a blank state." + prompt += "\nAre you sure you want to apply these changes [yes]/no?" + answer = yield (prompt) + if answer and answer in ("no", "n"): + caller.msg("Canceled: No changes were applied.") + return + # we let this raise exception if needed obj.swap_typeclass( new_typeclass, clean_attributes=reset, clean_cmdsets=reset, run_start_hooks=hooks ) + if "prototype" in self.switches: + modified = spawner.batch_update_objects_with_prototype(prototype, objects=[obj]) + prototype_success = modified > 0 + if not prototype_success: + caller.msg("Prototype %s failed to apply." % prototype["key"]) + if is_same: string = "%s updated its existing typeclass (%s).\n" % (obj.name, obj.path) else: @@ -2091,6 +2139,11 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): string += " All old attributes where deleted before the swap." else: string += " Attributes set before swap were not removed." + if "prototype" in self.switches and prototype_success: + string += ( + " Prototype '%s' was successfully applied over the object type." + % prototype["key"] + ) caller.msg(string) @@ -2832,8 +2885,8 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS): reference. A puppeted object cannot be moved to None. loc - teleport object to the target's location instead of its contents - Teleports an object somewhere. If no object is given, you yourself - is teleported to the target location. + Teleports an object somewhere. If no object is given, you yourself are + teleported to the target location. """ key = "tel" @@ -2998,7 +3051,8 @@ class CmdScript(COMMAND_DEFAULT_CLASS): ok = obj.scripts.add(self.rhs, autostart=True) if not ok: result.append( - "\nScript %s could not be added and/or started on %s." + "\nScript %s could not be added and/or started on %s " + "(or it started and immediately shut down)." % (self.rhs, obj.get_display_name(caller)) ) else: diff --git a/evennia/commands/default/help.py b/evennia/commands/default/help.py index 96ad641c1a..9e1b45c2c6 100644 --- a/evennia/commands/default/help.py +++ b/evennia/commands/default/help.py @@ -79,7 +79,7 @@ class CmdHelp(Command): evmore.msg(self.caller, text, session=self.session) return - self.msg((text, {"type": "help"})) + self.msg(text=(text, {"type": "help"})) @staticmethod def format_help_entry(title, help_text, aliases=None, suggested=None): diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index a250166530..d41a1ca4ca 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -991,6 +991,34 @@ class TestBuilding(CommandTest): "All object creation hooks were run. All old attributes where deleted before the swap.", ) + from evennia.prototypes.prototypes import homogenize_prototype + + test_prototype = [ + homogenize_prototype( + { + "prototype_key": "testkey", + "prototype_tags": [], + "typeclass": "typeclasses.objects.Object", + "key": "replaced_obj", + "attrs": [("foo", "bar", None, ""), ("desc", "protdesc", None, "")], + } + ) + ] + with mock.patch( + "evennia.commands.default.building.protlib.search_prototype", + new=mock.MagicMock(return_value=test_prototype), + ) as mprot: + self.call( + building.CmdTypeclass(), + "/prototype Obj=testkey", + "replaced_obj changed typeclass from " + "evennia.objects.objects.DefaultObject to " + "typeclasses.objects.Object.\nAll object creation hooks were " + "run. Attributes set before swap were not removed. Prototype " + "'replaced_obj' was successfully applied over the object type.", + ) + assert self.obj1.db.desc == "protdesc" + def test_lock(self): self.call(building.CmdLock(), "", "Usage: ") self.call(building.CmdLock(), "Obj = test:all()", "Added lock 'test:all()' to Obj.") diff --git a/evennia/comms/channelhandler.py b/evennia/comms/channelhandler.py index feea9f6e33..98e28786a0 100644 --- a/evennia/comms/channelhandler.py +++ b/evennia/comms/channelhandler.py @@ -27,7 +27,7 @@ from django.conf import settings from evennia.commands import cmdset, command from evennia.utils.logger import tail_log_file from evennia.utils.utils import class_from_module -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ # we must late-import these since any overloads are likely to # themselves be using these classes leading to a circular import. diff --git a/evennia/comms/managers.py b/evennia/comms/managers.py index 2a2589dd47..9b950ec129 100644 --- a/evennia/comms/managers.py +++ b/evennia/comms/managers.py @@ -8,6 +8,7 @@ Comm system components. from django.db.models import Q from evennia.typeclasses.managers import TypedObjectManager, TypeclassManager from evennia.utils import logger +from evennia.utils.utils import dbref _GA = object.__getattribute__ _AccountDB = None @@ -31,32 +32,6 @@ class CommError(Exception): # -def dbref(inp, reqhash=True): - """ - Valid forms of dbref (database reference number) are either a - string '#N' or an integer N. - - Args: - inp (int or str): A possible dbref to check syntactically. - reqhash (bool): Require an initial hash `#` to accept. - - Returns: - is_dbref (int or None): The dbref integer part if a valid - dbref, otherwise `None`. - - """ - if reqhash and not (isinstance(inp, str) and inp.startswith("#")): - return None - if isinstance(inp, str): - inp = inp.lstrip("#") - try: - if int(inp) < 0: - return None - except Exception: - return None - return inp - - def identify_object(inp): """ Helper function. Identifies if an object is an account or an object; diff --git a/evennia/contrib/gendersub.py b/evennia/contrib/gendersub.py index 09d8a2a006..627a4e3268 100644 --- a/evennia/contrib/gendersub.py +++ b/evennia/contrib/gendersub.py @@ -8,26 +8,39 @@ insert custom markers in their text to indicate gender-aware messaging. It relies on a modified msg() and is meant as an inspiration and starting point to how to do stuff like this. -When in use, all messages being sent to the character will make use of -the character's gender, for example the echo +An object can have the following genders: + - male (he/his) + - female (her/hers) + - neutral (it/its) + - ambiguous (they/them/their/theirs) + +When in use, messages can contain special tags to indicate pronouns gendered +based on the one being addressed. Capitalization will be retained. + +- `|s`, `|S`: Subjective form: he, she, it, He, She, It, They +- `|o`, `|O`: Objective form: him, her, it, Him, Her, It, Them +- `|p`, `|P`: Possessive form: his, her, its, His, Her, Its, Their +- `|a`, `|A`: Absolute Possessive form: his, hers, its, His, Hers, Its, Theirs + +For example, ``` char.msg("%s falls on |p face with a thud." % char.key) +"Tom falls on his face with a thud" ``` -will result in "Tom falls on his|her|its|their face with a thud" -depending on the gender of the object being messaged. Default gender -is "ambiguous" (they). +The default gender is "ambiguous" (they/them/their/theirs). To use, have DefaultCharacter inherit from this, or change setting.DEFAULT_CHARACTER to point to this class. -The `@gender` command needs to be added to the default cmdset before -it becomes available. +The `@gender` command is used to set the gender. It needs to be added to the +default cmdset before it becomes available. """ import re +from evennia.utils import logger from evennia import DefaultCharacter from evennia import Command @@ -114,7 +127,10 @@ class GenderCharacter(DefaultCharacter): gender-aware markers in output. Args: - text (str, optional): The message to send + text (str or tuple, optional): The message to send. This + is treated internally like any send-command, so its + value can be a tuple if sending multiple arguments to + the `text` oob command. from_obj (obj, optional): object that is sending. If given, at_msg_send will be called session (Session or list, optional): session or list of @@ -125,9 +141,13 @@ class GenderCharacter(DefaultCharacter): All extra kwargs will be passed on to the protocol. """ - # pre-process the text before continuing try: - text = _RE_GENDER_PRONOUN.sub(self._get_pronoun, text) + if text and isinstance(text, tuple): + text = (self._RE_GENDER_PRONOUN.sub(self._get_pronoun, text[0]), *text[1:]) + else: + text = self._RE_GENDER_PRONOUN.sub(self._get_pronoun, text) except TypeError: pass + except Exception as e: + logger.log_trace(e) super().msg(text, from_obj=from_obj, session=session, **kwargs) diff --git a/evennia/contrib/rpsystem.py b/evennia/contrib/rpsystem.py index e398384411..4f68f0dc44 100644 --- a/evennia/contrib/rpsystem.py +++ b/evennia/contrib/rpsystem.py @@ -798,6 +798,16 @@ class RecogHandler(object): # recog_mask log not passed, disable recog return obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key + def all(self): + """ + Get a mapping of the recogs stored in handler. + + Returns: + recogs (dict): A mapping of {recog: obj} stored in handler. + + """ + return {self.obj2recog[obj]: obj for obj in self.obj2recog.keys()} + def remove(self, obj): """ Clear recog for a given object. @@ -896,10 +906,9 @@ class CmdSay(RPCommand): # replaces standard say caller.msg("Say what?") return - # calling the speech hook on the location - speech = caller.location.at_before_say(self.args) + # calling the speech modifying hook + speech = caller.at_before_say(self.args) # preparing the speech with sdesc/speech parsing. - speech = '/me says, "{speech}"'.format(speech=speech) targets = self.caller.location.contents send_emote(self.caller, targets, speech, anonymous_add=None) @@ -932,6 +941,9 @@ class CmdSdesc(RPCommand): # set/look at own sdesc except SdescError as err: caller.msg(err) return + except AttributeError: + caller.msg(f"Cannot set sdesc on {caller.key}.") + return caller.msg("%s's sdesc was set to '%s'." % (caller.key, sdesc)) @@ -1041,6 +1053,7 @@ class CmdRecog(RPCommand): # assign personal alias to object in room Recognize another person in the same room. Usage: + recog recog sdesc as alias forget alias @@ -1048,8 +1061,8 @@ class CmdRecog(RPCommand): # assign personal alias to object in room recog tall man as Griatch forget griatch - This will assign a personal alias for a person, or - forget said alias. + This will assign a personal alias for a person, or forget said alias. + Using the command without arguments will list all current recogs. """ @@ -1058,6 +1071,7 @@ class CmdRecog(RPCommand): # assign personal alias to object in room def parse(self): "Parse for the sdesc as alias structure" + self.sdesc, self.alias = "", "" if " as " in self.args: self.sdesc, self.alias = [part.strip() for part in self.args.split(" as ", 2)] elif self.args: @@ -1070,22 +1084,47 @@ class CmdRecog(RPCommand): # assign personal alias to object in room def func(self): "Assign the recog" caller = self.caller - if not self.args: - caller.msg("Usage: recog as or forget ") - return - sdesc = self.sdesc alias = self.alias.rstrip(".?!") + sdesc = self.sdesc + + recog_mode = self.cmdstring != "forget" and alias and sdesc + forget_mode = self.cmdstring == "forget" and sdesc + list_mode = not self.args + + if not (recog_mode or forget_mode or list_mode): + caller.msg("Usage: recog, recog as or forget ") + return + + if list_mode: + # list all previously set recogs + all_recogs = caller.recog.all() + if not all_recogs: + caller.msg( + "You recognize no-one. " "(Use 'recog as ' to recognize people." + ) + else: + # note that we don't skip those failing enable_recog lock here, + # because that would actually reveal more than we want. + lst = "\n".join( + " {} ({})".format(key, obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key) + for key, obj in all_recogs.items() + ) + caller.msg( + f"Currently recognized (use 'recog as ' to add " + f"new and 'forget ' to remove):\n{lst}" + ) + return + prefixed_sdesc = sdesc if sdesc.startswith(_PREFIX) else _PREFIX + sdesc candidates = caller.location.contents matches = parse_sdescs_and_recogs(caller, candidates, prefixed_sdesc, search_mode=True) nmatches = len(matches) - # handle 0, 1 and >1 matches + # handle 0 and >1 matches if nmatches == 0: caller.msg(_EMOTE_NOMATCH_ERROR.format(ref=sdesc)) elif nmatches > 1: reflist = [ - "%s%s%s (%s%s)" - % ( + "{}{}{} ({}{})".format( inum + 1, _NUM_SEP, _RE_PREFIX.sub("", sdesc), @@ -1095,17 +1134,20 @@ class CmdRecog(RPCommand): # assign personal alias to object in room for inum, obj in enumerate(matches) ] caller.msg(_EMOTE_MULTIMATCH_ERROR.format(ref=sdesc, reflist="\n ".join(reflist))) + else: + # one single match obj = matches[0] if not obj.access(self.obj, "enable_recog", default=True): # don't apply recog if object doesn't allow it (e.g. by being masked). - caller.msg("Can't recognize someone who is masked.") + caller.msg("It's impossible to recognize them.") return - if self.cmdstring == "forget": + if forget_mode: # remove existing recog caller.recog.remove(obj) - caller.msg("%s will now know only '%s'." % (caller.key, obj.recog.get(obj))) + caller.msg("%s will now know them only as '%s'." % (caller.key, obj.recog.get(obj))) else: + # set recog sdesc = obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key try: alias = caller.recog.add(obj, alias) @@ -1509,6 +1551,20 @@ class ContribRPCharacter(DefaultCharacter, ContribRPObject): # initializing sdesc self.sdesc.add("A normal person") + def at_before_say(self, message, **kwargs): + """ + Called before the object says or whispers anything, return modified message. + + Args: + message (str): The suggested say/whisper text spoken by self. + Kwargs: + whisper (bool): If True, this is a whisper rather than a say. + + """ + if kwargs.get("whisper"): + return f'/me whispers "{message}"' + return f'/me says, "{message}"' + def process_sdesc(self, sdesc, obj, **kwargs): """ Allows to customize how your sdesc is displayed (primarily by diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index fa132f6264..d394cd5a3b 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -169,6 +169,8 @@ class TestRPSystem(EvenniaTest): self.speaker.recog.remove(self.receiver1) self.assertEqual(self.speaker.recog.get(self.receiver1), sdesc1) + self.assertEqual(self.speaker.recog.all(), {"Mr Receiver2": self.receiver2}) + def test_parse_language(self): self.assertEqual( rpsystem.parse_language(self.speaker, emote), @@ -233,6 +235,49 @@ class TestRPSystem(EvenniaTest): self.assertEqual(self.speaker.search("colliding"), self.receiver2) +class TestRPSystemCommands(CommandTest): + def setUp(self): + super().setUp() + self.char1.swap_typeclass(rpsystem.ContribRPCharacter) + self.char2.swap_typeclass(rpsystem.ContribRPCharacter) + + def test_commands(self): + + self.call( + rpsystem.CmdSdesc(), "Foobar Character", "Char's sdesc was set to 'Foobar Character'." + ) + self.call( + rpsystem.CmdSdesc(), + "BarFoo Character", + "Char2's sdesc was set to 'BarFoo Character'.", + caller=self.char2, + ) + self.call(rpsystem.CmdSay(), "Hello!", 'Char says, "Hello!"') + self.call(rpsystem.CmdEmote(), "/me smiles to /barfoo.", "Char smiles to BarFoo Character") + self.call( + rpsystem.CmdPose(), + "stands by the bar", + "Pose will read 'Foobar Character stands by the bar.'.", + ) + self.call( + rpsystem.CmdRecog(), + "barfoo as friend", + "Char will now remember BarFoo Character as friend.", + ) + self.call( + rpsystem.CmdRecog(), + "", + "Currently recognized (use 'recog as ' to add new " + "and 'forget ' to remove):\n friend (BarFoo Character)", + ) + self.call( + rpsystem.CmdRecog(), + "friend", + "Char will now know them only as 'BarFoo Character'", + cmdstring="forget", + ) + + # Testing of ExtendedRoom contrib from django.conf import settings diff --git a/evennia/game_template/server/conf/web_plugins.py b/evennia/game_template/server/conf/web_plugins.py index 4050a82664..ec11ad7c6a 100644 --- a/evennia/game_template/server/conf/web_plugins.py +++ b/evennia/game_template/server/conf/web_plugins.py @@ -26,3 +26,16 @@ def at_webserver_root_creation(web_root): """ return web_root + + +def at_webproxy_root_creation(web_root): + """ + This function can modify the portal proxy service. + Args: + web_root (evennia.server.webserver.Website): The Evennia + Website application. Use .putChild() to add new + subdomains that are Portal-accessible over TCP; + primarily for new protocol development, but suitable + for other shenanigans. + """ + return web_root diff --git a/evennia/locks/lockfuncs.py b/evennia/locks/lockfuncs.py index bd137aed8b..eda4c2d733 100644 --- a/evennia/locks/lockfuncs.py +++ b/evennia/locks/lockfuncs.py @@ -547,11 +547,39 @@ def inside(accessing_obj, accessed_obj, *args, **kwargs): Usage: inside() - Only true if accessing_obj is "inside" accessed_obj + True if accessing_obj is 'inside' accessing_obj. Note that this only checks + one level down. So if if the lock is on a room, you will pass but not your + inventory (since their location is you, not the locked object). If you + want also nested objects to pass the lock, use the `insiderecursive` + lockfunc. """ return accessing_obj.location == accessed_obj +def inside_rec(accessing_obj, accessed_obj, *args, **kwargs): + """ + Usage: + inside_rec() + + True if accessing_obj is inside the accessed obj, at up to 10 levels + of recursion (so if this lock is on a room, then an object inside a box + in your inventory will also pass the lock). + """ + + def _recursive_inside(obj, accessed_obj, lvl=1): + if obj.location: + if obj.location == accessed_obj: + return True + elif lvl >= 10: + # avoid infinite recursions + return False + else: + return _recursive_inside(obj.location, accessed_obj, lvl + 1) + return False + + return _recursive_inside(accessing_obj, accessed_obj) + + def holds(accessing_obj, accessed_obj, *args, **kwargs): """ Usage: diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py index 22f5844e31..ac8c85abc8 100644 --- a/evennia/locks/lockhandler.py +++ b/evennia/locks/lockhandler.py @@ -107,7 +107,7 @@ to any other identifier you can use. import re from django.conf import settings from evennia.utils import logger, utils -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ __all__ = ("LockHandler", "LockException") diff --git a/evennia/locks/tests.py b/evennia/locks/tests.py index bb02877f06..dce0e28fff 100644 --- a/evennia/locks/tests.py +++ b/evennia/locks/tests.py @@ -17,6 +17,7 @@ except ImportError: from evennia import settings_default from evennia.locks import lockfuncs +from evennia.utils.create import create_object # ------------------------------------------------------------ # Lock testing @@ -179,6 +180,13 @@ class TestLockfuncs(EvenniaTest): self.assertEqual(False, lockfuncs.inside(self.char1, self.room2)) self.assertEqual(True, lockfuncs.holds(self.room1, self.char1)) self.assertEqual(False, lockfuncs.holds(self.room2, self.char1)) + # test recursively + self.assertEqual(True, lockfuncs.inside_rec(self.char1, self.room1)) + self.assertEqual(False, lockfuncs.inside_rec(self.char1, self.room2)) + inventory_item = create_object(key="InsideTester", location=self.char1) + self.assertEqual(True, lockfuncs.inside_rec(inventory_item, self.room1)) + self.assertEqual(False, lockfuncs.inside_rec(inventory_item, self.room2)) + inventory_item.delete() def test_has_account(self): self.assertEqual(True, lockfuncs.has_account(self.char1, None)) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index d980b88303..25731923e6 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -31,7 +31,7 @@ from evennia.utils.utils import ( list_to_string, to_str, ) -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ _INFLECT = inflect.engine() _MULTISESSION_MODE = settings.MULTISESSION_MODE diff --git a/evennia/scripts/scripthandler.py b/evennia/scripts/scripthandler.py index d2298a7ce2..31775c3eec 100644 --- a/evennia/scripts/scripthandler.py +++ b/evennia/scripts/scripthandler.py @@ -9,7 +9,7 @@ from evennia.scripts.models import ScriptDB from evennia.utils import create from evennia.utils import logger -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ class ScriptHandler(object): @@ -78,11 +78,20 @@ class ScriptHandler(object): scriptclass, key=key, account=self.obj, autostart=autostart ) else: - # the normal - adding to an Object - script = create.create_script(scriptclass, key=key, obj=self.obj, autostart=autostart) + # the normal - adding to an Object. We wait to autostart so we can differentiate + # a failing creation from a script that immediately starts/stops. + script = create.create_script(scriptclass, key=key, obj=self.obj, autostart=False) if not script: - logger.log_err("Script %s could not be created and/or started." % scriptclass) + logger.log_err("Script %s failed to be created/started." % scriptclass) return False + if autostart: + script.start() + if not script.id: + # this can happen if the script has repeats=1 or calls stop() in at_repeat. + logger.log_info( + "Script %s started and then immediately stopped; " + "it could probably be a normal function." % scriptclass + ) return True def start(self, key): diff --git a/evennia/scripts/scripts.py b/evennia/scripts/scripts.py index 42bb1c8797..2f5f95852b 100644 --- a/evennia/scripts/scripts.py +++ b/evennia/scripts/scripts.py @@ -8,7 +8,7 @@ ability to run timers. from twisted.internet.defer import Deferred, maybeDeferred from twisted.internet.task import LoopingCall from django.core.exceptions import ObjectDoesNotExist -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ from evennia.typeclasses.models import TypeclassBase from evennia.scripts.models import ScriptDB from evennia.scripts.manager import ScriptManager @@ -209,6 +209,9 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase): Step task runner. No try..except needed due to defer wrap. """ + if not self.ndb._task: + # if there is no task, we have no business using this method + return if not self.is_valid(): self.stop() @@ -218,10 +221,13 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase): self.at_repeat() # check repeats - callcount = self.ndb._task.callcount - maxcount = self.db_repeats - if maxcount > 0 and maxcount <= callcount: - self.stop() + if self.ndb._task: + # we need to check for the task in case stop() was called + # inside at_repeat() and it already went away. + callcount = self.ndb._task.callcount + maxcount = self.db_repeats + if maxcount > 0 and maxcount <= callcount: + self.stop() def _step_task(self): """ @@ -339,9 +345,9 @@ class DefaultScript(ScriptBase): try: obj = create.create_script(**kwargs) - except Exception as e: + except Exception: + logger.log_trace() errors.append("The script '%s' encountered errors and could not be created." % key) - logger.log_err(e) return obj, errors diff --git a/evennia/server/deprecations.py b/evennia/server/deprecations.py index 45eefd160b..fd6a7fbc85 100644 --- a/evennia/server/deprecations.py +++ b/evennia/server/deprecations.py @@ -115,3 +115,10 @@ def check_warnings(settings): print(" [Devel: settings.IN_GAME_ERRORS is True. Turn off in production.]") if settings.ALLOWED_HOSTS == ["*"]: print(" [Devel: settings.ALLOWED_HOSTS set to '*' (all). Limit in production.]") + for dbentry in settings.DATABASES.values(): + if "psycopg" in dbentry.get("ENGINE", ""): + print( + 'Deprecation: postgresql_psycopg2 backend is deprecated". ' + "Switch settings.DATABASES to use " + '"ENGINE": "django.db.backends.postgresql instead"' + ) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index ce0dfb2fa0..e4a1518a75 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -94,7 +94,7 @@ SRESET = chr(19) # shutdown server in reset mode PYTHON_MIN = "3.7" TWISTED_MIN = "18.0.0" DJANGO_MIN = "2.1" -DJANGO_REC = "2.2.9" +DJANGO_REC = "2.2" try: sys.path[1] = EVENNIA_ROOT @@ -1281,7 +1281,7 @@ def check_main_evennia_dependencies(): try: dversion = ".".join(str(num) for num in django.VERSION if isinstance(num, int)) # only the main version (1.5, not 1.5.4.0) - dversion_main = ".".join(dversion.split(".")[:3]) + dversion_main = ".".join(dversion.split(".")[:2]) if LooseVersion(dversion) < LooseVersion(DJANGO_MIN): print(ERROR_DJANGO_MIN.format(dversion=dversion_main, django_min=DJANGO_MIN)) error = True diff --git a/evennia/server/game_index_client/README.md b/evennia/server/game_index_client/README.md index f5077a43e6..5471ba7418 100644 --- a/evennia/server/game_index_client/README.md +++ b/evennia/server/game_index_client/README.md @@ -1,8 +1,8 @@ # Evennia Game Index Client -Greg Taylor 2016 +Greg Taylor 2016, Griatch 2020 -This contrib features a client for the [Evennia Game Index] +This is a client for the [Evennia Game Index] (http://evennia-game-index.appspot.com/), a listing of games built on Evennia. By listing your game on the index, you make it easy for other people in the community to discover your creation. @@ -14,74 +14,24 @@ on remedying this.* ## Listing your Game -To list your game, you'll need to enable the Evennia Game Index client. -Start by `cd`'ing to your game directory. From there, open up -`server/conf/server_services_plugins.py`. It might look something like this -if you don't have any other optional add-ons enabled: +To list your game, go to your game dir and run -```python -""" -Server plugin services + evennia connections -This plugin module can define user-created services for the Server to -start. +Follow the prompts to add details to the listing. Use `evennia reload`. In your log (visible with `evennia --log` +you should see a note that info has been sent to the game index. -This module must handle all imports and setups required to start a -twisted service (see examples in evennia.server.server). It must also -contain a function start_plugin_services(application). Evennia will -call this function with the main Server application (so your services -can be added to it). The function should not return anything. Plugin -services are started last in the Server startup process. -""" +## Detailed settings - -def start_plugin_services(server): - """ - This hook is called by Evennia, last in the Server startup process. - - server - a reference to the main server application. - """ - pass -``` - -To enable the client, import `EvenniaGameIndexService` and fire it up after the -Evennia server has finished starting: - -```python -""" -Server plugin services - -This plugin module can define user-created services for the Server to -start. - -This module must handle all imports and setups required to start a -twisted service (see examples in evennia.server.server). It must also -contain a function start_plugin_services(application). Evennia will -call this function with the main Server application (so your services -can be added to it). The function should not return anything. Plugin -services are started last in the Server startup process. -""" - -from evennia.contrib.egi_client import EvenniaGameIndexService - -def start_plugin_services(server): - """ - This hook is called by Evennia, last in the Server startup process. - - server - a reference to the main server application. - """ - egi_service = EvenniaGameIndexService() - server.services.addService(egi_service) -``` - -Next, configure your game listing by opening up `server/conf/settings.py` and +If you don't want to use the wizard you can configure your game listing by opening up `server/conf/settings.py` and using the following as a starting point: ```python ###################################################################### -# Contrib config +# Game index ###################################################################### +GAME_INDEX_ENABLED = True GAME_INDEX_LISTING = { 'game_status': 'pre-alpha', # Optional, comment out or remove if N/A diff --git a/evennia/server/initial_setup.py b/evennia/server/initial_setup.py index 8f7fd3300f..e0845c0859 100644 --- a/evennia/server/initial_setup.py +++ b/evennia/server/initial_setup.py @@ -9,7 +9,7 @@ Everything starts at handle_setup() import time from django.conf import settings -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ from evennia.accounts.models import AccountDB from evennia.server.models import ServerConfig from evennia.utils import create, logger diff --git a/evennia/server/inputfuncs.py b/evennia/server/inputfuncs.py index bdf82c07c2..24f74af19f 100644 --- a/evennia/server/inputfuncs.py +++ b/evennia/server/inputfuncs.py @@ -576,8 +576,7 @@ def msdp_list(session, *args, **kwargs): fieldnames = [tup[1] for tup in monitor_infos] session.msg(reported_variables=(fieldnames, {})) if "sendable_variables" in args_lower: - # no default sendable variables - session.msg(sendable_variables=([], {})) + session.msg(sendable_variables=(_monitorable, {})) def msdp_report(session, *args, **kwargs): @@ -597,6 +596,17 @@ def msdp_unreport(session, *args, **kwargs): unmonitor(session, *args, **kwargs) +def msdp_send(session, *args, **kwargs): + """ + MSDP SEND command + """ + out = {} + for varname in args: + if varname.lower() in _monitorable: + out[varname] = _monitorable[varname.lower()] + session.msg(send=((), out)) + + # client specific diff --git a/evennia/server/portal/amp.py b/evennia/server/portal/amp.py index 4a96da9f25..505ea5e3e0 100644 --- a/evennia/server/portal/amp.py +++ b/evennia/server/portal/amp.py @@ -314,7 +314,9 @@ class AMPMultiConnectionProtocol(amp.AMP): try: super(AMPMultiConnectionProtocol, self).dataReceived(data) except KeyError: - _get_logger().log_trace("Discarded incoming partial data: {}".format(to_str(data))) + _get_logger().log_trace( + "Discarded incoming partial (packed) data (len {})".format(len(data)) + ) elif self.multibatches: # invalid AMP, but we have a pending multi-batch that is not yet complete if data[-2:] == NULNUL: @@ -323,7 +325,9 @@ class AMPMultiConnectionProtocol(amp.AMP): try: super(AMPMultiConnectionProtocol, self).dataReceived(data) except KeyError: - _get_logger().log_trace("Discarded incoming multi-batch data:".format(to_str(data))) + _get_logger().log_trace( + "Discarded incoming multi-batch (packed) data (len {})".format(len(data)) + ) else: # not an AMP communication, return warning self.transport.write(_HTTP_WARNING) diff --git a/evennia/server/portal/mccp.py b/evennia/server/portal/mccp.py index 3ca35ff4de..2d00e479b6 100644 --- a/evennia/server/portal/mccp.py +++ b/evennia/server/portal/mccp.py @@ -15,9 +15,10 @@ This protocol is implemented by the telnet protocol importing mccp_compress and calling it from its write methods. """ import zlib +from twisted.python.compat import _bytesChr as chr # negotiations for v1 and v2 of the protocol -MCCP = b"\x56" +MCCP = chr(86) # b"\x56" FLUSH = zlib.Z_SYNC_FLUSH diff --git a/evennia/server/portal/mssp.py b/evennia/server/portal/mssp.py index 20331741ed..d939bcc37b 100644 --- a/evennia/server/portal/mssp.py +++ b/evennia/server/portal/mssp.py @@ -12,10 +12,11 @@ active players and so on. """ from django.conf import settings from evennia.utils import utils +from twisted.python.compat import _bytesChr as bchr -MSSP = b"\x46" -MSSP_VAR = b"\x01" -MSSP_VAL = b"\x02" +MSSP = bchr(70) # b"\x46" +MSSP_VAR = bchr(1) # b"\x01" +MSSP_VAL = bchr(2) # b"\x02" # try to get the customized mssp info, if it exists. MSSPTable_CUSTOM = utils.variable_from_module(settings.MSSP_META_MODULE, "MSSPTable", default={}) @@ -86,7 +87,7 @@ class Mssp(object): "PLAYERS": self.get_player_count, "UPTIME": self.get_uptime, "PORT": list( - reversed(settings.TELNET_PORTS) + str(port) for port in reversed(settings.TELNET_PORTS) ), # most important port should be last in list # Evennia auto-filled "CRAWL DELAY": "-1", @@ -119,10 +120,15 @@ class Mssp(object): if utils.is_iter(value): for partval in value: varlist += ( - MSSP_VAR + bytes(variable, "utf-8") + MSSP_VAL + bytes(partval, "utf-8") + MSSP_VAR + + bytes(str(variable), "utf-8") + + MSSP_VAL + + bytes(str(partval), "utf-8") ) else: - varlist += MSSP_VAR + bytes(variable, "utf-8") + MSSP_VAL + bytes(value, "utf-8") + varlist += ( + MSSP_VAR + bytes(str(variable), "utf-8") + MSSP_VAL + bytes(str(value), "utf-8") + ) # send to crawler by subnegotiation self.protocol.requestNegotiation(MSSP, varlist) diff --git a/evennia/server/portal/mxp.py b/evennia/server/portal/mxp.py index c1d9e7422c..8ff773036d 100644 --- a/evennia/server/portal/mxp.py +++ b/evennia/server/portal/mxp.py @@ -14,11 +14,12 @@ http://www.gammon.com.au/mushclient/addingservermxp.htm """ import re +from twisted.python.compat import _bytesChr as bchr LINKS_SUB = re.compile(r"\|lc(.*?)\|lt(.*?)\|le", re.DOTALL) # MXP Telnet option -MXP = b"\x5b" +MXP = bchr(91) # b"\x5b" MXP_TEMPSECURE = "\x1B[4z" MXP_SEND = MXP_TEMPSECURE + '' + "\\2" + MXP_TEMPSECURE + "" diff --git a/evennia/server/portal/naws.py b/evennia/server/portal/naws.py index 737ba74845..caa9f73402 100644 --- a/evennia/server/portal/naws.py +++ b/evennia/server/portal/naws.py @@ -11,9 +11,10 @@ client and update it when the size changes """ from codecs import encode as codecs_encode from django.conf import settings +from twisted.python.compat import _bytesChr as bchr -NAWS = b"\x1f" -IS = b"\x00" +NAWS = bchr(31) # b"\x1f" +IS = bchr(0) # b"\x00" # default taken from telnet specification DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH DEFAULT_HEIGHT = settings.CLIENT_DEFAULT_HEIGHT diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index abbdb7f3a6..633e63bb51 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -95,6 +95,16 @@ INFO_DICT = { "webserver_internal": [], } +try: + WEB_PLUGINS_MODULE = mod_import(settings.WEB_PLUGINS_MODULE) +except ImportError: + WEB_PLUGINS_MODULE = None + INFO_DICT["errors"] = ( + "WARNING: settings.WEB_PLUGINS_MODULE not found - " + "copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf." + ) + + # ------------------------------------------------------------- # Portal Service object # ------------------------------------------------------------- @@ -190,7 +200,6 @@ class Portal(object): self.sessions.disconnect_all() if _stop_server: self.amp_protocol.stop_server(mode="shutdown") - if not _reactor_stopping: # shutting down the reactor will trigger another signal. We set # a flag to avoid loops. @@ -379,6 +388,14 @@ if WEBSERVER_ENABLED: webclientstr = "webclient-websocket%s: %s" % (w_ifacestr, port) INFO_DICT["webclient"].append(webclientstr) + if WEB_PLUGINS_MODULE: + try: + web_root = WEB_PLUGINS_MODULE.at_webproxy_root_creation(web_root) + except Exception as e: # Legacy user has not added an at_webproxy_root_creation function in existing web plugins file + INFO_DICT["errors"] = ( + "WARNING: WEB_PLUGINS_MODULE is enabled but at_webproxy_root_creation() not found - " + "copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf." + ) web_root = Website(web_root, logPath=settings.HTTP_LOG_FILE) web_root.is_portal = True proxy_service = internet.TCPServer(proxyport, web_root, interface=interface) diff --git a/evennia/server/portal/suppress_ga.py b/evennia/server/portal/suppress_ga.py index 2e8f38808a..a295582a26 100644 --- a/evennia/server/portal/suppress_ga.py +++ b/evennia/server/portal/suppress_ga.py @@ -13,7 +13,9 @@ It is set as the NOGOAHEAD protocol_flag option. http://www.faqs.org/rfcs/rfc858.html """ -SUPPRESS_GA = b"\x03" +from twisted.python.compat import _bytesChr as bchr + +SUPPRESS_GA = bchr(3) # b"\x03" # default taken from telnet specification diff --git a/evennia/server/portal/telnet.py b/evennia/server/portal/telnet.py index 41bb64fe9c..4dbee60f7d 100644 --- a/evennia/server/portal/telnet.py +++ b/evennia/server/portal/telnet.py @@ -75,13 +75,21 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session): self.protocol_key = "telnet" super().__init__(*args, **kwargs) + def dataReceived(self, data): + """ + Unused by default, but a good place to put debug printouts + of incoming data. + """ + # print(f"telnet dataReceived: {data}") + super().dataReceived(data) + def connectionMade(self): """ This is called when the connection is first established. """ # important in order to work normally with standard telnet - self.do(LINEMODE) + self.do(LINEMODE).addErrback(self._wont_linemode) # initialize the session self.line_buffer = b"" client_address = self.transport.client @@ -126,6 +134,14 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session): self.nop_keep_alive = None self.toggle_nop_keepalive() + def _wont_linemode(self, *args): + """ + Client refuses do(linemode). This is common for MUD-specific + clients, but we must ask for the sake of raw telnet. We ignore + this error. + """ + pass + def _send_nop_keepalive(self): """Send NOP keepalive unless flag is set""" if self.protocol_flags.get("NOPKEEPALIVE"): @@ -193,6 +209,16 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session): or option == suppress_ga.SUPPRESS_GA ) + def disableRemote(self, option): + return ( + option == LINEMODE + or option == ttype.TTYPE + or option == naws.NAWS + or option == MCCP + or option == mssp.MSSP + or option == suppress_ga.SUPPRESS_GA + ) + def enableLocal(self, option): """ Call to allow the activation of options for this protocol @@ -219,13 +245,20 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session): option (char): The telnet option to disable locally. """ + if option == LINEMODE: + return True if option == ECHO: return True if option == MCCP: self.mccp.no_mccp(option) return True else: - return super().disableLocal(option) + try: + return super().disableLocal(option) + except Exception: + from evennia.utils import logger + + logger.log_trace() def connectionLost(self, reason): """ diff --git a/evennia/server/portal/telnet_oob.py b/evennia/server/portal/telnet_oob.py index f6e156f5a4..7931dcb2a4 100644 --- a/evennia/server/portal/telnet_oob.py +++ b/evennia/server/portal/telnet_oob.py @@ -28,22 +28,24 @@ header where applicable. import re import json from evennia.utils.utils import is_iter - -# MSDP-relevant telnet cmd/opt-codes -MSDP = b"\x45" -MSDP_VAR = b"\x01" # ^A -MSDP_VAL = b"\x02" # ^B -MSDP_TABLE_OPEN = b"\x03" # ^C -MSDP_TABLE_CLOSE = b"\x04" # ^D -MSDP_ARRAY_OPEN = b"\x05" # ^E -MSDP_ARRAY_CLOSE = b"\x06" # ^F - -# GMCP -GMCP = b"\xc9" +from twisted.python.compat import _bytesChr as bchr # General Telnet from twisted.conch.telnet import IAC, SB, SE +# MSDP-relevant telnet cmd/opt-codes +MSDP = bchr(69) +MSDP_VAR = bchr(1) +MSDP_VAL = bchr(2) +MSDP_TABLE_OPEN = bchr(3) +MSDP_TABLE_CLOSE = bchr(4) + +MSDP_ARRAY_OPEN = bchr(5) +MSDP_ARRAY_CLOSE = bchr(6) + +# GMCP +GMCP = bchr(201) + # pre-compiled regexes # returns 2-tuple @@ -168,7 +170,7 @@ class TelnetOOB(object): """ msdp_cmdname = "{msdp_var}{msdp_cmdname}{msdp_val}".format( - msdp_var=MSDP_VAR, msdp_cmdname=cmdname, msdp_val=MSDP_VAL + msdp_var=MSDP_VAR.decode(), msdp_cmdname=cmdname, msdp_val=MSDP_VAL.decode() ) if not (args or kwargs): @@ -186,9 +188,9 @@ class TelnetOOB(object): "{msdp_array_open}" "{msdp_args}" "{msdp_array_close}".format( - msdp_array_open=MSDP_ARRAY_OPEN, - msdp_array_close=MSDP_ARRAY_CLOSE, - msdp_args="".join("%s%s" % (MSDP_VAL, json.dumps(val)) for val in args), + msdp_array_open=MSDP_ARRAY_OPEN.decode(), + msdp_array_close=MSDP_ARRAY_CLOSE.decode(), + msdp_args="".join("%s%s" % (MSDP_VAL.decode(), val) for val in args), ) ) @@ -199,10 +201,10 @@ class TelnetOOB(object): "{msdp_table_open}" "{msdp_kwargs}" "{msdp_table_close}".format( - msdp_table_open=MSDP_TABLE_OPEN, - msdp_table_close=MSDP_TABLE_CLOSE, + msdp_table_open=MSDP_TABLE_OPEN.decode(), + msdp_table_close=MSDP_TABLE_CLOSE.decode(), msdp_kwargs="".join( - "%s%s%s%s" % (MSDP_VAR, key, MSDP_VAL, json.dumps(val)) + "%s%s%s%s" % (MSDP_VAR.decode(), key, MSDP_VAL.decode(), val) for key, val in kwargs.items() ), ) diff --git a/evennia/server/portal/telnet_ssl.py b/evennia/server/portal/telnet_ssl.py index 1698d4faac..66fa606def 100644 --- a/evennia/server/portal/telnet_ssl.py +++ b/evennia/server/portal/telnet_ssl.py @@ -100,11 +100,11 @@ def verify_or_create_SSL_key_and_cert(keyfile, certfile): keypair.generate_key(crypto.TYPE_RSA, _PRIVATE_KEY_LENGTH) with open(_PRIVATE_KEY_FILE, "wt") as pfile: - pfile.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, keypair)) + pfile.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, keypair).decode("utf-8")) print("Created SSL private key in '{}'.".format(_PRIVATE_KEY_FILE)) with open(_PUBLIC_KEY_FILE, "wt") as pfile: - pfile.write(crypto.dump_publickey(crypto.FILETYPE_PEM, keypair)) + pfile.write(crypto.dump_publickey(crypto.FILETYPE_PEM, keypair).decode("utf-8")) print("Created SSL public key in '{}'.".format(_PUBLIC_KEY_FILE)) except Exception as err: @@ -128,7 +128,7 @@ def verify_or_create_SSL_key_and_cert(keyfile, certfile): cert.sign(keypair, "sha1") with open(_CERTIFICATE_FILE, "wt") as cfile: - cfile.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert)) + cfile.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode("utf-8")) print("Created SSL certificate in '{}'.".format(_CERTIFICATE_FILE)) except Exception as err: diff --git a/evennia/server/portal/ttype.py b/evennia/server/portal/ttype.py index fb946bf9a2..01e7ebf74a 100644 --- a/evennia/server/portal/ttype.py +++ b/evennia/server/portal/ttype.py @@ -10,11 +10,12 @@ etc. If the client does not support TTYPE, this will be ignored. All data will be stored on the protocol's protocol_flags dictionary, under the 'TTYPE' key. """ +from twisted.python.compat import _bytesChr as bchr # telnet option codes -TTYPE = b"\x18" -IS = b"\x00" -SEND = b"\x01" +TTYPE = bchr(24) # b"\x18" +IS = bchr(0) # b"\x00" +SEND = bchr(1) # b"\x01" # terminal capabilities and their codes MTTS = [ diff --git a/evennia/server/server.py b/evennia/server/server.py index 1c1783bef1..2093b5b88c 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -38,7 +38,7 @@ from evennia.utils import logger from evennia.comms import channelhandler from evennia.server.sessionhandler import SESSIONS -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ _SA = object.__setattr__ diff --git a/evennia/server/serversession.py b/evennia/server/serversession.py index 6ae0ec4b63..2443758b12 100644 --- a/evennia/server/serversession.py +++ b/evennia/server/serversession.py @@ -23,7 +23,7 @@ _ObjectDB = None _ANSI = None # i18n -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ # Handlers for Session.db/ndb operation diff --git a/evennia/server/sessionhandler.py b/evennia/server/sessionhandler.py index 1bf43746d6..0d099d6d30 100644 --- a/evennia/server/sessionhandler.py +++ b/evennia/server/sessionhandler.py @@ -67,7 +67,7 @@ PSTATUS = chr(18) # ping server or portal status SRESET = chr(19) # server shutdown in reset mode # i18n -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ _SERVERNAME = settings.SERVERNAME _MULTISESSION_MODE = settings.MULTISESSION_MODE diff --git a/evennia/typeclasses/migrations/0010_delete_old_player_tables.py b/evennia/typeclasses/migrations/0010_delete_old_player_tables.py index 0732f0fe99..32e1f80923 100644 --- a/evennia/typeclasses/migrations/0010_delete_old_player_tables.py +++ b/evennia/typeclasses/migrations/0010_delete_old_player_tables.py @@ -27,7 +27,7 @@ def _drop_table(db_cursor, table_name): db_cursor.execute("SET FOREIGN_KEY_CHECKS=0;") db_cursor.execute("DROP TABLE {table};".format(table=table_name)) db_cursor.execute("SET FOREIGN_KEY_CHECKS=1;") - elif _ENGINE == "postgresql_psycopg2": + elif _ENGINE == "postgresql": db_cursor.execute("ALTER TABLE {table} DISABLE TRIGGER ALL;".format(table=table_name)) db_cursor.execute("DROP TABLE {table};".format(table=table_name)) db_cursor.execute("ALTER TABLE {table} ENABLE TRIGGER ALL;".format(table=table_name)) diff --git a/evennia/utils/ansi.py b/evennia/utils/ansi.py index ec2b1b3378..21d1abd180 100644 --- a/evennia/utils/ansi.py +++ b/evennia/utils/ansi.py @@ -674,6 +674,13 @@ class ANSIString(str, metaclass=ANSIMeta): """ + # A compiled Regex for the format mini-language: https://docs.python.org/3/library/string.html#formatspec + re_format = re.compile( + r"(?i)(?P(?P.)?(?P\<|\>|\=|\^))?(?P\+|\-| )?(?P\#)?" + r"(?P0)?(?P\d+)?(?P\_|\,)?(?:\.(?P\d+))?" + r"(?Pb|c|d|e|E|f|F|g|G|n|o|s|x|X|%)?" + ) + def __new__(cls, *args, **kwargs): """ When creating a new ANSIString, you may use a custom parser that has @@ -733,6 +740,47 @@ class ANSIString(str, metaclass=ANSIMeta): def __str__(self): return self._raw_string + def __format__(self, format_spec): + """ + This magic method covers ANSIString's behavior within a str.format() or f-string. + + Current features supported: fill, align, width. + + Args: + format_spec (str): The format specification passed by f-string or str.format(). This is a string such as + "0<30" which would mean "left justify to 30, filling with zeros". The full specification can be found + at https://docs.python.org/3/library/string.html#formatspec + + Returns: + ansi_str (str): The formatted ANSIString's .raw() form, for display. + """ + # This calls the compiled regex stored on ANSIString's class to analyze the format spec. + # It returns a dictionary. + format_data = self.re_format.match(format_spec).groupdict() + clean = self.clean() + base_output = ANSIString(self.raw()) + align = format_data.get("align", "<") + fill = format_data.get("fill", " ") + + # Need to coerce width into an integer. We can be certain that it's numeric thanks to regex. + width = format_data.get("width", None) + if width is None: + width = len(clean) + else: + width = int(width) + + if align == "<": + base_output = self.ljust(width, fill) + elif align == ">": + base_output = self.rjust(width, fill) + elif align == "^": + base_output = self.center(width, fill) + elif align == "=": + pass + + # Return the raw string with ANSI markup, ready to be displayed. + return base_output.raw() + def __repr__(self): """ Let's make the repr the command that would actually be used to diff --git a/evennia/utils/dbserialize.py b/evennia/utils/dbserialize.py index b3e55d1ca6..ddfdb765de 100644 --- a/evennia/utils/dbserialize.py +++ b/evennia/utils/dbserialize.py @@ -28,7 +28,7 @@ except ImportError: from pickle import dumps, loads from django.core.exceptions import ObjectDoesNotExist from django.contrib.contenttypes.models import ContentType -from django.utils.safestring import SafeString, SafeBytes +from django.utils.safestring import SafeString from evennia.utils.utils import uses_database, is_iter, to_str, to_bytes from evennia.utils import logger @@ -549,7 +549,7 @@ def to_pickle(data): def process_item(item): """Recursive processor and identification of data""" dtype = type(item) - if dtype in (str, int, float, bool, bytes, SafeString, SafeBytes): + if dtype in (str, int, float, bool, bytes, SafeString): return item elif dtype == tuple: return tuple(process_item(val) for val in item) @@ -577,7 +577,7 @@ def to_pickle(data): except TypeError: return item except Exception: - logger.log_error(f"The object {item} of type {type(item)} could not be stored.") + logger.log_err(f"The object {item} of type {type(item)} could not be stored.") raise return process_item(data) @@ -609,7 +609,7 @@ def from_pickle(data, db_obj=None): def process_item(item): """Recursive processor and identification of data""" dtype = type(item) - if dtype in (str, int, float, bool, bytes, SafeString, SafeBytes): + if dtype in (str, int, float, bool, bytes, SafeString): return item elif _IS_PACKED_DBOBJ(item): # this must be checked before tuple @@ -638,7 +638,7 @@ def from_pickle(data, db_obj=None): def process_tree(item, parent): """Recursive processor, building a parent-tree from iterable data""" dtype = type(item) - if dtype in (str, int, float, bool, bytes, SafeString, SafeBytes): + if dtype in (str, int, float, bool, bytes, SafeString): return item elif _IS_PACKED_DBOBJ(item): # this must be checked before tuple @@ -716,7 +716,7 @@ def do_pickle(data): try: return dumps(data, protocol=PICKLE_PROTOCOL) except Exception: - logger.log_error(f"Could not pickle data for storage: {data}") + logger.log_err(f"Could not pickle data for storage: {data}") raise @@ -725,7 +725,7 @@ def do_unpickle(data): try: return loads(to_bytes(data)) except Exception: - logger.log_error(f"Could not unpickle data from storage: {data}") + logger.log_err(f"Could not unpickle data from storage: {data}") raise diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 0bc5b37475..ccf2840f91 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -187,7 +187,7 @@ _CMD_NOINPUT = cmdhandler.CMD_NOINPUT # Return messages # i18n -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ _ERR_NOT_IMPLEMENTED = _( "Menu node '{nodename}' is either not implemented or " "caused an error. Make another choice." diff --git a/evennia/utils/picklefield.py b/evennia/utils/picklefield.py index 7308f2f2d5..a86f0099ca 100644 --- a/evennia/utils/picklefield.py +++ b/evennia/utils/picklefield.py @@ -43,7 +43,7 @@ from django.forms.fields import CharField from django.forms.widgets import Textarea from pickle import loads, dumps -from django.utils.encoding import force_text +from django.utils.encoding import force_str DEFAULT_PROTOCOL = 4 @@ -210,10 +210,10 @@ class PickledObjectField(models.Field): """ Returns the default value for this field. - The default implementation on models.Field calls force_text + The default implementation on models.Field calls force_str on the default, which means you can't set arbitrary Python objects as the default. To fix this, we just return the value - without calling force_text on it. Note that if you set a + without calling force_str on it. Note that if you set a callable as a default, the field will still call it. It will *not* try to pickle and encode it. @@ -267,13 +267,13 @@ class PickledObjectField(models.Field): """ if value is not None and not isinstance(value, PickledObject): - # We call force_text here explicitly, so that the encoded string - # isn't rejected by the postgresql_psycopg2 backend. Alternatively, + # We call force_str here explicitly, so that the encoded string + # isn't rejected by the postgresql backend. Alternatively, # we could have just registered PickledObject with the psycopg # marshaller (telling it to store it like it would a string), but # since both of these methods result in the same value being stored, # doing things this way is much easier. - value = force_text(dbsafe_encode(value, self.compress, self.protocol)) + value = force_str(dbsafe_encode(value, self.compress, self.protocol)) return value def value_to_string(self, obj): diff --git a/evennia/utils/tests/test_utils.py b/evennia/utils/tests/test_utils.py index c969a988a5..eb01e38a83 100644 --- a/evennia/utils/tests/test_utils.py +++ b/evennia/utils/tests/test_utils.py @@ -98,6 +98,30 @@ class TestMLen(TestCase): self.assertEqual(utils.m_len({"hello": True, "Goodbye": False}), 2) +class TestANSIString(TestCase): + """ + Verifies that ANSIString's string-API works as intended. + """ + + def setUp(self): + self.example_raw = "|relectric |cboogaloo|n" + self.example_ansi = ANSIString(self.example_raw) + self.example_str = "electric boogaloo" + self.example_output = "\x1b[1m\x1b[31melectric \x1b[1m\x1b[36mboogaloo\x1b[0m" + + def test_length(self): + self.assertEqual(len(self.example_ansi), 17) + + def test_clean(self): + self.assertEqual(self.example_ansi.clean(), self.example_str) + + def test_raw(self): + self.assertEqual(self.example_ansi.raw(), self.example_output) + + def test_format(self): + self.assertEqual(f"{self.example_ansi:0<20}", self.example_output + "000") + + class TestTimeformat(TestCase): """ Default function header from utils.py: diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 49c8af1e7b..cc191fdd53 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -27,7 +27,7 @@ from collections import defaultdict, OrderedDict from twisted.internet import threads, reactor from django.conf import settings from django.utils import timezone -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ from django.apps import apps from evennia.utils import logger @@ -1036,7 +1036,7 @@ def uses_database(name="sqlite3"): shortcut to having to use the full backend name. Args: - name (str): One of 'sqlite3', 'mysql', 'postgresql_psycopg2' + name (str): One of 'sqlite3', 'mysql', 'postgresql' or 'oracle'. Returns: diff --git a/evennia/web/urls.py b/evennia/web/urls.py index a256a8c796..6fce632ca1 100644 --- a/evennia/web/urls.py +++ b/evennia/web/urls.py @@ -6,9 +6,9 @@ # http://diveintopython.org/regular_expressions/street_addresses.html#re.matching.2.3 # -from django.urls import path -from django.conf.urls import url, include +from django.conf.urls import url from django.conf import settings +from django.urls import path, include from django.views.generic import RedirectView # Setup the root url tree from / diff --git a/evennia/web/webclient/templates/webclient/base.html b/evennia/web/webclient/templates/webclient/base.html index 46353b18ad..e65467da12 100644 --- a/evennia/web/webclient/templates/webclient/base.html +++ b/evennia/web/webclient/templates/webclient/base.html @@ -6,7 +6,7 @@ with evennia set up automatically and get the Evennia JS lib and JQuery available. --> -{% load staticfiles %} +{% load static %} {{game_name}} diff --git a/evennia/web/webclient/urls.py b/evennia/web/webclient/urls.py index cfb57ad262..4b9d19d631 100644 --- a/evennia/web/webclient/urls.py +++ b/evennia/web/webclient/urls.py @@ -2,8 +2,9 @@ This structures the (simple) structure of the webpage 'application'. """ -from django.conf.urls import * +from django.urls import path from evennia.web.webclient import views as webclient_views app_name = "webclient" -urlpatterns = [url(r"^$", webclient_views.webclient, name="index")] + +urlpatterns = [path("", webclient_views.webclient, name="index")] diff --git a/evennia/web/website/templates/website/_menu.html b/evennia/web/website/templates/website/_menu.html index a947445167..3d6501fb45 100644 --- a/evennia/web/website/templates/website/_menu.html +++ b/evennia/web/website/templates/website/_menu.html @@ -3,7 +3,7 @@ Allow to customize the menu that appears at the top of every Evennia webpage. Copy this file to your game dir's web/template_overrides/website folder and edit it to add/remove links to the menu. {% endcomment %} -{% load staticfiles %} +{% load static %}