Merge with develop and fix merge conflicts

This commit is contained in:
Griatch 2018-10-01 20:58:16 +02:00
commit 72f4fedcbe
148 changed files with 20005 additions and 2718 deletions

View file

@ -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.

View file

@ -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,

View file

@ -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

View file

@ -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

34
bin/unix/evennia.service Normal file
View file

@ -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

View file

@ -1 +1 @@
0.7.0
0.8.0

View file

@ -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)

View file

@ -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 <Text>|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:

View file

@ -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
"""

188
evennia/accounts/tests.py Normal file
View file

@ -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)

View file

@ -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):

View file

@ -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:

View file

@ -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.
"""

View file

@ -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

View file

@ -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 [<obj>, <obj>, ... =] <message>
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] *<account> [= <permission>[,<permission>,...]]
Switches:
del : delete the given permission from <object> or <account>.
account : set permission on an account (same as adding * to name)
del - delete the given permission from <object> or <account>.
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 <object>.
"""
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 <message>
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 <message>")
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)

View file

@ -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"

View file

@ -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 <obj> [= [alias[,alias,alias,...]]]
@alias <obj> =
@alias/category <obj> = [alias[,alias,...]:<category>
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 <obj> [= <home_location>]
@sethome <obj> [= <home_location>]
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 <obj> [= <home_location>]"
string = "Usage: @sethome <obj> [= <home_location>]"
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] <object> [= 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 ["<None loaded>"]
core = [key for key in sorted(tclasses)
if key.startswith("evennia") and key not in contribs] or ["<None loaded>"]
game = [key for key in sorted(tclasses)
if not key.startswith("evennia")] or ["<None loaded>"]
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 <object> [= 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] <name or dbref or *account> [= 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 <string> [= 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 [<object> =] <target location>
@tel/switch [<object> to||=] <target location>
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] <prototype_name>
@spawn[/switch] {prototype dictionary}
@spawn[/noloc] <prototype_key>
@spawn[/noloc] <prototype_dict>
Switch:
@spawn/search [prototype_keykey][;tag[,tag]]
@spawn/list [tag, tag, ...]
@spawn/show [<prototype_key>]
@spawn/update <prototype_key>
@spawn/save <prototype_dict>
@spawn/edit [<prototype_key>]
@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<name> - 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 <key>[;desc[;tag,tag[,...][;lockstring]]] = <prototype_dict>")
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 <key> 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 <prototype-key> 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 = '<unnamed>'
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)

View file

@ -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())

View file

@ -23,3 +23,4 @@ class UnloggedinCmdSet(CmdSet):
self.add(unloggedin.CmdUnconnectedHelp())
self.add(unloggedin.CmdUnconnectedEncoding())
self.add(unloggedin.CmdUnconnectedScreenreader())
self.add(unloggedin.CmdUnconnectedInfo())

View file

@ -376,7 +376,7 @@ class CmdCBoot(COMMAND_DEFAULT_CLASS):
Usage:
@cboot[/quiet] <channel> = <account> [: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"

View file

@ -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] <string> [= [replacement_string]]
nick[/switches] <template> = <replacement_template>
nick/delete <string> or number
nick/test <test string>
nicks
Switches:
inputline - replace on the inputline (default)
object - replace on object-lookup
account - replace on account-lookup
delete - remove nick by name or by index given by /list
clearall - clear all nicks
account - replace on account-lookup
list - show all defined aliases (also "nicks" works)
test - test input to see what it matches with
delete - remove nick by index in /list
clearall - clear all nicks
Examples:
nick hi = say Hello, I'm Sarah!
nick/object tom = the tall man
nick build $1 $2 = @create/drop $1;$2 - (template)
nick tell $1 $2=@page $1=$2 - (template)
nick build $1 $2 = @create/drop $1;$2
nick tell $1 $2=@page $1=$2
nick tm?$1=@page tallman=$1
nick tm\=$1=@page tallman=$1
A 'nick' is a personal string replacement. Use $1, $2, ... to catch arguments.
Put the last $-marker without an ending space to catch all remaining text. You
can also use unix-glob matching:
can also use unix-glob matching for the left-hand side <string>:
* - matches everything
? - matches a single character
[seq] - matches all chars in sequence
[!seq] - matches everything not in sequence
? - matches 0 or 1 single characters
[abcd] - matches these chars in any order
[!abcd] - matches everything not among these chars
\= - escape literal '=' you want in your <string>
Note that no objects are actually renamed or changed by this command - your nicks
are only available to you. If you want to permanently add keywords to an object
@ -113,17 +117,40 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
"""
key = "nick"
aliases = ["nickname", "nicks", "alias"]
switch_options = ("inputline", "object", "account", "list", "delete", "clearall")
aliases = ["nickname", "nicks"]
locks = "cmd:all()"
def parse(self):
"""
Support escaping of = with \=
"""
super(CmdNick, self).parse()
args = (self.lhs or "") + (" = %s" % self.rhs if self.rhs else "")
parts = re.split(r"(?<!\\)=", args, 1)
self.rhs = None
if len(parts) < 2:
self.lhs = parts[0].strip()
else:
self.lhs, self.rhs = [part.strip() for part in parts]
self.lhs = self.lhs.replace("\=", "=")
def func(self):
"""Create the nickname"""
def _cy(string):
"add color to the special markers"
return re.sub(r"(\$[0-9]+|\*|\?|\[.+?\])", r"|Y\1|n", string)
caller = self.caller
switches = self.switches
nicktypes = [switch for switch in switches if switch in ("object", "account", "inputline")] or ["inputline"]
nicktypes = [switch for switch in switches if switch in ("object", "account", "inputline")]
specified_nicktype = bool(nicktypes)
nicktypes = nicktypes if specified_nicktype else ["inputline"]
nicklist = utils.make_iter(caller.nicks.get(return_obj=True) or [])
nicklist = (utils.make_iter(caller.nicks.get(category="inputline", return_obj=True) or []) +
utils.make_iter(caller.nicks.get(category="object", return_obj=True) or []) +
utils.make_iter(caller.nicks.get(category="account", return_obj=True) or []))
if 'list' in switches or self.cmdstring in ("nicks", "@nicks"):
@ -133,24 +160,121 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
table = evtable.EvTable("#", "Type", "Nick match", "Replacement")
for inum, nickobj in enumerate(nicklist):
_, _, nickvalue, replacement = nickobj.value
table.add_row(str(inum + 1), nickobj.db_category, nickvalue, replacement)
table.add_row(str(inum + 1), nickobj.db_category, _cy(nickvalue), _cy(replacement))
string = "|wDefined Nicks:|n\n%s" % table
caller.msg(string)
return
if 'clearall' in switches:
caller.nicks.clear()
caller.account.nicks.clear()
caller.msg("Cleared all nicks.")
return
if 'delete' in switches or 'del' in switches:
if not self.args or not self.lhs:
caller.msg("usage nick/delete <nick> or <#num> ('nicks' for list)")
return
# see if a number was given
arg = self.args.lstrip("#")
oldnicks = []
if arg.isdigit():
# we are given a index in nicklist
delindex = int(arg)
if 0 < delindex <= len(nicklist):
oldnicks.append(nicklist[delindex - 1])
else:
caller.msg("Not a valid nick index. See 'nicks' for a list.")
return
else:
if not specified_nicktype:
nicktypes = ("object", "account", "inputline")
for nicktype in nicktypes:
oldnicks.append(caller.nicks.get(arg, category=nicktype, return_obj=True))
oldnicks = [oldnick for oldnick in oldnicks if oldnick]
if oldnicks:
for oldnick in oldnicks:
nicktype = oldnick.category
nicktypestr = "%s-nick" % nicktype.capitalize()
_, _, old_nickstring, old_replstring = oldnick.value
caller.nicks.remove(old_nickstring, category=nicktype)
caller.msg("%s removed: '|w%s|n' -> |w%s|n." % (
nicktypestr, old_nickstring, old_replstring))
else:
caller.msg("No matching nicks to remove.")
return
if not self.rhs and self.lhs:
# check what a nick is set to
strings = []
if not specified_nicktype:
nicktypes = ("object", "account", "inputline")
for nicktype in nicktypes:
nicks = utils.make_iter(caller.nicks.get(category=nicktype, return_obj=True))
for nick in nicks:
_, _, nick, repl = nick.value
if nick.startswith(self.lhs):
strings.append("{}-nick: '{}' -> '{}'".format(
nicktype.capitalize(), nick, repl))
if strings:
caller.msg("\n".join(strings))
else:
caller.msg("No nicks found matching '{}'".format(self.lhs))
return
if not self.rhs and self.lhs:
# check what a nick is set to
strings = []
if not specified_nicktype:
nicktypes = ("object", "account", "inputline")
for nicktype in nicktypes:
if nicktype == "account":
obj = account
else:
obj = caller
nicks = utils.make_iter(obj.nicks.get(category=nicktype, return_obj=True))
for nick in nicks:
_, _, nick, repl = nick.value
if nick.startswith(self.lhs):
strings.append("{}-nick: '{}' -> '{}'".format(
nicktype.capitalize(), nick, repl))
if strings:
caller.msg("\n".join(strings))
else:
caller.msg("No nicks found matching '{}'".format(self.lhs))
return
if not self.rhs and self.lhs:
# check what a nick is set to
strings = []
if not specified_nicktype:
nicktypes = ("object", "account", "inputline")
for nicktype in nicktypes:
if nicktype == "account":
obj = account
else:
obj = caller
nicks = utils.make_iter(obj.nicks.get(category=nicktype, return_obj=True))
for nick in nicks:
_, _, nick, repl = nick.value
if nick.startswith(self.lhs):
strings.append("{}-nick: '{}' -> '{}'".format(
nicktype.capitalize(), nick, repl))
if strings:
caller.msg("\n".join(strings))
else:
caller.msg("No nicks found matching '{}'".format(self.lhs))
return
if not self.args or not self.lhs:
caller.msg("Usage: nick[/switches] nickname = [realname]")
return
# setting new nicks
nickstring = self.lhs
replstring = self.rhs
old_nickstring = None
old_replstring = None
if replstring == nickstring:
caller.msg("No point in setting nick same as the string to replace...")
@ -160,36 +284,24 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
errstring = ""
string = ""
for nicktype in nicktypes:
nicktypestr = "%s-nick" % nicktype.capitalize()
old_nickstring = None
old_replstring = None
oldnick = caller.nicks.get(key=nickstring, category=nicktype, return_obj=True)
if oldnick:
_, _, old_nickstring, old_replstring = oldnick.value
else:
# no old nick, see if a number was given
arg = self.args.lstrip("#")
if arg.isdigit():
# we are given a index in nicklist
delindex = int(arg)
if 0 < delindex <= len(nicklist):
oldnick = nicklist[delindex - 1]
_, _, old_nickstring, old_replstring = oldnick.value
else:
errstring += "Not a valid nick index."
else:
errstring += "Nick not found."
if "delete" in switches or "del" in switches:
# clear the nick
if old_nickstring and caller.nicks.has(old_nickstring, category=nicktype):
caller.nicks.remove(old_nickstring, category=nicktype)
string += "\nNick removed: '|w%s|n' -> |w%s|n." % (old_nickstring, old_replstring)
else:
errstring += "\nNick '|w%s|n' was not deleted." % old_nickstring
elif replstring:
if replstring:
# creating new nick
errstring = ""
if oldnick:
string += "\nNick '|w%s|n' updated to map to '|w%s|n'." % (old_nickstring, replstring)
if replstring == old_replstring:
string += "\nIdentical %s already set." % nicktypestr.lower()
else:
string += "\n%s '|w%s|n' updated to map to '|w%s|n'." % (
nicktypestr, old_nickstring, replstring)
else:
string += "\nNick '|w%s|n' mapped to '|w%s|n'." % (nickstring, replstring)
string += "\n%s '|w%s|n' mapped to '|w%s|n'." % (nicktypestr, nickstring, replstring)
try:
caller.nicks.add(nickstring, replstring, category=nicktype)
except NickTemplateInvalid:
@ -197,10 +309,10 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
return
elif old_nickstring and old_replstring:
# just looking at the nick
string += "\nNick '|w%s|n' maps to '|w%s|n'." % (old_nickstring, old_replstring)
string += "\n%s '|w%s|n' maps to '|w%s|n'." % (nicktypestr, old_nickstring, old_replstring)
errstring = ""
string = errstring if errstring else string
caller.msg(string)
caller.msg(_cy(string))
class CmdInventory(COMMAND_DEFAULT_CLASS):
@ -330,12 +442,13 @@ class CmdGive(COMMAND_DEFAULT_CLASS):
give away something to someone
Usage:
give <inventory obj> = <target>
give <inventory obj> <to||=> <target>
Gives an items from your inventory to another character,
placing it in their inventory.
"""
key = "give"
rhs_split = ("=", " to ") # Prefer = delimiter, but allow " to " usage.
locks = "cmd:all()"
arg_regex = r"\s|$"
@ -439,7 +552,7 @@ class CmdWhisper(COMMAND_DEFAULT_CLASS):
Usage:
whisper <character> = <message>
whisper <char1>, <char2> = <message?
whisper <char1>, <char2> = <message>
Talk privately to one or more characters in your current location, without
others in the room being informed.

View file

@ -317,6 +317,7 @@ class CmdSetHelp(COMMAND_DEFAULT_CLASS):
"""
key = "@sethelp"
switch_options = ("edit", "replace", "append", "extend", "delete")
locks = "cmd:perm(Helper)"
help_category = "Building"

View file

@ -79,6 +79,13 @@ class MuxCommand(Command):
it here). The rest of the command is stored in self.args, which can
start with the switch indicator /.
Optional variables to aid in parsing, if set:
self.switch_options - (tuple of valid /switches expected by this
command (without the /))
self.rhs_split - Alternate string delimiter or tuple of strings
to separate left/right hand sides. tuple form
gives priority split to first string delimiter.
This parser breaks self.args into its constituents and stores them in the
following variables:
self.switches = [list of /switches (without the /)]
@ -97,9 +104,18 @@ class MuxCommand(Command):
"""
raw = self.args
args = raw.strip()
# Without explicitly setting these attributes, they assume default values:
if not hasattr(self, "switch_options"):
self.switch_options = None
if not hasattr(self, "rhs_split"):
self.rhs_split = "="
if not hasattr(self, "account_caller"):
self.account_caller = False
# split out switches
switches = []
switches, delimiters = [], self.rhs_split
if self.switch_options:
self.switch_options = [opt.lower() for opt in self.switch_options]
if args and len(args) > 1 and raw[0] == "/":
# we have a switch, or a set of switches. These end with a space.
switches = args[1:].split(None, 1)
@ -109,16 +125,50 @@ class MuxCommand(Command):
else:
args = ""
switches = switches[0].split('/')
# If user-provides switches, parse them with parser switch options.
if switches and self.switch_options:
valid_switches, unused_switches, extra_switches = [], [], []
for element in switches:
option_check = [opt for opt in self.switch_options if opt == element]
if not option_check:
option_check = [opt for opt in self.switch_options if opt.startswith(element)]
match_count = len(option_check)
if match_count > 1:
extra_switches.extend(option_check) # Either the option provided is ambiguous,
elif match_count == 1:
valid_switches.extend(option_check) # or it is a valid option abbreviation,
elif match_count == 0:
unused_switches.append(element) # or an extraneous option to be ignored.
if extra_switches: # User provided switches
self.msg('|g%s|n: |wAmbiguous switch supplied: Did you mean /|C%s|w?' %
(self.cmdstring, ' |nor /|C'.join(extra_switches)))
if unused_switches:
plural = '' if len(unused_switches) == 1 else 'es'
self.msg('|g%s|n: |wExtra switch%s "/|C%s|w" ignored.' %
(self.cmdstring, plural, '|n, /|C'.join(unused_switches)))
switches = valid_switches # Only include valid_switches in command function call
arglist = [arg.strip() for arg in args.split()]
# check for arg1, arg2, ... = argA, argB, ... constructs
lhs, rhs = args, None
lhslist, rhslist = [arg.strip() for arg in args.split(',')], []
if args and '=' in args:
lhs, rhs = [arg.strip() for arg in args.split('=', 1)]
lhslist = [arg.strip() for arg in lhs.split(',')]
rhslist = [arg.strip() for arg in rhs.split(',')]
lhs, rhs = args.strip(), None
if lhs:
if delimiters and hasattr(delimiters, '__iter__'): # If delimiter is iterable,
best_split = delimiters[0] # (default to first delimiter)
for this_split in delimiters: # try each delimiter
if this_split in lhs: # to find first successful split
best_split = this_split # to be the best split.
break
else:
best_split = delimiters
# Parse to separate left into left/right sides using best_split delimiter string
if best_split in lhs:
lhs, rhs = lhs.split(best_split, 1)
# Trim user-injected whitespace
rhs = rhs.strip() if rhs is not None else None
lhs = lhs.strip()
# Further split left/right sides by comma delimiter
lhslist = [arg.strip() for arg in lhs.split(',')] if lhs is not None else ""
rhslist = [arg.strip() for arg in rhs.split(',')] if rhs is not None else ""
# save to object properties:
self.raw = raw
self.switches = switches
@ -133,7 +183,7 @@ class MuxCommand(Command):
# sure that self.caller is always the account if possible. We also create
# a special property "character" for the puppeted object, if any. This
# is convenient for commands defined on the Account only.
if hasattr(self, "account_caller") and self.account_caller:
if self.account_caller:
if utils.inherits_from(self.caller, "evennia.objects.objects.DefaultObject"):
# caller is an Object/Character
self.character = self.caller
@ -169,6 +219,8 @@ class MuxCommand(Command):
string += "\nraw argument (self.raw): |w%s|n \n" % self.raw
string += "cmd args (self.args): |w%s|n\n" % self.args
string += "cmd switches (self.switches): |w%s|n\n" % self.switches
string += "cmd options (self.switch_options): |w%s|n\n" % self.switch_options
string += "cmd parse left/right using (self.rhs_split): |w%s|n\n" % self.rhs_split
string += "space-separated arg list (self.arglist): |w%s|n\n" % self.arglist
string += "lhs, left-hand side of '=' (self.lhs): |w%s|n\n" % self.lhs
string += "lhs, comma separated (self.lhslist): |w%s|n\n" % self.lhslist
@ -193,18 +245,4 @@ class MuxAccountCommand(MuxCommand):
character is actually attached to this Account and Session.
"""
def parse(self):
"""
We run the parent parser as usual, then fix the result
"""
super().parse()
if utils.inherits_from(self.caller, "evennia.objects.objects.DefaultObject"):
# caller is an Object/Character
self.character = self.caller
self.caller = self.caller.account
elif utils.inherits_from(self.caller, "evennia.accounts.accounts.DefaultAccount"):
# caller was already an Account
self.character = self.caller.get_puppet(self.session)
else:
self.character = None
account_caller = True # Using MuxAccountCommand explicitly defaults the caller to an account

View file

@ -58,7 +58,7 @@ class CmdReload(COMMAND_DEFAULT_CLASS):
if self.args:
reason = "(Reason: %s) " % self.args.rstrip(".")
SESSIONS.announce_all(" Server restart initiated %s..." % reason)
SESSIONS.server.shutdown(mode='reload')
SESSIONS.portal_restart_server()
class CmdReset(COMMAND_DEFAULT_CLASS):
@ -91,7 +91,7 @@ class CmdReset(COMMAND_DEFAULT_CLASS):
Reload the system.
"""
SESSIONS.announce_all(" Server resetting/restarting ...")
SESSIONS.server.shutdown(mode='reset')
SESSIONS.portal_reset_server()
class CmdShutdown(COMMAND_DEFAULT_CLASS):
@ -119,7 +119,6 @@ class CmdShutdown(COMMAND_DEFAULT_CLASS):
announcement += "%s\n" % self.args
logger.log_info('Server shutdown by %s.' % self.caller.name)
SESSIONS.announce_all(announcement)
SESSIONS.server.shutdown(mode='shutdown')
SESSIONS.portal_shutdown()
@ -246,6 +245,7 @@ class CmdPy(COMMAND_DEFAULT_CLASS):
"""
key = "@py"
aliases = ["!"]
switch_options = ("time", "edit")
locks = "cmd:perm(py) or perm(Developer)"
help_category = "System"
@ -329,6 +329,7 @@ class CmdScripts(COMMAND_DEFAULT_CLASS):
"""
key = "@scripts"
aliases = ["@globalscript", "@listscripts"]
switch_options = ("start", "stop", "kill", "validate")
locks = "cmd:perm(listscripts) or perm(Admin)"
help_category = "System"
@ -522,6 +523,7 @@ class CmdService(COMMAND_DEFAULT_CLASS):
key = "@service"
aliases = ["@services"]
switch_options = ("list", "start", "stop", "delete")
locks = "cmd:perm(service) or perm(Developer)"
help_category = "System"
@ -673,7 +675,7 @@ class CmdServerLoad(COMMAND_DEFAULT_CLASS):
Usage:
@server[/mem]
Switch:
Switches:
mem - return only a string of the current memory usage
flushmem - flush the idmapper cache
@ -704,6 +706,7 @@ class CmdServerLoad(COMMAND_DEFAULT_CLASS):
"""
key = "@server"
aliases = ["@serverload", "@serverprocess"]
switch_options = ("mem", "flushmem")
locks = "cmd:perm(list) or perm(Developer)"
help_category = "System"

View file

@ -14,35 +14,39 @@ main test suite started with
import re
import types
import datetime
from django.conf import settings
from mock import Mock
from mock import Mock, mock
from evennia.commands.default.cmdset_character import CharacterCmdSet
from evennia.utils.test_resources import EvenniaTest
from evennia.commands.default import help, general, system, admin, account, building, batchprocess, comms
from evennia.commands.default import help, general, system, admin, account, building, batchprocess, comms, unloggedin
from evennia.commands.default.muxcommand import MuxCommand
from evennia.commands.command import Command, InterruptCommand
from evennia.utils import ansi, utils
from evennia.utils import ansi, utils, gametime
from evennia.server.sessionhandler import SESSIONS
from evennia import search_object
from evennia import DefaultObject, DefaultCharacter
from evennia.prototypes import prototypes as protlib
# set up signal here since we are not starting the server
_RE = re.compile(r"^\+|-+\+|\+-+|--*|\|(?:\s|$)", re.MULTILINE)
_RE = re.compile(r"^\+|-+\+|\+-+|--+|\|(?:\s|$)", re.MULTILINE)
# ------------------------------------------------------------
# Command testing
# ------------------------------------------------------------
class CommandTest(EvenniaTest):
"""
Tests a command
"""
def call(self, cmdobj, args, msg=None, cmdset=None, noansi=True, caller=None, receiver=None, cmdstring=None, obj=None):
def call(self, cmdobj, args, msg=None, cmdset=None, noansi=True, caller=None,
receiver=None, cmdstring=None, obj=None, inputs=None):
"""
Test a command by assigning all the needed
properties to cmdobj and running
@ -71,38 +75,58 @@ class CommandTest(EvenniaTest):
cmdobj.obj = obj or (caller if caller else self.char1)
# test
old_msg = receiver.msg
returned_msg = ""
inputs = inputs or []
try:
receiver.msg = Mock()
cmdobj.at_pre_cmd()
if cmdobj.at_pre_cmd():
return
cmdobj.parse()
ret = cmdobj.func()
# handle func's with yield in them (generators)
if isinstance(ret, types.GeneratorType):
next(ret)
while True:
try:
inp = inputs.pop() if inputs else None
if inp:
try:
ret.send(inp)
except TypeError:
next(ret)
ret = ret.send(inp)
else:
next(ret)
except StopIteration:
break
cmdobj.at_post_cmd()
except StopIteration:
pass
except InterruptCommand:
pass
finally:
# clean out evtable sugar. We only operate on text-type
stored_msg = [args[0] if args and args[0] else kwargs.get("text", utils.to_str(kwargs, force_string=True))
for name, args, kwargs in receiver.msg.mock_calls]
# Get the first element of a tuple if msg received a tuple instead of a string
stored_msg = [smsg[0] if isinstance(smsg, tuple) else smsg for smsg in stored_msg]
if msg is not None:
returned_msg = "||".join(_RE.sub("", mess) for mess in stored_msg)
returned_msg = ansi.parse_ansi(returned_msg, strip_ansi=noansi).strip()
if msg == "" and returned_msg or not returned_msg.startswith(msg.strip()):
sep1 = "\n" + "=" * 30 + "Wanted message" + "=" * 34 + "\n"
sep2 = "\n" + "=" * 30 + "Returned message" + "=" * 32 + "\n"
sep3 = "\n" + "=" * 78
retval = sep1 + msg.strip() + sep2 + returned_msg + sep3
raise AssertionError(retval)
else:
returned_msg = "\n".join(str(msg) for msg in stored_msg)
returned_msg = ansi.parse_ansi(returned_msg, strip_ansi=noansi).strip()
receiver.msg = old_msg
# clean out evtable sugar. We only operate on text-type
stored_msg = [args[0] if args and args[0] else kwargs.get("text", utils.to_str(kwargs, force_string=True))
for name, args, kwargs in receiver.msg.mock_calls]
# Get the first element of a tuple if msg received a tuple instead of a string
stored_msg = [smsg[0] if isinstance(smsg, tuple) else smsg for smsg in stored_msg]
if msg is not None:
# set our separator for returned messages based on parsing ansi or not
msg_sep = "|" if noansi else "||"
# Have to strip ansi for each returned message for the regex to handle it correctly
returned_msg = msg_sep.join(_RE.sub("", ansi.parse_ansi(mess, strip_ansi=noansi))
for mess in stored_msg).strip()
if msg == "" and returned_msg or not returned_msg.startswith(msg.strip()):
sep1 = "\n" + "=" * 30 + "Wanted message" + "=" * 34 + "\n"
sep2 = "\n" + "=" * 30 + "Returned message" + "=" * 32 + "\n"
sep3 = "\n" + "=" * 78
retval = sep1 + msg.strip() + sep2 + returned_msg + sep3
raise AssertionError(retval)
else:
returned_msg = "\n".join(str(msg) for msg in stored_msg)
returned_msg = ansi.parse_ansi(returned_msg, strip_ansi=noansi).strip()
receiver.msg = old_msg
return returned_msg
@ -125,17 +149,48 @@ class TestGeneral(CommandTest):
self.call(general.CmdPose(), "looks around", "Char looks around")
def test_nick(self):
self.call(general.CmdNick(), "testalias = testaliasedstring1", "Nick 'testalias' mapped to 'testaliasedstring1'.")
self.call(general.CmdNick(), "/account testalias = testaliasedstring2", "Nick 'testalias' mapped to 'testaliasedstring2'.")
self.call(general.CmdNick(), "/object testalias = testaliasedstring3", "Nick 'testalias' mapped to 'testaliasedstring3'.")
self.assertEqual("testaliasedstring1", self.char1.nicks.get("testalias"))
self.assertEqual("testaliasedstring2", self.char1.nicks.get("testalias", category="account"))
self.assertEqual("testaliasedstring3", self.char1.nicks.get("testalias", category="object"))
self.call(general.CmdNick(), "testalias = testaliasedstring1",
"Inputline-nick 'testalias' mapped to 'testaliasedstring1'.")
self.call(general.CmdNick(), "/account testalias = testaliasedstring2",
"Account-nick 'testalias' mapped to 'testaliasedstring2'.")
self.call(general.CmdNick(), "/object testalias = testaliasedstring3",
"Object-nick 'testalias' mapped to 'testaliasedstring3'.")
self.assertEqual(u"testaliasedstring1", self.char1.nicks.get("testalias"))
self.assertEqual(u"testaliasedstring2", self.char1.nicks.get("testalias", category="account"))
self.assertEqual(None, self.char1.account.nicks.get("testalias", category="account"))
self.assertEqual(u"testaliasedstring3", self.char1.nicks.get("testalias", category="object"))
def test_get_and_drop(self):
self.call(general.CmdGet(), "Obj", "You pick up Obj.")
self.call(general.CmdDrop(), "Obj", "You drop Obj.")
def test_give(self):
self.call(general.CmdGive(), "Obj to Char2", "You aren't carrying Obj.")
self.call(general.CmdGive(), "Obj = Char2", "You aren't carrying Obj.")
self.call(general.CmdGet(), "Obj", "You pick up Obj.")
self.call(general.CmdGive(), "Obj to Char2", "You give")
self.call(general.CmdGive(), "Obj = Char", "You give", caller=self.char2)
def test_mux_command(self):
class CmdTest(MuxCommand):
key = 'test'
switch_options = ('test', 'testswitch', 'testswitch2')
def func(self):
self.msg("Switches matched: {}".format(self.switches))
self.call(CmdTest(), "/test/testswitch/testswitch2", "Switches matched: ['test', 'testswitch', 'testswitch2']")
self.call(CmdTest(), "/test", "Switches matched: ['test']")
self.call(CmdTest(), "/test/testswitch", "Switches matched: ['test', 'testswitch']")
self.call(CmdTest(), "/testswitch/testswitch2", "Switches matched: ['testswitch', 'testswitch2']")
self.call(CmdTest(), "/testswitch", "Switches matched: ['testswitch']")
self.call(CmdTest(), "/testswitch2", "Switches matched: ['testswitch2']")
self.call(CmdTest(), "/t", "test: Ambiguous switch supplied: "
"Did you mean /test or /testswitch or /testswitch2?|Switches matched: []")
self.call(CmdTest(), "/tests", "test: Ambiguous switch supplied: "
"Did you mean /testswitch or /testswitch2?|Switches matched: []")
def test_say(self):
self.call(general.CmdSay(), "Testing", "You say, \"Testing\"")
@ -162,7 +217,7 @@ class TestSystem(CommandTest):
self.call(system.CmdPy(), "1+2", ">>> 1+2|3")
def test_scripts(self):
self.call(system.CmdScripts(), "", "| dbref |")
self.call(system.CmdScripts(), "", "dbref ")
def test_objects(self):
self.call(system.CmdObjects(), "", "Object subtype totals")
@ -183,17 +238,17 @@ class TestAdmin(CommandTest):
self.call(admin.CmdPerm(), "Char2 = Builder", "Permission 'Builder' given to Char2 (the Object/Character).")
def test_wall(self):
self.call(admin.CmdWall(), "Test", "Announcing to all connected accounts ...")
self.call(admin.CmdWall(), "Test", "Announcing to all connected sessions ...")
def test_ban(self):
self.call(admin.CmdBan(), "Char", "NameBan char was added.")
self.call(admin.CmdBan(), "Char", "Name-Ban char was added.")
class TestAccount(CommandTest):
def test_ooc_look(self):
if settings.MULTISESSION_MODE < 2:
self.call(account.CmdOOCLook(), "", "You are outofcharacter (OOC).", caller=self.account)
self.call(account.CmdOOCLook(), "", "You are out-of-character (OOC).", caller=self.account)
if settings.MULTISESSION_MODE == 2:
self.call(account.CmdOOCLook(), "", "Account TestAccount (you are OutofCharacter)", caller=self.account)
@ -223,7 +278,8 @@ class TestAccount(CommandTest):
self.call(account.CmdColorTest(), "ansi", "ANSI colors:", caller=self.account)
def test_char_create(self):
self.call(account.CmdCharCreate(), "Test1=Test char", "Created new character Test1. Use @ic Test1 to enter the game", caller=self.account)
self.call(account.CmdCharCreate(), "Test1=Test char",
"Created new character Test1. Use @ic Test1 to enter the game", caller=self.account)
def test_quell(self):
self.call(account.CmdQuell(), "", "Quelling to current puppet's permissions (developer).", caller=self.account)
@ -232,22 +288,25 @@ class TestAccount(CommandTest):
class TestBuilding(CommandTest):
def test_create(self):
name = settings.BASE_OBJECT_TYPECLASS.rsplit('.', 1)[1]
self.call(building.CmdCreate(), "/drop TestObj1", "You create a new %s: TestObj1." % name)
self.call(building.CmdCreate(), "/d TestObj1", # /d switch is abbreviated form of /drop
"You create a new %s: TestObj1." % name)
def test_examine(self):
self.call(building.CmdExamine(), "Obj", "Name/key: Obj")
def test_set_obj_alias(self):
self.call(building.CmdSetObjAlias(), "Obj = TestObj1b", "Alias(es) for 'Obj(#4)' set to testobj1b.")
self.call(building.CmdSetObjAlias(), "Obj =", "Cleared aliases from Obj(#4)")
self.call(building.CmdSetObjAlias(), "Obj = TestObj1b", "Alias(es) for 'Obj(#4)' set to 'testobj1b'.")
def test_copy(self):
self.call(building.CmdCopy(), "Obj = TestObj2;TestObj2b, TestObj3;TestObj3b", "Copied Obj to 'TestObj3' (aliases: ['TestObj3b']")
self.call(building.CmdCopy(), "Obj = TestObj2;TestObj2b, TestObj3;TestObj3b",
"Copied Obj to 'TestObj3' (aliases: ['TestObj3b']")
def test_attribute_commands(self):
self.call(building.CmdSetAttribute(), "Obj/test1=\"value1\"", "Created attribute Obj/test1 = 'value1'")
self.call(building.CmdSetAttribute(), "Obj2/test2=\"value2\"", "Created attribute Obj2/test2 = 'value2'")
self.call(building.CmdMvAttr(), "Obj2/test2 = Obj/test3", "Moved Obj2.test2 > Obj.test3")
self.call(building.CmdCpAttr(), "Obj/test1 = Obj2/test3", "Copied Obj.test1 > Obj2.test3")
self.call(building.CmdMvAttr(), "Obj2/test2 = Obj/test3", "Moved Obj2.test2 -> Obj.test3")
self.call(building.CmdCpAttr(), "Obj/test1 = Obj2/test3", "Copied Obj.test1 -> Obj2.test3")
self.call(building.CmdWipe(), "Obj2/test2/test3", "Wiped attributes test2,test3 on Obj2.")
def test_name(self):
@ -273,7 +332,7 @@ class TestBuilding(CommandTest):
def test_exit_commands(self):
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.CmdLink(), "TestExit1=Room", "Link created TestExit1 -> Room (one way).")
self.call(building.CmdUnLink(), "TestExit1", "Former exit TestExit1 no longer links anywhere.")
def test_set_home(self):
@ -284,19 +343,36 @@ class TestBuilding(CommandTest):
def test_typeclass(self):
self.call(building.CmdTypeclass(), "Obj = evennia.objects.objects.DefaultExit",
"Obj changed typeclass from evennia.objects.objects.DefaultObject to evennia.objects.objects.DefaultExit.")
"Obj changed typeclass from evennia.objects.objects.DefaultObject "
"to evennia.objects.objects.DefaultExit.")
def test_lock(self):
self.call(building.CmdLock(), "Obj = test:perm(Developer)", "Added lock 'test:perm(Developer)' to Obj.")
def test_find(self):
self.call(building.CmdFind(), "Room2", "One Match")
self.call(building.CmdFind(), "oom2", "One Match")
expect = "One Match(#1-#7, loc):\n " +\
"Char2(#7) - evennia.objects.objects.DefaultCharacter (location: Room(#1))"
self.call(building.CmdFind(), "Char2", expect, cmdstring="locate")
self.call(building.CmdFind(), "/ex Char2", # /ex is an ambiguous switch
"locate: Ambiguous switch supplied: Did you mean /exit or /exact?|" + expect,
cmdstring="locate")
self.call(building.CmdFind(), "Char2", expect, cmdstring="@locate")
self.call(building.CmdFind(), "/l Char2", expect, cmdstring="find") # /l switch is abbreviated form of /loc
self.call(building.CmdFind(), "Char2", "One Match", cmdstring="@find")
self.call(building.CmdFind(), "/startswith Room2", "One Match")
def test_script(self):
self.call(building.CmdScript(), "Obj = scripts.Script", "Script scripts.Script successfully added")
def test_teleport(self):
self.call(building.CmdTeleport(), "Room2", "Room2(#2)\n|Teleported to Room2.")
self.call(building.CmdTeleport(), "/quiet Room2", "Room2(#2)\n|Teleported to Room2.")
self.call(building.CmdTeleport(), "/t", # /t switch is abbreviated form of /tonone
"Cannot teleport a puppeted object (Char, puppeted by TestAccount(account 1)) to a None-location.")
self.call(building.CmdTeleport(), "/l Room2", # /l switch is abbreviated form of /loc
"Destination has no location.")
self.call(building.CmdTeleport(), "/q me to Room2", # /q switch is abbreviated form of /quiet
"Char is already at Room2.")
def test_spawn(self):
def getObject(commandTest, objKeyStr):
@ -304,6 +380,7 @@ class TestBuilding(CommandTest):
# check that it exists in the process.
query = search_object(objKeyStr)
commandTest.assertIsNotNone(query)
commandTest.assertTrue(bool(query))
obj = query[0]
commandTest.assertIsNotNone(obj)
return obj
@ -312,17 +389,20 @@ class TestBuilding(CommandTest):
self.call(building.CmdSpawn(), " ", "Usage: @spawn")
# Tests "@spawn <prototype_dictionary>" without specifying location.
self.call(building.CmdSpawn(), \
"{'key':'goblin', 'typeclass':'evennia.DefaultCharacter'}", "Spawned goblin")
goblin = getObject(self, "goblin")
# Tests that the spawned object's type is a DefaultCharacter.
self.assertIsInstance(goblin, DefaultCharacter)
self.call(building.CmdSpawn(),
"/save {'prototype_key': 'testprot', 'key':'Test Char', "
"'typeclass':'evennia.objects.objects.DefaultCharacter'}",
"Saved prototype: testprot", inputs=['y'])
self.call(building.CmdSpawn(), "/list", "Key ")
self.call(building.CmdSpawn(), 'testprot', "Spawned Test Char")
# Tests that the spawned object's location is the same as the caharacter's location, since
# we did not specify it.
self.assertEqual(goblin.location, self.char1.location)
goblin.delete()
testchar = getObject(self, "Test Char")
self.assertEqual(testchar.location, self.char1.location)
testchar.delete()
# Test "@spawn <prototype_dictionary>" with a location other than the character's.
spawnLoc = self.room2
@ -331,84 +411,111 @@ class TestBuilding(CommandTest):
# char1's default location in the future...
spawnLoc = self.room1
self.call(building.CmdSpawn(), \
"{'prototype':'GOBLIN', 'key':'goblin', 'location':'%s'}" \
% spawnLoc.dbref, "Spawned goblin")
self.call(building.CmdSpawn(),
"{'prototype_key':'GOBLIN', 'typeclass':'evennia.objects.objects.DefaultCharacter', "
"'key':'goblin', 'location':'%s'}" % spawnLoc.dbref, "Spawned goblin")
goblin = getObject(self, "goblin")
# Tests that the spawned object's type is a DefaultCharacter.
self.assertIsInstance(goblin, DefaultCharacter)
self.assertEqual(goblin.location, spawnLoc)
goblin.delete()
# create prototype
protlib.create_prototype(**{'key': 'Ball',
'typeclass': 'evennia.objects.objects.DefaultCharacter',
'prototype_key': 'testball'})
# Tests "@spawn <prototype_name>"
self.call(building.CmdSpawn(), "'BALL'", "Spawned Ball")
self.call(building.CmdSpawn(), "testball", "Spawned Ball")
ball = getObject(self, "Ball")
self.assertEqual(ball.location, self.char1.location)
self.assertIsInstance(ball, DefaultObject)
ball.delete()
# Tests "@spawn/noloc ..." without specifying a location.
# Tests "@spawn/n ..." without specifying a location.
# Location should be "None".
self.call(building.CmdSpawn(), "/noloc 'BALL'", "Spawned Ball")
self.call(building.CmdSpawn(), "/n 'BALL'", "Spawned Ball") # /n switch is abbreviated form of /noloc
ball = getObject(self, "Ball")
self.assertIsNone(ball.location)
ball.delete()
self.call(building.CmdSpawn(),
"/noloc {'prototype_parent':'TESTBALL', 'prototype_key': 'testball', 'location':'%s'}"
% spawnLoc.dbref, "Error: Prototype testball tries to parent itself.")
# Tests "@spawn/noloc ...", but DO specify a location.
# Location should be the specified location.
self.call(building.CmdSpawn(), \
"/noloc {'prototype':'BALL', 'location':'%s'}" \
self.call(building.CmdSpawn(),
"/noloc {'prototype_parent':'TESTBALL', 'key': 'Ball', 'prototype_key': 'foo', 'location':'%s'}"
% spawnLoc.dbref, "Spawned Ball")
ball = getObject(self, "Ball")
self.assertEqual(ball.location, spawnLoc)
ball.delete()
# test calling spawn with an invalid prototype.
self.call(building.CmdSpawn(), \
"'NO_EXIST'", "No prototype named 'NO_EXIST'")
self.call(building.CmdSpawn(), "'NO_EXIST'", "No prototype named 'NO_EXIST'")
# Test listing commands
self.call(building.CmdSpawn(), "/list", "Key ")
class TestComms(CommandTest):
def setUp(self):
super().setUp()
self.call(comms.CmdChannelCreate(), "testchan;test=Test Channel", "Created channel testchan and connected to it.", receiver=self.account)
super(CommandTest, self).setUp()
self.call(comms.CmdChannelCreate(), "testchan;test=Test Channel",
"Created channel testchan and connected to it.", receiver=self.account)
def test_toggle_com(self):
self.call(comms.CmdAddCom(), "tc = testchan", "You are already connected to channel testchan. You can now", receiver=self.account)
self.call(comms.CmdAddCom(), "tc = testchan",
"You are already connected to channel testchan. You can now", receiver=self.account)
self.call(comms.CmdDelCom(), "tc", "Your alias 'tc' for channel testchan was cleared.", receiver=self.account)
def test_channels(self):
self.call(comms.CmdChannels(), "", "Available channels (use comlist,addcom and delcom to manage", receiver=self.account)
self.call(comms.CmdChannels(), "",
"Available channels (use comlist,addcom and delcom to manage", receiver=self.account)
def test_all_com(self):
self.call(comms.CmdAllCom(), "", "Available channels (use comlist,addcom and delcom to manage", receiver=self.account)
self.call(comms.CmdAllCom(), "",
"Available channels (use comlist,addcom and delcom to manage", receiver=self.account)
def test_clock(self):
self.call(comms.CmdClock(), "testchan=send:all()", "Lock(s) applied. Current locks on testchan:", receiver=self.account)
self.call(comms.CmdClock(),
"testchan=send:all()", "Lock(s) applied. Current locks on testchan:", receiver=self.account)
def test_cdesc(self):
self.call(comms.CmdCdesc(), "testchan = Test Channel", "Description of channel 'testchan' set to 'Test Channel'.", receiver=self.account)
self.call(comms.CmdCdesc(), "testchan = Test Channel",
"Description of channel 'testchan' set to 'Test Channel'.", receiver=self.account)
def test_cemit(self):
self.call(comms.CmdCemit(), "testchan = Test Message", "[testchan] Test Message|Sent to channel testchan: Test Message", receiver=self.account)
self.call(comms.CmdCemit(), "testchan = Test Message",
"[testchan] Test Message|Sent to channel testchan: Test Message", receiver=self.account)
def test_cwho(self):
self.call(comms.CmdCWho(), "testchan", "Channel subscriptions\ntestchan:\n TestAccount", receiver=self.account)
def test_page(self):
self.call(comms.CmdPage(), "TestAccount2 = Test", "TestAccount2 is offline. They will see your message if they list their pages later.|You paged TestAccount2 with: 'Test'.", receiver=self.account)
self.call(comms.CmdPage(), "TestAccount2 = Test",
"TestAccount2 is offline. They will see your message if they list their pages later."
"|You paged TestAccount2 with: 'Test'.", receiver=self.account)
def test_cboot(self):
# No one else connected to boot
self.call(comms.CmdCBoot(), "", "Usage: @cboot[/quiet] <channel> = <account> [:reason]", receiver=self.account)
def test_cdestroy(self):
self.call(comms.CmdCdestroy(), "testchan", "[testchan] TestAccount: testchan is being destroyed. Make sure to change your aliases.|Channel 'testchan' was destroyed.", receiver=self.account)
self.call(comms.CmdCdestroy(), "testchan",
"[testchan] TestAccount: testchan is being destroyed. Make sure to change your aliases."
"|Channel 'testchan' was destroyed.", receiver=self.account)
class TestBatchProcess(CommandTest):
def test_batch_commands(self):
# cannot test batchcode here, it must run inside the server process
self.call(batchprocess.CmdBatchCommands(), "example_batch_cmds", "Running Batchcommand processor Automatic mode for example_batch_cmds")
self.call(batchprocess.CmdBatchCommands(), "example_batch_cmds",
"Running Batch-command processor - Automatic mode for example_batch_cmds")
# we make sure to delete the button again here to stop the running reactor
confirm = building.CmdDestroy.confirm
building.CmdDestroy.confirm = False
@ -431,3 +538,12 @@ class TestInterruptCommand(CommandTest):
def test_interrupt_command(self):
ret = self.call(CmdInterrupt(), "")
self.assertEqual(ret, "")
class TestUnconnectedCommand(CommandTest):
def test_info_command(self):
expected = "## BEGIN INFO 1.1\nName: %s\nUptime: %s\nConnected: %d\nVersion: Evennia %s\n## END INFO" % (
settings.SERVERNAME,
datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(),
SESSIONS.account_count(), utils.get_evennia_version())
self.call(unloggedin.CmdUnconnectedInfo(), "", expected)

View file

@ -2,18 +2,19 @@
Commands that are available from the connect screen.
"""
import re
import time
import datetime
from codecs import lookup as codecs_lookup
from collections import defaultdict
from random import getrandbits
from django.conf import settings
from django.contrib.auth import authenticate
from evennia.accounts.models import AccountDB
from evennia.objects.models import ObjectDB
from evennia.server.models import ServerConfig
from evennia.server.throttle import Throttle
from evennia.comms.models import ChannelDB
from evennia.server.sessionhandler import SESSIONS
from evennia.utils import create, logger, utils
from evennia.utils import create, logger, utils, gametime
from evennia.commands.cmdhandler import CMD_LOGINSTART
COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS)
@ -25,57 +26,10 @@ __all__ = ("CmdUnconnectedConnect", "CmdUnconnectedCreate",
MULTISESSION_MODE = settings.MULTISESSION_MODE
CONNECTION_SCREEN_MODULE = settings.CONNECTION_SCREEN_MODULE
# Helper function to throttle failed connection attempts.
# This can easily be used to limit account creation too,
# (just supply a different storage dictionary), but this
# would also block dummyrunner, so it's not added as default.
_LATEST_FAILED_LOGINS = defaultdict(list)
def _throttle(session, maxlim=None, timeout=None, storage=_LATEST_FAILED_LOGINS):
"""
This will check the session's address against the
_LATEST_LOGINS dictionary to check they haven't
spammed too many fails recently.
Args:
session (Session): Session failing
maxlim (int): max number of attempts to allow
timeout (int): number of timeout seconds after
max number of tries has been reached.
Returns:
throttles (bool): True if throttling is active,
False otherwise.
Notes:
If maxlim and/or timeout are set, the function will
just do the comparison, not append a new datapoint.
"""
address = session.address
if isinstance(address, tuple):
address = address[0]
now = time.time()
if maxlim and timeout:
# checking mode
latest_fails = storage[address]
if latest_fails and len(latest_fails) >= maxlim:
# too many fails recently
if now - latest_fails[-1] < timeout:
# too soon - timeout in play
return True
else:
# timeout has passed. Reset faillist
storage[address] = []
return False
else:
return False
else:
# store the time of the latest fail
storage[address].append(time.time())
return False
# Create throttles for too many connections, account-creations and login attempts
CONNECTION_THROTTLE = Throttle(limit=5, timeout=1 * 60)
CREATION_THROTTLE = Throttle(limit=2, timeout=10 * 60)
LOGIN_THROTTLE = Throttle(limit=5, timeout=5 * 60)
def create_guest_account(session):
@ -148,8 +102,11 @@ def create_normal_account(session, name, password):
account (Account): the account which was created from the name and password.
"""
# check for too many login errors too quick.
if _throttle(session, maxlim=5, timeout=5 * 60):
# timeout is 5 minutes.
address = session.address
if isinstance(address, tuple):
address = address[0]
if LOGIN_THROTTLE.check(address):
session.msg("|RYou made too many connection attempts. Try again in a few minutes.|n")
return None
@ -160,7 +117,7 @@ def create_normal_account(session, name, password):
# No accountname or password match
session.msg("Incorrect login information given.")
# this just updates the throttle
_throttle(session)
LOGIN_THROTTLE.update(address)
# calls account hook for a failed login if possible.
account = AccountDB.objects.get_account_from_name(name)
if account:
@ -170,7 +127,6 @@ def create_normal_account(session, name, password):
# Check IP and/or name bans
bans = ServerConfig.objects.conf("server_bans")
if bans and (any(tup[0] == account.name.lower() for tup in bans) or
any(tup[2].match(session.address) for tup in bans if tup[2])):
# this is a banned IP or name!
string = "|rYou have been banned and cannot continue from here." \
@ -210,7 +166,10 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS):
session = self.caller
# check for too many login errors too quick.
if _throttle(session, maxlim=5, timeout=5 * 60, storage=_LATEST_FAILED_LOGINS):
address = session.address
if isinstance(address, tuple):
address = address[0]
if CONNECTION_THROTTLE.check(address):
# timeout is 5 minutes.
session.msg("|RYou made too many connection attempts. Try again in a few minutes.|n")
return
@ -233,6 +192,7 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS):
session.msg("\n\r Usage (without <>): connect <name> <password>")
return
CONNECTION_THROTTLE.update(address)
name, password = parts
account = create_normal_account(session, name, password)
if account:
@ -262,6 +222,15 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
session = self.caller
args = self.args.strip()
# Rate-limit account creation.
address = session.address
if isinstance(address, tuple):
address = address[0]
if CREATION_THROTTLE.check(address):
session.msg("|RYou are creating too many accounts. Try again in a few minutes.|n")
return
# extract double quoted parts
parts = [part.strip() for part in re.split(r"\"", args) if part.strip()]
if len(parts) == 1:
@ -293,10 +262,14 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
string = "\n\r That name is reserved. Please choose another Accountname."
session.msg(string)
return
if not re.findall(r"^[\w. @+\-']+$", password) or not (3 < len(password)):
string = "\n\r Password should be longer than 3 characters. Letters, spaces, digits and @/./+/-/_/' only." \
"\nFor best security, make it longer than 8 characters. You can also use a phrase of" \
"\nmany words if you enclose the password in double quotes."
# Validate password
Account = utils.class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
# Have to create a dummy Account object to check username similarity
valid, error = Account.validate_password(password, account=Account(username=accountname))
if error:
errors = [e for suberror in error.messages for e in error.messages]
string = "\n".join(errors)
session.msg(string)
return
@ -321,6 +294,10 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
if MULTISESSION_MODE < 2:
default_home = ObjectDB.objects.get_id(settings.DEFAULT_HOME)
_create_character(session, new_account, typeclass, default_home, permissions)
# Update the throttle to indicate a new account was created from this IP
CREATION_THROTTLE.update(address)
# tell the caller everything went well.
string = "A new account '%s' was created. Welcome!"
if " " in accountname:
@ -517,6 +494,24 @@ class CmdUnconnectedScreenreader(COMMAND_DEFAULT_CLASS):
self.session.sessionhandler.session_portal_sync(self.session)
class CmdUnconnectedInfo(COMMAND_DEFAULT_CLASS):
"""
Provides MUDINFO output, so that Evennia games can be added to Mudconnector
and Mudstats. Sadly, the MUDINFO specification seems to have dropped off the
face of the net, but it is still used by some crawlers. This implementation
was created by looking at the MUDINFO implementation in MUX2, TinyMUSH, Rhost,
and PennMUSH.
"""
key = "info"
locks = "cmd:all()"
def func(self):
self.caller.msg("## BEGIN INFO 1.1\nName: %s\nUptime: %s\nConnected: %d\nVersion: Evennia %s\n## END INFO" % (
settings.SERVERNAME,
datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(),
SESSIONS.account_count(), utils.get_evennia_version()))
def _create_account(session, accountname, password, permissions, typeclass=None, email=None):
"""
Helper function, creates an account of the specified typeclass.

View file

@ -264,14 +264,20 @@ class TestCmdSetMergers(TestCase):
# test cmdhandler functions
import sys
from evennia.commands import cmdhandler
from twisted.trial.unittest import TestCase as TwistedTestCase
def _mockdelay(time, func, *args, **kwargs):
return func(*args, **kwargs)
class TestGetAndMergeCmdSets(TwistedTestCase, EvenniaTest):
"Test the cmdhandler.get_and_merge_cmdsets function."
def setUp(self):
self.patch(sys.modules['evennia.server.sessionhandler'], 'delay', _mockdelay)
super().setUp()
self.cmdset_a = _CmdSetA()
self.cmdset_b = _CmdSetB()
@ -325,6 +331,7 @@ class TestGetAndMergeCmdSets(TwistedTestCase, EvenniaTest):
a.no_exits = True
a.no_channels = True
self.set_cmdsets(self.obj1, a, b, c, d)
deferred = cmdhandler.get_and_merge_cmdsets(self.obj1, None, None, self.obj1, "object", "")
def _callback(cmdset):

View file

@ -53,12 +53,13 @@ class ChannelAdmin(admin.ModelAdmin):
list_display = ('id', 'db_key', 'db_lock_storage', "subscriptions")
list_display_links = ("id", 'db_key')
ordering = ["db_key"]
search_fields = ['id', 'db_key', 'db_aliases']
search_fields = ['id', 'db_key', 'db_tags__db_key']
save_as = True
save_on_top = True
list_select_related = True
raw_id_fields = ('db_object_subscriptions', 'db_account_subscriptions',)
fieldsets = (
(None, {'fields': (('db_key',), 'db_lock_storage', 'db_subscriptions')}),
(None, {'fields': (('db_key',), 'db_lock_storage', 'db_account_subscriptions', 'db_object_subscriptions')}),
)
def subscriptions(self, obj):
@ -69,7 +70,7 @@ class ChannelAdmin(admin.ModelAdmin):
obj (Channel): The channel to get subs from.
"""
return ", ".join([str(sub) for sub in obj.db_subscriptions.all()])
return ", ".join([str(sub) for sub in obj.subscriptions.all()])
def save_model(self, request, obj, form, change):
"""

View file

@ -159,7 +159,7 @@ class ChannelHandler(object):
"""
The ChannelHandler manages all active in-game channels and
dynamically creates channel commands for users so that they can
just give the channek's key or alias to write to it. Whenever a
just give the channel's key or alias to write to it. Whenever a
new channel is created in the database, the update() method on
this handler must be called to sync it with the database (this is
done automatically if creating the channel with

View file

@ -26,6 +26,7 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)):
the hooks called by this method.
"""
self.basetype_setup()
self.at_channel_creation()
self.attributes.add("log_file", "channel_%s.log" % self.key)
if hasattr(self, "_createdict"):
@ -46,11 +47,7 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)):
if cdict.get("desc"):
self.attributes.add("desc", cdict["desc"])
def at_channel_creation(self):
"""
Called once, when the channel is first created.
"""
def basetype_setup(self):
# delayed import of the channelhandler
global _CHANNEL_HANDLER
if not _CHANNEL_HANDLER:
@ -58,6 +55,15 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)):
# register ourselves with the channelhandler.
_CHANNEL_HANDLER.add(self)
self.locks.add("send:all();listen:all();control:perm(Admin)")
def at_channel_creation(self):
"""
Called once, when the channel is first created.
"""
pass
# helper methods, for easy overloading
def has_connection(self, subscriber):
@ -91,7 +97,8 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)):
@property
def wholist(self):
subs = self.subscriptions.all()
listening = [ob for ob in subs if ob.is_connected and ob not in self.mutelist]
muted = list(self.mutelist)
listening = [ob for ob in subs if ob.is_connected and ob not in muted]
if subs:
# display listening subscribers in bold
string = ", ".join([account.key if account not in listening else "|w%s|n" % account.key for account in subs])

View file

@ -355,15 +355,16 @@ class ChannelDBManager(TypedObjectManager):
channel (Channel or None): A channel match.
"""
# first check the channel key
channels = self.filter(db_key__iexact=channelkey)
if not channels:
# also check aliases
channels = [channel for channel in self.all()
if channelkey in channel.aliases.all()]
if channels:
return channels[0]
return None
dbref = self.dbref(channelkey)
if dbref:
try:
return self.get(id=dbref)
except self.model.DoesNotExist:
pass
results = self.filter(Q(db_key__iexact=channelkey) |
Q(db_tags__db_tagtype__iexact="alias",
db_tags__db_key__iexact=channelkey)).distinct()
return results[0] if results else None
def get_subscriptions(self, subscriber):
"""
@ -393,26 +394,20 @@ class ChannelDBManager(TypedObjectManager):
case sensitive) match.
"""
channels = []
if not ostring:
return channels
try:
# try an id match first
dbref = int(ostring.strip('#'))
channels = self.filter(id=dbref)
except Exception:
# Usually because we couldn't convert to int - not a dbref
pass
if not channels:
# no id match. Search on the key.
if exact:
channels = self.filter(db_key__iexact=ostring)
else:
channels = self.filter(db_key__icontains=ostring)
if not channels:
# still no match. Search by alias.
channels = [channel for channel in self.all()
if ostring.lower() in [a.lower for a in channel.aliases.all()]]
dbref = self.dbref(ostring)
if dbref:
try:
return self.get(id=dbref)
except self.model.DoesNotExist:
pass
if exact:
channels = self.filter(Q(db_key__iexact=ostring) |
Q(db_tags__db_tagtype__iexact="alias",
db_tags__db_key__iexact=ostring)).distinct()
else:
channels = self.filter(Q(db_key__icontains=ostring) |
Q(db_tags__db_tagtype__iexact="alias",
db_tags__db_key__icontains=ostring)).distinct()
return channels
# back-compatibility alias
channel_search = search_channel

View file

@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.15 on 2018-09-25 17:35
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('comms', '0015_auto_20170706_2041'),
]
operations = [
migrations.RemoveField(
model_name='channeldb',
name='db_subscriptions',
),
migrations.AlterField(
model_name='msg',
name='db_message',
field=models.TextField(verbose_name=b'message'),
),
]

View file

@ -107,7 +107,7 @@ class Msg(SharedMemoryModel):
# it, or as a separate store for the mail subject line maybe.
db_header = models.TextField('header', null=True, blank=True)
# the message body itself
db_message = models.TextField('messsage')
db_message = models.TextField('message')
# send date
db_date_created = models.DateTimeField('date sent', editable=False, auto_now_add=True, db_index=True)
# lock storage
@ -584,9 +584,7 @@ class SubscriptionHandler(object):
for obj in self.all():
from django.core.exceptions import ObjectDoesNotExist
try:
if hasattr(obj, 'account'):
if not obj.account:
continue
if hasattr(obj, 'account') and obj.account:
obj = obj.account
if not obj.is_connected:
continue

View file

@ -16,7 +16,9 @@ things you want from here into your game folder and change them there.
## Contrib modules
* Barter system (Griatch 2012) - A safe and effective barter-system
for any game. Allows safe trading of any godds (including coin)
for any game. Allows safe trading of any goods (including coin).
* Building menu (vincent-lg 2018) - An @edit command for modifying
objects using a generated menu. Customizable for different games.
* CharGen (Griatch 2011) - A simple Character creator for OOC mode.
Meant as a starting point for a more fleshed-out system.
* Clothing (FlutterSprite 2017) - A layered clothing system with
@ -29,11 +31,15 @@ things you want from here into your game folder and change them there.
that requires an email to login rather then just name+password.
* Extended Room (Griatch 2012) - An expanded Room typeclass with
multiple descriptions for time and season as well as details.
* Field Fill (FlutterSprite 2018) - A simple system for creating an
EvMenu that presents a player with a highly customizable fillable
form
* GenderSub (Griatch 2015) - Simple example (only) of storing gender
on a character and access it in an emote with a custom marker.
* Health Bar (Tim Ashley Jenkins 2017) - Tool to create colorful bars/meters.
* Mail (grungies1138 2016) - An in-game mail system for communication.
* Menu login (Griatch 2011) - A login system using menus asking
for name/password rather than giving them as one command
for name/password rather than giving them as one command.
* Map Builder (CloudKeeper 2016) - Build a game area based on a 2D
"graphical" unicode map. Supports assymmetric exits.
* Menu Login (Vincent-lg 2016) - Alternate login system using EvMenu.
@ -45,11 +51,18 @@ things you want from here into your game folder and change them there.
speaking unfamiliar languages. Also obfuscates whispers.
* RPSystem (Griatch 2015) - Full director-style emoting system
replacing names with sdescs/recogs. Supports wearing masks.
* Security/Auditing (Johhny 2018) - Log server input/output for debug/security.
* Simple Door - Example of an exit that can be opened and closed.
* Slow exit (Griatch 2014) - Custom Exit class that takes different
time to pass depending on if you are walking/running etc.
* Talking NPC (Griatch 2011) - A talking NPC object that offers a
menu-driven conversation tree.
* Tree Select (FlutterSprite 2017) - A simple system for creating a
branching EvMenu with selection options sourced from a single
multi-line string.
* Turnbattle (Tim Ashley Jenkins 2017) - This is a framework for a turn-based
combat system with different levels of complexity, including versions with
equipment and magic as well as ranged combat.
* Wilderness (titeuf87 2017) - Make infinitely large wilderness areas
with dynamically created locations.
* UnixCommand (Vincent Le Geoff 2017) - Add commands with UNIX-style syntax.
@ -57,7 +70,7 @@ things you want from here into your game folder and change them there.
## Contrib packages
* EGI_Client (gtaylor 2016) - Client for reporting game status
to the Evennia game index (games.evennia.com)
to the Evennia game index (games.evennia.com).
* In-game Python (Vincent Le Goff 2017) - Allow trusted builders to script
objects and events using Python from in-game.
* Turnbattle (FlutterSprite 2017) - A turn-based combat engine meant

File diff suppressed because it is too large Load diff

View file

@ -189,7 +189,7 @@ class ExtendedRoom(DefaultRoom):
key (str): A detail identifier.
Returns:
detail (str or None): A detail mathing the given key.
detail (str or None): A detail matching the given key.
Notes:
A detail is a way to offer more things to look at in a room
@ -213,37 +213,39 @@ class ExtendedRoom(DefaultRoom):
return detail
return None
def return_appearance(self, looker):
def return_appearance(self, looker, **kwargs):
"""
This is called when e.g. the look command wants to retrieve
the description of this object.
Args:
looker (Object): The object looking at us.
**kwargs (dict): Arbitrary, optional arguments for users
overriding the call (unused by default).
Returns:
description (str): Our description.
"""
update = False
# ensures that our description is current based on time/season
self.update_current_description()
# run the normal return_appearance method, now that desc is updated.
return super(ExtendedRoom, self).return_appearance(looker, **kwargs)
def update_current_description(self):
"""
This will update the description of the room if the time or season
has changed since last checked.
"""
update = False
# get current time and season
curr_season, curr_timeslot = self.get_time_and_season()
# compare with previously stored slots
last_season = self.ndb.last_season
last_timeslot = self.ndb.last_timeslot
if curr_season != last_season:
# season changed. Load new desc, or a fallback.
if curr_season == 'spring':
new_raw_desc = self.db.spring_desc
elif curr_season == 'summer':
new_raw_desc = self.db.summer_desc
elif curr_season == 'autumn':
new_raw_desc = self.db.autumn_desc
else:
new_raw_desc = self.db.winter_desc
new_raw_desc = self.attributes.get("%s_desc" % curr_season)
if new_raw_desc:
raw_desc = new_raw_desc
else:
@ -252,19 +254,15 @@ class ExtendedRoom(DefaultRoom):
self.db.raw_desc = raw_desc
self.ndb.last_season = curr_season
update = True
if curr_timeslot != last_timeslot:
# timeslot changed. Set update flag.
self.ndb.last_timeslot = curr_timeslot
update = True
if update:
# if anything changed we have to re-parse
# the raw_desc for time markers
# and re-save the description again.
self.db.desc = self.replace_timeslots(self.db.raw_desc, curr_timeslot)
# run the normal return_appearance method, now that desc is updated.
return super().return_appearance(looker)
# Custom Look command supporting Room details. Add this to
@ -369,6 +367,7 @@ class CmdExtendedDesc(default_cmds.CmdDesc):
"""
aliases = ["describe", "detail"]
switch_options = () # Inherits from default_cmds.CmdDesc, but unused here
def reset_times(self, obj):
"""By deleteting the caches we force a re-load."""

View file

@ -0,0 +1,667 @@
"""
Easy fillable form
Contrib - Tim Ashley Jenkins 2018
This module contains a function that calls an easily customizable EvMenu - this
menu presents the player with a fillable form, with fields that can be filled
out in any order. Each field's value can be verified, with the function
allowing easy checks for text and integer input, minimum and maximum values /
character lengths, or can even be verified by a custom function. Once the form
is submitted, the form's data is submitted as a dictionary to any callable of
your choice.
The function that initializes the fillable form menu is fairly simple, and
includes the caller, the template for the form, and the callback(caller, result) to which the form
data will be sent to upon submission.
init_fill_field(formtemplate, caller, formcallback)
Form templates are defined as a list of dictionaries - each dictionary
represents a field in the form, and contains the data for the field's name and
behavior. For example, this basic form template will allow a player to fill out
a brief character profile:
PROFILE_TEMPLATE = [
{"fieldname":"Name", "fieldtype":"text"},
{"fieldname":"Age", "fieldtype":"number"},
{"fieldname":"History", "fieldtype":"text"},
]
This will present the player with an EvMenu showing this basic form:
Name:
Age:
History:
While in this menu, the player can assign a new value to any field with the
syntax <field> = <new value>, like so:
> name = Ashley
Field 'Name' set to: Ashley
Typing 'look' by itself will show the form and its current values.
> look
Name: Ashley
Age:
History:
Number fields require an integer input, and will reject any text that can't
be converted into an integer.
> age = youthful
Field 'Age' requires a number.
> age = 31
Field 'Age' set to: 31
Form data is presented as an EvTable, so text of any length will wrap cleanly.
> history = EVERY MORNING I WAKE UP AND OPEN PALM SLAM[...]
Field 'History' set to: EVERY MORNING I WAKE UP AND[...]
> look
Name: Ashley
Age: 31
History: EVERY MORNING I WAKE UP AND OPEN PALM SLAM A VHS INTO THE SLOT.
IT'S CHRONICLES OF RIDDICK AND RIGHT THEN AND THERE I START DOING
THE MOVES ALONGSIDE WITH THE MAIN CHARACTER, RIDDICK. I DO EVERY
MOVE AND I DO EVERY MOVE HARD.
When the player types 'submit' (or your specified submit command), the menu
quits and the form's data is passed to your specified function as a dictionary,
like so:
formdata = {"Name":"Ashley", "Age":31, "History":"EVERY MORNING I[...]"}
You can do whatever you like with this data in your function - forms can be used
to set data on a character, to help builders create objects, or for players to
craft items or perform other complicated actions with many variables involved.
The data that your form will accept can also be specified in your form template -
let's say, for example, that you won't accept ages under 18 or over 100. You can
do this by specifying "min" and "max" values in your field's dictionary:
PROFILE_TEMPLATE = [
{"fieldname":"Name", "fieldtype":"text"},
{"fieldname":"Age", "fieldtype":"number", "min":18, "max":100},
{"fieldname":"History", "fieldtype":"text"}
]
Now if the player tries to enter a value out of range, the form will not acept the
given value.
> age = 10
Field 'Age' reqiures a minimum value of 18.
> age = 900
Field 'Age' has a maximum value of 100.
Setting 'min' and 'max' for a text field will instead act as a minimum or
maximum character length for the player's input.
There are lots of ways to present the form to the player - fields can have default
values or show a custom message in place of a blank value, and player input can be
verified by a custom function, allowing for a great deal of flexibility. There
is also an option for 'bool' fields, which accept only a True / False input and
can be customized to represent the choice to the player however you like (E.G.
Yes/No, On/Off, Enabled/Disabled, etc.)
This module contains a simple example form that demonstrates all of the included
functionality - a command that allows a player to compose a message to another
online character and have it send after a custom delay. You can test it by
importing this module in your game's default_cmdsets.py module and adding
CmdTestMenu to your default character's command set.
FIELD TEMPLATE KEYS:
Required:
fieldname (str): Name of the field, as presented to the player.
fieldtype (str): Type of value required: 'text', 'number', or 'bool'.
Optional:
max (int): Maximum character length (if text) or value (if number).
min (int): Minimum charater length (if text) or value (if number).
truestr (str): String for a 'True' value in a bool field.
(E.G. 'On', 'Enabled', 'Yes')
falsestr (str): String for a 'False' value in a bool field.
(E.G. 'Off', 'Disabled', 'No')
default (str): Initial value (blank if not given).
blankmsg (str): Message to show in place of value when field is blank.
cantclear (bool): Field can't be cleared if True.
required (bool): If True, form cannot be submitted while field is blank.
verifyfunc (callable): Name of a callable used to verify input - takes
(caller, value) as arguments. If the function returns True,
the player's input is considered valid - if it returns False,
the input is rejected. Any other value returned will act as
the field's new value, replacing the player's input. This
allows for values that aren't strings or integers (such as
object dbrefs). For boolean fields, return '0' or '1' to set
the field to False or True.
"""
from evennia.utils import evmenu, evtable, delay, list_to_string, logger
from evennia import Command
from evennia.server.sessionhandler import SESSIONS
class FieldEvMenu(evmenu.EvMenu):
"""
Custom EvMenu type with its own node formatter - removes extraneous lines
"""
def node_formatter(self, nodetext, optionstext):
"""
Formats the entirety of the node.
Args:
nodetext (str): The node text as returned by `self.nodetext_formatter`.
optionstext (str): The options display as returned by `self.options_formatter`.
caller (Object, Account or None, optional): The caller of the node.
Returns:
node (str): The formatted node to display.
"""
# Only return node text, no options or separators
return nodetext
def init_fill_field(formtemplate, caller, formcallback, pretext="", posttext="",
submitcmd="submit", borderstyle="cells", formhelptext=None,
persistent=False, initial_formdata=None):
"""
Initializes a menu presenting a player with a fillable form - once the form
is submitted, the data will be passed as a dictionary to your chosen
function.
Args:
formtemplate (list of dicts): The template for the form's fields.
caller (obj): Player who will be filling out the form.
formcallback (callable): Function to pass the completed form's data to.
Options:
pretext (str): Text to put before the form in the menu.
posttext (str): Text to put after the form in the menu.
submitcmd (str): Command used to submit the form.
borderstyle (str): Form's EvTable border style.
formhelptext (str): Help text for the form menu (or default is provided).
persistent (bool): Whether to make the EvMenu persistent across reboots.
initial_formdata (dict): Initial data for the form - a blank form with
defaults specified in the template will be generated otherwise.
In the case of a form used to edit properties on an object or a
similar application, you may want to generate the initial form
data dynamically before calling init_fill_field.
"""
# Initialize form data from the template if none provided
formdata = form_template_to_dict(formtemplate)
if initial_formdata:
formdata = initial_formdata
# Provide default help text if none given
if formhelptext is None:
formhelptext = (
"Available commands:|/"
"|w<field> = <new value>:|n Set given field to new value, replacing the old value|/"
"|wclear <field>:|n Clear the value in the given field, making it blank|/"
"|wlook|n: Show the form's current values|/"
"|whelp|n: Display this help screen|/"
"|wquit|n: Quit the form menu without submitting|/"
"|w%s|n: Submit this form and quit the menu" % submitcmd)
# Pass kwargs to store data needed in the menu
kwargs = {
"formdata": formdata,
"formtemplate": formtemplate,
"formcallback": formcallback,
"pretext": pretext,
"posttext": posttext,
"submitcmd": submitcmd,
"borderstyle": borderstyle,
"formhelptext": formhelptext
}
# Initialize menu of selections
FieldEvMenu(caller, "evennia.contrib.fieldfill", startnode="menunode_fieldfill",
auto_look=False, persistent=persistent, **kwargs)
def menunode_fieldfill(caller, raw_string, **kwargs):
"""
This is an EvMenu node, which calls itself over and over in order to
allow a player to enter values into a fillable form. When the form is
submitted, the form data is passed to a callback as a dictionary.
"""
# Retrieve menu info - taken from ndb if not persistent or db if persistent
if not caller.db._menutree:
formdata = caller.ndb._menutree.formdata
formtemplate = caller.ndb._menutree.formtemplate
formcallback = caller.ndb._menutree.formcallback
pretext = caller.ndb._menutree.pretext
posttext = caller.ndb._menutree.posttext
submitcmd = caller.ndb._menutree.submitcmd
borderstyle = caller.ndb._menutree.borderstyle
formhelptext = caller.ndb._menutree.formhelptext
else:
formdata = caller.db._menutree.formdata
formtemplate = caller.db._menutree.formtemplate
formcallback = caller.db._menutree.formcallback
pretext = caller.db._menutree.pretext
posttext = caller.db._menutree.posttext
submitcmd = caller.db._menutree.submitcmd
borderstyle = caller.db._menutree.borderstyle
formhelptext = caller.db._menutree.formhelptext
# Syntax error
syntax_err = "Syntax: <field> = <new value>|/Or: clear <field>, help, look, quit|/'%s' to submit form" % submitcmd
# Display current form data
text = (display_formdata(formtemplate, formdata, pretext=pretext,
posttext=posttext, borderstyle=borderstyle), formhelptext)
options = ({"key": "_default",
"goto": "menunode_fieldfill"})
if raw_string:
# Test for given 'submit' command
if raw_string.lower().strip() == submitcmd:
# Test to see if any blank fields are required
blank_and_required = []
for field in formtemplate:
if "required" in field.keys():
# If field is required but current form data for field is blank
if field["required"] is True and formdata[field["fieldname"]] is None:
# Add to blank and required fields
blank_and_required.append(field["fieldname"])
if len(blank_and_required) > 0:
# List the required fields left empty to the player
caller.msg("The following blank fields require a value: %s" % list_to_string(blank_and_required))
text = (None, formhelptext)
return text, options
# If everything checks out, pass form data to the callback and end the menu!
try:
formcallback(caller, formdata)
except Exception:
logger.log_trace("Error in fillable form callback.")
return None, None
# Test for 'look' command
if raw_string.lower().strip() == "look" or raw_string.lower().strip() == "l":
return text, options
# Test for 'clear' command
cleartest = raw_string.lower().strip().split(" ", 1)
if cleartest[0].lower() == "clear":
text = (None, formhelptext)
if len(cleartest) < 2:
caller.msg(syntax_err)
return text, options
matched_field = None
for key in formdata.keys():
if cleartest[1].lower() in key.lower():
matched_field = key
if not matched_field:
caller.msg("Field '%s' does not exist!" % cleartest[1])
text = (None, formhelptext)
return text, options
# Test to see if field can be cleared
for field in formtemplate:
if field["fieldname"] == matched_field and "cantclear" in field.keys():
if field["cantclear"] is True:
caller.msg("Field '%s' can't be cleared!" % matched_field)
text = (None, formhelptext)
return text, options
# Clear the field
formdata.update({matched_field: None})
caller.ndb._menutree.formdata = formdata
caller.msg("Field '%s' cleared." % matched_field)
return text, options
if "=" not in raw_string:
text = (None, formhelptext)
caller.msg(syntax_err)
return text, options
# Extract field name and new field value
entry = raw_string.split("=", 1)
fieldname = entry[0].strip()
newvalue = entry[1].strip()
# Syntax error if field name is too short or blank
if len(fieldname) < 1:
caller.msg(syntax_err)
text = (None, formhelptext)
return text, options
# Attempt to match field name to field in form data
matched_field = None
for key in formdata.keys():
if fieldname.lower() in key.lower():
matched_field = key
# No matched field
if matched_field is None:
caller.msg("Field '%s' does not exist!" % fieldname)
text = (None, formhelptext)
return text, options
# Set new field value if match
# Get data from template
fieldtype = None
max_value = None
min_value = None
truestr = "True"
falsestr = "False"
verifyfunc = None
for field in formtemplate:
if field["fieldname"] == matched_field:
fieldtype = field["fieldtype"]
if "max" in field.keys():
max_value = field["max"]
if "min" in field.keys():
min_value = field["min"]
if "truestr" in field.keys():
truestr = field["truestr"]
if "falsestr" in field.keys():
falsestr = field["falsestr"]
if "verifyfunc" in field.keys():
verifyfunc = field["verifyfunc"]
# Field type text verification
if fieldtype == "text":
# Test for max/min
if max_value is not None:
if len(newvalue) > max_value:
caller.msg("Field '%s' has a maximum length of %i characters." % (matched_field, max_value))
text = (None, formhelptext)
return text, options
if min_value is not None:
if len(newvalue) < min_value:
caller.msg("Field '%s' reqiures a minimum length of %i characters." % (matched_field, min_value))
text = (None, formhelptext)
return text, options
# Field type number verification
if fieldtype == "number":
try:
newvalue = int(newvalue)
except:
caller.msg("Field '%s' requires a number." % matched_field)
text = (None, formhelptext)
return text, options
# Test for max/min
if max_value is not None:
if newvalue > max_value:
caller.msg("Field '%s' has a maximum value of %i." % (matched_field, max_value))
text = (None, formhelptext)
return text, options
if min_value is not None:
if newvalue < min_value:
caller.msg("Field '%s' reqiures a minimum value of %i." % (matched_field, min_value))
text = (None, formhelptext)
return text, options
# Field type bool verification
if fieldtype == "bool":
if newvalue.lower() != truestr.lower() and newvalue.lower() != falsestr.lower():
caller.msg("Please enter '%s' or '%s' for field '%s'." % (truestr, falsestr, matched_field))
text = (None, formhelptext)
return text, options
if newvalue.lower() == truestr.lower():
newvalue = True
elif newvalue.lower() == falsestr.lower():
newvalue = False
# Call verify function if present
if verifyfunc:
if verifyfunc(caller, newvalue) is False:
# No error message is given - should be provided by verifyfunc
text = (None, formhelptext)
return text, options
elif verifyfunc(caller, newvalue) is not True:
newvalue = verifyfunc(caller, newvalue)
# Set '0' or '1' to True or False if the field type is bool
if fieldtype == "bool":
if newvalue == 0:
newvalue = False
elif newvalue == 1:
newvalue = True
# If everything checks out, update form!!
formdata.update({matched_field: newvalue})
caller.ndb._menutree.formdata = formdata
# Account for truestr and falsestr when updating a boolean form
announced_newvalue = newvalue
if newvalue is True:
announced_newvalue = truestr
elif newvalue is False:
announced_newvalue = falsestr
# Announce the new value to the player
caller.msg("Field '%s' set to: %s" % (matched_field, str(announced_newvalue)))
text = (None, formhelptext)
return text, options
def form_template_to_dict(formtemplate):
"""
Initializes a dictionary of form data from the given list-of-dictionaries
form template, as formatted above.
Args:
formtemplate (list of dicts): Tempate for the form to be initialized.
Returns:
formdata (dict): Dictionary of initalized form data.
"""
formdata = {}
for field in formtemplate:
# Value is blank by default
fieldvalue = None
if "default" in field:
# Add in default value if present
fieldvalue = field["default"]
formdata.update({field["fieldname"]: fieldvalue})
return formdata
def display_formdata(formtemplate, formdata,
pretext="", posttext="", borderstyle="cells"):
"""
Displays a form's current data as a table. Used in the form menu.
Args:
formtemplate (list of dicts): Template for the form
formdata (dict): Form's current data
Options:
pretext (str): Text to put before the form table.
posttext (str): Text to put after the form table.
borderstyle (str): EvTable's border style.
"""
formtable = evtable.EvTable(border=borderstyle, valign="t", maxwidth=80)
field_name_width = 5
for field in formtemplate:
new_fieldname = None
new_fieldvalue = None
# Get field name
new_fieldname = "|w" + field["fieldname"] + ":|n"
if len(field["fieldname"]) + 5 > field_name_width:
field_name_width = len(field["fieldname"]) + 5
# Get field value
if formdata[field["fieldname"]] is not None:
new_fieldvalue = str(formdata[field["fieldname"]])
# Use blank message if field is blank and once is present
if new_fieldvalue is None and "blankmsg" in field:
new_fieldvalue = "|x" + str(field["blankmsg"]) + "|n"
elif new_fieldvalue is None:
new_fieldvalue = " "
# Replace True and False values with truestr and falsestr from template
if formdata[field["fieldname"]] is True and "truestr" in field:
new_fieldvalue = field["truestr"]
elif formdata[field["fieldname"]] is False and "falsestr" in field:
new_fieldvalue = field["falsestr"]
# Add name and value to table
formtable.add_row(new_fieldname, new_fieldvalue)
formtable.reformat_column(0, align="r", width=field_name_width)
return pretext + "|/" + str(formtable) + "|/" + posttext
# EXAMPLE FUNCTIONS / COMMAND STARTS HERE
def verify_online_player(caller, value):
"""
Example 'verify function' that matches player input to an online character
or else rejects their input as invalid.
Args:
caller (obj): Player entering the form data.
value (str): String player entered into the form, to be verified.
Returns:
matched_character (obj or False): dbref to a currently logged in
character object - reference to the object will be stored in
the form instead of a string. Returns False if no match is
made.
"""
# Get a list of sessions
session_list = SESSIONS.get_sessions()
char_list = []
matched_character = None
# Get a list of online characters
for session in session_list:
if not session.logged_in:
# Skip over logged out characters
continue
# Append to our list of online characters otherwise
char_list.append(session.get_puppet())
# Match player input to a character name
for character in char_list:
if value.lower() == character.key.lower():
matched_character = character
# If input didn't match to a character
if not matched_character:
# Send the player an error message unique to this function
caller.msg("No character matching '%s' is online." % value)
# Returning False indicates the new value is not valid
return False
# Returning anything besides True or False will replace the player's input with the returned
# value. In this case, the value becomes a reference to the character object. You can store data
# besides strings and integers in the 'formdata' dictionary this way!
return matched_character
# Form template for the example 'delayed message' form
SAMPLE_FORM = [
{"fieldname": "Character",
"fieldtype": "text",
"max": 30,
"blankmsg": "(Name of an online player)",
"required": True,
"verifyfunc": verify_online_player
},
{"fieldname": "Delay",
"fieldtype": "number",
"min": 3,
"max": 30,
"default": 10,
"cantclear": True
},
{"fieldname": "Message",
"fieldtype": "text",
"min": 3,
"max": 200,
"blankmsg": "(Message up to 200 characters)"
},
{"fieldname": "Anonymous",
"fieldtype": "bool",
"truestr": "Yes",
"falsestr": "No",
"default": False
}
]
class CmdTestMenu(Command):
"""
This test command will initialize a menu that presents you with a form.
You can fill out the fields of this form in any order, and then type in
'send' to send a message to another online player, which will reach them
after a delay you specify.
Usage:
<field> = <new value>
clear <field>
help
look
quit
send
"""
key = "testmenu"
def func(self):
"""
This performs the actual command.
"""
pretext = "|cSend a delayed message to another player ---------------------------------------|n"
posttext = ("|c--------------------------------------------------------------------------------|n|/"
"Syntax: type |c<field> = <new value>|n to change the values of the form. Given|/"
"player must be currently logged in, delay is given in seconds. When you are|/"
"finished, type '|csend|n' to send the message.|/")
init_fill_field(SAMPLE_FORM, self.caller, init_delayed_message,
pretext=pretext, posttext=posttext,
submitcmd="send", borderstyle="none")
def sendmessage(obj, text):
"""
Callback to send a message to a player.
Args:
obj (obj): Player to message.
text (str): Message.
"""
obj.msg(text)
def init_delayed_message(caller, formdata):
"""
Initializes a delayed message, using data from the example form.
Args:
caller (obj): Character submitting the message.
formdata (dict): Data from submitted form.
"""
# Retrieve data from the filled out form.
# We stored the character to message as an object ref using a verifyfunc
# So we don't have to do any more searching or matching here!
player_to_message = formdata["Character"]
message_delay = formdata["Delay"]
sender = str(caller)
if formdata["Anonymous"] is True:
sender = "anonymous"
message = ("Message from %s: " % sender) + str(formdata["Message"])
caller.msg("Message sent to %s!" % player_to_message)
# Make a deferred call to 'sendmessage' above.
delay(message_delay, sendmessage, player_to_message, message)
return

View file

@ -0,0 +1,103 @@
"""
Health Bar
Contrib - Tim Ashley Jenkins 2017
The function provided in this module lets you easily display visual
bars or meters - "health bar" is merely the most obvious use for this,
though these bars are highly customizable and can be used for any sort
of appropriate data besides player health.
Today's players may be more used to seeing statistics like health,
stamina, magic, and etc. displayed as bars rather than bare numerical
values, so using this module to present this data this way may make it
more accessible. Keep in mind, however, that players may also be using
a screen reader to connect to your game, which will not be able to
represent the colors of the bar in any way. By default, the values
represented are rendered as text inside the bar which can be read by
screen readers.
The health bar will account for current values above the maximum or
below 0, rendering them as a completely full or empty bar with the
values displayed within.
"""
def display_meter(cur_value, max_value,
length=30, fill_color=["R", "Y", "G"],
empty_color="B", text_color="w",
align="left", pre_text="", post_text="",
show_values=True):
"""
Represents a current and maximum value given as a "bar" rendered with
ANSI or xterm256 background colors.
Args:
cur_value (int): Current value to display
max_value (int): Maximum value to display
Options:
length (int): Length of meter returned, in characters
fill_color (list): List of color codes for the full portion
of the bar, sans any sort of prefix - both ANSI and xterm256
colors are usable. When the bar is empty, colors toward the
start of the list will be chosen - when the bar is full, colors
towards the end are picked. You can adjust the 'weights' of
the changing colors by adding multiple entries of the same
color - for example, if you only want the bar to change when
it's close to empty, you could supply ['R','Y','G','G','G']
empty_color (str): Color code for the empty portion of the bar.
text_color (str): Color code for text inside the bar.
align (str): "left", "right", or "center" - alignment of text in the bar
pre_text (str): Text to put before the numbers in the bar
post_text (str): Text to put after the numbers in the bar
show_values (bool): If true, shows the numerical values represented by
the bar. It's highly recommended you keep this on, especially if
there's no info given in pre_text or post_text, as players on screen
readers will be unable to read the graphical aspect of the bar.
"""
# Start by building the base string.
num_text = ""
if show_values:
num_text = "%i / %i" % (cur_value, max_value)
bar_base_str = pre_text + num_text + post_text
# Cut down the length of the base string if needed
if len(bar_base_str) > length:
bar_base_str = bar_base_str[:length]
# Pad and align the bar base string
if align == "right":
bar_base_str = bar_base_str.rjust(length, " ")
elif align == "center":
bar_base_str = bar_base_str.center(length, " ")
else:
bar_base_str = bar_base_str.ljust(length, " ")
if max_value < 1: # Prevent divide by zero
max_value = 1
if cur_value < 0: # Prevent weirdly formatted 'negative bars'
cur_value = 0
if cur_value > max_value: # Display overfull bars correctly
cur_value = max_value
# Now it's time to determine where to put the color codes.
percent_full = float(cur_value) / float(max_value)
split_index = round(float(length) * percent_full)
# Determine point at which to split the bar
split_index = int(split_index)
# Separate the bar string into full and empty portions
full_portion = bar_base_str[:split_index]
empty_portion = bar_base_str[split_index:]
# Pick which fill color to use based on how full the bar is
fillcolor_index = (float(len(fill_color)) * percent_full)
fillcolor_index = int(round(fillcolor_index)) - 1
fillcolor_code = "|[" + fill_color[fillcolor_index]
# Make color codes for empty bar portion and text_color
emptycolor_code = "|[" + empty_color
textcolor_code = "|" + text_color
# Assemble the final bar
final_bar = fillcolor_code + textcolor_code + full_portion + "|n" + emptycolor_code + textcolor_code + empty_portion + "|n"
return final_bar

View file

@ -430,7 +430,7 @@ class EventCharacter(DefaultCharacter):
# Browse all the room's other characters
for obj in location.contents:
if obj is self or not inherits_from(obj, "objects.objects.DefaultCharacter"):
if obj is self or not inherits_from(obj, "evennia.objects.objects.DefaultCharacter"):
continue
allow = obj.callbacks.call("can_say", self, obj, message, parameters=message)
@ -491,7 +491,7 @@ class EventCharacter(DefaultCharacter):
parameters=message)
# Call the other characters' "say" event
presents = [obj for obj in location.contents if obj is not self and inherits_from(obj, "objects.objects.DefaultCharacter")]
presents = [obj for obj in location.contents if obj is not self and inherits_from(obj, "evennia.objects.objects.DefaultCharacter")]
for present in presents:
present.callbacks.call("say", self, present, message, parameters=message)

View file

@ -253,7 +253,7 @@ class CmdMail(default_cmds.MuxCommand):
index += 1
table.reformat_column(0, width=6)
table.reformat_column(1, width=17)
table.reformat_column(1, width=18)
table.reformat_column(2, width=34)
table.reformat_column(3, width=13)
table.reformat_column(4, width=7)

View file

@ -21,30 +21,30 @@ in the game in various ways:
Usage:
```python
from evennia.contrib import rplanguages
from evennia.contrib import rplanguage
# need to be done once, here we create the "default" lang
rplanguages.add_language()
rplanguage.add_language()
say = "This is me talking."
whisper = "This is me whispering.
print rplanguages.obfuscate_language(say, level=0.0)
print rplanguage.obfuscate_language(say, level=0.0)
<<< "This is me talking."
print rplanguages.obfuscate_language(say, level=0.5)
print rplanguage.obfuscate_language(say, level=0.5)
<<< "This is me byngyry."
print rplanguages.obfuscate_language(say, level=1.0)
print rplanguage.obfuscate_language(say, level=1.0)
<<< "Daly ly sy byngyry."
result = rplanguages.obfuscate_whisper(whisper, level=0.0)
result = rplanguage.obfuscate_whisper(whisper, level=0.0)
<<< "This is me whispering"
result = rplanguages.obfuscate_whisper(whisper, level=0.2)
result = rplanguage.obfuscate_whisper(whisper, level=0.2)
<<< "This is m- whisp-ring"
result = rplanguages.obfuscate_whisper(whisper, level=0.5)
result = rplanguage.obfuscate_whisper(whisper, level=0.5)
<<< "---s -s -- ---s------"
result = rplanguages.obfuscate_whisper(whisper, level=0.7)
result = rplanguage.obfuscate_whisper(whisper, level=0.7)
<<< "---- -- -- ----------"
result = rplanguages.obfuscate_whisper(whisper, level=1.0)
result = rplanguage.obfuscate_whisper(whisper, level=1.0)
<<< "..."
```
@ -71,7 +71,7 @@ Usage:
manual_translations = {"the":"y'e", "we":"uyi", "she":"semi", "he":"emi",
"you": "do", 'me':'mi','i':'me', 'be':"hy'e", 'and':'y'}
rplanguages.add_language(key="elvish", phonemes=phonemes, grammar=grammar,
rplanguage.add_language(key="elvish", phonemes=phonemes, grammar=grammar,
word_length_variance=word_length_variance,
noun_postfix=noun_postfix, vowels=vowels,
manual_translations=manual_translations
@ -96,6 +96,7 @@ import re
from random import choice, randint
from collections import defaultdict
from evennia import DefaultScript
from evennia.utils import logger
#------------------------------------------------------------
@ -105,21 +106,26 @@ from evennia import DefaultScript
#------------------------------------------------------------
# default language grammar
_PHONEMES = "ea oh ae aa eh ah ao aw ai er ey ow ia ih iy oy ua uh uw a e i u y p b t d f v t dh s z sh zh ch jh k ng g m n l r w"
_PHONEMES = "ea oh ae aa eh ah ao aw ai er ey ow ia ih iy oy ua uh uw a e i u y p b t d f v t dh " \
"s z sh zh ch jh k ng g m n l r w"
_VOWELS = "eaoiuy"
# these must be able to be constructed from phonemes (so for example,
# if you have v here, there must exixt at least one single-character
# if you have v here, there must exist at least one single-character
# vowel phoneme defined above)
_GRAMMAR = "v cv vc cvv vcc vcv cvcc vccv cvccv cvcvcc cvccvcv vccvccvc cvcvccvv cvcvcvcvv"
_RE_FLAGS = re.MULTILINE + re.IGNORECASE + re.UNICODE
_RE_GRAMMAR = re.compile(r"vv|cc|v|c", _RE_FLAGS)
_RE_WORD = re.compile(r'\w+', _RE_FLAGS)
_RE_EXTRA_CHARS = re.compile(r'\s+(?=\W)|[,.?;](?=[,.?;]|\s+[,.?;])', _RE_FLAGS)
class LanguageExistsError(Exception):
message = "Language is already created. Re-adding it will re-build" \
" its dictionary map. Use 'force=True' keyword if you are sure."
class LanguageError(RuntimeError):
pass
class LanguageExistsError(LanguageError):
pass
class LanguageHandler(DefaultScript):
@ -156,8 +162,11 @@ class LanguageHandler(DefaultScript):
self.db.language_storage = {}
def add(self, key="default", phonemes=_PHONEMES,
grammar=_GRAMMAR, word_length_variance=0, noun_prefix="",
noun_postfix="", vowels=_VOWELS, manual_translations=None,
grammar=_GRAMMAR, word_length_variance=0,
noun_translate=False,
noun_prefix="",
noun_postfix="",
vowels=_VOWELS, manual_translations=None,
auto_translations=None, force=False):
"""
Add a new language. Note that you generally only need to do
@ -170,14 +179,21 @@ class LanguageHandler(DefaultScript):
will be used as an identifier for the language so it
should be short and unique.
phonemes (str, optional): Space-separated string of all allowed
phonemes in this language.
phonemes in this language. If either of the base phonemes
(c, v, cc, vv) are present in the grammar, the phoneme list must
at least include one example of each.
grammar (str): All allowed consonant (c) and vowel (v) combinations
allowed to build up words. For example cvv would be a consonant
followed by two vowels (would allow for a word like 'die').
allowed to build up words. Grammars are broken into the base phonemes
(c, v, cc, vv) prioritizing the longer bases. So cvv would be a
the c + vv (would allow for a word like 'die' whereas
cvcvccc would be c+v+c+v+cc+c (a word like 'galosch').
word_length_variance (real): The variation of length of words.
0 means a minimal variance, higher variance may mean words
have wildly varying length; this strongly affects how the
language "looks".
noun_translate (bool, optional): If a proper noun, identified as a
capitalized word, should be translated or not. By default they
will not, allowing for e.g. the names of characters to be understandable.
noun_prefix (str, optional): A prefix to go before every noun
in this language (if any).
noun_postfix (str, optuonal): A postfix to go after every noun
@ -213,21 +229,28 @@ class LanguageHandler(DefaultScript):
"""
if key in self.db.language_storage and not force:
raise LanguageExistsError
# allowed grammar are grouped by length
gramdict = defaultdict(list)
for gram in grammar.split():
gramdict[len(gram)].append(gram)
grammar = dict(gramdict)
raise LanguageExistsError(
"Language is already created. Re-adding it will re-build"
" its dictionary map. Use 'force=True' keyword if you are sure.")
# create grammar_component->phoneme mapping
# {"vv": ["ea", "oh", ...], ...}
grammar2phonemes = defaultdict(list)
for phoneme in phonemes.split():
if re.search("\W", phoneme):
raise LanguageError("The phoneme '%s' contains an invalid character" % phoneme)
gram = "".join(["v" if char in vowels else "c" for char in phoneme])
grammar2phonemes[gram].append(phoneme)
# allowed grammar are grouped by length
gramdict = defaultdict(list)
for gram in grammar.split():
if re.search("\W|(!=[cv])", gram):
raise LanguageError("The grammar '%s' is invalid (only 'c' and 'v' are allowed)" % gram)
gramdict[len(gram)].append(gram)
grammar = dict(gramdict)
# create automatic translation
translation = {}
@ -261,6 +284,7 @@ class LanguageHandler(DefaultScript):
"grammar": grammar,
"grammar2phonemes": dict(grammar2phonemes),
"word_length_variance": word_length_variance,
"noun_translate": noun_translate,
"noun_prefix": noun_prefix,
"noun_postfix": noun_postfix}
self.db.language_storage[key] = storage
@ -282,34 +306,63 @@ class LanguageHandler(DefaultScript):
"""
word = match.group()
lword = len(word)
if len(word) <= self.level:
# below level. Don't translate
new_word = word
else:
# translate the word
# try to translate the word from dictionary
new_word = self.language["translation"].get(word.lower(), "")
if not new_word:
if word.istitle():
# capitalized word we don't have a translation for -
# treat as a name (don't translate)
new_word = "%s%s%s" % (self.language["noun_prefix"], word, self.language["noun_postfix"])
else:
# make up translation on the fly. Length can
# vary from un-translated word.
wlen = max(0, lword + sum(randint(-1, 1) for i
in range(self.language["word_length_variance"])))
grammar = self.language["grammar"]
if wlen not in grammar:
# no dictionary translation. Generate one
# find out what preceeded this word
wpos = match.start()
preceeding = match.string[:wpos].strip()
start_sentence = preceeding.endswith(".") or not preceeding
# make up translation on the fly. Length can
# vary from un-translated word.
wlen = max(0, lword + sum(randint(-1, 1) for i
in range(self.language["word_length_variance"])))
grammar = self.language["grammar"]
if wlen not in grammar:
if randint(0, 1) == 0:
# this word has no direct translation!
return ""
wlen = 0
new_word = ''
else:
# use random word length
wlen = choice(grammar.keys())
if wlen:
structure = choice(grammar[wlen])
grammar2phonemes = self.language["grammar2phonemes"]
for match in _RE_GRAMMAR.finditer(structure):
# there are only four combinations: vv,cc,c,v
new_word += choice(grammar2phonemes[match.group()])
if word.istitle():
# capitalize words the same way
new_word = new_word.capitalize()
try:
new_word += choice(grammar2phonemes[match.group()])
except KeyError:
logger.log_trace("You need to supply at least one example of each of "
"the four base phonemes (c, v, cc, vv)")
# abort translation here
new_word = ''
break
if word.istitle():
title_word = ''
if not start_sentence and not self.language.get("noun_translate", False):
# don't translate what we identify as proper nouns (names)
title_word = word
elif new_word:
title_word = new_word
if title_word:
# Regardless of if we translate or not, we will add the custom prefix/postfixes
new_word = "%s%s%s" % (self.language["noun_prefix"],
title_word.capitalize(),
self.language["noun_postfix"])
if len(word) > 1 and word.isupper():
# keep LOUD words loud also when translated
new_word = new_word.upper()
@ -341,7 +394,9 @@ class LanguageHandler(DefaultScript):
# configuring the translation
self.level = int(10 * (1.0 - max(0, min(level, 1.0))))
return _RE_WORD.sub(self._translate_sub, text)
translation = _RE_WORD.sub(self._translate_sub, text)
# the substitution may create too long empty spaces, remove those
return _RE_EXTRA_CHARS.sub("", translation)
# Language access functions

View file

@ -708,12 +708,15 @@ class RecogHandler(object):
than `max_length`.
"""
if not obj.access(self.obj, "enable_recog", default=True):
raise SdescError("This person is unrecognizeable.")
# strip emote components from recog
recog = _RE_REF.sub(r"\1",
_RE_REF_LANG.sub(r"\1",
_RE_SELF_REF.sub(r"",
_RE_LANGUAGE.sub(r"",
_RE_OBJ_REF_START.sub(r"", recog)))))
recog = _RE_REF.sub(
r"\1", _RE_REF_LANG.sub(
r"\1", _RE_SELF_REF.sub(
r"", _RE_LANGUAGE.sub(
r"", _RE_OBJ_REF_START.sub(r"", recog)))))
# make an recog clean of ANSI codes
cleaned_recog = ansi.strip_ansi(recog)
@ -1085,7 +1088,7 @@ class CmdMask(RPCommand):
if self.cmdstring == "mask":
# wear a mask
if not self.args:
caller.msg("Usage: (un)wearmask sdesc")
caller.msg("Usage: (un)mask sdesc")
return
if caller.db.unmasked_sdesc:
caller.msg("You are already wearing a mask.")
@ -1108,7 +1111,7 @@ class CmdMask(RPCommand):
del caller.db.unmasked_sdesc
caller.locks.remove("enable_recog")
caller.sdesc.add(old_sdesc)
caller.msg("You remove your mask and is again '%s'." % old_sdesc)
caller.msg("You remove your mask and are again '%s'." % old_sdesc)
class RPSystemCmdSet(CmdSet):
@ -1200,7 +1203,7 @@ class ContribRPObject(DefaultObject):
below.
exact (bool): if unset (default) - prefers to match to beginning of
string rather than not matching at all. If set, requires
exact mathing of entire string.
exact matching of entire string.
candidates (list of objects): this is an optional custom list of objects
to search (filter) between. It is ignored if `global_search`
is given. If not set, this list will automatically be defined

View file

@ -0,0 +1,5 @@
# Security
This directory contains security-related contribs
- Auditing (Johnny 2018) - Allow for optional security logging of user input/output.

View file

View file

@ -0,0 +1,72 @@
# Input/Output Auditing
Contrib - Johnny 2017
This is a tap that optionally intercepts all data sent to/from clients and the
server and passes it to a callback of your choosing.
It is intended for quality assurance, post-incident investigations and debugging
but obviously can be abused. All data is recorded in cleartext. Please
be ethical, and if you are unwilling to properly deal with the implications of
recording user passwords or private communications, please do not enable
this module.
Some checks have been implemented to protect the privacy of users.
Files included in this module:
outputs.py - Example callback methods. This module ships with examples of
callbacks that send data as JSON to a file in your game/server/logs
dir or to your native Linux syslog daemon. You can of course write
your own to do other things like post them to Kafka topics.
server.py - Extends the Evennia ServerSession object to pipe data to the
callback upon receipt.
tests.py - Unit tests that check to make sure commands with sensitive
arguments are having their PII scrubbed.
Installation/Configuration:
Deployment is completed by configuring a few settings in server.conf. This line
is required:
SERVER_SESSION_CLASS = 'evennia.contrib.security.auditing.server.AuditedServerSession'
This tells Evennia to use this ServerSession instead of its own. Below are the
other possible options along with the default value that will be used if unset.
# Where to send logs? Define the path to a module containing your callback
# function. It should take a single dict argument as input
AUDIT_CALLBACK = 'evennia.contrib.security.auditing.outputs.to_file'
# Log user input? Be ethical about this; it will log all private and
# public communications between players and/or admins (default: False).
AUDIT_IN = False
# Log server output? This will result in logging of ALL system
# messages and ALL broadcasts to connected players, so on a busy game any
# broadcast to all users will yield a single event for every connected user!
AUDIT_OUT = False
# The default output is a dict. Do you want to allow key:value pairs with
# null/blank values? If you're just writing to disk, disabling this saves
# some disk space, but whether you *want* sparse values or not is more of a
# consideration if you're shipping logs to a NoSQL/schemaless database.
# (default: False)
AUDIT_ALLOW_SPARSE = False
# If you write custom commands that handle sensitive data like passwords,
# you must write a regular expression to remove that before writing to log.
# AUDIT_MASKS is a list of dictionaries that define the names of commands
# and the regexes needed to scrub them.
# The system already has defaults to filter out sensitive login/creation
# commands in the default command set. Your list of AUDIT_MASKS will be appended
# to those defaults.
#
# In the regex, the sensitive data itself must be captured in a named group with a
# label of 'secret' (see the Python docs on the `re` module for more info). For
# example: `{'authentication': r"^@auth\s+(?P<secret>[\w]+)"}`
AUDIT_MASKS = []

View file

@ -0,0 +1,60 @@
"""
Auditable Server Sessions - Example Outputs
Example methods demonstrating output destinations for logs generated by
audited server sessions.
This is designed to be a single source of events for developers to customize
and add any additional enhancements before events are written out-- i.e. if you
want to keep a running list of what IPs a user logs in from on account/character
objects, or if you want to perform geoip or ASN lookups on IPs before committing,
or tag certain events with the results of a reputational lookup, this should be
the easiest place to do it. Write a method and invoke it via
`settings.AUDIT_CALLBACK` to have log data objects passed to it.
Evennia contribution - Johnny 2017
"""
from evennia.utils.logger import log_file
import json
import syslog
def to_file(data):
"""
Writes dictionaries of data generated by an AuditedServerSession to files
in JSON format, bucketed by date.
Uses Evennia's native logger and writes to the default
log directory (~/yourgame/server/logs/ or settings.LOG_DIR)
Args:
data (dict): Parsed session transmission data.
"""
# Bucket logs by day and remove objects before serialization
bucket = data.pop('objects')['time'].strftime('%Y-%m-%d')
# Write it
log_file(json.dumps(data), filename="audit_%s.log" % bucket)
def to_syslog(data):
"""
Writes dictionaries of data generated by an AuditedServerSession to syslog.
Takes advantage of your system's native logger and writes to wherever
you have it configured, which is independent of Evennia.
Linux systems tend to write to /var/log/syslog.
If you're running rsyslog, you can configure it to dump and/or forward logs
to disk and/or an external data warehouse (recommended-- if your server is
compromised or taken down, losing your logs along with it is no help!).
Args:
data (dict): Parsed session transmission data.
"""
# Remove objects before serialization
data.pop('objects')
# Write it out
syslog.syslog(json.dumps(data))

View file

@ -0,0 +1,241 @@
"""
Auditable Server Sessions:
Extension of the stock ServerSession that yields objects representing
user inputs and system outputs.
Evennia contribution - Johnny 2017
"""
import os
import re
import socket
from django.utils import timezone
from django.conf import settings as ev_settings
from evennia.utils import utils, logger, mod_import, get_evennia_version
from evennia.server.serversession import ServerSession
# Attributes governing auditing of commands and where to send log objects
AUDIT_CALLBACK = getattr(ev_settings, 'AUDIT_CALLBACK',
'evennia.contrib.security.auditing.outputs.to_file')
AUDIT_IN = getattr(ev_settings, 'AUDIT_IN', False)
AUDIT_OUT = getattr(ev_settings, 'AUDIT_OUT', False)
AUDIT_ALLOW_SPARSE = getattr(ev_settings, 'AUDIT_ALLOW_SPARSE', False)
AUDIT_MASKS = [
{'connect': r"^[@\s]*[connect]{5,8}\s+(\".+?\"|[^\s]+)\s+(?P<secret>.+)"},
{'connect': r"^[@\s]*[connect]{5,8}\s+(?P<secret>[\w]+)"},
{'create': r"^[^@]?[create]{5,6}\s+(\w+|\".+?\")\s+(?P<secret>[\w]+)"},
{'create': r"^[^@]?[create]{5,6}\s+(?P<secret>[\w]+)"},
{'userpassword': r"^[@\s]*[userpassword]{11,14}\s+(\w+|\".+?\")\s+=*\s*(?P<secret>[\w]+)"},
{'userpassword': r"^.*new password set to '(?P<secret>[^']+)'\."},
{'userpassword': r"^.* has changed your password to '(?P<secret>[^']+)'\."},
{'password': r"^[@\s]*[password]{6,9}\s+(?P<secret>.*)"},
] + getattr(ev_settings, 'AUDIT_MASKS', [])
if AUDIT_CALLBACK:
try:
AUDIT_CALLBACK = getattr(
mod_import('.'.join(AUDIT_CALLBACK.split('.')[:-1])), AUDIT_CALLBACK.split('.')[-1])
logger.log_sec("Auditing module online.")
logger.log_sec("Audit record User input: {}, output: {}.\n"
"Audit sparse recording: {}, Log callback: {}".format(
AUDIT_IN, AUDIT_OUT, AUDIT_ALLOW_SPARSE, AUDIT_CALLBACK))
except Exception as e:
logger.log_err("Failed to activate Auditing module. %s" % e)
class AuditedServerSession(ServerSession):
"""
This particular implementation parses all server inputs and/or outputs and
passes a dict containing the parsed metadata to a callback method of your
creation. This is useful for recording player activity where necessary for
security auditing, usage analysis or post-incident forensic discovery.
*** WARNING ***
All strings are recorded and stored in plaintext. This includes those strings
which might contain sensitive data (create, connect, @password). These commands
have their arguments masked by default, but you must mask or mask any
custom commands of your own that handle sensitive information.
See README.md for installation/configuration instructions.
"""
def audit(self, **kwargs):
"""
Extracts messages and system data from a Session object upon message
send or receive.
Kwargs:
src (str): Source of data; 'client' or 'server'. Indicates direction.
text (str or list): Client sends messages to server in the form of
lists. Server sends messages to client as string.
Returns:
log (dict): Dictionary object containing parsed system and user data
related to this message.
"""
# Get time at start of processing
time_obj = timezone.now()
time_str = str(time_obj)
session = self
src = kwargs.pop('src', '?')
bytecount = 0
# Do not log empty lines
if not kwargs:
return {}
# Get current session's IP address
client_ip = session.address
# Capture Account name and dbref together
account = session.get_account()
account_token = ''
if account:
account_token = '%s%s' % (account.key, account.dbref)
# Capture Character name and dbref together
char = session.get_puppet()
char_token = ''
if char:
char_token = '%s%s' % (char.key, char.dbref)
# Capture Room name and dbref together
room = None
room_token = ''
if char:
room = char.location
room_token = '%s%s' % (room.key, room.dbref)
# Try to compile an input/output string
def drill(obj, bucket):
if isinstance(obj, dict):
return bucket
elif utils.is_iter(obj):
for sub_obj in obj:
bucket.extend(drill(sub_obj, []))
else:
bucket.append(obj)
return bucket
text = kwargs.pop('text', '')
if utils.is_iter(text):
text = '|'.join(drill(text, []))
# Mask any PII in message, where possible
bytecount = len(text.encode('utf-8'))
text = self.mask(text)
# Compile the IP, Account, Character, Room, and the message.
log = {
'time': time_str,
'hostname': socket.getfqdn(),
'application': '%s' % ev_settings.SERVERNAME,
'version': get_evennia_version(),
'pid': os.getpid(),
'direction': 'SND' if src == 'server' else 'RCV',
'protocol': self.protocol_key,
'ip': client_ip,
'session': 'session#%s' % self.sessid,
'account': account_token,
'character': char_token,
'room': room_token,
'text': text.strip(),
'bytes': bytecount,
'data': kwargs,
'objects': {
'time': time_obj,
'session': self,
'account': account,
'character': char,
'room': room,
}
}
# Remove any keys with blank values
if AUDIT_ALLOW_SPARSE is False:
log['data'] = {k: v for k, v in log['data'].iteritems() if v}
log['objects'] = {k: v for k, v in log['objects'].iteritems() if v}
log = {k: v for k, v in log.iteritems() if v}
return log
def mask(self, msg):
"""
Masks potentially sensitive user information within messages before
writing to log. Recording cleartext password attempts is bad policy.
Args:
msg (str): Raw text string sent from client <-> server
Returns:
msg (str): Text string with sensitive information masked out.
"""
# Check to see if the command is embedded within server output
_msg = msg
is_embedded = False
match = re.match(".*Command.*'(.+)'.*is not available.*", msg, flags=re.IGNORECASE)
if match:
msg = match.group(1).replace('\\', '')
submsg = msg
is_embedded = True
for mask in AUDIT_MASKS:
for command, regex in mask.iteritems():
try:
match = re.match(regex, msg, flags=re.IGNORECASE)
except Exception as e:
logger.log_err(regex)
logger.log_err(e)
continue
if match:
term = match.group('secret')
masked = re.sub(term, '*' * len(term.zfill(8)), msg)
if is_embedded:
msg = re.sub(submsg, '%s <Masked: %s>' % (masked, command), _msg, flags=re.IGNORECASE)
else:
msg = masked
return msg
return _msg
def data_out(self, **kwargs):
"""
Generic hook for sending data out through the protocol.
Kwargs:
kwargs (any): Other data to the protocol.
"""
if AUDIT_CALLBACK and AUDIT_OUT:
try:
log = self.audit(src='server', **kwargs)
if log:
AUDIT_CALLBACK(log)
except Exception as e:
logger.log_err(e)
super(AuditedServerSession, self).data_out(**kwargs)
def data_in(self, **kwargs):
"""
Hook for protocols to send incoming data to the engine.
Kwargs:
kwargs (any): Other data from the protocol.
"""
if AUDIT_CALLBACK and AUDIT_IN:
try:
log = self.audit(src='client', **kwargs)
if log:
AUDIT_CALLBACK(log)
except Exception as e:
logger.log_err(e)
super(AuditedServerSession, self).data_in(**kwargs)

View file

@ -0,0 +1,95 @@
"""
Module containing the test cases for the Audit system.
"""
from django.conf import settings
from evennia.utils.test_resources import EvenniaTest
import re
# Configure session auditing settings
settings.AUDIT_CALLBACK = "evennia.security.contrib.auditing.outputs.to_syslog"
settings.AUDIT_IN = True
settings.AUDIT_OUT = True
settings.AUDIT_ALLOW_SPARSE = True
# Configure settings to use custom session
settings.SERVER_SESSION_CLASS = "evennia.contrib.security.auditing.server.AuditedServerSession"
class AuditingTest(EvenniaTest):
def test_mask(self):
"""
Make sure the 'mask' function is properly masking potentially sensitive
information from strings.
"""
safe_cmds = (
'/say hello to my little friend',
'@ccreate channel = for channeling',
'@create/drop some stuff',
'@create rock',
'@create a pretty shirt : evennia.contrib.clothing.Clothing',
'@charcreate johnnyefhiwuhefwhef',
'Command "@logout" is not available. Maybe you meant "@color" or "@cboot"?',
'/me says, "what is the password?"',
'say the password is plugh',
# Unfortunately given the syntax, there is no way to discern the
# latter of these as sensitive
'@create pretty sunset'
'@create johnny password123',
'{"text": "Command \'do stuff\' is not available. Type \"help\" for help."}'
)
for cmd in safe_cmds:
self.assertEqual(self.session.mask(cmd), cmd)
unsafe_cmds = (
("something - new password set to 'asdfghjk'.", "something - new password set to '********'."),
("someone has changed your password to 'something'.", "someone has changed your password to '*********'."),
('connect johnny password123', 'connect johnny ***********'),
('concnct johnny password123', 'concnct johnny ***********'),
('concnct johnnypassword123', 'concnct *****************'),
('connect "johnny five" "password 123"', 'connect "johnny five" **************'),
('connect johnny "password 123"', 'connect johnny **************'),
('create johnny password123', 'create johnny ***********'),
('@password password1234 = password2345', '@password ***************************'),
('@password password1234 password2345', '@password *************************'),
('@passwd password1234 = password2345', '@passwd ***************************'),
('@userpassword johnny = password234', '@userpassword johnny = ***********'),
('craete johnnypassword123', 'craete *****************'),
("Command 'conncect teddy teddy' is not available. Maybe you meant \"@encode\"?", 'Command \'conncect ******** ********\' is not available. Maybe you meant "@encode"?'),
("{'text': u'Command \\'conncect jsis dfiidf\\' is not available. Type \"help\" for help.'}", "{'text': u'Command \\'conncect jsis ********\\' is not available. Type \"help\" for help.'}")
)
for index, (unsafe, safe) in enumerate(unsafe_cmds):
self.assertEqual(re.sub(' <Masked: .+>', '', self.session.mask(unsafe)).strip(), safe)
# Make sure scrubbing is not being abused to evade monitoring
secrets = [
'say password password password; ive got a secret that i cant explain',
'whisper johnny = password\n let\'s lynch the landlord',
'say connect johnny password1234|the secret life of arabia',
"@password eval(\"__import__('os').system('clear')\", {'__builtins__':{}})"
]
for secret in secrets:
self.assertEqual(self.session.mask(secret), secret)
def test_audit(self):
"""
Make sure the 'audit' function is returning a dictionary based on values
parsed from the Session object.
"""
log = self.session.audit(src='client', text=[['hello']])
obj = {k:v for k,v in log.iteritems() if k in ('direction', 'protocol', 'application', 'text')}
self.assertEqual(obj, {
'direction': 'RCV',
'protocol': 'telnet',
'application': 'Evennia',
'text': 'hello'
})
# Make sure OOB data is being recorded
log = self.session.audit(src='client', text="connect johnny password123", prompt="hp=20|st=10|ma=15", pane=2)
self.assertEqual(log['text'], 'connect johnny ***********')
self.assertEqual(log['data']['prompt'], 'hp=20|st=10|ma=15')
self.assertEqual(log['data']['pane'], 2)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,535 @@
"""
Easy menu selection tree
Contrib - Tim Ashley Jenkins 2017
This module allows you to create and initialize an entire branching EvMenu
instance with nothing but a multi-line string passed to one function.
EvMenu is incredibly powerful and flexible, but using it for simple menus
can often be fairly cumbersome - a simple menu that can branch into five
categories would require six nodes, each with options represented as a list
of dictionaries.
This module provides a function, init_tree_selection, which acts as a frontend
for EvMenu, dynamically sourcing the options from a multi-line string you provide.
For example, if you define a string as such:
TEST_MENU = '''Foo
Bar
Baz
Qux'''
And then use TEST_MENU as the 'treestr' source when you call init_tree_selection
on a player:
init_tree_selection(TEST_MENU, caller, callback)
The player will be presented with an EvMenu, like so:
___________________________
Make your selection:
___________________________
Foo
Bar
Baz
Qux
Making a selection will pass the selection's key to the specified callback as a
string along with the caller, as well as the index of the selection (the line number
on the source string) along with the source string for the tree itself.
In addition to specifying selections on the menu, you can also specify categories.
Categories are indicated by putting options below it preceded with a '-' character.
If a selection is a category, then choosing it will bring up a new menu node, prompting
the player to select between those options, or to go back to the previous menu. In
addition, categories are marked by default with a '[+]' at the end of their key. Both
this marker and the option to go back can be disabled.
Categories can be nested in other categories as well - just go another '-' deeper. You
can do this as many times as you like. There's no hard limit to the number of
categories you can go down.
For example, let's add some more options to our menu, turning 'Bar' into a category.
TEST_MENU = '''Foo
Bar
-You've got to know
--When to hold em
--When to fold em
--When to walk away
Baz
Qux'''
Now when we call the menu, we can see that 'Bar' has become a category instead of a
selectable option.
_______________________________
Make your selection:
_______________________________
Foo
Bar [+]
Baz
Qux
Note the [+] next to 'Bar'. If we select 'Bar', it'll show us the option listed under it.
________________________________________________________________
Bar
________________________________________________________________
You've got to know [+]
<< Go Back: Return to the previous menu.
Just the one option, which is a category itself, and the option to go back, which will
take us back to the previous menu. Let's select 'You've got to know'.
________________________________________________________________
You've got to know
________________________________________________________________
When to hold em
When to fold em
When to walk away
<< Go Back: Return to the previous menu.
Now we see the three options listed under it, too. We can select one of them or use 'Go
Back' to return to the 'Bar' menu we were just at before. It's very simple to make a
branching tree of selections!
One last thing - you can set the descriptions for the various options simply by adding a
':' character followed by the description to the option's line. For example, let's add a
description to 'Baz' in our menu:
TEST_MENU = '''Foo
Bar
-You've got to know
--When to hold em
--When to fold em
--When to walk away
Baz: Look at this one: the best option.
Qux'''
Now we see that the Baz option has a description attached that's separate from its key:
_______________________________________________________________
Make your selection:
_______________________________________________________________
Foo
Bar [+]
Baz: Look at this one: the best option.
Qux
Once the player makes a selection - let's say, 'Foo' - the menu will terminate and call
your specified callback with the selection, like so:
callback(caller, TEST_MENU, 0, "Foo")
The index of the selection is given along with a string containing the selection's key.
That way, if you have two selections in the menu with the same key, you can still
differentiate between them.
And that's all there is to it! For simple branching-tree selections, using this system is
much easier than manually creating EvMenu nodes. It also makes generating menus with dynamic
options much easier - since the source of the menu tree is just a string, you could easily
generate that string procedurally before passing it to the init_tree_selection function.
For example, if a player casts a spell or does an attack without specifying a target, instead
of giving them an error, you could present them with a list of valid targets to select by
generating a multi-line string of targets and passing it to init_tree_selection, with the
callable performing the maneuver once a selection is made.
This selection system only works for simple branching trees - doing anything really complicated
like jumping between categories or prompting for arbitrary input would still require a full
EvMenu implementation. For simple selections, however, I'm sure you will find using this function
to be much easier!
Included in this module is a sample menu and function which will let a player change the color
of their name - feel free to mess with it to get a feel for how this system works by importing
this module in your game's default_cmdsets.py module and adding CmdNameColor to your default
character's command set.
"""
from evennia.utils import evmenu
from evennia.utils.logger import log_trace
from evennia import Command
def init_tree_selection(treestr, caller, callback,
index=None, mark_category=True, go_back=True,
cmd_on_exit="look",
start_text="Make your selection:"):
"""
Prompts a player to select an option from a menu tree given as a multi-line string.
Args:
treestr (str): Multi-lne string representing menu options
caller (obj): Player to initialize the menu for
callback (callable): Function to run when a selection is made. Must take 4 args:
caller (obj): Caller given above
treestr (str): Menu tree string given above
index (int): Index of final selection
selection (str): Key of final selection
Options:
index (int or None): Index to start the menu at, or None for top level
mark_category (bool): If True, marks categories with a [+] symbol in the menu
go_back (bool): If True, present an option to go back to previous categories
start_text (str): Text to display at the top level of the menu
cmd_on_exit(str): Command to enter when the menu exits - 'look' by default
Notes:
This function will initialize an instance of EvMenu with options generated
dynamically from the source string, and passes the menu user's selection to
a function of your choosing. The EvMenu is made of a single, repeating node,
which will call itself over and over at different levels of the menu tree as
categories are selected.
Once a non-category selection is made, the user's selection will be passed to
the given callable, both as a string and as an index number. The index is given
to ensure every selection has a unique identifier, so that selections with the
same key in different categories can be distinguished between.
The menus called by this function are not persistent and cannot perform
complicated tasks like prompt for arbitrary input or jump multiple category
levels at once - you'll have to use EvMenu itself if you want to take full
advantage of its features.
"""
# Pass kwargs to store data needed in the menu
kwargs = {
"index":index,
"mark_category":mark_category,
"go_back":go_back,
"treestr":treestr,
"callback":callback,
"start_text":start_text
}
# Initialize menu of selections
evmenu.EvMenu(caller, "evennia.contrib.tree_select", startnode="menunode_treeselect",
startnode_input=None, cmd_on_exit=cmd_on_exit, **kwargs)
def dashcount(entry):
"""
Counts the number of dashes at the beginning of a string. This
is needed to determine the depth of options in categories.
Args:
entry (str): String to count the dashes at the start of
Returns:
dashes (int): Number of dashes at the start
"""
dashes = 0
for char in entry:
if char == "-":
dashes += 1
else:
return dashes
return dashes
def is_category(treestr, index):
"""
Determines whether an option in a tree string is a category by
whether or not there are additional options below it.
Args:
treestr (str): Multi-line string representing menu options
index (int): Which line of the string to test
Returns:
is_category (bool): Whether the option is a category
"""
opt_list = treestr.split('\n')
# Not a category if it's the last one in the list
if index == len(opt_list) - 1:
return False
# Not a category if next option is not one level deeper
return not bool(dashcount(opt_list[index+1]) != dashcount(opt_list[index]) + 1)
def parse_opts(treestr, category_index=None):
"""
Parses a tree string and given index into a list of options. If
category_index is none, returns all the options at the top level of
the menu. If category_index corresponds to a category, returns a list
of options under that category. If category_index corresponds to
an option that is not a category, it's a selection and returns True.
Args:
treestr (str): Multi-line string representing menu options
category_index (int): Index of category or None for top level
Returns:
kept_opts (list or True): Either a list of options in the selected
category or True if a selection was made
"""
dash_depth = 0
opt_list = treestr.split('\n')
kept_opts = []
# If a category index is given
if category_index != None:
# If given index is not a category, it's a selection - return True.
if not is_category(treestr, category_index):
return True
# Otherwise, change the dash depth to match the new category.
dash_depth = dashcount(opt_list[category_index]) + 1
# Delete everything before the category index
opt_list = opt_list [category_index+1:]
# Keep every option (referenced by index) at the appropriate depth
cur_index = 0
for option in opt_list:
if dashcount(option) == dash_depth:
if category_index == None:
kept_opts.append((cur_index, option[dash_depth:]))
else:
kept_opts.append((cur_index + category_index + 1, option[dash_depth:]))
# Exits the loop if leaving a category
if dashcount(option) < dash_depth:
return kept_opts
cur_index += 1
return kept_opts
def index_to_selection(treestr, index, desc=False):
"""
Given a menu tree string and an index, returns the corresponding selection's
name as a string. If 'desc' is set to True, will return the selection's
description as a string instead.
Args:
treestr (str): Multi-line string representing menu options
index (int): Index to convert to selection key or description
Options:
desc (bool): If true, returns description instead of key
Returns:
selection (str): Selection key or description if 'desc' is set
"""
opt_list = treestr.split('\n')
# Fetch the given line
selection = opt_list[index]
# Strip out the dashes at the start
selection = selection[dashcount(selection):]
# Separate out description, if any
if ":" in selection:
# Split string into key and description
selection = selection.split(':', 1)
selection[1] = selection[1].strip(" ")
else:
# If no description given, set description to None
selection = [selection, None]
if not desc:
return selection[0]
else:
return selection[1]
def go_up_one_category(treestr, index):
"""
Given a menu tree string and an index, returns the category that the given option
belongs to. Used for the 'go back' option.
Args:
treestr (str): Multi-line string representing menu options
index (int): Index to determine the parent category of
Returns:
parent_category (int): Index of parent category
"""
opt_list = treestr.split('\n')
# Get the number of dashes deep the given index is
dash_level = dashcount(opt_list[index])
# Delete everything after the current index
opt_list = opt_list[:index+1]
# If there's no dash, return 'None' to return to base menu
if dash_level == 0:
return None
current_index = index
# Go up through each option until we find one that's one category above
for selection in reversed(opt_list):
if dashcount(selection) == dash_level - 1:
return current_index
current_index -= 1
def optlist_to_menuoptions(treestr, optlist, index, mark_category, go_back):
"""
Takes a list of options processed by parse_opts and turns it into
a list/dictionary of menu options for use in menunode_treeselect.
Args:
treestr (str): Multi-line string representing menu options
optlist (list): List of options to convert to EvMenu's option format
index (int): Index of current category
mark_category (bool): Whether or not to mark categories with [+]
go_back (bool): Whether or not to add an option to go back in the menu
Returns:
menuoptions (list of dicts): List of menu options formatted for use
in EvMenu, each passing a different "newindex" kwarg that changes
the menu level or makes a selection
"""
menuoptions = []
cur_index = 0
for option in optlist:
index_to_add = optlist[cur_index][0]
menuitem = {}
keystr = index_to_selection(treestr, index_to_add)
if mark_category and is_category(treestr, index_to_add):
# Add the [+] to the key if marking categories, and the key by itself as an alias
menuitem["key"] = [keystr + " [+]", keystr]
else:
menuitem["key"] = keystr
# Get the option's description
desc = index_to_selection(treestr, index_to_add, desc=True)
if desc:
menuitem["desc"] = desc
# Passing 'newindex' as a kwarg to the node is how we move through the menu!
menuitem["goto"] = ["menunode_treeselect", {"newindex":index_to_add}]
menuoptions.append(menuitem)
cur_index += 1
# Add option to go back, if needed
if index != None and go_back == True:
gobackitem = {"key":["<< Go Back", "go back", "back"],
"desc":"Return to the previous menu.",
"goto":["menunode_treeselect", {"newindex":go_up_one_category(treestr, index)}]}
menuoptions.append(gobackitem)
return menuoptions
def menunode_treeselect(caller, raw_string, **kwargs):
"""
This is the repeating menu node that handles the tree selection.
"""
# If 'newindex' is in the kwargs, change the stored index.
if "newindex" in kwargs:
caller.ndb._menutree.index = kwargs["newindex"]
# Retrieve menu info
index = caller.ndb._menutree.index
mark_category = caller.ndb._menutree.mark_category
go_back = caller.ndb._menutree.go_back
treestr = caller.ndb._menutree.treestr
callback = caller.ndb._menutree.callback
start_text = caller.ndb._menutree.start_text
# List of options if index is 'None' or category, or 'True' if a selection
optlist = parse_opts(treestr, category_index=index)
# If given index returns optlist as 'True', it's a selection. Pass to callback and end the menu.
if optlist == True:
selection = index_to_selection(treestr, index)
try:
callback(caller, treestr, index, selection)
except Exception:
log_trace("Error in tree selection callback.")
# Returning None, None ends the menu.
return None, None
# Otherwise, convert optlist to a list of menu options.
else:
options = optlist_to_menuoptions(treestr, optlist, index, mark_category, go_back)
if index == None:
# Use start_text for the menu text on the top level
text = start_text
else:
# Use the category name and description (if any) as the menu text
if index_to_selection(treestr, index, desc=True) != None:
text = "|w" + index_to_selection(treestr, index) + "|n: " + index_to_selection(treestr, index, desc=True)
else:
text = "|w" + index_to_selection(treestr, index) + "|n"
return text, options
# The rest of this module is for the example menu and command! It'll change the color of your name.
"""
Here's an example string that you can initialize a menu from. Note the dashes at
the beginning of each line - that's how menu option depth and hierarchy is determined.
"""
NAMECOLOR_MENU = """Set name color: Choose a color for your name!
-Red shades: Various shades of |511red|n
--Red: |511Set your name to Red|n
--Pink: |533Set your name to Pink|n
--Maroon: |301Set your name to Maroon|n
-Orange shades: Various shades of |531orange|n
--Orange: |531Set your name to Orange|n
--Brown: |321Set your name to Brown|n
--Sienna: |420Set your name to Sienna|n
-Yellow shades: Various shades of |551yellow|n
--Yellow: |551Set your name to Yellow|n
--Gold: |540Set your name to Gold|n
--Dandelion: |553Set your name to Dandelion|n
-Green shades: Various shades of |141green|n
--Green: |141Set your name to Green|n
--Lime: |350Set your name to Lime|n
--Forest: |032Set your name to Forest|n
-Blue shades: Various shades of |115blue|n
--Blue: |115Set your name to Blue|n
--Cyan: |155Set your name to Cyan|n
--Navy: |113Set your name to Navy|n
-Purple shades: Various shades of |415purple|n
--Purple: |415Set your name to Purple|n
--Lavender: |535Set your name to Lavender|n
--Fuchsia: |503Set your name to Fuchsia|n
Remove name color: Remove your name color, if any"""
class CmdNameColor(Command):
"""
Set or remove a special color on your name. Just an example for the
easy menu selection tree contrib.
"""
key = "namecolor"
def func(self):
# This is all you have to do to initialize a menu!
init_tree_selection(NAMECOLOR_MENU, self.caller,
change_name_color,
start_text="Name color options:")
def change_name_color(caller, treestr, index, selection):
"""
Changes a player's name color.
Args:
caller (obj): Character whose name to color.
treestr (str): String for the color change menu - unused
index (int): Index of menu selection - unused
selection (str): Selection made from the name color menu - used
to determine the color the player chose.
"""
# Store the caller's uncolored name
if not caller.db.uncolored_name:
caller.db.uncolored_name = caller.key
# Dictionary matching color selection names to color codes
colordict = { "Red":"|511", "Pink":"|533", "Maroon":"|301",
"Orange":"|531", "Brown":"|321", "Sienna":"|420",
"Yellow":"|551", "Gold":"|540", "Dandelion":"|553",
"Green":"|141", "Lime":"|350", "Forest":"|032",
"Blue":"|115", "Cyan":"|155", "Navy":"|113",
"Purple":"|415", "Lavender":"|535", "Fuchsia":"|503"}
# I know this probably isn't the best way to do this. It's just an example!
if selection == "Remove name color": # Player chose to remove their name color
caller.key = caller.db.uncolored_name
caller.msg("Name color removed.")
elif selection in colordict:
newcolor = colordict[selection] # Retrieve color code based on menu selection
caller.key = newcolor + caller.db.uncolored_name + "|n" # Add color code to caller's name
caller.msg(newcolor + ("Name color changed to %s!" % selection) + "|n")

View file

@ -21,6 +21,19 @@ implemented and customized:
the battle system, including commands for wielding weapons and
donning armor, and modifiers to accuracy and damage based on
currently used equipment.
tb_items.py - Adds usable items and conditions/status effects, and gives
a lot of examples for each. Items can perform nearly any sort of
function, including healing, adding or curing conditions, or
being used to attack. Conditions affect a fighter's attributes
and options in combat and persist outside of fights, counting
down per turn in combat and in real time outside combat.
tb_magic.py - Adds a spellcasting system, allowing characters to cast
spells with a variety of effects by spending MP. Spells are
linked to functions, and as such can perform any sort of action
the developer can imagine - spells for attacking, healing and
conjuring objects are included as examples.
tb_range.py - Adds a system for abstract positioning and movement, which
tracks the distance between different characters and objects in

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -31,10 +31,12 @@ class BodyFunctions(DefaultScript):
This gets called every self.interval seconds. We make
a random check here so as to only return 33% of the time.
"""
if random.random() < 0.66:
# no message this time
return
self.send_random_message()
def send_random_message(self):
rand = random.random()
# return a random message
if rand < 0.1:

View file

@ -0,0 +1,69 @@
from mock import Mock, patch
from evennia.utils.test_resources import EvenniaTest
from .bodyfunctions import BodyFunctions
@patch("evennia.contrib.tutorial_examples.bodyfunctions.random")
class TestBodyFunctions(EvenniaTest):
script_typeclass = BodyFunctions
def setUp(self):
super(TestBodyFunctions, self).setUp()
self.script.obj = self.char1
def tearDown(self):
super(TestBodyFunctions, self).tearDown()
# if we forget to stop the script, DirtyReactorAggregateError will be raised
self.script.stop()
def test_at_repeat(self, mock_random):
"""test that no message will be sent when below the 66% threshold"""
mock_random.random = Mock(return_value=0.5)
old_func = self.script.send_random_message
self.script.send_random_message = Mock()
self.script.at_repeat()
self.script.send_random_message.assert_not_called()
# test that random message will be sent
mock_random.random = Mock(return_value=0.7)
self.script.at_repeat()
self.script.send_random_message.assert_called()
self.script.send_random_message = old_func
def test_send_random_message(self, mock_random):
"""Test that correct message is sent for each random value"""
old_func = self.char1.msg
self.char1.msg = Mock()
# test each of the values
mock_random.random = Mock(return_value=0.05)
self.script.send_random_message()
self.char1.msg.assert_called_with("You tap your foot, looking around.")
mock_random.random = Mock(return_value=0.15)
self.script.send_random_message()
self.char1.msg.assert_called_with("You have an itch. Hard to reach too.")
mock_random.random = Mock(return_value=0.25)
self.script.send_random_message()
self.char1.msg.assert_called_with("You think you hear someone behind you. ... "
"but when you look there's noone there.")
mock_random.random = Mock(return_value=0.35)
self.script.send_random_message()
self.char1.msg.assert_called_with("You inspect your fingernails. Nothing to report.")
mock_random.random = Mock(return_value=0.45)
self.script.send_random_message()
self.char1.msg.assert_called_with("You cough discreetly into your hand.")
mock_random.random = Mock(return_value=0.55)
self.script.send_random_message()
self.char1.msg.assert_called_with("You scratch your head, looking around.")
mock_random.random = Mock(return_value=0.65)
self.script.send_random_message()
self.char1.msg.assert_called_with("You blink, forgetting what it was you were going to do.")
mock_random.random = Mock(return_value=0.75)
self.script.send_random_message()
self.char1.msg.assert_called_with("You feel lonely all of a sudden.")
mock_random.random = Mock(return_value=0.85)
self.script.send_random_message()
self.char1.msg.assert_called_with("You get a great idea. Of course you won't tell anyone.")
mock_random.random = Mock(return_value=0.95)
self.script.send_random_message()
self.char1.msg.assert_called_with("You suddenly realize how much you love Evennia!")
self.char1.msg = old_func

View file

@ -749,9 +749,9 @@ hole
to the ground together with the stone archway that once help it up.
#
# We lock the bridge exit for the mob, so it don't wander out on the bridge. Only
# traversing objects controlled by a player (i.e. Characters) may cross the bridge.
# traversing objects controlled by an account (i.e. Characters) may cross the bridge.
#
@lock bridge = traverse:has_player()
@lock bridge = traverse:has_account()
#------------------------------------------------------------
#
@ -997,7 +997,7 @@ mobon ghost
The stairs are worn by the age-old passage of feet.
#
# Lock the antechamber so the ghost cannot get in there.
@lock stairs down = traverse:has_player()
@lock stairs down = traverse:has_account()
#
# Go down
#

View file

@ -23,9 +23,8 @@ from future.utils import listvalues
import random
from evennia import DefaultObject, DefaultExit, Command, CmdSet
from evennia import utils
from evennia.utils import search
from evennia.utils.spawner import spawn
from evennia.utils import search, delay
from evennia.prototypes.spawner import spawn
# -------------------------------------------------------------
#
@ -373,7 +372,7 @@ class LightSource(TutorialObject):
# start the burn timer. When it runs out, self._burnout
# will be called. We store the deferred so it can be
# killed in unittesting.
self.deferred = utils.delay(60 * 3, self._burnout)
self.deferred = delay(60 * 3, self._burnout)
return True
@ -645,7 +644,7 @@ class CrumblingWall(TutorialObject, DefaultExit):
self.db.exit_open = True
# start a 45 second timer before closing again. We store the deferred so it can be
# killed in unittesting.
self.deferred = utils.delay(45, self.reset)
self.deferred = delay(45, self.reset)
def _translate_position(self, root, ipos):
"""Translates the position into words"""
@ -675,7 +674,7 @@ class CrumblingWall(TutorialObject, DefaultExit):
# we found the button by moving the roots
result = ["Having moved all the roots aside, you find that the center of the wall, "
"previously hidden by the vegetation, hid a curious square depression. It was maybe once "
"concealed and made to look a part of the wall, but with the crumbling of stone around it,"
"concealed and made to look a part of the wall, but with the crumbling of stone around it, "
"it's now easily identifiable as some sort of button."]
elif self.db.exit_open:
# we pressed the button; the exit is open
@ -906,19 +905,19 @@ WEAPON_PROTOTYPES = {
"magic": False,
"desc": "A generic blade."},
"knife": {
"prototype": "weapon",
"prototype_parent": "weapon",
"aliases": "sword",
"key": "Kitchen knife",
"desc": "A rusty kitchen knife. Better than nothing.",
"damage": 3},
"dagger": {
"prototype": "knife",
"prototype_parent": "knife",
"key": "Rusty dagger",
"aliases": ["knife", "dagger"],
"desc": "A double-edged dagger with a nicked edge and a wooden handle.",
"hit": 0.25},
"sword": {
"prototype": "weapon",
"prototype_parent": "weapon",
"key": "Rusty sword",
"aliases": ["sword"],
"desc": "A rusty shortsword. It has a leather-wrapped handle covered i food grease.",
@ -926,28 +925,28 @@ WEAPON_PROTOTYPES = {
"damage": 5,
"parry": 0.5},
"club": {
"prototype": "weapon",
"prototype_parent": "weapon",
"key": "Club",
"desc": "A heavy wooden club, little more than a heavy branch.",
"hit": 0.4,
"damage": 6,
"parry": 0.2},
"axe": {
"prototype": "weapon",
"prototype_parent": "weapon",
"key": "Axe",
"desc": "A woodcutter's axe with a keen edge.",
"hit": 0.4,
"damage": 6,
"parry": 0.2},
"ornate longsword": {
"prototype": "sword",
"prototype_parent": "sword",
"key": "Ornate longsword",
"desc": "A fine longsword with some swirling patterns on the handle.",
"hit": 0.5,
"magic": True,
"damage": 5},
"warhammer": {
"prototype": "club",
"prototype_parent": "club",
"key": "Silver Warhammer",
"aliases": ["hammer", "warhammer", "war"],
"desc": "A heavy war hammer with silver ornaments. This huge weapon causes massive damage - if you can hit.",
@ -955,21 +954,21 @@ WEAPON_PROTOTYPES = {
"magic": True,
"damage": 8},
"rune axe": {
"prototype": "axe",
"prototype_parent": "axe",
"key": "Runeaxe",
"aliases": ["axe"],
"hit": 0.4,
"magic": True,
"damage": 6},
"thruning": {
"prototype": "ornate longsword",
"prototype_parent": "ornate longsword",
"key": "Broadsword named Thruning",
"desc": "This heavy bladed weapon is marked with the name 'Thruning'. It is very powerful in skilled hands.",
"hit": 0.6,
"parry": 0.6,
"damage": 7},
"slayer waraxe": {
"prototype": "rune axe",
"prototype_parent": "rune axe",
"key": "Slayer waraxe",
"aliases": ["waraxe", "war", "slayer"],
"desc": "A huge double-bladed axe marked with the runes for 'Slayer'."
@ -977,7 +976,7 @@ WEAPON_PROTOTYPES = {
"hit": 0.7,
"damage": 8},
"ghostblade": {
"prototype": "ornate longsword",
"prototype_parent": "ornate longsword",
"key": "The Ghostblade",
"aliases": ["blade", "ghost"],
"desc": "This massive sword is large as you are tall, yet seems to weigh almost nothing."
@ -986,7 +985,7 @@ WEAPON_PROTOTYPES = {
"parry": 0.8,
"damage": 10},
"hawkblade": {
"prototype": "ghostblade",
"prototype_parent": "ghostblade",
"key": "The Hawkblade",
"aliases": ["hawk", "blade"],
"desc": "The weapon of a long-dead heroine and a more civilized age,"

View file

@ -168,7 +168,7 @@ class CmdTutorialLook(default_cmds.CmdLook):
else:
# no detail found, delegate our result to the normal
# error message handler.
_SEARCH_AT_RESULT(None, caller, args, looking_at_obj)
_SEARCH_AT_RESULT(looking_at_obj, caller, args)
return
else:
# we found a match, extract it from the list and carry on
@ -747,7 +747,7 @@ class CmdLookDark(Command):
"""
caller = self.caller
if random.random() < 0.8:
if random.random() < 0.75:
# we don't find anything
caller.msg(random.choice(DARK_MESSAGES))
else:

View file

@ -34,27 +34,6 @@ from evennia.settings_default import *
# This is the name of your game. Make it catchy!
SERVERNAME = {servername}
# Server ports. If enabled and marked as "visible", the port
# should be visible to the outside world on a production server.
# Note that there are many more options available beyond these.
# Telnet ports. Visible.
TELNET_ENABLED = True
TELNET_PORTS = [4000]
# (proxy, internal). Only proxy should be visible.
WEBSERVER_ENABLED = True
WEBSERVER_PORTS = [(4001, 4002)]
# Telnet+SSL ports, for supporting clients. Visible.
SSL_ENABLED = False
SSL_PORTS = [4003]
# SSH client ports. Requires crypto lib. Visible.
SSH_ENABLED = False
SSH_PORTS = [4004]
# Websocket-client port. Visible.
WEBSOCKET_CLIENT_ENABLED = True
WEBSOCKET_CLIENT_PORT = 4005
# Internal Server-Portal port. Not visible.
AMP_PORT = 4006
######################################################################
# Settings given in secret_settings.py override those in this file.

View file

@ -65,7 +65,6 @@ class Account(DefaultAccount):
* Helper methods
msg(text=None, **kwargs)
swap_character(new_character, delete_old_character=False)
execute_cmd(raw_string, session=None)
search(ostring, global_search=False, attribute_name=None, use_nicks=False, location=None, ignore_errors=False, account=False)
is_typeclass(typeclass, exact=False)

View file

@ -89,10 +89,14 @@ DefaultLock: Exits: controls who may traverse the exit to
"""
from ast import literal_eval
from django.conf import settings
from evennia.utils import utils
_PERMISSION_HIERARCHY = [pe.lower() for pe in settings.PERMISSION_HIERARCHY]
# also accept different plural forms
_PERMISSION_HIERARCHY_PLURAL = [pe + 's' if not pe.endswith('s') else pe
for pe in _PERMISSION_HIERARCHY]
def _to_account(accessing_obj):
@ -158,49 +162,77 @@ def perm(accessing_obj, accessed_obj, *args, **kwargs):
"""
# this allows the perm_above lockfunc to make use of this function too
gtmode = kwargs.pop("_greater_than", False)
try:
permission = args[0].lower()
perms_object = [p.lower() for p in accessing_obj.permissions.all()]
perms_object = accessing_obj.permissions.all()
except (AttributeError, IndexError):
return False
if utils.inherits_from(accessing_obj, "evennia.objects.objects.DefaultObject") and accessing_obj.account:
account = accessing_obj.account
# we strip eventual plural forms, so Builders == Builder
perms_account = [p.lower().rstrip("s") for p in account.permissions.all()]
gtmode = kwargs.pop("_greater_than", False)
is_quell = False
account = (utils.inherits_from(accessing_obj, "evennia.objects.objects.DefaultObject") and
accessing_obj.account)
# check object perms (note that accessing_obj could be an Account too)
perms_account = []
if account:
perms_account = account.permissions.all()
is_quell = account.attributes.get("_quell")
if permission in _PERMISSION_HIERARCHY:
# check hierarchy without allowing escalation obj->account
hpos_target = _PERMISSION_HIERARCHY.index(permission)
hpos_account = [hpos for hpos, hperm in enumerate(_PERMISSION_HIERARCHY) if hperm in perms_account]
# Check hirarchy matches; handle both singular/plural forms in hierarchy
hpos_target = None
if permission in _PERMISSION_HIERARCHY:
hpos_target = _PERMISSION_HIERARCHY.index(permission)
if permission.endswith('s') and permission[:-1] in _PERMISSION_HIERARCHY:
hpos_target = _PERMISSION_HIERARCHY.index(permission[:-1])
if hpos_target is not None:
# hieratchy match
hpos_account = -1
hpos_object = -1
if account:
# we have an account puppeting this object. We must check what perms it has
perms_account_single = [p[:-1] if p.endswith('s') else p for p in perms_account]
hpos_account = [hpos for hpos, hperm in enumerate(_PERMISSION_HIERARCHY)
if hperm in perms_account_single]
hpos_account = hpos_account and hpos_account[-1] or -1
if is_quell:
hpos_object = [hpos for hpos, hperm in enumerate(_PERMISSION_HIERARCHY) if hperm in perms_object]
hpos_object = hpos_object and hpos_object[-1] or -1
if gtmode:
return hpos_target < min(hpos_account, hpos_object)
else:
return hpos_target <= min(hpos_account, hpos_object)
elif gtmode:
if not account or is_quell:
# only get the object-level perms if there is no account or quelling
perms_object_single = [p[:-1] if p.endswith('s') else p for p in perms_object]
hpos_object = [hpos for hpos, hperm in enumerate(_PERMISSION_HIERARCHY)
if hperm in perms_object_single]
hpos_object = hpos_object and hpos_object[-1] or -1
if account and is_quell:
# quell mode: use smallest perm from account and object
if gtmode:
return hpos_target < min(hpos_account, hpos_object)
else:
return hpos_target <= min(hpos_account, hpos_object)
elif account:
# use account perm
if gtmode:
return hpos_target < hpos_account
else:
return hpos_target <= hpos_account
elif not is_quell and permission in perms_account:
# if we get here, check account perms first, otherwise
# continue as normal
else:
# use object perm
if gtmode:
return hpos_target < hpos_object
else:
return hpos_target <= hpos_object
else:
# no hierarchy match - check direct matches
if account:
# account exists, check it first unless quelled
if is_quell and permission in perms_object:
return True
elif permission in perms_account:
return True
elif permission in perms_object:
return True
if permission in perms_object:
# simplest case - we have direct match
return True
if permission in _PERMISSION_HIERARCHY:
# check if we have a higher hierarchy position
hpos_target = _PERMISSION_HIERARCHY.index(permission)
return any(1 for hpos, hperm in enumerate(_PERMISSION_HIERARCHY)
if hperm in perms_object and hpos_target < hpos)
return False
@ -229,7 +261,6 @@ def pperm(accessing_obj, accessed_obj, *args, **kwargs):
"""
return perm(_to_account(accessing_obj), accessed_obj, *args, **kwargs)
def pperm_above(accessing_obj, accessed_obj, *args, **kwargs):
"""
Only allow Account objects with a permission *higher* in the permission
@ -482,7 +513,7 @@ def tag(accessing_obj, accessed_obj, *args, **kwargs):
accessing_obj = accessing_obj.obj
tagkey = args[0] if args else None
category = args[1] if len(args) > 1 else None
return accessing_obj.tags.get(tagkey, category=category)
return bool(accessing_obj.tags.get(tagkey, category=category))
def objtag(accessing_obj, accessed_obj, *args, **kwargs):
@ -494,7 +525,7 @@ def objtag(accessing_obj, accessed_obj, *args, **kwargs):
Only true if accessed_obj has the specified tag and optional
category.
"""
return accessed_obj.tags.get(*args)
return bool(accessed_obj.tags.get(*args))
def inside(accessing_obj, accessed_obj, *args, **kwargs):
@ -592,7 +623,9 @@ def serversetting(accessing_obj, accessed_obj, *args, **kwargs):
serversetting(IRC_ENABLED)
serversetting(BASE_SCRIPT_PATH, ['types'])
A given True/False or integers will be converted properly.
A given True/False or integers will be converted properly. Note that
everything will enter this function as strings, so they have to be
unpacked to their real value. We only support basic properties.
"""
if not args or not args[0]:
return False
@ -602,12 +635,12 @@ def serversetting(accessing_obj, accessed_obj, *args, **kwargs):
else:
setting, val = args[0], args[1]
# convert
if val == 'True':
val = True
elif val == 'False':
val = False
elif val.isdigit():
val = int(val)
try:
val = literal_eval(val)
except Exception:
# we swallow errors here, lockfuncs has noone to report to
return False
if setting in settings._wrapped.__dict__:
return settings._wrapped.__dict__[setting] == val
return False

View file

@ -114,6 +114,9 @@ from django.utils.translation import ugettext as _
__all__ = ("LockHandler", "LockException")
WARNING_LOG = settings.LOCKWARNING_LOG_FILE
_LOCK_HANDLER = None
#
# Exception class. This will be raised
@ -287,7 +290,7 @@ class LockHandler(object):
"""
self.lock_bypass = hasattr(obj, "is_superuser") and obj.is_superuser
def add(self, lockstring):
def add(self, lockstring, validate_only=False):
"""
Add a new lockstring to handler.
@ -296,10 +299,12 @@ class LockHandler(object):
`"<access_type>:<functions>"`. Multiple access types
should be separated by semicolon (`;`). Alternatively,
a list with lockstrings.
validate_only (bool, optional): If True, validate the lockstring but
don't actually store it.
Returns:
success (bool): The outcome of the addition, `False` on
error.
error. If `validate_only` is True, this will be a tuple
(bool, error), for pass/fail and a string error.
"""
if isinstance(lockstring, str):
@ -308,21 +313,41 @@ class LockHandler(object):
lockdefs = [lockdef for locks in lockstring for lockdef in locks.split(";")]
lockstring = ";".join(lockdefs)
err = ""
# sanity checks
for lockdef in lockdefs:
if ':' not in lockdef:
self._log_error(_("Lock: '%s' contains no colon (:).") % lockdef)
return False
err = _("Lock: '{lockdef}' contains no colon (:).").format(lockdef=lockdef)
if validate_only:
return False, err
else:
self._log_error(err)
return False
access_type, rhs = [part.strip() for part in lockdef.split(':', 1)]
if not access_type:
self._log_error(_("Lock: '%s' has no access_type (left-side of colon is empty).") % lockdef)
return False
err = _("Lock: '{lockdef}' has no access_type "
"(left-side of colon is empty).").format(lockdef=lockdef)
if validate_only:
return False, err
else:
self._log_error(err)
return False
if rhs.count('(') != rhs.count(')'):
self._log_error(_("Lock: '%s' has mismatched parentheses.") % lockdef)
return False
err = _("Lock: '{lockdef}' has mismatched parentheses.").format(lockdef=lockdef)
if validate_only:
return False, err
else:
self._log_error(err)
return False
if not _RE_FUNCS.findall(rhs):
self._log_error(_("Lock: '%s' has no valid lock functions.") % lockdef)
return False
err = _("Lock: '{lockdef}' has no valid lock functions.").format(lockdef=lockdef)
if validate_only:
return False, err
else:
self._log_error(err)
return False
if validate_only:
return True, None
# get the lock string
storage_lockstring = self.obj.lock_storage
if storage_lockstring:
@ -334,6 +359,18 @@ class LockHandler(object):
self._save_locks()
return True
def validate(self, lockstring):
"""
Validate lockstring syntactically, without saving it.
Args:
lockstring (str): Lockstring to validate.
Returns:
valid (bool): If validation passed or not.
"""
return self.add(lockstring, validate_only=True)
def replace(self, lockstring):
"""
Replaces the lockstring entirely.
@ -421,6 +458,28 @@ class LockHandler(object):
self._cache_locks(self.obj.lock_storage)
self.cache_lock_bypass(self.obj)
def append(self, access_type, lockstring, op='or'):
"""
Append a lock definition to access_type if it doesn't already exist.
Args:
access_type (str): Access type.
lockstring (str): A valid lockstring, without the operator to
link it to an eventual existing lockstring.
op (str): An operator 'and', 'or', 'and not', 'or not' used
for appending the lockstring to an existing access-type.
Note:
The most common use of this method is for use in commands where
the user can specify their own lockstrings. This method allows
the system to auto-add things like Admin-override access.
"""
old_lockstring = self.get(access_type)
if not lockstring.strip().lower() in old_lockstring.lower():
lockstring = "{old} {op} {new}".format(
old=old_lockstring, op=op, new=lockstring.strip())
self.add(lockstring)
def check(self, accessing_obj, access_type, default=False, no_superuser_bypass=False):
"""
Checks a lock of the correct type by passing execution off to
@ -459,9 +518,13 @@ class LockHandler(object):
return True
except AttributeError:
# happens before session is initiated.
if not no_superuser_bypass and ((hasattr(accessing_obj, 'is_superuser') and accessing_obj.is_superuser) or
(hasattr(accessing_obj, 'account') and hasattr(accessing_obj.account, 'is_superuser') and accessing_obj.account.is_superuser) or
(hasattr(accessing_obj, 'get_account') and (not accessing_obj.get_account() or accessing_obj.get_account().is_superuser))):
if not no_superuser_bypass and (
(hasattr(accessing_obj, 'is_superuser') and accessing_obj.is_superuser) or
(hasattr(accessing_obj, 'account') and
hasattr(accessing_obj.account, 'is_superuser') and
accessing_obj.account.is_superuser) or
(hasattr(accessing_obj, 'get_account') and
(not accessing_obj.get_account() or accessing_obj.get_account().is_superuser))):
return True
# no superuser or bypass -> normal lock operation
@ -469,7 +532,8 @@ class LockHandler(object):
# we have a lock, test it.
evalstring, func_tup, raw_string = self.locks[access_type]
# execute all lock funcs in the correct order, producing a tuple of True/False results.
true_false = tuple(bool(tup[0](accessing_obj, self.obj, *tup[1], **tup[2])) for tup in func_tup)
true_false = tuple(bool(
tup[0](accessing_obj, self.obj, *tup[1], **tup[2])) for tup in func_tup)
# the True/False tuple goes into evalstring, which combines them
# with AND/OR/NOT in order to get the final result.
return eval(evalstring % true_false)
@ -520,9 +584,13 @@ class LockHandler(object):
if accessing_obj.locks.lock_bypass and not no_superuser_bypass:
return True
except AttributeError:
if no_superuser_bypass and ((hasattr(accessing_obj, 'is_superuser') and accessing_obj.is_superuser) or
(hasattr(accessing_obj, 'account') and hasattr(accessing_obj.account, 'is_superuser') and accessing_obj.account.is_superuser) or
(hasattr(accessing_obj, 'get_account') and (not accessing_obj.get_account() or accessing_obj.get_account().is_superuser))):
if no_superuser_bypass and (
(hasattr(accessing_obj, 'is_superuser') and accessing_obj.is_superuser) or
(hasattr(accessing_obj, 'account') and
hasattr(accessing_obj.account, 'is_superuser') and
accessing_obj.account.is_superuser) or
(hasattr(accessing_obj, 'get_account') and
(not accessing_obj.get_account() or accessing_obj.get_account().is_superuser))):
return True
if ":" not in lockstring:
lockstring = "%s:%s" % ("_dummy", lockstring)
@ -538,7 +606,81 @@ class LockHandler(object):
else:
# if no access types was given and multiple locks were
# embedded in the lockstring we assume all must be true
return all(self._eval_access_type(accessing_obj, locks, access_type) for access_type in locks)
return all(self._eval_access_type(
accessing_obj, locks, access_type) for access_type in locks)
# convenience access function
# dummy to be able to call check_lockstring from the outside
class _ObjDummy:
lock_storage = ''
def check_lockstring(self, accessing_obj, lockstring, no_superuser_bypass=False,
default=False, access_type=None):
"""
Do a direct check against a lockstring ('atype:func()..'),
without any intermediary storage on the accessed object.
Args:
accessing_obj (object or None): The object seeking access.
Importantly, this can be left unset if the lock functions
don't access it, no updating or storage of locks are made
against this object in this method.
lockstring (str): Lock string to check, on the form
`"access_type:lock_definition"` where the `access_type`
part can potentially be set to a dummy value to just check
a lock condition.
no_superuser_bypass (bool, optional): Force superusers to heed lock.
default (bool, optional): Fallback result to use if `access_type` is set
but no such `access_type` is found in the given `lockstring`.
access_type (str, bool): If set, only this access_type will be looked up
among the locks defined by `lockstring`.
Return:
access (bool): If check is passed or not.
"""
global _LOCKHANDLER
if not _LOCKHANDLER:
_LOCKHANDLER = LockHandler(_ObjDummy())
return _LOCK_HANDLER.check_lockstring(
accessing_obj, lockstring, no_superuser_bypass=no_superuser_bypass,
default=default, access_type=access_type)
def validate_lockstring(lockstring):
"""
Validate so lockstring is on a valid form.
Args:
lockstring (str): Lockstring to validate.
Returns:
is_valid (bool): If the lockstring is valid or not.
error (str or None): A string describing the error, or None
if no error was found.
"""
global _LOCK_HANDLER
if not _LOCK_HANDLER:
_LOCK_HANDLER = LockHandler(_ObjDummy())
return _LOCK_HANDLER.validate(lockstring)
def get_all_lockfuncs():
"""
Get a dict of available lock funcs.
Returns:
lockfuncs (dict): Mapping {lockfuncname:func}.
"""
if not _LOCKFUNCS:
_cache_lockfuncs()
return _LOCKFUNCS
def _test():

View file

@ -11,10 +11,11 @@ from evennia.utils.test_resources import EvenniaTest
try:
# this is a special optimized Django version, only available in current Django devel
from django.utils.unittest import TestCase
from django.utils.unittest import TestCase, override_settings
except ImportError:
from django.test import TestCase
from django.test import TestCase, override_settings
from evennia import settings_default
from evennia.locks import lockfuncs
# ------------------------------------------------------------
@ -25,7 +26,8 @@ from evennia.locks import lockfuncs
class TestLockCheck(EvenniaTest):
def testrun(self):
dbref = self.obj2.dbref
self.obj1.locks.add("owner:dbref(%s);edit:dbref(%s) or perm(Admin);examine:perm(Builder) and id(%s);delete:perm(Admin);get:all()" % (dbref, dbref, dbref))
self.obj1.locks.add("owner:dbref(%s);edit:dbref(%s) or perm(Admin);examine:perm(Builder) "
"and id(%s);delete:perm(Admin);get:all()" % (dbref, dbref, dbref))
self.obj2.permissions.add('Admin')
self.assertEqual(True, self.obj1.locks.check(self.obj2, 'owner'))
self.assertEqual(True, self.obj1.locks.check(self.obj2, 'edit'))
@ -36,20 +38,152 @@ class TestLockCheck(EvenniaTest):
self.assertEqual(False, self.obj1.locks.check(self.obj2, 'get'))
self.assertEqual(True, self.obj1.locks.check(self.obj2, 'not_exist', default=True))
class TestLockfuncs(EvenniaTest):
def testrun(self):
def setUp(self):
super(TestLockfuncs, self).setUp()
self.account2.permissions.add('Admin')
self.char2.permissions.add('Builder')
def test_booleans(self):
self.assertEquals(True, lockfuncs.true(self.account2, self.obj1))
self.assertEquals(True, lockfuncs.all(self.account2, self.obj1))
self.assertEquals(False, lockfuncs.false(self.account2, self.obj1))
self.assertEquals(False, lockfuncs.none(self.account2, self.obj1))
self.assertEquals(True, lockfuncs.self(self.obj1, self.obj1))
self.assertEquals(True, lockfuncs.self(self.account, self.account))
self.assertEquals(False, lockfuncs.superuser(self.account, None))
@override_settings(PERMISSION_HIERARCHY=settings_default.PERMISSION_HIERARCHY)
def test_account_perm(self):
self.assertEquals(False, lockfuncs.perm(self.account2, None, 'foo'))
self.assertEquals(False, lockfuncs.perm(self.account2, None, 'Developer'))
self.assertEquals(False, lockfuncs.perm(self.account2, None, 'Developers'))
self.assertEquals(True, lockfuncs.perm(self.account2, None, 'Admin'))
self.assertEquals(True, lockfuncs.perm(self.account2, None, 'Admins'))
self.assertEquals(True, lockfuncs.perm(self.account2, None, 'Player'))
self.assertEquals(True, lockfuncs.perm(self.account2, None, 'Players'))
self.assertEquals(True, lockfuncs.perm(self.account2, None, 'Builder'))
self.assertEquals(True, lockfuncs.perm(self.account2, None, 'Builders'))
self.assertEquals(True, lockfuncs.perm_above(self.account2, None, 'Builder'))
@override_settings(PERMISSION_HIERARCHY=settings_default.PERMISSION_HIERARCHY)
def test_puppet_perm(self):
self.assertEquals(False, lockfuncs.perm(self.char2, None, 'foo'))
self.assertEquals(False, lockfuncs.perm(self.char2, None, 'Developer'))
self.assertEquals(False, lockfuncs.perm(self.char2, None, 'Develoeprs'))
self.assertEquals(True, lockfuncs.perm(self.char2, None, 'Admin'))
self.assertEquals(True, lockfuncs.perm(self.char2, None, 'Admins'))
self.assertEquals(True, lockfuncs.perm(self.char2, None, 'Player'))
self.assertEquals(True, lockfuncs.perm(self.char2, None, 'Players'))
self.assertEquals(True, lockfuncs.perm(self.char2, None, 'Builder'))
self.assertEquals(True, lockfuncs.perm(self.char2, None, 'Builders'))
@override_settings(PERMISSION_HIERARCHY=settings_default.PERMISSION_HIERARCHY)
def test_account_perm_above(self):
self.assertEquals(True, lockfuncs.perm_above(self.char2, None, 'Builder'))
self.assertEquals(True, lockfuncs.perm_above(self.char2, None, 'Builders'))
self.assertEquals(True, lockfuncs.perm_above(self.char2, None, 'Player'))
self.assertEquals(False, lockfuncs.perm_above(self.char2, None, 'Admin'))
self.assertEquals(False, lockfuncs.perm_above(self.char2, None, 'Admins'))
self.assertEquals(False, lockfuncs.perm_above(self.char2, None, 'Developers'))
@override_settings(PERMISSION_HIERARCHY=settings_default.PERMISSION_HIERARCHY)
def test_quell_perm(self):
self.account2.db._quell = True
self.assertEquals(False, lockfuncs.false(self.char2, None))
self.assertEquals(False, lockfuncs.perm(self.char2, None, 'Developer'))
self.assertEquals(False, lockfuncs.perm(self.char2, None, 'Developers'))
self.assertEquals(False, lockfuncs.perm(self.char2, None, 'Admin'))
self.assertEquals(False, lockfuncs.perm(self.char2, None, 'Admins'))
self.assertEquals(True, lockfuncs.perm(self.char2, None, 'Player'))
self.assertEquals(True, lockfuncs.perm(self.char2, None, 'Players'))
self.assertEquals(True, lockfuncs.perm(self.char2, None, 'Builder'))
self.assertEquals(True, lockfuncs.perm(self.char2, None, 'Builders'))
@override_settings(PERMISSION_HIERARCHY=settings_default.PERMISSION_HIERARCHY)
def test_quell_above_perm(self):
self.assertEquals(True, lockfuncs.perm_above(self.char2, None, 'Player'))
self.assertEquals(True, lockfuncs.perm_above(self.char2, None, 'Builder'))
@override_settings(PERMISSION_HIERARCHY=settings_default.PERMISSION_HIERARCHY)
def test_object_perm(self):
self.obj2.permissions.add('Admin')
self.assertEqual(True, lockfuncs.true(self.obj2, self.obj1))
self.assertEqual(False, lockfuncs.false(self.obj2, self.obj1))
self.assertEqual(True, lockfuncs.perm(self.obj2, self.obj1, 'Admin'))
self.assertEqual(True, lockfuncs.perm_above(self.obj2, self.obj1, 'Builder'))
self.assertEquals(False, lockfuncs.perm(self.obj2, None, 'Developer'))
self.assertEquals(False, lockfuncs.perm(self.obj2, None, 'Developers'))
self.assertEquals(True, lockfuncs.perm(self.obj2, None, 'Admin'))
self.assertEquals(True, lockfuncs.perm(self.obj2, None, 'Admins'))
self.assertEquals(True, lockfuncs.perm(self.obj2, None, 'Player'))
self.assertEquals(True, lockfuncs.perm(self.obj2, None, 'Players'))
self.assertEquals(True, lockfuncs.perm(self.obj2, None, 'Builder'))
self.assertEquals(True, lockfuncs.perm(self.obj2, None, 'Builders'))
@override_settings(PERMISSION_HIERARCHY=settings_default.PERMISSION_HIERARCHY)
def test_object_above_perm(self):
self.obj2.permissions.add('Admin')
self.assertEquals(False, lockfuncs.perm_above(self.obj2, None, 'Admins'))
self.assertEquals(True, lockfuncs.perm_above(self.obj2, None, 'Builder'))
self.assertEquals(True, lockfuncs.perm_above(self.obj2, None, 'Builders'))
@override_settings(PERMISSION_HIERARCHY=settings_default.PERMISSION_HIERARCHY)
def test_pperm(self):
self.obj2.permissions.add('Developer')
self.char2.permissions.add('Developer')
self.assertEquals(False, lockfuncs.pperm(self.obj2, None, 'Players'))
self.assertEquals(True, lockfuncs.pperm(self.char2, None, 'Players'))
self.assertEquals(True, lockfuncs.pperm(self.account, None, 'Admins'))
self.assertEquals(True, lockfuncs.pperm_above(self.account, None, 'Builders'))
self.assertEquals(False, lockfuncs.pperm_above(self.account2, None, 'Admins'))
self.assertEquals(True, lockfuncs.pperm_above(self.char2, None, 'Players'))
def test_dbref(self):
dbref = self.obj2.dbref
self.assertEqual(True, lockfuncs.dbref(self.obj2, self.obj1, '%s' % dbref))
self.assertEquals(True, lockfuncs.dbref(self.obj2, None, '%s' % dbref))
self.assertEquals(False, lockfuncs.id(self.obj2, None, '%s' % (dbref + '1')))
dbref = self.account2.dbref
self.assertEquals(True, lockfuncs.pdbref(self.account2, None, '%s' % dbref))
self.assertEquals(False, lockfuncs.pid(self.account2, None, '%s' % (dbref + '1')))
def test_attr(self):
self.obj2.db.testattr = 45
self.assertEqual(True, lockfuncs.attr(self.obj2, self.obj1, 'testattr', '45'))
self.assertEqual(False, lockfuncs.attr_gt(self.obj2, self.obj1, 'testattr', '45'))
self.assertEqual(True, lockfuncs.attr_ge(self.obj2, self.obj1, 'testattr', '45'))
self.assertEqual(False, lockfuncs.attr_lt(self.obj2, self.obj1, 'testattr', '45'))
self.assertEqual(True, lockfuncs.attr_le(self.obj2, self.obj1, 'testattr', '45'))
self.assertEqual(False, lockfuncs.attr_ne(self.obj2, self.obj1, 'testattr', '45'))
self.assertEquals(True, lockfuncs.attr(self.obj2, None, 'testattr', '45'))
self.assertEquals(False, lockfuncs.attr_gt(self.obj2, None, 'testattr', '45'))
self.assertEquals(True, lockfuncs.attr_ge(self.obj2, None, 'testattr', '45'))
self.assertEquals(False, lockfuncs.attr_lt(self.obj2, None, 'testattr', '45'))
self.assertEquals(True, lockfuncs.attr_le(self.obj2, None, 'testattr', '45'))
self.assertEquals(True, lockfuncs.objattr(None, self.obj2, 'testattr', '45'))
self.assertEquals(True, lockfuncs.objattr(None, self.obj2, 'testattr', '45'))
self.assertEquals(False, lockfuncs.objattr(None, self.obj2, 'testattr', '45', compare='lt'))
def test_locattr(self):
self.obj2.location.db.locattr = 'test'
self.assertEquals(True, lockfuncs.locattr(self.obj2, None, 'locattr', 'test'))
self.assertEquals(False, lockfuncs.locattr(self.obj2, None, 'fail', 'testfail'))
self.assertEquals(True, lockfuncs.objlocattr(None, self.obj2, 'locattr', 'test'))
def test_tag(self):
self.obj2.tags.add("test1")
self.obj2.tags.add("test2", "category1")
self.assertEquals(True, lockfuncs.tag(self.obj2, None, 'test1'))
self.assertEquals(True, lockfuncs.tag(self.obj2, None, 'test2', 'category1'))
self.assertEquals(False, lockfuncs.tag(self.obj2, None, 'test1', 'category1'))
self.assertEquals(False, lockfuncs.tag(self.obj2, None, 'test1', 'category2'))
self.assertEquals(True, lockfuncs.objtag(None, self.obj2, 'test2', 'category1'))
self.assertEquals(False, lockfuncs.objtag(None, self.obj2, 'test2'))
def test_inside_holds(self):
self.assertEquals(True, lockfuncs.inside(self.char1, self.room1))
self.assertEquals(False, lockfuncs.inside(self.char1, self.room2))
self.assertEquals(True, lockfuncs.holds(self.room1, self.char1))
self.assertEquals(False, lockfuncs.holds(self.room2, self.char1))
def test_has_account(self):
self.assertEquals(True, lockfuncs.has_account(self.char1, None))
self.assertEquals(False, lockfuncs.has_account(self.obj1, None))
@override_settings(IRC_ENABLED=True, TESTVAL=[1, 2, 3])
def test_serversetting(self):
self.assertEquals(True, lockfuncs.serversetting(None, None, 'IRC_ENABLED', 'True'))
self.assertEquals(True, lockfuncs.serversetting(None, None, 'TESTVAL', '[1, 2, 3]'))
self.assertEquals(False, lockfuncs.serversetting(None, None, 'TESTVAL', '[1, 2, 4]'))
self.assertEquals(False, lockfuncs.serversetting(None, None, 'TESTVAL', '123'))

View file

@ -76,10 +76,14 @@ class ObjectDBManager(TypedObjectManager):
# simplest case - search by dbref
dbref = self.dbref(ostring)
if dbref:
return dbref
try:
return self.get(id=dbref)
except self.model.DoesNotExist:
pass
# not a dbref. Search by name.
cand_restriction = candidates is not None and Q(pk__in=[_GA(obj, "id") for obj in make_iter(candidates)
if obj]) or Q()
cand_restriction = candidates is not None and Q(
pk__in=[_GA(obj, "id") for obj in make_iter(candidates) if obj]) or Q()
if exact:
return self.filter(cand_restriction & Q(db_account__username__iexact=ostring))
else: # fuzzy matching
@ -431,7 +435,7 @@ class ObjectDBManager(TypedObjectManager):
"""
Create and return a new object as a copy of the original object. All
will be identical to the original except for the arguments given
specifically to this method.
specifically to this method. Object contents will not be copied.
Args:
original_object (Object): The object to make a copy from.
@ -496,6 +500,10 @@ class ObjectDBManager(TypedObjectManager):
for script in original_object.scripts.all():
ScriptDB.objects.copy_script(script, new_obj=new_object)
# copy over all tags, if any
for tag in original_object.tags.get(return_tagobj=True, return_list=True):
new_object.tags.add(tag=tag.key, category=tag.category, data=tag.data)
return new_object
def clear_all_sessids(self):

View file

@ -6,8 +6,10 @@ entities.
"""
import time
import inflect
from builtins import object
from future.utils import with_metaclass
from collections import defaultdict
from django.conf import settings
@ -21,10 +23,13 @@ from evennia.commands.cmdsethandler import CmdSetHandler
from evennia.commands import cmdhandler
from evennia.utils import search
from evennia.utils import logger
from evennia.utils import ansi
from evennia.utils.utils import (variable_from_module, lazy_property,
make_iter, is_iter)
make_iter, is_iter, list_to_string,
to_str)
from django.utils.translation import ugettext as _
_INFLECT = inflect.engine()
_MULTISESSION_MODE = settings.MULTISESSION_MODE
_ScriptDB = None
@ -206,6 +211,14 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
def sessions(self):
return ObjectSessionHandler(self)
@property
def is_connected(self):
# we get an error for objects subscribed to channels without this
if self.account: # seems sane to pass on the account
return self.account.is_connected
else:
return False
@property
def has_account(self):
"""
@ -281,9 +294,40 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
return "{}(#{})".format(self.name, self.id)
return self.name
def get_numbered_name(self, count, looker, **kwargs):
"""
Return the numbered (singular, plural) forms of this object's key. This is by default called
by return_appearance and is used for grouping multiple same-named of this object. Note that
this will be called on *every* member of a group even though the plural name will be only
shown once. Also the singular display version, such as 'an apple', 'a tree' is determined
from this method.
Args:
count (int): Number of objects of this type
looker (Object): Onlooker. Not used by default.
Kwargs:
key (str): Optional key to pluralize, if given, use this instead of the object's key.
Returns:
singular (str): The singular form to display.
plural (str): The determined plural form of the key, including the count.
"""
key = kwargs.get("key", self.key)
key = ansi.ANSIString(key) # this is needed to allow inflection of colored names
plural = _INFLECT.plural(key, 2)
plural = "%s %s" % (_INFLECT.number_to_words(count, threshold=12), plural)
singular = _INFLECT.an(key)
if not self.aliases.get(plural, category="plural_key"):
# we need to wipe any old plurals/an/a in case key changed in the interrim
self.aliases.clear(category="plural_key")
self.aliases.add(plural, category="plural_key")
# save the singular form as an alias here too so we can display "an egg" and also
# look at 'an egg'.
self.aliases.add(singular, category="plural_key")
return singular, plural
def search(self, searchdata,
global_search=False,
use_nicks=True, # should this default to off?
use_nicks=True,
typeclass=None,
location=None,
attribute_name=None,
@ -335,7 +379,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
below.
exact (bool): if unset (default) - prefers to match to beginning of
string rather than not matching at all. If set, requires
exact mathing of entire string.
exact matching of entire string.
candidates (list of objects): this is an optional custom list of objects
to search (filter) between. It is ignored if `global_search`
is given. If not set, this list will automatically be defined
@ -518,6 +562,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
obj.at_msg_send(text=text, to_obj=self, **kwargs)
except Exception:
logger.log_trace()
kwargs["options"] = options
try:
if not self.at_msg_receive(text=text, **kwargs):
# if at_msg_receive returns false, we abort message to this object
@ -525,12 +570,20 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
except Exception:
logger.log_trace()
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
# relay to session(s)
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 for_contents(self, func, exclude=None, **kwargs):
"""
@ -951,14 +1004,14 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
cdict["location"].at_object_receive(self, None)
self.at_after_move(None)
if cdict.get("tags"):
# this should be a list of 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 a dict of attrname:value
# 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"].items():
for key, value in cdict["nattributes"]:
self.nattributes.add(key, value)
del self._createdict
@ -1432,7 +1485,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
# get and identify all objects
visible = (con for con in self.contents if con != looker and
con.access(looker, "view"))
exits, users, things = [], [], []
exits, users, things = [], [], defaultdict(list)
for con in visible:
key = con.get_display_name(looker)
if con.destination:
@ -1440,16 +1493,28 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
elif con.has_account:
users.append("|c%s|n" % key)
else:
things.append(key)
# things can be pluralized
things[key].append(con)
# get description, build string
string = "|c%s|n\n" % self.get_display_name(looker)
desc = self.db.desc
if desc:
string += "%s" % desc
if exits:
string += "\n|wExits:|n " + ", ".join(exits)
string += "\n|wExits:|n " + list_to_string(exits)
if users or things:
string += "\n|wYou see:|n " + ", ".join(users + things)
# handle pluralization of things (never pluralize users)
thing_strings = []
for key, itemlist in sorted(things.iteritems()):
nitem = len(itemlist)
if nitem == 1:
key, _ = itemlist[0].get_numbered_name(nitem, looker, key=key)
else:
key = [item.get_numbered_name(nitem, looker, key=key)[1] for item in itemlist][0]
thing_strings.append(key)
string += "\n|wYou see:|n " + list_to_string(users + thing_strings)
return string
def at_look(self, target, **kwargs):
@ -1684,11 +1749,12 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
msg_type = 'whisper'
msg_self = '{self} whisper to {all_receivers}, "{speech}"' if msg_self is True else msg_self
msg_receivers = '{object} whispers: "{speech}"'
msg_receivers = msg_receivers or '{object} whispers: "{speech}"'
msg_location = None
else:
msg_self = '{self} say, "{speech}"' if msg_self is True else msg_self
msg_receivers = None
msg_location = msg_location or '{object} says, "{speech}"'
msg_receivers = msg_receivers or message
custom_mapping = kwargs.get('mapping', {})
receivers = make_iter(receivers) if receivers else None
@ -1704,7 +1770,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
for recv in receivers) if receivers else None,
"speech": message}
self_mapping.update(custom_mapping)
self.msg(text=(msg_self.format(**self_mapping), {"type": msg_type}))
self.msg(text=(msg_self.format(**self_mapping), {"type": msg_type}), from_obj=self)
if receivers and msg_receivers:
receiver_mapping = {"self": "You",
@ -1722,19 +1788,25 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
for recv in receivers) if receivers else None}
receiver_mapping.update(individual_mapping)
receiver_mapping.update(custom_mapping)
receiver.msg(text=(msg_receivers.format(**receiver_mapping), {"type": msg_type}))
receiver.msg(text=(msg_receivers.format(**receiver_mapping),
{"type": msg_type}), from_obj=self)
if self.location and msg_location:
location_mapping = {"self": "You",
"object": self,
"location": location,
"all_receivers": ", ".join(recv for recv in receivers) if receivers else None,
"all_receivers": ", ".join(str(recv) for recv in receivers) if receivers else None,
"receiver": None,
"speech": message}
location_mapping.update(custom_mapping)
exclude = []
if msg_self:
exclude.append(self)
if receivers:
exclude.extend(receivers)
self.location.msg_contents(text=(msg_location, {"type": msg_type}),
from_obj=self,
exclude=(self, ) if msg_self else None,
exclude=exclude,
mapping=location_mapping)
@ -1805,7 +1877,7 @@ class DefaultCharacter(DefaultObject):
"""
self.msg("\nYou become |c%s|n.\n" % self.name)
self.msg(self.at_look(self.location))
self.msg((self.at_look(self.location), {'type':'look'}), options = None)
def message(obj, from_obj):
obj.msg("%s has entered the game." % self.get_display_name(obj), from_obj=from_obj)

View file

@ -0,0 +1,145 @@
# Prototypes
A 'Prototype' is a normal Python dictionary describing unique features of individual instance of a
Typeclass. The prototype is used to 'spawn' a new instance with custom features detailed by said
prototype. This allows for creating variations without having to create a large number of actual
Typeclasses. It is a good way to allow Builders more freedom of creation without giving them full
Python access to create Typeclasses.
For example, if a Typeclass 'Cat' describes all the coded differences between a Cat and
other types of animals, then prototypes could be used to quickly create unique individual cats with
different Attributes/properties (like different colors, stats, names etc) without having to make a new
Typeclass for each. Prototypes have inheritance and can be scripted when they are applied to create
a new instance of a typeclass - a common example would be to randomize stats and name.
The prototype is a normal dictionary with specific keys. Almost all values can be callables
triggered when the prototype is used to spawn a new instance. Below is an example:
```
{
# meta-keys - these are used only when listing prototypes in-game. Only prototype_key is mandatory,
# but it must be globally unique.
"prototype_key": "base_goblin",
"prototype_desc": "A basic goblin",
"prototype_locks": "edit:all();spawn:all()",
"prototype_tags": "mobs",
# fixed-meaning keys, modifying the spawned instance. 'typeclass' may be
# replaced by 'parent', referring to the prototype_key of an existing prototype
# to inherit from.
"typeclass": "types.objects.Monster",
"key": "goblin grunt",
"tags": ["mob", "evil", ('greenskin','mob')] # tags as well as tags with category etc
"attrs": [("weapon", "sword")] # this allows to set Attributes with categories etc
# non-fixed keys are interpreted as Attributes and their
"health": lambda: randint(20,30),
"resists": ["cold", "poison"],
"attacks": ["fists"],
"weaknesses": ["fire", "light"]
}
```
## Using prototypes
Prototypes are generally used as inputs to the `spawn` command:
@spawn prototype_key
This will spawn a new instance of the prototype in the caller's current location unless the
`location` key of the prototype was set (see below). The caller must pass the prototype's 'spawn'
lock to be able to use it.
@spawn/list [prototype_key]
will show all available prototypes along with meta info, or look at a specific prototype in detail.
## Creating prototypes
The `spawn` command can also be used to directly create/update prototypes from in-game.
spawn/save {"prototype_key: "goblin", ... }
but it is probably more convenient to use the menu-driven prototype wizard:
spawn/menu goblin
In code:
```python
from evennia import prototypes
goblin = {"prototype_key": "goblin:, ... }
prototype = prototypes.save_prototype(caller, **goblin)
```
Prototypes will normally be stored in the database (internally this is done using a Script, holding
the meta-info and the prototype). One can also define prototypes outside of the game by assigning
the prototype dictionary to a global variable in a module defined by `settings.PROTOTYPE_MODULES`:
```python
# in e.g. mygame/world/prototypes.py
GOBLIN = {
"prototype_key": "goblin",
...
}
```
Such prototypes cannot be modified from inside the game no matter what `edit` lock they are given
(we refer to them as 'readonly') but can be a fast and efficient way to give builders a starting
library of prototypes to inherit from.
## Valid Prototype keys
Every prototype key also accepts a callable (taking no arguments) for producing its value or a
string with an $protfunc definition. That callable/protfunc must then return a value on a form the
prototype key expects.
- `prototype_key` (str): name of this prototype. This is used when storing prototypes and should
be unique. This should always be defined but for prototypes defined in modules, the
variable holding the prototype dict will become the prototype_key if it's not explicitly
given.
- `prototype_desc` (str, optional): describes prototype in listings
- `prototype_locks` (str, optional): locks for restricting access to this prototype. Locktypes
supported are 'edit' and 'use'.
- `prototype_tags` (list, optional): List of tags or tuples (tag, category) used to group prototype
in listings
- `parent` (str or tuple, optional): name (`prototype_key`) of eventual parent prototype, or a
list of parents for multiple left-to-right inheritance.
- `prototype`: Deprecated. Same meaning as 'parent'.
- `typeclass` (str, optional): if not set, will use typeclass of parent prototype or use
`settings.BASE_OBJECT_TYPECLASS`
- `key` (str, optional): the name of the spawned object. If not given this will set to a
random hash
- `location` (obj, optional): location of the object - a valid object or #dbref
- `home` (obj or str, optional): valid object or #dbref
- `destination` (obj or str, optional): only valid for exits (object or #dbref)
- `permissions` (str or list, optional): which permissions for spawned object to have
- `locks` (str, optional): lock-string for the spawned object
- `aliases` (str or list, optional): Aliases for the spawned object.
- `exec` (str, optional): this is a string of python code to execute or a list of such
codes. This can be used e.g. to trigger custom handlers on the object. The execution
namespace contains 'evennia' for the library and 'obj'. All default spawn commands limit
this functionality to Developer/superusers. Usually it's better to use callables or
prototypefuncs instead of this.
- `tags` (str, tuple or list, optional): string or list of strings or tuples
`(tagstr, category)`. Plain strings will be result in tags with no category (default tags).
- `attrs` (tuple or list, optional): tuple or list of tuples of Attributes to add. This
form allows more complex Attributes to be set. Tuples at least specify `(key, value)`
but can also specify up to `(key, value, category, lockstring)`. If you want to specify a
lockstring but not a category, set the category to `None`.
- `ndb_<name>` (any): value of a nattribute (`ndb_` is stripped). This is usually not useful to
put in a prototype unless the NAttribute is used immediately upon spawning.
- `other` (any): any other name is interpreted as the key of an Attribute with
its value. Such Attributes have no categories.

View file

2462
evennia/prototypes/menus.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,327 @@
"""
Protfuncs are function-strings embedded in a prototype and allows for a builder to create a
prototype with custom logics without having access to Python. The Protfunc is parsed using the
inlinefunc parser but is fired at the moment the spawning happens, using the creating object's
session as input.
In the prototype dict, the protfunc is specified as a string inside the prototype, e.g.:
{ ...
"key": "$funcname(arg1, arg2, ...)"
... }
and multiple functions can be nested (no keyword args are supported). The result will be used as the
value for that prototype key for that individual spawn.
Available protfuncs are callables in one of the modules of `settings.PROT_FUNC_MODULES`. They
are specified as functions
def funcname (*args, **kwargs)
where *args are the arguments given in the prototype, and **kwargs are inserted by Evennia:
- session (Session): The Session of the entity spawning using this prototype.
- prototype (dict): The dict this protfunc is a part of.
- current_key (str): The active key this value belongs to in the prototype.
- testing (bool): This is set if this function is called as part of the prototype validation; if
set, the protfunc should take care not to perform any persistent actions, such as operate on
objects or add things to the database.
Any traceback raised by this function will be handled at the time of spawning and abort the spawn
before any object is created/updated. It must otherwise return the value to store for the specified
prototype key (this value must be possible to serialize in an Attribute).
"""
from ast import literal_eval
from random import randint as base_randint, random as base_random, choice as base_choice
from evennia.utils import search
from evennia.utils.utils import justify as base_justify, is_iter, to_str
_PROTLIB = None
# default protfuncs
def random(*args, **kwargs):
"""
Usage: $random()
Returns a random value in the interval [0, 1)
"""
return base_random()
def randint(*args, **kwargs):
"""
Usage: $randint(start, end)
Returns random integer in interval [start, end]
"""
if len(args) != 2:
raise TypeError("$randint needs two arguments - start and end.")
start, end = int(args[0]), int(args[1])
return base_randint(start, end)
def left_justify(*args, **kwargs):
"""
Usage: $left_justify(<text>)
Returns <text> left-justified.
"""
if args:
return base_justify(args[0], align='l')
return ""
def right_justify(*args, **kwargs):
"""
Usage: $right_justify(<text>)
Returns <text> right-justified across screen width.
"""
if args:
return base_justify(args[0], align='r')
return ""
def center_justify(*args, **kwargs):
"""
Usage: $center_justify(<text>)
Returns <text> centered in screen width.
"""
if args:
return base_justify(args[0], align='c')
return ""
def choice(*args, **kwargs):
"""
Usage: $choice(val, val, val, ...)
Returns one of the values randomly
"""
if args:
return base_choice(args)
return ""
def full_justify(*args, **kwargs):
"""
Usage: $full_justify(<text>)
Returns <text> filling up screen width by adding extra space.
"""
if args:
return base_justify(args[0], align='f')
return ""
def protkey(*args, **kwargs):
"""
Usage: $protkey(<key>)
Returns the value of another key in this prototoype. Will raise an error if
the key is not found in this prototype.
"""
if args:
prototype = kwargs['prototype']
return prototype[args[0].strip()]
def add(*args, **kwargs):
"""
Usage: $add(val1, val2)
Returns the result of val1 + val2. Values must be
valid simple Python structures possible to add,
such as numbers, lists etc.
"""
if len(args) > 1:
val1, val2 = args[0], args[1]
# try to convert to python structures, otherwise, keep as strings
try:
val1 = literal_eval(val1.strip())
except Exception:
pass
try:
val2 = literal_eval(val2.strip())
except Exception:
pass
return val1 + val2
raise ValueError("$add requires two arguments.")
def sub(*args, **kwargs):
"""
Usage: $del(val1, val2)
Returns the value of val1 - val2. Values must be
valid simple Python structures possible to
subtract.
"""
if len(args) > 1:
val1, val2 = args[0], args[1]
# try to convert to python structures, otherwise, keep as strings
try:
val1 = literal_eval(val1.strip())
except Exception:
pass
try:
val2 = literal_eval(val2.strip())
except Exception:
pass
return val1 - val2
raise ValueError("$sub requires two arguments.")
def mult(*args, **kwargs):
"""
Usage: $mul(val1, val2)
Returns the value of val1 * val2. The values must be
valid simple Python structures possible to
multiply, like strings and/or numbers.
"""
if len(args) > 1:
val1, val2 = args[0], args[1]
# try to convert to python structures, otherwise, keep as strings
try:
val1 = literal_eval(val1.strip())
except Exception:
pass
try:
val2 = literal_eval(val2.strip())
except Exception:
pass
return val1 * val2
raise ValueError("$mul requires two arguments.")
def div(*args, **kwargs):
"""
Usage: $div(val1, val2)
Returns the value of val1 / val2. Values must be numbers and
the result is always a float.
"""
if len(args) > 1:
val1, val2 = args[0], args[1]
# try to convert to python structures, otherwise, keep as strings
try:
val1 = literal_eval(val1.strip())
except Exception:
pass
try:
val2 = literal_eval(val2.strip())
except Exception:
pass
return val1 / float(val2)
raise ValueError("$mult requires two arguments.")
def toint(*args, **kwargs):
"""
Usage: $toint(<number>)
Returns <number> as an integer.
"""
if args:
val = args[0]
try:
return int(literal_eval(val.strip()))
except ValueError:
return val
raise ValueError("$toint requires one argument.")
def eval(*args, **kwargs):
"""
Usage $eval(<expression>)
Returns evaluation of a simple Python expression. The string may *only* consist of the following
Python literal structures: strings, numbers, tuples, lists, dicts, booleans,
and None. The strings can also contain #dbrefs. Escape embedded protfuncs as $$protfunc(..)
- those will then be evaluated *after* $eval.
"""
global _PROTLIB
if not _PROTLIB:
from evennia.prototypes import prototypes as _PROTLIB
string = ",".join(args)
struct = literal_eval(string)
if isinstance(struct, basestring):
# we must shield the string, otherwise it will be merged as a string and future
# literal_evas will pick up e.g. '2' as something that should be converted to a number
struct = '"{}"'.format(struct)
# convert any #dbrefs to objects (also in nested structures)
struct = _PROTLIB.value_to_obj_or_any(struct)
return struct
def _obj_search(*args, **kwargs):
"Helper function to search for an object"
query = "".join(args)
session = kwargs.get("session", None)
return_list = kwargs.pop("return_list", False)
account = None
if session:
account = session.account
targets = search.search_object(query)
if return_list:
retlist = []
if account:
for target in targets:
if target.access(account, target, 'control'):
retlist.append(target)
else:
retlist = targets
return retlist
else:
# single-match
if not targets:
raise ValueError("$obj: Query '{}' gave no matches.".format(query))
if len(targets) > 1:
raise ValueError("$obj: Query '{query}' gave {nmatches} matches. Limit your "
"query or use $objlist instead.".format(
query=query, nmatches=len(targets)))
target = targets[0]
if account:
if not target.access(account, target, 'control'):
raise ValueError("$obj: Obj {target}(#{dbref} cannot be added - "
"Account {account} does not have 'control' access.".format(
target=target.key, dbref=target.id, account=account))
return target
def obj(*args, **kwargs):
"""
Usage $obj(<query>)
Returns one Object searched globally by key, alias or #dbref. Error if more than one.
"""
obj = _obj_search(return_list=False, *args, **kwargs)
if obj:
return "#{}".format(obj.id)
return "".join(args)
def objlist(*args, **kwargs):
"""
Usage $objlist(<query>)
Returns list with one or more Objects searched globally by key, alias or #dbref.
"""
return ["#{}".format(obj.id) for obj in _obj_search(return_list=True, *args, **kwargs)]

View file

@ -0,0 +1,735 @@
"""
Handling storage of prototypes, both database-based ones (DBPrototypes) and those defined in modules
(Read-only prototypes). Also contains utility functions, formatters and manager functions.
"""
import re
from ast import literal_eval
from django.conf import settings
from evennia.scripts.scripts import DefaultScript
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)
from evennia.locks.lockhandler import validate_lockstring, check_lockstring
from evennia.utils import logger
from evennia.utils import inlinefuncs, dbserialize
from evennia.utils.evtable import EvTable
_MODULE_PROTOTYPE_MODULES = {}
_MODULE_PROTOTYPES = {}
_PROTOTYPE_META_NAMES = (
"prototype_key", "prototype_desc", "prototype_tags", "prototype_locks", "prototype_parent")
_PROTOTYPE_RESERVED_KEYS = _PROTOTYPE_META_NAMES + (
"key", "aliases", "typeclass", "location", "home", "destination",
"permissions", "locks", "exec", "tags", "attrs")
_PROTOTYPE_TAG_CATEGORY = "from_prototype"
_PROTOTYPE_TAG_META_CATEGORY = "db_prototype"
PROT_FUNCS = {}
_RE_DBREF = re.compile(r"(?<!\$obj\()(#[0-9]+)")
class PermissionError(RuntimeError):
pass
class ValidationError(RuntimeError):
"""
Raised on prototype validation errors
"""
pass
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.
Args:
prototype (dict): Prototype.
custom_keys (list, optional): Custom keys which should not be interpreted as attrs, beyond
the default reserved keys.
Returns:
homogenized (dict): Prototype where all non-identified keys grouped as attributes.
"""
reserved = _PROTOTYPE_RESERVED_KEYS + (custom_keys or ())
attrs = list(prototype.get('attrs', [])) # break reference
homogenized = {}
for key, val in prototype.items():
if key in reserved:
homogenized[key] = val
else:
attrs.append((key, val, None, ''))
if attrs:
homogenized['attrs'] = attrs
return homogenized
# module-based prototypes
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)]
# 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
for prototype_key, prot in prots:
actual_prot_key = prot.get('prototype_key', prototype_key).lower()
prot.update({
"prototype_key": actual_prot_key,
"prototype_desc": prot['prototype_desc'] if 'prototype_desc' in prot else mod,
"prototype_locks": (prot['prototype_locks']
if 'prototype_locks' in prot else "use:all();edit:false()"),
"prototype_tags": list(set(make_iter(prot.get('prototype_tags', [])) + ["module"]))})
_MODULE_PROTOTYPES[actual_prot_key] = prot
# Db-based prototypes
class DbPrototype(DefaultScript):
"""
This stores a single prototype, in an Attribute `prototype`.
"""
def at_script_creation(self):
self.key = "empty prototype" # prototype_key
self.desc = "A prototype" # prototype_desc (.tags are used for prototype_tags)
self.db.prototype = {} # actual prototype
@property
def prototype(self):
"Make sure to decouple from db!"
return dbserialize.deserialize(self.attributes.get('prototype', {}))
@prototype.setter
def prototype(self, prototype):
self.attributes.add('prototype', prototype)
# Prototype manager functions
def save_prototype(**kwargs):
"""
Create/Store a prototype persistently.
Kwargs:
prototype_key (str): This is required for any storage.
All other kwargs are considered part of the new prototype dict.
Returns:
prototype (dict or None): The prototype stored using the given kwargs, None if deleting.
Raises:
prototypes.ValidationError: If prototype does not validate.
Note:
No edit/spawn locks will be checked here - if this function is called the caller
is expected to have valid permissions.
"""
kwargs = homogenize_prototype(kwargs)
def _to_batchtuple(inp, *args):
"build tuple suitable for batch-creation"
if is_iter(inp):
# already a tuple/list, use as-is
return inp
return (inp, ) + args
prototype_key = kwargs.get("prototype_key")
if not prototype_key:
raise ValidationError("Prototype requires a prototype_key")
prototype_key = str(prototype_key).lower()
# we can't edit a prototype defined in a module
if prototype_key in _MODULE_PROTOTYPES:
mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key, "N/A")
raise PermissionError("{} is a read-only prototype "
"(defined as code in {}).".format(prototype_key, mod))
# make sure meta properties are included with defaults
stored_prototype = DbPrototype.objects.filter(db_key=prototype_key)
prototype = stored_prototype[0].prototype if stored_prototype else {}
kwargs['prototype_desc'] = kwargs.get("prototype_desc", prototype.get("prototype_desc", ""))
prototype_locks = kwargs.get(
"prototype_locks", prototype.get('prototype_locks', "spawn:all();edit:perm(Admin)"))
is_valid, err = validate_lockstring(prototype_locks)
if not is_valid:
raise ValidationError("Lock error: {}".format(err))
kwargs['prototype_locks'] = prototype_locks
prototype_tags = [
_to_batchtuple(tag, _PROTOTYPE_TAG_META_CATEGORY)
for tag in make_iter(kwargs.get("prototype_tags",
prototype.get('prototype_tags', [])))]
kwargs["prototype_tags"] = prototype_tags
prototype.update(kwargs)
if stored_prototype:
# edit existing prototype
stored_prototype = stored_prototype[0]
stored_prototype.desc = prototype['prototype_desc']
if prototype_tags:
stored_prototype.tags.clear(category=_PROTOTYPE_TAG_CATEGORY)
stored_prototype.tags.batch_add(*prototype['prototype_tags'])
stored_prototype.locks.add(prototype['prototype_locks'])
stored_prototype.attributes.add('prototype', prototype)
else:
# create a new prototype
stored_prototype = create_script(
DbPrototype, key=prototype_key, desc=prototype['prototype_desc'], persistent=True,
locks=prototype_locks, tags=prototype['prototype_tags'],
attributes=[("prototype", prototype)])
return stored_prototype.prototype
create_prototype = save_prototype # alias
def delete_prototype(prototype_key, caller=None):
"""
Delete a stored prototype
Args:
key (str): The persistent prototype to delete.
caller (Account or Object, optionsl): Caller aiming to delete a prototype.
Note that no locks will be checked if`caller` is not passed.
Returns:
success (bool): If deletion worked or not.
Raises:
PermissionError: If 'edit' lock was not passed or deletion failed for some other reason.
"""
if prototype_key in _MODULE_PROTOTYPES:
mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key.lower(), "N/A")
raise PermissionError("{} is a read-only prototype "
"(defined as code in {}).".format(prototype_key, mod))
stored_prototype = DbPrototype.objects.filter(db_key__iexact=prototype_key)
if not stored_prototype:
raise PermissionError("Prototype {} was not found.".format(prototype_key))
stored_prototype = stored_prototype[0]
if caller:
if not stored_prototype.access(caller, 'edit'):
raise PermissionError("{} does not have permission to "
"delete prototype {}.".format(caller, prototype_key))
stored_prototype.delete()
return True
def search_prototype(key=None, tags=None):
"""
Find prototypes based on key and/or tags, or all prototypes.
Kwargs:
key (str): An exact or partial key to query for.
tags (str or list): Tag key or keys to query for. These
will always be applied with the 'db_protototype'
tag category.
Return:
matches (list): All found prototype dicts. If no keys
or tags are given, all available prototypes will be returned.
Note:
The available prototypes is a combination of those supplied in
PROTOTYPE_MODULES and those stored in the database. Note that if
tags are given and the prototype has no tags defined, it will not
be found as a match.
"""
# search module prototypes
mod_matches = {}
if tags:
# use tags to limit selection
tagset = set(tags)
mod_matches = {prototype_key: prototype
for prototype_key, prototype in _MODULE_PROTOTYPES.items()
if tagset.intersection(prototype.get("prototype_tags", []))}
else:
mod_matches = _MODULE_PROTOTYPES
if key:
if key in mod_matches:
# exact match
module_prototypes = [mod_matches[key]]
else:
# fuzzy matching
module_prototypes = [prototype for prototype_key, prototype in mod_matches.items()
if key in prototype_key]
else:
module_prototypes = [match for match in mod_matches.values()]
# search db-stored prototypes
if tags:
# exact match on tag(s)
tags = make_iter(tags)
tag_categories = ["db_prototype" for _ in tags]
db_matches = DbPrototype.objects.get_by_tag(tags, tag_categories)
else:
db_matches = DbPrototype.objects.all()
if key:
# exact or partial match on key
db_matches = db_matches.filter(db_key=key) or db_matches.filter(db_key__icontains=key)
# return prototype
db_prototypes = [dbprot.prototype for dbprot in db_matches]
matches = db_prototypes + module_prototypes
nmatches = len(matches)
if nmatches > 1 and key:
key = key.lower()
# avoid duplicates if an exact match exist between the two types
filter_matches = [mta for mta in matches
if mta.get('prototype_key') and mta['prototype_key'] == key]
if filter_matches and len(filter_matches) < nmatches:
matches = filter_matches
return matches
def search_objects_with_prototype(prototype_key):
"""
Retrieve all object instances created by a given prototype.
Args:
prototype_key (str): The exact (and unique) prototype identifier to query for.
Returns:
matches (Queryset): All matching objects spawned from this prototype.
"""
return ObjectDB.objects.get_by_tag(key=prototype_key, category=_PROTOTYPE_TAG_CATEGORY)
def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True):
"""
Collate a list of found prototypes based on search criteria and access.
Args:
caller (Account or Object): The object requesting the list.
key (str, optional): Exact or partial prototype key to query for.
tags (str or list, optional): Tag key or keys to query for.
show_non_use (bool, optional): Show also prototypes the caller may not use.
show_non_edit (bool, optional): Show also prototypes the caller may not edit.
Returns:
table (EvTable or None): An EvTable representation of the prototypes. None
if no prototypes were found.
"""
# this allows us to pass lists of empty strings
tags = [tag for tag in make_iter(tags) if tag]
# get prototypes for readonly and db-based prototypes
prototypes = search_prototype(key, tags)
# get use-permissions of readonly attributes (edit is always False)
display_tuples = []
for prototype in sorted(prototypes, key=lambda d: d.get('prototype_key', '')):
lock_use = caller.locks.check_lockstring(
caller, prototype.get('prototype_locks', ''), access_type='spawn')
if not show_non_use and not lock_use:
continue
if prototype.get('prototype_key', '') in _MODULE_PROTOTYPES:
lock_edit = False
else:
lock_edit = caller.locks.check_lockstring(
caller, prototype.get('prototype_locks', ''), access_type='edit')
if not show_non_edit and not lock_edit:
continue
ptags = []
for ptag in prototype.get('prototype_tags', []):
if is_iter(ptag):
if len(ptag) > 1:
ptags.append("{} (category: {}".format(ptag[0], ptag[1]))
else:
ptags.append(ptag[0])
else:
ptags.append(str(ptag))
display_tuples.append(
(prototype.get('prototype_key', '<unset>'),
prototype.get('prototype_desc', '<unset>'),
"{}/{}".format('Y' if lock_use else 'N', 'Y' if lock_edit else 'N'),
",".join(ptags)))
if not display_tuples:
return ""
table = []
width = 78
for i in range(len(display_tuples[0])):
table.append([str(display_tuple[i]) for display_tuple in display_tuples])
table = EvTable("Key", "Desc", "Spawn/Edit", "Tags", table=table, crop=True, width=width)
table.reformat_column(0, width=22)
table.reformat_column(1, width=29)
table.reformat_column(2, width=11, align='c')
table.reformat_column(3, width=16)
return table
def validate_prototype(prototype, protkey=None, protparents=None,
is_prototype_base=True, strict=True, _flags=None):
"""
Run validation on a prototype, checking for inifinite regress.
Args:
prototype (dict): Prototype to validate.
protkey (str, optional): The name of the prototype definition. If not given, the prototype
dict needs to have the `prototype_key` field set.
protpartents (dict, optional): The available prototype parent library. If
note given this will be determined from settings/database.
is_prototype_base (bool, optional): We are trying to create a new object *based on this
object*. This means we can't allow 'mixin'-style prototypes without typeclass/parent
etc.
strict (bool, optional): If unset, don't require needed keys, only check against infinite
recursion etc.
_flags (dict, optional): Internal work dict that should not be set externally.
Raises:
RuntimeError: If prototype has invalid structure.
RuntimeWarning: If prototype has issues that would make it unsuitable to build an object
with (it may still be useful as a mix-in prototype).
"""
assert isinstance(prototype, dict)
if _flags is None:
_flags = {"visited": [], "depth": 0, "typeclass": False, "errors": [], "warnings": []}
if not protparents:
protparents = {prototype.get('prototype_key', "").lower(): prototype
for prototype in search_prototype()}
protkey = protkey and protkey.lower() or prototype.get('prototype_key', None)
if strict and not bool(protkey):
_flags['errors'].append("Prototype lacks a `prototype_key`.")
protkey = "[UNSET]"
typeclass = prototype.get('typeclass')
prototype_parent = prototype.get('prototype_parent', [])
if strict and not (typeclass or prototype_parent):
if is_prototype_base:
_flags['errors'].append("Prototype {} requires `typeclass` "
"or 'prototype_parent'.".format(protkey))
else:
_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))
# recursively traverese prototype_parent chain
for protstring in make_iter(prototype_parent):
protstring = protstring.lower()
if protkey is not None and protstring == protkey:
_flags['errors'].append("Prototype {} tries to parent itself.".format(protkey))
protparent = protparents.get(protstring)
if not protparent:
_flags['errors'].append("Prototype {}'s prototype_parent '{}' was not found.".format(
(protkey, protstring)))
if id(prototype) in _flags['visited']:
_flags['errors'].append(
"{} has infinite nesting of prototypes.".format(protkey or prototype))
if _flags['errors']:
raise RuntimeError("Error: " + "\nError: ".join(_flags['errors']))
_flags['visited'].append(id(prototype))
_flags['depth'] += 1
validate_prototype(protparent, protstring, protparents,
is_prototype_base=is_prototype_base, _flags=_flags)
_flags['visited'].pop()
_flags['depth'] -= 1
if typeclass and not _flags['typeclass']:
_flags['typeclass'] = typeclass
# if we get back to the current level without a typeclass it's an error.
if strict and is_prototype_base and _flags['depth'] <= 0 and not _flags['typeclass']:
_flags['errors'].append("Prototype {} has no `typeclass` defined anywhere in its parent\n "
"chain. Add `typeclass`, or a `prototype_parent` pointing to a "
"prototype with a typeclass.".format(protkey))
if _flags['depth'] <= 0:
if _flags['errors']:
raise RuntimeError("Error: " + "\nError: ".join(_flags['errors']))
if _flags['warnings']:
raise RuntimeWarning("Warning: " + "\nWarning: ".join(_flags['warnings']))
# make sure prototype_locks are set to defaults
prototype_locks = [lstring.split(":", 1)
for lstring in prototype.get("prototype_locks", "").split(';')
if ":" in lstring]
locktypes = [tup[0].strip() for tup in prototype_locks]
if "spawn" not in locktypes:
prototype_locks.append(("spawn", "all()"))
if "edit" not in locktypes:
prototype_locks.append(("edit", "all()"))
prototype_locks = ";".join(":".join(tup) for tup in prototype_locks)
prototype['prototype_locks'] = prototype_locks
# Protfunc parsing (in-prototype functions)
for mod in settings.PROT_FUNC_MODULES:
try:
callables = callables_from_module(mod)
PROT_FUNCS.update(callables)
except ImportError:
logger.log_trace()
raise
def protfunc_parser(value, available_functions=None, testing=False, stacktrace=False, **kwargs):
"""
Parse a prototype value string for a protfunc and process it.
Available protfuncs are specified as callables in one of the modules of
`settings.PROTFUNC_MODULES`, or specified on the command line.
Args:
value (any): The value to test for a parseable protfunc. Only strings will be parsed for
protfuncs, all other types are returned as-is.
available_functions (dict, optional): Mapping of name:protfunction to use for this parsing.
If not set, use default sources.
testing (bool, optional): Passed to protfunc. If in a testing mode, some protfuncs may
behave differently.
stacktrace (bool, optional): If set, print the stack parsing process of the protfunc-parser.
Kwargs:
session (Session): Passed to protfunc. Session of the entity spawning the prototype.
protototype (dict): Passed to protfunc. The dict this protfunc is a part of.
current_key(str): Passed to protfunc. The key in the prototype that will hold this value.
any (any): Passed on to the protfunc.
Returns:
testresult (tuple): If `testing` is set, returns a tuple (error, result) where error is
either None or a string detailing the error from protfunc_parser or seen when trying to
run `literal_eval` on the parsed string.
any (any): A structure to replace the string on the prototype level. If this is a
callable or a (callable, (args,)) structure, it will be executed as if one had supplied
it to the prototype directly. This structure is also passed through literal_eval so one
can get actual Python primitives out of it (not just strings). It will also identify
eventual object #dbrefs in the output from the protfunc.
"""
if not isinstance(value, basestring):
try:
value = value.dbref
except AttributeError:
pass
value = to_str(value, force_string=True)
available_functions = PROT_FUNCS if available_functions is None else available_functions
# insert $obj(#dbref) for #dbref
value = _RE_DBREF.sub("$obj(\\1)", value)
result = inlinefuncs.parse_inlinefunc(
value, available_funcs=available_functions,
stacktrace=stacktrace, testing=testing, **kwargs)
err = None
try:
result = literal_eval(result)
except ValueError:
pass
except Exception as err:
err = str(err)
if testing:
return err, result
return result
# Various prototype utilities
def format_available_protfuncs():
"""
Get all protfuncs in a pretty-formatted form.
Args:
clr (str, optional): What coloration tag to use.
"""
out = []
for protfunc_name, protfunc in PROT_FUNCS.items():
out.append("- |c${name}|n - |W{docs}".format(
name=protfunc_name, docs=protfunc.__doc__.strip().replace("\n", "")))
return justify("\n".join(out), indent=8)
def prototype_to_str(prototype):
"""
Format a prototype to a nice string representation.
Args:
prototype (dict): The prototype.
"""
prototype = homogenize_prototype(prototype)
header = """
|cprototype-key:|n {prototype_key}, |c-tags:|n {prototype_tags}, |c-locks:|n {prototype_locks}|n
|c-desc|n: {prototype_desc}
|cprototype-parent:|n {prototype_parent}
\n""".format(
prototype_key=prototype.get('prototype_key', '|r[UNSET](required)|n'),
prototype_tags=prototype.get('prototype_tags', '|wNone|n'),
prototype_locks=prototype.get('prototype_locks', '|wNone|n'),
prototype_desc=prototype.get('prototype_desc', '|wNone|n'),
prototype_parent=prototype.get('prototype_parent', '|wNone|n'))
key = prototype.get('key', '')
if key:
key = "|ckey:|n {key}".format(key=key)
aliases = prototype.get("aliases", '')
if aliases:
aliases = "|caliases:|n {aliases}".format(
aliases=", ".join(aliases))
attrs = prototype.get("attrs", '')
if attrs:
out = []
for (attrkey, value, category, locks) in attrs:
locks = ", ".join(lock for lock in locks if lock)
category = "|ccategory:|n {}".format(category) if category else ''
cat_locks = ""
if category or locks:
cat_locks = " (|ccategory:|n {category}, ".format(
category=category if category else "|wNone|n")
out.append(
"{attrkey}{cat_locks} |c=|n {value}".format(
attrkey=attrkey,
cat_locks=cat_locks,
locks=locks if locks else "|wNone|n",
value=value))
attrs = "|cattrs:|n\n {attrs}".format(attrs="\n ".join(out))
tags = prototype.get('tags', '')
if tags:
out = []
for (tagkey, category, data) in tags:
out.append("{tagkey} (category: {category}{dat})".format(
tagkey=tagkey, category=category, dat=", data: {}".format(data) if data else ""))
tags = "|ctags:|n\n {tags}".format(tags=", ".join(out))
locks = prototype.get('locks', '')
if locks:
locks = "|clocks:|n\n {locks}".format(locks=locks)
permissions = prototype.get("permissions", '')
if permissions:
permissions = "|cpermissions:|n {perms}".format(perms=", ".join(permissions))
location = prototype.get("location", '')
if location:
location = "|clocation:|n {location}".format(location=location)
home = prototype.get("home", '')
if home:
home = "|chome:|n {home}".format(home=home)
destination = prototype.get("destination", '')
if destination:
destination = "|cdestination:|n {destination}".format(destination=destination)
body = "\n".join(part for part in (key, aliases, attrs, tags, locks, permissions,
location, home, destination) if part)
return header.lstrip() + body.strip()
def check_permission(prototype_key, action, default=True):
"""
Helper function to check access to actions on given prototype.
Args:
prototype_key (str): The prototype to affect.
action (str): One of "spawn" or "edit".
default (str): If action is unknown or prototype has no locks
Returns:
passes (bool): If permission for action is granted or not.
"""
if action == 'edit':
if prototype_key in _MODULE_PROTOTYPES:
mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key, "N/A")
logger.log_err("{} is a read-only prototype "
"(defined as code in {}).".format(prototype_key, mod))
return False
prototype = search_prototype(key=prototype_key)
if not prototype:
logger.log_err("Prototype {} not found.".format(prototype_key))
return False
lockstring = prototype.get("prototype_locks")
if lockstring:
return check_lockstring(None, lockstring, default=default, access_type=action)
return default
def init_spawn_value(value, validator=None):
"""
Analyze the prototype value and produce a value useful at the point of spawning.
Args:
value (any): This can be:
callable - will be called as callable()
(callable, (args,)) - will be called as callable(*args)
other - will be assigned depending on the variable type
validator (callable, optional): If given, this will be called with the value to
check and guarantee the outcome is of a given type.
Returns:
any (any): The (potentially pre-processed value to use for this prototype key)
"""
value = protfunc_parser(value)
validator = validator if validator else lambda o: o
if callable(value):
return validator(value())
elif value and is_iter(value) and callable(value[0]):
# a structure (callable, (args, ))
args = value[1:]
return validator(value[0](*make_iter(args)))
else:
return validator(value)
def value_to_obj_or_any(value):
"Convert value(s) to Object if possible, otherwise keep original value"
stype = type(value)
if is_iter(value):
if stype == dict:
return {value_to_obj_or_any(key):
value_to_obj_or_any(val) for key, val in value.items()}
else:
return stype([value_to_obj_or_any(val) for val in value])
obj = dbid_to_obj(value, ObjectDB)
return obj if obj is not None else value
def value_to_obj(value, force=True):
"Always convert value(s) to Object, or None"
stype = type(value)
if is_iter(value):
if stype == dict:
return {value_to_obj_or_any(key): value_to_obj_or_any(val) for key, val in value.iter()}
else:
return stype([value_to_obj_or_any(val) for val in value])
return dbid_to_obj(value, ObjectDB)

View file

@ -0,0 +1,754 @@
"""
Spawner
The spawner takes input files containing object definitions in
dictionary forms. These use a prototype architecture to define
unique objects without having to make a Typeclass for each.
There main function is `spawn(*prototype)`, where the `prototype`
is a dictionary like this:
```python
from evennia.prototypes import prototypes
prot = {
"prototype_key": "goblin",
"typeclass": "types.objects.Monster",
"key": "goblin grunt",
"health": lambda: randint(20,30),
"resists": ["cold", "poison"],
"attacks": ["fists"],
"weaknesses": ["fire", "light"]
"tags": ["mob", "evil", ('greenskin','mob')]
"attrs": [("weapon", "sword")]
}
prot = prototypes.create_prototype(**prot)
```
Possible keywords are:
prototype_key (str): name of this prototype. This is used when storing prototypes and should
be unique. This should always be defined but for prototypes defined in modules, the
variable holding the prototype dict will become the prototype_key if it's not explicitly
given.
prototype_desc (str, optional): describes prototype in listings
prototype_locks (str, optional): locks for restricting access to this prototype. Locktypes
supported are 'edit' and 'use'.
prototype_tags(list, optional): List of tags or tuples (tag, category) used to group prototype
in listings
prototype_parent (str, tuple or callable, optional): name (prototype_key) of eventual parent prototype, or
a list of parents, for multiple left-to-right inheritance.
prototype: Deprecated. Same meaning as 'parent'.
typeclass (str or callable, optional): if not set, will use typeclass of parent prototype or use
`settings.BASE_OBJECT_TYPECLASS`
key (str or callable, optional): the name of the spawned object. If not given this will set to a
random hash
location (obj, str or callable, optional): location of the object - a valid object or #dbref
home (obj, str or callable, optional): valid object or #dbref
destination (obj, str or callable, optional): only valid for exits (object or #dbref)
permissions (str, list or callable, optional): which permissions for spawned object to have
locks (str or callable, optional): lock-string for the spawned object
aliases (str, list or callable, optional): Aliases for the spawned object
exec (str or callable, optional): this is a string of python code to execute or a list of such
codes. This can be used e.g. to trigger custom handlers on the object. The execution
namespace contains 'evennia' for the library and 'obj'. All default spawn commands limit
this functionality to Developer/superusers. Usually it's better to use callables or
prototypefuncs instead of this.
tags (str, tuple, list or callable, optional): string or list of strings or tuples
`(tagstr, category)`. Plain strings will be result in tags with no category (default tags).
attrs (tuple, list or callable, optional): tuple or list of tuples of Attributes to add. This
form allows more complex Attributes to be set. Tuples at least specify `(key, value)`
but can also specify up to `(key, value, category, lockstring)`. If you want to specify a
lockstring but not a category, set the category to `None`.
ndb_<name> (any): value of a nattribute (ndb_ is stripped) - this is of limited use.
other (any): any other name is interpreted as the key of an Attribute with
its value. Such Attributes have no categories.
Each value can also be a callable that takes no arguments. It should
return the value to enter into the field and will be called every time
the prototype is used to spawn an object. Note, if you want to store
a callable in an Attribute, embed it in a tuple to the `args` keyword.
By specifying the "prototype_parent" key, the prototype becomes a child of
the given prototype, inheritng all prototype slots it does not explicitly
define itself, while overloading those that it does specify.
```python
import random
{
"prototype_key": "goblin_wizard",
"prototype_parent": GOBLIN,
"key": "goblin wizard",
"spells": ["fire ball", "lighting bolt"]
}
GOBLIN_ARCHER = {
"prototype_parent": GOBLIN,
"key": "goblin archer",
"attack_skill": (random, (5, 10))"
"attacks": ["short bow"]
}
```
One can also have multiple prototypes. These are inherited from the
left, with the ones further to the right taking precedence.
```python
ARCHWIZARD = {
"attack": ["archwizard staff", "eye of doom"]
GOBLIN_ARCHWIZARD = {
"key" : "goblin archwizard"
"prototype_parent": (GOBLIN_WIZARD, ARCHWIZARD),
}
```
The *goblin archwizard* will have some different attacks, but will
otherwise have the same spells as a *goblin wizard* who in turn shares
many traits with a normal *goblin*.
Storage mechanism:
This sets up a central storage for prototypes. The idea is to make these
available in a repository for buildiers to use. Each prototype is stored
in a Script so that it can be tagged for quick sorting/finding and locked for limiting
access.
This system also takes into consideration prototypes defined and stored in modules.
Such prototypes are considered 'read-only' to the system and can only be modified
in code. To replace a default prototype, add the same-name prototype in a
custom module read later in the settings.PROTOTYPE_MODULES list. To remove a default
prototype, override its name with an empty dict.
"""
from __future__ import print_function
import copy
import hashlib
import time
from django.conf import settings
import evennia
from evennia.objects.models import ObjectDB
from evennia.utils.utils import make_iter, is_iter
from evennia.prototypes import prototypes as protlib
from evennia.prototypes.prototypes import (
value_to_obj, value_to_obj_or_any, init_spawn_value, _PROTOTYPE_TAG_CATEGORY)
_CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination")
_PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks")
_PROTOTYPE_ROOT_NAMES = ('typeclass', 'key', 'aliases', 'attrs', 'tags', 'locks', 'permissions',
'location', 'home', 'destination')
_NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES
# Helper
def _get_prototype(inprot, protparents, uninherited=None, _workprot=None):
"""
Recursively traverse a prototype dictionary, including multiple
inheritance. Use validate_prototype before this, we don't check
for infinite recursion here.
Args:
inprot (dict): Prototype dict (the individual prototype, with no inheritance included).
protparents (dict): Available protparents, keyed by prototype_key.
uninherited (dict): Parts of prototype to not inherit.
_workprot (dict, optional): Work dict for the recursive algorithm.
"""
_workprot = {} if _workprot is None else _workprot
if "prototype_parent" in inprot:
# move backwards through the inheritance
for prototype in make_iter(inprot["prototype_parent"]):
# Build the prot dictionary in reverse order, overloading
new_prot = _get_prototype(protparents.get(prototype.lower(), {}),
protparents, _workprot=_workprot)
_workprot.update(new_prot)
# the inprot represents a higher level (a child prot), which should override parents
_workprot.update(inprot)
if uninherited:
# put back the parts that should not be inherited
_workprot.update(uninherited)
_workprot.pop("prototype_parent", None) # we don't need this for spawning
return _workprot
def flatten_prototype(prototype, validate=False):
"""
Produce a 'flattened' prototype, where all prototype parents in the inheritance tree have been
merged into a final prototype.
Args:
prototype (dict): Prototype to flatten. Its `prototype_parent` field will be parsed.
validate (bool, optional): Validate for valid keys etc.
Returns:
flattened (dict): The final, flattened prototype.
"""
if prototype:
prototype = protlib.homogenize_prototype(prototype)
protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()}
protlib.validate_prototype(prototype, None, protparents,
is_prototype_base=validate, strict=validate)
return _get_prototype(prototype, protparents,
uninherited={"prototype_key": prototype.get("prototype_key")})
return {}
# obj-related prototype functions
def prototype_from_object(obj):
"""
Guess a minimal prototype from an existing object.
Args:
obj (Object): An object to analyze.
Returns:
prototype (dict): A prototype estimating the current state of the object.
"""
# first, check if this object already has a prototype
prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True)
if prot:
prot = protlib.search_prototype(prot[0])
if not prot or len(prot) > 1:
# no unambiguous prototype found - build new prototype
prot = {}
prot['prototype_key'] = "From-Object-{}-{}".format(
obj.key, hashlib.md5(str(time.time())).hexdigest()[:7])
prot['prototype_desc'] = "Built from {}".format(str(obj))
prot['prototype_locks'] = "spawn:all();edit:all()"
prot['prototype_tags'] = []
else:
prot = prot[0]
prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6]
prot['typeclass'] = obj.db_typeclass_path
location = obj.db_location
if location:
prot['location'] = location.dbref
home = obj.db_home
if home:
prot['home'] = home.dbref
destination = obj.db_destination
if destination:
prot['destination'] = destination.dbref
locks = obj.locks.all()
if locks:
prot['locks'] = ";".join(locks)
perms = obj.permissions.get(return_list=True)
if perms:
prot['permissions'] = make_iter(perms)
aliases = obj.aliases.get(return_list=True)
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]
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]
if attrs:
prot['attrs'] = attrs
return prot
def prototype_diff(prototype1, prototype2, maxdepth=2):
"""
A 'detailed' diff specifies differences down to individual sub-sectiions
of the prototype, like individual attributes, permissions etc. It is used
by the menu to allow a user to customize what should be kept.
Args:
prototype1 (dict): Original prototype.
prototype2 (dict): Comparison prototype.
maxdepth (int, optional): The maximum depth into the diff we go before treating the elements
of iterables as individual entities to compare. This is important since a single
attr/tag (for example) are represented by a tuple.
Returns:
diff (dict): A structure detailing how to convert prototype1 to prototype2. All
nested structures are dicts with keys matching either the prototype's matching
key or the first element in the tuple describing the prototype value (so for
a tag tuple `(tagname, category)` the second-level key in the diff would be tagname).
The the bottom level of the diff consist of tuples `(old, new, instruction)`, where
instruction can be one of "REMOVE", "ADD", "UPDATE" or "KEEP".
"""
def _recursive_diff(old, new, depth=0):
old_type = type(old)
new_type = type(new)
if old_type != new_type:
if old and not new:
if depth < maxdepth and old_type == dict:
return {key: (part, None, "REMOVE") for key, part in old.items()}
elif depth < maxdepth and is_iter(old):
return {part[0] if is_iter(part) else part:
(part, None, "REMOVE") for part in old}
return (old, new, "REMOVE")
elif not old and new:
if depth < maxdepth and new_type == dict:
return {key: (None, part, "ADD") for key, part in new.items()}
elif depth < maxdepth and is_iter(new):
return {part[0] if is_iter(part) else part: (None, part, "ADD") for part in new}
return (old, new, "ADD")
else:
# this condition should not occur in a standard diff
return (old, new, "UPDATE")
elif depth < maxdepth and new_type == dict:
all_keys = set(old.keys() + new.keys())
return {key: _recursive_diff(old.get(key), new.get(key), depth=depth + 1)
for key in all_keys}
elif depth < maxdepth and is_iter(new):
old_map = {part[0] if is_iter(part) else part: part for part in old}
new_map = {part[0] if is_iter(part) else part: part for part in new}
all_keys = set(old_map.keys() + new_map.keys())
return {key: _recursive_diff(old_map.get(key), new_map.get(key), depth=depth + 1)
for key in all_keys}
elif old != new:
return (old, new, "UPDATE")
else:
return (old, new, "KEEP")
diff = _recursive_diff(prototype1, prototype2)
return diff
def flatten_diff(diff):
"""
For spawning, a 'detailed' diff is not necessary, rather we just want instructions on how to
handle each root key.
Args:
diff (dict): Diff produced by `prototype_diff` and
possibly modified by the user. Note that also a pre-flattened diff will come out
unchanged by this function.
Returns:
flattened_diff (dict): A flat structure detailing how to operate on each
root component of the prototype.
Notes:
The flattened diff has the following possible instructions:
UPDATE, REPLACE, REMOVE
Many of the detailed diff's values can hold nested structures with their own
individual instructions. A detailed diff can have the following instructions:
REMOVE, ADD, UPDATE, KEEP
Here's how they are translated:
- All REMOVE -> REMOVE
- All ADD|UPDATE -> UPDATE
- All KEEP -> KEEP
- Mix KEEP, UPDATE, ADD -> UPDATE
- Mix REMOVE, KEEP, UPDATE, ADD -> REPLACE
"""
valid_instructions = ('KEEP', 'REMOVE', 'ADD', 'UPDATE')
def _get_all_nested_diff_instructions(diffpart):
"Started for each root key, returns all instructions nested under it"
out = []
typ = type(diffpart)
if typ == tuple and len(diffpart) == 3 and diffpart[2] in valid_instructions:
out = [diffpart[2]]
elif typ == dict:
# all other are dicts
for val in diffpart.values():
out.extend(_get_all_nested_diff_instructions(val))
else:
raise RuntimeError("Diff contains non-dicts that are not on the "
"form (old, new, inst): {}".format(diffpart))
return out
flat_diff = {}
# flatten diff based on rules
for rootkey, diffpart in diff.items():
insts = _get_all_nested_diff_instructions(diffpart)
if all(inst == "KEEP" for inst in insts):
rootinst = "KEEP"
elif all(inst in ("ADD", "UPDATE") for inst in insts):
rootinst = "UPDATE"
elif all(inst == "REMOVE" for inst in insts):
rootinst = "REMOVE"
elif "REMOVE" in insts:
rootinst = "REPLACE"
else:
rootinst = "UPDATE"
flat_diff[rootkey] = rootinst
return flat_diff
def prototype_diff_from_object(prototype, obj):
"""
Get a simple diff for a prototype compared to an object which may or may not already have a
prototype (or has one but changed locally). For more complex migratations a manual diff may be
needed.
Args:
prototype (dict): New prototype.
obj (Object): Object to compare prototype against.
Returns:
diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...}
obj_prototype (dict): The prototype calculated for the given object. The diff is how to
convert this prototype into the new prototype.
Notes:
The `diff` is on the following form:
{"key": (old, new, "KEEP|REPLACE|UPDATE|REMOVE"),
"attrs": {"attrkey": (old, new, "KEEP|REPLACE|UPDATE|REMOVE"),
"attrkey": (old, new, "KEEP|REPLACE|UPDATE|REMOVE"), ...},
"aliases": {"aliasname": (old, new, "KEEP...", ...},
... }
"""
obj_prototype = prototype_from_object(obj)
diff = prototype_diff(obj_prototype, protlib.homogenize_prototype(prototype))
return diff, obj_prototype
def batch_update_objects_with_prototype(prototype, diff=None, objects=None):
"""
Update existing objects with the latest version of the prototype.
Args:
prototype (str or dict): Either the `prototype_key` to use or the
prototype dict itself.
diff (dict, optional): This a diff structure that describes how to update the protototype.
If not given this will be constructed from the first object found.
objects (list, optional): List of objects to update. If not given, query for these
objects using the prototype's `prototype_key`.
Returns:
changed (int): The number of objects that had changes applied to them.
"""
prototype = protlib.homogenize_prototype(prototype)
if isinstance(prototype, basestring):
new_prototype = protlib.search_prototype(prototype)
else:
new_prototype = prototype
prototype_key = new_prototype['prototype_key']
if not objects:
objects = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY)
if not objects:
return 0
if not diff:
diff, _ = prototype_diff_from_object(new_prototype, objects[0])
# make sure the diff is flattened
diff = flatten_diff(diff)
changed = 0
for obj in objects:
do_save = False
old_prot_key = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True)
old_prot_key = old_prot_key[0] if old_prot_key else None
if prototype_key != old_prot_key:
obj.tags.clear(category=_PROTOTYPE_TAG_CATEGORY)
obj.tags.add(prototype_key, category=_PROTOTYPE_TAG_CATEGORY)
for key, directive in diff.items():
if directive in ('UPDATE', 'REPLACE'):
if key in _PROTOTYPE_META_NAMES:
# prototype meta keys are not stored on-object
continue
val = new_prototype[key]
do_save = True
if key == 'key':
obj.db_key = init_spawn_value(val, str)
elif key == 'typeclass':
obj.db_typeclass_path = init_spawn_value(val, str)
elif key == 'location':
obj.db_location = init_spawn_value(val, value_to_obj)
elif key == 'home':
obj.db_home = init_spawn_value(val, value_to_obj)
elif key == 'destination':
obj.db_destination = init_spawn_value(val, value_to_obj)
elif key == 'locks':
if directive == 'REPLACE':
obj.locks.clear()
obj.locks.add(init_spawn_value(val, str))
elif key == 'permissions':
if directive == 'REPLACE':
obj.permissions.clear()
obj.permissions.batch_add(*(init_spawn_value(perm, str) for perm in val))
elif key == 'aliases':
if directive == 'REPLACE':
obj.aliases.clear()
obj.aliases.batch_add(*(init_spawn_value(alias, str) for alias in val))
elif key == 'tags':
if directive == 'REPLACE':
obj.tags.clear()
obj.tags.batch_add(*(
(init_spawn_value(ttag, str), tcategory, tdata)
for ttag, tcategory, tdata in val))
elif key == 'attrs':
if directive == 'REPLACE':
obj.attributes.clear()
obj.attributes.batch_add(*(
(init_spawn_value(akey, str),
init_spawn_value(aval, value_to_obj),
acategory,
alocks)
for akey, aval, acategory, alocks in val))
elif key == 'exec':
# we don't auto-rerun exec statements, it would be huge security risk!
pass
else:
obj.attributes.add(key, init_spawn_value(val, value_to_obj))
elif directive == 'REMOVE':
do_save = True
if key == 'key':
obj.db_key = ''
elif key == 'typeclass':
# fall back to default
obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS
elif key == 'location':
obj.db_location = None
elif key == 'home':
obj.db_home = None
elif key == 'destination':
obj.db_destination = None
elif key == 'locks':
obj.locks.clear()
elif key == 'permissions':
obj.permissions.clear()
elif key == 'aliases':
obj.aliases.clear()
elif key == 'tags':
obj.tags.clear()
elif key == 'attrs':
obj.attributes.clear()
elif key == 'exec':
# we don't auto-rerun exec statements, it would be huge security risk!
pass
else:
obj.attributes.remove(key)
if do_save:
changed += 1
obj.save()
return changed
def batch_create_object(*objparams):
"""
This is a cut-down version of the create_object() function,
optimized for speed. It does NOT check and convert various input
so make sure the spawned Typeclass works before using this!
Args:
objsparams (tuple): Each paremter tuple will create one object instance using the parameters
within.
The parameters should be given in the following order:
- `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`.
- `permissions` (str): Permission string used with `new_obj.batch_add(permission)`.
- `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`.
- `aliases` (list): A list of alias strings for
adding with `new_object.aliases.batch_add(*aliases)`.
- `nattributes` (list): list of tuples `(key, value)` to be loop-added to
add with `new_obj.nattributes.add(*tuple)`.
- `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for
adding with `new_obj.attributes.batch_add(*attributes)`.
- `tags` (list): list of tuples `(key, category)` for adding
with `new_obj.tags.batch_add(*tags)`.
- `execs` (list): Code strings to execute together with the creation
of each object. They will be executed with `evennia` and `obj`
(the newly created object) available in the namespace. Execution
will happend after all other properties have been assigned and
is intended for calling custom handlers etc.
Returns:
objects (list): A list of created objects
Notes:
The `exec` list will execute arbitrary python code so don't allow this to be available to
unprivileged users!
"""
# bulk create all objects in one go
# unfortunately this doesn't work since bulk_create doesn't creates pks;
# the result would be duplicate objects at the next stage, so we comment
# it out for now:
# dbobjs = _ObjectDB.objects.bulk_create(dbobjs)
dbobjs = [ObjectDB(**objparam[0]) for objparam in objparams]
objs = []
for iobj, obj in enumerate(dbobjs):
# call all setup hooks on each object
objparam = objparams[iobj]
# setup
obj._createdict = {"permissions": make_iter(objparam[1]),
"locks": objparam[2],
"aliases": make_iter(objparam[3]),
"nattributes": objparam[4],
"attributes": objparam[5],
"tags": make_iter(objparam[6])}
# this triggers all hooks
obj.save()
# run eventual extra code
for code in objparam[7]:
if code:
exec(code, {}, {"evennia": evennia, "obj": obj})
objs.append(obj)
return objs
# Spawner mechanism
def spawn(*prototypes, **kwargs):
"""
Spawn a number of prototyped objects.
Args:
prototypes (dict): Each argument should be a prototype
dictionary.
Kwargs:
prototype_modules (str or list): A python-path to a prototype
module, or a list of such paths. These will be used to build
the global protparents dictionary accessible by the input
prototypes. If not given, it will instead look for modules
defined by settings.PROTOTYPE_MODULES.
prototype_parents (dict): A dictionary holding a custom
prototype-parent dictionary. Will overload same-named
prototypes from prototype_modules.
return_parents (bool): Only return a dict of the
prototype-parents (no object creation happens)
only_validate (bool): Only run validation of prototype/parents
(no object creation) and return the create-kwargs.
Returns:
object (Object, dict or list): Spawned object(s). If `only_validate` is given, return
a list of the creation kwargs to build the object(s) without actually creating it. If
`return_parents` is set, instead return dict of prototype parents.
"""
# get available protparents
protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()}
# 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
for key, protparent in kwargs.get("prototype_parents", {}).items():
key = str(key).lower()
protparent['prototype_key'] = str(protparent.get("prototype_key", key)).lower()
protparents[key] = protparent
if "return_parents" in kwargs:
# only return the parents
return copy.deepcopy(protparents)
objsparams = []
for prototype in prototypes:
protlib.validate_prototype(prototype, None, protparents, is_prototype_base=True)
prot = _get_prototype(prototype, protparents,
uninherited={"prototype_key": prototype.get("prototype_key")})
if not prot:
continue
# extract the keyword args we need to create the object itself. If we get a callable,
# call that to get the value (don't catch errors)
create_kwargs = {}
# we must always add a key, so if not given we use a shortened md5 hash. There is a (small)
# chance this is not unique but it should usually not be a problem.
val = prot.pop("key", "Spawned-{}".format(
hashlib.md5(str(time.time())).hexdigest()[:6]))
create_kwargs["db_key"] = init_spawn_value(val, str)
val = prot.pop("location", None)
create_kwargs["db_location"] = init_spawn_value(val, value_to_obj)
val = prot.pop("home", settings.DEFAULT_HOME)
create_kwargs["db_home"] = init_spawn_value(val, value_to_obj)
val = prot.pop("destination", None)
create_kwargs["db_destination"] = init_spawn_value(val, value_to_obj)
val = prot.pop("typeclass", settings.BASE_OBJECT_TYPECLASS)
create_kwargs["db_typeclass_path"] = init_spawn_value(val, str)
# extract calls to handlers
val = prot.pop("permissions", [])
permission_string = init_spawn_value(val, make_iter)
val = prot.pop("locks", "")
lock_string = init_spawn_value(val, str)
val = prot.pop("aliases", [])
alias_string = init_spawn_value(val, make_iter)
val = prot.pop("tags", [])
tags = []
for (tag, category, data) in tags:
tags.append((init_spawn_value(val, str), category, data))
prototype_key = prototype.get('prototype_key', None)
if prototype_key:
# we make sure to add a tag identifying which prototype created this object
tags.append((prototype_key, _PROTOTYPE_TAG_CATEGORY))
val = prot.pop("exec", "")
execs = init_spawn_value(val, make_iter)
# extract ndb assignments
nattributes = dict((key.split("_", 1)[1], init_spawn_value(val, value_to_obj))
for key, val in prot.items() if key.startswith("ndb_"))
# the rest are attribute tuples (attrname, value, category, locks)
val = make_iter(prot.pop("attrs", []))
attributes = []
for (attrname, value, category, locks) in val:
attributes.append((attrname, init_spawn_value(val), category, locks))
simple_attributes = []
for key, value in ((key, value) for key, value in prot.items()
if not (key.startswith("ndb_"))):
# we don't support categories, nor locks for simple attributes
if key in _PROTOTYPE_META_NAMES:
continue
else:
simple_attributes.append(
(key, init_spawn_value(value, value_to_obj_or_any), None, None))
attributes = attributes + simple_attributes
attributes = [tup for tup in attributes if not tup[0] in _NON_CREATE_KWARGS]
# pack for call into _batch_create_object
objsparams.append((create_kwargs, permission_string, lock_string,
alias_string, nattributes, attributes, tags, execs))
if kwargs.get("only_validate"):
return objsparams
return batch_create_object(*objsparams)

677
evennia/prototypes/tests.py Normal file
View file

@ -0,0 +1,677 @@
"""
Unit tests for the prototypes and spawner
"""
from random import randint
import mock
from anything import Something
from django.test.utils import override_settings
from evennia.utils.test_resources import EvenniaTest
from evennia.utils.tests.test_evmenu import TestEvMenu
from evennia.prototypes import spawner, prototypes as protlib
from evennia.prototypes import menus as olc_menus
from evennia.prototypes.prototypes import _PROTOTYPE_TAG_META_CATEGORY
_PROTPARENTS = {
"NOBODY": {},
"GOBLIN": {
"prototype_key": "GOBLIN",
"typeclass": "evennia.objects.objects.DefaultObject",
"key": "goblin grunt",
"health": lambda: randint(1, 1),
"resists": ["cold", "poison"],
"attacks": ["fists"],
"weaknesses": ["fire", "light"]
},
"GOBLIN_WIZARD": {
"prototype_parent": "GOBLIN",
"key": "goblin wizard",
"spells": ["fire ball", "lighting bolt"]
},
"GOBLIN_ARCHER": {
"prototype_parent": "GOBLIN",
"key": "goblin archer",
"attacks": ["short bow"]
},
"ARCHWIZARD": {
"prototype_parent": "GOBLIN",
"attacks": ["archwizard staff"],
},
"GOBLIN_ARCHWIZARD": {
"key": "goblin archwizard",
"prototype_parent": ("GOBLIN_WIZARD", "ARCHWIZARD")
}
}
class TestSpawner(EvenniaTest):
def setUp(self):
super(TestSpawner, self).setUp()
self.prot1 = {"prototype_key": "testprototype",
"typeclass": "evennia.objects.objects.DefaultObject"}
def test_spawn(self):
obj1 = spawner.spawn(self.prot1)
# check spawned objects have the right tag
self.assertEqual(list(protlib.search_objects_with_prototype("testprototype")), obj1)
self.assertEqual([o.key for o in spawner.spawn(
_PROTPARENTS["GOBLIN"], _PROTPARENTS["GOBLIN_ARCHWIZARD"],
prototype_parents=_PROTPARENTS)], ['goblin grunt', 'goblin archwizard'])
class TestUtils(EvenniaTest):
def test_prototype_from_object(self):
self.maxDiff = None
self.obj1.attributes.add("test", "testval")
self.obj1.tags.add('foo')
new_prot = spawner.prototype_from_object(self.obj1)
self.assertEqual(
{'attrs': [('test', 'testval', None, '')],
'home': Something,
'key': 'Obj',
'location': Something,
'locks': ";".join([
'call:true()',
'control:perm(Developer)',
'delete:perm(Admin)',
'edit:perm(Admin)',
'examine:perm(Builder)',
'get:all()',
'puppet:pperm(Developer)',
'tell:perm(Admin)',
'view:all()']),
'prototype_desc': 'Built from Obj',
'prototype_key': Something,
'prototype_locks': 'spawn:all();edit:all()',
'prototype_tags': [],
'tags': [(u'foo', None, None)],
'typeclass': 'evennia.objects.objects.DefaultObject'}, new_prot)
def test_update_objects_from_prototypes(self):
self.maxDiff = None
self.obj1.attributes.add('oldtest', 'to_keep')
old_prot = spawner.prototype_from_object(self.obj1)
# modify object away from prototype
self.obj1.attributes.add('test', 'testval')
self.obj1.attributes.add('desc', 'changed desc')
self.obj1.aliases.add('foo')
self.obj1.tags.add('footag', 'foocategory')
# modify prototype
old_prot['new'] = 'new_val'
old_prot['test'] = 'testval_changed'
old_prot['permissions'] = ['Builder']
# this will not update, since we don't update the prototype on-disk
old_prot['prototype_desc'] = 'New version of prototype'
old_prot['attrs'] += (("fooattr", "fooattrval", None, ''),)
# diff obj/prototype
old_prot_copy = old_prot.copy()
pdiff, obj_prototype = spawner.prototype_diff_from_object(old_prot, self.obj1)
self.assertEqual(old_prot_copy, old_prot)
self.assertEqual(obj_prototype,
{'aliases': ['foo'],
'attrs': [('oldtest', 'to_keep', None, ''),
('test', 'testval', None, ''),
('desc', 'changed desc', None, '')],
'key': 'Obj',
'home': '#1',
'location': '#1',
'locks': 'call:true();control:perm(Developer);delete:perm(Admin);'
'edit:perm(Admin);examine:perm(Builder);get:all();'
'puppet:pperm(Developer);tell:perm(Admin);view:all()',
'prototype_desc': 'Built from Obj',
'prototype_key': Something,
'prototype_locks': 'spawn:all();edit:all()',
'prototype_tags': [],
'typeclass': 'evennia.objects.objects.DefaultObject'})
self.assertEqual(old_prot,
{'attrs': [('oldtest', 'to_keep', None, ''),
('fooattr', 'fooattrval', None, '')],
'home': '#1',
'key': 'Obj',
'location': '#1',
'locks': 'call:true();control:perm(Developer);delete:perm(Admin);'
'edit:perm(Admin);examine:perm(Builder);get:all();'
'puppet:pperm(Developer);tell:perm(Admin);view:all()',
'new': 'new_val',
'permissions': ['Builder'],
'prototype_desc': 'New version of prototype',
'prototype_key': Something,
'prototype_locks': 'spawn:all();edit:all()',
'prototype_tags': [],
'test': 'testval_changed',
'typeclass': 'evennia.objects.objects.DefaultObject'})
self.assertEqual(
pdiff,
{'home': ('#1', '#1', 'KEEP'),
'prototype_locks': ('spawn:all();edit:all()',
'spawn:all();edit:all()', 'KEEP'),
'prototype_key': (Something, Something, 'UPDATE'),
'location': ('#1', '#1', 'KEEP'),
'locks': ('call:true();control:perm(Developer);delete:perm(Admin);'
'edit:perm(Admin);examine:perm(Builder);get:all();'
'puppet:pperm(Developer);tell:perm(Admin);view:all()',
'call:true();control:perm(Developer);delete:perm(Admin);'
'edit:perm(Admin);examine:perm(Builder);get:all();'
'puppet:pperm(Developer);tell:perm(Admin);view:all()', 'KEEP'),
'prototype_tags': {},
'attrs': {'oldtest': (('oldtest', 'to_keep', None, ''),
('oldtest', 'to_keep', None, ''), 'KEEP'),
'test': (('test', 'testval', None, ''),
None, 'REMOVE'),
'desc': (('desc', 'changed desc', None, ''),
None, 'REMOVE'),
'fooattr': (None, ('fooattr', 'fooattrval', None, ''), 'ADD'),
'test': (('test', 'testval', None, ''),
('test', 'testval_changed', None, ''), 'UPDATE'),
'new': (None, ('new', 'new_val', None, ''), 'ADD')},
'key': ('Obj', 'Obj', 'KEEP'),
'typeclass': ('evennia.objects.objects.DefaultObject',
'evennia.objects.objects.DefaultObject', 'KEEP'),
'aliases': {'foo': ('foo', None, 'REMOVE')},
'prototype_desc': ('Built from Obj',
'New version of prototype', 'UPDATE'),
'permissions': {"Builder": (None, 'Builder', 'ADD')}
})
self.assertEqual(
spawner.flatten_diff(pdiff),
{'aliases': 'REMOVE',
'attrs': 'REPLACE',
'home': 'KEEP',
'key': 'KEEP',
'location': 'KEEP',
'locks': 'KEEP',
'permissions': 'UPDATE',
'prototype_desc': 'UPDATE',
'prototype_key': 'UPDATE',
'prototype_locks': 'KEEP',
'prototype_tags': 'KEEP',
'typeclass': 'KEEP'}
)
# apply diff
count = spawner.batch_update_objects_with_prototype(
old_prot, diff=pdiff, objects=[self.obj1])
self.assertEqual(count, 1)
new_prot = spawner.prototype_from_object(self.obj1)
self.assertEqual({'attrs': [('oldtest', 'to_keep', None, ''),
('fooattr', 'fooattrval', None, ''),
('new', 'new_val', None, ''),
('test', 'testval_changed', None, '')],
'home': Something,
'key': 'Obj',
'location': Something,
'locks': ";".join([
'call:true()',
'control:perm(Developer)',
'delete:perm(Admin)',
'edit:perm(Admin)',
'examine:perm(Builder)',
'get:all()',
'puppet:pperm(Developer)',
'tell:perm(Admin)',
'view:all()']),
'permissions': ['builder'],
'prototype_desc': 'Built from Obj',
'prototype_key': Something,
'prototype_locks': 'spawn:all();edit:all()',
'prototype_tags': [],
'typeclass': 'evennia.objects.objects.DefaultObject'},
new_prot)
class TestProtLib(EvenniaTest):
def setUp(self):
super(TestProtLib, self).setUp()
self.obj1.attributes.add("testattr", "testval")
self.prot = spawner.prototype_from_object(self.obj1)
def test_prototype_to_str(self):
prstr = protlib.prototype_to_str(self.prot)
self.assertTrue(prstr.startswith("|cprototype-key:|n"))
def test_check_permission(self):
pass
@override_settings(PROT_FUNC_MODULES=['evennia.prototypes.protfuncs'], CLIENT_DEFAULT_WIDTH=20)
class TestProtFuncs(EvenniaTest):
def setUp(self):
super(TestProtFuncs, self).setUp()
self.prot = {"prototype_key": "test_prototype",
"prototype_desc": "testing prot",
"key": "ExampleObj"}
@mock.patch("evennia.prototypes.protfuncs.base_random", new=mock.MagicMock(return_value=0.5))
@mock.patch("evennia.prototypes.protfuncs.base_randint", new=mock.MagicMock(return_value=5))
def test_protfuncs(self):
self.assertEqual(protlib.protfunc_parser("$random()"), 0.5)
self.assertEqual(protlib.protfunc_parser("$randint(1, 10)"), 5)
self.assertEqual(protlib.protfunc_parser("$left_justify( foo )"), "foo ")
self.assertEqual(protlib.protfunc_parser("$right_justify( foo )"), " foo")
self.assertEqual(protlib.protfunc_parser("$center_justify(foo )"), " foo ")
self.assertEqual(protlib.protfunc_parser(
"$full_justify(foo bar moo too)"), 'foo bar moo too')
self.assertEqual(
protlib.protfunc_parser("$right_justify( foo )", testing=True),
('unexpected indent (<unknown>, line 1)', ' foo'))
test_prot = {"key1": "value1",
"key2": 2}
self.assertEqual(protlib.protfunc_parser(
"$protkey(key1)", testing=True, prototype=test_prot), (None, "value1"))
self.assertEqual(protlib.protfunc_parser(
"$protkey(key2)", testing=True, prototype=test_prot), (None, 2))
self.assertEqual(protlib.protfunc_parser("$add(1, 2)"), 3)
self.assertEqual(protlib.protfunc_parser("$add(10, 25)"), 35)
self.assertEqual(protlib.protfunc_parser(
"$add('''[1,2,3]''', '''[4,5,6]''')"), [1, 2, 3, 4, 5, 6])
self.assertEqual(protlib.protfunc_parser("$add(foo, bar)"), "foo bar")
self.assertEqual(protlib.protfunc_parser("$sub(5, 2)"), 3)
self.assertRaises(TypeError, protlib.protfunc_parser, "$sub(5, test)")
self.assertEqual(protlib.protfunc_parser("$mult(5, 2)"), 10)
self.assertEqual(protlib.protfunc_parser("$mult( 5 , 10)"), 50)
self.assertEqual(protlib.protfunc_parser("$mult('foo',3)"), "foofoofoo")
self.assertEqual(protlib.protfunc_parser("$mult(foo,3)"), "foofoofoo")
self.assertRaises(TypeError, protlib.protfunc_parser, "$mult(foo, foo)")
self.assertEqual(protlib.protfunc_parser("$toint(5.3)"), 5)
self.assertEqual(protlib.protfunc_parser("$div(5, 2)"), 2.5)
self.assertEqual(protlib.protfunc_parser("$toint($div(5, 2))"), 2)
self.assertEqual(protlib.protfunc_parser("$sub($add(5, 3), $add(10, 2))"), -4)
self.assertEqual(protlib.protfunc_parser("$eval('2')"), '2')
self.assertEqual(protlib.protfunc_parser(
"$eval(['test', 1, '2', 3.5, \"foo\"])"), ['test', 1, '2', 3.5, 'foo'])
self.assertEqual(protlib.protfunc_parser(
"$eval({'test': '1', 2:3, 3: $toint(3.5)})"), {'test': '1', 2: 3, 3: 3})
self.assertEqual(protlib.protfunc_parser("$obj(#1)", session=self.session), '#1')
self.assertEqual(protlib.protfunc_parser("#1", session=self.session), '#1')
self.assertEqual(protlib.protfunc_parser("$obj(Char)", session=self.session), '#6')
self.assertEqual(protlib.protfunc_parser("$obj(Char)", session=self.session), '#6')
self.assertEqual(protlib.protfunc_parser("$objlist(#1)", session=self.session), ['#1'])
self.assertEqual(protlib.value_to_obj(
protlib.protfunc_parser("#6", session=self.session)), self.char1)
self.assertEqual(protlib.value_to_obj_or_any(
protlib.protfunc_parser("#6", session=self.session)), self.char1)
self.assertEqual(protlib.value_to_obj_or_any(
protlib.protfunc_parser("[1,2,3,'#6',5]", session=self.session)),
[1, 2, 3, self.char1, 5])
class TestPrototypeStorage(EvenniaTest):
def setUp(self):
super(TestPrototypeStorage, self).setUp()
self.maxDiff = None
self.prot1 = spawner.prototype_from_object(self.obj1)
self.prot1['prototype_key'] = 'testprototype1'
self.prot1['prototype_desc'] = 'testdesc1'
self.prot1['prototype_tags'] = [('foo1', _PROTOTYPE_TAG_META_CATEGORY)]
self.prot2 = self.prot1.copy()
self.prot2['prototype_key'] = 'testprototype2'
self.prot2['prototype_desc'] = 'testdesc2'
self.prot2['prototype_tags'] = [('foo1', _PROTOTYPE_TAG_META_CATEGORY)]
self.prot3 = self.prot2.copy()
self.prot3['prototype_key'] = 'testprototype3'
self.prot3['prototype_desc'] = 'testdesc3'
self.prot3['prototype_tags'] = [('foo1', _PROTOTYPE_TAG_META_CATEGORY)]
def test_prototype_storage(self):
# from evennia import set_trace;set_trace(term_size=(180, 50))
prot1 = protlib.create_prototype(**self.prot1)
self.assertTrue(bool(prot1))
self.assertEqual(prot1, self.prot1)
self.assertEqual(prot1['prototype_desc'], "testdesc1")
self.assertEqual(prot1['prototype_tags'], [("foo1", _PROTOTYPE_TAG_META_CATEGORY)])
self.assertEqual(
protlib.DbPrototype.objects.get_by_tag(
"foo1", _PROTOTYPE_TAG_META_CATEGORY)[0].db.prototype, prot1)
prot2 = protlib.create_prototype(**self.prot2)
self.assertEqual(
[pobj.db.prototype
for pobj in protlib.DbPrototype.objects.get_by_tag(
"foo1", _PROTOTYPE_TAG_META_CATEGORY)],
[prot1, prot2])
# add to existing prototype
prot1b = protlib.create_prototype(
prototype_key='testprototype1', foo='bar', prototype_tags=['foo2'])
self.assertEqual(
[pobj.db.prototype
for pobj in protlib.DbPrototype.objects.get_by_tag(
"foo2", _PROTOTYPE_TAG_META_CATEGORY)],
[prot1b])
self.assertEqual(list(protlib.search_prototype("testprototype2")), [prot2])
self.assertNotEqual(list(protlib.search_prototype("testprototype1")), [prot1])
self.assertEqual(list(protlib.search_prototype("testprototype1")), [prot1b])
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])
self.assertTrue(str(unicode(protlib.list_prototypes(self.char1))))
class _MockMenu(object):
pass
class TestMenuModule(EvenniaTest):
def setUp(self):
super(TestMenuModule, self).setUp()
# set up fake store
self.caller = self.char1
menutree = _MockMenu()
self.caller.ndb._menutree = menutree
self.test_prot = {"prototype_key": "test_prot",
"typeclass": "evennia.objects.objects.DefaultObject",
"prototype_locks": "edit:all();spawn:all()"}
def test_helpers(self):
caller = self.caller
# general helpers
self.assertEqual(olc_menus._get_menu_prototype(caller), {})
self.assertEqual(olc_menus._is_new_prototype(caller), True)
self.assertEqual(olc_menus._set_menu_prototype(caller, {}), {})
self.assertEqual(
olc_menus._set_prototype_value(caller, "key", "TestKey"), {"key": "TestKey"})
self.assertEqual(olc_menus._get_menu_prototype(caller), {"key": "TestKey"})
self.assertEqual(olc_menus._format_option_value(
"key", required=True, prototype=olc_menus._get_menu_prototype(caller)), " (TestKey|n)")
self.assertEqual(olc_menus._format_option_value(
[1, 2, 3, "foo"], required=True), ' (1, 2, 3, foo|n)')
self.assertEqual(olc_menus._set_property(
caller, "ChangedKey", prop="key", processor=str, next_node="foo"), "foo")
self.assertEqual(olc_menus._get_menu_prototype(caller), {"key": "ChangedKey"})
self.assertEqual(olc_menus._wizard_options(
"ThisNode", "PrevNode", "NextNode"),
[{'goto': 'node_PrevNode', 'key': ('|wB|Wack', 'b'), 'desc': '|W(PrevNode)|n'},
{'goto': 'node_NextNode', 'key': ('|wF|Worward', 'f'), 'desc': '|W(NextNode)|n'},
{'goto': 'node_index', 'key': ('|wI|Wndex', 'i')},
{'goto': ('node_validate_prototype', {'back': 'ThisNode'}),
'key': ('|wV|Walidate prototype', 'validate', 'v')}])
self.assertEqual(olc_menus._validate_prototype(self.test_prot), (False, Something))
self.assertEqual(olc_menus._validate_prototype(
{"prototype_key": "testthing", "key": "mytest"}),
(True, Something))
choices = ["test1", "test2", "test3", "test4"]
actions = (("examine", "e", "l"), ("add", "a"), ("foo", "f"))
self.assertEqual(olc_menus._default_parse("l4", choices, *actions), ('test4', 'examine'))
self.assertEqual(olc_menus._default_parse("add 2", choices, *actions), ('test2', 'add'))
self.assertEqual(olc_menus._default_parse("foo3", choices, *actions), ('test3', 'foo'))
self.assertEqual(olc_menus._default_parse("f3", choices, *actions), ('test3', 'foo'))
self.assertEqual(olc_menus._default_parse("f5", choices, *actions), (None, None))
def test_node_helpers(self):
caller = self.caller
with mock.patch("evennia.prototypes.menus.protlib.search_prototype",
new=mock.MagicMock(return_value=[self.test_prot])):
# prototype_key helpers
self.assertEqual(olc_menus._check_prototype_key(caller, "test_prot"), None)
caller.ndb._menutree.olc_new = True
self.assertEqual(olc_menus._check_prototype_key(caller, "test_prot"), "node_index")
# prototype_parent helpers
self.assertEqual(olc_menus._all_prototype_parents(caller), ['test_prot'])
# self.assertEqual(olc_menus._prototype_parent_parse(
# caller, 'test_prot'),
# "|cprototype key:|n test_prot, |ctags:|n None, |clocks:|n edit:all();spawn:all() "
# "\n|cdesc:|n None \n|cprototype:|n "
# "{\n 'typeclass': 'evennia.objects.objects.DefaultObject', \n}")
with mock.patch("evennia.prototypes.menus.protlib.search_prototype",
new=mock.MagicMock(return_value=[_PROTPARENTS['GOBLIN']])):
self.assertEqual(olc_menus._prototype_parent_select(caller, "goblin"), "node_prototype_parent")
self.assertEqual(olc_menus._get_menu_prototype(caller),
{'prototype_key': 'test_prot',
'prototype_locks': 'edit:all();spawn:all()',
'prototype_parent': 'goblin',
'typeclass': 'evennia.objects.objects.DefaultObject'})
# typeclass helpers
with mock.patch("evennia.utils.utils.get_all_typeclasses",
new=mock.MagicMock(return_value={"foo": None, "bar": None})):
self.assertEqual(olc_menus._all_typeclasses(caller), ["bar", "foo"])
self.assertEqual(olc_menus._typeclass_select(
caller, "evennia.objects.objects.DefaultObject"), None)
# prototype_parent should be popped off here
self.assertEqual(olc_menus._get_menu_prototype(caller),
{'prototype_key': 'test_prot',
'prototype_locks': 'edit:all();spawn:all()',
'prototype_parent': 'goblin',
'typeclass': 'evennia.objects.objects.DefaultObject'})
# attr helpers
self.assertEqual(olc_menus._caller_attrs(caller), [])
self.assertEqual(olc_menus._add_attr(caller, "test1=foo1"), Something)
self.assertEqual(olc_menus._add_attr(caller, "test2;cat1=foo2"), Something)
self.assertEqual(olc_menus._add_attr(caller, "test3;cat2;edit:false()=foo3"), Something)
self.assertEqual(olc_menus._add_attr(caller, "test4;cat3;set:true();edit:false()=foo4"), Something)
self.assertEqual(olc_menus._add_attr(caller, "test5;cat4;set:true();edit:false()=123"), Something)
self.assertEqual(olc_menus._add_attr(caller, "test1=foo1_changed"), Something)
self.assertEqual(olc_menus._get_menu_prototype(caller)['attrs'],
[("test1", "foo1_changed", None, ''),
("test2", "foo2", "cat1", ''),
("test3", "foo3", "cat2", "edit:false()"),
("test4", "foo4", "cat3", "set:true();edit:false()"),
("test5", '123', "cat4", "set:true();edit:false()")])
# tag helpers
self.assertEqual(olc_menus._caller_tags(caller), [])
self.assertEqual(olc_menus._add_tag(caller, "foo1"), Something)
self.assertEqual(olc_menus._add_tag(caller, "foo2;cat1"), Something)
self.assertEqual(olc_menus._add_tag(caller, "foo3;cat2;dat1"), Something)
self.assertEqual(olc_menus._caller_tags(caller), ['foo1', 'foo2', 'foo3'])
self.assertEqual(olc_menus._get_menu_prototype(caller)['tags'],
[('foo1', None, ""),
('foo2', 'cat1', ""),
('foo3', 'cat2', "dat1")])
self.assertEqual(olc_menus._add_tag(caller, "foo1", delete=True), "Removed Tag 'foo1'.")
self.assertEqual(olc_menus._get_menu_prototype(caller)['tags'],
[('foo2', 'cat1', ""),
('foo3', 'cat2', "dat1")])
self.assertEqual(olc_menus._display_tag(olc_menus._get_menu_prototype(caller)['tags'][0]), Something)
self.assertEqual(olc_menus._caller_tags(caller), ["foo2", "foo3"])
protlib.save_prototype(**self.test_prot)
# locks helpers
self.assertEqual(olc_menus._lock_add(caller, "foo:false()"), "Added lock 'foo:false()'.")
self.assertEqual(olc_menus._lock_add(caller, "foo2:false()"), "Added lock 'foo2:false()'.")
self.assertEqual(olc_menus._lock_add(caller, "foo2:true()"), "Lock with locktype 'foo2' updated.")
self.assertEqual(olc_menus._get_menu_prototype(caller)["locks"], "foo:false();foo2:true()")
# perm helpers
self.assertEqual(olc_menus._add_perm(caller, "foo"), "Added Permission 'foo'")
self.assertEqual(olc_menus._add_perm(caller, "foo2"), "Added Permission 'foo2'")
self.assertEqual(olc_menus._get_menu_prototype(caller)["permissions"], ["foo", "foo2"])
# prototype_tags helpers
self.assertEqual(olc_menus._add_prototype_tag(caller, "foo"), "Added Prototype-Tag 'foo'.")
self.assertEqual(olc_menus._add_prototype_tag(caller, "foo2"), "Added Prototype-Tag 'foo2'.")
self.assertEqual(olc_menus._get_menu_prototype(caller)["prototype_tags"], ["foo", "foo2"])
# spawn helpers
with mock.patch("evennia.prototypes.menus.protlib.search_prototype",
new=mock.MagicMock(return_value=[_PROTPARENTS['GOBLIN']])):
self.assertEqual(olc_menus._spawn(caller, prototype=self.test_prot), Something)
obj = caller.contents[0]
self.assertEqual(obj.typeclass_path, "evennia.objects.objects.DefaultObject")
self.assertEqual(obj.tags.get(category=spawner._PROTOTYPE_TAG_CATEGORY), self.test_prot['prototype_key'])
# update helpers
self.assertEqual(olc_menus._apply_diff(
caller, prototype=self.test_prot, back_node="foo", objects=[obj]), 'foo') # no changes to apply
self.test_prot['key'] = "updated key" # change prototype
self.assertEqual(olc_menus._apply_diff(
caller, prototype=self.test_prot, objects=[obj], back_node='foo'), 'foo') # apply change to the one obj
# load helpers
self.assertEqual(olc_menus._prototype_load_select(caller, self.test_prot['prototype_key']),
('node_examine_entity', {'text': '|gLoaded prototype test_prot.|n', 'back': 'index'}) )
# diff helpers
obj_diff = {
'attrs': {
u'desc': ((u'desc', u'This is User #1.', None, ''),
(u'desc', u'This is User #1.', None, ''),
'KEEP'),
u'foo': (None,
(u'foo', u'bar', None, ''),
'ADD'),
u'prelogout_location': ((u'prelogout_location', "#2", None, ''),
(u'prelogout_location', "#2", None, ''),
'KEEP')},
'home': ('#2', '#2', 'KEEP'),
'key': (u'TestChar', u'TestChar', 'KEEP'),
'locks': ('boot:false();call:false();control:perm(Developer);delete:false();'
'edit:false();examine:perm(Developer);get:false();msg:all();'
'puppet:false();tell:perm(Admin);view:all()',
'boot:false();call:false();control:perm(Developer);delete:false();'
'edit:false();examine:perm(Developer);get:false();msg:all();'
'puppet:false();tell:perm(Admin);view:all()',
'KEEP'),
'permissions': {'developer': ('developer', 'developer', 'KEEP')},
'prototype_desc': ('Testobject build', None, 'REMOVE'),
'prototype_key': ('TestDiffKey', 'TestDiffKey', 'KEEP'),
'prototype_locks': ('spawn:all();edit:all()', 'spawn:all();edit:all()', 'KEEP'),
'prototype_tags': {},
'tags': {'foo': (None, ('foo', None, ''), 'ADD')},
'typeclass': (u'typeclasses.characters.Character',
u'typeclasses.characters.Character', 'KEEP')}
texts, options = olc_menus._format_diff_text_and_options(obj_diff)
self.assertEqual(
"\n".join(texts),
'- |wattrs:|n \n'
' |c[1] |yADD|n|W:|n None |W->|n foo |W=|n bar |W(category:|n None|W, locks:|n |W)|n\n'
' |gKEEP|W:|n prelogout_location |W=|n #2 |W(category:|n None|W, locks:|n |W)|n\n'
' |gKEEP|W:|n desc |W=|n This is User #1. |W(category:|n None|W, locks:|n |W)|n\n'
'- |whome:|n |gKEEP|W:|n #2\n'
'- |wkey:|n |gKEEP|W:|n TestChar\n'
'- |wlocks:|n |gKEEP|W:|n boot:false();call:false();control:perm(Developer);delete:false();edit:false();examine:perm(Developer);get:false();msg:all();puppet:false();tell:perm(Admin);view:all()\n'
'- |wpermissions:|n \n'
' |gKEEP|W:|n developer\n'
'- |wprototype_desc:|n |c[2] |rREMOVE|n|W:|n Testobject build |W->|n None\n'
'- |wprototype_key:|n |gKEEP|W:|n TestDiffKey\n'
'- |wprototype_locks:|n |gKEEP|W:|n spawn:all();edit:all()\n'
'- |wprototype_tags:|n \n'
'- |wtags:|n \n'
' |c[3] |yADD|n|W:|n None |W->|n foo |W(category:|n None|W)|n\n'
'- |wtypeclass:|n |gKEEP|W:|n typeclasses.characters.Character')
self.assertEqual(
options,
[{'goto': (Something, Something),
'key': '1',
'desc': '|gKEEP|n (attrs) None'},
{'goto': (Something, Something),
'key': '2',
'desc': '|gKEEP|n (prototype_desc) Testobject build'},
{'goto': (Something, Something),
'key': '3',
'desc': '|gKEEP|n (tags) None'}])
@mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock(
return_value=[{"prototype_key": "TestPrototype",
"typeclass": "TypeClassTest", "key": "TestObj"}]))
@mock.patch("evennia.utils.utils.get_all_typeclasses", new=mock.MagicMock(
return_value={"TypeclassTest": None}))
class TestOLCMenu(TestEvMenu):
maxDiff = None
menutree = "evennia.prototypes.menus"
startnode = "node_index"
# debug_output = True
expect_all_nodes = True
expected_node_texts = {
"node_index": "|c --- Prototype wizard --- |n"
}
expected_tree = ['node_index', ['node_prototype_key', ['node_index', 'node_index', 'node_index',
'node_validate_prototype', ['node_index', 'node_index', 'node_index'], 'node_index'],
'node_prototype_parent', ['node_prototype_parent', 'node_prototype_key',
'node_prototype_parent', 'node_index', 'node_validate_prototype', 'node_index'],
'node_typeclass', ['node_typeclass', 'node_prototype_parent', 'node_typeclass',
'node_index', 'node_validate_prototype', 'node_index'], 'node_key', ['node_typeclass',
'node_key', 'node_index', 'node_validate_prototype', 'node_index'], 'node_aliases',
['node_key', 'node_aliases', 'node_index', 'node_validate_prototype', 'node_index'],
'node_attrs', ['node_aliases', 'node_attrs', 'node_index', 'node_validate_prototype',
'node_index'], 'node_tags', ['node_attrs', 'node_tags', 'node_index',
'node_validate_prototype', 'node_index'], 'node_locks', ['node_tags',
'node_locks', 'node_index', 'node_validate_prototype', 'node_index'],
'node_permissions', ['node_locks', 'node_permissions', 'node_index',
'node_validate_prototype', 'node_index'], 'node_location',
['node_permissions', 'node_location', 'node_index', 'node_validate_prototype',
'node_index', 'node_index'], 'node_home', ['node_location', 'node_home',
'node_index', 'node_validate_prototype', 'node_index', 'node_index'],
'node_destination', ['node_home', 'node_destination', 'node_index',
'node_validate_prototype', 'node_index', 'node_index'],
'node_prototype_desc', ['node_prototype_key', 'node_prototype_parent',
'node_index', 'node_validate_prototype', 'node_index'],
'node_prototype_tags', ['node_prototype_desc', 'node_prototype_tags',
'node_index', 'node_validate_prototype', 'node_index'],
'node_prototype_locks', ['node_prototype_tags', 'node_prototype_locks',
'node_index', 'node_validate_prototype', 'node_index'],
'node_validate_prototype', 'node_index', 'node_prototype_spawn',
['node_index', 'node_index', 'node_validate_prototype'], 'node_index',
'node_search_object', ['node_index', 'node_index', 'node_index']]]

View file

@ -29,8 +29,8 @@ class Migration(migrations.Migration):
('db_persistent', models.BooleanField(default=False, verbose_name=b'survive server reboot')),
('db_is_active', models.BooleanField(default=False, verbose_name=b'script active')),
('db_attributes', models.ManyToManyField(help_text=b'attributes on this object. An attribute can hold any pickle-able python object (see docs for special cases).', to='typeclasses.Attribute', null=True)),
('db_obj', models.ForeignKey(blank=True, to='objects.ObjectDB', help_text=b'the object to store this script on, if not a global script.', null=True, verbose_name=b'scripted object')),
('db_account', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, help_text=b'the account to store this script on (should not be set if obj is set)', null=True, verbose_name=b'scripted account')),
('db_obj', models.ForeignKey(blank=True, to='objects.ObjectDB', on_delete=models.CASCADE, help_text=b'the object to store this script on, if not a global script.', null=True, verbose_name=b'scripted object')),
('db_account', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE, help_text=b'the account to store this script on (should not be set if obj is set)', null=True, verbose_name=b'scripted account')),
('db_tags', models.ManyToManyField(help_text=b'tags on this object. Tags are simple string markers to identify, group and alias objects.', to='typeclasses.Tag', null=True)),
],
options={

View file

@ -83,9 +83,11 @@ class ScriptDB(TypedObject):
# optional description.
db_desc = models.CharField('desc', max_length=255, blank=True)
# A reference to the database object affected by this Script, if any.
db_obj = models.ForeignKey("objects.ObjectDB", null=True, blank=True, verbose_name='scripted object',
db_obj = models.ForeignKey("objects.ObjectDB", null=True, blank=True, on_delete=models.CASCADE,
verbose_name='scripted object',
help_text='the object to store this script on, if not a global script.')
db_account = models.ForeignKey("accounts.AccountDB", null=True, blank=True, verbose_name="scripted account",
db_account = models.ForeignKey("accounts.AccountDB", null=True, blank=True,
on_delete=models.CASCADE, verbose_name="scripted account",
help_text='the account to store this script on (should not be set if db_obj is set)')
# how often to run Script (secs). -1 means there is no timer

View file

@ -18,6 +18,17 @@ from future.utils import with_metaclass
__all__ = ["DefaultScript", "DoNothing", "Store"]
FLUSHING_INSTANCES = False # whether we're in the process of flushing scripts from the cache
SCRIPT_FLUSH_TIMERS = {} # stores timers for scripts that are currently being flushed
def restart_scripts_after_flush():
"""After instances are flushed, validate scripts so they're not dead for a long period of time"""
global FLUSHING_INSTANCES
ScriptDB.objects.validate()
FLUSHING_INSTANCES = False
class ExtendedLoopingCall(LoopingCall):
"""
LoopingCall that can start at a delay different
@ -141,15 +152,6 @@ class ScriptBase(with_metaclass(TypeclassBase, ScriptDB)):
"""
objects = ScriptManager()
class DefaultScript(ScriptBase):
"""
This is the base TypeClass for all Scripts. Scripts describe
events, timers and states in game, they can have a time component
or describe a state that changes under certain conditions.
"""
def __eq__(self, other):
"""
Compares two Scripts. Compares dbids.
@ -239,7 +241,96 @@ class DefaultScript(ScriptBase):
logger.log_trace()
return None
# Public methods
def at_script_creation(self):
"""
Should be overridden in child.
"""
pass
def at_first_save(self, **kwargs):
"""
This is called after very first time this object is saved.
Generally, you don't need to overload this, but only the hooks
called by this method.
Args:
**kwargs (dict): Arbitrary, optional arguments for users
overriding the call (unused by default).
"""
self.at_script_creation()
if hasattr(self, "_createdict"):
# this will only be set if the utils.create_script
# function was used to create the object. We want
# the create call's kwargs to override the values
# set by hooks.
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.db_key != cdict["key"]:
self.db_key = cdict["key"]
updates.append("db_key")
if cdict.get("interval") and self.interval != cdict["interval"]:
self.db_interval = cdict["interval"]
updates.append("db_interval")
if cdict.get("start_delay") and self.start_delay != cdict["start_delay"]:
self.db_start_delay = cdict["start_delay"]
updates.append("db_start_delay")
if cdict.get("repeats") and self.repeats != cdict["repeats"]:
self.db_repeats = cdict["repeats"]
updates.append("db_repeats")
if cdict.get("persistent") and self.persistent != cdict["persistent"]:
self.db_persistent = cdict["persistent"]
updates.append("db_persistent")
if cdict.get("desc") and self.desc != cdict["desc"]:
self.db_desc = cdict["desc"]
updates.append("db_desc")
if updates:
self.save(update_fields=updates)
if cdict.get("permissions"):
self.permissions.batch_add(*cdict["permissions"])
if cdict.get("locks"):
self.locks.add(cdict["locks"])
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)
if not cdict.get("autostart"):
# don't auto-start the script
return
# auto-start script (default)
self.start()
class DefaultScript(ScriptBase):
"""
This is the base TypeClass for all Scripts. Scripts describe
events, timers and states in game, they can have a time component
or describe a state that changes under certain conditions.
"""
def at_script_creation(self):
"""
Only called once, when script is first created.
"""
pass
def time_until_next_repeat(self):
"""
@ -278,6 +369,27 @@ class DefaultScript(ScriptBase):
return max(0, self.db_repeats - task.callcount)
return None
def at_idmapper_flush(self):
"""If we're flushing this object, make sure the LoopingCall is gone too"""
ret = super(DefaultScript, self).at_idmapper_flush()
if ret and self.ndb._task:
try:
from twisted.internet import reactor
global FLUSHING_INSTANCES
# store the current timers for the _task and stop it to avoid duplicates after cache flush
paused_time = self.ndb._task.next_call_time()
callcount = self.ndb._task.callcount
self._stop_task()
SCRIPT_FLUSH_TIMERS[self.id] = (paused_time, callcount)
# here we ensure that the restart call only happens once, not once per script
if not FLUSHING_INSTANCES:
FLUSHING_INSTANCES = True
reactor.callLater(2, restart_scripts_after_flush)
except Exception:
import traceback
traceback.print_exc()
return ret
def start(self, force_restart=False):
"""
Called every time the script is started (for persistent
@ -294,9 +406,19 @@ class DefaultScript(ScriptBase):
started or not. Used in counting.
"""
if self.is_active and not force_restart:
# script already runs and should not be restarted.
# The script is already running, but make sure we have a _task if this is after a cache flush
if not self.ndb._task and self.db_interval >= 0:
self.ndb._task = ExtendedLoopingCall(self._step_task)
try:
start_delay, callcount = SCRIPT_FLUSH_TIMERS[self.id]
del SCRIPT_FLUSH_TIMERS[self.id]
now = False
except (KeyError, ValueError, TypeError):
now = not self.db_start_delay
start_delay = None
callcount = 0
self.ndb._task.start(self.db_interval, now=now, start_delay=start_delay, count_start=callcount)
return 0
obj = self.obj
@ -472,61 +594,6 @@ class DefaultScript(ScriptBase):
if task:
task.force_repeat()
def at_first_save(self, **kwargs):
"""
This is called after very first time this object is saved.
Generally, you don't need to overload this, but only the hooks
called by this method.
Args:
**kwargs (dict): Arbitrary, optional arguments for users
overriding the call (unused by default).
"""
self.at_script_creation()
if hasattr(self, "_createdict"):
# this will only be set if the utils.create_script
# function was used to create the object. We want
# the create call's kwargs to override the values
# set by hooks.
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.db_key != cdict["key"]:
self.db_key = cdict["key"]
updates.append("db_key")
if cdict.get("interval") and self.interval != cdict["interval"]:
self.db_interval = cdict["interval"]
updates.append("db_interval")
if cdict.get("start_delay") and self.start_delay != cdict["start_delay"]:
self.db_start_delay = cdict["start_delay"]
updates.append("db_start_delay")
if cdict.get("repeats") and self.repeats != cdict["repeats"]:
self.db_repeats = cdict["repeats"]
updates.append("db_repeats")
if cdict.get("persistent") and self.persistent != cdict["persistent"]:
self.db_persistent = cdict["persistent"]
updates.append("db_persistent")
if updates:
self.save(update_fields=updates)
if not cdict.get("autostart"):
# don't auto-start the script
return
# auto-start script (default)
self.start()
def at_script_creation(self):
"""
Only called once, by the create function.
"""
pass
def is_valid(self):
"""
Is called to check if the script is valid to run at this time.

View file

@ -4,7 +4,8 @@ Module containing the task handler for Evennia deferred tasks, persistent or not
from datetime import datetime, timedelta
from twisted.internet import reactor, task
from twisted.internet import reactor
from twisted.internet.task import deferLater
from evennia.server.models import ServerConfig
from evennia.utils.logger import log_err
from evennia.utils.dbserialize import dbserialize, dbunserialize
@ -143,7 +144,7 @@ class TaskHandler(object):
args = [task_id]
kwargs = {}
return task.deferLater(reactor, timedelay, callback, *args, **kwargs)
return deferLater(reactor, timedelay, callback, *args, **kwargs)
def remove(self, task_id):
"""Remove a persistent task without executing it.
@ -189,7 +190,7 @@ class TaskHandler(object):
now = datetime.now()
for task_id, (date, callbac, args, kwargs) in self.tasks.items():
seconds = max(0, (date - now).total_seconds())
task.deferLater(reactor, seconds, self.do_task, task_id)
deferLater(reactor, seconds, self.do_task, task_id)
# Create the soft singleton

View file

@ -0,0 +1,241 @@
"""
The Evennia Server service acts as an AMP-client when talking to the
Portal. This module sets up the Client-side communication.
"""
import os
from evennia.server.portal import amp
from twisted.internet import protocol
from evennia.utils import logger
class AMPClientFactory(protocol.ReconnectingClientFactory):
"""
This factory creates an instance of an AMP client connection. This handles communication from
the be the Evennia 'Server' service to the 'Portal'. The client will try to auto-reconnect on a
connection error.
"""
# Initial reconnect delay in seconds.
initialDelay = 1
factor = 1.5
maxDelay = 1
noisy = False
def __init__(self, server):
"""
Initializes the client factory.
Args:
server (server): server instance.
"""
self.server = server
self.protocol = AMPServerClientProtocol
self.maxDelay = 10
# not really used unless connecting to multiple servers, but
# avoids having to check for its existence on the protocol
self.broadcasts = []
def startedConnecting(self, connector):
"""
Called when starting to try to connect to the MUD server.
Args:
connector (Connector): Twisted Connector instance representing
this connection.
"""
pass
def buildProtocol(self, addr):
"""
Creates an AMPProtocol instance when connecting to the AMP server.
Args:
addr (str): Connection address. Not used.
"""
self.resetDelay()
self.server.amp_protocol = AMPServerClientProtocol()
self.server.amp_protocol.factory = self
return self.server.amp_protocol
def clientConnectionLost(self, connector, reason):
"""
Called when the AMP connection to the MUD server is lost.
Args:
connector (Connector): Twisted Connector instance representing
this connection.
reason (str): Eventual text describing why connection was lost.
"""
logger.log_info("Server disconnected from the portal.")
protocol.ReconnectingClientFactory.clientConnectionLost(self, connector, reason)
def clientConnectionFailed(self, connector, reason):
"""
Called when an AMP connection attempt to the MUD server fails.
Args:
connector (Connector): Twisted Connector instance representing
this connection.
reason (str): Eventual text describing why connection failed.
"""
logger.log_msg("Attempting to reconnect to Portal ...")
protocol.ReconnectingClientFactory.clientConnectionFailed(self, connector, reason)
class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol):
"""
This protocol describes the Server service (acting as an AMP-client)'s communication with the
Portal (which acts as the AMP-server)
"""
# sending AMP data
def connectionMade(self):
"""
Called when a new connection is established.
"""
info_dict = self.factory.server.get_info_dict()
super(AMPServerClientProtocol, self).connectionMade()
# first thing we do is to request the Portal to sync all sessions
# back with the Server side. We also need the startup mode (reload, reset, shutdown)
self.send_AdminServer2Portal(
amp.DUMMYSESSION, operation=amp.PSYNC, spid=os.getpid(), info_dict=info_dict)
# run the intial setup if needed
self.factory.server.run_initial_setup()
def data_to_portal(self, command, sessid, **kwargs):
"""
Send data across the wire to the Portal
Args:
command (AMP Command): A protocol send command.
sessid (int): A unique Session id.
kwargs (any): Any data to pickle into the command.
Returns:
deferred (deferred or None): A deferred with an errback.
Notes:
Data will be sent across the wire pickled as a tuple
(sessid, kwargs).
"""
return self.callRemote(command, packed_data=amp.dumps((sessid, kwargs))).addErrback(
self.errback, command.key)
def send_MsgServer2Portal(self, session, **kwargs):
"""
Access method - executed on the Server for sending data
to Portal.
Args:
session (Session): Unique Session.
kwargs (any, optiona): Extra data.
"""
return self.data_to_portal(amp.MsgServer2Portal, session.sessid, **kwargs)
def send_AdminServer2Portal(self, session, operation="", **kwargs):
"""
Administrative access method called by the Server to send an
instruction to the Portal.
Args:
session (Session): Session.
operation (char, optional): Identifier for the server
operation, as defined by the global variables in
`evennia/server/amp.py`.
kwargs (dict, optional): Data going into the adminstrative.
"""
return self.data_to_portal(amp.AdminServer2Portal, session.sessid,
operation=operation, **kwargs)
# receiving AMP data
@amp.MsgStatus.responder
def server_receive_status(self, question):
return {"status": "OK"}
@amp.MsgPortal2Server.responder
@amp.catch_traceback
def server_receive_msgportal2server(self, packed_data):
"""
Receives message arriving to server. This method is executed
on the Server.
Args:
packed_data (str): Data to receive (a pickled tuple (sessid,kwargs))
"""
sessid, kwargs = self.data_in(packed_data)
session = self.factory.server.sessions.get(sessid, None)
if session:
self.factory.server.sessions.data_in(session, **kwargs)
return {}
@amp.AdminPortal2Server.responder
@amp.catch_traceback
def server_receive_adminportal2server(self, packed_data):
"""
Receives admin data from the Portal (allows the portal to
perform admin operations on the server). This is executed on
the Server.
Args:
packed_data (str): Incoming, pickled data.
"""
sessid, kwargs = self.data_in(packed_data)
operation = kwargs.pop("operation", "")
server_sessionhandler = self.factory.server.sessions
if operation == amp.PCONN: # portal_session_connect
# create a new session and sync it
server_sessionhandler.portal_connect(kwargs.get("sessiondata"))
elif operation == amp.PCONNSYNC: # portal_session_sync
server_sessionhandler.portal_session_sync(kwargs.get("sessiondata"))
elif operation == amp.PDISCONN: # portal_session_disconnect
# session closed from portal sid
session = server_sessionhandler.get(sessid)
if session:
server_sessionhandler.portal_disconnect(session)
elif operation == amp.PDISCONNALL: # portal_disconnect_all
# portal orders all sessions to close
server_sessionhandler.portal_disconnect_all()
elif operation == amp.PSYNC: # portal_session_sync
# force a resync of sessions from the portal side. This happens on
# first server-connect.
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"))
elif operation == amp.SRELOAD: # server reload
# shut down in reload mode
server_sessionhandler.all_sessions_portal_sync()
server_sessionhandler.server.shutdown(mode='reload')
elif operation == amp.SRESET:
# shut down in reset mode
server_sessionhandler.all_sessions_portal_sync()
server_sessionhandler.server.shutdown(mode='reset')
elif operation == amp.SSHUTD: # server shutdown
# shutdown in stop mode
server_sessionhandler.server.shutdown(mode='shutdown')
else:
raise Exception("operation %(op)s not recognized." % {'op': operation})
return {}

File diff suppressed because it is too large Load diff

View file

@ -59,7 +59,7 @@ def create_objects():
"""
logger.log_info("Creating objects (Account #1 and Limbo room) ...")
logger.log_info("Initial setup: Creating objects (Account #1 and Limbo room) ...")
# Set the initial User's account object's username on the #1 object.
# This object is pure django and only holds name, email and password.
@ -121,7 +121,7 @@ def create_channels():
Creates some sensible default channels.
"""
logger.log_info("Creating default channels ...")
logger.log_info("Initial setup: Creating default channels ...")
goduser = get_god_account()
for channeldict in settings.DEFAULT_CHANNELS:
@ -144,11 +144,21 @@ def at_initial_setup():
mod = __import__(modname, fromlist=[None])
except (ImportError, ValueError):
return
logger.log_info(" Running at_initial_setup() hook.")
logger.log_info("Initial setup: Running at_initial_setup() hook.")
if mod.__dict__.get("at_initial_setup", None):
mod.at_initial_setup()
def collectstatic():
"""
Run collectstatic to make sure all web assets are loaded.
"""
from django.core.management import call_command
logger.log_info("Initial setup: Gathering static resources using 'collectstatic'")
call_command('collectstatic', '--noinput')
def reset_server():
"""
We end the initialization by resetting the server. This makes sure
@ -159,8 +169,8 @@ def reset_server():
"""
ServerConfig.objects.conf("server_epoch", time.time())
from evennia.server.sessionhandler import SESSIONS
logger.log_info(" Initial setup complete. Restarting Server once.")
SESSIONS.server.shutdown(mode='reset')
logger.log_info("Initial setup complete. Restarting Server once.")
SESSIONS.portal_reset_server()
def handle_setup(last_step):
@ -186,6 +196,7 @@ def handle_setup(last_step):
setup_queue = [create_objects,
create_channels,
at_initial_setup,
collectstatic,
reset_server]
# step through queue, from last completed function

View file

@ -161,10 +161,10 @@ def client_options(session, *args, **kwargs):
raw (bool): Turn off parsing
"""
flags = session.protocol_flags
old_flags = session.protocol_flags
if not kwargs or kwargs.get("get", False):
# return current settings
options = dict((key, flags[key]) for key in flags
options = dict((key, old_flags[key]) for key in old_flags
if key.upper() in ("ANSI", "XTERM256", "MXP",
"UTF-8", "SCREENREADER", "ENCODING",
"MCCP", "SCREENHEIGHT",
@ -190,6 +190,7 @@ def client_options(session, *args, **kwargs):
return True if val.lower() in ("true", "on", "1") else False
return bool(val)
flags = {}
for key, value in kwargs.items():
key = key.lower()
if key == "client":
@ -231,9 +232,11 @@ def client_options(session, *args, **kwargs):
err = _ERROR_INPUT.format(
name="client_settings", session=session, inp=key)
session.msg(text=err)
session.protocol_flags = flags
# we must update the portal as well
session.sessionhandler.session_portal_sync(session)
session.protocol_flags.update(flags)
# we must update the protocol flags on the portal session copy as well
session.sessionhandler.session_portal_partial_sync(
{session.sessid: {"protocol_flags": flags}})
# GMCP alias

View file

@ -0,0 +1,438 @@
"""
The AMP (Asynchronous Message Protocol)-communication commands and constants used by Evennia.
This module acts as a central place for AMP-servers and -clients to get commands to use.
"""
from __future__ import print_function
from functools import wraps
import time
from twisted.protocols import amp
from collections import defaultdict, namedtuple
from cStringIO import StringIO
from itertools import count
import zlib # Used in Compressed class
try:
import cPickle as pickle
except ImportError:
import pickle
from twisted.internet.defer import DeferredList, Deferred
from evennia.utils.utils import to_str, variable_from_module
# delayed import
_LOGGER = None
# communication bits
# (chr(9) and chr(10) are \t and \n, so skipping them)
PCONN = chr(1) # portal session connect
PDISCONN = chr(2) # portal session disconnect
PSYNC = chr(3) # portal session sync
SLOGIN = chr(4) # server session login
SDISCONN = chr(5) # server session disconnect
SDISCONNALL = chr(6) # server session disconnect all
SSHUTD = chr(7) # server shutdown
SSYNC = chr(8) # server session sync
SCONN = chr(11) # server creating new connection (for irc bots and etc)
PCONNSYNC = chr(12) # portal post-syncing a session
PDISCONNALL = chr(13) # portal session disconnect all
SRELOAD = chr(14) # server shutdown in reload mode
SSTART = chr(15) # server start (portal must already be running anyway)
PSHUTD = chr(16) # portal (+server) shutdown
SSHUTD = chr(17) # server shutdown
PSTATUS = chr(18) # ping server or portal status
SRESET = chr(19) # server shutdown in reset mode
NUL = b'\0'
NULNUL = '\0\0'
AMP_MAXLEN = amp.MAX_VALUE_LENGTH # max allowed data length in AMP protocol (cannot be changed)
# buffers
_SENDBATCH = defaultdict(list)
_MSGBUFFER = defaultdict(list)
# resources
DUMMYSESSION = namedtuple('DummySession', ['sessid'])(0)
_HTTP_WARNING = """
HTTP/1.1 200 OK
Content-Type: text/html
<html>
<body>
This is Evennia's internal AMP port. It handles communication
between Evennia's different processes.
<p>
<h3>This port should NOT be publicly visible.</h3>
</p>
</body>
</html>""".strip()
# Helper functions for pickling.
def dumps(data):
return to_str(pickle.dumps(to_str(data), pickle.HIGHEST_PROTOCOL))
def loads(data):
return pickle.loads(to_str(data))
@wraps
def catch_traceback(func):
"Helper decorator"
def decorator(*args, **kwargs):
try:
func(*args, **kwargs)
except Exception as err:
global _LOGGER
if not _LOGGER:
from evennia.utils import logger as _LOGGER
_LOGGER.log_trace()
raise # make sure the error is visible on the other side of the connection too
print(err)
return decorator
# AMP Communication Command types
class Compressed(amp.String):
"""
This is a custom AMP command Argument that both handles too-long
sends as well as uses zlib for compression across the wire. The
batch-grouping of too-long sends is borrowed from the "mediumbox"
recipy at twisted-hacks's ~glyph/+junk/amphacks/mediumbox.
"""
def fromBox(self, name, strings, objects, proto):
"""
Converts from box string representation to python. We read back too-long batched data and
put it back together here.
"""
value = StringIO()
value.write(self.fromStringProto(strings.get(name), proto))
for counter in count(2):
# count from 2 upwards
chunk = strings.get("%s.%d" % (name, counter))
if chunk is None:
break
value.write(self.fromStringProto(chunk, proto))
objects[name] = value.getvalue()
def toBox(self, name, strings, objects, proto):
"""
Convert from python object to string box representation.
we break up too-long data snippets into multiple batches here.
"""
value = StringIO(objects[name])
strings[name] = self.toStringProto(value.read(AMP_MAXLEN), proto)
for counter in count(2):
chunk = value.read(AMP_MAXLEN)
if not chunk:
break
strings["%s.%d" % (name, counter)] = self.toStringProto(chunk, proto)
def toString(self, inObject):
"""
Convert to send as a string on the wire, with compression.
"""
return zlib.compress(super(Compressed, self).toString(inObject), 9)
def fromString(self, inString):
"""
Convert (decompress) from the string-representation on the wire to Python.
"""
return super(Compressed, self).fromString(zlib.decompress(inString))
class MsgLauncher2Portal(amp.Command):
"""
Message Launcher -> Portal
"""
key = "MsgLauncher2Portal"
arguments = [('operation', amp.String()),
('arguments', amp.String())]
errors = {Exception: 'EXCEPTION'}
response = []
class MsgPortal2Server(amp.Command):
"""
Message Portal -> Server
"""
key = "MsgPortal2Server"
arguments = [('packed_data', Compressed())]
errors = {Exception: 'EXCEPTION'}
response = []
class MsgServer2Portal(amp.Command):
"""
Message Server -> Portal
"""
key = "MsgServer2Portal"
arguments = [('packed_data', Compressed())]
errors = {Exception: 'EXCEPTION'}
response = []
class AdminPortal2Server(amp.Command):
"""
Administration Portal -> Server
Sent when the portal needs to perform admin operations on the
server, such as when a new session connects or resyncs
"""
key = "AdminPortal2Server"
arguments = [('packed_data', Compressed())]
errors = {Exception: 'EXCEPTION'}
response = []
class AdminServer2Portal(amp.Command):
"""
Administration Server -> Portal
Sent when the server needs to perform admin operations on the
portal.
"""
key = "AdminServer2Portal"
arguments = [('packed_data', Compressed())]
errors = {Exception: 'EXCEPTION'}
response = []
class MsgStatus(amp.Command):
"""
Check Status between AMP services
"""
key = "MsgStatus"
arguments = [('status', amp.String())]
errors = {Exception: 'EXCEPTION'}
response = [('status', amp.String())]
class FunctionCall(amp.Command):
"""
Bidirectional Server <-> Portal
Sent when either process needs to call an arbitrary function in
the other. This does not use the batch-send functionality.
"""
key = "FunctionCall"
arguments = [('module', amp.String()),
('function', amp.String()),
('args', amp.String()),
('kwargs', amp.String())]
errors = {Exception: 'EXCEPTION'}
response = [('result', amp.String())]
# -------------------------------------------------------------
# Core AMP protocol for communication Server <-> Portal
# -------------------------------------------------------------
class AMPMultiConnectionProtocol(amp.AMP):
"""
AMP protocol that safely handle multiple connections to the same
server without dropping old ones - new clients will receive
all server returns (broadcast). Will also correctly handle
erroneous HTTP requests on the port and return a HTTP error response.
"""
# helper methods
def __init__(self, *args, **kwargs):
"""
Initialize protocol with some things that need to be in place
already before connecting both on portal and server.
"""
self.send_batch_counter = 0
self.send_reset_time = time.time()
self.send_mode = True
self.send_task = None
self.multibatches = 0
def dataReceived(self, data):
"""
Handle non-AMP messages, such as HTTP communication.
"""
if data[0] == NUL:
# an AMP communication
if data[-2:] != NULNUL:
# an incomplete AMP box means more batches are forthcoming.
self.multibatches += 1
super(AMPMultiConnectionProtocol, self).dataReceived(data)
elif self.multibatches:
# invalid AMP, but we have a pending multi-batch that is not yet complete
if data[-2:] == NULNUL:
# end of existing multibatch
self.multibatches = max(0, self.multibatches - 1)
super(AMPMultiConnectionProtocol, self).dataReceived(data)
else:
# not an AMP communication, return warning
self.transport.write(_HTTP_WARNING)
self.transport.loseConnection()
print("HTML received: %s" % data)
def makeConnection(self, transport):
"""
Swallow connection log message here. Copied from original
in the amp protocol.
"""
# copied from original, removing the log message
if not self._ampInitialized:
amp.AMP.__init__(self)
self._transportPeer = transport.getPeer()
self._transportHost = transport.getHost()
amp.BinaryBoxProtocol.makeConnection(self, transport)
def connectionMade(self):
"""
This is called when an AMP connection is (re-)established. AMP calls it on both sides.
"""
self.factory.broadcasts.append(self)
def connectionLost(self, reason):
"""
We swallow connection errors here. The reason is that during a
normal reload/shutdown there will almost always be cases where
either the portal or server shuts down before a message has
returned its (empty) return, triggering a connectionLost error
that is irrelevant. If a true connection error happens, the
portal will continuously try to reconnect, showing the problem
that way.
"""
try:
self.factory.broadcasts.remove(self)
except ValueError:
pass
# Error handling
def errback(self, e, info):
"""
Error callback.
Handles errors to avoid dropping connections on server tracebacks.
Args:
e (Failure): Deferred error instance.
info (str): Error string.
"""
global _LOGGER
if not _LOGGER:
from evennia.utils import logger as _LOGGER
e.trap(Exception)
_LOGGER.log_err("AMP Error for %(info)s: %(e)s" % {'info': info,
'e': e.getErrorMessage()})
def data_in(self, packed_data):
"""
Process incoming packed data.
Args:
packed_data (bytes): Zip-packed data.
Returns:
unpaced_data (any): Unpacked package
"""
return loads(packed_data)
def broadcast(self, command, sessid, **kwargs):
"""
Send data across the wire to all connections.
Args:
command (AMP Command): A protocol send command.
sessid (int): A unique Session id.
Returns:
deferred (deferred or None): A deferred with an errback.
Notes:
Data will be sent across the wire pickled as a tuple
(sessid, kwargs).
"""
deferreds = []
for protcl in self.factory.broadcasts:
deferreds.append(protcl.callRemote(command, **kwargs).addErrback(
self.errback, command.key))
return DeferredList(deferreds)
# generic function send/recvs
def send_FunctionCall(self, modulepath, functionname, *args, **kwargs):
"""
Access method called by either process. This will call an arbitrary
function on the other process (On Portal if calling from Server and
vice versa).
Inputs:
modulepath (str) - python path to module holding function to call
functionname (str) - name of function in given module
*args, **kwargs will be used as arguments/keyword args for the
remote function call
Returns:
A deferred that fires with the return value of the remote
function call
"""
return self.callRemote(FunctionCall,
module=modulepath,
function=functionname,
args=dumps(args),
kwargs=dumps(kwargs)).addCallback(
lambda r: loads(r["result"])).addErrback(self.errback, "FunctionCall")
@FunctionCall.responder
@catch_traceback
def receive_functioncall(self, module, function, func_args, func_kwargs):
"""
This allows Portal- and Server-process to call an arbitrary
function in the other process. It is intended for use by
plugin modules.
Args:
module (str or module): The module containing the
`function` to call.
function (str): The name of the function to call in
`module`.
func_args (str): Pickled args tuple for use in `function` call.
func_kwargs (str): Pickled kwargs dict for use in `function` call.
"""
args = loads(func_args)
kwargs = loads(func_kwargs)
# call the function (don't catch tracebacks here)
result = variable_from_module(module, function)(*args, **kwargs)
if isinstance(result, Deferred):
# if result is a deferred, attach handler to properly
# wrap the return value
result.addCallback(lambda r: {"result": dumps(r)})
return result
else:
return {'result': dumps(result)}

View file

@ -0,0 +1,458 @@
"""
The Evennia Portal service acts as an AMP-server, handling AMP
communication to the AMP clients connecting to it (by default
these are the Evennia Server and the evennia launcher).
"""
import os
import sys
from twisted.internet import protocol
from evennia.server.portal import amp
from django.conf import settings
from subprocess import Popen, STDOUT
from evennia.utils import logger
def _is_windows():
return os.name == 'nt'
def getenv():
"""
Get current environment and add PYTHONPATH.
Returns:
env (dict): Environment global dict.
"""
sep = ";" if _is_windows() else ":"
env = os.environ.copy()
env['PYTHONPATH'] = sep.join(sys.path)
return env
class AMPServerFactory(protocol.ServerFactory):
"""
This factory creates AMP Server connection. This acts as the 'Portal'-side communication to the
'Server' process.
"""
noisy = False
def logPrefix(self):
"How this is named in logs"
return "AMP"
def __init__(self, portal):
"""
Initialize the factory. This is called as the Portal service starts.
Args:
portal (Portal): The Evennia Portal service instance.
protocol (Protocol): The protocol the factory creates
instances of.
"""
self.portal = portal
self.protocol = AMPServerProtocol
self.broadcasts = []
self.server_connection = None
self.launcher_connection = None
self.disconnect_callbacks = {}
self.server_connect_callbacks = []
def buildProtocol(self, addr):
"""
Start a new connection, and store it on the service object.
Args:
addr (str): Connection address. Not used.
Returns:
protocol (Protocol): The created protocol.
"""
self.portal.amp_protocol = AMPServerProtocol()
self.portal.amp_protocol.factory = self
return self.portal.amp_protocol
class AMPServerProtocol(amp.AMPMultiConnectionProtocol):
"""
Protocol subclass for the AMP-server run by the Portal.
"""
def connectionLost(self, reason):
"""
Set up a simple callback mechanism to let the amp-server wait for a connection to close.
"""
# wipe broadcast and data memory
super(AMPServerProtocol, self).connectionLost(reason)
if self.factory.server_connection == self:
self.factory.server_connection = None
self.factory.portal.server_info_dict = {}
if self.factory.launcher_connection == self:
self.factory.launcher_connection = None
callback, args, kwargs = self.factory.disconnect_callbacks.pop(self, (None, None, None))
if callback:
try:
callback(*args, **kwargs)
except Exception:
logger.log_trace()
def get_status(self):
"""
Return status for the Evennia infrastructure.
Returns:
status (tuple): The portal/server status and pids
(portal_live, server_live, portal_PID, server_PID).
"""
server_connected = bool(self.factory.server_connection and
self.factory.server_connection.transport.connected)
portal_info_dict = self.factory.portal.get_info_dict()
server_info_dict = self.factory.portal.server_info_dict
server_pid = self.factory.portal.server_process_id
portal_pid = os.getpid()
return (True, server_connected, portal_pid, server_pid, portal_info_dict, server_info_dict)
def data_to_server(self, command, sessid, **kwargs):
"""
Send data across the wire to the Server.
Args:
command (AMP Command): A protocol send command.
sessid (int): A unique Session id.
Returns:
deferred (deferred or None): A deferred with an errback.
Notes:
Data will be sent across the wire pickled as a tuple
(sessid, kwargs).
"""
if self.factory.server_connection:
return self.factory.server_connection.callRemote(
command, packed_data=amp.dumps((sessid, kwargs))).addErrback(
self.errback, command.key)
else:
# if no server connection is available, broadcast
return self.broadcast(command, sessid, packed_data=amp.dumps((sessid, kwargs)))
def start_server(self, server_twistd_cmd):
"""
(Re-)Launch the Evennia server.
Args:
server_twisted_cmd (list): The server start instruction
to pass to POpen to start the server.
"""
# start the Server
process = None
with open(settings.SERVER_LOG_FILE, 'a') as logfile:
# we link stdout to a file in order to catch
# eventual errors happening before the Server has
# opened its logger.
try:
if _is_windows():
# Windows requires special care
create_no_window = 0x08000000
process = Popen(server_twistd_cmd, env=getenv(), bufsize=-1,
stdout=logfile, stderr=STDOUT,
creationflags=create_no_window)
else:
process = Popen(server_twistd_cmd, env=getenv(), bufsize=-1,
stdout=logfile, stderr=STDOUT)
except Exception:
logger.log_trace()
self.factory.portal.server_twistd_cmd = server_twistd_cmd
logfile.flush()
if process and not _is_windows():
# avoid zombie-process on Unix/BSD
process.wait()
return
def wait_for_disconnect(self, callback, *args, **kwargs):
"""
Add a callback for when this connection is lost.
Args:
callback (callable): Will be called with *args, **kwargs
once this protocol is disconnected.
"""
self.factory.disconnect_callbacks[self] = (callback, args, kwargs)
def wait_for_server_connect(self, callback, *args, **kwargs):
"""
Add a callback for when the Server is sure to have connected.
Args:
callback (callable): Will be called with *args, **kwargs
once the Server handshake with Portal is complete.
"""
self.factory.server_connect_callbacks.append((callback, args, kwargs))
def stop_server(self, mode='shutdown'):
"""
Shut down server in one or more modes.
Args:
mode (str): One of 'shutdown', 'reload' or 'reset'.
"""
if mode == 'reload':
self.send_AdminPortal2Server(amp.DUMMYSESSION, operation=amp.SRELOAD)
elif mode == 'reset':
self.send_AdminPortal2Server(amp.DUMMYSESSION, operation=amp.SRESET)
elif mode == 'shutdown':
self.send_AdminPortal2Server(amp.DUMMYSESSION, operation=amp.SSHUTD)
self.factory.portal.server_restart_mode = mode
# sending amp data
def send_Status2Launcher(self):
"""
Send a status stanza to the launcher.
"""
if self.factory.launcher_connection:
self.factory.launcher_connection.callRemote(
amp.MsgStatus,
status=amp.dumps(self.get_status())).addErrback(
self.errback, amp.MsgStatus.key)
def send_MsgPortal2Server(self, session, **kwargs):
"""
Access method called by the Portal and executed on the Portal.
Args:
session (session): Session
kwargs (any, optional): Optional data.
Returns:
deferred (Deferred): Asynchronous return.
"""
return self.data_to_server(amp.MsgPortal2Server, session.sessid, **kwargs)
def send_AdminPortal2Server(self, session, operation="", **kwargs):
"""
Send Admin instructions from the Portal to the Server.
Executed on the Portal.
Args:
session (Session): Session.
operation (char, optional): Identifier for the server operation, as defined by the
global variables in `evennia/server/amp.py`.
data (str or dict, optional): Data used in the administrative operation.
"""
return self.data_to_server(amp.AdminPortal2Server, session.sessid,
operation=operation, **kwargs)
# receive amp data
@amp.MsgStatus.responder
@amp.catch_traceback
def portal_receive_status(self, status):
"""
Returns run-status for the server/portal.
Args:
status (str): Not used.
Returns:
status (dict): The status is a tuple
(portal_running, server_running, portal_pid, server_pid).
"""
return {"status": amp.dumps(self.get_status())}
@amp.MsgLauncher2Portal.responder
@amp.catch_traceback
def portal_receive_launcher2portal(self, operation, arguments):
"""
Receives message arriving from evennia_launcher.
This method is executed on the Portal.
Args:
operation (str): The action to perform.
arguments (str): Possible argument to the instruction, or the empty string.
Returns:
result (dict): The result back to the launcher.
Notes:
This is the entrypoint for controlling the entire Evennia system from the evennia
launcher. It can obviously only accessed when the Portal is already up and running.
"""
self.factory.launcher_connection = self
_, server_connected, _, _, _, _ = self.get_status()
# logger.log_msg("Evennia Launcher->Portal operation %s received" % (ord(operation)))
if operation == amp.SSTART: # portal start #15
# first, check if server is already running
if not server_connected:
self.wait_for_server_connect(self.send_Status2Launcher)
self.start_server(amp.loads(arguments))
elif operation == amp.SRELOAD: # reload server #14
if server_connected:
# We let the launcher restart us once they get the signal
self.factory.server_connection.wait_for_disconnect(
self.send_Status2Launcher)
self.stop_server(mode='reload')
else:
self.wait_for_server_connect(self.send_Status2Launcher)
self.start_server(amp.loads(arguments))
elif operation == amp.SRESET: # reload server #19
if server_connected:
self.factory.server_connection.wait_for_disconnect(
self.send_Status2Launcher)
self.stop_server(mode='reset')
else:
self.wait_for_server_connect(self.send_Status2Launcher)
self.start_server(amp.loads(arguments))
elif operation == amp.SSHUTD: # server-only shutdown #17
if server_connected:
self.factory.server_connection.wait_for_disconnect(
self.send_Status2Launcher)
self.stop_server(mode='shutdown')
elif operation == amp.PSHUTD: # portal + server shutdown #16
if server_connected:
self.factory.server_connection.wait_for_disconnect(
self.factory.portal.shutdown )
else:
self.factory.portal.shutdown()
else:
raise Exception("operation %(op)s not recognized." % {'op': operation})
return {}
@amp.MsgServer2Portal.responder
@amp.catch_traceback
def portal_receive_server2portal(self, packed_data):
"""
Receives message arriving to Portal from Server.
This method is executed on the Portal.
Args:
packed_data (str): Pickled data (sessid, kwargs) coming over the wire.
"""
try:
sessid, kwargs = self.data_in(packed_data)
session = self.factory.portal.sessions.get(sessid, None)
if session:
self.factory.portal.sessions.data_out(session, **kwargs)
except Exception:
logger.log_trace("packed_data len {}".format(len(packed_data)))
return {}
@amp.AdminServer2Portal.responder
@amp.catch_traceback
def portal_receive_adminserver2portal(self, packed_data):
"""
Receives and handles admin operations sent to the Portal
This is executed on the Portal.
Args:
packed_data (str): Data received, a pickled tuple (sessid, kwargs).
"""
self.factory.server_connection = self
sessid, kwargs = self.data_in(packed_data)
operation = kwargs.pop("operation")
portal_sessionhandler = self.factory.portal.sessions
if operation == amp.SLOGIN: # server_session_login
# a session has authenticated; sync it.
session = portal_sessionhandler.get(sessid)
if session:
portal_sessionhandler.server_logged_in(session, kwargs.get("sessiondata"))
elif operation == amp.SDISCONN: # server_session_disconnect
# the server is ordering to disconnect the session
session = portal_sessionhandler.get(sessid)
if session:
portal_sessionhandler.server_disconnect(session, reason=kwargs.get("reason"))
elif operation == amp.SDISCONNALL: # server_session_disconnect_all
# server orders all sessions to disconnect
portal_sessionhandler.server_disconnect_all(reason=kwargs.get("reason"))
elif operation == amp.SRELOAD: # server reload
self.factory.server_connection.wait_for_disconnect(
self.start_server, self.factory.portal.server_twistd_cmd)
self.stop_server(mode='reload')
elif operation == amp.SRESET: # server reset
self.factory.server_connection.wait_for_disconnect(
self.start_server, self.factory.portal.server_twistd_cmd)
self.stop_server(mode='reset')
elif operation == amp.SSHUTD: # server-only shutdown
self.stop_server(mode='shutdown')
elif operation == amp.PSHUTD: # full server+server shutdown
self.factory.server_connection.wait_for_disconnect(
self.factory.portal.shutdown)
self.stop_server(mode='shutdown')
elif operation == amp.PSYNC: # portal sync
# Server has (re-)connected and wants the session data from portal
self.factory.portal.server_info_dict = kwargs.get("info_dict", {})
self.factory.portal.server_process_id = kwargs.get("spid", None)
# this defaults to 'shutdown' or whatever value set in server_stop
server_restart_mode = self.factory.portal.server_restart_mode
sessdata = self.factory.portal.sessions.get_all_sync_data()
self.send_AdminPortal2Server(amp.DUMMYSESSION,
amp.PSYNC,
server_restart_mode=server_restart_mode,
sessiondata=sessdata)
self.factory.portal.sessions.at_server_connection()
if self.factory.server_connection:
# this is an indication the server has successfully connected, so
# we trigger any callbacks (usually to tell the launcher server is up)
for callback, args, kwargs in self.factory.server_connect_callbacks:
try:
callback(*args, **kwargs)
except Exception:
logger.log_trace()
self.factory.server_connect_callbacks = []
elif operation == amp.SSYNC: # server_session_sync
# server wants to save session data to the portal,
# maybe because it's about to shut down.
portal_sessionhandler.server_session_sync(kwargs.get("sessiondata"),
kwargs.get("clean", True))
# set a flag in case we are about to shut down soon
self.factory.server_restart_mode = True
elif operation == amp.SCONN: # server_force_connection (for irc/etc)
portal_sessionhandler.server_connect(**kwargs)
else:
raise Exception("operation %(op)s not recognized." % {'op': operation})
return {}

View file

@ -7,17 +7,16 @@ sets up all the networking features. (this is done automatically
by game/evennia.py).
"""
from builtins import object
import time
import sys
import os
from os.path import dirname, abspath
from twisted.application import internet, service
from twisted.internet import protocol, reactor
from twisted.internet.task import LoopingCall
from twisted.web import server
from twisted.python.log import ILogObserver
import django
django.setup()
from django.conf import settings
@ -27,6 +26,7 @@ evennia._init()
from evennia.utils.utils import get_evennia_version, mod_import, make_iter
from evennia.server.portal.portalsessionhandler import PORTAL_SESSIONS
from evennia.utils import logger
from evennia.server.webserver import EvenniaReverseProxyResource
from django.db import connection
@ -40,11 +40,6 @@ except Exception:
PORTAL_SERVICES_PLUGIN_MODULES = [mod_import(module) for module in make_iter(settings.PORTAL_SERVICES_PLUGIN_MODULES)]
LOCKDOWN_MODE = settings.LOCKDOWN_MODE
PORTAL_PIDFILE = ""
if os.name == 'nt':
# For Windows we need to handle pid files manually.
PORTAL_PIDFILE = os.path.join(settings.GAME_DIR, "server", 'portal.pid')
# -------------------------------------------------------------
# Evennia Portal settings
# -------------------------------------------------------------
@ -80,10 +75,15 @@ AMP_PORT = settings.AMP_PORT
AMP_INTERFACE = settings.AMP_INTERFACE
AMP_ENABLED = AMP_HOST and AMP_PORT and AMP_INTERFACE
INFO_DICT = {"servername": SERVERNAME, "version": VERSION, "errors": "", "info": "",
"lockdown_mode": "", "amp": "", "telnet": [], "telnet_ssl": [], "ssh": [],
"webclient": [], "webserver_proxy": [], "webserver_internal": []}
# -------------------------------------------------------------
# Portal Service object
# -------------------------------------------------------------
class Portal(object):
"""
@ -108,41 +108,52 @@ class Portal(object):
self.amp_protocol = None # set by amp factory
self.sessions = PORTAL_SESSIONS
self.sessions.portal = self
self.process_id = os.getpid()
self.server_process_id = None
self.server_restart_mode = "shutdown"
self.server_info_dict = {}
# 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()
# set a callback if the server is killed abruptly,
# by Ctrl-C, reboot etc.
reactor.addSystemEventTrigger('before', 'shutdown', self.shutdown, _reactor_stopping=True)
reactor.addSystemEventTrigger('before', 'shutdown',
self.shutdown, _reactor_stopping=True, _stop_server=True)
self.game_running = False
def set_restart_mode(self, mode=None):
def _get_backup_server_twistd_cmd(self):
"""
This manages the flag file that tells the runner if the server
should be restarted or is shutting down.
Args:
mode (bool or None): Valid modes are True/False and None.
If mode is None, no change will be done to the flag file.
For interactive Portal mode there is no way to get the server cmdline from the launcher, so
we need to guess it here (it's very likely to not change)
Returns:
server_twistd_cmd (list): An instruction for starting the server, to pass to Popen.
"""
if mode is None:
return
with open(PORTAL_RESTART, 'w') as f:
print("writing mode=%(mode)s to %(portal_restart)s" % {'mode': mode, 'portal_restart': PORTAL_RESTART})
f.write(str(mode))
server_twistd_cmd = [
"twistd",
"--python={}".format(os.path.join(dirname(dirname(abspath(__file__))), "server.py"))]
if os.name != 'nt':
gamedir = os.getcwd()
server_twistd_cmd.append("--pidfile={}".format(
os.path.join(gamedir, "server", "server.pid")))
return server_twistd_cmd
def shutdown(self, restart=None, _reactor_stopping=False):
def get_info_dict(self):
"Return the Portal info, for display."
return INFO_DICT
def shutdown(self, _reactor_stopping=False, _stop_server=False):
"""
Shuts down the server from inside it.
Args:
restart (bool or None, optional): True/False sets the
flags so the server will be restarted or not. If None, the
current flag setting (set at initialization or previous
runs) is used.
_reactor_stopping (bool, optional): This is set if server
is already in the process of shutting down; in this case
we don't need to stop it again.
_stop_server (bool, optional): Only used in portal-interactive mode;
makes sure to stop the Server cleanly.
Note that restarting (regardless of the setting) will not work
if the Portal is currently running in daemon mode. In that
@ -153,11 +164,11 @@ class Portal(object):
# we get here due to us calling reactor.stop below. No need
# to do the shutdown procedure again.
return
self.sessions.disconnect_all()
self.set_restart_mode(restart)
if os.name == 'nt' and os.path.exists(PORTAL_PIDFILE):
# for Windows we need to remove pid files manually
os.remove(PORTAL_PIDFILE)
if _stop_server:
self.amp_protocol.stop_server(mode='shutdown')
if not _reactor_stopping:
# shutting down the reactor will trigger another signal. We set
# a flag to avoid loops.
@ -175,14 +186,20 @@ class Portal(object):
# what to execute from.
application = service.Application('Portal')
# custom logging
if "--nodaemon" not in sys.argv:
logfile = logger.WeeklyLogFile(os.path.basename(settings.PORTAL_LOG_FILE),
os.path.dirname(settings.PORTAL_LOG_FILE))
application.setComponent(ILogObserver, logger.PortalLogObserver(logfile).emit)
# The main Portal server program. This sets up the database
# and is where we store all the other services.
PORTAL = Portal(application)
print('-' * 50)
print(' %(servername)s Portal (%(version)s) started.' % {'servername': SERVERNAME, 'version': VERSION})
if LOCKDOWN_MODE:
print(' LOCKDOWN_MODE active: Only local connections.')
INFO_DICT["lockdown_mode"] = ' LOCKDOWN_MODE active: Only local connections.'
if AMP_ENABLED:
@ -190,14 +207,14 @@ if AMP_ENABLED:
# the portal and the mud server. Only reason to ever deactivate
# it would be during testing and debugging.
from evennia.server import amp
from evennia.server.portal import amp_server
print(' amp (to Server): %s (internal)' % AMP_PORT)
INFO_DICT["amp"] = 'amp: %s' % AMP_PORT
factory = amp.AmpClientFactory(PORTAL)
amp_client = internet.TCPClient(AMP_HOST, AMP_PORT, factory)
amp_client.setName('evennia_amp')
PORTAL.services.addService(amp_client)
factory = amp_server.AMPServerFactory(PORTAL)
amp_service = internet.TCPServer(AMP_PORT, factory, interface=AMP_INTERFACE)
amp_service.setName("PortalAMPServer")
PORTAL.services.addService(amp_service)
# We group all the various services under the same twisted app.
@ -215,7 +232,7 @@ if TELNET_ENABLED:
ifacestr = "-%s" % interface
for port in TELNET_PORTS:
pstring = "%s:%s" % (ifacestr, port)
factory = protocol.ServerFactory()
factory = telnet.TelnetServerFactory()
factory.noisy = False
factory.protocol = telnet.TelnetProtocol
factory.sessionhandler = PORTAL_SESSIONS
@ -223,14 +240,14 @@ if TELNET_ENABLED:
telnet_service.setName('EvenniaTelnet%s' % pstring)
PORTAL.services.addService(telnet_service)
print(' telnet%s: %s (external)' % (ifacestr, port))
INFO_DICT["telnet"].append('telnet%s: %s' % (ifacestr, port))
if SSL_ENABLED:
# Start SSL game connection (requires PyOpenSSL).
# Start Telnet+SSL game connection (requires PyOpenSSL).
from evennia.server.portal import ssl
from evennia.server.portal import telnet_ssl
for interface in SSL_INTERFACES:
ifacestr = ""
@ -241,15 +258,21 @@ if SSL_ENABLED:
factory = protocol.ServerFactory()
factory.noisy = False
factory.sessionhandler = PORTAL_SESSIONS
factory.protocol = ssl.SSLProtocol
ssl_service = internet.SSLServer(port,
factory,
ssl.getSSLContext(),
interface=interface)
ssl_service.setName('EvenniaSSL%s' % pstring)
PORTAL.services.addService(ssl_service)
factory.protocol = telnet_ssl.SSLProtocol
print(" ssl%s: %s (external)" % (ifacestr, port))
ssl_context = telnet_ssl.getSSLContext()
if ssl_context:
ssl_service = internet.SSLServer(port,
factory,
telnet_ssl.getSSLContext(),
interface=interface)
ssl_service.setName('EvenniaSSL%s' % pstring)
PORTAL.services.addService(ssl_service)
INFO_DICT["telnet_ssl"].append("telnet+ssl%s: %s" % (ifacestr, port))
else:
INFO_DICT["telnet_ssl"].append(
"telnet+ssl%s: %s (deactivated - keys/cert unset)" % (ifacestr, port))
if SSH_ENABLED:
@ -273,7 +296,7 @@ if SSH_ENABLED:
ssh_service.setName('EvenniaSSH%s' % pstring)
PORTAL.services.addService(ssh_service)
print(" ssh%s: %s (external)" % (ifacestr, port))
INFO_DICT["ssh"].append("ssh%s: %s" % (ifacestr, port))
if WEBSERVER_ENABLED:
@ -296,7 +319,7 @@ if WEBSERVER_ENABLED:
ajax_webclient = webclient_ajax.AjaxWebClient()
ajax_webclient.sessionhandler = PORTAL_SESSIONS
web_root.putChild(b"webclientdata", ajax_webclient)
webclientstr = "\n + webclient (ajax only)"
webclientstr = "webclient (ajax only)"
if WEBSOCKET_CLIENT_ENABLED and not websocket_started:
# start websocket client port for the webclient
@ -309,32 +332,33 @@ if WEBSERVER_ENABLED:
if w_interface not in ('0.0.0.0', '::') or len(WEBSERVER_INTERFACES) > 1:
w_ifacestr = "-%s" % interface
port = WEBSOCKET_CLIENT_PORT
factory = WebSocketServerFactory()
class Websocket(WebSocketServerFactory):
"Only here for better naming in logs"
pass
factory = Websocket()
factory.noisy = False
factory.protocol = webclient.WebSocketClient
factory.sessionhandler = PORTAL_SESSIONS
websocket_service = internet.TCPServer(port, factory, interface=w_interface)
websocket_service.setName('EvenniaWebSocket%s:%s' % (w_ifacestr, proxyport))
websocket_service.setName('EvenniaWebSocket%s:%s' % (w_ifacestr, port))
PORTAL.services.addService(websocket_service)
websocket_started = True
webclientstr = "\n + webclient-websocket%s: %s (external)" % (w_ifacestr, proxyport)
webclientstr = "webclient-websocket%s: %s" % (w_ifacestr, port)
INFO_DICT["webclient"].append(webclientstr)
web_root = Website(web_root, logPath=settings.HTTP_LOG_FILE)
web_root.is_portal = True
proxy_service = internet.TCPServer(proxyport,
web_root,
interface=interface)
proxy_service.setName('EvenniaWebProxy%s' % pstring)
proxy_service.setName('EvenniaWebProxy%s:%s' % (ifacestr, proxyport))
PORTAL.services.addService(proxy_service)
print(" website-proxy%s: %s (external) %s" % (ifacestr, proxyport, webclientstr))
INFO_DICT["webserver_proxy"].append("webserver-proxy%s: %s" % (ifacestr, proxyport))
INFO_DICT["webserver_internal"].append("webserver: %s" % serverport)
for plugin_module in PORTAL_SERVICES_PLUGIN_MODULES:
# external plugin services to start
plugin_module.start_plugin_services(PORTAL)
print('-' * 50) # end of terminal output
if os.name == 'nt':
# Windows only: Set PID file manually
with open(PORTAL_PIDFILE, 'w') as f:
f.write(str(os.getpid()))

View file

@ -15,12 +15,15 @@ from evennia.utils.logger import log_trace
# module import
_MOD_IMPORT = None
# throttles
# global throttles
_MAX_CONNECTION_RATE = float(settings.MAX_CONNECTION_RATE)
# per-session throttles
_MAX_COMMAND_RATE = float(settings.MAX_COMMAND_RATE)
_MAX_CHAR_LIMIT = int(settings.MAX_CHAR_LIMIT)
_MIN_TIME_BETWEEN_CONNECTS = 1.0 / float(settings.MAX_CONNECTION_RATE)
_MIN_TIME_BETWEEN_CONNECTS = 1.0 / float(_MAX_CONNECTION_RATE)
_MIN_TIME_BETWEEN_COMMANDS = 1.0 / float(_MAX_COMMAND_RATE)
_ERROR_COMMAND_OVERFLOW = settings.COMMAND_RATE_WARNING
_ERROR_MAX_CHAR = settings.MAX_CHAR_LIMIT_WARNING
@ -58,9 +61,6 @@ class PortalSessionHandler(SessionHandler):
self.connection_last = self.uptime
self.connection_task = None
self.command_counter = 0
self.command_counter_reset = self.uptime
self.command_overflow = False
def at_server_connection(self):
"""
@ -354,8 +354,6 @@ class PortalSessionHandler(SessionHandler):
Data is serialized before passed on.
"""
# from evennia.server.profiling.timetrace import timetrace # DEBUG
# text = timetrace(text, "portalsessionhandler.data_in") # DEBUG
try:
text = kwargs['text']
if (_MAX_CHAR_LIMIT > 0) and len(text) > _MAX_CHAR_LIMIT:
@ -367,30 +365,38 @@ class PortalSessionHandler(SessionHandler):
pass
if session:
now = time.time()
if self.command_counter > _MAX_COMMAND_RATE > 0:
# data throttle (anti DoS measure)
delta_time = now - self.command_counter_reset
self.command_counter = 0
self.command_counter_reset = now
self.command_overflow = delta_time < 1.0
if self.command_overflow:
reactor.callLater(1.0, self.data_in, None)
if self.command_overflow:
try:
command_counter_reset = session.command_counter_reset
except AttributeError:
command_counter_reset = session.command_counter_reset = now
session.command_counter = 0
# global command-rate limit
if max(0, now - command_counter_reset) > 1.0:
# more than a second since resetting the counter. Refresh.
session.command_counter_reset = now
session.command_counter = 0
session.command_counter += 1
if session.command_counter * _MIN_TIME_BETWEEN_COMMANDS > 1.0:
self.data_out(session, text=[[_ERROR_COMMAND_OVERFLOW], {}])
return
if not self.portal.amp_protocol:
# this can happen if someone connects before AMP connection
# was established (usually on first start)
reactor.callLater(1.0, self.data_in, session, **kwargs)
return
# scrub data
kwargs = self.clean_senddata(session, kwargs)
# relay data to Server
self.command_counter += 1
session.cmd_last = now
self.portal.amp_protocol.send_MsgPortal2Server(session,
**kwargs)
else:
# called by the callLater callback
if self.command_overflow:
self.command_overflow = False
reactor.callLater(1.0, self.data_in, None)
def data_out(self, session, **kwargs):
"""

View file

@ -39,7 +39,7 @@ from twisted.conch.ssh import common
from twisted.conch.insults import insults
from twisted.conch.manhole_ssh import TerminalRealm, _Glue, ConchFactory
from twisted.conch.manhole import Manhole, recvline
from twisted.internet import defer
from twisted.internet import defer, protocol
from twisted.conch import interfaces as iconch
from twisted.python import components
from django.conf import settings
@ -52,12 +52,34 @@ from evennia.utils.utils import to_str
_RE_N = re.compile(r"\|n$")
_RE_SCREENREADER_REGEX = re.compile(r"%s" % settings.SCREENREADER_REGEX_STRIP, re.DOTALL + re.MULTILINE)
_GAME_DIR = settings.GAME_DIR
_PRIVATE_KEY_FILE = os.path.join(_GAME_DIR, "server", "ssh-private.key")
_PUBLIC_KEY_FILE = os.path.join(_GAME_DIR, "server", "ssh-public.key")
_KEY_LENGTH = 2048
CTRL_C = '\x03'
CTRL_D = '\x04'
CTRL_BACKSLASH = '\x1c'
CTRL_L = '\x0c'
_NO_AUTOGEN = """
Evennia could not generate SSH private- and public keys ({{err}})
Using conch default keys instead.
If this error persists, create the keys manually (using the tools for your OS)
and put them here:
{}
{}
""".format(_PRIVATE_KEY_FILE, _PUBLIC_KEY_FILE)
# not used atm
class SSHServerFactory(protocol.ServerFactory):
"This is only to name this better in logs"
noisy = False
def logPrefix(self):
return "SSH"
class SshProtocol(Manhole, session.Session):
"""
@ -66,6 +88,7 @@ class SshProtocol(Manhole, session.Session):
here.
"""
noisy = False
def __init__(self, starttuple):
"""
@ -76,6 +99,7 @@ class SshProtocol(Manhole, session.Session):
starttuple (tuple): A (account, factory) tuple.
"""
self.protocol_key = "ssh"
self.authenticated_account = starttuple[0]
# obs must not be called self.factory, that gets overwritten!
self.cfactory = starttuple[1]
@ -104,7 +128,7 @@ class SshProtocol(Manhole, session.Session):
# since we might have authenticated already, we might set this here.
if self.authenticated_account:
self.logged_in = True
self.uid = self.authenticated_account.user.id
self.uid = self.authenticated_account.id
self.sessionhandler.connect(self)
def connectionMade(self):
@ -228,7 +252,7 @@ class SshProtocol(Manhole, session.Session):
"""
if reason:
self.data_out(text=reason)
self.data_out(text=((reason, ), {}))
self.connectionLost(reason)
def data_out(self, **kwargs):
@ -302,6 +326,9 @@ class SshProtocol(Manhole, session.Session):
class ExtraInfoAuthServer(SSHUserAuthServer):
noisy = False
def auth_password(self, packet):
"""
Password authentication.
@ -327,6 +354,7 @@ class AccountDBPasswordChecker(object):
useful for the Realm.
"""
noisy = False
credentialInterfaces = (credentials.IUsernamePassword,)
def __init__(self, factory):
@ -362,6 +390,8 @@ class PassAvatarIdTerminalRealm(TerminalRealm):
"""
noisy = False
def _getAvatar(self, avatarId):
comp = components.Componentized()
user = self.userFactory(comp, avatarId)
@ -383,6 +413,8 @@ class TerminalSessionTransport_getPeer(object):
"""
noisy = False
def __init__(self, proto, chainedProtocol, avatar, width, height):
self.proto = proto
self.avatar = avatar
@ -417,33 +449,32 @@ def getKeyPair(pubkeyfile, privkeyfile):
if not (os.path.exists(pubkeyfile) and os.path.exists(privkeyfile)):
# No keypair exists. Generate a new RSA keypair
print(" Generating SSH RSA keypair ...", end=' ')
from Crypto.PublicKey import RSA
KEY_LENGTH = 1024
rsaKey = Key(RSA.generate(KEY_LENGTH))
publicKeyString = rsaKey.public().toString(type="OPENSSH")
privateKeyString = rsaKey.toString(type="OPENSSH")
rsa_key = Key(RSA.generate(_KEY_LENGTH))
public_key_string = rsa_key.public().toString(type="OPENSSH")
private_key_string = rsa_key.toString(type="OPENSSH")
# save keys for the future.
file(pubkeyfile, 'w+b').write(publicKeyString)
file(privkeyfile, 'w+b').write(privateKeyString)
print(" done.")
with open(privkeyfile, 'wt') as pfile:
pfile.write(private_key_string)
print("Created SSH private key in '{}'".format(_PRIVATE_KEY_FILE))
with open(pubkeyfile, 'wt') as pfile:
pfile.write(public_key_string)
print("Created SSH public key in '{}'".format(_PUBLIC_KEY_FILE))
else:
publicKeyString = file(pubkeyfile).read()
privateKeyString = file(privkeyfile).read()
with open(pubkeyfile) as pfile:
public_key_string = pfile.read()
with open(privkeyfile) as pfile:
private_key_string = pfile.read()
return Key.fromString(publicKeyString), Key.fromString(privateKeyString)
return Key.fromString(public_key_string), Key.fromString(private_key_string)
def makeFactory(configdict):
"""
Creates the ssh server factory.
"""
pubkeyfile = os.path.join(_GAME_DIR, "server", "ssh-public.key")
privkeyfile = os.path.join(_GAME_DIR, "server", "ssh-private.key")
def chainProtocolFactory(username=None):
return insults.ServerProtocol(
configdict['protocolFactory'],
@ -458,14 +489,11 @@ def makeFactory(configdict):
try:
# create/get RSA keypair
publicKey, privateKey = getKeyPair(pubkeyfile, privkeyfile)
publicKey, privateKey = getKeyPair(_PUBLIC_KEY_FILE, _PRIVATE_KEY_FILE)
factory.publicKeys = {'ssh-rsa': publicKey}
factory.privateKeys = {'ssh-rsa': privateKey}
except Exception as err:
print("getKeyPair error: {err}\n WARNING: Evennia could not "
"auto-generate SSH keypair. Using conch default keys instead.\n"
"If this error persists, create {pub} and "
"{priv} yourself using third-party tools.".format(err=err, pub=pubkeyfile, priv=privkeyfile))
print(_NO_AUTOGEN.format(err=err))
factory.services = factory.services.copy()
factory.services['ssh-userauth'] = ExtraInfoAuthServer

View file

@ -40,11 +40,9 @@ class SuppressGA(object):
self.protocol.protocol_flags["NOGOAHEAD"] = True
# tell the client that we prefer to suppress GA ...
self.protocol.will(SUPPRESS_GA).addCallbacks(self.do_suppress_ga, self.dont_suppress_ga)
# ... but also accept if the client really wants not to.
self.protocol.do(SUPPRESS_GA).addCallbacks(self.do_suppress_ga, self.dont_suppress_ga)
self.protocol.will(SUPPRESS_GA).addCallbacks(self.will_suppress_ga, self.wont_suppress_ga)
def dont_suppress_ga(self, option):
def wont_suppress_ga(self, option):
"""
Called when client requests to not suppress GA.
@ -55,9 +53,9 @@ class SuppressGA(object):
self.protocol.protocol_flags["NOGOAHEAD"] = False
self.protocol.handshake_done()
def do_suppress_ga(self, option):
def will_suppress_ga(self, option):
"""
Client wants to suppress GA
Client will suppress GA
Args:
option (Option): Not used.

View file

@ -8,6 +8,7 @@ sessions etc.
"""
import re
from twisted.internet import protocol
from twisted.internet.task import LoopingCall
from twisted.conch.telnet import Telnet, StatefulTelnetProtocol
from twisted.conch.telnet import IAC, NOP, LINEMODE, GA, WILL, WONT, ECHO, NULL
@ -26,6 +27,14 @@ _RE_SCREENREADER_REGEX = re.compile(r"%s" % settings.SCREENREADER_REGEX_STRIP, r
_IDLE_COMMAND = str.encode(settings.IDLE_COMMAND + "\n")
class TelnetServerFactory(protocol.ServerFactory):
"This is only to name this better in logs"
noisy = False
def logPrefix(self):
return "Telnet"
class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
"""
Each player connecting over telnet (ie using most traditional mud
@ -34,7 +43,7 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
"""
def __init__(self, *args, **kwargs):
self.protocol_name = "telnet"
self.protocol_key = "telnet"
super().__init__(*args, **kwargs)
def connectionMade(self):
@ -49,7 +58,13 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
# this number is counted down for every handshake that completes.
# when it reaches 0 the portal/server syncs their data
self.handshakes = 8 # suppress-go-ahead, naws, ttype, mccp, mssp, msdp, gmcp, mxp
self.init_session(self.protocol_name, client_address, self.factory.sessionhandler)
self.init_session(self.protocol_key, client_address, self.factory.sessionhandler)
self.protocol_flags["ENCODING"] = settings.ENCODINGS[0] if settings.ENCODINGS else 'utf-8'
# add this new connection to sessionhandler so
# the Server becomes aware of it.
self.sessionhandler.connect(self)
# change encoding to ENCODINGS[0] which reflects Telnet default encoding
# suppress go-ahead
self.sga = suppress_ga.SuppressGA(self)
@ -66,13 +81,10 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
self.oob = telnet_oob.TelnetOOB(self)
# mxp support
self.mxp = Mxp(self)
# add this new connection to sessionhandler so
# the Server becomes aware of it.
self.sessionhandler.connect(self)
# timeout the handshakes in case the client doesn't reply at all
from evennia.utils.utils import delay
delay(2, callback=self.handshake_done, force=True)
# timeout the handshakes in case the client doesn't reply at all
delay(2, callback=self.handshake_done, timeout=True)
# TCP/IP keepalive watches for dead links
self.transport.setTcpKeepAlive(1)
@ -100,17 +112,18 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
self.nop_keep_alive = LoopingCall(self._send_nop_keepalive)
self.nop_keep_alive.start(30, now=False)
def handshake_done(self, force=False):
def handshake_done(self, timeout=False):
"""
This is called by all telnet extensions once they are finished.
When all have reported, a sync with the server is performed.
The system will force-call this sync after a small time to handle
clients that don't reply to handshakes at all.
"""
if self.handshakes > 0:
if force:
if timeout:
if self.handshakes > 0:
self.handshakes = 0
self.sessionhandler.sync(self)
return
else:
self.handshakes -= 1
if self.handshakes <= 0:
# do the sync
@ -230,10 +243,11 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
line (str): Line to send.
"""
# escape IAC in line mode, and correctly add \r\n
line = line.encode()
line += self.delimiter
line = line.replace(IAC, IAC + IAC).replace(b'\n', b'\r\n')
# escape IAC in line mode, and correctly add \r\n (the TELNET end-of-line)
line = line.replace(IAC, IAC + IAC)
line = line.replace('\n', '\r\n')
if not line.endswith("\r\n") and self.protocol_flags.get("FORCEDENDLINE", True):
line += "\r\n"
if not self.protocol_flags.get("NOGOAHEAD", True):
line += IAC + GA
return self.transport.write(mccp_compress(self, line))
@ -306,8 +320,8 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
# handle arguments
options = kwargs.get("options", {})
flags = self.protocol_flags
xterm256 = options.get("xterm256", flags.get('XTERM256', False) if flags["TTYPE"] else True)
useansi = options.get("ansi", flags.get('ANSI', False) if flags["TTYPE"] else True)
xterm256 = options.get("xterm256", flags.get('XTERM256', False) if flags.get("TTYPE", False) else True)
useansi = options.get("ansi", flags.get('ANSI', False) if flags.get("TTYPE", False) else True)
raw = options.get("raw", flags.get("RAW", False))
nocolor = options.get("nocolor", flags.get("NOCOLOR") or not (xterm256 or useansi))
echo = options.get("echo", None)

View file

@ -225,26 +225,45 @@ class TelnetOOB(object):
GMCP messages will be outgoing on the following
form (the non-JSON cmdname at the start is what
IRE games use, supposedly, and what clients appear
to have adopted):
to have adopted). A cmdname without Package will end
up in the Core package, while Core package names will
be stripped on the Evennia side.
[cmdname, [], {}] -> cmdname
[cmdname, [arg], {}] -> cmdname arg
[cmdname, [args],{}] -> cmdname [args]
[cmdname, [], {kwargs}] -> cmdname {kwargs}
[cmdname, [args, {kwargs}] -> cmdname [[args],{kwargs}]
[cmd.name, [], {}] -> Cmd.Name
[cmd.name, [arg], {}] -> Cmd.Name arg
[cmd.name, [args],{}] -> Cmd.Name [args]
[cmd.name, [], {kwargs}] -> Cmd.Name {kwargs}
[cmdname, [args, {kwargs}] -> Core.Cmdname [[args],{kwargs}]
Notes:
There are also a few default mappings between evennia outputcmds and
GMCP:
client_options -> Core.Supports.Get
get_inputfuncs -> Core.Commands.Get
get_value -> Char.Value.Get
repeat -> Char.Repeat.Update
monitor -> Char.Monitor.Update
"""
if cmdname in EVENNIA_TO_GMCP:
gmcp_cmdname = EVENNIA_TO_GMCP[cmdname]
elif "_" in cmdname:
gmcp_cmdname = ".".join(word.capitalize() for word in cmdname.split("_"))
else:
gmcp_cmdname = "Core.%s" % cmdname.capitalize()
if not (args or kwargs):
gmcp_string = cmdname
gmcp_string = gmcp_cmdname
elif args:
if len(args) == 1:
args = args[0]
if kwargs:
gmcp_string = "%s %s" % (cmdname, json.dumps([args, kwargs]))
gmcp_string = "%s %s" % (gmcp_cmdname, json.dumps([args, kwargs]))
else:
gmcp_string = "%s %s" % (cmdname, json.dumps(args))
gmcp_string = "%s %s" % (gmcp_cmdname, json.dumps(args))
else: # only kwargs
gmcp_string = "%s %s" % (cmdname, json.dumps(kwargs))
gmcp_string = "%s %s" % (gmcp_cmdname, json.dumps(kwargs))
# print("gmcp string", gmcp_string) # DEBUG
return gmcp_string.encode()
@ -401,14 +420,9 @@ class TelnetOOB(object):
kwargs.pop("options", None)
if self.MSDP:
msdp_cmdname = cmdname
encoded_oob = self.encode_msdp(msdp_cmdname, *args, **kwargs)
encoded_oob = self.encode_msdp(cmdname, *args, **kwargs)
self.protocol._write(IAC + SB + MSDP + encoded_oob + IAC + SE)
if self.GMCP:
if cmdname in EVENNIA_TO_GMCP:
gmcp_cmdname = EVENNIA_TO_GMCP[cmdname]
else:
gmcp_cmdname = "Custom.Cmd"
encoded_oob = self.encode_gmcp(gmcp_cmdname, *args, **kwargs)
encoded_oob = self.encode_gmcp(cmdname, *args, **kwargs)
self.protocol._write(IAC + SB + GMCP + encoded_oob + IAC + SE)

View file

@ -0,0 +1,146 @@
"""
This allows for running the telnet communication over an encrypted SSL tunnel. To use it, requires a
client supporting Telnet SSL.
The protocol will try to automatically create the private key and certificate on the server side
when starting and will warn if this was not possible. These will appear as files ssl.key and
ssl.cert in mygame/server/.
"""
from __future__ import print_function
import os
try:
from OpenSSL import crypto
from twisted.internet import ssl as twisted_ssl
except ImportError as error:
errstr = """
{err}
Telnet-SSL requires the PyOpenSSL library and dependencies:
pip install pyopenssl pycrypto enum pyasn1 service_identity
Stop and start Evennia again. If no certificate can be generated, you'll
get a suggestion for a (linux) command to generate this locally.
"""
raise ImportError(errstr.format(err=error))
from django.conf import settings
from evennia.server.portal.telnet import TelnetProtocol
_GAME_DIR = settings.GAME_DIR
_PRIVATE_KEY_LENGTH = 2048
_PRIVATE_KEY_FILE = os.path.join(_GAME_DIR, "server", "ssl.key")
_PUBLIC_KEY_FILE = os.path.join(_GAME_DIR, "server", "ssl-public.key")
_CERTIFICATE_FILE = os.path.join(_GAME_DIR, "server", "ssl.cert")
_CERTIFICATE_EXPIRE = 365 * 24 * 60 * 60 * 20 # 20 years
_CERTIFICATE_ISSUER = {"C": "EV", "ST": "Evennia", "L": "Evennia", "O":
"Evennia Security", "OU": "Evennia Department", "CN": "evennia"}
# messages
NO_AUTOGEN = """
Evennia could not auto-generate the SSL private- and public keys ({{err}}).
If this error persists, create them manually (using the tools for your OS). The files
should be placed and named like this:
{}
{}
""".format(_PRIVATE_KEY_FILE, _PUBLIC_KEY_FILE)
NO_AUTOCERT = """
Evennia's could not auto-generate the SSL certificate ({{err}}).
The private key already exists here:
{}
If this error persists, create the certificate manually (using the private key and
the tools for your OS). The file should be placed and named like this:
{}
""".format(_PRIVATE_KEY_FILE, _CERTIFICATE_FILE)
class SSLProtocol(TelnetProtocol):
"""
Communication is the same as telnet, except data transfer
is done with encryption set up by the portal at start time.
"""
def __init__(self, *args, **kwargs):
super(SSLProtocol, self).__init__(*args, **kwargs)
self.protocol_key = "telnet/ssl"
def verify_or_create_SSL_key_and_cert(keyfile, certfile):
"""
Verify or create new key/certificate files.
Args:
keyfile (str): Path to ssl.key file.
certfile (str): Parth to ssl.cert file.
Notes:
If files don't already exist, they are created.
"""
if not (os.path.exists(keyfile) and os.path.exists(certfile)):
# key/cert does not exist. Create.
try:
# generate the keypair
keypair = crypto.PKey()
keypair.generate_key(crypto.TYPE_RSA, _PRIVATE_KEY_LENGTH)
with open(_PRIVATE_KEY_FILE, 'wt') as pfile:
pfile.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, keypair))
print("Created SSL private key in '{}'.".format(_PRIVATE_KEY_FILE))
with open(_PUBLIC_KEY_FILE, 'wt') as pfile:
pfile.write(crypto.dump_publickey(crypto.FILETYPE_PEM, keypair))
print("Created SSL public key in '{}'.".format(_PUBLIC_KEY_FILE))
except Exception as err:
print(NO_AUTOGEN.format(err=err))
return False
else:
try:
# create certificate
cert = crypto.X509()
subj = cert.get_subject()
for key, value in _CERTIFICATE_ISSUER.items():
setattr(subj, key, value)
cert.set_issuer(subj)
cert.set_serial_number(1000)
cert.gmtime_adj_notBefore(0)
cert.gmtime_adj_notAfter(_CERTIFICATE_EXPIRE)
cert.set_pubkey(keypair)
cert.sign(keypair, 'sha1')
with open(_CERTIFICATE_FILE, 'wt') as cfile:
cfile.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert))
print("Created SSL certificate in '{}'.".format(_CERTIFICATE_FILE))
except Exception as err:
print(NO_AUTOCERT.format(err=err))
return False
return True
def getSSLContext():
"""
This is called by the portal when creating the SSL context
server-side.
Returns:
ssl_context (tuple): A key and certificate that is either
existing previously or created on the fly.
"""
if verify_or_create_SSL_key_and_cert(_PRIVATE_KEY_FILE, _CERTIFICATE_FILE):
return twisted_ssl.DefaultOpenSSLContextFactory(_PRIVATE_KEY_FILE, _CERTIFICATE_FILE)
else:
return None

View file

@ -50,6 +50,8 @@ class Ttype(object):
"""
self.ttype_step = 0
self.protocol = protocol
# we set FORCEDENDLINE for clients not supporting ttype
self.protocol.protocol_flags["FORCEDENDLINE"] = True
self.protocol.protocol_flags['TTYPE'] = False
# is it a safe bet to assume ANSI is always supported?
self.protocol.protocol_flags['ANSI'] = True
@ -66,7 +68,7 @@ class Ttype(object):
option (Option): Not used.
"""
self.protocol.protocol_flags['TTYPE'] = True
self.protocol.protocol_flags['TTYPE'] = False
self.protocol.handshake_done()
def will_ttype(self, option):
@ -107,20 +109,28 @@ class Ttype(object):
# only support after a certain version, but all support
# it since at least 4 years. We assume recent client here for now.
cupper = clientname.upper()
xterm256 = False
if cupper.startswith("MUDLET"):
# supports xterm256 stably since 1.1 (2010?)
xterm256 = cupper.split("MUDLET", 1)[1].strip() >= "1.1"
else:
xterm256 = (cupper.startswith("XTERM") or
cupper.endswith("-256COLOR") or
cupper in ("ATLANTIS", # > 0.9.9.0 (aug 2009)
"CMUD", # > 3.04 (mar 2009)
"KILDCLIENT", # > 2.2.0 (sep 2005)
"MUDLET", # > beta 15 (sep 2009)
"MUSHCLIENT", # > 4.02 (apr 2007)
"PUTTY", # > 0.58 (apr 2005)
"BEIP", # > 2.00.206 (late 2009) (BeipMu)
"POTATO")) # > 2.00 (maybe earlier)
self.protocol.protocol_flags["FORCEDENDLINE"] = False
if cupper.startswith("TINTIN++"):
self.protocol.protocol_flags["FORCEDENDLINE"] = True
if (cupper.startswith("XTERM") or
cupper.endswith("-256COLOR") or
cupper in ("ATLANTIS", # > 0.9.9.0 (aug 2009)
"CMUD", # > 3.04 (mar 2009)
"KILDCLIENT", # > 2.2.0 (sep 2005)
"MUDLET", # > beta 15 (sep 2009)
"MUSHCLIENT", # > 4.02 (apr 2007)
"PUTTY", # > 0.58 (apr 2005)
"BEIP", # > 2.00.206 (late 2009) (BeipMu)
"POTATO", # > 2.00 (maybe earlier)
"TINYFUGUE" # > 4.x (maybe earlier)
)):
xterm256 = True
# all clients supporting TTYPE at all seem to support ANSI
self.protocol.protocol_flags['ANSI'] = True

View file

@ -32,6 +32,9 @@ class WebSocketClient(WebSocketServerProtocol, Session):
"""
Implements the server-side of the Websocket connection.
"""
def __init__(self, *args, **kwargs):
super(WebSocketClient, self).__init__(*args, **kwargs)
self.protocol_key = "webclient/websocket"
def get_client_session(self):
"""

View file

@ -297,7 +297,7 @@ class AjaxWebClientSession(session.Session):
"""
def __init__(self, *args, **kwargs):
self.protocol_name = "ajax/comet"
self.protocol_key = "webclient/ajax"
super().__init__(*args, **kwargs)
def get_client_session(self):

View file

@ -106,7 +106,7 @@ ERROR_NO_MIXIN = \
error completely.
Warning: Don't run dummyrunner on a production database! It will
create a lot of spammy objects and account accounts!
create a lot of spammy objects and accounts!
"""

View file

@ -0,0 +1,93 @@
from django.test import TestCase
from mock import Mock
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)
class TestDummyrunnerSettings(TestCase):
def setUp(self):
self.client = Mock()
self.client.cid = 1
self.client.counter = Mock(return_value=1)
self.client.gid = "20171025161153-1"
self.client.name = "Dummy-%s" % self.client.gid
self.client.password = "password-%s" % self.client.gid
self.client.start_room = "testing_room_start_%s" % self.client.gid
self.client.objs = []
self.client.exits = []
def clear_client_lists(self):
self.client.objs = []
self.client.exits = []
def test_c_login(self):
self.assertEqual(c_login(self.client), ('create %s %s' % (self.client.name, self.client.password),
'connect %s %s' % (self.client.name, self.client.password),
'@dig %s' % self.client.start_room,
'@teleport %s' % self.client.start_room,
"@dig testing_room_1 = exit_1, exit_1"))
def test_c_login_no_dig(self):
self.assertEqual(c_login_nodig(self.client), ('create %s %s' % (self.client.name, self.client.password),
'connect %s %s' % (self.client.name, self.client.password)))
def test_c_logout(self):
self.assertEqual(c_logout(self.client), "@quit")
def perception_method_tests(self, func, verb, alone_suffix=""):
self.assertEqual(func(self.client), "%s%s" % (verb, alone_suffix))
self.client.exits = ["exit1", "exit2"]
self.assertEqual(func(self.client), ["%s exit1" % verb, "%s exit2" % verb])
self.client.objs = ["foo", "bar"]
self.assertEqual(func(self.client), ["%s foo" % verb, "%s bar" % verb])
self.clear_client_lists()
def test_c_looks(self):
self.perception_method_tests(c_looks, "look")
def test_c_examines(self):
self.perception_method_tests(c_examines, "examine", " me")
def test_idles(self):
self.assertEqual(c_idles(self.client), ('idle', 'idle'))
def test_c_help(self):
self.assertEqual(c_help(self.client), ('help', 'help @teleport', 'help look', 'help @tunnel', 'help @dig'))
def test_c_digs(self):
self.assertEqual(c_digs(self.client), ('@dig/tel testing_room_1 = exit_1, exit_1'))
self.assertEqual(self.client.exits, ['exit_1', 'exit_1'])
self.clear_client_lists()
def test_c_creates_obj(self):
objname = "testing_obj_1"
self.assertEqual(c_creates_obj(self.client), ('@create %s' % objname,
'@desc %s = "this is a test object' % objname,
'@set %s/testattr = this is a test attribute value.' % objname,
'@set %s/testattr2 = this is a second test attribute.' % objname))
self.assertEqual(self.client.objs, [objname])
self.clear_client_lists()
def test_c_creates_button(self):
objname = "testing_button_1"
typeclass_name = "contrib.tutorial_examples.red_button.RedButton"
self.assertEqual(c_creates_button(self.client), ('@create %s:%s' % (objname, typeclass_name),
'@desc %s = test red button!' % objname))
self.assertEqual(self.client.objs, [objname])
self.clear_client_lists()
def test_c_socialize(self):
self.assertEqual(c_socialize(self.client), ('ooc Hello!', 'ooc Testing ...', 'ooc Testing ... times 2',
'say Yo!', 'emote stands looking around.'))
def test_c_moves(self):
self.assertEqual(c_moves(self.client), "look")
self.client.exits = ["south", "north"]
self.assertEqual(c_moves(self.client), ["south", "north"])
self.clear_client_lists()
def test_c_move_n(self):
self.assertEqual(c_moves_n(self.client), "north")
def test_c_move_s(self):
self.assertEqual(c_moves_s(self.client), "south")

View file

@ -7,7 +7,6 @@ sets up all the networking features. (this is done automatically
by evennia/server/server_runner.py).
"""
from builtins import object
import time
import sys
@ -17,6 +16,7 @@ from twisted.web import static
from twisted.application import internet, service
from twisted.internet import reactor, defer
from twisted.internet.task import LoopingCall
from twisted.python.log import ILogObserver
import django
django.setup()
@ -33,6 +33,7 @@ from evennia.server.models import ServerConfig
from evennia.server import initial_setup
from evennia.utils.utils import get_evennia_version, mod_import, make_iter
from evennia.utils import logger
from evennia.comms import channelhandler
from evennia.server.sessionhandler import SESSIONS
@ -40,11 +41,6 @@ from django.utils.translation import ugettext as _
_SA = object.__setattr__
SERVER_PIDFILE = ""
if os.name == 'nt':
# For Windows we need to handle pid files manually.
SERVER_PIDFILE = os.path.join(settings.GAME_DIR, "server", 'server.pid')
# a file with a flag telling the server to restart after shutdown or not.
SERVER_RESTART = os.path.join(settings.GAME_DIR, "server", 'server.restart')
@ -53,16 +49,11 @@ SERVER_STARTSTOP_MODULE = mod_import(settings.AT_SERVER_STARTSTOP_MODULE)
# modules containing plugin services
SERVER_SERVICES_PLUGIN_MODULES = [mod_import(module) for module in make_iter(settings.SERVER_SERVICES_PLUGIN_MODULES)]
try:
WEB_PLUGINS_MODULE = mod_import(settings.WEB_PLUGINS_MODULE)
except ImportError:
WEB_PLUGINS_MODULE = None
print ("WARNING: settings.WEB_PLUGINS_MODULE not found - "
"copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf.")
#------------------------------------------------------------
# ------------------------------------------------------------
# Evennia Server settings
#------------------------------------------------------------
# ------------------------------------------------------------
SERVERNAME = settings.SERVERNAME
VERSION = get_evennia_version()
@ -83,6 +74,17 @@ IRC_ENABLED = settings.IRC_ENABLED
RSS_ENABLED = settings.RSS_ENABLED
WEBCLIENT_ENABLED = settings.WEBCLIENT_ENABLED
INFO_DICT = {"servername": SERVERNAME, "version": VERSION,
"amp": "", "errors": "", "info": "", "webserver": "", "irc_rss": ""}
try:
WEB_PLUGINS_MODULE = mod_import(settings.WEB_PLUGINS_MODULE)
except ImportError:
WEB_PLUGINS_MODULE = None
INFO_DICT["errors"] = (
"WARNING: settings.WEB_PLUGINS_MODULE not found - "
"copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf.")
# Maintenance function - this is called repeatedly by the server
@ -127,6 +129,10 @@ def _server_maintenance():
if _MAINTENANCE_COUNT % 3700 == 0:
# validate channels off-sync with scripts
evennia.CHANNEL_HANDLER.update()
if _MAINTENANCE_COUNT % (3600 * 7) == 0:
# drop database connection every 7 hrs to avoid default timeouts on MySQL
# (see https://github.com/evennia/evennia/issues/1376)
connection.close()
# handle idle timeouts
if _IDLE_TIMEOUT > 0:
@ -137,11 +143,6 @@ def _server_maintenance():
session.account.access(session.account, "noidletimeout", default=False):
SESSIONS.disconnect(session, reason=reason)
# Commenting this out, it is probably not needed
# with CONN_MAX_AGE set. Keeping it as a reminder
# if database-gone-away errors appears again /Griatch
# if _MAINTENANCE_COUNT % 18000 == 0:
# connection.close()
maintenance_task = LoopingCall(_server_maintenance)
maintenance_task.start(60, now=True) # call every minute
@ -173,15 +174,13 @@ class Evennia(object):
self.amp_protocol = None # set by amp factory
self.sessions = SESSIONS
self.sessions.server = self
self.process_id = os.getpid()
# Database-specific startup optimizations.
self.sqlite3_prep()
self.start_time = time.time()
# Run the initial setup if needed
self.run_initial_setup()
# initialize channelhandler
channelhandler.CHANNELHANDLER.update()
@ -193,18 +192,13 @@ class Evennia(object):
from twisted.internet.defer import Deferred
if hasattr(self, "web_root"):
d = self.web_root.empty_threadpool()
d.addCallback(lambda _: self.shutdown(_reactor_stopping=True))
d.addCallback(lambda _: self.shutdown("reload", _reactor_stopping=True))
else:
d = Deferred(lambda _: self.shutdown(_reactor_stopping=True))
d = Deferred(lambda _: self.shutdown("reload", _reactor_stopping=True))
d.addCallback(lambda _: reactor.stop())
reactor.callLater(1, d.callback, None)
reactor.sigInt = _wrap_sigint_handler
self.game_running = True
# track the server time
self.run_init_hooks()
# Server startup methods
def sqlite3_prep(self):
@ -212,7 +206,8 @@ class Evennia(object):
Optimize some SQLite stuff at startup since we
can't save it to the database.
"""
if ((".".join(str(i) for i in django.VERSION) < "1.2" and settings.DATABASES.get('default', {}).get('ENGINE') == "sqlite3") or
if ((".".join(str(i) for i in django.VERSION) < "1.2" and
settings.DATABASES.get('default', {}).get('ENGINE') == "sqlite3") or
(hasattr(settings, 'DATABASES') and
settings.DATABASES.get("default", {}).get('ENGINE', None) ==
'django.db.backends.sqlite3')):
@ -230,6 +225,8 @@ class Evennia(object):
typeclasses in the settings file and have them auto-update all
already existing objects.
"""
global INFO_DICT
# setting names
settings_names = ("CMDSET_CHARACTER", "CMDSET_ACCOUNT",
"BASE_ACCOUNT_TYPECLASS", "BASE_OBJECT_TYPECLASS",
@ -248,7 +245,7 @@ class Evennia(object):
#from evennia.accounts.models import AccountDB
for i, prev, curr in ((i, tup[0], tup[1]) for i, tup in enumerate(settings_compare) if i in mismatches):
# update the database
print(" %s:\n '%s' changed to '%s'. Updating unchanged entries in database ..." % (settings_names[i], prev, curr))
INFO_DICT['info'] = " %s:\n '%s' changed to '%s'. Updating unchanged entries in database ..." % (settings_names[i], prev, curr)
if i == 0:
ObjectDB.objects.filter(db_cmdset_storage__exact=prev).update(db_cmdset_storage=curr)
if i == 1:
@ -274,33 +271,37 @@ class Evennia(object):
def run_initial_setup(self):
"""
This is triggered by the amp protocol when the connection
to the portal has been established.
This attempts to run the initial_setup script of the server.
It returns if this is not the first time the server starts.
Once finished the last_initial_setup_step is set to -1.
"""
global INFO_DICT
last_initial_setup_step = ServerConfig.objects.conf('last_initial_setup_step')
if not last_initial_setup_step:
# None is only returned if the config does not exist,
# i.e. this is an empty DB that needs populating.
print(' Server started for the first time. Setting defaults.')
INFO_DICT['info'] = ' Server started for the first time. Setting defaults.'
initial_setup.handle_setup(0)
print('-' * 50)
elif int(last_initial_setup_step) >= 0:
# a positive value means the setup crashed on one of its
# modules and setup will resume from this step, retrying
# the last failed module. When all are finished, the step
# is set to -1 to show it does not need to be run again.
print(' Resuming initial setup from step %(last)s.' %
{'last': last_initial_setup_step})
INFO_DICT['info'] = ' Resuming initial setup from step {last}.'.format(
last=last_initial_setup_step)
initial_setup.handle_setup(int(last_initial_setup_step))
print('-' * 50)
def run_init_hooks(self):
def run_init_hooks(self, mode):
"""
Called every server start
Called by the amp client once receiving sync back from Portal
Args:
mode (str): One of shutdown, reload or reset
"""
from evennia.objects.models import ObjectDB
#from evennia.accounts.models import AccountDB
# update eventual changed defaults
self.update_defaults()
@ -308,47 +309,24 @@ class Evennia(object):
[o.at_init() for o in ObjectDB.get_all_cached_instances()]
[p.at_init() for p in AccountDB.get_all_cached_instances()]
mode = self.getset_restart_mode()
# call correct server hook based on start file value
if mode == 'reload':
# True was the old reload flag, kept for compatibilty
logger.log_msg("Server successfully reloaded.")
self.at_server_reload_start()
elif mode == 'reset':
# only run hook, don't purge sessions
self.at_server_cold_start()
elif mode in ('reset', 'shutdown'):
logger.log_msg("Evennia Server successfully restarted in 'reset' mode.")
elif mode == 'shutdown':
self.at_server_cold_start()
# clear eventual lingering session storages
ObjectDB.objects.clear_all_sessids()
logger.log_msg("Evennia Server successfully started.")
# always call this regardless of start type
self.at_server_start()
def getset_restart_mode(self, mode=None):
"""
This manages the flag file that tells the runner if the server is
reloading, resetting or shutting down.
Args:
mode (string or None, optional): Valid values are
'reload', 'reset', 'shutdown' and `None`. If mode is `None`,
no change will be done to the flag file.
Returns:
mode (str): The currently active restart mode, either just
set or previously set.
"""
if mode is None:
with open(SERVER_RESTART, 'r') as f:
# mode is either shutdown, reset or reload
mode = f.read()
else:
with open(SERVER_RESTART, 'w') as f:
f.write(str(mode))
return mode
@defer.inlineCallbacks
def shutdown(self, mode=None, _reactor_stopping=False):
def shutdown(self, mode='reload', _reactor_stopping=False):
"""
Shuts down the server from inside it.
@ -359,7 +337,6 @@ class Evennia(object):
at_shutdown hooks called but sessions will not
be disconnected.
'shutdown' - like reset, but server will not auto-restart.
None - keep currently set flag from flag file.
_reactor_stopping - this is set if server is stopped by a kill
command OR this method was already called
once - in both cases the reactor is
@ -370,10 +347,7 @@ class Evennia(object):
# once; we don't need to run the shutdown procedure again.
defer.returnValue(None)
mode = self.getset_restart_mode(mode)
from evennia.objects.models import ObjectDB
#from evennia.accounts.models import AccountDB
from evennia.server.models import ServerConfig
from evennia.utils import gametime as _GAMETIME_MODULE
@ -382,7 +356,8 @@ class Evennia(object):
ServerConfig.objects.conf("server_restart_mode", "reload")
yield [o.at_server_reload() for o in ObjectDB.get_all_cached_instances()]
yield [p.at_server_reload() for p in AccountDB.get_all_cached_instances()]
yield [(s.pause(manual_pause=False), s.at_server_reload()) for s in ScriptDB.get_all_cached_instances() if s.is_active]
yield [(s.pause(manual_pause=False), s.at_server_reload())
for s in ScriptDB.get_all_cached_instances() if s.is_active]
yield self.sessions.all_sessions_portal_sync()
self.at_server_reload_stop()
# only save monitor state on reload, not on shutdown/reset
@ -412,10 +387,6 @@ class Evennia(object):
# always called, also for a reload
self.at_server_stop()
if os.name == 'nt' and os.path.exists(SERVER_PIDFILE):
# for Windows we need to remove pid files manually
os.remove(SERVER_PIDFILE)
if hasattr(self, "web_root"): # not set very first start
yield self.web_root.empty_threadpool()
@ -427,6 +398,10 @@ class Evennia(object):
# we make sure the proper gametime is saved as late as possible
ServerConfig.objects.conf("runtime", _GAMETIME_MODULE.runtime())
def get_info_dict(self):
"Return the server info, for display."
return INFO_DICT
# server start/stop hooks
def at_server_start(self):
@ -452,13 +427,15 @@ class Evennia(object):
if SERVER_STARTSTOP_MODULE:
SERVER_STARTSTOP_MODULE.at_server_reload_start()
def at_post_portal_sync(self):
def at_post_portal_sync(self, mode):
"""
This is called just after the portal has finished syncing back data to the server
after reconnecting.
Args:
mode (str): One of reload, reset or shutdown.
"""
# one of reload, reset or shutdown
mode = self.getset_restart_mode()
from evennia.scripts.monitorhandler import MONITOR_HANDLER
MONITOR_HANDLER.restore(mode == 'reload')
@ -530,13 +507,16 @@ ServerConfig.objects.conf("server_starting_mode", True)
# what to execute from.
application = service.Application('Evennia')
if "--nodaemon" not in sys.argv:
# custom logging, but only if we are not running in interactive mode
logfile = logger.WeeklyLogFile(os.path.basename(settings.SERVER_LOG_FILE),
os.path.dirname(settings.SERVER_LOG_FILE))
application.setComponent(ILogObserver, logger.ServerLogObserver(logfile).emit)
# The main evennia server program. This sets up the database
# and is where we store all the other services.
EVENNIA = Evennia(application)
print('-' * 50)
print(' %(servername)s Server (%(version)s) started.' % {'servername': SERVERNAME, 'version': VERSION})
if AMP_ENABLED:
# The AMP protocol handles the communication between
@ -546,20 +526,20 @@ if AMP_ENABLED:
ifacestr = ""
if AMP_INTERFACE != '127.0.0.1':
ifacestr = "-%s" % AMP_INTERFACE
print(' amp (to Portal)%s: %s (internal)' % (ifacestr, AMP_PORT))
from evennia.server import amp
INFO_DICT["amp"] = 'amp %s: %s' % (ifacestr, AMP_PORT)
factory = amp.AmpServerFactory(EVENNIA)
amp_service = internet.TCPServer(AMP_PORT, factory, interface=AMP_INTERFACE)
amp_service.setName("EvenniaPortal")
from evennia.server import amp_client
factory = amp_client.AMPClientFactory(EVENNIA)
amp_service = internet.TCPClient(AMP_HOST, AMP_PORT, factory)
amp_service.setName('ServerAMPClient')
EVENNIA.services.addService(amp_service)
if WEBSERVER_ENABLED:
# Start a django-compatible webserver.
#from twisted.python import threadpool
from evennia.server.webserver import DjangoWebRoot, WSGIWebServer, Website, LockableThreadPool
# start a thread pool and define the root url (/) as a wsgi resource
@ -579,14 +559,16 @@ if WEBSERVER_ENABLED:
web_root = WEB_PLUGINS_MODULE.at_webserver_root_creation(web_root)
web_site = Website(web_root, logPath=settings.HTTP_LOG_FILE)
web_site.is_portal = False
INFO_DICT["webserver"] = ""
for proxyport, serverport in WEBSERVER_PORTS:
# create the webserver (we only need the port for this)
webserver = WSGIWebServer(threads, serverport, web_site, interface='127.0.0.1')
webserver.setName('EvenniaWebServer%s' % serverport)
EVENNIA.services.addService(webserver)
print(" webserver: %s (internal)" % serverport)
INFO_DICT["webserver"] += "webserver: %s" % serverport
ENABLED = []
if IRC_ENABLED:
@ -598,18 +580,11 @@ if RSS_ENABLED:
ENABLED.append('rss')
if ENABLED:
print(" " + ", ".join(ENABLED) + " enabled.")
INFO_DICT["irc_rss"] = ", ".join(ENABLED) + " enabled."
for plugin_module in SERVER_SERVICES_PLUGIN_MODULES:
# external plugin protocols
plugin_module.start_plugin_services(EVENNIA)
print('-' * 50) # end of terminal output
# clear server startup mode
ServerConfig.objects.conf("server_starting_mode", delete=True)
if os.name == 'nt':
# Windows only: Set PID file manually
with open(SERVER_PIDFILE, 'w') as f:
f.write(str(os.getpid()))

View file

@ -188,6 +188,7 @@ class ServerSession(Session):
if not _ObjectDB:
from evennia.objects.models import ObjectDB as _ObjectDB
super(ServerSession, self).at_sync()
if not self.logged_in:
# assign the unloggedin-command set.
self.cmdset_storage = settings.CMDSET_UNLOGGEDIN
@ -400,6 +401,7 @@ class ServerSession(Session):
# this can happen if this is triggered e.g. a command.msg
# that auto-adds the session, we'd get a kwarg collision.
kwargs.pop("session", None)
kwargs.pop("from_obj", None)
if text is not None:
self.data_out(text=text, **kwargs)
else:

View file

@ -7,7 +7,6 @@ from builtins import object
import time
#------------------------------------------------------------
# Server Session
#------------------------------------------------------------
@ -47,8 +46,8 @@ class Session(object):
a new session is established.
Args:
protocol_key (str): By default, one of 'telnet', 'ssh',
'ssl' or 'web'.
protocol_key (str): By default, one of 'telnet', 'telnet/ssl', 'ssh',
'webclient/websocket' or 'webclient/ajax'.
address (str): Client address.
sessionhandler (SessionHandler): Reference to the
main sessionhandler instance.
@ -118,7 +117,13 @@ class Session(object):
"""
for propname, value in sessdata.items():
setattr(self, propname, value)
if (propname == "protocol_flags" and isinstance(value, dict) and
hasattr(self, "protocol_flags") and
isinstance(self.protocol_flags, dict)):
# special handling to allow partial update of protocol flags
self.protocol_flags.update(value)
else:
setattr(self, propname, value)
def at_sync(self):
"""
@ -127,7 +132,8 @@ class Session(object):
on uid etc).
"""
self.protocol_flags.update(self.account.attributs.get("_saved_protocol_flags"), {})
if self.account:
self.protocol_flags.update(self.account.attributes.get("_saved_protocol_flags", {}))
# access hooks

View file

@ -20,9 +20,7 @@ from django.conf import settings
from evennia.commands.cmdhandler import CMD_LOGINSTART
from evennia.utils.logger import log_trace
from evennia.utils.utils import (variable_from_module, is_iter,
to_str,
make_iter,
callables_from_module)
to_str, make_iter, delay, callables_from_module)
from evennia.utils.inlinefuncs import parse_inlinefunc
from codecs import decode as codecs_decode
@ -47,8 +45,23 @@ class DummySession(object):
DUMMYSESSION = DummySession()
# AMP signals
from .amp import (PCONN, PDISCONN, PSYNC, SLOGIN, SDISCONN, SDISCONNALL,
SSHUTD, SSYNC, SCONN, PCONNSYNC, PDISCONNALL, )
PCONN = chr(1) # portal session connect
PDISCONN = chr(2) # portal session disconnect
PSYNC = chr(3) # portal session sync
SLOGIN = chr(4) # server session login
SDISCONN = chr(5) # server session disconnect
SDISCONNALL = chr(6) # server session disconnect all
SSHUTD = chr(7) # server shutdown
SSYNC = chr(8) # server session sync
SCONN = chr(11) # server portal connection (for bots)
PCONNSYNC = chr(12) # portal post-syncing session
PDISCONNALL = chr(13) # portal session discnnect all
SRELOAD = chr(14) # server reloading (have portal start a new server)
SSTART = chr(15) # server start (portal must already be running anyway)
PSHUTD = chr(16) # portal (+server) shutdown
SSHUTD = chr(17) # server shutdown
PSTATUS = chr(18) # ping server or portal status
SRESET = chr(19) # server shutdown in reset mode
# i18n
from django.utils.translation import ugettext as _
@ -56,6 +69,7 @@ from django.utils.translation import ugettext as _
_SERVERNAME = settings.SERVERNAME
_MULTISESSION_MODE = settings.MULTISESSION_MODE
_IDLE_TIMEOUT = settings.IDLE_TIMEOUT
_DELAY_CMD_LOGINSTART = settings.DELAY_CMD_LOGINSTART
_MAX_SERVER_COMMANDS_PER_SECOND = 100.0
_MAX_SESSION_COMMANDS_PER_SECOND = 5.0
_MODEL_MAP = None
@ -268,9 +282,18 @@ class ServerSessionHandler(SessionHandler):
"""
super().__init__(*args, **kwargs)
self.server = None
self.server = None # set at server initialization
self.server_data = {"servername": _SERVERNAME}
def _run_cmd_login(self, session):
"""
Launch the CMD_LOGINSTART command. This is wrapped
for delays.
"""
if not session.logged_in:
self.data_in(session, text=[[CMD_LOGINSTART], {}])
def portal_connect(self, portalsessiondata):
"""
Called by Portal when a new session has connected.
@ -306,8 +329,9 @@ class ServerSessionHandler(SessionHandler):
sess.logged_in = False
sess.uid = None
# show the first login command
self.data_in(sess, text=[[CMD_LOGINSTART], {}])
# show the first login command, may delay slightly to allow
# the handshakes to finish.
delay(_DELAY_CMD_LOGINSTART, self._run_cmd_login, sess)
def portal_session_sync(self, portalsessiondata):
"""
@ -358,8 +382,10 @@ class ServerSessionHandler(SessionHandler):
self[sessid] = sess
sess.at_sync()
mode = 'reload'
# tell the server hook we synced
self.server.at_post_portal_sync()
self.server.at_post_portal_sync(mode)
# announce the reconnection
self.announce_all(_(" ... Server restarted."))
@ -417,13 +443,28 @@ class ServerSessionHandler(SessionHandler):
self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION, operation=SCONN,
protocol_path=protocol_path, config=configdict)
def portal_restart_server(self):
"""
Called by server when reloading. We tell the portal to start a new server instance.
"""
self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION, operation=SRELOAD)
def portal_reset_server(self):
"""
Called by server when reloading. We tell the portal to start a new server instance.
"""
self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION, operation=SRESET)
def portal_shutdown(self):
"""
Called by server when shutting down the portal.
Called by server when it's time to shut down (the portal will shut us down and then shut
itself down)
"""
self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION,
operation=SSHUTD)
operation=PSHUTD)
def login(self, session, account, force=False, testmode=False):
"""
@ -535,6 +576,20 @@ class ServerSessionHandler(SessionHandler):
sessiondata=sessdata,
clean=False)
def session_portal_partial_sync(self, session_data):
"""
Call to make a partial update of the session, such as only a particular property.
Args:
session_data (dict): Store `{sessid: {property:value}, ...}` defining one or
more sessions in detail.
"""
return self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION,
operation=SSYNC,
sessiondata=session_data,
clean=False)
def disconnect_all_sessions(self, reason="You have been disconnected."):
"""
Cleanly disconnect all of the connected sessions.
@ -562,10 +617,14 @@ class ServerSessionHandler(SessionHandler):
"""
uid = curr_session.uid
# we can't compare sessions directly since this will compare addresses and
# mean connecting from the same host would not catch duplicates
sid = id(curr_session)
doublet_sessions = [sess for sess in self.values()
if sess.logged_in and
sess.uid == uid and
sess != curr_session]
id(sess) != sid]
for session in doublet_sessions:
self.disconnect(session, reason)

View file

@ -15,11 +15,6 @@ Guidelines:
used as test methods by the runner. Inside the test methods, special member
methods assert*() are used to test the behaviour.
"""
import os
import sys
import glob
try:
from django.utils.unittest import TestCase
except ImportError:
@ -29,8 +24,15 @@ try:
except ImportError:
import unittest
from evennia.server.validators import EvenniaPasswordValidator
from evennia.utils.test_resources import EvenniaTest
from django.test.runner import DiscoverRunner
from evennia.server.throttle import Throttle
from .deprecations import check_errors
class EvenniaTestSuiteRunner(DiscoverRunner):
"""
@ -46,3 +48,100 @@ class EvenniaTestSuiteRunner(DiscoverRunner):
import evennia
evennia._init()
return super().build_suite(test_labels, extra_tests=extra_tests, **kwargs)
class MockSettings(object):
"""
Class for simulating django.conf.settings. Created with a single value, and then sets the required
WEBSERVER_ENABLED setting to True or False depending if we're testing WEBSERVER_PORTS.
"""
def __init__(self, setting, value=None):
setattr(self, setting, value)
if setting == "WEBSERVER_PORTS":
self.WEBSERVER_ENABLED = True
else:
self.WEBSERVER_ENABLED = False
class TestDeprecations(TestCase):
"""
Class for testing deprecations.check_errors.
"""
deprecated_settings = (
"CMDSET_DEFAULT", "CMDSET_OOC", "BASE_COMM_TYPECLASS", "COMM_TYPECLASS_PATHS",
"CHARACTER_DEFAULT_HOME", "OBJECT_TYPECLASS_PATHS", "SCRIPT_TYPECLASS_PATHS",
"ACCOUNT_TYPECLASS_PATHS", "CHANNEL_TYPECLASS_PATHS", "SEARCH_MULTIMATCH_SEPARATOR",
"TIME_SEC_PER_MIN", "TIME_MIN_PER_HOUR", "TIME_HOUR_PER_DAY", "TIME_DAY_PER_WEEK",
"TIME_WEEK_PER_MONTH", "TIME_MONTH_PER_YEAR")
def test_check_errors(self):
"""
All settings in deprecated_settings should raise a DeprecationWarning if they exist.
WEBSERVER_PORTS raises an error if the iterable value passed does not have a tuple as its
first element.
"""
for setting in self.deprecated_settings:
self.assertRaises(DeprecationWarning, check_errors, MockSettings(setting))
# test check for WEBSERVER_PORTS having correct value
self.assertRaises(
DeprecationWarning,
check_errors, MockSettings("WEBSERVER_PORTS", value=["not a tuple"]))
class ValidatorTest(EvenniaTest):
def test_validator(self):
# Validator returns None on success and ValidationError on failure.
validator = EvenniaPasswordValidator()
# This password should meet Evennia standards.
self.assertFalse(validator.validate('testpassword', user=self.account))
# This password contains illegal characters and should raise an Exception.
from django.core.exceptions import ValidationError
self.assertRaises(ValidationError, validator.validate, '(#)[#]<>', user=self.account)
class ThrottleTest(EvenniaTest):
"""
Class for testing the connection/IP throttle.
"""
def test_throttle(self):
ips = ('94.100.176.153', '45.56.148.77', '5.196.1.129')
kwargs = {
'limit': 5,
'timeout': 15 * 60
}
throttle = Throttle(**kwargs)
for ip in ips:
# Throttle should not be engaged by default
self.assertFalse(throttle.check(ip))
# Pretend to fail a bunch of events
for x in range(50):
obj = throttle.update(ip)
self.assertFalse(obj)
# Next ones should be blocked
self.assertTrue(throttle.check(ip))
for x in range(throttle.cache_size * 2):
obj = throttle.update(ip)
self.assertFalse(obj)
# Should still be blocked
self.assertTrue(throttle.check(ip))
# Number of values should be limited by cache size
self.assertEqual(throttle.cache_size, len(throttle.get(ip)))
cache = throttle.get()
# Make sure there are entries for each IP
self.assertEqual(len(ips), len(cache.keys()))
# There should only be (cache_size * num_ips) total in the Throttle cache
self.assertEqual(sum([len(cache[x]) for x in cache.keys()]), throttle.cache_size * len(ips))
>>>>>>> develop

Some files were not shown because too many files have changed in this diff Show more