diff --git a/CHANGELOG.md b/CHANGELOG.md index afe5ad1ba7..8a31aecb97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,27 @@ # Changelog +## Evennia 0.9 (2018-2019) + +### Commands + +- Removed default `@delaccount` command, incorporating as `@account/delete` instead. Added confirmation + question. +- Add new `@force` command to have another object perform a command. +- Add the Portal uptime to the `@time` command. +- Make the `@link` command first make a local search before a global search. + +### Utils + +- Added more unit tests. + + ## Evennia 0.8 (2018) +### Requirements + +- Up requirements to Django 1.11.x, Twisted 18 and pillow 5.2.0 +- Add `inflect` dependency for automatic pluralization of object names. + ### Server/Portal - Removed `evennia_runner`, completely refactor `evennia_launcher.py` (the 'evennia' program) @@ -85,7 +105,6 @@ ### General -- Up requirements to Django 1.11.x, Twisted 18 and pillow 5.2.0 - Start structuring the `CHANGELOG` to list features in more detail. - Docker image `evennia/evennia:develop` is now auto-built, tracking the develop branch. - Inflection and grouping of multiple objects in default room (an box, three boxes) diff --git a/Dockerfile b/Dockerfile index 381c83f925..961d3ad8ed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ # Usage: # cd to a folder where you want your game data to be (or where it already is). # -# docker run -it -p 4000:4000 -p 4001:4001 -p 4005:4005 -v $PWD:/usr/src/game evennia/evennia +# docker run -it --rm -p 4000:4000 -p 4001:4001 -p 4005:4005 -v $PWD:/usr/src/game evennia/evennia # # (If your OS does not support $PWD, replace it with the full path to your current # folder). @@ -15,6 +15,14 @@ # You will end up in a shell where the `evennia` command is available. From here you # can install and run the game normally. Use Ctrl-D to exit the evennia docker container. # +# You can also start evennia directly by passing arguments to the folder: +# +# docker run -it --rm -p 4000:4000 -p 4001:4001 -p 4005:4005 -v $PWD:/usr/src/game evennia/evennia evennia start -l +# +# This will start Evennia running as the core process of the container. Note that you *must* use -l +# or one of the foreground modes (like evennia ipstart) since otherwise the container will immediately +# die since no foreground process keeps it up. +# # The evennia/evennia base image is found on DockerHub and can also be used # as a base for creating your own custom containerized Evennia game. For more # info, see https://github.com/evennia/evennia/wiki/Running%20Evennia%20in%20Docker . @@ -58,7 +66,7 @@ WORKDIR /usr/src/game ENV PS1 "evennia|docker \w $ " # startup a shell when we start the container -ENTRYPOINT bash -c "source /usr/src/evennia/bin/unix/evennia-docker-start.sh" +ENTRYPOINT ["/usr/src/evennia/bin/unix/evennia-docker-start.sh"] # expose the telnet, webserver and websocket client ports EXPOSE 4000 4001 4005 diff --git a/bin/unix/evennia-docker-start.sh b/bin/unix/evennia-docker-start.sh old mode 100644 new mode 100755 index d1333aaef6..5c87052da9 --- a/bin/unix/evennia-docker-start.sh +++ b/bin/unix/evennia-docker-start.sh @@ -1,10 +1,18 @@ -#! /bin/bash +#! /bin/sh # called by the Dockerfile to start the server in docker mode # remove leftover .pid files (such as from when dropping the container) rm /usr/src/game/server/*.pid >& /dev/null || true -# start evennia server; log to server.log but also output to stdout so it can -# be viewed with docker-compose logs -exec 3>&1; evennia start -l +PS1="evennia|docker \w $ " + +cmd="$@" +output="Docker starting with argument '$cmd' ..." +if test -z $cmd; then + cmd="bash" + output="No argument given, starting shell ..." +fi + +echo $output +exec 3>&1; $cmd diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 2c33e5c1f8..3eab69a3ce 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -1001,7 +1001,10 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): if target and not is_iter(target): # single target - just show it - return target.return_appearance(self) + if hasattr(target, "return_appearance"): + return target.return_appearance(self) + else: + return "{} has no in-game appearance.".format(target) else: # list of targets - make list to disconnect from db characters = list(tar for tar in target if tar) if target else [] diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index be07a30cbd..d60db0fce5 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -1,11 +1,13 @@ -from mock import Mock +from mock import Mock, MagicMock from random import randint from unittest import TestCase +from django.test import override_settings from evennia.accounts.accounts import AccountSessionHandler from evennia.accounts.accounts import DefaultAccount from evennia.server.session import Session from evennia.utils import create +from evennia.utils.test_resources import EvenniaTest from django.conf import settings @@ -14,9 +16,15 @@ class TestAccountSessionHandler(TestCase): "Check AccountSessionHandler class" def setUp(self): - self.account = create.create_account("TestAccount%s" % randint(0, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount) + self.account = create.create_account( + "TestAccount%s" % randint(0, 999999), email="test@test.com", + password="testpassword", typeclass=DefaultAccount) self.handler = AccountSessionHandler(self.account) + def tearDown(self): + if hasattr(self, 'account'): + self.account.delete() + def test_get(self): "Check get method" self.assertEqual(self.handler.get(), []) @@ -24,24 +32,24 @@ class TestAccountSessionHandler(TestCase): import evennia.server.sessionhandler - s1 = Session() + s1 = MagicMock() s1.logged_in = True s1.uid = self.account.uid evennia.server.sessionhandler.SESSIONS[s1.uid] = s1 - s2 = Session() + s2 = MagicMock() s2.logged_in = True s2.uid = self.account.uid + 1 evennia.server.sessionhandler.SESSIONS[s2.uid] = s2 - s3 = Session() + s3 = MagicMock() s3.logged_in = False s3.uid = self.account.uid + 2 evennia.server.sessionhandler.SESSIONS[s3.uid] = s3 - self.assertEqual(self.handler.get(), [s1]) - self.assertEqual(self.handler.get(self.account.uid), [s1]) - self.assertEqual(self.handler.get(self.account.uid + 1), []) + self.assertEqual([s.uid for s in self.handler.get()], [s1.uid]) + self.assertEqual([s.uid for s in [self.handler.get(self.account.uid)]], [s1.uid]) + self.assertEqual([s.uid for s in self.handler.get(self.account.uid + 1)], []) def test_all(self): "Check all method" @@ -56,10 +64,10 @@ class TestDefaultAccount(TestCase): "Check DefaultAccount class" def setUp(self): - self.s1 = Session() + self.s1 = MagicMock() self.s1.puppet = None self.s1.sessid = 0 - + def test_absolute_url(self): "Get URL for account detail page on website" self.account = create.create_account("TestAccount%s" % randint(100000, 999999), @@ -84,7 +92,6 @@ class TestDefaultAccount(TestCase): "Check validators allow sufficiently complex passwords" for better in ('Mxyzptlk', "j0hn, i'M 0n1y d4nc1nG"): self.assertTrue(self.account.validate_password(better, account=self.account)[0]) - self.account.delete() def test_password_change(self): "Check password setting and validation is working as expected" @@ -122,7 +129,9 @@ class TestDefaultAccount(TestCase): import evennia.server.sessionhandler - account = create.create_account("TestAccount%s" % randint(0, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount) + account = create.create_account( + "TestAccount%s" % randint(0, 999999), email="test@test.com", + password="testpassword", typeclass=DefaultAccount) self.s1.uid = account.uid evennia.server.sessionhandler.SESSIONS[self.s1.uid] = self.s1 @@ -144,10 +153,7 @@ class TestDefaultAccount(TestCase): self.s1.uid = account.uid evennia.server.sessionhandler.SESSIONS[self.s1.uid] = self.s1 - self.s1.puppet = None - self.s1.logged_in = True - self.s1.data_out = Mock(return_value=None) - + self.s1.data_out = MagicMock() obj = Mock() obj.access = Mock(return_value=False) @@ -156,6 +162,7 @@ class TestDefaultAccount(TestCase): self.assertTrue(self.s1.data_out.call_args[1]['text'].startswith("You don't have permission to puppet")) self.assertIsNone(obj.at_post_puppet.call_args) + @override_settings(MULTISESSION_MODE=0) def test_puppet_object_joining_other_session(self): "Check puppet_object method called, joining other session" @@ -167,15 +174,16 @@ class TestDefaultAccount(TestCase): self.s1.puppet = None self.s1.logged_in = True - self.s1.data_out = Mock(return_value=None) + self.s1.data_out = MagicMock() obj = Mock() obj.access = Mock(return_value=True) obj.account = account + obj.sessions.all = MagicMock(return_value=[self.s1]) account.puppet_object(self.s1, obj) # works because django.conf.settings.MULTISESSION_MODE is not in (1, 3) - self.assertTrue(self.s1.data_out.call_args[1]['text'].endswith("from another of your sessions.")) + self.assertTrue(self.s1.data_out.call_args[1]['text'].endswith("from another of your sessions.|n")) self.assertTrue(obj.at_post_puppet.call_args[1] == {}) def test_puppet_object_already_puppeted(self): @@ -184,6 +192,7 @@ class TestDefaultAccount(TestCase): import evennia.server.sessionhandler account = create.create_account("TestAccount%s" % randint(0, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount) + self.account = account self.s1.uid = account.uid evennia.server.sessionhandler.SESSIONS[self.s1.uid] = self.s1 @@ -199,3 +208,20 @@ class TestDefaultAccount(TestCase): account.puppet_object(self.s1, obj) self.assertTrue(self.s1.data_out.call_args[1]['text'].endswith("is already puppeted by another Account.")) self.assertIsNone(obj.at_post_puppet.call_args) + + +class TestAccountPuppetDeletion(EvenniaTest): + + @override_settings(MULTISESSION_MODE=2) + def test_puppet_deletion(self): + # Check for existing chars + self.assertFalse(self.account.db._playable_characters, 'Account should not have any chars by default.') + + # Add char1 to account's playable characters + self.account.db._playable_characters.append(self.char1) + self.assertTrue(self.account.db._playable_characters, 'Char was not added to account.') + + # See what happens when we delete char1. + self.char1.delete() + # Playable char list should be empty. + self.assertFalse(self.account.db._playable_characters, 'Playable character list is not empty! %s' % self.account.db._playable_characters) \ No newline at end of file diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index b50f55a8e0..7eda54e75c 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -23,7 +23,7 @@ from builtins import range import time from django.conf import settings from evennia.server.sessionhandler import SESSIONS -from evennia.utils import utils, create, search, evtable +from evennia.utils import utils, create, logger, search, evtable COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -171,6 +171,7 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS): new_character.db.desc = "This is a character." self.msg("Created new character %s. Use |w@ic %s|n to enter the game as this character." % (new_character.key, new_character.key)) + logger.log_sec('Character Created: %s (Caller: %s, IP: %s).' % (new_character, account, self.session.address)) class CmdCharDelete(COMMAND_DEFAULT_CLASS): @@ -214,6 +215,7 @@ class CmdCharDelete(COMMAND_DEFAULT_CLASS): caller.db._playable_characters = [pc for pc in caller.db._playable_characters if pc != delobj] delobj.delete() self.msg("Character '%s' was permanently deleted." % key) + logger.log_sec('Character Deleted: %s (Caller: %s, IP: %s).' % (key, account, self.session.address)) else: self.msg("Deletion was aborted.") del caller.ndb._char_to_delete @@ -279,8 +281,10 @@ class CmdIC(COMMAND_DEFAULT_CLASS): try: account.puppet_object(session, new_character) account.db._last_puppet = new_character + logger.log_sec('Puppet Success: (Caller: %s, Target: %s, IP: %s).' % (account, new_character, self.session.address)) except RuntimeError as exc: self.msg("|rYou cannot become |C%s|n: %s" % (new_character.name, exc)) + logger.log_sec('Puppet Failed: %s (Caller: %s, Target: %s, IP: %s).' % (exc, account, new_character, self.session.address)) # note that this is inheriting from MuxAccountLookCommand, @@ -641,6 +645,7 @@ class CmdPassword(COMMAND_DEFAULT_CLASS): account.set_password(newpass) account.save() self.msg("Password changed.") + logger.log_sec('Password Changed: %s (Caller: %s, IP: %s).' % (account, account, self.session.address)) class CmdQuit(COMMAND_DEFAULT_CLASS): diff --git a/evennia/commands/default/admin.py b/evennia/commands/default/admin.py index fc90277127..c260abaa2e 100644 --- a/evennia/commands/default/admin.py +++ b/evennia/commands/default/admin.py @@ -9,15 +9,15 @@ import re from django.conf import settings from evennia.server.sessionhandler import SESSIONS from evennia.server.models import ServerConfig -from evennia.utils import evtable, search, class_from_module +from evennia.utils import evtable, logger, search, class_from_module COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) PERMISSION_HIERARCHY = [p.lower() for p in settings.PERMISSION_HIERARCHY] # limit members for API inclusion -__all__ = ("CmdBoot", "CmdBan", "CmdUnban", "CmdDelAccount", - "CmdEmit", "CmdNewPassword", "CmdPerm", "CmdWall") +__all__ = ("CmdBoot", "CmdBan", "CmdUnban", + "CmdEmit", "CmdNewPassword", "CmdPerm", "CmdWall", "CmdForce") class CmdBoot(COMMAND_DEFAULT_CLASS): @@ -96,6 +96,9 @@ class CmdBoot(COMMAND_DEFAULT_CLASS): session.msg(feedback) session.account.disconnect_session_from_account(session) + if pobj and boot_list: + logger.log_sec('Booted: %s (Reason: %s, Caller: %s, IP: %s).' % (pobj, reason, caller, self.session.address)) + # regex matching IP addresses with wildcards, eg. 233.122.4.* IPREGEX = re.compile(r"[0-9*]{1,3}\.[0-9*]{1,3}\.[0-9*]{1,3}\.[0-9*]{1,3}") @@ -130,7 +133,7 @@ class CmdBan(COMMAND_DEFAULT_CLASS): reason to be able to later remember why the ban was put in place. It is often preferable to ban an account from the server than to - delete an account with @delaccount. If banned by name, that account + delete an account with @accounts/delete. If banned by name, that account account can no longer be logged into. IP (Internet Protocol) address banning allows blocking all access @@ -203,6 +206,7 @@ class CmdBan(COMMAND_DEFAULT_CLASS): banlist.append(bantup) ServerConfig.objects.conf('server_bans', banlist) self.caller.msg("%s-Ban |w%s|n was added." % (typ, ban)) + logger.log_sec('Banned %s: %s (Caller: %s, IP: %s).' % (typ, ban.strip(), self.caller, self.session.address)) class CmdUnban(COMMAND_DEFAULT_CLASS): @@ -246,79 +250,10 @@ class CmdUnban(COMMAND_DEFAULT_CLASS): ban = banlist[num - 1] del banlist[num - 1] ServerConfig.objects.conf('server_bans', banlist) + value = " ".join([s for s in ban[:2]]) self.caller.msg("Cleared ban %s: %s" % - (num, " ".join([s for s in ban[:2]]))) - - -class CmdDelAccount(COMMAND_DEFAULT_CLASS): - """ - delete an account from the server - - Usage: - @delaccount[/switch] [: reason] - - Switch: - delobj - also delete the account's currently - assigned in-game object. - - Completely deletes a user from the server database, - making their nick and e-mail again available. - """ - - key = "@delaccount" - switch_options = ("delobj",) - locks = "cmd:perm(delaccount) or perm(Developer)" - help_category = "Admin" - - def func(self): - """Implements the command.""" - - caller = self.caller - args = self.args - - if hasattr(caller, 'account'): - caller = caller.account - - if not args: - self.msg("Usage: @delaccount [: reason]") - return - - reason = "" - if ':' in args: - args, reason = [arg.strip() for arg in args.split(':', 1)] - - # We use account_search since we want to be sure to find also accounts - # that lack characters. - accounts = search.account_search(args) - - if not accounts: - self.msg('Could not find an account by that name.') - return - - if len(accounts) > 1: - string = "There were multiple matches:\n" - string += "\n".join(" %s %s" % (account.id, account.key) for account in accounts) - self.msg(string) - return - - # one single match - - account = accounts.first() - - if not account.access(caller, 'delete'): - string = "You don't have the permissions to delete that account." - self.msg(string) - return - - uname = account.username - # boot the account then delete - self.msg("Informing and disconnecting account ...") - string = "\nYour account '%s' is being *permanently* deleted.\n" % uname - if reason: - string += " Reason given:\n '%s'" % reason - account.msg(string) - account.delete() - self.msg("Account %s was successfully deleted." % uname) + (num, value)) + logger.log_sec('Unbanned: %s (Caller: %s, IP: %s).' % (value.strip(), self.caller, self.session.address)) class CmdEmit(COMMAND_DEFAULT_CLASS): @@ -428,9 +363,9 @@ class CmdNewPassword(COMMAND_DEFAULT_CLASS): account = caller.search_account(self.lhs) if not account: return - + newpass = self.rhs - + # Validate password validated, error = account.validate_password(newpass) if not validated: @@ -438,13 +373,14 @@ class CmdNewPassword(COMMAND_DEFAULT_CLASS): string = "\n".join(errors) caller.msg(string) return - + account.set_password(newpass) account.save() self.msg("%s - new password set to '%s'." % (account.name, newpass)) if account.character != caller: account.msg("%s has changed your password to '%s'." % (caller.name, newpass)) + logger.log_sec('Password Changed: %s (Caller: %s, IP: %s).' % (account, caller, self.session.address)) class CmdPerm(COMMAND_DEFAULT_CLASS): @@ -526,6 +462,7 @@ class CmdPerm(COMMAND_DEFAULT_CLASS): else: caller_result.append("\nPermission %s removed from %s (if they existed)." % (perm, obj.name)) target_result.append("\n%s revokes the permission(s) %s from you." % (caller.name, perm)) + logger.log_sec('Permissions Deleted: %s, %s (Caller: %s, IP: %s).' % (perm, obj, caller, self.session.address)) else: # add a new permission permissions = obj.permissions.all() @@ -547,6 +484,8 @@ class CmdPerm(COMMAND_DEFAULT_CLASS): caller_result.append("\nPermission '%s' given to %s (%s)." % (perm, obj.name, plystring)) target_result.append("\n%s gives you (%s, %s) the permission '%s'." % (caller.name, obj.name, plystring, perm)) + logger.log_sec('Permissions Added: %s, %s (Caller: %s, IP: %s).' % (obj, perm, caller, self.session.address)) + caller.msg("".join(caller_result).strip()) if target_result: obj.msg("".join(target_result).strip()) @@ -574,3 +513,33 @@ class CmdWall(COMMAND_DEFAULT_CLASS): message = "%s shouts \"%s\"" % (self.caller.name, self.args) self.msg("Announcing to all connected sessions ...") SESSIONS.announce_all(message) + + +class CmdForce(COMMAND_DEFAULT_CLASS): + """ + forces an object to execute a command + + Usage: + @force = + + Example: + @force bob=get stick + """ + key = "@force" + locks = "cmd:perm(spawn) or perm(Builder)" + help_category = "Building" + perm_used = "edit" + + def func(self): + """Implements the force command""" + if not self.lhs or not self.rhs: + self.caller.msg("You must provide a target and a command string to execute.") + return + targ = self.caller.search(self.lhs) + if not targ: + return + if not targ.access(self.caller, self.perm_used): + self.caller.msg("You don't have permission to force them to execute commands.") + return + targ.execute_cmd(self.rhs) + self.caller.msg("You have forced %s to: %s" % (targ, self.rhs)) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index bd4fb5e188..84f8e4002e 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -10,7 +10,7 @@ from evennia.objects.models import ObjectDB from evennia.locks.lockhandler import LockException from evennia.commands.cmdhandler import get_and_merge_cmdsets from evennia.utils import create, utils, search -from evennia.utils.utils import inherits_from, class_from_module, get_all_typeclasses +from evennia.utils.utils import inherits_from, class_from_module, get_all_typeclasses, variable_from_module from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore from evennia.prototypes import spawner, prototypes as protlib, menus as olc_menus @@ -612,12 +612,12 @@ class CmdDesc(COMMAND_DEFAULT_CLASS): self.edit_handler() return - if self.rhs: + if '=' in self.args: # We have an = obj = caller.search(self.lhs) if not obj: return - desc = self.rhs + desc = self.rhs or '' else: obj = caller.location or self.msg("|rYou can't describe oblivion.|n") if not obj: @@ -737,12 +737,11 @@ class CmdDestroy(COMMAND_DEFAULT_CLASS): confirm += ", ".join(["#{}".format(obj.id) for obj in objs]) confirm += " [yes]/no?" if self.default_confirm == 'yes' else " yes/[no]" answer = "" - while answer.strip().lower() not in ("y", "yes", "n", "no"): - answer = yield(confirm) - answer = self.default_confirm if answer == '' else answer + answer = yield(confirm) + answer = self.default_confirm if answer == '' else answer if answer.strip().lower() in ("n", "no"): - caller.msg("Cancelled: no object was destroyed.") + caller.msg("Canceled: no object was destroyed.") delete = False if delete: @@ -1023,10 +1022,17 @@ class CmdLink(COMMAND_DEFAULT_CLASS): object_name = self.lhs - # get object - obj = caller.search(object_name, global_search=True) - if not obj: - return + # try to search locally first + results = caller.search(object_name, quiet=True) + if len(results) > 1: # local results was a multimatch. Inform them to be more specific + _AT_SEARCH_RESULT = variable_from_module(*settings.SEARCH_AT_RESULT.rsplit('.', 1)) + return _AT_SEARCH_RESULT(results, caller, query=object_name) + elif len(results) == 1: # A unique local match + obj = results[0] + else: # No matches. Search globally + obj = caller.search(object_name, global_search=True) + if not obj: + return if self.rhs: # this means a target name was given @@ -2889,7 +2895,8 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): "use the 'exec' prototype key.") return None try: - protlib.validate_prototype(prototype) + # we homogenize first, to be more lenient + protlib.validate_prototype(protlib.homogenize_prototype(prototype)) except RuntimeError as err: self.caller.msg(str(err)) return diff --git a/evennia/commands/default/cmdset_account.py b/evennia/commands/default/cmdset_account.py index d7b887c017..8173e461c5 100644 --- a/evennia/commands/default/cmdset_account.py +++ b/evennia/commands/default/cmdset_account.py @@ -55,7 +55,6 @@ class AccountCmdSet(CmdSet): self.add(system.CmdPy()) # Admin commands - self.add(admin.CmdDelAccount()) self.add(admin.CmdNewPassword()) # Comm commands diff --git a/evennia/commands/default/cmdset_character.py b/evennia/commands/default/cmdset_character.py index cfc8a30ca4..438996f536 100644 --- a/evennia/commands/default/cmdset_character.py +++ b/evennia/commands/default/cmdset_character.py @@ -57,6 +57,7 @@ class CharacterCmdSet(CmdSet): self.add(admin.CmdEmit()) self.add(admin.CmdPerm()) self.add(admin.CmdWall()) + self.add(admin.CmdForce()) # Building and world manipulation self.add(building.CmdTeleport()) diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index d9fe0b0d20..cd73765089 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -7,6 +7,8 @@ make sure to homogenize self.caller to always be the account object for easy handling. """ +import hashlib +import time from past.builtins import cmp from django.conf import settings from evennia.comms.models import ChannelDB, Msg @@ -14,7 +16,7 @@ from evennia.accounts.models import AccountDB from evennia.accounts import bots from evennia.comms.channelhandler import CHANNELHANDLER from evennia.locks.lockhandler import LockException -from evennia.utils import create, utils, evtable +from evennia.utils import create, logger, utils, evtable from evennia.utils.utils import make_iter, class_from_module COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -368,6 +370,7 @@ class CmdCdestroy(COMMAND_DEFAULT_CLASS): channel.delete() CHANNELHANDLER.update() self.msg("Channel '%s' was destroyed." % channel_key) + logger.log_sec('Channel Deleted: %s (Caller: %s, IP: %s).' % (channel_key, caller, self.session.address)) class CmdCBoot(COMMAND_DEFAULT_CLASS): @@ -433,6 +436,8 @@ class CmdCBoot(COMMAND_DEFAULT_CLASS): # disconnect account channel.disconnect(account) CHANNELHANDLER.update() + logger.log_sec('Channel Boot: %s (Channel: %s, Reason: %s, Caller: %s, IP: %s).' % ( + account, channel, reason, self.caller, self.session.address)) class CmdCemit(COMMAND_DEFAULT_CLASS): @@ -918,8 +923,9 @@ class CmdIRC2Chan(COMMAND_DEFAULT_CLASS): self.msg("Account '%s' already exists and is not a bot." % botname) return else: + password = hashlib.md5(str(time.time())).hexdigest()[:11] try: - bot = create.create_account(botname, None, None, typeclass=botclass) + bot = create.create_account(botname, None, password, typeclass=botclass) except Exception as err: self.msg("|rError, could not create the bot:|n '%s'." % err) return diff --git a/evennia/commands/default/system.py b/evennia/commands/default/system.py index c454de9aff..a6cc24263d 100644 --- a/evennia/commands/default/system.py +++ b/evennia/commands/default/system.py @@ -18,7 +18,7 @@ from evennia.server.sessionhandler import SESSIONS from evennia.scripts.models import ScriptDB from evennia.objects.models import ObjectDB from evennia.accounts.models import AccountDB -from evennia.utils import logger, utils, gametime, create +from evennia.utils import logger, utils, gametime, create, search from evennia.utils.eveditor import EvEditor from evennia.utils.evtable import EvTable from evennia.utils.utils import crop, class_from_module @@ -460,17 +460,22 @@ class CmdObjects(COMMAND_DEFAULT_CLASS): class CmdAccounts(COMMAND_DEFAULT_CLASS): """ - list all registered accounts + Manage registered accounts Usage: @accounts [nr] + @accounts/delete [: reason] - Lists statistics about the Accounts registered with the game. + Switches: + delete - delete an account from the server + + By default, lists statistics about the Accounts registered with the game. It will list the amount of latest registered accounts If not given, defaults to 10. """ key = "@accounts" - aliases = ["@listaccounts"] + aliases = ["@account", "@listaccounts"] + switch_options = ("delete", ) locks = "cmd:perm(listaccounts) or perm(Admin)" help_category = "System" @@ -478,6 +483,56 @@ class CmdAccounts(COMMAND_DEFAULT_CLASS): """List the accounts""" caller = self.caller + args = self.args + + if "delete" in self.switches: + account = getattr(caller, "account") + if not account or not account.check_permstring("Developer"): + caller.msg("You are not allowed to delete accounts.") + return + if not args: + caller.msg("Usage: @accounts/delete [: reason]") + return + reason = "" + if ":" in args: + args, reason = [arg.strip() for arg in args.split(":", 1)] + # We use account_search since we want to be sure to find also accounts + # that lack characters. + accounts = search.account_search(args) + if not accounts: + self.msg("Could not find an account by that name.") + return + if len(accounts) > 1: + string = "There were multiple matches:\n" + string += "\n".join(" %s %s" % (account.id, account.key) for account in accounts) + self.msg(string) + return + account = accounts.first() + if not account.access(caller, "delete"): + self.msg("You don't have the permissions to delete that account.") + return + username = account.username + # ask for confirmation + confirm = ("It is often better to block access to an account rather than to delete it. " + "|yAre you sure you want to permanently delete " + "account '|n{}|y'|n yes/[no]?".format(username)) + answer = yield(confirm) + if answer.lower() not in ('y', 'yes'): + caller.msg("Canceled deletion.") + return + + # Boot the account then delete it. + self.msg("Informing and disconnecting account ...") + string = "\nYour account '%s' is being *permanently* deleted.\n" % username + if reason: + string += " Reason given:\n '%s'" % reason + account.msg(string) + logger.log_sec("Account Deleted: %s (Reason: %s, Caller: %s, IP: %s)." % (account, reason, caller, self.session.address)) + account.delete() + self.msg("Account %s was successfully deleted." % username) + return + + # No switches, default to displaying a list of accounts. if self.args and self.args.isdigit(): nlim = int(self.args) else: @@ -655,6 +710,7 @@ class CmdTime(COMMAND_DEFAULT_CLASS): """Show server time data in a table.""" table1 = EvTable("|wServer time", "", align="l", width=78) table1.add_row("Current uptime", utils.time_format(gametime.uptime(), 3)) + table1.add_row("Portal uptime", utils.time_format(gametime.portal_uptime(), 3)) table1.add_row("Total runtime", utils.time_format(gametime.runtime(), 2)) table1.add_row("First start", datetime.datetime.fromtimestamp(gametime.server_epoch())) table1.add_row("Current time", datetime.datetime.now()) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 19277c168a..f37e8760b6 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -243,6 +243,9 @@ class TestAdmin(CommandTest): def test_ban(self): self.call(admin.CmdBan(), "Char", "Name-Ban char was added.") + def test_force(self): + self.call(admin.CmdForce(), "Char2=say test", 'Char2(#7) says, "test"|You have forced Char2 to: say test') + class TestAccount(CommandTest): @@ -315,6 +318,24 @@ class TestBuilding(CommandTest): def test_desc(self): self.call(building.CmdDesc(), "Obj2=TestDesc", "The description was set on Obj2(#5).") + def test_empty_desc(self): + """ + empty desc sets desc as '' + """ + o2d = self.obj2.db.desc + r1d = self.room1.db.desc + self.call(building.CmdDesc(), "Obj2=", "The description was set on Obj2(#5).") + assert self.obj2.db.desc == '' and self.obj2.db.desc != o2d + assert self.room1.db.desc == r1d + + def test_desc_default_to_room(self): + """no rhs changes room's desc""" + o2d = self.obj2.db.desc + r1d = self.room1.db.desc + self.call(building.CmdDesc(), "Obj2", "The description was set on Room(#1).") + assert self.obj2.db.desc == o2d + assert self.room1.db.desc == 'Obj2' and self.room1.db.desc != r1d + def test_wipe(self): confirm = building.CmdDestroy.confirm building.CmdDestroy.confirm = False @@ -334,6 +355,14 @@ class TestBuilding(CommandTest): self.call(building.CmdOpen(), "TestExit1=Room2", "Created new Exit 'TestExit1' from Room to Room2") self.call(building.CmdLink(), "TestExit1=Room", "Link created TestExit1 -> Room (one way).") self.call(building.CmdUnLink(), "TestExit1", "Former exit TestExit1 no longer links anywhere.") + self.char1.location = self.room2 + self.call(building.CmdOpen(), "TestExit2=Room", "Created new Exit 'TestExit2' from Room2 to Room.") + # ensure it matches locally first + self.call(building.CmdLink(), "TestExit=Room2", "Link created TestExit2 -> Room2 (one way).") + # ensure can still match globally when not a local name + self.call(building.CmdLink(), "TestExit1=Room2", "Note: TestExit1(#8) did not have a destination set before. " + "Make sure you linked the right thing.\n" + "Link created TestExit1 -> Room2 (one way).") def test_set_home(self): self.call(building.CmdSetHome(), "Obj = Room2", "Obj's home location was changed from Room") diff --git a/evennia/contrib/tutorial_world/objects.py b/evennia/contrib/tutorial_world/objects.py index f83462ad6b..331b6b1a21 100644 --- a/evennia/contrib/tutorial_world/objects.py +++ b/evennia/contrib/tutorial_world/objects.py @@ -475,14 +475,14 @@ class CmdShiftRoot(Command): root_pos["blue"] -= 1 self.caller.msg("The root with blue flowers gets in the way and is pushed to the left.") else: - self.caller.msg("You cannot move the root in that direction.") + self.caller.msg("The root hangs straight down - you can only move it left or right.") elif color == "blue": if direction == "left": root_pos[color] = max(-1, root_pos[color] - 1) self.caller.msg("You shift the root with small blue flowers to the left.") if root_pos[color] != 0 and root_pos[color] == root_pos["red"]: root_pos["red"] += 1 - self.caller.msg("The reddish root is to big to fit as well, so that one falls away to the left.") + self.caller.msg("The reddish root is too big to fit as well, so that one falls away to the left.") elif direction == "right": root_pos[color] = min(1, root_pos[color] + 1) self.caller.msg("You shove the root adorned with small blue flowers to the right.") @@ -490,7 +490,7 @@ class CmdShiftRoot(Command): root_pos["red"] -= 1 self.caller.msg("The thick reddish root gets in the way and is pushed back to the left.") else: - self.caller.msg("You cannot move the root in that direction.") + self.caller.msg("The root hangs straight down - you can only move it left or right.") # now the horizontal roots (yellow/green). They can be moved up/down elif color == "yellow": @@ -507,7 +507,7 @@ class CmdShiftRoot(Command): root_pos["green"] -= 1 self.caller.msg("The weedy green root is shifted upwards to make room.") else: - self.caller.msg("You cannot move the root in that direction.") + self.caller.msg("The root hangs across the wall - you can only move it up or down.") elif color == "green": if direction == "up": root_pos[color] = max(-1, root_pos[color] - 1) @@ -522,7 +522,7 @@ class CmdShiftRoot(Command): root_pos["yellow"] -= 1 self.caller.msg("The root with yellow flowers gets in the way and is pushed upwards.") else: - self.caller.msg("You cannot move the root in that direction.") + self.caller.msg("The root hangs across the wall - you can only move it up or down.") # we have moved the root. Store new position self.obj.db.root_pos = root_pos diff --git a/evennia/contrib/tutorial_world/rooms.py b/evennia/contrib/tutorial_world/rooms.py index 780f774af7..58e13a1356 100644 --- a/evennia/contrib/tutorial_world/rooms.py +++ b/evennia/contrib/tutorial_world/rooms.py @@ -747,9 +747,16 @@ class CmdLookDark(Command): """ caller = self.caller - if random.random() < 0.75: + # count how many searches we've done + nr_searches = caller.ndb.dark_searches + if nr_searches is None: + nr_searches = 0 + caller.ndb.dark_searches = nr_searches + + if nr_searches < 4 and random.random() < 0.90: # we don't find anything caller.msg(random.choice(DARK_MESSAGES)) + caller.ndb.dark_searches += 1 else: # we could have found something! if any(obj for obj in caller.contents if utils.inherits_from(obj, LightSource)): @@ -791,7 +798,8 @@ class CmdDarkNoMatch(Command): def func(self): """Implements the command.""" - self.caller.msg("Until you find some light, there's not much you can do. Try feeling around.") + self.caller.msg("Until you find some light, there's not much you can do. " + "Try feeling around, maybe you'll find something helpful!") class DarkCmdSet(CmdSet): @@ -814,7 +822,9 @@ class DarkCmdSet(CmdSet): self.add(CmdLookDark()) self.add(CmdDarkHelp()) self.add(CmdDarkNoMatch()) - self.add(default_cmds.CmdSay) + self.add(default_cmds.CmdSay()) + self.add(default_cmds.CmdQuit()) + self.add(default_cmds.CmdHome()) class DarkRoom(TutorialRoom): diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 027e8ff47f..7e1a13722e 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -917,8 +917,12 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): # no need to disconnect, Account just jumps to OOC mode. # sever the connection (important!) if self.account: + # Remove the object from playable characters list + if self in self.account.db._playable_characters: + self.account.db._playable_characters = [x for x in self.account.db._playable_characters if x != self] for session in self.sessions.all(): self.account.unpuppet_object(session) + self.account = None for script in _ScriptDB.objects.get_all_scripts_on_obj(self): diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 67eccaafd0..fc8edb55ab 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -6,6 +6,8 @@ Handling storage of prototypes, both database-based ones (DBPrototypes) and thos """ import re +import hashlib +import time from ast import literal_eval from django.conf import settings from evennia.scripts.scripts import DefaultScript @@ -13,7 +15,7 @@ from evennia.objects.models import ObjectDB from evennia.utils.create import create_script from evennia.utils.utils import ( all_from_module, make_iter, is_iter, dbid_to_obj, callables_from_module, - get_all_typeclasses, to_str, dbref, justify) + get_all_typeclasses, to_str, dbref, justify, class_from_module) from evennia.locks.lockhandler import validate_lockstring, check_lockstring from evennia.utils import logger from evennia.utils import inlinefuncs, dbserialize @@ -47,8 +49,8 @@ class ValidationError(RuntimeError): def homogenize_prototype(prototype, custom_keys=None): """ - Homogenize the more free-form prototype (where undefined keys are non-category attributes) - into the stricter form using `attrs` required by the system. + Homogenize the more free-form prototype supported pre Evennia 0.7 into the stricter form. + Args: prototype (dict): Prototype. @@ -56,18 +58,45 @@ def homogenize_prototype(prototype, custom_keys=None): the default reserved keys. Returns: - homogenized (dict): Prototype where all non-identified keys grouped as attributes. + homogenized (dict): Prototype where all non-identified keys grouped as attributes and other + homogenizations like adding missing prototype_keys and setting a default typeclass. + """ reserved = _PROTOTYPE_RESERVED_KEYS + (custom_keys or ()) + attrs = list(prototype.get('attrs', [])) # break reference + tags = make_iter(prototype.get('tags', [])) + homogenized_tags = [] + homogenized = {} for key, val in prototype.items(): if key in reserved: - homogenized[key] = val + if key == 'tags': + for tag in tags: + if not is_iter(tag): + homogenized_tags.append((tag, None, None)) + else: + homogenized_tags.append(tag) + else: + homogenized[key] = val else: + # unassigned keys -> attrs attrs.append((key, val, None, '')) if attrs: homogenized['attrs'] = attrs + if homogenized_tags: + homogenized['tags'] = homogenized_tags + + # add required missing parts that had defaults before + + if "prototype_key" not in prototype: + # assign a random hash as key + homogenized["prototype_key"] = "prototype-{}".format( + hashlib.md5(str(time.time())).hexdigest()[:7]) + + if "typeclass" not in prototype and "prototype_parent" not in prototype: + homogenized["typeclass"] = settings.BASE_OBJECT_TYPECLASS + return homogenized @@ -76,9 +105,12 @@ def homogenize_prototype(prototype, custom_keys=None): for mod in settings.PROTOTYPE_MODULES: # to remove a default prototype, override it with an empty dict. # internally we store as (key, desc, locks, tags, prototype_dict) - prots = [(prototype_key.lower(), homogenize_prototype(prot)) - for prototype_key, prot in all_from_module(mod).items() - if prot and isinstance(prot, dict)] + prots = [] + for variable_name, prot in all_from_module(mod).items(): + if isinstance(prot, dict): + if "prototype_key" not in prot: + prot['prototype_key'] = variable_name.lower() + prots.append((prot['prototype_key'], homogenize_prototype(prot))) # assign module path to each prototype_key for easy reference _MODULE_PROTOTYPE_MODULES.update({prototype_key.lower(): mod for prototype_key, _ in prots}) # make sure the prototype contains all meta info @@ -432,11 +464,13 @@ def validate_prototype(prototype, protkey=None, protparents=None, _flags['warnings'].append("Prototype {} can only be used as a mixin since it lacks " "a typeclass or a prototype_parent.".format(protkey)) - if (strict and typeclass and typeclass not - in get_all_typeclasses("evennia.objects.models.ObjectDB")): - _flags['errors'].append( - "Prototype {} is based on typeclass {}, which could not be imported!".format( - protkey, typeclass)) + if strict and typeclass: + try: + class_from_module(typeclass) + except ImportError as err: + _flags['errors'].append( + "{}: Prototype {} is based on typeclass {}, which could not be imported!".format( + err, protkey, typeclass)) # recursively traverese prototype_parent chain diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 7d876cf580..d1c099fb57 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -259,11 +259,11 @@ def prototype_from_object(obj): if aliases: prot['aliases'] = aliases tags = [(tag.db_key, tag.db_category, tag.db_data) - for tag in obj.tags.get(return_tagobj=True, return_list=True) if tag] + for tag in obj.tags.all(return_objs=True)] if tags: prot['tags'] = tags attrs = [(attr.key, attr.value, attr.category, ';'.join(attr.locks.all())) - for attr in obj.attributes.get(return_obj=True, return_list=True) if attr] + for attr in obj.attributes.all()] if attrs: prot['attrs'] = attrs @@ -659,6 +659,10 @@ def spawn(*prototypes, **kwargs): # get available protparents protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()} + if not kwargs.get("only_validate"): + # homogenization to be more lenient about prototype format when entering the prototype manually + prototypes = [protlib.homogenize_prototype(prot) for prot in prototypes] + # overload module's protparents with specifically given protparents # we allow prototype_key to be the key of the protparent dict, to allow for module-level # prototype imports. We need to insert prototype_key in this case @@ -711,8 +715,8 @@ def spawn(*prototypes, **kwargs): val = prot.pop("tags", []) tags = [] - for (tag, category, data) in tags: - tags.append((init_spawn_value(val, str), category, data)) + for (tag, category, data) in val: + tags.append((init_spawn_value(tag, str), category, data)) prototype_key = prototype.get('prototype_key', None) if prototype_key: @@ -730,7 +734,7 @@ def spawn(*prototypes, **kwargs): val = make_iter(prot.pop("attrs", [])) attributes = [] for (attrname, value, category, locks) in val: - attributes.append((attrname, init_spawn_value(val), category, locks)) + attributes.append((attrname, init_spawn_value(value), category, locks)) simple_attributes = [] for key, value in ((key, value) for key, value in prot.items() diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 1ad1d9ac47..411bd45c27 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -134,6 +134,7 @@ class TestUtils(EvenniaTest): 'prototype_key': Something, 'prototype_locks': 'spawn:all();edit:all()', 'prototype_tags': [], + 'tags': [(u'footag', u'foocategory', None)], 'typeclass': 'evennia.objects.objects.DefaultObject'}) self.assertEqual(old_prot, @@ -182,6 +183,7 @@ class TestUtils(EvenniaTest): 'typeclass': ('evennia.objects.objects.DefaultObject', 'evennia.objects.objects.DefaultObject', 'KEEP'), 'aliases': {'foo': ('foo', None, 'REMOVE')}, + 'tags': {u'footag': ((u'footag', u'foocategory', None), None, 'REMOVE')}, 'prototype_desc': ('Built from Obj', 'New version of prototype', 'UPDATE'), 'permissions': {"Builder": (None, 'Builder', 'ADD')} @@ -200,6 +202,7 @@ class TestUtils(EvenniaTest): 'prototype_key': 'UPDATE', 'prototype_locks': 'KEEP', 'prototype_tags': 'KEEP', + 'tags': 'REMOVE', 'typeclass': 'KEEP'} ) @@ -384,8 +387,9 @@ class TestPrototypeStorage(EvenniaTest): prot3 = protlib.create_prototype(**self.prot3) # partial match - self.assertEqual(list(protlib.search_prototype("prot")), [prot1b, prot2, prot3]) - self.assertEqual(list(protlib.search_prototype(tags="foo1")), [prot1b, prot2, prot3]) + with mock.patch("evennia.prototypes.prototypes._MODULE_PROTOTYPES", {}): + self.assertEqual(list(protlib.search_prototype("prot")), [prot1b, prot2, prot3]) + self.assertEqual(list(protlib.search_prototype(tags="foo1")), [prot1b, prot2, prot3]) self.assertTrue(str(unicode(protlib.list_prototypes(self.char1)))) diff --git a/evennia/server/amp_client.py b/evennia/server/amp_client.py index a4300adf4d..816ecf2705 100644 --- a/evennia/server/amp_client.py +++ b/evennia/server/amp_client.py @@ -221,6 +221,7 @@ class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol): server_restart_mode = kwargs.get("server_restart_mode", "shutdown") self.factory.server.run_init_hooks(server_restart_mode) server_sessionhandler.portal_sessions_sync(kwargs.get("sessiondata")) + server_sessionhandler.portal_start_time = kwargs.get("portal_start_time") elif operation == amp.SRELOAD: # server reload # shut down in reload mode diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 779a1e4aa2..ef6bf61055 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -325,7 +325,7 @@ MENU = \ | 7) Kill Server only (send kill signal to process) | | 8) Kill Portal + Server | +--- Information -----------------------------------------------+ - | 9) Tail log files (quickly see errors) | + | 9) Tail log files (quickly see errors - Ctrl-C to exit) | | 10) Status | | 11) Port info | +--- Testing ---------------------------------------------------+ diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index c07b5c121d..cdcd4a1552 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -428,7 +428,8 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): self.send_AdminPortal2Server(amp.DUMMYSESSION, amp.PSYNC, server_restart_mode=server_restart_mode, - sessiondata=sessdata) + sessiondata=sessdata, + portal_start_time=self.factory.portal.start_time) self.factory.portal.sessions.at_server_connection() if self.factory.server_connection: diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index 91b3efc7bc..bb2a7b9f07 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -11,6 +11,7 @@ from builtins import object import sys import os +import time from os.path import dirname, abspath from twisted.application import internet, service @@ -114,6 +115,8 @@ class Portal(object): self.server_restart_mode = "shutdown" self.server_info_dict = {} + self.start_time = time.time() + # in non-interactive portal mode, this gets overwritten by # cmdline sent by the evennia launcher self.server_twistd_cmd = self._get_backup_server_twistd_cmd() diff --git a/evennia/server/portal/telnet.py b/evennia/server/portal/telnet.py index 955ea5e918..83e2fa03a2 100644 --- a/evennia/server/portal/telnet.py +++ b/evennia/server/portal/telnet.py @@ -84,7 +84,7 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session): from evennia.utils.utils import delay # timeout the handshakes in case the client doesn't reply at all - delay(2, callback=self.handshake_done, timeout=True) + self._handshake_delay = delay(2, callback=self.handshake_done, timeout=True) # TCP/IP keepalive watches for dead links self.transport.setTcpKeepAlive(1) diff --git a/evennia/server/portal/tests.py b/evennia/server/portal/tests.py index be400144c6..791e5172a4 100644 --- a/evennia/server/portal/tests.py +++ b/evennia/server/portal/tests.py @@ -8,9 +8,24 @@ try: except ImportError: import unittest +from mock import Mock import string from evennia.server.portal import irc +from twisted.conch.telnet import IAC, WILL, DONT, SB, SE, NAWS, DO +from twisted.test import proto_helpers +from twisted.trial.unittest import TestCase as TwistedTestCase + +from .telnet import TelnetServerFactory, TelnetProtocol +from .portal import PORTAL_SESSIONS +from .suppress_ga import SUPPRESS_GA +from .naws import DEFAULT_HEIGHT, DEFAULT_WIDTH +from .ttype import TTYPE, IS +from .mccp import MCCP +from .mssp import MSSP +from .mxp import MXP +from .telnet_oob import MSDP, MSDP_VAL, MSDP_VAR + class TestIRC(TestCase): @@ -73,3 +88,64 @@ class TestIRC(TestCase): s = r'|wthis|Xis|gis|Ma|C|complex|*string' self.assertEqual(irc.parse_irc_to_ansi(irc.parse_ansi_to_irc(s)), s) + + +class TestTelnet(TwistedTestCase): + def setUp(self): + super(TestTelnet, self).setUp() + factory = TelnetServerFactory() + factory.protocol = TelnetProtocol + factory.sessionhandler = PORTAL_SESSIONS + factory.sessionhandler.portal = Mock() + self.proto = factory.buildProtocol(("localhost", 0)) + self.transport = proto_helpers.StringTransport() + self.addCleanup(factory.sessionhandler.disconnect_all) + + def test_mudlet_ttype(self): + self.transport.client = ["localhost"] + self.transport.setTcpKeepAlive = Mock() + d = self.proto.makeConnection(self.transport) + # test suppress_ga + self.assertTrue(self.proto.protocol_flags["NOGOAHEAD"]) + self.proto.dataReceived(IAC + DONT + SUPPRESS_GA) + self.assertFalse(self.proto.protocol_flags["NOGOAHEAD"]) + self.assertEqual(self.proto.handshakes, 7) + # test naws + self.assertEqual(self.proto.protocol_flags['SCREENWIDTH'], {0: DEFAULT_WIDTH}) + self.assertEqual(self.proto.protocol_flags['SCREENHEIGHT'], {0: DEFAULT_HEIGHT}) + self.proto.dataReceived(IAC + WILL + NAWS) + self.proto.dataReceived([IAC, SB, NAWS, '', 'x', '', 'd', IAC, SE]) + self.assertEqual(self.proto.protocol_flags['SCREENWIDTH'][0], 120) + self.assertEqual(self.proto.protocol_flags['SCREENHEIGHT'][0], 100) + self.assertEqual(self.proto.handshakes, 6) + # test ttype + self.assertTrue(self.proto.protocol_flags["FORCEDENDLINE"]) + self.assertFalse(self.proto.protocol_flags["TTYPE"]) + self.assertTrue(self.proto.protocol_flags["ANSI"]) + self.proto.dataReceived(IAC + WILL + TTYPE) + self.proto.dataReceived([IAC, SB, TTYPE, IS, "MUDLET", IAC, SE]) + self.assertTrue(self.proto.protocol_flags["XTERM256"]) + self.assertEqual(self.proto.protocol_flags["CLIENTNAME"], "MUDLET") + self.proto.dataReceived([IAC, SB, TTYPE, IS, "XTERM", IAC, SE]) + self.proto.dataReceived([IAC, SB, TTYPE, IS, "MTTS 137", IAC, SE]) + self.assertEqual(self.proto.handshakes, 5) + # test mccp + self.proto.dataReceived(IAC + DONT + MCCP) + self.assertFalse(self.proto.protocol_flags['MCCP']) + self.assertEqual(self.proto.handshakes, 4) + # test mssp + self.proto.dataReceived(IAC + DONT + MSSP) + self.assertEqual(self.proto.handshakes, 3) + # test oob + self.proto.dataReceived(IAC + DO + MSDP) + self.proto.dataReceived([IAC, SB, MSDP, MSDP_VAR, "LIST", MSDP_VAL, "COMMANDS", IAC, SE]) + self.assertTrue(self.proto.protocol_flags['OOB']) + self.assertEqual(self.proto.handshakes, 2) + # test mxp + self.proto.dataReceived(IAC + DONT + MXP) + self.assertFalse(self.proto.protocol_flags['MXP']) + self.assertEqual(self.proto.handshakes, 1) + # clean up to prevent Unclean reactor + self.proto.nop_keep_alive.stop() + self.proto._handshake_delay.cancel() + return d diff --git a/evennia/server/profiling/memplot.py b/evennia/server/profiling/memplot.py index e8d7e76b12..c6a227a370 100644 --- a/evennia/server/profiling/memplot.py +++ b/evennia/server/profiling/memplot.py @@ -13,14 +13,14 @@ import time # TODO! #sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))) #os.environ['DJANGO_SETTINGS_MODULE'] = 'game.settings' -import ev -from evennia.utils.idmapper import base as _idmapper +import evennia +from evennia.utils.idmapper import models as _idmapper LOGFILE = "logs/memoryusage.log" INTERVAL = 30 # log every 30 seconds -class Memplot(ev.Script): +class Memplot(evennia.DefaultScript): """ Describes a memory plotting action. diff --git a/evennia/server/profiling/tests.py b/evennia/server/profiling/tests.py index b3e9fba8d5..cca4e0d99b 100644 --- a/evennia/server/profiling/tests.py +++ b/evennia/server/profiling/tests.py @@ -1,7 +1,8 @@ from django.test import TestCase -from mock import Mock +from mock import Mock, patch, mock_open from .dummyrunner_settings import (c_creates_button, c_creates_obj, c_digs, c_examines, c_help, c_idles, c_login, c_login_nodig, c_logout, c_looks, c_moves, c_moves_n, c_moves_s, c_socialize) +import memplot class TestDummyrunnerSettings(TestCase): @@ -91,3 +92,21 @@ class TestDummyrunnerSettings(TestCase): def test_c_move_s(self): self.assertEqual(c_moves_s(self.client), "south") + + +class TestMemPlot(TestCase): + @patch.object(memplot, "_idmapper") + @patch.object(memplot, "os") + @patch.object(memplot, "open", new_callable=mock_open, create=True) + @patch.object(memplot, "time") + def test_memplot(self, mock_time, mocked_open, mocked_os, mocked_idmapper): + from evennia.utils.create import create_script + mocked_idmapper.cache_size.return_value = (9, 5000) + mock_time.time = Mock(return_value=6000.0) + script = create_script(memplot.Memplot) + script.db.starttime = 0.0 + mocked_os.popen.read.return_value = 5000.0 + script.at_repeat() + handle = mocked_open() + handle.write.assert_called_with('100.0, 0.001, 0.001, 9\n') + script.stop() diff --git a/evennia/server/serversession.py b/evennia/server/serversession.py index b7f74cef5d..c5de7cf5be 100644 --- a/evennia/server/serversession.py +++ b/evennia/server/serversession.py @@ -407,7 +407,7 @@ class ServerSession(Session): else: self.data_out(**kwargs) - def execute_cmd(self, raw_string, **kwargs): + def execute_cmd(self, raw_string, session=None, **kwargs): """ Do something as this object. This method is normally never called directly, instead incoming command instructions are @@ -417,6 +417,9 @@ class ServerSession(Session): Args: raw_string (string): Raw command input + session (Session): This is here to make API consistent with + Account/Object.execute_cmd. If given, data is passed to + that Session, otherwise use self. Kwargs: Other keyword arguments will be added to the found command object instace as variables before it executes. This is @@ -426,7 +429,7 @@ class ServerSession(Session): """ # inject instruction into input stream kwargs["text"] = ((raw_string,), {}) - self.sessionhandler.data_in(self, **kwargs) + self.sessionhandler.data_in(session or self, **kwargs) def __eq__(self, other): """Handle session comparisons""" diff --git a/evennia/server/sessionhandler.py b/evennia/server/sessionhandler.py index 8e439b42dd..906ad1ec54 100644 --- a/evennia/server/sessionhandler.py +++ b/evennia/server/sessionhandler.py @@ -280,6 +280,8 @@ class ServerSessionHandler(SessionHandler): super(ServerSessionHandler, self).__init__(*args, **kwargs) self.server = None # set at server initialization self.server_data = {"servername": _SERVERNAME} + # will be set on psync + self.portal_start_time = 0.0 def _run_cmd_login(self, session): """ diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 9efbb6314b..9d6ad9e686 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -13,6 +13,7 @@ always be sure of what you have changed and what is default behaviour. """ from builtins import range +from django.urls import reverse_lazy import os import sys @@ -697,9 +698,9 @@ ROOT_URLCONF = 'web.urls' # Where users are redirected after logging in via contrib.auth.login. LOGIN_REDIRECT_URL = '/' # Where to redirect users when using the @login_required decorator. -LOGIN_URL = '/accounts/login' +LOGIN_URL = reverse_lazy('login') # Where to redirect users who wish to logout. -LOGOUT_URL = '/accounts/login' +LOGOUT_URL = reverse_lazy('logout') # URL that handles the media served from MEDIA_ROOT. # Example: "http://media.lawrence.com" MEDIA_URL = '/media/' diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index 863628172a..1dc1902494 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -668,7 +668,7 @@ class AttributeHandler(object): def all(self, accessing_obj=None, default_access=True): """ - Return all Attribute objects on this object. + Return all Attribute objects on this object, regardless of category. Args: accessing_obj (object, optional): Check the `attrread` diff --git a/evennia/typeclasses/tags.py b/evennia/typeclasses/tags.py index 488dce0f85..ea675366fd 100644 --- a/evennia/typeclasses/tags.py +++ b/evennia/typeclasses/tags.py @@ -345,13 +345,14 @@ class TagHandler(object): self._catcache = {} self._cache_complete = False - def all(self, return_key_and_category=False): + def all(self, return_key_and_category=False, return_objs=False): """ Get all tags in this handler, regardless of category. Args: return_key_and_category (bool, optional): Return a list of tuples `[(key, category), ...]`. + return_objs (bool, optional): Return tag objects. Returns: tags (list): A list of tag keys `[tagkey, tagkey, ...]` or @@ -365,6 +366,8 @@ class TagHandler(object): if return_key_and_category: # return tuple (key, category) return [(to_str(tag.db_key), to_str(tag.db_category)) for tag in tags] + elif return_objs: + return tags else: return [to_str(tag.db_key) for tag in tags] diff --git a/evennia/utils/gametime.py b/evennia/utils/gametime.py index 3736128819..48910c4bfd 100644 --- a/evennia/utils/gametime.py +++ b/evennia/utils/gametime.py @@ -107,6 +107,17 @@ def uptime(): return time.time() - SERVER_START_TIME +def portal_uptime(): + """ + Get the current uptime of the portal. + + Returns: + time (float): The uptime of the portal. + """ + from evennia.server.sessionhandler import SESSIONS + return time.time() - SESSIONS.portal_start_time + + def game_epoch(): """ Get the game epoch. diff --git a/evennia/web/webclient/static/webclient/js/plugins/default_in.js b/evennia/web/webclient/static/webclient/js/plugins/default_in.js index 28bfc9f315..02fd401706 100644 --- a/evennia/web/webclient/static/webclient/js/plugins/default_in.js +++ b/evennia/web/webclient/static/webclient/js/plugins/default_in.js @@ -8,6 +8,7 @@ let defaultin_plugin = (function () { // // handle the default key triggering onSend() var onKeydown = function (event) { + $("#inputfield").focus(); if ( (event.which === 13) && (!event.shiftKey) ) { // Enter Key without shift var inputfield = $("#inputfield"); var outtext = inputfield.val(); diff --git a/evennia/web/webclient/static/webclient/js/plugins/history.js b/evennia/web/webclient/static/webclient/js/plugins/history.js index 1bef6031cd..c33dbcabf9 100644 --- a/evennia/web/webclient/static/webclient/js/plugins/history.js +++ b/evennia/web/webclient/static/webclient/js/plugins/history.js @@ -43,14 +43,6 @@ let history_plugin = (function () { history_pos = 0; } - // - // Go to the last history line - var end = function () { - // move to the end of the history stack - history_pos = 0; - return history[history.length -1]; - } - // // Add input to the scratch line var scratch = function (input) { @@ -69,28 +61,17 @@ let history_plugin = (function () { var history_entry = null; var inputfield = $("#inputfield"); - if (inputfield[0].selectionStart == inputfield.val().length) { - // Only process up/down arrow if cursor is at the end of the line. - if (code === 38) { // Arrow up - history_entry = back(); - } - else if (code === 40) { // Arrow down - history_entry = fwd(); - } + if (code === 38) { // Arrow up + history_entry = back(); + } + else if (code === 40) { // Arrow down + history_entry = fwd(); } if (history_entry !== null) { // Doing a history navigation; replace the text in the input. inputfield.val(history_entry); } - else { - // Save the current contents of the input to the history scratch area. - setTimeout(function () { - // Need to wait until after the key-up to capture the value. - scratch(inputfield.val()); - end(); - }, 0); - } return false; } @@ -99,6 +80,7 @@ let history_plugin = (function () { // Listen for onSend lines to add to history var onSend = function (line) { add(line); + return null; // we are not returning an altered input line } //