diff --git a/CHANGELOG.md b/CHANGELOG.md index a05d65fdc0..afe5ad1ba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,118 @@ -# Evennia Changelog +# Changelog -# Sept 2017: -Release of Evennia 0.7; upgrade to Django 1.11, change 'Player' to +## Evennia 0.8 (2018) + +### Server/Portal + +- Removed `evennia_runner`, completely refactor `evennia_launcher.py` (the 'evennia' program) + with different functionality). +- Both Portal/Server are now stand-alone processes (easy to run as daemon) +- Made Portal the AMP Server for starting/restarting the Server (the AMP client) +- Dynamic logging now happens using `evennia -l` rather than by interactive mode. +- Made AMP secure against erroneous HTTP requests on the wrong port (return error messages). +- The `evennia istart` option will start/switch the Server in foreground (interactive) mode, where it logs + to terminal and can be stopped with Ctrl-C. Using `evennia reload`, or reloading in-game, will + return Server to normal daemon operation. +- For validating passwords, use safe Django password-validation backend instead of custom Evennia one. +- Alias `evennia restart` to mean the same as `evennia reload`. + +### Prototype changes + +- New OLC started from `olc` command for loading/saving/manipulating prototypes in a menu. +- Moved evennia/utils/spawner.py into the new evennia/prototypes/ along with all new + functionality around prototypes. +- A new form of prototype - database-stored prototypes, editable from in-game, was added. The old, + module-created prototypes remain as read-only prototypes. +- All prototypes must have a key `prototype_key` identifying the prototype in listings. This is + checked to be server-unique. Prototypes created in a module will use the global variable name they + are assigned to if no `prototype_key` is given. +- Prototype field `prototype` was renamed to `prototype_parent` to avoid mixing terms. +- All prototypes must either have `typeclass` or `prototype_parent` defined. If using + `prototype_parent`, `typeclass` must be defined somewhere in the inheritance chain. This is a + change from Evennia 0.7 which allowed 'mixin' prototypes without `typeclass`/`prototype_key`. To + make a mixin now, give it a default typeclass, like `evennia.objects.objects.DefaultObject` and just + override in the child as needed. +- Spawning an object using a prototype will automatically assign a new tag to it, named the same as + the `prototype_key` and with the category `from_prototype`. +- The spawn command was extended to accept a full prototype on one line. +- The spawn command got the /save switch to save the defined prototype and its key +- The command spawn/menu will now start an OLC (OnLine Creation) menu to load/save/edit/spawn prototypes. + +### EvMenu + +- Added `EvMenu.helptext_formatter(helptext)` to allow custom formatting of per-node help. +- Added `evennia.utils.evmenu.list_node` decorator for turning an EvMenu node into a multi-page listing. +- A `goto` option callable returning None (rather than the name of the next node) will now rerun the + current node instead of failing. +- Better error handling of in-node syntax errors. +- Improve dedent of default text/helptext formatter. Right-strip whitespace. +- Add `debug` option when creating menu - this turns off persistence and makes the `menudebug` + command available for examining the current menu state. + + +### Webclient + +- Webclient now uses a plugin system to inject new components from the html file. +- Split-windows - divide input field into any number of horizontal/vertical panes and + assign different types of server messages to them. +- Lots of cleanup and bug fixes. +- Hot buttons plugin (friarzen) (disabled by default). + +### Locks + +- New function `evennia.locks.lockhandler.check_lockstring`. This allows for checking an object + against an arbitrary lockstring without needing the lock to be stored on an object first. +- New function `evennia.locks.lockhandler.validate_lockstring` allows for stand-alone validation + of a lockstring. +- New function `evennia.locks.lockhandler.get_all_lockfuncs` gives a dict {"name": lockfunc} for + all available lock funcs. This is useful for dynamic listings. + + +### Utils + +- Added new `columnize` function for easily splitting text into multiple columns. At this point it + is not working too well with ansi-colored text however. +- Extend the `dedent` function with a new `baseline_index` kwarg. This allows to force all lines to + the indentation given by the given line regardless of if other lines were already a 0 indentation. + This removes a problem with the original `textwrap.dedent` which will only dedent to the least + indented part of a text. +- Added `exit_cmd` to EvMore pager, to allow for calling a command (e.g. 'look') when leaving the pager. +- `get_all_typeclasses` will return dict `{"path": typeclass, ...}` for all typeclasses available + in the system. This is used by the new `@typeclass/list` subcommand (useful for builders etc). +- `evennia.utils.dbserialize.deserialize(obj)` is a new helper function to *completely* disconnect + a mutable recovered from an Attribute from the database. This will convert all nested `_Saver*` + classes to their plain-Python counterparts. + +### 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) +- `evennia.set_trace()` is now a shortcut for launching pdb/pudb on a line in the Evennia event loop. +- Removed the enforcing of `MAX_NR_CHARACTERS=1` for `MULTISESSION_MODE` `0` and `1` by default. +- Add `evennia.utils.logger.log_sec` for logging security-related messages (marked SS in log). + +### Contribs + +- `Auditing` (Johnny): Log and filter server input/output for security purposes +- `Build Menu` (vincent-lg): New @edit command to edit object properties in a menu. +- `Field Fill` (Tim Ashley Jenkins): Wraps EvMenu for creating submittable forms. +- `Health Bar` (Tim Ashley Jenkins): Easily create colorful bars/meters. +- `Tree select` (Fluttersprite): Wrap EvMenu to create a common type of menu from a string. +- `Turnbattle suite` (Tim Ashley Jenkins)- the old `turnbattle.py` was moved into its own + `turnbattle/` package and reworked with many different flavors of combat systems: + - `tb_basic` - The basic turnbattle system, with initiative/turn order attack/defense/damage. + - `tb_equip` - Adds weapon and armor, wielding, accuracy modifiers. + - `tb_items` - Extends `tb_equip` with item use with conditions/status effects. + - `tb_magic` - Extends `tb_equip` with spellcasting. + - `tb_range` - Adds system for abstract positioning and movement. +- Updates and some cleanup of existing contribs. + +# Overviews + +## Sept 2017: +Release of Evennia 0.7; upgrade to Django 1.11, change 'Player' to 'Account', rework the website template and a slew of other updates. Info on what changed and how to migrate is found here: https://groups.google.com/forum/#!msg/evennia/0JYYNGY-NfE/cDFaIwmPBAAJ @@ -14,9 +125,9 @@ Lots of bugfixes and considerable uptick in contributors. Unittest coverage and PEP8 adoption and refactoring. ## May 2016: -Evennia 0.6 with completely reworked Out-of-band system, making +Evennia 0.6 with completely reworked Out-of-band system, making the message path completely flexible and built around input/outputfuncs. -A completely new webclient, split into the evennia.js library and a +A completely new webclient, split into the evennia.js library and a gui library, making it easier to customize. ## Feb 2016: @@ -33,15 +144,15 @@ library format with a stand-alone launcher, in preparation for making an 'evennia' pypy package and using versioning. The version we will merge with will likely be 0.5. There is also work with an expanded testing structure and the use of threading for saves. We also now -use Travis for automatic build checking. +use Travis for automatic build checking. ## Sept 2014: Updated to Django 1.7+ which means South dependency was dropped and minimum Python version upped to 2.7. MULTISESSION_MODE=3 was added -and the web customization system was overhauled using the latest -functionality of django. Otherwise, mostly bug-fixes and +and the web customization system was overhauled using the latest +functionality of django. Otherwise, mostly bug-fixes and implementation of various smaller feature requests as we got used -to github. Many new users have appeared. +to github. Many new users have appeared. ## Jan 2014: Moved Evennia project from Google Code to github.com/evennia/evennia. diff --git a/CODING_STYLE.md b/CODING_STYLE.md index 79488e94ec..460dfc15d5 100644 --- a/CODING_STYLE.md +++ b/CODING_STYLE.md @@ -97,15 +97,15 @@ def funcname(a, b, c, d=False, **kwargs): Args: a (str): This is a string argument that we can talk about over multiple lines. - b (int or str): Another argument - c (list): A list argument - d (bool, optional): An optional keyword argument + b (int or str): Another argument. + c (list): A list argument. + d (bool, optional): An optional keyword argument. Kwargs: - test (list): A test keyword + test (list): A test keyword. Returns: - e (str): The result of the function + e (str): The result of the function. Raises: RuntimeException: If there is a critical error, diff --git a/Dockerfile b/Dockerfile index c231f2a733..381c83f925 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,11 +5,11 @@ # install `docker` (http://docker.com) # # Usage: -# cd to a folder where you want your game data to be (or where it already is). +# 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 -# -# (If your OS does not support $PWD, replace it with the full path to your current +# +# (If your OS does not support $PWD, replace it with the full path to your current # folder). # # You will end up in a shell where the `evennia` command is available. From here you @@ -21,20 +21,30 @@ # FROM alpine -MAINTAINER www.evennia.com +LABEL maintainer="www.evennia.com" # install compilation environment -RUN apk update && apk add python py-pip python-dev py-setuptools gcc musl-dev jpeg-dev zlib-dev bash +RUN apk update && apk add bash gcc jpeg-dev musl-dev procps py-pip \ +py-setuptools py2-openssl python python-dev zlib-dev -# add the project source -ADD . /usr/src/evennia +# add the files required for pip installation +COPY ./setup.py /usr/src/evennia/ +COPY ./requirements.txt /usr/src/evennia/ +COPY ./evennia/VERSION.txt /usr/src/evennia/evennia/ +COPY ./bin /usr/src/evennia/bin/ # install dependencies -RUN pip install -e /usr/src/evennia --index-url=http://pypi.python.org/simple/ --trusted-host pypi.python.org +RUN pip install --upgrade pip && pip install -e /usr/src/evennia --trusted-host pypi.python.org +RUN pip install cryptography pyasn1 service_identity + +# add the project source; this should always be done after all +# expensive operations have completed to avoid prematurely +# invalidating the build cache. +COPY . /usr/src/evennia # add the game source when rebuilding a new docker image from inside -# a game dir -ONBUILD ADD . /usr/src/game +# a game dir +ONBUILD COPY . /usr/src/game # make the game source hierarchy persistent with a named volume. # mount on-disk game location here when using the container @@ -48,7 +58,7 @@ WORKDIR /usr/src/game ENV PS1 "evennia|docker \w $ " # startup a shell when we start the container -ENTRYPOINT ["bash"] +ENTRYPOINT bash -c "source /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 new file mode 100644 index 0000000000..d1333aaef6 --- /dev/null +++ b/bin/unix/evennia-docker-start.sh @@ -0,0 +1,10 @@ +#! /bin/bash + +# 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 diff --git a/bin/unix/evennia.service b/bin/unix/evennia.service new file mode 100644 index 0000000000..a312bb8b4e --- /dev/null +++ b/bin/unix/evennia.service @@ -0,0 +1,34 @@ +# Evennia systemd unit script +# +# Copy this to /usr/lib/systemd/system/ and Edit the paths to match your game. +# +# Then, register with systemd using: +# +# sudo systemctl daemon-reload +# sudo systemctl enable evennia.service +# + +[Unit] +Description=Evennia Server + +[Service] +Type=simple + +# +# Change this to the user the game should run as. +# Don't run this as root. Please, I beg you. +# +User=your-user + +# +# The command to start Evennia as a Systemd service. NOTE: These must be absolute paths. +# Replace /your/path/to with whatever is appropriate. +# +ExecStart=/your/path/to/pyenv/bin/python /your/path/to/evennia/bin/unix/evennia ipstart --gamedir /your/path/to/mygame + +# restart on all failures, wait 3 seconds before doing so. +Restart=on-failure +RestartSec=3 + + [Install] +WantedBy=default.target diff --git a/evennia/VERSION.txt b/evennia/VERSION.txt index faef31a435..a3df0a6959 100644 --- a/evennia/VERSION.txt +++ b/evennia/VERSION.txt @@ -1 +1 @@ -0.7.0 +0.8.0 diff --git a/evennia/__init__.py b/evennia/__init__.py index 682749ab5a..3dd82d56f9 100644 --- a/evennia/__init__.py +++ b/evennia/__init__.py @@ -176,7 +176,7 @@ def _init(): from .utils import logger from .utils import gametime from .utils import ansi - from .utils.spawner import spawn + from .prototypes.spawner import spawn from . import contrib from .utils.evmenu import EvMenu from .utils.evtable import EvTable @@ -316,3 +316,64 @@ def _init(): syscmdkeys = SystemCmds() del SystemCmds del _EvContainer + + +del object +del absolute_import +del print_function + + +def set_trace(debugger="auto", term_size=(140, 40)): + """ + Helper function for running a debugger inside the Evennia event loop. + + Args: + debugger (str, optional): One of 'auto', 'pdb' or 'pudb'. Pdb is the standard debugger. Pudb + is an external package with a different, more 'graphical', ncurses-based UI. With + 'auto', will use pudb if possible, otherwise fall back to pdb. Pudb is available through + `pip install pudb`. + term_size (tuple, optional): Only used for Pudb and defines the size of the terminal + (width, height) in number of characters. + + Notes: + To use: + + 1) add this to a line to act as a breakpoint for entering the debugger: + + from evennia import set_trace; set_trace() + + 2) restart evennia in interactive mode + + evennia istart + + 3) debugger will appear in the interactive terminal when breakpoint is reached. Exit + with 'q', remove the break line and restart server when finished. + + """ + import sys + dbg = None + pudb_mode = False + + if debugger in ('auto', 'pudb'): + try: + from pudb import debugger + dbg = debugger.Debugger(stdout=sys.__stdout__, + term_size=term_size) + pudb_mode = True + except ImportError: + if debugger == 'pudb': + raise + pass + + if not dbg: + import pdb + dbg = pdb.Pdb(stdout=sys.__stdout__) + pudb_mode = False + + if pudb_mode: + # Stopped at breakpoint. Press 'n' to continue into the code. + dbg.set_trace() + else: + # Start debugger, forcing it up one stack frame (otherwise `set_trace` will start debugger + # this point, not the actual code location) + dbg.set_trace(sys._getframe().f_back) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 204ff93bf5..06bb464a55 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -13,6 +13,8 @@ instead for most things). import time from django.conf import settings +from django.contrib.auth import password_validation +from django.core.exceptions import ValidationError from django.utils import timezone from evennia.typeclasses.models import TypeclassBase from evennia.accounts.manager import AccountManager @@ -21,7 +23,7 @@ from evennia.objects.models import ObjectDB from evennia.comms.models import ChannelDB from evennia.commands import cmdhandler from evennia.utils import logger -from evennia.utils.utils import (lazy_property, +from evennia.utils.utils import (lazy_property, to_str, make_iter, is_iter, variable_from_module) from evennia.typeclasses.attributes import NickHandler @@ -357,6 +359,65 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): puppet = property(__get_single_puppet) # utility methods + @classmethod + def validate_password(cls, password, account=None): + """ + Checks the given password against the list of Django validators enabled + in the server.conf file. + + Args: + password (str): Password to validate + + Kwargs: + account (DefaultAccount, optional): Account object to validate the + password for. Optional, but Django includes some validators to + do things like making sure users aren't setting passwords to the + same value as their username. If left blank, these user-specific + checks are skipped. + + Returns: + valid (bool): Whether or not the password passed validation + error (ValidationError, None): Any validation error(s) raised. Multiple + errors can be nested within a single object. + + """ + valid = False + error = None + + # Validation returns None on success; invert it and return a more sensible bool + try: + valid = not password_validation.validate_password(password, user=account) + except ValidationError as e: + error = e + + return valid, error + + def set_password(self, password, force=False): + """ + Applies the given password to the account if it passes validation checks. + Can be overridden by using the 'force' flag. + + Args: + password (str): Password to set + + Kwargs: + force (bool): Sets password without running validation checks. + + Raises: + ValidationError + + Returns: + None (None): Does not return a value. + + """ + if not force: + # Run validation checks + valid, error = self.validate_password(password, account=self) + if error: raise error + + super(DefaultAccount, self).set_password(password) + logger.log_info("Password succesfully changed for %s." % self) + self.at_password_change() def delete(self, *args, **kwargs): """ @@ -421,10 +482,19 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): kwargs["options"] = options + if text is not None: + if not (isinstance(text, basestring) or isinstance(text, tuple)): + # sanitize text before sending across the wire + try: + text = to_str(text, force_string=True) + except Exception: + text = repr(text) + kwargs['text'] = text + # session relay sessions = make_iter(session) if session else self.sessions.all() for session in sessions: - session.data_out(text=text, **kwargs) + session.data_out(**kwargs) def execute_cmd(self, raw_string, session=None, **kwargs): """ @@ -456,7 +526,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): callertype="account", session=session, **kwargs) def search(self, searchdata, return_puppet=False, search_object=False, - typeclass=None, nofound_string=None, multimatch_string=None, **kwargs): + typeclass=None, nofound_string=None, multimatch_string=None, use_nicks=True, **kwargs): """ This is similar to `DefaultObject.search` but defaults to searching for Accounts only. @@ -480,6 +550,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): multimatch_string (str, optional): A one-time error message to echo if `searchdata` leads to multiple matches. If not given, will fall back to the default handler. + use_nicks (bool, optional): Use account-level nick replacement. Return: match (Account, Object or None): A single Account or Object match. @@ -495,8 +566,10 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): if searchdata.lower() in ("me", "*me", "self", "*self",): return self if search_object: - matches = ObjectDB.objects.object_search(searchdata, typeclass=typeclass) + matches = ObjectDB.objects.object_search(searchdata, typeclass=typeclass, use_nicks=use_nicks) else: + searchdata = self.nicks.nickreplace(searchdata, categories=("account", ), include_account=False) + matches = AccountDB.objects.account_search(searchdata, typeclass=typeclass) matches = _AT_SEARCH_RESULT(matches, self, query=searchdata, nofound_string=nofound_string, @@ -615,15 +688,36 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): self.basetype_setup() self.at_account_creation() - permissions = settings.PERMISSION_ACCOUNT_DEFAULT + permissions = [settings.PERMISSION_ACCOUNT_DEFAULT] if hasattr(self, "_createdict"): # this will only be set if the utils.create_account # function was used to create the object. cdict = self._createdict + updates = [] + if not cdict.get("key"): + if not self.db_key: + self.db_key = "#%i" % self.dbid + updates.append("db_key") + elif self.key != cdict.get("key"): + updates.append("db_key") + self.db_key = cdict["key"] + if updates: + self.save(update_fields=updates) + if cdict.get("locks"): self.locks.add(cdict["locks"]) if cdict.get("permissions"): permissions = cdict["permissions"] + if cdict.get("tags"): + # this should be a list of tags, tuples (key, category) or (key, category, data) + self.tags.batch_add(*cdict["tags"]) + if cdict.get("attributes"): + # this should be tuples (key, val, ...) + self.attributes.batch_add(*cdict["attributes"]) + if cdict.get("nattributes"): + # this should be a dict of nattrname:value + for key, value in cdict["nattributes"]: + self.nattributes.add(key, value) del self._createdict self.permissions.batch_add(*permissions) @@ -681,6 +775,17 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): """ pass + def at_password_change(self, **kwargs): + """ + Called after a successful password set/modify. + + Args: + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + pass + def at_pre_login(self, **kwargs): """ Called every time the user logs in, just before the actual @@ -764,7 +869,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): # any was deleted in the interim. self.db._playable_characters = [char for char in self.db._playable_characters if char] self.msg(self.at_look(target=self.db._playable_characters, - session=session)) + session=session), session=session) def at_failed_login(self, session, **kwargs): """ @@ -916,7 +1021,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): result.append("\n\n |whelp|n - more commands") result.append("\n |wooc |n - talk on public channel") - charmax = _MAX_NR_CHARACTERS if _MULTISESSION_MODE > 1 else 1 + charmax = _MAX_NR_CHARACTERS if is_su or len(characters) < charmax: if not characters: diff --git a/evennia/accounts/manager.py b/evennia/accounts/manager.py index ade900ea70..e8be5e41a7 100644 --- a/evennia/accounts/manager.py +++ b/evennia/accounts/manager.py @@ -35,7 +35,6 @@ class AccountDBManager(TypedObjectManager, UserManager): get_account_from_uid get_account_from_name account_search (equivalent to evennia.search_account) - #swap_character """ diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py new file mode 100644 index 0000000000..2855dd0ca2 --- /dev/null +++ b/evennia/accounts/tests.py @@ -0,0 +1,188 @@ +from mock import Mock +from random import randint +from unittest import TestCase + +from evennia.accounts.accounts import AccountSessionHandler +from evennia.accounts.accounts import DefaultAccount +from evennia.server.session import Session +from evennia.utils import create + +from django.conf import settings + + +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.handler = AccountSessionHandler(self.account) + + def test_get(self): + "Check get method" + self.assertEqual(self.handler.get(), []) + self.assertEqual(self.handler.get(100), []) + + import evennia.server.sessionhandler + + s1 = Session() + s1.logged_in = True + s1.uid = self.account.uid + evennia.server.sessionhandler.SESSIONS[s1.uid] = s1 + + s2 = Session() + s2.logged_in = True + s2.uid = self.account.uid + 1 + evennia.server.sessionhandler.SESSIONS[s2.uid] = s2 + + s3 = Session() + 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), []) + + def test_all(self): + "Check all method" + self.assertEqual(self.handler.get(), self.handler.all()) + + def test_count(self): + "Check count method" + self.assertEqual(self.handler.count(), len(self.handler.get())) + + +class TestDefaultAccount(TestCase): + "Check DefaultAccount class" + + def setUp(self): + self.s1 = Session() + self.s1.puppet = None + self.s1.sessid = 0 + + def test_password_validation(self): + "Check password validators deny bad passwords" + + self.account = create.create_account("TestAccount%s" % randint(0, 9), + email="test@test.com", password="testpassword", typeclass=DefaultAccount) + for bad in ('', '123', 'password', 'TestAccount', '#', 'xyzzy'): + self.assertFalse(self.account.validate_password(bad, account=self.account)[0]) + + "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" + self.account = create.create_account("TestAccount%s" % randint(0, 9), + email="test@test.com", password="testpassword", typeclass=DefaultAccount) + + from django.core.exceptions import ValidationError + # Try setting some bad passwords + for bad in ('', '#', 'TestAccount', 'password'): + self.assertRaises(ValidationError, self.account.set_password, bad) + + # Try setting a better password (test for False; returns None on success) + self.assertFalse(self.account.set_password('Mxyzptlk')) + + def test_puppet_object_no_object(self): + "Check puppet_object method called with no object param" + + try: + DefaultAccount().puppet_object(self.s1, None) + self.fail("Expected error: 'Object not found'") + except RuntimeError as re: + self.assertEqual("Object not found", str(re)) + + def test_puppet_object_no_session(self): + "Check puppet_object method called with no session param" + + try: + DefaultAccount().puppet_object(None, Mock()) + self.fail("Expected error: 'Session not found'") + except RuntimeError as re: + self.assertEqual("Session not found", str(re)) + + def test_puppet_object_already_puppeting(self): + "Check puppet_object method called, already puppeting this" + + import evennia.server.sessionhandler + + 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 + + self.s1.logged_in = True + self.s1.data_out = Mock(return_value=None) + + obj = Mock() + self.s1.puppet = obj + account.puppet_object(self.s1, obj) + self.s1.data_out.assert_called_with(options=None, text="You are already puppeting this object.") + self.assertIsNone(obj.at_post_puppet.call_args) + + def test_puppet_object_no_permission(self): + "Check puppet_object method called, no permission" + + import evennia.server.sessionhandler + + 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 + + self.s1.puppet = None + self.s1.logged_in = True + self.s1.data_out = Mock(return_value=None) + + obj = Mock() + obj.access = Mock(return_value=False) + + account.puppet_object(self.s1, obj) + + 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) + + def test_puppet_object_joining_other_session(self): + "Check puppet_object method called, joining other session" + + import evennia.server.sessionhandler + + 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 + + self.s1.puppet = None + self.s1.logged_in = True + self.s1.data_out = Mock(return_value=None) + + obj = Mock() + obj.access = Mock(return_value=True) + obj.account = account + + 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(obj.at_post_puppet.call_args[1] == {}) + + def test_puppet_object_already_puppeted(self): + "Check puppet_object method called, already puppeted" + + import evennia.server.sessionhandler + + 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 + + self.s1.puppet = None + self.s1.logged_in = True + self.s1.data_out = Mock(return_value=None) + + obj = Mock() + obj.access = Mock(return_value=True) + obj.account = Mock() + obj.at_post_puppet = Mock() + + 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) diff --git a/evennia/commands/cmdset.py b/evennia/commands/cmdset.py index c170d40a36..95bbf9da22 100644 --- a/evennia/commands/cmdset.py +++ b/evennia/commands/cmdset.py @@ -296,9 +296,9 @@ class CmdSet(with_metaclass(_CmdSetMeta, object)): result (any): An instantiated Command or the input unmodified. """ - try: + if callable(cmd): return cmd() - except TypeError: + else: return cmd def _duplicate(self): diff --git a/evennia/commands/cmdsethandler.py b/evennia/commands/cmdsethandler.py index 20b869dc39..d332c7fcd2 100644 --- a/evennia/commands/cmdsethandler.py +++ b/evennia/commands/cmdsethandler.py @@ -586,11 +586,9 @@ class CmdSetHandler(object): """ if callable(cmdset) and hasattr(cmdset, 'path'): # try it as a callable - print("Try callable", cmdset) if must_be_default: return self.cmdset_stack and (self.cmdset_stack[0].path == cmdset.path) else: - print([cset.path for cset in self.cmdset_stack], cmdset.path) return any([cset for cset in self.cmdset_stack if cset.path == cmdset.path]) else: diff --git a/evennia/commands/command.py b/evennia/commands/command.py index ab4fb90438..2b4febb914 100644 --- a/evennia/commands/command.py +++ b/evennia/commands/command.py @@ -310,7 +310,7 @@ class Command(with_metaclass(CommandMeta, object)): Args: srcobj (Object): Object trying to gain permission access_type (str, optional): The lock type to check. - default (bool, optional): The fallbacl result if no lock + default (bool, optional): The fallback result if no lock of matching `access_type` is found on this Command. """ diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index 73078ce468..4446d713f9 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -137,7 +137,7 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS): key = self.lhs desc = self.rhs - charmax = _MAX_NR_CHARACTERS if _MULTISESSION_MODE > 1 else 1 + charmax = _MAX_NR_CHARACTERS if not account.is_superuser and \ (account.db._playable_characters and @@ -456,7 +456,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS): Usage: @option[/save] [name = value] - Switch: + Switches: save - Save the current option settings for future logins. clear - Clear the saved options. @@ -468,6 +468,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS): """ key = "@option" aliases = "@options" + switch_options = ("save", "clear") locks = "cmd:all()" # this is used by the parent @@ -550,8 +551,11 @@ class CmdOption(COMMAND_DEFAULT_CLASS): try: old_val = flags.get(new_name, False) new_val = validator(new_val) - flags[new_name] = new_val - self.msg("Option |w%s|n was changed from '|w%s|n' to '|w%s|n'." % (new_name, old_val, new_val)) + if old_val == new_val: + self.msg("Option |w%s|n was kept as '|w%s|n'." % (new_name, old_val)) + else: + flags[new_name] = new_val + self.msg("Option |w%s|n was changed from '|w%s|n' to '|w%s|n'." % (new_name, old_val, new_val)) return {new_name: new_val} except Exception as err: self.msg("|rCould not set option |w%s|r:|n %s" % (new_name, err)) @@ -573,7 +577,8 @@ class CmdOption(COMMAND_DEFAULT_CLASS): "TERM": utils.to_str, "UTF-8": validate_bool, "XTERM256": validate_bool, - "INPUTDEBUG": validate_bool} + "INPUTDEBUG": validate_bool, + "FORCEDENDLINE": validate_bool} name = self.lhs.upper() val = self.rhs.strip() @@ -623,10 +628,16 @@ class CmdPassword(COMMAND_DEFAULT_CLASS): return oldpass = self.lhslist[0] # Both of these are newpass = self.rhslist[0] # already stripped by parse() + + # Validate password + validated, error = account.validate_password(newpass) + if not account.check_password(oldpass): self.msg("The specified old password isn't correct.") - elif len(newpass) < 3: - self.msg("Passwords must be at least three characters long.") + elif not validated: + errors = [e for suberror in error.messages for e in error.messages] + string = "\n".join(errors) + self.msg(string) else: account.set_password(newpass) account.save() @@ -647,6 +658,7 @@ class CmdQuit(COMMAND_DEFAULT_CLASS): game. Use the /all switch to disconnect from all sessions. """ key = "@quit" + switch_options = ("all",) locks = "cmd:all()" # this is used by the parent diff --git a/evennia/commands/default/admin.py b/evennia/commands/default/admin.py index bd6e55adaa..fc90277127 100644 --- a/evennia/commands/default/admin.py +++ b/evennia/commands/default/admin.py @@ -36,6 +36,7 @@ class CmdBoot(COMMAND_DEFAULT_CLASS): """ key = "@boot" + switch_options = ("quiet", "sid") locks = "cmd:perm(boot) or perm(Admin)" help_category = "Admin" @@ -265,6 +266,7 @@ class CmdDelAccount(COMMAND_DEFAULT_CLASS): """ key = "@delaccount" + switch_options = ("delobj",) locks = "cmd:perm(delaccount) or perm(Developer)" help_category = "Admin" @@ -301,7 +303,7 @@ class CmdDelAccount(COMMAND_DEFAULT_CLASS): # one single match - account = accounts.pop() + account = accounts.first() if not account.access(caller, 'delete'): string = "You don't have the permissions to delete that account." @@ -329,9 +331,9 @@ class CmdEmit(COMMAND_DEFAULT_CLASS): @pemit [, , ... =] Switches: - room : limit emits to rooms only (default) - accounts : limit emits to accounts only - contents : send to the contents of matched objects too + room - limit emits to rooms only (default) + accounts - limit emits to accounts only + contents - send to the contents of matched objects too Emits a message to the selected objects or to your immediate surroundings. If the object is a room, @@ -341,6 +343,7 @@ class CmdEmit(COMMAND_DEFAULT_CLASS): """ key = "@emit" aliases = ["@pemit", "@remit"] + switch_options = ("room", "accounts", "contents") locks = "cmd:perm(emit) or perm(Builder)" help_category = "Admin" @@ -425,12 +428,23 @@ class CmdNewPassword(COMMAND_DEFAULT_CLASS): account = caller.search_account(self.lhs) if not account: return - account.set_password(self.rhs) + + newpass = self.rhs + + # Validate password + validated, error = account.validate_password(newpass) + if not validated: + errors = [e for suberror in error.messages for e in error.messages] + string = "\n".join(errors) + caller.msg(string) + return + + account.set_password(newpass) account.save() - self.msg("%s - new password set to '%s'." % (account.name, self.rhs)) + 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, - self.rhs)) + newpass)) class CmdPerm(COMMAND_DEFAULT_CLASS): @@ -442,14 +456,15 @@ class CmdPerm(COMMAND_DEFAULT_CLASS): @perm[/switch] * [= [,,...]] Switches: - del : delete the given permission from or . - account : set permission on an account (same as adding * to name) + del - delete the given permission from or . + account - set permission on an account (same as adding * to name) This command sets/clears individual permission strings on an object or account. If no permission is given, list all permissions on . """ key = "@perm" aliases = "@setperm" + switch_options = ("del", "account") locks = "cmd:perm(perm) or perm(Developer)" help_category = "Admin" @@ -544,7 +559,8 @@ class CmdWall(COMMAND_DEFAULT_CLASS): Usage: @wall - Announces a message to all connected accounts. + Announces a message to all connected sessions + including all currently unlogged in. """ key = "@wall" locks = "cmd:perm(wall) or perm(Admin)" @@ -556,5 +572,5 @@ class CmdWall(COMMAND_DEFAULT_CLASS): self.caller.msg("Usage: @wall ") return message = "%s shouts \"%s\"" % (self.caller.name, self.args) - self.msg("Announcing to all connected accounts ...") + self.msg("Announcing to all connected sessions ...") SESSIONS.announce_all(message) diff --git a/evennia/commands/default/batchprocess.py b/evennia/commands/default/batchprocess.py index f0b117a816..5970d35887 100644 --- a/evennia/commands/default/batchprocess.py +++ b/evennia/commands/default/batchprocess.py @@ -237,6 +237,7 @@ class CmdBatchCommands(_COMMAND_DEFAULT_CLASS): """ key = "@batchcommands" aliases = ["@batchcommand", "@batchcmd"] + switch_options = ("interactive",) locks = "cmd:perm(batchcommands) or perm(Developer)" help_category = "Building" @@ -347,6 +348,7 @@ class CmdBatchCode(_COMMAND_DEFAULT_CLASS): """ key = "@batchcode" aliases = ["@batchcodes"] + switch_options = ("interactive", "debug") locks = "cmd:superuser()" help_category = "Building" diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index a41bf872c1..3f5f4e50aa 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -10,9 +10,10 @@ 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 +from evennia.utils.utils import inherits_from, class_from_module, get_all_typeclasses from evennia.utils.eveditor import EvEditor -from evennia.utils.spawner import spawn +from evennia.utils.evmore import EvMore +from evennia.prototypes import spawner, prototypes as protlib, menus as olc_menus from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -26,12 +27,8 @@ __all__ = ("ObjManipCommand", "CmdSetObjAlias", "CmdCopy", "CmdLock", "CmdExamine", "CmdFind", "CmdTeleport", "CmdScript", "CmdTag", "CmdSpawn") -try: - # used by @set - from ast import literal_eval as _LITERAL_EVAL -except ImportError: - # literal_eval is not available before Python 2.6 - _LITERAL_EVAL = None +# used by @set +from ast import literal_eval as _LITERAL_EVAL # used by @find CHAR_TYPECLASS = settings.BASE_CHARACTER_TYPECLASS @@ -106,9 +103,15 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS): Usage: @alias [= [alias[,alias,alias,...]]] @alias = + @alias/category = [alias[,alias,...]: + + Switches: + category - requires ending input with :category, to store the + given aliases with the given category. Assigns aliases to an object so it can be referenced by more - than one name. Assign empty to remove all aliases from object. + than one name. Assign empty to remove all aliases from object. If + assigning a category, all aliases given will be using this category. Observe that this is not the same thing as personal aliases created with the 'nick' command! Aliases set with @alias are @@ -118,6 +121,7 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS): key = "@alias" aliases = "@setobjalias" + switch_options = ("category",) locks = "cmd:perm(setobjalias) or perm(Builder)" help_category = "Building" @@ -138,9 +142,12 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS): return if self.rhs is None: # no =, so we just list aliases on object. - aliases = obj.aliases.all() + aliases = obj.aliases.all(return_key_and_category=True) if aliases: - caller.msg("Aliases for '%s': %s" % (obj.get_display_name(caller), ", ".join(aliases))) + caller.msg("Aliases for %s: %s" % ( + obj.get_display_name(caller), + ", ".join("'%s'%s" % (alias, "" if category is None else "[category:'%s']" % category) + for (alias, category) in aliases))) else: caller.msg("No aliases exist for '%s'." % obj.get_display_name(caller)) return @@ -159,17 +166,27 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS): caller.msg("No aliases to clear.") return + category = None + if "category" in self.switches: + if ":" in self.rhs: + rhs, category = self.rhs.rsplit(':', 1) + category = category.strip() + else: + caller.msg("If specifying the /category switch, the category must be given " + "as :category at the end.") + else: + rhs = self.rhs + # merge the old and new aliases (if any) - old_aliases = obj.aliases.all() - new_aliases = [alias.strip().lower() for alias in self.rhs.split(',') - if alias.strip()] + old_aliases = obj.aliases.get(category=category, return_list=True) + new_aliases = [alias.strip().lower() for alias in rhs.split(',') if alias.strip()] # make the aliases only appear once old_aliases.extend(new_aliases) aliases = list(set(old_aliases)) # save back to object. - obj.aliases.add(aliases) + obj.aliases.add(aliases, category=category) # we need to trigger this here, since this will force # (default) Exits to rebuild their Exit commands with the new @@ -177,7 +194,8 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS): obj.at_cmdset_get(force_init=True) # report all aliases on the object - caller.msg("Alias(es) for '%s' set to %s." % (obj.get_display_name(caller), str(obj.aliases))) + caller.msg("Alias(es) for '%s' set to '%s'%s." % (obj.get_display_name(caller), + str(obj.aliases), " (category: '%s')" % category if category else "")) class CmdCopy(ObjManipCommand): @@ -198,6 +216,7 @@ class CmdCopy(ObjManipCommand): """ key = "@copy" + switch_options = ("reset",) locks = "cmd:perm(copy) or perm(Builder)" help_category = "Building" @@ -279,6 +298,7 @@ class CmdCpAttr(ObjManipCommand): If you don't supply a source object, yourself is used. """ key = "@cpattr" + switch_options = ("move",) locks = "cmd:perm(cpattr) or perm(Builder)" help_category = "Building" @@ -420,6 +440,7 @@ class CmdMvAttr(ObjManipCommand): object. If you don't supply a source object, yourself is used. """ key = "@mvattr" + switch_options = ("copy",) locks = "cmd:perm(mvattr) or perm(Builder)" help_category = "Building" @@ -468,6 +489,7 @@ class CmdCreate(ObjManipCommand): """ key = "@create" + switch_options = ("drop",) locks = "cmd:perm(create) or perm(Builder)" help_category = "Building" @@ -553,6 +575,7 @@ class CmdDesc(COMMAND_DEFAULT_CLASS): """ key = "@desc" aliases = "@describe" + switch_options = ("edit",) locks = "cmd:perm(desc) or perm(Builder)" help_category = "Building" @@ -568,6 +591,9 @@ class CmdDesc(COMMAND_DEFAULT_CLASS): if not obj: return + if not (obj.access(self.caller, 'control') or obj.access(self.caller, 'edit')): + self.caller.msg("You don't have permission to edit the description of %s." % obj.key) + self.caller.db.evmenu_target = obj # launch the editor EvEditor(self.caller, loadfunc=_desc_load, savefunc=_desc_save, @@ -597,7 +623,7 @@ class CmdDesc(COMMAND_DEFAULT_CLASS): if not obj: return desc = self.args - if obj.access(caller, "edit"): + if (obj.access(self.caller, 'control') or obj.access(self.caller, 'edit')): obj.db.desc = desc caller.msg("The description was set on %s." % obj.get_display_name(caller)) else: @@ -611,11 +637,11 @@ class CmdDestroy(COMMAND_DEFAULT_CLASS): Usage: @destroy[/switches] [obj, obj2, obj3, [dbref-dbref], ...] - switches: + Switches: override - The @destroy command will usually avoid accidentally destroying account objects. This switch overrides this safety. force - destroy without confirmation. - examples: + Examples: @destroy house, roof, door, 44-78 @destroy 5-10, flower, 45 @destroy/force north @@ -628,6 +654,7 @@ class CmdDestroy(COMMAND_DEFAULT_CLASS): key = "@destroy" aliases = ["@delete", "@del"] + switch_options = ("override", "force") locks = "cmd:perm(destroy) or perm(Builder)" help_category = "Building" @@ -751,6 +778,7 @@ class CmdDig(ObjManipCommand): would be 'north;no;n'. """ key = "@dig" + switch_options = ("teleport",) locks = "cmd:perm(dig) or perm(Builder)" help_category = "Building" @@ -860,7 +888,7 @@ class CmdDig(ObjManipCommand): new_back_exit.dbref, alias_string) caller.msg("%s%s%s" % (room_string, exit_to_string, exit_back_string)) - if new_room and ('teleport' in self.switches or "tel" in self.switches): + if new_room and 'teleport' in self.switches: caller.move_to(new_room) @@ -893,6 +921,7 @@ class CmdTunnel(COMMAND_DEFAULT_CLASS): key = "@tunnel" aliases = ["@tun"] + switch_options = ("oneway", "tel") locks = "cmd: perm(tunnel) or perm(Builder)" help_category = "Building" @@ -1087,7 +1116,7 @@ class CmdSetHome(CmdLink): set an object's home location Usage: - @home [= ] + @sethome [= ] The "home" location is a "safety" location for objects; they will be moved there if their current location ceases to exist. All @@ -1098,13 +1127,13 @@ class CmdSetHome(CmdLink): """ key = "@sethome" - locks = "cmd:perm(@home) or perm(Builder)" + locks = "cmd:perm(@sethome) or perm(Builder)" help_category = "Building" def func(self): """implement the command""" if not self.args: - string = "Usage: @home [= ]" + string = "Usage: @sethome [= ]" self.caller.msg(string) return @@ -1426,17 +1455,16 @@ def _convert_from_string(cmd, strobj): # if nothing matches, return as-is return obj - if _LITERAL_EVAL: - # Use literal_eval to parse python structure exactly. - try: - return _LITERAL_EVAL(strobj) - except (SyntaxError, ValueError): - # treat as string - strobj = utils.to_str(strobj) - string = "|RNote: name \"|r%s|R\" was converted to a string. " \ - "Make sure this is acceptable." % strobj - cmd.caller.msg(string) - return strobj + # Use literal_eval to parse python structure exactly. + try: + return _LITERAL_EVAL(strobj) + except (SyntaxError, ValueError): + # treat as string + strobj = utils.to_str(strobj) + string = "|RNote: name \"|r%s|R\" was converted to a string. " \ + "Make sure this is acceptable." % strobj + cmd.caller.msg(string) + return strobj else: # fall back to old recursive solution (does not support # nested lists/dicts) @@ -1455,6 +1483,13 @@ class CmdSetAttribute(ObjManipCommand): Switch: edit: Open the line editor (string values only) + script: If we're trying to set an attribute on a script + channel: If we're trying to set an attribute on a channel + account: If we're trying to set an attribute on an account + room: Setting an attribute on a room (global search) + exit: Setting an attribute on an exit (global search) + char: Setting an attribute on a character (global search) + character: Alias for char, as above. Sets attributes on objects. The second form clears a previously set attribute while the last form @@ -1555,6 +1590,38 @@ class CmdSetAttribute(ObjManipCommand): # start the editor EvEditor(self.caller, load, save, key="%s/%s" % (obj, attr)) + def search_for_obj(self, objname): + """ + Searches for an object matching objname. The object may be of different typeclasses. + Args: + objname: Name of the object we're looking for + + Returns: + A typeclassed object, or None if nothing is found. + """ + from evennia.utils.utils import variable_from_module + _AT_SEARCH_RESULT = variable_from_module(*settings.SEARCH_AT_RESULT.rsplit('.', 1)) + caller = self.caller + if objname.startswith('*') or "account" in self.switches: + found_obj = caller.search_account(objname.lstrip('*')) + elif "script" in self.switches: + found_obj = _AT_SEARCH_RESULT(search.search_script(objname), caller) + elif "channel" in self.switches: + found_obj = _AT_SEARCH_RESULT(search.search_channel(objname), caller) + else: + global_search = True + if "char" in self.switches or "character" in self.switches: + typeclass = settings.BASE_CHARACTER_TYPECLASS + elif "room" in self.switches: + typeclass = settings.BASE_ROOM_TYPECLASS + elif "exit" in self.switches: + typeclass = settings.BASE_EXIT_TYPECLASS + else: + global_search = False + typeclass = None + found_obj = caller.search(objname, global_search=global_search, typeclass=typeclass) + return found_obj + def func(self): """Implement the set attribute - a limited form of @py.""" @@ -1568,10 +1635,7 @@ class CmdSetAttribute(ObjManipCommand): objname = self.lhs_objattr[0]['name'] attrs = self.lhs_objattr[0]['attrs'] - if objname.startswith('*'): - obj = caller.search_account(objname.lstrip('*')) - else: - obj = caller.search(objname) + obj = self.search_for_obj(objname) if not obj: return @@ -1581,6 +1645,10 @@ class CmdSetAttribute(ObjManipCommand): result = [] if "edit" in self.switches: # edit in the line editor + if not (obj.access(self.caller, 'control') or obj.access(self.caller, 'edit')): + caller.msg("You don't have permission to edit %s." % obj.key) + return + if len(attrs) > 1: caller.msg("The Line editor can only be applied " "to one attribute at a time.") @@ -1601,12 +1669,18 @@ class CmdSetAttribute(ObjManipCommand): return else: # deleting the attribute(s) + if not (obj.access(self.caller, 'control') or obj.access(self.caller, 'edit')): + caller.msg("You don't have permission to edit %s." % obj.key) + return for attr in attrs: if not self.check_attr(obj, attr): continue result.append(self.rm_attr(obj, attr)) else: # setting attribute(s). Make sure to convert to real Python type before saving. + if not (obj.access(self.caller, 'control') or obj.access(self.caller, 'edit')): + caller.msg("You don't have permission to edit %s." % obj.key) + return for attr in attrs: if not self.check_attr(obj, attr): continue @@ -1624,17 +1698,22 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): @typeclass[/switch] [= typeclass.path] @type '' @parent '' + @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. Switch: - show - display the current typeclass of object (default) + show, examine - display the current typeclass of object (default) or, if + given a typeclass path, show the docstring of that typeclass. update - *only* re-run at_object_creation on this object meaning locks or other properties set later may remain. reset - clean out *all* the attributes and properties on the object - basically making this a new clean object. force - change to the typeclass also if the object already has a typeclass of the same name. + list - show available typeclasses. + + Example: @type button = examples.red_button.RedButton @@ -1658,6 +1737,7 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): key = "@typeclass" aliases = ["@type", "@parent", "@swap", "@update"] + switch_options = ("show", "examine", "update", "reset", "force", "list") locks = "cmd:perm(typeclass) or perm(Builder)" help_category = "Building" @@ -1666,10 +1746,56 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): caller = self.caller + if 'list' in self.switches: + tclasses = get_all_typeclasses() + contribs = [key for key in sorted(tclasses) + if key.startswith("evennia.contrib")] or [""] + core = [key for key in sorted(tclasses) + if key.startswith("evennia") and key not in contribs] or [""] + game = [key for key in sorted(tclasses) + if not key.startswith("evennia")] or [""] + string = ("|wCore typeclasses|n\n" + " {core}\n" + "|wLoaded Contrib typeclasses|n\n" + " {contrib}\n" + "|wGame-dir typeclasses|n\n" + " {game}").format(core="\n ".join(core), + contrib="\n ".join(contribs), + game="\n ".join(game)) + EvMore(caller, string, exit_on_lastpage=True) + return + if not self.args: caller.msg("Usage: %s [= typeclass]" % self.cmdstring) return + if "show" in self.switches or "examine" in self.switches: + oquery = self.lhs + obj = caller.search(oquery, quiet=True) + if not obj: + # no object found to examine, see if it's a typeclass-path instead + tclasses = get_all_typeclasses() + matches = [(key, tclass) + for key, tclass in tclasses.items() if key.endswith(oquery)] + nmatches = len(matches) + if nmatches > 1: + caller.msg("Multiple typeclasses found matching {}:\n {}".format( + oquery, "\n ".join(tup[0] for tup in matches))) + elif not matches: + caller.msg("No object or typeclass path found to match '{}'".format(oquery)) + else: + # one match found + caller.msg("Docstring for typeclass '{}':\n{}".format( + oquery, matches[0][1].__doc__)) + else: + # do the search again to get the error handling in case of multi-match + obj = caller.search(oquery) + if not obj: + return + caller.msg("{}'s current typeclass is '{}.{}'".format( + obj.name, obj.__class__.__module__, obj.__class__.__name__)) + return + # get object to swap on obj = caller.search(self.lhs) if not obj: @@ -1682,7 +1808,7 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): new_typeclass = self.rhs or obj.path - if "show" in self.switches: + if "show" in self.switches or "examine" in self.switches: string = "%s's current typeclass is %s." % (obj.name, obj.__class__) caller.msg(string) return @@ -1807,13 +1933,13 @@ class CmdLock(ObjManipCommand): For example: 'get: id(25) or perm(Admin)' - The 'get' access_type is checked by the get command and will - an object locked with this string will only be possible to - pick up by Admins or by object with id=25. + The 'get' lock access_type is checked e.g. by the 'get' command. + An object locked with this example lock will only be possible to pick up + by Admins or by an object with id=25. You can add several access_types after one another by separating them by ';', i.e: - 'get:id(25);delete:perm(Builder)' + 'get:id(25); delete:perm(Builder)' """ key = "@lock" aliases = ["@locks"] @@ -1840,9 +1966,16 @@ class CmdLock(ObjManipCommand): obj = caller.search(objname) if not obj: return - if not (obj.access(caller, 'control') or obj.access(caller, "edit")): + has_control_access = obj.access(caller, 'control') + if access_type == 'control' and not has_control_access: + # only allow to change 'control' access if you have 'control' access already + caller.msg("You need 'control' access to change this type of lock.") + return + + if not has_control_access or obj.access(caller, "edit"): caller.msg("You are not allowed to do that.") return + lockdef = obj.locks.get(access_type) if lockdef: @@ -2093,12 +2226,15 @@ class CmdExamine(ObjManipCommand): else: things.append(content) if exits: - string += "\n|wExits|n: %s" % ", ".join(["%s(%s)" % (exit.name, exit.dbref) for exit in exits]) + string += "\n|wExits|n: %s" % ", ".join( + ["%s(%s)" % (exit.name, exit.dbref) for exit in exits]) if pobjs: - string += "\n|wCharacters|n: %s" % ", ".join(["|c%s|n(%s)" % (pobj.name, pobj.dbref) for pobj in pobjs]) + string += "\n|wCharacters|n: %s" % ", ".join( + ["|c%s|n(%s)" % (pobj.name, pobj.dbref) for pobj in pobjs]) if things: - string += "\n|wContents|n: %s" % ", ".join(["%s(%s)" % (cont.name, cont.dbref) for cont in obj.contents - if cont not in exits and cont not in pobjs]) + string += "\n|wContents|n: %s" % ", ".join( + ["%s(%s)" % (cont.name, cont.dbref) for cont in obj.contents + if cont not in exits and cont not in pobjs]) separator = "-" * _DEFAULT_WIDTH # output info return '%s\n%s\n%s' % (separator, string.strip(), separator) @@ -2181,12 +2317,15 @@ class CmdFind(COMMAND_DEFAULT_CLASS): Usage: @find[/switches] [= dbrefmin[-dbrefmax]] + @locate - this is a shorthand for using the /loc switch. Switches: - room - only look for rooms (location=None) - exit - only look for exits (destination!=None) - char - only look for characters (BASE_CHARACTER_TYPECLASS) - exact- only exact matches are returned. + room - only look for rooms (location=None) + exit - only look for exits (destination!=None) + char - only look for characters (BASE_CHARACTER_TYPECLASS) + exact - only exact matches are returned. + loc - display object location if exists and match has one result + startswith - search for names starting with the string, rather than containing Searches the database for an object of a particular name or exact #dbref. Use *accountname to search for an account. The switches allows for @@ -2197,6 +2336,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS): key = "@find" aliases = "@search, @locate" + switch_options = ("room", "exit", "char", "exact", "loc", "startswith") locks = "cmd:perm(find) or perm(Builder)" help_category = "Building" @@ -2209,6 +2349,9 @@ class CmdFind(COMMAND_DEFAULT_CLASS): caller.msg("Usage: @find [= low [-high]]") return + if "locate" in self.cmdstring: # Use option /loc as a default for @locate command alias + switches.append('loc') + searchstring = self.lhs low, high = 1, ObjectDB.objects.all().order_by("-id")[0].id if self.rhs: @@ -2230,7 +2373,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS): restrictions = "" if self.switches: - restrictions = ", %s" % (",".join(self.switches)) + restrictions = ", %s" % (", ".join(self.switches)) if is_dbref or is_account: @@ -2258,6 +2401,8 @@ class CmdFind(COMMAND_DEFAULT_CLASS): else: result = result[0] string += "\n|g %s - %s|n" % (result.get_display_name(caller), result.path) + if "loc" in self.switches and not is_account and result.location: + string += " (|wlocation|n: |g{}|n)".format(result.location.get_display_name(caller)) else: # Not an account/dbref search but a wider search; build a queryset. # Searchs for key and aliases @@ -2265,10 +2410,14 @@ class CmdFind(COMMAND_DEFAULT_CLASS): keyquery = Q(db_key__iexact=searchstring, id__gte=low, id__lte=high) aliasquery = Q(db_tags__db_key__iexact=searchstring, db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high) - else: + elif "startswith" in switches: keyquery = Q(db_key__istartswith=searchstring, id__gte=low, id__lte=high) aliasquery = Q(db_tags__db_key__istartswith=searchstring, db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high) + else: + keyquery = Q(db_key__icontains=searchstring, id__gte=low, id__lte=high) + aliasquery = Q(db_tags__db_key__icontains=searchstring, + db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high) results = ObjectDB.objects.filter(keyquery | aliasquery).distinct() nresults = results.count() @@ -2293,6 +2442,8 @@ class CmdFind(COMMAND_DEFAULT_CLASS): else: string = "|wOne Match|n(#%i-#%i%s):" % (low, high, restrictions) string += "\n |g%s - %s|n" % (results[0].get_display_name(caller), results[0].path) + if "loc" in self.switches and nresults == 1 and results[0].location: + string += " (|wlocation|n: |g{}|n)".format(results[0].location.get_display_name(caller)) else: string = "|wMatch|n(#%i-#%i%s):" % (low, high, restrictions) string += "\n |RNo matches found for '%s'|n" % searchstring @@ -2306,11 +2457,11 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS): teleport object to another location Usage: - @tel/switch [ =] + @tel/switch [ to||=] Examples: @tel Limbo - @tel/quiet box Limbo + @tel/quiet box = Limbo @tel/tonone box Switches: @@ -2326,9 +2477,12 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS): 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. """ + is teleported to the target location. + """ key = "@tel" aliases = "@teleport" + switch_options = ("quiet", "intoexit", "tonone", "loc") + rhs_split = ("=", " to ") # Prefer = delimiter, but allow " to " usage. locks = "cmd:perm(teleport) or perm(Builder)" help_category = "Building" @@ -2436,6 +2590,7 @@ class CmdScript(COMMAND_DEFAULT_CLASS): key = "@script" aliases = "@addscript" + switch_options = ("start", "stop") locks = "cmd:perm(script) or perm(Builder)" help_category = "Building" @@ -2535,6 +2690,7 @@ class CmdTag(COMMAND_DEFAULT_CLASS): key = "@tag" aliases = ["@tags"] + options = ("search", "del") locks = "cmd:perm(tag) or perm(Builder)" help_category = "Building" arg_regex = r"(/\w+?(\s|$))|\s|$" @@ -2632,100 +2788,312 @@ class CmdTag(COMMAND_DEFAULT_CLASS): string = "No tags attached to %s." % obj self.caller.msg(string) -# -# To use the prototypes with the @spawn function set -# PROTOTYPE_MODULES = ["commands.prototypes"] -# Reload the server and the prototypes should be available. -# - class CmdSpawn(COMMAND_DEFAULT_CLASS): """ spawn objects from prototype Usage: - @spawn - @spawn[/switch] - @spawn[/switch] {prototype dictionary} + @spawn[/noloc] + @spawn[/noloc] - Switch: + @spawn/search [prototype_keykey][;tag[,tag]] + @spawn/list [tag, tag, ...] + @spawn/show [] + @spawn/update + + @spawn/save + @spawn/edit [] + @olc - equivalent to @spawn/edit + + Switches: noloc - allow location to be None if not specified explicitly. Otherwise, location will default to caller's current location. + search - search prototype by name or tags. + list - list available prototypes, optionally limit by tags. + show, examine - inspect prototype by key. If not given, acts like list. + save - save a prototype to the database. It will be listable by /list. + delete - remove a prototype from database, if allowed to. + update - find existing objects with the same prototype_key and update + them with latest version of given prototype. If given with /save, + will auto-update all objects with the old version of the prototype + without asking first. + edit, olc - create/manipulate prototype in a menu interface. Example: @spawn GOBLIN @spawn {"key":"goblin", "typeclass":"monster.Monster", "location":"#2"} + @spawn/save {"key": "grunt", prototype: "goblin"};;mobs;edit:all() Dictionary keys: - |wprototype |n - name of parent prototype to use. Can be a list for - multiple inheritance (inherits left to right) + |wprototype_parent |n - name of parent prototype to use. Required if typeclass is + not set. Can be a path or a list for multiple inheritance (inherits + left to right). If set one of the parents must have a typeclass. + |wtypeclass |n - string. Required if prototype_parent is not set. |wkey |n - string, the main object identifier - |wtypeclass |n - string, if not set, will use settings.BASE_OBJECT_TYPECLASS |wlocation |n - this should be a valid object or #dbref |whome |n - valid object or #dbref |wdestination|n - only valid for exits (object or dbref) |wpermissions|n - string or list of permission strings |wlocks |n - a lock-string - |waliases |n - string or list of strings + |waliases |n - string or list of strings. |wndb_|n - value of a nattribute (ndb_ is stripped) + + |wprototype_key|n - name of this prototype. Unique. Used to store/retrieve from db + and update existing prototyped objects if desired. + |wprototype_desc|n - desc of this prototype. Used in listings + |wprototype_locks|n - locks of this prototype. Limits who may use prototype + |wprototype_tags|n - tags of this prototype. Used to find prototype + any other keywords are interpreted as Attributes and their values. The available prototypes are defined globally in modules set in settings.PROTOTYPE_MODULES. If @spawn is used without arguments it displays a list of available prototypes. + """ key = "@spawn" + aliases = ["olc"] + switch_options = ("noloc", "search", "list", "show", "save", "delete", "menu", "olc", "update") locks = "cmd:perm(spawn) or perm(Builder)" help_category = "Building" def func(self): """Implements the spawner""" - def _show_prototypes(prototypes): - """Helper to show a list of available prototypes""" - prots = ", ".join(sorted(prototypes.keys())) - return "\nAvailable prototypes (case sensistive): %s" % ( - "\n" + utils.fill(prots) if prots else "None") + def _parse_prototype(inp, expect=dict): + err = None + try: + prototype = _LITERAL_EVAL(inp) + except (SyntaxError, ValueError) as err: + # treat as string + prototype = utils.to_str(inp) + finally: + if not isinstance(prototype, expect): + if err: + string = ("{}\n|RCritical Python syntax error in argument. Only primitive " + "Python structures are allowed. \nYou also need to use correct " + "Python syntax. Remember especially to put quotes around all " + "strings inside lists and dicts.|n For more advanced uses, embed " + "inline functions in the strings.".format(err)) + else: + string = "Expected {}, got {}.".format(expect, type(prototype)) + self.caller.msg(string) + return None + if expect == dict: + # an actual prototype. We need to make sure it's safe. Don't allow exec + if "exec" in prototype and not self.caller.check_permstring("Developer"): + self.caller.msg("Spawn aborted: You are not allowed to " + "use the 'exec' prototype key.") + return None + try: + protlib.validate_prototype(prototype) + except RuntimeError as err: + self.caller.msg(str(err)) + return + return prototype - prototypes = spawn(return_prototypes=True) - if not self.args: - string = "Usage: @spawn {key:value, key, value, ... }" - self.caller.msg(string + _show_prototypes(prototypes)) - return - try: - # make use of _convert_from_string from the SetAttribute command - prototype = _convert_from_string(self, self.args) - except SyntaxError: - # this means literal_eval tried to parse a faulty string - string = "|RCritical Python syntax error in argument. " - string += "Only primitive Python structures are allowed. " - string += "\nYou also need to use correct Python syntax. " - string += "Remember especially to put quotes around all " - string += "strings inside lists and dicts.|n" - self.caller.msg(string) + def _search_show_prototype(query, prototypes=None): + # prototype detail + if not prototypes: + prototypes = protlib.search_prototype(key=query) + if prototypes: + return "\n".join(protlib.prototype_to_str(prot) for prot in prototypes) + else: + return False + + caller = self.caller + + if self.cmdstring == "olc" or 'menu' in self.switches or 'olc' in self.switches: + # OLC menu mode + prototype = None + if self.lhs: + key = self.lhs + prototype = spawner.search_prototype(key=key, return_meta=True) + 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] + olc_menus.start_olc(caller, session=self.session, prototype=prototype) return - if isinstance(prototype, str): - # A prototype key - keystr = prototype - prototype = prototypes.get(prototype, None) + if 'search' in self.switches: + # query for a key match + if not self.args: + self.switches.append("list") + else: + key, tags = self.args.strip(), None + if ';' in self.args: + key, tags = (part.strip().lower() for part in self.args.split(";", 1)) + tags = [tag.strip() for tag in tags.split(",")] if tags else None + EvMore(caller, unicode(protlib.list_prototypes(caller, key=key, tags=tags)), + exit_on_lastpage=True) + return + + if 'show' in self.switches or 'examine' in self.switches: + # the argument is a key in this case (may be a partial key) + if not self.args: + self.switches.append('list') + else: + matchstring = _search_show_prototype(self.args) + if matchstring: + caller.msg(matchstring) + else: + caller.msg("No prototype '{}' was found.".format(self.args)) + return + + if 'list' in self.switches: + # for list, all optional arguments are tags + # import pudb; pudb.set_trace() + + EvMore(caller, str(protlib.list_prototypes(caller, + tags=self.lhslist)), exit_on_lastpage=True) + return + + if 'save' in self.switches: + # store a prototype to the database store + if not self.args: + caller.msg( + "Usage: @spawn/save [;desc[;tag,tag[,...][;lockstring]]] = ") + return + + # handle rhs: + prototype = _parse_prototype(self.lhs.strip()) if not prototype: - string = "No prototype named '%s'." % keystr - self.caller.msg(string + _show_prototypes(prototypes)) return - elif isinstance(prototype, dict): - # we got the prototype on the command line. We must make sure to not allow - # the 'exec' key unless we are developers or higher. - if "exec" in prototype and not self.caller.check_permstring("Developer"): - self.caller.msg("Spawn aborted: You don't have access to use the 'exec' prototype key.") + + # present prototype to save + new_matchstring = _search_show_prototype("", prototypes=[prototype]) + string = "|yCreating new prototype:|n\n{}".format(new_matchstring) + question = "\nDo you want to continue saving? [Y]/N" + + prototype_key = prototype.get("prototype_key") + if not prototype_key: + caller.msg("\n|yTo save a prototype it must have the 'prototype_key' set.") return - else: - self.caller.msg("The prototype must be a prototype key or a Python dictionary.") + + # check for existing prototype, + old_matchstring = _search_show_prototype(prototype_key) + + if old_matchstring: + string += "\n|yExisting saved prototype found:|n\n{}".format(old_matchstring) + question = "\n|yDo you want to replace the existing prototype?|n [Y]/N" + + answer = yield(string + question) + if answer.lower() in ["n", "no"]: + caller.msg("|rSave cancelled.|n") + return + + # all seems ok. Try to save. + try: + prot = protlib.save_prototype(**prototype) + if not prot: + caller.msg("|rError saving:|R {}.|n".format(prototype_key)) + return + except protlib.PermissionError as err: + caller.msg("|rError saving:|R {}|n".format(err)) + return + caller.msg("|gSaved prototype:|n {}".format(prototype_key)) + + # check if we want to update existing objects + existing_objects = protlib.search_objects_with_prototype(prototype_key) + if existing_objects: + if 'update' not in self.switches: + n_existing = len(existing_objects) + slow = " (note that this may be slow)" if n_existing > 10 else "" + string = ("There are {} objects already created with an older version " + "of prototype {}. Should it be re-applied to them{}? [Y]/N".format( + n_existing, prototype_key, slow)) + answer = yield(string) + if answer.lower() in ["n", "no"]: + caller.msg("|rNo update was done of existing objects. " + "Use @spawn/update to apply later as needed.|n") + return + n_updated = spawner.batch_update_objects_with_prototype(existing_objects, key) + caller.msg("{} objects were updated.".format(n_updated)) return + if not self.args: + ncount = len(protlib.search_prototype()) + caller.msg("Usage: @spawn or {{key: value, ...}}" + "\n ({} existing prototypes. Use /list to inspect)".format(ncount)) + return + + if 'delete' in self.switches: + # remove db-based prototype + matchstring = _search_show_prototype(self.args) + if matchstring: + string = "|rDeleting prototype:|n\n{}".format(matchstring) + question = "\nDo you want to continue deleting? [Y]/N" + answer = yield(string + question) + if answer.lower() in ["n", "no"]: + caller.msg("|rDeletion cancelled.|n") + return + try: + success = protlib.delete_db_prototype(caller, self.args) + except protlib.PermissionError as err: + caller.msg("|rError deleting:|R {}|n".format(err)) + caller.msg("Deletion {}.".format( + 'successful' if success else 'failed (does the prototype exist?)')) + return + else: + caller.msg("Could not find prototype '{}'".format(key)) + + if 'update' in self.switches: + # update existing prototypes + key = self.args.strip().lower() + existing_objects = protlib.search_objects_with_prototype(key) + if existing_objects: + n_existing = len(existing_objects) + slow = " (note that this may be slow)" if n_existing > 10 else "" + string = ("There are {} objects already created with an older version " + "of prototype {}. Should it be re-applied to them{}? [Y]/N".format( + n_existing, key, slow)) + answer = yield(string) + if answer.lower() in ["n", "no"]: + caller.msg("|rUpdate cancelled.") + return + n_updated = spawner.batch_update_objects_with_prototype(existing_objects, key) + caller.msg("{} objects were updated.".format(n_updated)) + + # A direct creation of an object from a given prototype + + prototype = _parse_prototype( + self.args, expect=dict if self.args.strip().startswith("{") else basestring) + if not prototype: + # this will only let through dicts or strings + return + + key = '' + if isinstance(prototype, basestring): + # A prototype key we are looking to apply + key = prototype + prototypes = protlib.search_prototype(prototype) + nprots = len(prototypes) + if not prototypes: + caller.msg("No prototype named '%s'." % prototype) + return + elif nprots > 1: + caller.msg("Found {} prototypes matching '{}':\n {}".format( + nprots, prototype, ", ".join(prot.get('prototype_key', '') + for proto in prototypes))) + return + # we have a prototype, check access + prototype = prototypes[0] + if not caller.locks.check_lockstring(caller, prototype.get('prototype_locks', ''), access_type='spawn'): + caller.msg("You don't have access to use this prototype.") + return + if "noloc" not in self.switches and "location" not in prototype: prototype["location"] = self.caller.location - for obj in spawn(prototype): - self.caller.msg("Spawned %s." % obj.get_display_name(self.caller)) + # proceed to spawning + try: + for obj in spawner.spawn(prototype): + self.caller.msg("Spawned %s." % obj.get_display_name(self.caller)) + except RuntimeError as err: + caller.msg(err) diff --git a/evennia/commands/default/cmdset_account.py b/evennia/commands/default/cmdset_account.py index 4e357a2ce0..d7b887c017 100644 --- a/evennia/commands/default/cmdset_account.py +++ b/evennia/commands/default/cmdset_account.py @@ -11,7 +11,7 @@ command method rather than caller.msg(). from evennia.commands.cmdset import CmdSet from evennia.commands.default import help, comms, admin, system -from evennia.commands.default import building, account +from evennia.commands.default import building, account, general class AccountCmdSet(CmdSet): @@ -39,6 +39,9 @@ class AccountCmdSet(CmdSet): self.add(account.CmdColorTest()) self.add(account.CmdQuell()) + # nicks + self.add(general.CmdNick()) + # testing self.add(building.CmdExamine()) diff --git a/evennia/commands/default/cmdset_unloggedin.py b/evennia/commands/default/cmdset_unloggedin.py index 5e43d5e8c8..1c45d908aa 100644 --- a/evennia/commands/default/cmdset_unloggedin.py +++ b/evennia/commands/default/cmdset_unloggedin.py @@ -23,3 +23,4 @@ class UnloggedinCmdSet(CmdSet): self.add(unloggedin.CmdUnconnectedHelp()) self.add(unloggedin.CmdUnconnectedEncoding()) self.add(unloggedin.CmdUnconnectedScreenreader()) + self.add(unloggedin.CmdUnconnectedInfo()) diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index c806a08296..5e47f700fe 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -376,7 +376,7 @@ class CmdCBoot(COMMAND_DEFAULT_CLASS): Usage: @cboot[/quiet] = [:reason] - Switches: + Switch: quiet - don't notify the channel Kicks an account or object from a channel you control. @@ -384,6 +384,7 @@ class CmdCBoot(COMMAND_DEFAULT_CLASS): """ key = "@cboot" + switch_options = ("quiet",) locks = "cmd: not pperm(channel_banned)" help_category = "Comms" @@ -416,7 +417,7 @@ class CmdCBoot(COMMAND_DEFAULT_CLASS): string = "You don't control this channel." self.msg(string) return - if account not in channel.db_subscriptions.all(): + if not channel.subscriptions.has(account): string = "Account %s is not connected to channel %s." % (account.key, channel.key) self.msg(string) return @@ -452,6 +453,7 @@ class CmdCemit(COMMAND_DEFAULT_CLASS): key = "@cemit" aliases = ["@cmsg"] + switch_options = ("sendername", "quiet") locks = "cmd: not pperm(channel_banned) and pperm(Player)" help_category = "Comms" @@ -682,6 +684,7 @@ class CmdPage(COMMAND_DEFAULT_CLASS): key = "page" aliases = ['tell'] + switch_options = ("last", "list") locks = "cmd:not pperm(page_banned)" help_category = "Comms" @@ -849,6 +852,7 @@ class CmdIRC2Chan(COMMAND_DEFAULT_CLASS): """ key = "@irc2chan" + switch_options = ("delete", "remove", "disconnect", "list", "ssl") locks = "cmd:serversetting(IRC_ENABLED) and pperm(Developer)" help_category = "Comms" @@ -1015,6 +1019,7 @@ class CmdRSS2Chan(COMMAND_DEFAULT_CLASS): """ key = "@rss2chan" + switch_options = ("disconnect", "remove", "list") locks = "cmd:serversetting(RSS_ENABLED) and pperm(Developer)" help_category = "Comms" diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index 2f880f3f7f..85fb1b4dd4 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -1,6 +1,7 @@ """ General Character commands usually available to all characters """ +import re from django.conf import settings from evennia.utils import utils, evtable from evennia.typeclasses.attributes import NickTemplateInvalid @@ -70,42 +71,45 @@ class CmdLook(COMMAND_DEFAULT_CLASS): target = caller.search(self.args) if not target: return - self.msg(caller.at_look(target)) + self.msg((caller.at_look(target), {'type': 'look'}), options=None) class CmdNick(COMMAND_DEFAULT_CLASS): """ - define a personal alias/nick + define a personal alias/nick by defining a string to + match and replace it with another on the fly Usage: nick[/switches] [= [replacement_string]] nick[/switches]