mirror of
https://github.com/evennia/evennia.git
synced 2026-03-16 21:06:30 +01:00
Merge branch 'master' of github.com:evennia/evennia
This commit is contained in:
commit
1d2982ef89
136 changed files with 22516 additions and 3989 deletions
135
CHANGELOG.md
135
CHANGELOG.md
|
|
@ -1,9 +1,124 @@
|
|||
# Evennia Changelog
|
||||
# Changelog
|
||||
|
||||
# Sept 2017:
|
||||
Release of Evennia 0.7; upgrade to Django 1.11, change 'Player' to
|
||||
## Evennia 0.8 (2018)
|
||||
|
||||
### Requirements
|
||||
|
||||
- Up requirements to Django 1.11.x, Twisted 18 and pillow 5.2.0
|
||||
- Add `inflect` dependency for automatic pluralization of object names.
|
||||
|
||||
### Server/Portal
|
||||
|
||||
- Removed `evennia_runner`, completely refactor `evennia_launcher.py` (the 'evennia' program)
|
||||
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
|
||||
|
||||
- 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 migrat is found here:
|
||||
Info on what changed and how to migrate is found here:
|
||||
https://groups.google.com/forum/#!msg/evennia/0JYYNGY-NfE/cDFaIwmPBAAJ
|
||||
|
||||
## Feb 2017:
|
||||
|
|
@ -14,9 +129,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 +148,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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
42
Dockerfile
42
Dockerfile
|
|
@ -5,36 +5,54 @@
|
|||
# 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
|
||||
# docker run -it --rm -p 4000:4000 -p 4001:4001 -p 4005:4005 -v $PWD:/usr/src/game evennia/evennia
|
||||
#
|
||||
# (If your OS does not support $PWD, replace it with the full path to your current
|
||||
# folder).
|
||||
#
|
||||
# You will end up in a shell where the `evennia` command is available. From here you
|
||||
# can install and run the game normally. Use Ctrl-D to exit the evennia docker container.
|
||||
#
|
||||
# You can also start evennia directly by passing arguments to the folder:
|
||||
#
|
||||
# docker run -it --rm -p 4000:4000 -p 4001:4001 -p 4005:4005 -v $PWD:/usr/src/game evennia/evennia evennia start -l
|
||||
#
|
||||
# This will start Evennia running as the core process of the container. Note that you *must* use -l
|
||||
# or one of the foreground modes (like evennia ipstart) since otherwise the container will immediately
|
||||
# die since no foreground process keeps it up.
|
||||
#
|
||||
# The evennia/evennia base image is found on DockerHub and can also be used
|
||||
# as a base for creating your own custom containerized Evennia game. For more
|
||||
# info, see https://github.com/evennia/evennia/wiki/Running%20Evennia%20in%20Docker .
|
||||
#
|
||||
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 --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 +66,7 @@ WORKDIR /usr/src/game
|
|||
ENV PS1 "evennia|docker \w $ "
|
||||
|
||||
# startup a shell when we start the container
|
||||
ENTRYPOINT ["bash"]
|
||||
ENTRYPOINT ["/usr/src/evennia/bin/unix/evennia-docker-start.sh"]
|
||||
|
||||
# expose the telnet, webserver and websocket client ports
|
||||
EXPOSE 4000 4001 4005
|
||||
|
|
|
|||
18
bin/unix/evennia-docker-start.sh
Executable file
18
bin/unix/evennia-docker-start.sh
Executable file
|
|
@ -0,0 +1,18 @@
|
|||
#! /bin/sh
|
||||
|
||||
# called by the Dockerfile to start the server in docker mode
|
||||
|
||||
# remove leftover .pid files (such as from when dropping the container)
|
||||
rm /usr/src/game/server/*.pid >& /dev/null || true
|
||||
|
||||
PS1="evennia|docker \w $ "
|
||||
|
||||
cmd="$@"
|
||||
output="Docker starting with argument '$cmd' ..."
|
||||
if test -z $cmd; then
|
||||
cmd="bash"
|
||||
output="No argument given, starting shell ..."
|
||||
fi
|
||||
|
||||
echo $output
|
||||
exec 3>&1; $cmd
|
||||
34
bin/unix/evennia.service
Normal file
34
bin/unix/evennia.service
Normal 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
|
||||
|
|
@ -1 +1 @@
|
|||
0.7.0
|
||||
0.8.0
|
||||
|
|
|
|||
|
|
@ -174,7 +174,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
|
||||
|
|
@ -319,3 +319,59 @@ def _init():
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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):
|
||||
"""
|
||||
|
|
@ -633,10 +694,31 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
|
|||
# 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)
|
||||
|
|
@ -694,6 +776,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
|
||||
|
|
@ -773,11 +866,11 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
|
|||
elif _MULTISESSION_MODE in (2, 3):
|
||||
# In this mode we by default end up at a character selection
|
||||
# screen. We execute look on the account.
|
||||
# we make sure to clean up the _playable_characers list in case
|
||||
# we make sure to clean up the _playable_characters list in case
|
||||
# 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):
|
||||
"""
|
||||
|
|
@ -908,7 +1001,10 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
|
|||
|
||||
if target and not is_iter(target):
|
||||
# single target - just show it
|
||||
return target.return_appearance(self)
|
||||
if hasattr(target, "return_appearance"):
|
||||
return target.return_appearance(self)
|
||||
else:
|
||||
return "{} has no in-game appearance.".format(target)
|
||||
else:
|
||||
# list of targets - make list to disconnect from db
|
||||
characters = list(tar for tar in target if tar) if target else []
|
||||
|
|
@ -929,7 +1025,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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
from mock import Mock
|
||||
from mock import Mock, MagicMock
|
||||
from random import randint
|
||||
from unittest import TestCase
|
||||
|
||||
from django.test import override_settings
|
||||
from evennia.accounts.accounts import AccountSessionHandler
|
||||
from evennia.accounts.accounts import DefaultAccount
|
||||
from evennia.server.session import Session
|
||||
|
|
@ -14,9 +15,15 @@ class TestAccountSessionHandler(TestCase):
|
|||
"Check AccountSessionHandler class"
|
||||
|
||||
def setUp(self):
|
||||
self.account = create.create_account("TestAccount%s" % randint(0, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount)
|
||||
self.account = create.create_account(
|
||||
"TestAccount%s" % randint(0, 999999), email="test@test.com",
|
||||
password="testpassword", typeclass=DefaultAccount)
|
||||
self.handler = AccountSessionHandler(self.account)
|
||||
|
||||
def tearDown(self):
|
||||
if hasattr(self, 'account'):
|
||||
self.account.delete()
|
||||
|
||||
def test_get(self):
|
||||
"Check get method"
|
||||
self.assertEqual(self.handler.get(), [])
|
||||
|
|
@ -24,24 +31,24 @@ class TestAccountSessionHandler(TestCase):
|
|||
|
||||
import evennia.server.sessionhandler
|
||||
|
||||
s1 = Session()
|
||||
s1 = MagicMock()
|
||||
s1.logged_in = True
|
||||
s1.uid = self.account.uid
|
||||
evennia.server.sessionhandler.SESSIONS[s1.uid] = s1
|
||||
|
||||
s2 = Session()
|
||||
s2 = MagicMock()
|
||||
s2.logged_in = True
|
||||
s2.uid = self.account.uid + 1
|
||||
evennia.server.sessionhandler.SESSIONS[s2.uid] = s2
|
||||
|
||||
s3 = Session()
|
||||
s3 = MagicMock()
|
||||
s3.logged_in = False
|
||||
s3.uid = self.account.uid + 2
|
||||
evennia.server.sessionhandler.SESSIONS[s3.uid] = s3
|
||||
|
||||
self.assertEqual(self.handler.get(), [s1])
|
||||
self.assertEqual(self.handler.get(self.account.uid), [s1])
|
||||
self.assertEqual(self.handler.get(self.account.uid + 1), [])
|
||||
self.assertEqual([s.uid for s in self.handler.get()], [s1.uid])
|
||||
self.assertEqual([s.uid for s in [self.handler.get(self.account.uid)]], [s1.uid])
|
||||
self.assertEqual([s.uid for s in self.handler.get(self.account.uid + 1)], [])
|
||||
|
||||
def test_all(self):
|
||||
"Check all method"
|
||||
|
|
@ -51,12 +58,44 @@ class TestAccountSessionHandler(TestCase):
|
|||
"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 = MagicMock()
|
||||
self.s1.puppet = None
|
||||
self.s1.sessid = 0
|
||||
self.s1.data_outj
|
||||
|
||||
def tearDown(self):
|
||||
if hasattr(self, "account"):
|
||||
self.account.delete()
|
||||
|
||||
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])
|
||||
|
||||
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"
|
||||
|
|
@ -65,7 +104,7 @@ class TestDefaultAccount(TestCase):
|
|||
DefaultAccount().puppet_object(self.s1, None)
|
||||
self.fail("Expected error: 'Object not found'")
|
||||
except RuntimeError as re:
|
||||
self.assertEqual("Object not found", re.message)
|
||||
self.assertEqual("Object not found", str(re))
|
||||
|
||||
def test_puppet_object_no_session(self):
|
||||
"Check puppet_object method called with no session param"
|
||||
|
|
@ -74,14 +113,16 @@ class TestDefaultAccount(TestCase):
|
|||
DefaultAccount().puppet_object(None, Mock())
|
||||
self.fail("Expected error: 'Session not found'")
|
||||
except RuntimeError as re:
|
||||
self.assertEqual("Session not found", re.message)
|
||||
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)
|
||||
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
|
||||
|
||||
|
|
@ -103,10 +144,7 @@ class TestDefaultAccount(TestCase):
|
|||
self.s1.uid = account.uid
|
||||
evennia.server.sessionhandler.SESSIONS[self.s1.uid] = self.s1
|
||||
|
||||
self.s1.puppet = None
|
||||
self.s1.logged_in = True
|
||||
self.s1.data_out = Mock(return_value=None)
|
||||
|
||||
self.s1.data_out = MagicMock()
|
||||
obj = Mock()
|
||||
obj.access = Mock(return_value=False)
|
||||
|
||||
|
|
@ -115,6 +153,7 @@ class TestDefaultAccount(TestCase):
|
|||
self.assertTrue(self.s1.data_out.call_args[1]['text'].startswith("You don't have permission to puppet"))
|
||||
self.assertIsNone(obj.at_post_puppet.call_args)
|
||||
|
||||
@override_settings(MULTISESSION_MODE=0)
|
||||
def test_puppet_object_joining_other_session(self):
|
||||
"Check puppet_object method called, joining other session"
|
||||
|
||||
|
|
@ -126,15 +165,16 @@ class TestDefaultAccount(TestCase):
|
|||
|
||||
self.s1.puppet = None
|
||||
self.s1.logged_in = True
|
||||
self.s1.data_out = Mock(return_value=None)
|
||||
self.s1.data_out = MagicMock()
|
||||
|
||||
obj = Mock()
|
||||
obj.access = Mock(return_value=True)
|
||||
obj.account = account
|
||||
obj.sessions.all = MagicMock(return_value=[self.s1])
|
||||
|
||||
account.puppet_object(self.s1, obj)
|
||||
# works because django.conf.settings.MULTISESSION_MODE is not in (1, 3)
|
||||
self.assertTrue(self.s1.data_out.call_args[1]['text'].endswith("from another of your sessions."))
|
||||
self.assertTrue(self.s1.data_out.call_args[1]['text'].endswith("from another of your sessions.|n"))
|
||||
self.assertTrue(obj.at_post_puppet.call_args[1] == {})
|
||||
|
||||
def test_puppet_object_already_puppeted(self):
|
||||
|
|
@ -143,6 +183,7 @@ class TestDefaultAccount(TestCase):
|
|||
import evennia.server.sessionhandler
|
||||
|
||||
account = create.create_account("TestAccount%s" % randint(0, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount)
|
||||
self.account = account
|
||||
self.s1.uid = account.uid
|
||||
evennia.server.sessionhandler.SESSIONS[self.s1.uid] = self.s1
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -297,7 +297,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.
|
||||
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -136,7 +136,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
|
||||
|
|
@ -168,7 +168,7 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS):
|
|||
if desc:
|
||||
new_character.db.desc = desc
|
||||
elif not new_character.db.desc:
|
||||
new_character.db.desc = "This is an Account."
|
||||
new_character.db.desc = "This is a character."
|
||||
self.msg("Created new character %s. Use |w@ic %s|n to enter the game as this character."
|
||||
% (new_character.key, new_character.key))
|
||||
|
||||
|
|
@ -455,7 +455,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.
|
||||
|
||||
|
|
@ -467,6 +467,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS):
|
|||
"""
|
||||
key = "@option"
|
||||
aliases = "@options"
|
||||
switch_options = ("save", "clear")
|
||||
locks = "cmd:all()"
|
||||
|
||||
# this is used by the parent
|
||||
|
|
@ -626,10 +627,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()
|
||||
|
|
@ -650,6 +657,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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
@ -589,12 +612,12 @@ class CmdDesc(COMMAND_DEFAULT_CLASS):
|
|||
self.edit_handler()
|
||||
return
|
||||
|
||||
if self.rhs:
|
||||
if '=' in self.args:
|
||||
# We have an =
|
||||
obj = caller.search(self.lhs)
|
||||
if not obj:
|
||||
return
|
||||
desc = self.rhs
|
||||
desc = self.rhs or ''
|
||||
else:
|
||||
obj = caller.location or self.msg("|rYou can't describe oblivion.|n")
|
||||
if not obj:
|
||||
|
|
@ -614,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
|
||||
|
|
@ -631,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"
|
||||
|
||||
|
|
@ -754,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"
|
||||
|
||||
|
|
@ -863,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)
|
||||
|
||||
|
||||
|
|
@ -896,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"
|
||||
|
||||
|
|
@ -1429,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)
|
||||
|
|
@ -1458,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
|
||||
|
|
@ -1558,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."""
|
||||
|
||||
|
|
@ -1571,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
|
||||
|
||||
|
|
@ -1637,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
|
||||
|
||||
|
|
@ -1671,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"
|
||||
|
||||
|
|
@ -1679,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:
|
||||
|
|
@ -1695,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
|
||||
|
|
@ -2114,12 +2227,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)
|
||||
|
|
@ -2202,12 +2318,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
|
||||
|
|
@ -2218,6 +2337,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"
|
||||
|
||||
|
|
@ -2230,6 +2350,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:
|
||||
|
|
@ -2251,7 +2374,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:
|
||||
|
||||
|
|
@ -2279,6 +2402,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
|
||||
|
|
@ -2286,10 +2411,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()
|
||||
|
|
@ -2314,6 +2443,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
|
||||
|
|
@ -2327,7 +2458,7 @@ 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
|
||||
|
|
@ -2351,6 +2482,8 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS):
|
|||
"""
|
||||
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"
|
||||
|
||||
|
|
@ -2458,6 +2591,7 @@ class CmdScript(COMMAND_DEFAULT_CLASS):
|
|||
|
||||
key = "@script"
|
||||
aliases = "@addscript"
|
||||
switch_options = ("start", "stop")
|
||||
locks = "cmd:perm(script) or perm(Builder)"
|
||||
help_category = "Building"
|
||||
|
||||
|
|
@ -2557,6 +2691,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|$"
|
||||
|
|
@ -2654,100 +2789,318 @@ 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", "examine", "save", "delete", "menu", "olc", "update", "edit")
|
||||
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 sensitive): %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:
|
||||
# we homogenize first, to be more lenient
|
||||
protlib.validate_prototype(protlib.homogenize_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 or 'edit' in self.switches:
|
||||
# OLC menu mode
|
||||
prototype = None
|
||||
if self.lhs:
|
||||
key = self.lhs
|
||||
prototype = protlib.search_prototype(key=key)
|
||||
if len(prototype) > 1:
|
||||
caller.msg("More than one match for {}:\n{}".format(
|
||||
key, "\n".join(proto.get('prototype_key', '') for proto in prototype)))
|
||||
return
|
||||
elif prototype:
|
||||
# one match
|
||||
prototype = prototype[0]
|
||||
else:
|
||||
# no match
|
||||
caller.msg("No prototype '{}' was found.".format(key))
|
||||
return
|
||||
olc_menus.start_olc(caller, session=self.session, prototype=prototype)
|
||||
return
|
||||
|
||||
if isinstance(prototype, basestring):
|
||||
# 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, unicode(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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ make sure to homogenize self.caller to always be the account object
|
|||
for easy handling.
|
||||
|
||||
"""
|
||||
import hashlib
|
||||
import time
|
||||
from past.builtins import cmp
|
||||
from django.conf import settings
|
||||
from evennia.comms.models import ChannelDB, Msg
|
||||
|
|
@ -377,7 +379,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.
|
||||
|
|
@ -385,6 +387,7 @@ class CmdCBoot(COMMAND_DEFAULT_CLASS):
|
|||
"""
|
||||
|
||||
key = "@cboot"
|
||||
switch_options = ("quiet",)
|
||||
locks = "cmd: not pperm(channel_banned)"
|
||||
help_category = "Comms"
|
||||
|
||||
|
|
@ -453,6 +456,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"
|
||||
|
||||
|
|
@ -683,6 +687,7 @@ class CmdPage(COMMAND_DEFAULT_CLASS):
|
|||
|
||||
key = "page"
|
||||
aliases = ['tell']
|
||||
switch_options = ("last", "list")
|
||||
locks = "cmd:not pperm(page_banned)"
|
||||
help_category = "Comms"
|
||||
|
||||
|
|
@ -850,6 +855,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"
|
||||
|
||||
|
|
@ -914,8 +920,9 @@ class CmdIRC2Chan(COMMAND_DEFAULT_CLASS):
|
|||
self.msg("Account '%s' already exists and is not a bot." % botname)
|
||||
return
|
||||
else:
|
||||
password = hashlib.md5(str(time.time())).hexdigest()[:11]
|
||||
try:
|
||||
bot = create.create_account(botname, None, None, typeclass=botclass)
|
||||
bot = create.create_account(botname, None, password, typeclass=botclass)
|
||||
except Exception as err:
|
||||
self.msg("|rError, could not create the bot:|n '%s'." % err)
|
||||
return
|
||||
|
|
@ -1016,6 +1023,7 @@ class CmdRSS2Chan(COMMAND_DEFAULT_CLASS):
|
|||
"""
|
||||
|
||||
key = "@rss2chan"
|
||||
switch_options = ("disconnect", "remove", "list")
|
||||
locks = "cmd:serversetting(RSS_ENABLED) and pperm(Developer)"
|
||||
help_category = "Comms"
|
||||
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ 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):
|
||||
|
|
@ -88,8 +88,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
|
|||
Switches:
|
||||
inputline - replace on the inputline (default)
|
||||
object - replace on object-lookup
|
||||
account - replace on account-lookup
|
||||
|
||||
account - replace on account-lookup
|
||||
list - show all defined aliases (also "nicks" works)
|
||||
delete - remove nick by index in /list
|
||||
clearall - clear all nicks
|
||||
|
|
@ -118,7 +117,8 @@ 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):
|
||||
|
|
@ -143,7 +143,6 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
|
|||
return re.sub(r"(\$[0-9]+|\*|\?|\[.+?\])", r"|Y\1|n", string)
|
||||
|
||||
caller = self.caller
|
||||
account = self.caller.account or caller
|
||||
switches = self.switches
|
||||
nicktypes = [switch for switch in switches if switch in ("object", "account", "inputline")]
|
||||
specified_nicktype = bool(nicktypes)
|
||||
|
|
@ -151,7 +150,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
|
|||
|
||||
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(account.nicks.get(category="account", 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"):
|
||||
|
||||
|
|
@ -174,29 +173,77 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
|
|||
|
||||
if 'delete' in switches or 'del' in switches:
|
||||
if not self.args or not self.lhs:
|
||||
caller.msg("usage nick/delete #num ('nicks' for list)")
|
||||
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):
|
||||
oldnick = nicklist[delindex - 1]
|
||||
_, _, old_nickstring, old_replstring = oldnick.value
|
||||
oldnicks.append(nicklist[delindex - 1])
|
||||
else:
|
||||
caller.msg("Not a valid nick index. See 'nicks' for a list.")
|
||||
return
|
||||
nicktype = oldnick.category
|
||||
nicktypestr = "%s-nick" % nicktype.capitalize()
|
||||
else:
|
||||
if not specified_nicktype:
|
||||
nicktypes = ("object", "account", "inputline")
|
||||
for nicktype in nicktypes:
|
||||
oldnicks.append(caller.nicks.get(arg, category=nicktype, return_obj=True))
|
||||
|
||||
if nicktype == "account":
|
||||
account.nicks.remove(old_nickstring, category=nicktype)
|
||||
else:
|
||||
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))
|
||||
return
|
||||
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
|
||||
|
|
@ -237,16 +284,11 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
|
|||
errstring = ""
|
||||
string = ""
|
||||
for nicktype in nicktypes:
|
||||
if nicktype == "account":
|
||||
obj = account
|
||||
else:
|
||||
obj = caller
|
||||
|
||||
nicktypestr = "%s-nick" % nicktype.capitalize()
|
||||
old_nickstring = None
|
||||
old_replstring = None
|
||||
|
||||
oldnick = obj.nicks.get(key=nickstring, category=nicktype, return_obj=True)
|
||||
oldnick = caller.nicks.get(key=nickstring, category=nicktype, return_obj=True)
|
||||
if oldnick:
|
||||
_, _, old_nickstring, old_replstring = oldnick.value
|
||||
if replstring:
|
||||
|
|
@ -261,7 +303,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
|
|||
else:
|
||||
string += "\n%s '|w%s|n' mapped to '|w%s|n'." % (nicktypestr, nickstring, replstring)
|
||||
try:
|
||||
obj.nicks.add(nickstring, replstring, category=nicktype)
|
||||
caller.nicks.add(nickstring, replstring, category=nicktype)
|
||||
except NickTemplateInvalid:
|
||||
caller.msg("You must use the same $-markers both in the nick and in the replacement.")
|
||||
return
|
||||
|
|
@ -337,13 +379,17 @@ class CmdGet(COMMAND_DEFAULT_CLASS):
|
|||
caller.msg("You can't get that.")
|
||||
return
|
||||
|
||||
# calling at_before_get hook method
|
||||
if not obj.at_before_get(caller):
|
||||
return
|
||||
|
||||
obj.move_to(caller, quiet=True)
|
||||
caller.msg("You pick up %s." % obj.name)
|
||||
caller.location.msg_contents("%s picks up %s." %
|
||||
(caller.name,
|
||||
obj.name),
|
||||
exclude=caller)
|
||||
# calling hook method
|
||||
# calling at_get hook method
|
||||
obj.at_get(caller)
|
||||
|
||||
|
||||
|
|
@ -378,6 +424,10 @@ class CmdDrop(COMMAND_DEFAULT_CLASS):
|
|||
if not obj:
|
||||
return
|
||||
|
||||
# Call the object script's at_before_drop() method.
|
||||
if not obj.at_before_drop(caller):
|
||||
return
|
||||
|
||||
obj.move_to(caller.location, quiet=True)
|
||||
caller.msg("You drop %s." % (obj.name,))
|
||||
caller.location.msg_contents("%s drops %s." %
|
||||
|
|
@ -392,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|$"
|
||||
|
||||
|
|
@ -420,6 +471,11 @@ class CmdGive(COMMAND_DEFAULT_CLASS):
|
|||
if not to_give.location == caller:
|
||||
caller.msg("You are not holding %s." % to_give.key)
|
||||
return
|
||||
|
||||
# calling at_before_give hook method
|
||||
if not to_give.at_before_give(caller, target):
|
||||
return
|
||||
|
||||
# give object
|
||||
caller.msg("You give %s to %s." % (to_give.key, target.key))
|
||||
to_give.move_to(target, quiet=True)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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(MuxAccountCommand, self).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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -22,16 +22,18 @@ 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, unloggedin
|
||||
from evennia.commands.default.muxcommand import MuxCommand
|
||||
from evennia.commands.command import Command, InterruptCommand
|
||||
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)
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
|
|
@ -44,7 +46,7 @@ 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):
|
||||
receiver=None, cmdstring=None, obj=None, inputs=None):
|
||||
"""
|
||||
Test a command by assigning all the needed
|
||||
properties to cmdobj and running
|
||||
|
|
@ -73,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):
|
||||
ret.next()
|
||||
while True:
|
||||
try:
|
||||
inp = inputs.pop() if inputs else None
|
||||
if inp:
|
||||
try:
|
||||
ret.send(inp)
|
||||
except TypeError:
|
||||
ret.next()
|
||||
ret = ret.send(inp)
|
||||
else:
|
||||
ret.next()
|
||||
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
|
||||
|
||||
|
|
@ -127,18 +149,48 @@ class TestGeneral(CommandTest):
|
|||
self.call(general.CmdPose(), "looks around", "Char looks around")
|
||||
|
||||
def test_nick(self):
|
||||
self.call(general.CmdNick(), "testalias = testaliasedstring1", "Inputlinenick 'testalias' mapped to 'testaliasedstring1'.")
|
||||
self.call(general.CmdNick(), "/account testalias = testaliasedstring2", "Accountnick 'testalias' mapped to 'testaliasedstring2'.")
|
||||
self.call(general.CmdNick(), "/object testalias = testaliasedstring3", "Objectnick 'testalias' mapped to 'testaliasedstring3'.")
|
||||
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(None, self.char1.nicks.get("testalias", category="account"))
|
||||
self.assertEqual(u"testaliasedstring2", self.char1.account.nicks.get("testalias", category="account"))
|
||||
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\"")
|
||||
|
||||
|
|
@ -165,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")
|
||||
|
|
@ -189,14 +241,14 @@ class TestAdmin(CommandTest):
|
|||
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)
|
||||
|
||||
|
|
@ -226,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)
|
||||
|
|
@ -235,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):
|
||||
|
|
@ -259,6 +315,24 @@ class TestBuilding(CommandTest):
|
|||
def test_desc(self):
|
||||
self.call(building.CmdDesc(), "Obj2=TestDesc", "The description was set on Obj2(#5).")
|
||||
|
||||
def test_empty_desc(self):
|
||||
"""
|
||||
empty desc sets desc as ''
|
||||
"""
|
||||
o2d = self.obj2.db.desc
|
||||
r1d = self.room1.db.desc
|
||||
self.call(building.CmdDesc(), "Obj2=", "The description was set on Obj2(#5).")
|
||||
assert self.obj2.db.desc == '' and self.obj2.db.desc != o2d
|
||||
assert self.room1.db.desc == r1d
|
||||
|
||||
def test_desc_default_to_room(self):
|
||||
"""no rhs changes room's desc"""
|
||||
o2d = self.obj2.db.desc
|
||||
r1d = self.room1.db.desc
|
||||
self.call(building.CmdDesc(), "Obj2", "The description was set on Room(#1).")
|
||||
assert self.obj2.db.desc == o2d
|
||||
assert self.room1.db.desc == 'Obj2' and self.room1.db.desc != r1d
|
||||
|
||||
def test_wipe(self):
|
||||
confirm = building.CmdDestroy.confirm
|
||||
building.CmdDestroy.confirm = False
|
||||
|
|
@ -276,7 +350,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):
|
||||
|
|
@ -287,19 +361,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):
|
||||
|
|
@ -307,6 +398,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
|
||||
|
|
@ -315,17 +407,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
|
||||
|
|
@ -334,84 +429,166 @@ 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 ")
|
||||
|
||||
# @spawn/edit (missing prototype)
|
||||
# brings up olc menu
|
||||
msg = self.call(
|
||||
building.CmdSpawn(),
|
||||
'/edit')
|
||||
assert 'Prototype wizard' in msg
|
||||
|
||||
# @spawn/edit with valid prototype
|
||||
# brings up olc menu loaded with prototype
|
||||
msg = self.call(
|
||||
building.CmdSpawn(),
|
||||
'/edit testball')
|
||||
assert 'Prototype wizard' in msg
|
||||
assert hasattr(self.char1.ndb._menutree, "olc_prototype")
|
||||
assert dict == type(self.char1.ndb._menutree.olc_prototype) \
|
||||
and 'prototype_key' in self.char1.ndb._menutree.olc_prototype \
|
||||
and 'key' in self.char1.ndb._menutree.olc_prototype \
|
||||
and 'testball' == self.char1.ndb._menutree.olc_prototype['prototype_key'] \
|
||||
and 'Ball' == self.char1.ndb._menutree.olc_prototype['key']
|
||||
assert 'Ball' in msg and 'testball' in msg
|
||||
|
||||
# @spawn/edit with valid prototype (synomym)
|
||||
msg = self.call(
|
||||
building.CmdSpawn(),
|
||||
'/edit BALL')
|
||||
assert 'Prototype wizard' in msg
|
||||
assert 'Ball' in msg and 'testball' in msg
|
||||
|
||||
# @spawn/edit with invalid prototype
|
||||
msg = self.call(
|
||||
building.CmdSpawn(),
|
||||
'/edit NO_EXISTS',
|
||||
"No prototype 'NO_EXISTS' was found.")
|
||||
|
||||
# @spawn/examine (missing prototype)
|
||||
# lists all prototypes that exist
|
||||
msg = self.call(
|
||||
building.CmdSpawn(),
|
||||
'/examine')
|
||||
assert 'testball' in msg and 'testprot' in msg
|
||||
|
||||
# @spawn/examine with valid prototype
|
||||
# prints the prototype
|
||||
msg = self.call(
|
||||
building.CmdSpawn(),
|
||||
'/examine BALL')
|
||||
assert 'Ball' in msg and 'testball' in msg
|
||||
|
||||
# @spawn/examine with invalid prototype
|
||||
# shows error
|
||||
self.call(
|
||||
building.CmdSpawn(),
|
||||
'/examine NO_EXISTS',
|
||||
"No prototype 'NO_EXISTS' was found.")
|
||||
|
||||
|
||||
class TestComms(CommandTest):
|
||||
|
||||
def setUp(self):
|
||||
super(CommandTest, self).setUp()
|
||||
self.call(comms.CmdChannelCreate(), "testchan;test=Test Channel", "Created channel testchan and connected to it.", receiver=self.account)
|
||||
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
|
||||
|
|
@ -442,4 +619,4 @@ class TestUnconnectedCommand(CommandTest):
|
|||
settings.SERVERNAME,
|
||||
datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(),
|
||||
SESSIONS.account_count(), utils.get_evennia_version())
|
||||
self.call(unloggedin.CmdUnconnectedInfo(), "", expected)
|
||||
self.call(unloggedin.CmdUnconnectedInfo(), "", expected)
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ Commands that are available from the connect screen.
|
|||
import re
|
||||
import time
|
||||
import datetime
|
||||
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
|
||||
|
||||
|
|
@ -26,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):
|
||||
|
|
@ -149,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
|
||||
|
||||
|
|
@ -161,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:
|
||||
|
|
@ -171,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." \
|
||||
|
|
@ -211,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
|
||||
|
|
@ -234,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:
|
||||
|
|
@ -263,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:
|
||||
|
|
@ -294,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 characers. 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
|
||||
|
||||
|
|
@ -322,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:
|
||||
|
|
@ -577,7 +553,7 @@ def _create_character(session, new_account, typeclass, home, permissions):
|
|||
|
||||
# If no description is set, set a default description
|
||||
if not new_character.db.desc:
|
||||
new_character.db.desc = "This is an Account."
|
||||
new_character.db.desc = "This is a character."
|
||||
# We need to set this to have @ic auto-connect to this character
|
||||
new_account.db._last_puppet = new_character
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
24
evennia/comms/migrations/0016_auto_20180925_1735.py
Normal file
24
evennia/comms/migrations/0016_auto_20180925_1735.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -16,10 +16,12 @@ 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 (BattleJenkins 2017) - A layered clothing system with
|
||||
* Clothing (FlutterSprite 2017) - A layered clothing system with
|
||||
slots for different types of garments auto-showing in description.
|
||||
* Color-markups (Griatch, 2017) - Alternative in-game color markups.
|
||||
* Custom gametime (Griatch, vlgeoff 2017) - Implements Evennia's
|
||||
|
|
@ -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,13 +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.
|
||||
* Turnbattle (BattleJenkins 2017) - A turn-based combat engine meant
|
||||
as a start to build from. Has attack/disengage and turn timeouts.
|
||||
* 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.
|
||||
|
|
@ -59,9 +70,12 @@ 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
|
||||
as a start to build from. Has attack/disengage and turn timeouts,
|
||||
and includes optional expansions for equipment and combat movement.
|
||||
* Tutorial examples (Griatch 2011, 2015) - A folder of basic
|
||||
example objects, commands and scripts.
|
||||
* Tutorial world (Griatch 2011, 2015) - A folder containing the
|
||||
|
|
|
|||
1147
evennia/contrib/building_menu.py
Normal file
1147
evennia/contrib/building_menu.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -197,7 +197,7 @@ class CmdUnconnectedCreate(MuxCommand):
|
|||
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 characers. Letters, spaces, digits and @/./+/-/_/' only." \
|
||||
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."
|
||||
session.msg(string)
|
||||
|
|
|
|||
|
|
@ -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(ExtendedRoom, self).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."""
|
||||
|
|
|
|||
667
evennia/contrib/fieldfill.py
Normal file
667
evennia/contrib/fieldfill.py
Normal 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
|
||||
103
evennia/contrib/health_bar.py
Normal file
103
evennia/contrib/health_bar.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
5
evennia/contrib/security/README.md
Normal file
5
evennia/contrib/security/README.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Security
|
||||
|
||||
This directory contains security-related contribs
|
||||
|
||||
- Auditing (Johnny 2018) - Allow for optional security logging of user input/output.
|
||||
0
evennia/contrib/security/__init__.py
Normal file
0
evennia/contrib/security/__init__.py
Normal file
72
evennia/contrib/security/auditing/README.md
Normal file
72
evennia/contrib/security/auditing/README.md
Normal 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 = []
|
||||
0
evennia/contrib/security/auditing/__init__.py
Normal file
0
evennia/contrib/security/auditing/__init__.py
Normal file
60
evennia/contrib/security/auditing/outputs.py
Normal file
60
evennia/contrib/security/auditing/outputs.py
Normal 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))
|
||||
241
evennia/contrib/security/auditing/server.py
Normal file
241
evennia/contrib/security/auditing/server.py
Normal 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)
|
||||
95
evennia/contrib/security/auditing/tests.py
Normal file
95
evennia/contrib/security/auditing/tests.py
Normal 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)
|
||||
|
|
@ -671,6 +671,15 @@ class TestGenderSub(CommandTest):
|
|||
txt = "Test |p gender"
|
||||
self.assertEqual(gendersub._RE_GENDER_PRONOUN.sub(char._get_pronoun, txt), "Test their gender")
|
||||
|
||||
# test health bar contrib
|
||||
|
||||
from evennia.contrib import health_bar
|
||||
|
||||
class TestHealthBar(EvenniaTest):
|
||||
def test_healthbar(self):
|
||||
expected_bar_str = "|[G|w |n|[B|w test24 / 200test |n"
|
||||
self.assertTrue(health_bar.display_meter(24, 200, length=40, pre_text="test", post_text="test", align="center") == expected_bar_str)
|
||||
|
||||
# test mail contrib
|
||||
|
||||
|
||||
|
|
@ -688,7 +697,7 @@ class TestMail(CommandTest):
|
|||
"You have received a new @mail from TestAccount2(account 2)|You sent your message.", caller=self.account2)
|
||||
self.call(mail.CmdMail(), "TestAccount=Message 1", "You sent your message.", caller=self.account2)
|
||||
self.call(mail.CmdMail(), "TestAccount=Message 2", "You sent your message.", caller=self.account2)
|
||||
self.call(mail.CmdMail(), "", "| ID: From: Subject:", caller=self.account)
|
||||
self.call(mail.CmdMail(), "", "| ID: From: Subject:", caller=self.account)
|
||||
self.call(mail.CmdMail(), "2", "From: TestAccount2", caller=self.account)
|
||||
self.call(mail.CmdMail(), "/forward TestAccount2 = 1/Forward message", "You sent your message.|Message forwarded.", caller=self.account)
|
||||
self.call(mail.CmdMail(), "/reply 2=Reply Message2", "You sent your message.", caller=self.account)
|
||||
|
|
@ -714,9 +723,9 @@ class TestMapBuilder(CommandTest):
|
|||
"evennia.contrib.mapbuilder.EXAMPLE2_MAP evennia.contrib.mapbuilder.EXAMPLE2_LEGEND",
|
||||
"""Creating Map...|≈ ≈ ≈ ≈ ≈
|
||||
|
||||
≈ ♣♣♣ ≈
|
||||
≈ ♣-♣-♣ ≈
|
||||
≈ ♣ ♣ ♣ ≈
|
||||
≈ ♣♣♣ ≈
|
||||
≈ ♣-♣-♣ ≈
|
||||
|
||||
≈ ≈ ≈ ≈ ≈
|
||||
|Creating Landmass...|""")
|
||||
|
|
@ -759,8 +768,8 @@ from evennia.contrib import simpledoor
|
|||
class TestSimpleDoor(CommandTest):
|
||||
def test_cmdopen(self):
|
||||
self.call(simpledoor.CmdOpen(), "newdoor;door:contrib.simpledoor.SimpleDoor,backdoor;door = Room2",
|
||||
"Created new Exit 'newdoor' from Room to Room2 (aliases: door).|Note: A doortype exit was "
|
||||
"created ignored eventual custom returnexit type.|Created new Exit 'newdoor' from Room2 to Room (aliases: door).")
|
||||
"Created new Exit 'newdoor' from Room to Room2 (aliases: door).|Note: A door-type exit was "
|
||||
"created - ignored eventual custom return-exit type.|Created new Exit 'newdoor' from Room2 to Room (aliases: door).")
|
||||
self.call(simpledoor.CmdOpenCloseDoor(), "newdoor", "You close newdoor.", cmdstring="close")
|
||||
self.call(simpledoor.CmdOpenCloseDoor(), "newdoor", "newdoor is already closed.", cmdstring="close")
|
||||
self.call(simpledoor.CmdOpenCloseDoor(), "newdoor", "You open newdoor.", cmdstring="open")
|
||||
|
|
@ -789,7 +798,7 @@ from evennia.contrib import talking_npc
|
|||
class TestTalkingNPC(CommandTest):
|
||||
def test_talkingnpc(self):
|
||||
npc = create_object(talking_npc.TalkingNPC, key="npctalker", location=self.room1)
|
||||
self.call(talking_npc.CmdTalk(), "", "(You walk up and talk to Char.)|")
|
||||
self.call(talking_npc.CmdTalk(), "", "(You walk up and talk to Char.)")
|
||||
npc.delete()
|
||||
|
||||
|
||||
|
|
@ -944,101 +953,637 @@ class TestTutorialWorldRooms(CommandTest):
|
|||
|
||||
|
||||
# test turnbattle
|
||||
from evennia.contrib import turnbattle
|
||||
from evennia.contrib.turnbattle import tb_basic, tb_equip, tb_range, tb_items, tb_magic
|
||||
from evennia.objects.objects import DefaultRoom
|
||||
|
||||
|
||||
class TestTurnBattleCmd(CommandTest):
|
||||
class TestTurnBattleBasicCmd(CommandTest):
|
||||
|
||||
# Test combat commands
|
||||
# Test basic combat commands
|
||||
def test_turnbattlecmd(self):
|
||||
self.call(turnbattle.CmdFight(), "", "You can't start a fight if you've been defeated!")
|
||||
self.call(turnbattle.CmdAttack(), "", "You can only do that in combat. (see: help fight)")
|
||||
self.call(turnbattle.CmdPass(), "", "You can only do that in combat. (see: help fight)")
|
||||
self.call(turnbattle.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
|
||||
self.call(turnbattle.CmdRest(), "", "Char rests to recover HP.")
|
||||
self.call(tb_basic.CmdFight(), "", "You can't start a fight if you've been defeated!")
|
||||
self.call(tb_basic.CmdAttack(), "", "You can only do that in combat. (see: help fight)")
|
||||
self.call(tb_basic.CmdPass(), "", "You can only do that in combat. (see: help fight)")
|
||||
self.call(tb_basic.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
|
||||
self.call(tb_basic.CmdRest(), "", "Char rests to recover HP.")
|
||||
|
||||
|
||||
class TestTurnBattleFunc(EvenniaTest):
|
||||
class TestTurnBattleEquipCmd(CommandTest):
|
||||
|
||||
def setUp(self):
|
||||
super(TestTurnBattleEquipCmd, self).setUp()
|
||||
self.testweapon = create_object(tb_equip.TBEWeapon, key="test weapon")
|
||||
self.testarmor = create_object(tb_equip.TBEArmor, key="test armor")
|
||||
self.testweapon.move_to(self.char1)
|
||||
self.testarmor.move_to(self.char1)
|
||||
|
||||
# Test equipment commands
|
||||
def test_turnbattleequipcmd(self):
|
||||
# Start with equip module specific commands.
|
||||
self.call(tb_equip.CmdWield(), "weapon", "Char wields test weapon.")
|
||||
self.call(tb_equip.CmdUnwield(), "", "Char lowers test weapon.")
|
||||
self.call(tb_equip.CmdDon(), "armor", "Char dons test armor.")
|
||||
self.call(tb_equip.CmdDoff(), "", "Char removes test armor.")
|
||||
# Also test the commands that are the same in the basic module
|
||||
self.call(tb_equip.CmdFight(), "", "You can't start a fight if you've been defeated!")
|
||||
self.call(tb_equip.CmdAttack(), "", "You can only do that in combat. (see: help fight)")
|
||||
self.call(tb_equip.CmdPass(), "", "You can only do that in combat. (see: help fight)")
|
||||
self.call(tb_equip.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
|
||||
self.call(tb_equip.CmdRest(), "", "Char rests to recover HP.")
|
||||
|
||||
|
||||
class TestTurnBattleRangeCmd(CommandTest):
|
||||
# Test range commands
|
||||
def test_turnbattlerangecmd(self):
|
||||
# Start with range module specific commands.
|
||||
self.call(tb_range.CmdShoot(), "", "You can only do that in combat. (see: help fight)")
|
||||
self.call(tb_range.CmdApproach(), "", "You can only do that in combat. (see: help fight)")
|
||||
self.call(tb_range.CmdWithdraw(), "", "You can only do that in combat. (see: help fight)")
|
||||
self.call(tb_range.CmdStatus(), "", "HP Remaining: 100 / 100")
|
||||
# Also test the commands that are the same in the basic module
|
||||
self.call(tb_range.CmdFight(), "", "There's nobody here to fight!")
|
||||
self.call(tb_range.CmdAttack(), "", "You can only do that in combat. (see: help fight)")
|
||||
self.call(tb_range.CmdPass(), "", "You can only do that in combat. (see: help fight)")
|
||||
self.call(tb_range.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
|
||||
self.call(tb_range.CmdRest(), "", "Char rests to recover HP.")
|
||||
|
||||
|
||||
class TestTurnBattleItemsCmd(CommandTest):
|
||||
|
||||
def setUp(self):
|
||||
super(TestTurnBattleItemsCmd, self).setUp()
|
||||
self.testitem = create_object(key="test item")
|
||||
self.testitem.move_to(self.char1)
|
||||
|
||||
# Test item commands
|
||||
def test_turnbattleitemcmd(self):
|
||||
self.call(tb_items.CmdUse(), "item", "'Test item' is not a usable item.")
|
||||
# Also test the commands that are the same in the basic module
|
||||
self.call(tb_items.CmdFight(), "", "You can't start a fight if you've been defeated!")
|
||||
self.call(tb_items.CmdAttack(), "", "You can only do that in combat. (see: help fight)")
|
||||
self.call(tb_items.CmdPass(), "", "You can only do that in combat. (see: help fight)")
|
||||
self.call(tb_items.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
|
||||
self.call(tb_items.CmdRest(), "", "Char rests to recover HP.")
|
||||
|
||||
|
||||
class TestTurnBattleMagicCmd(CommandTest):
|
||||
|
||||
# Test magic commands
|
||||
def test_turnbattlemagiccmd(self):
|
||||
self.call(tb_magic.CmdStatus(), "", "You have 100 / 100 HP and 20 / 20 MP.")
|
||||
self.call(tb_magic.CmdLearnSpell(), "test spell", "There is no spell with that name.")
|
||||
self.call(tb_magic.CmdCast(), "", "Usage: cast <spell name> = <target>, <target2>")
|
||||
# Also test the commands that are the same in the basic module
|
||||
self.call(tb_magic.CmdFight(), "", "There's nobody here to fight!")
|
||||
self.call(tb_magic.CmdAttack(), "", "You can only do that in combat. (see: help fight)")
|
||||
self.call(tb_magic.CmdPass(), "", "You can only do that in combat. (see: help fight)")
|
||||
self.call(tb_magic.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
|
||||
self.call(tb_magic.CmdRest(), "", "Char rests to recover HP and MP.")
|
||||
|
||||
|
||||
class TestTurnBattleBasicFunc(EvenniaTest):
|
||||
|
||||
def setUp(self):
|
||||
super(TestTurnBattleBasicFunc, self).setUp()
|
||||
self.testroom = create_object(DefaultRoom, key="Test Room")
|
||||
self.attacker = create_object(tb_basic.TBBasicCharacter, key="Attacker", location=self.testroom)
|
||||
self.defender = create_object(tb_basic.TBBasicCharacter, key="Defender", location=self.testroom)
|
||||
self.joiner = create_object(tb_basic.TBBasicCharacter, key="Joiner", location=None)
|
||||
|
||||
def tearDown(self):
|
||||
super(TestTurnBattleBasicFunc, self).tearDown()
|
||||
self.attacker.delete()
|
||||
self.defender.delete()
|
||||
self.joiner.delete()
|
||||
self.testroom.delete()
|
||||
self.turnhandler.stop()
|
||||
|
||||
# Test combat functions
|
||||
def test_turnbattlefunc(self):
|
||||
attacker = create_object(turnbattle.BattleCharacter, key="Attacker")
|
||||
defender = create_object(turnbattle.BattleCharacter, key="Defender")
|
||||
testroom = create_object(DefaultRoom, key="Test Room")
|
||||
attacker.location = testroom
|
||||
defender.loaction = testroom
|
||||
def test_tbbasicfunc(self):
|
||||
# Initiative roll
|
||||
initiative = turnbattle.roll_init(attacker)
|
||||
initiative = tb_basic.roll_init(self.attacker)
|
||||
self.assertTrue(initiative >= 0 and initiative <= 1000)
|
||||
# Attack roll
|
||||
attack_roll = turnbattle.get_attack(attacker, defender)
|
||||
attack_roll = tb_basic.get_attack(self.attacker, self.defender)
|
||||
self.assertTrue(attack_roll >= 0 and attack_roll <= 100)
|
||||
# Defense roll
|
||||
defense_roll = turnbattle.get_defense(attacker, defender)
|
||||
defense_roll = tb_basic.get_defense(self.attacker, self.defender)
|
||||
self.assertTrue(defense_roll == 50)
|
||||
# Damage roll
|
||||
damage_roll = turnbattle.get_damage(attacker, defender)
|
||||
damage_roll = tb_basic.get_damage(self.attacker, self.defender)
|
||||
self.assertTrue(damage_roll >= 15 and damage_roll <= 25)
|
||||
# Apply damage
|
||||
defender.db.hp = 10
|
||||
turnbattle.apply_damage(defender, 3)
|
||||
self.assertTrue(defender.db.hp == 7)
|
||||
self.defender.db.hp = 10
|
||||
tb_basic.apply_damage(self.defender, 3)
|
||||
self.assertTrue(self.defender.db.hp == 7)
|
||||
# Resolve attack
|
||||
defender.db.hp = 40
|
||||
turnbattle.resolve_attack(attacker, defender, attack_value=20, defense_value=10)
|
||||
self.assertTrue(defender.db.hp < 40)
|
||||
self.defender.db.hp = 40
|
||||
tb_basic.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10)
|
||||
self.assertTrue(self.defender.db.hp < 40)
|
||||
# Combat cleanup
|
||||
attacker.db.Combat_attribute = True
|
||||
turnbattle.combat_cleanup(attacker)
|
||||
self.assertFalse(attacker.db.combat_attribute)
|
||||
self.attacker.db.Combat_attribute = True
|
||||
tb_basic.combat_cleanup(self.attacker)
|
||||
self.assertFalse(self.attacker.db.combat_attribute)
|
||||
# Is in combat
|
||||
self.assertFalse(turnbattle.is_in_combat(attacker))
|
||||
self.assertFalse(tb_basic.is_in_combat(self.attacker))
|
||||
# Set up turn handler script for further tests
|
||||
attacker.location.scripts.add(turnbattle.TurnHandler)
|
||||
turnhandler = attacker.db.combat_TurnHandler
|
||||
self.assertTrue(attacker.db.combat_TurnHandler)
|
||||
self.attacker.location.scripts.add(tb_basic.TBBasicTurnHandler)
|
||||
self.turnhandler = self.attacker.db.combat_TurnHandler
|
||||
self.assertTrue(self.attacker.db.combat_TurnHandler)
|
||||
# Set the turn handler's interval very high to keep it from repeating during tests.
|
||||
self.turnhandler.interval = 10000
|
||||
# Force turn order
|
||||
turnhandler.db.fighters = [attacker, defender]
|
||||
turnhandler.db.turn = 0
|
||||
self.turnhandler.db.fighters = [self.attacker, self.defender]
|
||||
self.turnhandler.db.turn = 0
|
||||
# Test is turn
|
||||
self.assertTrue(turnbattle.is_turn(attacker))
|
||||
self.assertTrue(tb_basic.is_turn(self.attacker))
|
||||
# Spend actions
|
||||
attacker.db.Combat_ActionsLeft = 1
|
||||
turnbattle.spend_action(attacker, 1, action_name="Test")
|
||||
self.assertTrue(attacker.db.Combat_ActionsLeft == 0)
|
||||
self.assertTrue(attacker.db.Combat_LastAction == "Test")
|
||||
self.attacker.db.Combat_ActionsLeft = 1
|
||||
tb_basic.spend_action(self.attacker, 1, action_name="Test")
|
||||
self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
|
||||
self.assertTrue(self.attacker.db.Combat_LastAction == "Test")
|
||||
# Initialize for combat
|
||||
attacker.db.Combat_ActionsLeft = 983
|
||||
turnhandler.initialize_for_combat(attacker)
|
||||
self.assertTrue(attacker.db.Combat_ActionsLeft == 0)
|
||||
self.assertTrue(attacker.db.Combat_LastAction == "null")
|
||||
self.attacker.db.Combat_ActionsLeft = 983
|
||||
self.turnhandler.initialize_for_combat(self.attacker)
|
||||
self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
|
||||
self.assertTrue(self.attacker.db.Combat_LastAction == "null")
|
||||
# Start turn
|
||||
defender.db.Combat_ActionsLeft = 0
|
||||
turnhandler.start_turn(defender)
|
||||
self.assertTrue(defender.db.Combat_ActionsLeft == 1)
|
||||
self.defender.db.Combat_ActionsLeft = 0
|
||||
self.turnhandler.start_turn(self.defender)
|
||||
self.assertTrue(self.defender.db.Combat_ActionsLeft == 1)
|
||||
# Next turn
|
||||
turnhandler.db.fighters = [attacker, defender]
|
||||
turnhandler.db.turn = 0
|
||||
turnhandler.next_turn()
|
||||
self.assertTrue(turnhandler.db.turn == 1)
|
||||
self.turnhandler.db.fighters = [self.attacker, self.defender]
|
||||
self.turnhandler.db.turn = 0
|
||||
self.turnhandler.next_turn()
|
||||
self.assertTrue(self.turnhandler.db.turn == 1)
|
||||
# Turn end check
|
||||
turnhandler.db.fighters = [attacker, defender]
|
||||
turnhandler.db.turn = 0
|
||||
attacker.db.Combat_ActionsLeft = 0
|
||||
turnhandler.turn_end_check(attacker)
|
||||
self.assertTrue(turnhandler.db.turn == 1)
|
||||
self.turnhandler.db.fighters = [self.attacker, self.defender]
|
||||
self.turnhandler.db.turn = 0
|
||||
self.attacker.db.Combat_ActionsLeft = 0
|
||||
self.turnhandler.turn_end_check(self.attacker)
|
||||
self.assertTrue(self.turnhandler.db.turn == 1)
|
||||
# Join fight
|
||||
joiner = create_object(turnbattle.BattleCharacter, key="Joiner")
|
||||
turnhandler.db.fighters = [attacker, defender]
|
||||
turnhandler.db.turn = 0
|
||||
turnhandler.join_fight(joiner)
|
||||
self.assertTrue(turnhandler.db.turn == 1)
|
||||
self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender])
|
||||
# Remove the script at the end
|
||||
turnhandler.stop()
|
||||
self.joiner.location = self.testroom
|
||||
self.turnhandler.db.fighters = [self.attacker, self.defender]
|
||||
self.turnhandler.db.turn = 0
|
||||
self.turnhandler.join_fight(self.joiner)
|
||||
self.assertTrue(self.turnhandler.db.turn == 1)
|
||||
self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender])
|
||||
|
||||
|
||||
class TestTurnBattleEquipFunc(EvenniaTest):
|
||||
|
||||
def setUp(self):
|
||||
super(TestTurnBattleEquipFunc, self).setUp()
|
||||
self.testroom = create_object(DefaultRoom, key="Test Room")
|
||||
self.attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker", location=self.testroom)
|
||||
self.defender = create_object(tb_equip.TBEquipCharacter, key="Defender", location=self.testroom)
|
||||
self.joiner = create_object(tb_equip.TBEquipCharacter, key="Joiner", location=None)
|
||||
|
||||
def tearDown(self):
|
||||
super(TestTurnBattleEquipFunc, self).tearDown()
|
||||
self.attacker.delete()
|
||||
self.defender.delete()
|
||||
self.joiner.delete()
|
||||
self.testroom.delete()
|
||||
self.turnhandler.stop()
|
||||
|
||||
# Test the combat functions in tb_equip too. They work mostly the same.
|
||||
def test_tbequipfunc(self):
|
||||
# Initiative roll
|
||||
initiative = tb_equip.roll_init(self.attacker)
|
||||
self.assertTrue(initiative >= 0 and initiative <= 1000)
|
||||
# Attack roll
|
||||
attack_roll = tb_equip.get_attack(self.attacker, self.defender)
|
||||
self.assertTrue(attack_roll >= -50 and attack_roll <= 150)
|
||||
# Defense roll
|
||||
defense_roll = tb_equip.get_defense(self.attacker, self.defender)
|
||||
self.assertTrue(defense_roll == 50)
|
||||
# Damage roll
|
||||
damage_roll = tb_equip.get_damage(self.attacker, self.defender)
|
||||
self.assertTrue(damage_roll >= 0 and damage_roll <= 50)
|
||||
# Apply damage
|
||||
self.defender.db.hp = 10
|
||||
tb_equip.apply_damage(self.defender, 3)
|
||||
self.assertTrue(self.defender.db.hp == 7)
|
||||
# Resolve attack
|
||||
self.defender.db.hp = 40
|
||||
tb_equip.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10)
|
||||
self.assertTrue(self.defender.db.hp < 40)
|
||||
# Combat cleanup
|
||||
self.attacker.db.Combat_attribute = True
|
||||
tb_equip.combat_cleanup(self.attacker)
|
||||
self.assertFalse(self.attacker.db.combat_attribute)
|
||||
# Is in combat
|
||||
self.assertFalse(tb_equip.is_in_combat(self.attacker))
|
||||
# Set up turn handler script for further tests
|
||||
self.attacker.location.scripts.add(tb_equip.TBEquipTurnHandler)
|
||||
self.turnhandler = self.attacker.db.combat_TurnHandler
|
||||
self.assertTrue(self.attacker.db.combat_TurnHandler)
|
||||
# Set the turn handler's interval very high to keep it from repeating during tests.
|
||||
self.turnhandler.interval = 10000
|
||||
# Force turn order
|
||||
self.turnhandler.db.fighters = [self.attacker, self.defender]
|
||||
self.turnhandler.db.turn = 0
|
||||
# Test is turn
|
||||
self.assertTrue(tb_equip.is_turn(self.attacker))
|
||||
# Spend actions
|
||||
self.attacker.db.Combat_ActionsLeft = 1
|
||||
tb_equip.spend_action(self.attacker, 1, action_name="Test")
|
||||
self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
|
||||
self.assertTrue(self.attacker.db.Combat_LastAction == "Test")
|
||||
# Initialize for combat
|
||||
self.attacker.db.Combat_ActionsLeft = 983
|
||||
self.turnhandler.initialize_for_combat(self.attacker)
|
||||
self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
|
||||
self.assertTrue(self.attacker.db.Combat_LastAction == "null")
|
||||
# Start turn
|
||||
self.defender.db.Combat_ActionsLeft = 0
|
||||
self.turnhandler.start_turn(self.defender)
|
||||
self.assertTrue(self.defender.db.Combat_ActionsLeft == 1)
|
||||
# Next turn
|
||||
self.turnhandler.db.fighters = [self.attacker, self.defender]
|
||||
self.turnhandler.db.turn = 0
|
||||
self.turnhandler.next_turn()
|
||||
self.assertTrue(self.turnhandler.db.turn == 1)
|
||||
# Turn end check
|
||||
self.turnhandler.db.fighters = [self.attacker, self.defender]
|
||||
self.turnhandler.db.turn = 0
|
||||
self.attacker.db.Combat_ActionsLeft = 0
|
||||
self.turnhandler.turn_end_check(self.attacker)
|
||||
self.assertTrue(self.turnhandler.db.turn == 1)
|
||||
# Join fight
|
||||
self.turnhandler.db.fighters = [self.attacker, self.defender]
|
||||
self.turnhandler.db.turn = 0
|
||||
self.turnhandler.join_fight(self.joiner)
|
||||
self.assertTrue(self.turnhandler.db.turn == 1)
|
||||
self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender])
|
||||
|
||||
|
||||
class TestTurnBattleRangeFunc(EvenniaTest):
|
||||
|
||||
def setUp(self):
|
||||
super(TestTurnBattleRangeFunc, self).setUp()
|
||||
self.testroom = create_object(DefaultRoom, key="Test Room")
|
||||
self.attacker = create_object(tb_range.TBRangeCharacter, key="Attacker", location=self.testroom)
|
||||
self.defender = create_object(tb_range.TBRangeCharacter, key="Defender", location=self.testroom)
|
||||
self.joiner = create_object(tb_range.TBRangeCharacter, key="Joiner", location=self.testroom)
|
||||
|
||||
def tearDown(self):
|
||||
super(TestTurnBattleRangeFunc, self).tearDown()
|
||||
self.attacker.delete()
|
||||
self.defender.delete()
|
||||
self.joiner.delete()
|
||||
self.testroom.delete()
|
||||
self.turnhandler.stop()
|
||||
|
||||
# Test combat functions in tb_range too.
|
||||
def test_tbrangefunc(self):
|
||||
# Initiative roll
|
||||
initiative = tb_range.roll_init(self.attacker)
|
||||
self.assertTrue(initiative >= 0 and initiative <= 1000)
|
||||
# Attack roll
|
||||
attack_roll = tb_range.get_attack(self.attacker, self.defender, "test")
|
||||
self.assertTrue(attack_roll >= 0 and attack_roll <= 100)
|
||||
# Defense roll
|
||||
defense_roll = tb_range.get_defense(self.attacker, self.defender, "test")
|
||||
self.assertTrue(defense_roll == 50)
|
||||
# Damage roll
|
||||
damage_roll = tb_range.get_damage(self.attacker, self.defender)
|
||||
self.assertTrue(damage_roll >= 15 and damage_roll <= 25)
|
||||
# Apply damage
|
||||
self.defender.db.hp = 10
|
||||
tb_range.apply_damage(self.defender, 3)
|
||||
self.assertTrue(self.defender.db.hp == 7)
|
||||
# Resolve attack
|
||||
self.defender.db.hp = 40
|
||||
tb_range.resolve_attack(self.attacker, self.defender, "test", attack_value=20, defense_value=10)
|
||||
self.assertTrue(self.defender.db.hp < 40)
|
||||
# Combat cleanup
|
||||
self.attacker.db.Combat_attribute = True
|
||||
tb_range.combat_cleanup(self.attacker)
|
||||
self.assertFalse(self.attacker.db.combat_attribute)
|
||||
# Is in combat
|
||||
self.assertFalse(tb_range.is_in_combat(self.attacker))
|
||||
# Set up turn handler script for further tests
|
||||
self.attacker.location.scripts.add(tb_range.TBRangeTurnHandler)
|
||||
self.turnhandler = self.attacker.db.combat_TurnHandler
|
||||
self.assertTrue(self.attacker.db.combat_TurnHandler)
|
||||
# Set the turn handler's interval very high to keep it from repeating during tests.
|
||||
self.turnhandler.interval = 10000
|
||||
# Force turn order
|
||||
self.turnhandler.db.fighters = [self.attacker, self.defender]
|
||||
self.turnhandler.db.turn = 0
|
||||
# Test is turn
|
||||
self.assertTrue(tb_range.is_turn(self.attacker))
|
||||
# Spend actions
|
||||
self.attacker.db.Combat_ActionsLeft = 1
|
||||
tb_range.spend_action(self.attacker, 1, action_name="Test")
|
||||
self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
|
||||
self.assertTrue(self.attacker.db.Combat_LastAction == "Test")
|
||||
# Initialize for combat
|
||||
self.attacker.db.Combat_ActionsLeft = 983
|
||||
self.turnhandler.initialize_for_combat(self.attacker)
|
||||
self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
|
||||
self.assertTrue(self.attacker.db.Combat_LastAction == "null")
|
||||
# Set up ranges again, since initialize_for_combat clears them
|
||||
self.attacker.db.combat_range = {}
|
||||
self.attacker.db.combat_range[self.attacker] = 0
|
||||
self.attacker.db.combat_range[self.defender] = 1
|
||||
self.defender.db.combat_range = {}
|
||||
self.defender.db.combat_range[self.defender] = 0
|
||||
self.defender.db.combat_range[self.attacker] = 1
|
||||
# Start turn
|
||||
self.defender.db.Combat_ActionsLeft = 0
|
||||
self.turnhandler.start_turn(self.defender)
|
||||
self.assertTrue(self.defender.db.Combat_ActionsLeft == 2)
|
||||
# Next turn
|
||||
self.turnhandler.db.fighters = [self.attacker, self.defender]
|
||||
self.turnhandler.db.turn = 0
|
||||
self.turnhandler.next_turn()
|
||||
self.assertTrue(self.turnhandler.db.turn == 1)
|
||||
# Turn end check
|
||||
self.turnhandler.db.fighters = [self.attacker, self.defender]
|
||||
self.turnhandler.db.turn = 0
|
||||
self.attacker.db.Combat_ActionsLeft = 0
|
||||
self.turnhandler.turn_end_check(self.attacker)
|
||||
self.assertTrue(self.turnhandler.db.turn == 1)
|
||||
# Join fight
|
||||
self.turnhandler.db.fighters = [self.attacker, self.defender]
|
||||
self.turnhandler.db.turn = 0
|
||||
self.turnhandler.join_fight(self.joiner)
|
||||
self.assertTrue(self.turnhandler.db.turn == 1)
|
||||
self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender])
|
||||
# Now, test for approach/withdraw functions
|
||||
self.assertTrue(tb_range.get_range(self.attacker, self.defender) == 1)
|
||||
# Approach
|
||||
tb_range.approach(self.attacker, self.defender)
|
||||
self.assertTrue(tb_range.get_range(self.attacker, self.defender) == 0)
|
||||
# Withdraw
|
||||
tb_range.withdraw(self.attacker, self.defender)
|
||||
self.assertTrue(tb_range.get_range(self.attacker, self.defender) == 1)
|
||||
|
||||
|
||||
class TestTurnBattleItemsFunc(EvenniaTest):
|
||||
|
||||
@patch("evennia.contrib.turnbattle.tb_items.tickerhandler", new=MagicMock())
|
||||
def setUp(self):
|
||||
super(TestTurnBattleItemsFunc, self).setUp()
|
||||
self.testroom = create_object(DefaultRoom, key="Test Room")
|
||||
self.attacker = create_object(tb_items.TBItemsCharacter, key="Attacker", location=self.testroom)
|
||||
self.defender = create_object(tb_items.TBItemsCharacter, key="Defender", location=self.testroom)
|
||||
self.joiner = create_object(tb_items.TBItemsCharacter, key="Joiner", location=self.testroom)
|
||||
self.user = create_object(tb_items.TBItemsCharacter, key="User", location=self.testroom)
|
||||
self.test_healpotion = create_object(key="healing potion")
|
||||
self.test_healpotion.db.item_func = "heal"
|
||||
self.test_healpotion.db.item_uses = 3
|
||||
|
||||
def tearDown(self):
|
||||
super(TestTurnBattleItemsFunc, self).tearDown()
|
||||
self.attacker.delete()
|
||||
self.defender.delete()
|
||||
self.joiner.delete()
|
||||
self.user.delete()
|
||||
self.testroom.delete()
|
||||
self.turnhandler.stop()
|
||||
|
||||
# Test functions in tb_items.
|
||||
def test_tbitemsfunc(self):
|
||||
# Initiative roll
|
||||
initiative = tb_items.roll_init(self.attacker)
|
||||
self.assertTrue(initiative >= 0 and initiative <= 1000)
|
||||
# Attack roll
|
||||
attack_roll = tb_items.get_attack(self.attacker, self.defender)
|
||||
self.assertTrue(attack_roll >= 0 and attack_roll <= 100)
|
||||
# Defense roll
|
||||
defense_roll = tb_items.get_defense(self.attacker, self.defender)
|
||||
self.assertTrue(defense_roll == 50)
|
||||
# Damage roll
|
||||
damage_roll = tb_items.get_damage(self.attacker, self.defender)
|
||||
self.assertTrue(damage_roll >= 15 and damage_roll <= 25)
|
||||
# Apply damage
|
||||
self.defender.db.hp = 10
|
||||
tb_items.apply_damage(self.defender, 3)
|
||||
self.assertTrue(self.defender.db.hp == 7)
|
||||
# Resolve attack
|
||||
self.defender.db.hp = 40
|
||||
tb_items.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10)
|
||||
self.assertTrue(self.defender.db.hp < 40)
|
||||
# Combat cleanup
|
||||
self.attacker.db.Combat_attribute = True
|
||||
tb_items.combat_cleanup(self.attacker)
|
||||
self.assertFalse(self.attacker.db.combat_attribute)
|
||||
# Is in combat
|
||||
self.assertFalse(tb_items.is_in_combat(self.attacker))
|
||||
# Set up turn handler script for further tests
|
||||
self.attacker.location.scripts.add(tb_items.TBItemsTurnHandler)
|
||||
self.turnhandler = self.attacker.db.combat_TurnHandler
|
||||
self.assertTrue(self.attacker.db.combat_TurnHandler)
|
||||
# Set the turn handler's interval very high to keep it from repeating during tests.
|
||||
self.turnhandler.interval = 10000
|
||||
# Force turn order
|
||||
self.turnhandler.db.fighters = [self.attacker, self.defender]
|
||||
self.turnhandler.db.turn = 0
|
||||
# Test is turn
|
||||
self.assertTrue(tb_items.is_turn(self.attacker))
|
||||
# Spend actions
|
||||
self.attacker.db.Combat_ActionsLeft = 1
|
||||
tb_items.spend_action(self.attacker, 1, action_name="Test")
|
||||
self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
|
||||
self.assertTrue(self.attacker.db.Combat_LastAction == "Test")
|
||||
# Initialize for combat
|
||||
self.attacker.db.Combat_ActionsLeft = 983
|
||||
self.turnhandler.initialize_for_combat(self.attacker)
|
||||
self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
|
||||
self.assertTrue(self.attacker.db.Combat_LastAction == "null")
|
||||
# Start turn
|
||||
self.defender.db.Combat_ActionsLeft = 0
|
||||
self.turnhandler.start_turn(self.defender)
|
||||
self.assertTrue(self.defender.db.Combat_ActionsLeft == 1)
|
||||
# Next turn
|
||||
self.turnhandler.db.fighters = [self.attacker, self.defender]
|
||||
self.turnhandler.db.turn = 0
|
||||
self.turnhandler.next_turn()
|
||||
self.assertTrue(self.turnhandler.db.turn == 1)
|
||||
# Turn end check
|
||||
self.turnhandler.db.fighters = [self.attacker, self.defender]
|
||||
self.turnhandler.db.turn = 0
|
||||
self.attacker.db.Combat_ActionsLeft = 0
|
||||
self.turnhandler.turn_end_check(self.attacker)
|
||||
self.assertTrue(self.turnhandler.db.turn == 1)
|
||||
# Join fight
|
||||
self.turnhandler.db.fighters = [self.attacker, self.defender]
|
||||
self.turnhandler.db.turn = 0
|
||||
self.turnhandler.join_fight(self.joiner)
|
||||
self.assertTrue(self.turnhandler.db.turn == 1)
|
||||
self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender])
|
||||
# Now time to test item stuff.
|
||||
# Spend item use
|
||||
tb_items.spend_item_use(self.test_healpotion, self.user)
|
||||
self.assertTrue(self.test_healpotion.db.item_uses == 2)
|
||||
# Use item
|
||||
self.user.db.hp = 2
|
||||
tb_items.use_item(self.user, self.test_healpotion, self.user)
|
||||
self.assertTrue(self.user.db.hp > 2)
|
||||
# Add contition
|
||||
tb_items.add_condition(self.user, self.user, "Test", 5)
|
||||
self.assertTrue(self.user.db.conditions == {"Test":[5, self.user]})
|
||||
# Condition tickdown
|
||||
tb_items.condition_tickdown(self.user, self.user)
|
||||
self.assertTrue(self.user.db.conditions == {"Test":[4, self.user]})
|
||||
# Test item functions now!
|
||||
# Item heal
|
||||
self.user.db.hp = 2
|
||||
tb_items.itemfunc_heal(self.test_healpotion, self.user, self.user)
|
||||
# Item add condition
|
||||
self.user.db.conditions = {}
|
||||
tb_items.itemfunc_add_condition(self.test_healpotion, self.user, self.user)
|
||||
self.assertTrue(self.user.db.conditions == {"Regeneration":[5, self.user]})
|
||||
# Item cure condition
|
||||
self.user.db.conditions = {"Poisoned":[5, self.user]}
|
||||
tb_items.itemfunc_cure_condition(self.test_healpotion, self.user, self.user)
|
||||
self.assertTrue(self.user.db.conditions == {})
|
||||
|
||||
|
||||
class TestTurnBattleMagicFunc(EvenniaTest):
|
||||
|
||||
def setUp(self):
|
||||
super(TestTurnBattleMagicFunc, self).setUp()
|
||||
self.testroom = create_object(DefaultRoom, key="Test Room")
|
||||
self.attacker = create_object(tb_magic.TBMagicCharacter, key="Attacker", location=self.testroom)
|
||||
self.defender = create_object(tb_magic.TBMagicCharacter, key="Defender", location=self.testroom)
|
||||
self.joiner = create_object(tb_magic.TBMagicCharacter, key="Joiner", location=self.testroom)
|
||||
|
||||
def tearDown(self):
|
||||
super(TestTurnBattleMagicFunc, self).tearDown()
|
||||
self.attacker.delete()
|
||||
self.defender.delete()
|
||||
self.joiner.delete()
|
||||
self.testroom.delete()
|
||||
self.turnhandler.stop()
|
||||
|
||||
# Test combat functions in tb_magic.
|
||||
def test_tbbasicfunc(self):
|
||||
# Initiative roll
|
||||
initiative = tb_magic.roll_init(self.attacker)
|
||||
self.assertTrue(initiative >= 0 and initiative <= 1000)
|
||||
# Attack roll
|
||||
attack_roll = tb_magic.get_attack(self.attacker, self.defender)
|
||||
self.assertTrue(attack_roll >= 0 and attack_roll <= 100)
|
||||
# Defense roll
|
||||
defense_roll = tb_magic.get_defense(self.attacker, self.defender)
|
||||
self.assertTrue(defense_roll == 50)
|
||||
# Damage roll
|
||||
damage_roll = tb_magic.get_damage(self.attacker, self.defender)
|
||||
self.assertTrue(damage_roll >= 15 and damage_roll <= 25)
|
||||
# Apply damage
|
||||
self.defender.db.hp = 10
|
||||
tb_magic.apply_damage(self.defender, 3)
|
||||
self.assertTrue(self.defender.db.hp == 7)
|
||||
# Resolve attack
|
||||
self.defender.db.hp = 40
|
||||
tb_magic.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10)
|
||||
self.assertTrue(self.defender.db.hp < 40)
|
||||
# Combat cleanup
|
||||
self.attacker.db.Combat_attribute = True
|
||||
tb_magic.combat_cleanup(self.attacker)
|
||||
self.assertFalse(self.attacker.db.combat_attribute)
|
||||
# Is in combat
|
||||
self.assertFalse(tb_magic.is_in_combat(self.attacker))
|
||||
# Set up turn handler script for further tests
|
||||
self.attacker.location.scripts.add(tb_magic.TBMagicTurnHandler)
|
||||
self.turnhandler = self.attacker.db.combat_TurnHandler
|
||||
self.assertTrue(self.attacker.db.combat_TurnHandler)
|
||||
# Set the turn handler's interval very high to keep it from repeating during tests.
|
||||
self.turnhandler.interval = 10000
|
||||
# Force turn order
|
||||
self.turnhandler.db.fighters = [self.attacker, self.defender]
|
||||
self.turnhandler.db.turn = 0
|
||||
# Test is turn
|
||||
self.assertTrue(tb_magic.is_turn(self.attacker))
|
||||
# Spend actions
|
||||
self.attacker.db.Combat_ActionsLeft = 1
|
||||
tb_magic.spend_action(self.attacker, 1, action_name="Test")
|
||||
self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
|
||||
self.assertTrue(self.attacker.db.Combat_LastAction == "Test")
|
||||
# Initialize for combat
|
||||
self.attacker.db.Combat_ActionsLeft = 983
|
||||
self.turnhandler.initialize_for_combat(self.attacker)
|
||||
self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
|
||||
self.assertTrue(self.attacker.db.Combat_LastAction == "null")
|
||||
# Start turn
|
||||
self.defender.db.Combat_ActionsLeft = 0
|
||||
self.turnhandler.start_turn(self.defender)
|
||||
self.assertTrue(self.defender.db.Combat_ActionsLeft == 1)
|
||||
# Next turn
|
||||
self.turnhandler.db.fighters = [self.attacker, self.defender]
|
||||
self.turnhandler.db.turn = 0
|
||||
self.turnhandler.next_turn()
|
||||
self.assertTrue(self.turnhandler.db.turn == 1)
|
||||
# Turn end check
|
||||
self.turnhandler.db.fighters = [self.attacker, self.defender]
|
||||
self.turnhandler.db.turn = 0
|
||||
self.attacker.db.Combat_ActionsLeft = 0
|
||||
self.turnhandler.turn_end_check(self.attacker)
|
||||
self.assertTrue(self.turnhandler.db.turn == 1)
|
||||
# Join fight
|
||||
self.turnhandler.db.fighters = [self.attacker, self.defender]
|
||||
self.turnhandler.db.turn = 0
|
||||
self.turnhandler.join_fight(self.joiner)
|
||||
self.assertTrue(self.turnhandler.db.turn == 1)
|
||||
self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender])
|
||||
|
||||
|
||||
# Test tree select
|
||||
|
||||
from evennia.contrib import tree_select
|
||||
|
||||
TREE_MENU_TESTSTR = """Foo
|
||||
Bar
|
||||
-Baz
|
||||
--Baz 1
|
||||
--Baz 2
|
||||
-Qux"""
|
||||
|
||||
|
||||
class TestTreeSelectFunc(EvenniaTest):
|
||||
|
||||
def test_tree_functions(self):
|
||||
# Dash counter
|
||||
self.assertTrue(tree_select.dashcount("--test") == 2)
|
||||
# Is category
|
||||
self.assertTrue(tree_select.is_category(TREE_MENU_TESTSTR, 1) == True)
|
||||
# Parse options
|
||||
self.assertTrue(tree_select.parse_opts(TREE_MENU_TESTSTR, category_index=2) == [(3, "Baz 1"), (4, "Baz 2")])
|
||||
# Index to selection
|
||||
self.assertTrue(tree_select.index_to_selection(TREE_MENU_TESTSTR, 2) == "Baz")
|
||||
# Go up one category
|
||||
self.assertTrue(tree_select.go_up_one_category(TREE_MENU_TESTSTR, 4) == 2)
|
||||
# Option list to menu options
|
||||
test_optlist = tree_select.parse_opts(TREE_MENU_TESTSTR, category_index=2)
|
||||
optlist_to_menu_expected_result = [{'goto': ['menunode_treeselect', {'newindex': 3}], 'key': 'Baz 1'},
|
||||
{'goto': ['menunode_treeselect', {'newindex': 4}], 'key': 'Baz 2'},
|
||||
{'goto': ['menunode_treeselect', {'newindex': 1}], 'key': ['<< Go Back', 'go back', 'back'], 'desc': 'Return to the previous menu.'}]
|
||||
self.assertTrue(tree_select.optlist_to_menuoptions(TREE_MENU_TESTSTR, test_optlist, 2, True, True) == optlist_to_menu_expected_result)
|
||||
|
||||
# Test field fill
|
||||
|
||||
from evennia.contrib import fieldfill
|
||||
|
||||
FIELD_TEST_TEMPLATE = [
|
||||
{"fieldname":"TextTest", "fieldtype":"text"},
|
||||
{"fieldname":"NumberTest", "fieldtype":"number", "blankmsg":"Number here!"},
|
||||
{"fieldname":"DefaultText", "fieldtype":"text", "default":"Test"},
|
||||
{"fieldname":"DefaultNum", "fieldtype":"number", "default":3}
|
||||
]
|
||||
|
||||
FIELD_TEST_DATA = {"TextTest":None, "NumberTest":None, "DefaultText":"Test", "DefaultNum":3}
|
||||
|
||||
class TestFieldFillFunc(EvenniaTest):
|
||||
|
||||
def test_field_functions(self):
|
||||
self.assertTrue(fieldfill.form_template_to_dict(FIELD_TEST_TEMPLATE) == FIELD_TEST_DATA)
|
||||
|
||||
# Test of the unixcommand module
|
||||
|
||||
from evennia.contrib.unixcommand import UnixCommand
|
||||
|
|
@ -1170,3 +1715,149 @@ class TestRandomStringGenerator(EvenniaTest):
|
|||
# We can't generate one more
|
||||
with self.assertRaises(random_string_generator.ExhaustedGenerator):
|
||||
SIMPLE_GENERATOR.get()
|
||||
|
||||
|
||||
# Tests for the building_menu contrib
|
||||
from evennia.contrib.building_menu import BuildingMenu, CmdNoInput, CmdNoMatch
|
||||
|
||||
class Submenu(BuildingMenu):
|
||||
def init(self, exit):
|
||||
self.add_choice("title", key="t", attr="key")
|
||||
|
||||
class TestBuildingMenu(CommandTest):
|
||||
|
||||
def setUp(self):
|
||||
super(TestBuildingMenu, self).setUp()
|
||||
self.menu = BuildingMenu(caller=self.char1, obj=self.room1, title="test")
|
||||
self.menu.add_choice("title", key="t", attr="key")
|
||||
|
||||
def test_quit(self):
|
||||
"""Try to quit the building menu."""
|
||||
self.assertFalse(self.char1.cmdset.has("building_menu"))
|
||||
self.menu.open()
|
||||
self.assertTrue(self.char1.cmdset.has("building_menu"))
|
||||
self.call(CmdNoMatch(building_menu=self.menu), "q")
|
||||
# char1 tries to quit the editor
|
||||
self.assertFalse(self.char1.cmdset.has("building_menu"))
|
||||
|
||||
def test_setattr(self):
|
||||
"""Test the simple setattr provided by building menus."""
|
||||
key = self.room1.key
|
||||
self.menu.open()
|
||||
self.call(CmdNoMatch(building_menu=self.menu), "t")
|
||||
self.assertIsNotNone(self.menu.current_choice)
|
||||
self.call(CmdNoMatch(building_menu=self.menu), "some new title")
|
||||
self.call(CmdNoMatch(building_menu=self.menu), "@")
|
||||
self.assertIsNone(self.menu.current_choice)
|
||||
self.assertEqual(self.room1.key, "some new title")
|
||||
self.call(CmdNoMatch(building_menu=self.menu), "q")
|
||||
|
||||
def test_add_choice_without_key(self):
|
||||
"""Try to add choices without keys."""
|
||||
choices = []
|
||||
for i in range(20):
|
||||
choices.append(self.menu.add_choice("choice", attr="test"))
|
||||
self.menu._add_keys_choice()
|
||||
keys = ["c", "h", "o", "i", "e", "ch", "ho", "oi", "ic", "ce", "cho", "hoi", "oic", "ice", "choi", "hoic", "oice", "choic", "hoice", "choice"]
|
||||
for i in range(20):
|
||||
self.assertEqual(choices[i].key, keys[i])
|
||||
|
||||
# Adding another key of the same title would break, no more available shortcut
|
||||
self.menu.add_choice("choice", attr="test")
|
||||
with self.assertRaises(ValueError):
|
||||
self.menu._add_keys_choice()
|
||||
|
||||
def test_callbacks(self):
|
||||
"""Test callbacks in menus."""
|
||||
self.room1.key = "room1"
|
||||
def on_enter(caller, menu):
|
||||
caller.msg("on_enter:{}".format(menu.title))
|
||||
def on_nomatch(caller, string, choice):
|
||||
caller.msg("on_nomatch:{},{}".format(string, choice.key))
|
||||
def on_leave(caller, obj):
|
||||
caller.msg("on_leave:{}".format(obj.key))
|
||||
self.menu.add_choice("test", key="e", on_enter=on_enter, on_nomatch=on_nomatch, on_leave=on_leave)
|
||||
self.call(CmdNoMatch(building_menu=self.menu), "e", "on_enter:test")
|
||||
self.call(CmdNoMatch(building_menu=self.menu), "ok", "on_nomatch:ok,e")
|
||||
self.call(CmdNoMatch(building_menu=self.menu), "@", "on_leave:room1")
|
||||
self.call(CmdNoMatch(building_menu=self.menu), "q")
|
||||
|
||||
def test_multi_level(self):
|
||||
"""Test multi-level choices."""
|
||||
# Creaste three succeeding menu (t2 is contained in t1, t3 is contained in t2)
|
||||
def on_nomatch_t1(caller, menu):
|
||||
menu.move("whatever") # this will be valid since after t1 is a joker
|
||||
|
||||
def on_nomatch_t2(caller, menu):
|
||||
menu.move("t3") # this time the key matters
|
||||
|
||||
t1 = self.menu.add_choice("what", key="t1", on_nomatch=on_nomatch_t1)
|
||||
t2 = self.menu.add_choice("and", key="t1.*", on_nomatch=on_nomatch_t2)
|
||||
t3 = self.menu.add_choice("why", key="t1.*.t3")
|
||||
self.menu.open()
|
||||
|
||||
# Move into t1
|
||||
self.assertIn(t1, self.menu.relevant_choices)
|
||||
self.assertNotIn(t2, self.menu.relevant_choices)
|
||||
self.assertNotIn(t3, self.menu.relevant_choices)
|
||||
self.assertIsNone(self.menu.current_choice)
|
||||
self.call(CmdNoMatch(building_menu=self.menu), "t1")
|
||||
self.assertEqual(self.menu.current_choice, t1)
|
||||
self.assertNotIn(t1, self.menu.relevant_choices)
|
||||
self.assertIn(t2, self.menu.relevant_choices)
|
||||
self.assertNotIn(t3, self.menu.relevant_choices)
|
||||
|
||||
# Move into t2
|
||||
self.call(CmdNoMatch(building_menu=self.menu), "t2")
|
||||
self.assertEqual(self.menu.current_choice, t2)
|
||||
self.assertNotIn(t1, self.menu.relevant_choices)
|
||||
self.assertNotIn(t2, self.menu.relevant_choices)
|
||||
self.assertIn(t3, self.menu.relevant_choices)
|
||||
|
||||
# Move into t3
|
||||
self.call(CmdNoMatch(building_menu=self.menu), "t3")
|
||||
self.assertEqual(self.menu.current_choice, t3)
|
||||
self.assertNotIn(t1, self.menu.relevant_choices)
|
||||
self.assertNotIn(t2, self.menu.relevant_choices)
|
||||
self.assertNotIn(t3, self.menu.relevant_choices)
|
||||
|
||||
# Move back to t2
|
||||
self.call(CmdNoMatch(building_menu=self.menu), "@")
|
||||
self.assertEqual(self.menu.current_choice, t2)
|
||||
self.assertNotIn(t1, self.menu.relevant_choices)
|
||||
self.assertNotIn(t2, self.menu.relevant_choices)
|
||||
self.assertIn(t3, self.menu.relevant_choices)
|
||||
|
||||
# Move back into t1
|
||||
self.call(CmdNoMatch(building_menu=self.menu), "@")
|
||||
self.assertEqual(self.menu.current_choice, t1)
|
||||
self.assertNotIn(t1, self.menu.relevant_choices)
|
||||
self.assertIn(t2, self.menu.relevant_choices)
|
||||
self.assertNotIn(t3, self.menu.relevant_choices)
|
||||
|
||||
# Moves back to the main menu
|
||||
self.call(CmdNoMatch(building_menu=self.menu), "@")
|
||||
self.assertIn(t1, self.menu.relevant_choices)
|
||||
self.assertNotIn(t2, self.menu.relevant_choices)
|
||||
self.assertNotIn(t3, self.menu.relevant_choices)
|
||||
self.assertIsNone(self.menu.current_choice)
|
||||
self.call(CmdNoMatch(building_menu=self.menu), "q")
|
||||
|
||||
def test_submenu(self):
|
||||
"""Test to add sub-menus."""
|
||||
def open_exit(menu):
|
||||
menu.open_submenu("evennia.contrib.tests.Submenu", self.exit)
|
||||
return False
|
||||
|
||||
self.menu.add_choice("exit", key="x", on_enter=open_exit)
|
||||
self.menu.open()
|
||||
self.call(CmdNoMatch(building_menu=self.menu), "x")
|
||||
self.menu = self.char1.ndb._building_menu
|
||||
self.call(CmdNoMatch(building_menu=self.menu), "t")
|
||||
self.call(CmdNoMatch(building_menu=self.menu), "in")
|
||||
self.call(CmdNoMatch(building_menu=self.menu), "@")
|
||||
self.call(CmdNoMatch(building_menu=self.menu), "@")
|
||||
self.menu = self.char1.ndb._building_menu
|
||||
self.assertEqual(self.char1.ndb._building_menu.obj, self.room1)
|
||||
self.call(CmdNoMatch(building_menu=self.menu), "q")
|
||||
self.assertEqual(self.exit.key, "in")
|
||||
|
|
|
|||
535
evennia/contrib/tree_select.py
Normal file
535
evennia/contrib/tree_select.py
Normal 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")
|
||||
|
||||
55
evennia/contrib/turnbattle/README.md
Normal file
55
evennia/contrib/turnbattle/README.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Turn based battle system framework
|
||||
|
||||
Contrib - Tim Ashley Jenkins 2017
|
||||
|
||||
This is a framework for a simple turn-based combat system, similar
|
||||
to those used in D&D-style tabletop role playing games. It allows
|
||||
any character to start a fight in a room, at which point initiative
|
||||
is rolled and a turn order is established. Each participant in combat
|
||||
has a limited time to decide their action for that turn (30 seconds by
|
||||
default), and combat progresses through the turn order, looping through
|
||||
the participants until the fight ends.
|
||||
|
||||
This folder contains multiple examples of how such a system can be
|
||||
implemented and customized:
|
||||
|
||||
tb_basic.py - The simplest system, which implements initiative and turn
|
||||
order, attack rolls against defense values, and damage to hit
|
||||
points. Only very basic game mechanics are included.
|
||||
|
||||
tb_equip.py - Adds weapons and armor to the basic implementation of
|
||||
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
|
||||
combat, as well as differentiates between melee and ranged
|
||||
attacks.
|
||||
|
||||
This system is meant as a basic framework to start from, and is modeled
|
||||
after the combat systems of popular tabletop role playing games rather than
|
||||
the real-time battle systems that many MMOs and some MUDs use. As such, it
|
||||
may be better suited to role-playing or more story-oriented games, or games
|
||||
meant to closely emulate the experience of playing a tabletop RPG.
|
||||
|
||||
Each of these modules contains the full functionality of the battle system
|
||||
with different customizations added in - the instructions to install each
|
||||
one is contained in the module itself. It's recommended that you install
|
||||
and test tb_basic first, so you can better understand how the other
|
||||
modules expand on it and get a better idea of how you can customize the
|
||||
system to your liking and integrate the subsystems presented here into
|
||||
your own combat system.
|
||||
1
evennia/contrib/turnbattle/__init__.py
Normal file
1
evennia/contrib/turnbattle/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -16,26 +16,26 @@ is easily extensible and can be used as the foundation for implementing
|
|||
the rules from your turn-based tabletop game of choice or making your
|
||||
own battle system.
|
||||
|
||||
To install and test, import this module's BattleCharacter object into
|
||||
To install and test, import this module's TBBasicCharacter object into
|
||||
your game's character.py module:
|
||||
|
||||
from evennia.contrib.turnbattle import BattleCharacter
|
||||
from evennia.contrib.turnbattle.tb_basic import TBBasicCharacter
|
||||
|
||||
And change your game's character typeclass to inherit from BattleCharacter
|
||||
And change your game's character typeclass to inherit from TBBasicCharacter
|
||||
instead of the default:
|
||||
|
||||
class Character(BattleCharacter):
|
||||
class Character(TBBasicCharacter):
|
||||
|
||||
Next, import this module into your default_cmdsets.py module:
|
||||
|
||||
from evennia.contrib import turnbattle
|
||||
from evennia.contrib.turnbattle import tb_basic
|
||||
|
||||
And add the battle command set to your default command set:
|
||||
|
||||
#
|
||||
# any commands you add below will overload the default ones.
|
||||
#
|
||||
self.add(turnbattle.BattleCmdSet())
|
||||
self.add(tb_basic.BattleCmdSet())
|
||||
|
||||
This module is meant to be heavily expanded on, so you may want to copy it
|
||||
to your game's 'world' folder and modify it there rather than importing it
|
||||
|
|
@ -48,10 +48,18 @@ from evennia.commands.default.help import CmdHelp
|
|||
|
||||
"""
|
||||
----------------------------------------------------------------------------
|
||||
COMBAT FUNCTIONS START HERE
|
||||
OPTIONS
|
||||
----------------------------------------------------------------------------
|
||||
"""
|
||||
|
||||
TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds
|
||||
ACTIONS_PER_TURN = 1 # Number of actions allowed per turn
|
||||
|
||||
"""
|
||||
----------------------------------------------------------------------------
|
||||
COMBAT FUNCTIONS START HERE
|
||||
----------------------------------------------------------------------------
|
||||
"""
|
||||
|
||||
def roll_init(character):
|
||||
"""
|
||||
|
|
@ -167,6 +175,20 @@ def apply_damage(defender, damage):
|
|||
if defender.db.hp <= 0:
|
||||
defender.db.hp = 0
|
||||
|
||||
def at_defeat(defeated):
|
||||
"""
|
||||
Announces the defeat of a fighter in combat.
|
||||
|
||||
Args:
|
||||
defeated (obj): Fighter that's been defeated.
|
||||
|
||||
Notes:
|
||||
All this does is announce a defeat message by default, but if you
|
||||
want anything else to happen to defeated fighters (like putting them
|
||||
into a dying state or something similar) then this is the place to
|
||||
do it.
|
||||
"""
|
||||
defeated.location.msg_contents("%s has been defeated!" % defeated)
|
||||
|
||||
def resolve_attack(attacker, defender, attack_value=None, defense_value=None):
|
||||
"""
|
||||
|
|
@ -195,10 +217,9 @@ def resolve_attack(attacker, defender, attack_value=None, defense_value=None):
|
|||
# Announce damage dealt and apply damage.
|
||||
attacker.location.msg_contents("%s hits %s for %i damage!" % (attacker, defender, damage_value))
|
||||
apply_damage(defender, damage_value)
|
||||
# If defender HP is reduced to 0 or less, announce defeat.
|
||||
# If defender HP is reduced to 0 or less, call at_defeat.
|
||||
if defender.db.hp <= 0:
|
||||
attacker.location.msg_contents("%s has been defeated!" % defender)
|
||||
|
||||
at_defeat(defender)
|
||||
|
||||
def combat_cleanup(character):
|
||||
"""
|
||||
|
|
@ -226,9 +247,7 @@ def is_in_combat(character):
|
|||
Returns:
|
||||
(bool): True if in combat or False if not in combat
|
||||
"""
|
||||
if character.db.Combat_TurnHandler:
|
||||
return True
|
||||
return False
|
||||
return bool(character.db.combat_turnhandler)
|
||||
|
||||
|
||||
def is_turn(character):
|
||||
|
|
@ -241,11 +260,9 @@ def is_turn(character):
|
|||
Returns:
|
||||
(bool): True if it is their turn or False otherwise
|
||||
"""
|
||||
turnhandler = character.db.Combat_TurnHandler
|
||||
turnhandler = character.db.combat_turnhandler
|
||||
currentchar = turnhandler.db.fighters[turnhandler.db.turn]
|
||||
if character == currentchar:
|
||||
return True
|
||||
return False
|
||||
return bool(character == currentchar)
|
||||
|
||||
|
||||
def spend_action(character, actions, action_name=None):
|
||||
|
|
@ -261,14 +278,14 @@ def spend_action(character, actions, action_name=None):
|
|||
combat to provided string
|
||||
"""
|
||||
if action_name:
|
||||
character.db.Combat_LastAction = action_name
|
||||
character.db.combat_lastaction = action_name
|
||||
if actions == 'all': # If spending all actions
|
||||
character.db.Combat_ActionsLeft = 0 # Set actions to 0
|
||||
character.db.combat_actionsleft = 0 # Set actions to 0
|
||||
else:
|
||||
character.db.Combat_ActionsLeft -= actions # Use up actions.
|
||||
if character.db.Combat_ActionsLeft < 0:
|
||||
character.db.Combat_ActionsLeft = 0 # Can't have fewer than 0 actions
|
||||
character.db.Combat_TurnHandler.turn_end_check(character) # Signal potential end of turn.
|
||||
character.db.combat_actionsleft -= actions # Use up actions.
|
||||
if character.db.combat_actionsleft < 0:
|
||||
character.db.combat_actionsleft = 0 # Can't have fewer than 0 actions
|
||||
character.db.combat_turnhandler.turn_end_check(character) # Signal potential end of turn.
|
||||
|
||||
|
||||
"""
|
||||
|
|
@ -278,7 +295,7 @@ CHARACTER TYPECLASS
|
|||
"""
|
||||
|
||||
|
||||
class BattleCharacter(DefaultCharacter):
|
||||
class TBBasicCharacter(DefaultCharacter):
|
||||
"""
|
||||
A character able to participate in turn-based combat. Has attributes for current
|
||||
and maximum HP, and access to combat commands.
|
||||
|
|
@ -324,7 +341,182 @@ class BattleCharacter(DefaultCharacter):
|
|||
return False
|
||||
return True
|
||||
|
||||
"""
|
||||
----------------------------------------------------------------------------
|
||||
SCRIPTS START HERE
|
||||
----------------------------------------------------------------------------
|
||||
"""
|
||||
|
||||
|
||||
class TBBasicTurnHandler(DefaultScript):
|
||||
"""
|
||||
This is the script that handles the progression of combat through turns.
|
||||
On creation (when a fight is started) it adds all combat-ready characters
|
||||
to its roster and then sorts them into a turn order. There can only be one
|
||||
fight going on in a single room at a time, so the script is assigned to a
|
||||
room as its object.
|
||||
|
||||
Fights persist until only one participant is left with any HP or all
|
||||
remaining participants choose to end the combat with the 'disengage' command.
|
||||
"""
|
||||
|
||||
def at_script_creation(self):
|
||||
"""
|
||||
Called once, when the script is created.
|
||||
"""
|
||||
self.key = "Combat Turn Handler"
|
||||
self.interval = 5 # Once every 5 seconds
|
||||
self.persistent = True
|
||||
self.db.fighters = []
|
||||
|
||||
# Add all fighters in the room with at least 1 HP to the combat."
|
||||
for thing in self.obj.contents:
|
||||
if thing.db.hp:
|
||||
self.db.fighters.append(thing)
|
||||
|
||||
# Initialize each fighter for combat
|
||||
for fighter in self.db.fighters:
|
||||
self.initialize_for_combat(fighter)
|
||||
|
||||
# Add a reference to this script to the room
|
||||
self.obj.db.combat_turnhandler = self
|
||||
|
||||
# Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order.
|
||||
# The initiative roll is determined by the roll_init function and can be customized easily.
|
||||
ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True)
|
||||
self.db.fighters = ordered_by_roll
|
||||
|
||||
# Announce the turn order.
|
||||
self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters))
|
||||
|
||||
# Start first fighter's turn.
|
||||
self.start_turn(self.db.fighters[0])
|
||||
|
||||
# Set up the current turn and turn timeout delay.
|
||||
self.db.turn = 0
|
||||
self.db.timer = TURN_TIMEOUT # Set timer to turn timeout specified in options
|
||||
|
||||
def at_stop(self):
|
||||
"""
|
||||
Called at script termination.
|
||||
"""
|
||||
for fighter in self.db.fighters:
|
||||
combat_cleanup(fighter) # Clean up the combat attributes for every fighter.
|
||||
self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location
|
||||
|
||||
def at_repeat(self):
|
||||
"""
|
||||
Called once every self.interval seconds.
|
||||
"""
|
||||
currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order.
|
||||
self.db.timer -= self.interval # Count down the timer.
|
||||
|
||||
if self.db.timer <= 0:
|
||||
# Force current character to disengage if timer runs out.
|
||||
self.obj.msg_contents("%s's turn timed out!" % currentchar)
|
||||
spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions.
|
||||
return
|
||||
elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left
|
||||
# Warn the current character if they're about to time out.
|
||||
currentchar.msg("WARNING: About to time out!")
|
||||
self.db.timeout_warning_given = True
|
||||
|
||||
def initialize_for_combat(self, character):
|
||||
"""
|
||||
Prepares a character for combat when starting or entering a fight.
|
||||
|
||||
Args:
|
||||
character (obj): Character to initialize for combat.
|
||||
"""
|
||||
combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case.
|
||||
character.db.combat_actionsleft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0
|
||||
character.db.combat_turnhandler = self # Add a reference to this turn handler script to the character
|
||||
character.db.combat_lastaction = "null" # Track last action taken in combat
|
||||
|
||||
def start_turn(self, character):
|
||||
"""
|
||||
Readies a character for the start of their turn by replenishing their
|
||||
available actions and notifying them that their turn has come up.
|
||||
|
||||
Args:
|
||||
character (obj): Character to be readied.
|
||||
|
||||
Notes:
|
||||
Here, you only get one action per turn, but you might want to allow more than
|
||||
one per turn, or even grant a number of actions based on a character's
|
||||
attributes. You can even add multiple different kinds of actions, I.E. actions
|
||||
separated for movement, by adding "character.db.combat_movesleft = 3" or
|
||||
something similar.
|
||||
"""
|
||||
character.db.combat_actionsleft = ACTIONS_PER_TURN # Replenish actions
|
||||
# Prompt the character for their turn and give some information.
|
||||
character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp)
|
||||
|
||||
def next_turn(self):
|
||||
"""
|
||||
Advances to the next character in the turn order.
|
||||
"""
|
||||
|
||||
# Check to see if every character disengaged as their last action. If so, end combat.
|
||||
disengage_check = True
|
||||
for fighter in self.db.fighters:
|
||||
if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage
|
||||
disengage_check = False
|
||||
if disengage_check: # All characters have disengaged
|
||||
self.obj.msg_contents("All fighters have disengaged! Combat is over!")
|
||||
self.stop() # Stop this script and end combat.
|
||||
return
|
||||
|
||||
# Check to see if only one character is left standing. If so, end combat.
|
||||
defeated_characters = 0
|
||||
for fighter in self.db.fighters:
|
||||
if fighter.db.HP == 0:
|
||||
defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated)
|
||||
if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated
|
||||
for fighter in self.db.fighters:
|
||||
if fighter.db.HP != 0:
|
||||
LastStanding = fighter # Pick the one fighter left with HP remaining
|
||||
self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding)
|
||||
self.stop() # Stop this script and end combat.
|
||||
return
|
||||
|
||||
# Cycle to the next turn.
|
||||
currentchar = self.db.fighters[self.db.turn]
|
||||
self.db.turn += 1 # Go to the next in the turn order.
|
||||
if self.db.turn > len(self.db.fighters) - 1:
|
||||
self.db.turn = 0 # Go back to the first in the turn order once you reach the end.
|
||||
newchar = self.db.fighters[self.db.turn] # Note the new character
|
||||
self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer.
|
||||
self.db.timeout_warning_given = False # Reset the timeout warning.
|
||||
self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar))
|
||||
self.start_turn(newchar) # Start the new character's turn.
|
||||
|
||||
def turn_end_check(self, character):
|
||||
"""
|
||||
Tests to see if a character's turn is over, and cycles to the next turn if it is.
|
||||
|
||||
Args:
|
||||
character (obj): Character to test for end of turn
|
||||
"""
|
||||
if not character.db.combat_actionsleft: # Character has no actions remaining
|
||||
self.next_turn()
|
||||
return
|
||||
|
||||
def join_fight(self, character):
|
||||
"""
|
||||
Adds a new character to a fight already in progress.
|
||||
|
||||
Args:
|
||||
character (obj): Character to be added to the fight.
|
||||
"""
|
||||
# Inserts the fighter to the turn order, right behind whoever's turn it currently is.
|
||||
self.db.fighters.insert(self.db.turn, character)
|
||||
# Tick the turn counter forward one to compensate.
|
||||
self.db.turn += 1
|
||||
# Initialize the character like you do at the start.
|
||||
self.initialize_for_combat(character)
|
||||
|
||||
|
||||
"""
|
||||
----------------------------------------------------------------------------
|
||||
COMMANDS START HERE
|
||||
|
|
@ -365,13 +557,13 @@ class CmdFight(Command):
|
|||
if len(fighters) <= 1: # If you're the only able fighter in the room
|
||||
self.caller.msg("There's nobody here to fight!")
|
||||
return
|
||||
if here.db.Combat_TurnHandler: # If there's already a fight going on...
|
||||
if here.db.combat_turnhandler: # If there's already a fight going on...
|
||||
here.msg_contents("%s joins the fight!" % self.caller)
|
||||
here.db.Combat_TurnHandler.join_fight(self.caller) # Join the fight!
|
||||
here.db.combat_turnhandler.join_fight(self.caller) # Join the fight!
|
||||
return
|
||||
here.msg_contents("%s starts a fight!" % self.caller)
|
||||
# Add a turn handler script to the room, which starts combat.
|
||||
here.scripts.add("contrib.turnbattle.TurnHandler")
|
||||
here.scripts.add("contrib.turnbattle.tb_basic.TBBasicTurnHandler")
|
||||
# Remember you'll have to change the path to the script if you copy this code to your own modules!
|
||||
|
||||
|
||||
|
|
@ -559,177 +751,4 @@ class BattleCmdSet(default_cmds.CharacterCmdSet):
|
|||
self.add(CmdRest())
|
||||
self.add(CmdPass())
|
||||
self.add(CmdDisengage())
|
||||
self.add(CmdCombatHelp())
|
||||
|
||||
|
||||
"""
|
||||
----------------------------------------------------------------------------
|
||||
SCRIPTS START HERE
|
||||
----------------------------------------------------------------------------
|
||||
"""
|
||||
|
||||
|
||||
class TurnHandler(DefaultScript):
|
||||
"""
|
||||
This is the script that handles the progression of combat through turns.
|
||||
On creation (when a fight is started) it adds all combat-ready characters
|
||||
to its roster and then sorts them into a turn order. There can only be one
|
||||
fight going on in a single room at a time, so the script is assigned to a
|
||||
room as its object.
|
||||
|
||||
Fights persist until only one participant is left with any HP or all
|
||||
remaining participants choose to end the combat with the 'disengage' command.
|
||||
"""
|
||||
|
||||
def at_script_creation(self):
|
||||
"""
|
||||
Called once, when the script is created.
|
||||
"""
|
||||
self.key = "Combat Turn Handler"
|
||||
self.interval = 5 # Once every 5 seconds
|
||||
self.persistent = True
|
||||
self.db.fighters = []
|
||||
|
||||
# Add all fighters in the room with at least 1 HP to the combat."
|
||||
for object in self.obj.contents:
|
||||
if object.db.hp:
|
||||
self.db.fighters.append(object)
|
||||
|
||||
# Initialize each fighter for combat
|
||||
for fighter in self.db.fighters:
|
||||
self.initialize_for_combat(fighter)
|
||||
|
||||
# Add a reference to this script to the room
|
||||
self.obj.db.Combat_TurnHandler = self
|
||||
|
||||
# Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order.
|
||||
# The initiative roll is determined by the roll_init function and can be customized easily.
|
||||
ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True)
|
||||
self.db.fighters = ordered_by_roll
|
||||
|
||||
# Announce the turn order.
|
||||
self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters))
|
||||
|
||||
# Set up the current turn and turn timeout delay.
|
||||
self.db.turn = 0
|
||||
self.db.timer = 30 # 30 seconds
|
||||
|
||||
def at_stop(self):
|
||||
"""
|
||||
Called at script termination.
|
||||
"""
|
||||
for fighter in self.db.fighters:
|
||||
combat_cleanup(fighter) # Clean up the combat attributes for every fighter.
|
||||
self.obj.db.Combat_TurnHandler = None # Remove reference to turn handler in location
|
||||
|
||||
def at_repeat(self):
|
||||
"""
|
||||
Called once every self.interval seconds.
|
||||
"""
|
||||
currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order.
|
||||
self.db.timer -= self.interval # Count down the timer.
|
||||
|
||||
if self.db.timer <= 0:
|
||||
# Force current character to disengage if timer runs out.
|
||||
self.obj.msg_contents("%s's turn timed out!" % currentchar)
|
||||
spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions.
|
||||
return
|
||||
elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left
|
||||
# Warn the current character if they're about to time out.
|
||||
currentchar.msg("WARNING: About to time out!")
|
||||
self.db.timeout_warning_given = True
|
||||
|
||||
def initialize_for_combat(self, character):
|
||||
"""
|
||||
Prepares a character for combat when starting or entering a fight.
|
||||
|
||||
Args:
|
||||
character (obj): Character to initialize for combat.
|
||||
"""
|
||||
combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case.
|
||||
character.db.Combat_ActionsLeft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0
|
||||
character.db.Combat_TurnHandler = self # Add a reference to this turn handler script to the character
|
||||
character.db.Combat_LastAction = "null" # Track last action taken in combat
|
||||
|
||||
def start_turn(self, character):
|
||||
"""
|
||||
Readies a character for the start of their turn by replenishing their
|
||||
available actions and notifying them that their turn has come up.
|
||||
|
||||
Args:
|
||||
character (obj): Character to be readied.
|
||||
|
||||
Notes:
|
||||
Here, you only get one action per turn, but you might want to allow more than
|
||||
one per turn, or even grant a number of actions based on a character's
|
||||
attributes. You can even add multiple different kinds of actions, I.E. actions
|
||||
separated for movement, by adding "character.db.Combat_MovesLeft = 3" or
|
||||
something similar.
|
||||
"""
|
||||
character.db.Combat_ActionsLeft = 1 # 1 action per turn.
|
||||
# Prompt the character for their turn and give some information.
|
||||
character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp)
|
||||
|
||||
def next_turn(self):
|
||||
"""
|
||||
Advances to the next character in the turn order.
|
||||
"""
|
||||
|
||||
# Check to see if every character disengaged as their last action. If so, end combat.
|
||||
disengage_check = True
|
||||
for fighter in self.db.fighters:
|
||||
if fighter.db.Combat_LastAction != "disengage": # If a character has done anything but disengage
|
||||
disengage_check = False
|
||||
if disengage_check: # All characters have disengaged
|
||||
self.obj.msg_contents("All fighters have disengaged! Combat is over!")
|
||||
self.stop() # Stop this script and end combat.
|
||||
return
|
||||
|
||||
# Check to see if only one character is left standing. If so, end combat.
|
||||
defeated_characters = 0
|
||||
for fighter in self.db.fighters:
|
||||
if fighter.db.HP == 0:
|
||||
defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated)
|
||||
if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated
|
||||
for fighter in self.db.fighters:
|
||||
if fighter.db.HP != 0:
|
||||
LastStanding = fighter # Pick the one fighter left with HP remaining
|
||||
self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding)
|
||||
self.stop() # Stop this script and end combat.
|
||||
return
|
||||
|
||||
# Cycle to the next turn.
|
||||
currentchar = self.db.fighters[self.db.turn]
|
||||
self.db.turn += 1 # Go to the next in the turn order.
|
||||
if self.db.turn > len(self.db.fighters) - 1:
|
||||
self.db.turn = 0 # Go back to the first in the turn order once you reach the end.
|
||||
newchar = self.db.fighters[self.db.turn] # Note the new character
|
||||
self.db.timer = 30 + self.time_until_next_repeat() # Reset the timer.
|
||||
self.db.timeout_warning_given = False # Reset the timeout warning.
|
||||
self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar))
|
||||
self.start_turn(newchar) # Start the new character's turn.
|
||||
|
||||
def turn_end_check(self, character):
|
||||
"""
|
||||
Tests to see if a character's turn is over, and cycles to the next turn if it is.
|
||||
|
||||
Args:
|
||||
character (obj): Character to test for end of turn
|
||||
"""
|
||||
if not character.db.Combat_ActionsLeft: # Character has no actions remaining
|
||||
self.next_turn()
|
||||
return
|
||||
|
||||
def join_fight(self, character):
|
||||
"""
|
||||
Adds a new character to a fight already in progress.
|
||||
|
||||
Args:
|
||||
character (obj): Character to be added to the fight.
|
||||
"""
|
||||
# Inserts the fighter to the turn order, right behind whoever's turn it currently is.
|
||||
self.db.fighters.insert(self.db.turn, character)
|
||||
# Tick the turn counter forward one to compensate.
|
||||
self.db.turn += 1
|
||||
# Initialize the character like you do at the start.
|
||||
self.initialize_for_combat(character)
|
||||
self.add(CmdCombatHelp())
|
||||
1086
evennia/contrib/turnbattle/tb_equip.py
Normal file
1086
evennia/contrib/turnbattle/tb_equip.py
Normal file
File diff suppressed because it is too large
Load diff
1397
evennia/contrib/turnbattle/tb_items.py
Normal file
1397
evennia/contrib/turnbattle/tb_items.py
Normal file
File diff suppressed because it is too large
Load diff
1290
evennia/contrib/turnbattle/tb_magic.py
Normal file
1290
evennia/contrib/turnbattle/tb_magic.py
Normal file
File diff suppressed because it is too large
Load diff
1383
evennia/contrib/turnbattle/tb_range.py
Normal file
1383
evennia/contrib/turnbattle/tb_range.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
#
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import random
|
|||
|
||||
from evennia import DefaultObject, DefaultExit, Command, CmdSet
|
||||
from evennia.utils import search, delay
|
||||
from evennia.utils.spawner import spawn
|
||||
from evennia.prototypes.spawner import spawn
|
||||
|
||||
# -------------------------------------------------------------
|
||||
#
|
||||
|
|
@ -475,14 +475,14 @@ class CmdShiftRoot(Command):
|
|||
root_pos["blue"] -= 1
|
||||
self.caller.msg("The root with blue flowers gets in the way and is pushed to the left.")
|
||||
else:
|
||||
self.caller.msg("You cannot move the root in that direction.")
|
||||
self.caller.msg("The root hangs straight down - you can only move it left or right.")
|
||||
elif color == "blue":
|
||||
if direction == "left":
|
||||
root_pos[color] = max(-1, root_pos[color] - 1)
|
||||
self.caller.msg("You shift the root with small blue flowers to the left.")
|
||||
if root_pos[color] != 0 and root_pos[color] == root_pos["red"]:
|
||||
root_pos["red"] += 1
|
||||
self.caller.msg("The reddish root is to big to fit as well, so that one falls away to the left.")
|
||||
self.caller.msg("The reddish root is too big to fit as well, so that one falls away to the left.")
|
||||
elif direction == "right":
|
||||
root_pos[color] = min(1, root_pos[color] + 1)
|
||||
self.caller.msg("You shove the root adorned with small blue flowers to the right.")
|
||||
|
|
@ -490,7 +490,7 @@ class CmdShiftRoot(Command):
|
|||
root_pos["red"] -= 1
|
||||
self.caller.msg("The thick reddish root gets in the way and is pushed back to the left.")
|
||||
else:
|
||||
self.caller.msg("You cannot move the root in that direction.")
|
||||
self.caller.msg("The root hangs straight down - you can only move it left or right.")
|
||||
|
||||
# now the horizontal roots (yellow/green). They can be moved up/down
|
||||
elif color == "yellow":
|
||||
|
|
@ -507,7 +507,7 @@ class CmdShiftRoot(Command):
|
|||
root_pos["green"] -= 1
|
||||
self.caller.msg("The weedy green root is shifted upwards to make room.")
|
||||
else:
|
||||
self.caller.msg("You cannot move the root in that direction.")
|
||||
self.caller.msg("The root hangs across the wall - you can only move it up or down.")
|
||||
elif color == "green":
|
||||
if direction == "up":
|
||||
root_pos[color] = max(-1, root_pos[color] - 1)
|
||||
|
|
@ -522,7 +522,7 @@ class CmdShiftRoot(Command):
|
|||
root_pos["yellow"] -= 1
|
||||
self.caller.msg("The root with yellow flowers gets in the way and is pushed upwards.")
|
||||
else:
|
||||
self.caller.msg("You cannot move the root in that direction.")
|
||||
self.caller.msg("The root hangs across the wall - you can only move it up or down.")
|
||||
|
||||
# we have moved the root. Store new position
|
||||
self.obj.db.root_pos = root_pos
|
||||
|
|
@ -674,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
|
||||
|
|
@ -905,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.",
|
||||
|
|
@ -925,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.",
|
||||
|
|
@ -954,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'."
|
||||
|
|
@ -976,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."
|
||||
|
|
@ -985,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,"
|
||||
|
|
|
|||
|
|
@ -747,9 +747,16 @@ class CmdLookDark(Command):
|
|||
"""
|
||||
caller = self.caller
|
||||
|
||||
if random.random() < 0.8:
|
||||
# count how many searches we've done
|
||||
nr_searches = caller.ndb.dark_searches
|
||||
if nr_searches is None:
|
||||
nr_searches = 0
|
||||
caller.ndb.dark_searches = nr_searches
|
||||
|
||||
if nr_searches < 4 and random.random() < 0.90:
|
||||
# we don't find anything
|
||||
caller.msg(random.choice(DARK_MESSAGES))
|
||||
caller.ndb.dark_searches += 1
|
||||
else:
|
||||
# we could have found something!
|
||||
if any(obj for obj in caller.contents if utils.inherits_from(obj, LightSource)):
|
||||
|
|
@ -791,7 +798,8 @@ class CmdDarkNoMatch(Command):
|
|||
|
||||
def func(self):
|
||||
"""Implements the command."""
|
||||
self.caller.msg("Until you find some light, there's not much you can do. Try feeling around.")
|
||||
self.caller.msg("Until you find some light, there's not much you can do. "
|
||||
"Try feeling around, maybe you'll find something helpful!")
|
||||
|
||||
|
||||
class DarkCmdSet(CmdSet):
|
||||
|
|
@ -814,7 +822,9 @@ class DarkCmdSet(CmdSet):
|
|||
self.add(CmdLookDark())
|
||||
self.add(CmdDarkHelp())
|
||||
self.add(CmdDarkNoMatch())
|
||||
self.add(default_cmds.CmdSay)
|
||||
self.add(default_cmds.CmdSay())
|
||||
self.add(default_cmds.CmdQuit())
|
||||
self.add(default_cmds.CmdHome())
|
||||
|
||||
|
||||
class DarkRoom(TutorialRoom):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -62,4 +41,4 @@ AMP_PORT = 4006
|
|||
try:
|
||||
from server.conf.secret_settings import *
|
||||
except ImportError:
|
||||
print "secret_settings.py file not found or failed to import."
|
||||
print("secret_settings.py file not found or failed to import.")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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, basestring):
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -437,7 +437,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.
|
||||
|
|
@ -502,6 +502,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):
|
||||
|
|
|
|||
|
|
@ -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, to_unicode, is_iter, to_str)
|
||||
make_iter, to_unicode, is_iter, list_to_string,
|
||||
to_str)
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
_INFLECT = inflect.engine()
|
||||
_MULTISESSION_MODE = settings.MULTISESSION_MODE
|
||||
|
||||
_ScriptDB = None
|
||||
|
|
@ -289,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,
|
||||
|
|
@ -969,14 +1005,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
|
||||
|
|
@ -1450,7 +1486,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:
|
||||
|
|
@ -1458,16 +1494,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):
|
||||
|
|
@ -1514,6 +1562,25 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
|
|||
"""
|
||||
pass
|
||||
|
||||
def at_before_get(self, getter, **kwargs):
|
||||
"""
|
||||
Called by the default `get` command before this object has been
|
||||
picked up.
|
||||
|
||||
Args:
|
||||
getter (Object): The object about to get this object.
|
||||
**kwargs (dict): Arbitrary, optional arguments for users
|
||||
overriding the call (unused by default).
|
||||
|
||||
Returns:
|
||||
shouldget (bool): If the object should be gotten or not.
|
||||
|
||||
Notes:
|
||||
If this method returns False/None, the getting is cancelled
|
||||
before it is even started.
|
||||
"""
|
||||
return True
|
||||
|
||||
def at_get(self, getter, **kwargs):
|
||||
"""
|
||||
Called by the default `get` command when this object has been
|
||||
|
|
@ -1526,11 +1593,32 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
|
|||
|
||||
Notes:
|
||||
This hook cannot stop the pickup from happening. Use
|
||||
permissions for that.
|
||||
permissions or the at_before_get() hook for that.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
def at_before_give(self, giver, getter, **kwargs):
|
||||
"""
|
||||
Called by the default `give` command before this object has been
|
||||
given.
|
||||
|
||||
Args:
|
||||
giver (Object): The object about to give this object.
|
||||
getter (Object): The object about to get this object.
|
||||
**kwargs (dict): Arbitrary, optional arguments for users
|
||||
overriding the call (unused by default).
|
||||
|
||||
Returns:
|
||||
shouldgive (bool): If the object should be given or not.
|
||||
|
||||
Notes:
|
||||
If this method returns False/None, the giving is cancelled
|
||||
before it is even started.
|
||||
|
||||
"""
|
||||
return True
|
||||
|
||||
def at_give(self, giver, getter, **kwargs):
|
||||
"""
|
||||
Called by the default `give` command when this object has been
|
||||
|
|
@ -1544,11 +1632,31 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
|
|||
|
||||
Notes:
|
||||
This hook cannot stop the give from happening. Use
|
||||
permissions for that.
|
||||
permissions or the at_before_give() hook for that.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
def at_before_drop(self, dropper, **kwargs):
|
||||
"""
|
||||
Called by the default `drop` command before this object has been
|
||||
dropped.
|
||||
|
||||
Args:
|
||||
dropper (Object): The object which will drop this object.
|
||||
**kwargs (dict): Arbitrary, optional arguments for users
|
||||
overriding the call (unused by default).
|
||||
|
||||
Returns:
|
||||
shoulddrop (bool): If the object should be dropped or not.
|
||||
|
||||
Notes:
|
||||
If this method returns False/None, the dropping is cancelled
|
||||
before it is even started.
|
||||
|
||||
"""
|
||||
return True
|
||||
|
||||
def at_drop(self, dropper, **kwargs):
|
||||
"""
|
||||
Called by the default `drop` command when this object has been
|
||||
|
|
@ -1561,7 +1669,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
|
|||
|
||||
Notes:
|
||||
This hook cannot stop the drop from happening. Use
|
||||
permissions from that.
|
||||
permissions or the at_before_drop() hook for that.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
|
@ -1641,12 +1749,13 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
|
|||
# whisper mode
|
||||
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
|
||||
|
|
@ -1691,9 +1800,14 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
|
|||
"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)
|
||||
|
||||
|
||||
|
|
@ -1764,7 +1878,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)
|
||||
|
|
|
|||
145
evennia/prototypes/README.md
Normal file
145
evennia/prototypes/README.md
Normal 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.
|
||||
0
evennia/prototypes/__init__.py
Normal file
0
evennia/prototypes/__init__.py
Normal file
2474
evennia/prototypes/menus.py
Normal file
2474
evennia/prototypes/menus.py
Normal file
File diff suppressed because it is too large
Load diff
327
evennia/prototypes/protfuncs.py
Normal file
327
evennia/prototypes/protfuncs.py
Normal 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)]
|
||||
769
evennia/prototypes/prototypes.py
Normal file
769
evennia/prototypes/prototypes.py
Normal file
|
|
@ -0,0 +1,769 @@
|
|||
"""
|
||||
|
||||
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
|
||||
import hashlib
|
||||
import time
|
||||
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, class_from_module)
|
||||
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 supported pre Evennia 0.7 into the stricter form.
|
||||
|
||||
|
||||
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 and other
|
||||
homogenizations like adding missing prototype_keys and setting a default typeclass.
|
||||
|
||||
"""
|
||||
reserved = _PROTOTYPE_RESERVED_KEYS + (custom_keys or ())
|
||||
|
||||
attrs = list(prototype.get('attrs', [])) # break reference
|
||||
tags = make_iter(prototype.get('tags', []))
|
||||
homogenized_tags = []
|
||||
|
||||
homogenized = {}
|
||||
for key, val in prototype.items():
|
||||
if key in reserved:
|
||||
if key == 'tags':
|
||||
for tag in tags:
|
||||
if not is_iter(tag):
|
||||
homogenized_tags.append((tag, None, None))
|
||||
else:
|
||||
homogenized_tags.append(tag)
|
||||
else:
|
||||
homogenized[key] = val
|
||||
else:
|
||||
# unassigned keys -> attrs
|
||||
attrs.append((key, val, None, ''))
|
||||
if attrs:
|
||||
homogenized['attrs'] = attrs
|
||||
if homogenized_tags:
|
||||
homogenized['tags'] = homogenized_tags
|
||||
|
||||
# add required missing parts that had defaults before
|
||||
|
||||
if "prototype_key" not in prototype:
|
||||
# assign a random hash as key
|
||||
homogenized["prototype_key"] = "prototype-{}".format(
|
||||
hashlib.md5(str(time.time())).hexdigest()[:7])
|
||||
|
||||
if "typeclass" not in prototype and "prototype_parent" not in prototype:
|
||||
homogenized["typeclass"] = settings.BASE_OBJECT_TYPECLASS
|
||||
|
||||
return homogenized
|
||||
|
||||
|
||||
# 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 = []
|
||||
for variable_name, prot in all_from_module(mod).items():
|
||||
if isinstance(prot, dict):
|
||||
if "prototype_key" not in prot:
|
||||
prot['prototype_key'] = variable_name.lower()
|
||||
prots.append((prot['prototype_key'], homogenize_prototype(prot)))
|
||||
# assign module path to each prototype_key for easy reference
|
||||
_MODULE_PROTOTYPE_MODULES.update({prototype_key.lower(): mod for prototype_key, _ in prots})
|
||||
# make sure the prototype contains all meta info
|
||||
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:
|
||||
try:
|
||||
class_from_module(typeclass)
|
||||
except ImportError as err:
|
||||
_flags['errors'].append(
|
||||
"{}: Prototype {} is based on typeclass {}, which could not be imported!".format(
|
||||
err, protkey, typeclass))
|
||||
|
||||
# recursively traverese prototype_parent chain
|
||||
|
||||
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)
|
||||
758
evennia/prototypes/spawner.py
Normal file
758
evennia/prototypes/spawner.py
Normal file
|
|
@ -0,0 +1,758 @@
|
|||
"""
|
||||
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.all(return_objs=True)]
|
||||
if tags:
|
||||
prot['tags'] = tags
|
||||
attrs = [(attr.key, attr.value, attr.category, ';'.join(attr.locks.all()))
|
||||
for attr in obj.attributes.all()]
|
||||
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()}
|
||||
|
||||
if not kwargs.get("only_validate"):
|
||||
# homogenization to be more lenient about prototype format when entering the prototype manually
|
||||
prototypes = [protlib.homogenize_prototype(prot) for prot in prototypes]
|
||||
|
||||
# overload module's protparents with specifically given protparents
|
||||
# we allow prototype_key to be the key of the protparent dict, to allow for module-level
|
||||
# prototype imports. We need to insert prototype_key in this case
|
||||
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 val:
|
||||
tags.append((init_spawn_value(tag, 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(value), 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)
|
||||
681
evennia/prototypes/tests.py
Normal file
681
evennia/prototypes/tests.py
Normal file
|
|
@ -0,0 +1,681 @@
|
|||
"""
|
||||
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': [],
|
||||
'tags': [(u'footag', u'foocategory', None)],
|
||||
'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')},
|
||||
'tags': {u'footag': ((u'footag', u'foocategory', None), None, 'REMOVE')},
|
||||
'prototype_desc': ('Built from Obj',
|
||||
'New version of prototype', 'UPDATE'),
|
||||
'permissions': {"Builder": (None, 'Builder', 'ADD')}
|
||||
})
|
||||
|
||||
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',
|
||||
'tags': 'REMOVE',
|
||||
'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
|
||||
with mock.patch("evennia.prototypes.prototypes._MODULE_PROTOTYPES", {}):
|
||||
self.assertEqual(list(protlib.search_prototype("prot")), [prot1b, prot2, prot3])
|
||||
self.assertEqual(list(protlib.search_prototype(tags="foo1")), [prot1b, prot2, prot3])
|
||||
|
||||
self.assertTrue(str(unicode(protlib.list_prototypes(self.char1))))
|
||||
|
||||
|
||||
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']]]
|
||||
|
|
@ -214,7 +214,7 @@ class ScriptDBManager(TypedObjectManager):
|
|||
VALIDATE_ITERATION -= 1
|
||||
return nr_started, nr_stopped
|
||||
|
||||
def search_script(self, ostring, obj=None, only_timed=False):
|
||||
def search_script(self, ostring, obj=None, only_timed=False, typeclass=None):
|
||||
"""
|
||||
Search for a particular script.
|
||||
|
||||
|
|
@ -224,6 +224,7 @@ class ScriptDBManager(TypedObjectManager):
|
|||
this object
|
||||
only_timed (bool): Limit search only to scripts that run
|
||||
on a timer.
|
||||
typeclass (class or str): Typeclass or path to typeclass.
|
||||
|
||||
"""
|
||||
|
||||
|
|
@ -237,10 +238,17 @@ class ScriptDBManager(TypedObjectManager):
|
|||
(only_timed and dbref_match.interval)):
|
||||
return [dbref_match]
|
||||
|
||||
if typeclass:
|
||||
if callable(typeclass):
|
||||
typeclass = u"%s.%s" % (typeclass.__module__, typeclass.__name__)
|
||||
else:
|
||||
typeclass = u"%s" % typeclass
|
||||
|
||||
# not a dbref; normal search
|
||||
obj_restriction = obj and Q(db_obj=obj) or Q()
|
||||
timed_restriction = only_timed and Q(interval__gt=0) or Q()
|
||||
scripts = self.filter(timed_restriction & obj_restriction & Q(db_key__iexact=ostring))
|
||||
timed_restriction = only_timed and Q(db_interval__gt=0) or Q()
|
||||
typeclass_restriction = typeclass and Q(db_typeclass_path=typeclass) or Q()
|
||||
scripts = self.filter(timed_restriction & obj_restriction & typeclass_restriction & Q(db_key__iexact=ostring))
|
||||
return scripts
|
||||
# back-compatibility alias
|
||||
script_search = search_script
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,670 +0,0 @@
|
|||
"""
|
||||
Contains the protocols, commands, and client factory needed for the Server
|
||||
and Portal to communicate with each other, letting Portal work as a proxy.
|
||||
Both sides use this same protocol.
|
||||
|
||||
The separation works like this:
|
||||
|
||||
Portal - (AMP client) handles protocols. It contains a list of connected
|
||||
sessions in a dictionary for identifying the respective account
|
||||
connected. If it loses the AMP connection it will automatically
|
||||
try to reconnect.
|
||||
|
||||
Server - (AMP server) Handles all mud operations. The server holds its own list
|
||||
of sessions tied to account objects. This is synced against the portal
|
||||
at startup and when a session connects/disconnects
|
||||
|
||||
"""
|
||||
from __future__ import print_function
|
||||
|
||||
# imports needed on both server and portal side
|
||||
import os
|
||||
import time
|
||||
from collections import defaultdict, namedtuple
|
||||
from itertools import count
|
||||
from cStringIO import StringIO
|
||||
try:
|
||||
import cPickle as pickle
|
||||
except ImportError:
|
||||
import pickle
|
||||
from twisted.protocols import amp
|
||||
from twisted.internet import protocol
|
||||
from twisted.internet.defer import Deferred
|
||||
from evennia.utils import logger
|
||||
from evennia.utils.utils import to_str, variable_from_module
|
||||
import zlib # Used in Compressed class
|
||||
|
||||
DUMMYSESSION = namedtuple('DummySession', ['sessid'])(0)
|
||||
|
||||
# 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
|
||||
AMP_MAXLEN = amp.MAX_VALUE_LENGTH # max allowed data length in AMP protocol (cannot be changed)
|
||||
|
||||
BATCH_RATE = 250 # max commands/sec before switching to batch-sending
|
||||
BATCH_TIMEOUT = 0.5 # how often to poll to empty batch queue, in seconds
|
||||
|
||||
# buffers
|
||||
_SENDBATCH = defaultdict(list)
|
||||
_MSGBUFFER = defaultdict(list)
|
||||
|
||||
|
||||
def get_restart_mode(restart_file):
|
||||
"""
|
||||
Parse the server/portal restart status
|
||||
|
||||
Args:
|
||||
restart_file (str): Path to restart.dat file.
|
||||
|
||||
Returns:
|
||||
restart_mode (bool): If the file indicates the server is in
|
||||
restart mode or not.
|
||||
|
||||
"""
|
||||
if os.path.exists(restart_file):
|
||||
flag = open(restart_file, 'r').read()
|
||||
return flag == "True"
|
||||
return False
|
||||
|
||||
|
||||
class AmpServerFactory(protocol.ServerFactory):
|
||||
"""
|
||||
This factory creates the Server as a new AMPProtocol instance for accepting
|
||||
connections from the Portal.
|
||||
"""
|
||||
noisy = False
|
||||
|
||||
def __init__(self, server):
|
||||
"""
|
||||
Initialize the factory.
|
||||
|
||||
Args:
|
||||
server (Server): The Evennia server service instance.
|
||||
protocol (Protocol): The protocol the factory creates
|
||||
instances of.
|
||||
|
||||
"""
|
||||
self.server = server
|
||||
self.protocol = AMPProtocol
|
||||
|
||||
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.server.amp_protocol = AMPProtocol()
|
||||
self.server.amp_protocol.factory = self
|
||||
return self.server.amp_protocol
|
||||
|
||||
|
||||
class AmpClientFactory(protocol.ReconnectingClientFactory):
|
||||
"""
|
||||
This factory creates an instance of the Portal, an AMPProtocol
|
||||
instances to use to connect
|
||||
|
||||
"""
|
||||
# Initial reconnect delay in seconds.
|
||||
initialDelay = 1
|
||||
factor = 1.5
|
||||
maxDelay = 1
|
||||
noisy = False
|
||||
|
||||
def __init__(self, portal):
|
||||
"""
|
||||
Initializes the client factory.
|
||||
|
||||
Args:
|
||||
portal (Portal): Portal instance.
|
||||
|
||||
"""
|
||||
self.portal = portal
|
||||
self.protocol = AMPProtocol
|
||||
|
||||
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 server.
|
||||
|
||||
Args:
|
||||
addr (str): Connection address. Not used.
|
||||
|
||||
"""
|
||||
self.resetDelay()
|
||||
self.portal.amp_protocol = AMPProtocol()
|
||||
self.portal.amp_protocol.factory = self
|
||||
return self.portal.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.
|
||||
|
||||
"""
|
||||
if hasattr(self, "server_restart_mode"):
|
||||
self.portal.sessions.announce_all(" Server restarting ...")
|
||||
self.maxDelay = 2
|
||||
else:
|
||||
# Don't translate this; avoid loading django on portal side.
|
||||
self.maxDelay = 10
|
||||
self.portal.sessions.announce_all(" ... Portal lost connection to Server.")
|
||||
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.
|
||||
|
||||
"""
|
||||
if hasattr(self, "server_restart_mode"):
|
||||
self.maxDelay = 2
|
||||
else:
|
||||
self.maxDelay = 10
|
||||
self.portal.sessions.announce_all(" ...")
|
||||
protocol.ReconnectingClientFactory.clientConnectionFailed(self, connector, reason)
|
||||
|
||||
|
||||
# AMP Communication Command types
|
||||
|
||||
class Compressed(amp.String):
|
||||
"""
|
||||
This is a customn 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 representation to python. We
|
||||
group very long data into batches.
|
||||
"""
|
||||
value = StringIO()
|
||||
value.write(strings.get(name))
|
||||
for counter in count(2):
|
||||
# count from 2 upwards
|
||||
chunk = strings.get("%s.%d" % (name, counter))
|
||||
if chunk is None:
|
||||
break
|
||||
value.write(chunk)
|
||||
objects[name] = value.getvalue()
|
||||
|
||||
def toBox(self, name, strings, objects, proto):
|
||||
"""
|
||||
Convert from data to box. We handled too-long
|
||||
batched data and put it together here.
|
||||
"""
|
||||
value = StringIO(objects[name])
|
||||
strings[name] = value.read(AMP_MAXLEN)
|
||||
for counter in count(2):
|
||||
chunk = value.read(AMP_MAXLEN)
|
||||
if not chunk:
|
||||
break
|
||||
strings["%s.%d" % (name, counter)] = chunk
|
||||
|
||||
def toString(self, inObject):
|
||||
"""
|
||||
Convert to send on the wire, with compression.
|
||||
"""
|
||||
return zlib.compress(inObject, 9)
|
||||
|
||||
def fromString(self, inString):
|
||||
"""
|
||||
Convert (decompress) from the wire to Python.
|
||||
"""
|
||||
return zlib.decompress(inString)
|
||||
|
||||
|
||||
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 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())]
|
||||
|
||||
|
||||
# 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))
|
||||
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# Core AMP protocol for communication Server <-> Portal
|
||||
# -------------------------------------------------------------
|
||||
|
||||
class AMPProtocol(amp.AMP):
|
||||
"""
|
||||
This is the protocol that the MUD server and the proxy server
|
||||
communicate to each other with. AMP is a bi-directional protocol,
|
||||
so both the proxy and the MUD use the same commands and protocol.
|
||||
|
||||
AMP specifies responder methods here and connect them to
|
||||
amp.Command subclasses that specify the datatypes of the
|
||||
input/output of these methods.
|
||||
|
||||
"""
|
||||
|
||||
# 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
|
||||
|
||||
def connectionMade(self):
|
||||
"""
|
||||
This is called when an AMP connection is (re-)established
|
||||
between server and portal. AMP calls it on both sides, so we
|
||||
need to make sure to only trigger resync from the portal side.
|
||||
|
||||
"""
|
||||
# this makes for a factor x10 faster sends across the wire
|
||||
self.transport.setTcpNoDelay(True)
|
||||
|
||||
if hasattr(self.factory, "portal"):
|
||||
# only the portal has the 'portal' property, so we know we are
|
||||
# on the portal side and can initialize the connection.
|
||||
sessdata = self.factory.portal.sessions.get_all_sync_data()
|
||||
self.send_AdminPortal2Server(DUMMYSESSION,
|
||||
PSYNC,
|
||||
sessiondata=sessdata)
|
||||
self.factory.portal.sessions.at_server_connection()
|
||||
if hasattr(self.factory, "server_restart_mode"):
|
||||
del self.factory.server_restart_mode
|
||||
|
||||
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.
|
||||
"""
|
||||
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.
|
||||
|
||||
"""
|
||||
e.trap(Exception)
|
||||
logger.log_err("AMP Error for %(info)s: %(e)s" % {'info': info,
|
||||
'e': e.getErrorMessage()})
|
||||
|
||||
def send_data(self, command, sessid, **kwargs):
|
||||
"""
|
||||
Send data across the wire.
|
||||
|
||||
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).
|
||||
|
||||
"""
|
||||
return self.callRemote(command,
|
||||
packed_data=dumps((sessid, kwargs))
|
||||
).addErrback(self.errback, command.key)
|
||||
|
||||
# Message definition + helper methods to call/create each message type
|
||||
|
||||
# Portal -> Server Msg
|
||||
|
||||
@MsgPortal2Server.responder
|
||||
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 = loads(packed_data)
|
||||
session = self.factory.server.sessions.get(sessid, None)
|
||||
if session:
|
||||
self.factory.server.sessions.data_in(session, **kwargs)
|
||||
return {}
|
||||
|
||||
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.send_data(MsgPortal2Server, session.sessid, **kwargs)
|
||||
|
||||
# Server -> Portal message
|
||||
|
||||
@MsgServer2Portal.responder
|
||||
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.
|
||||
"""
|
||||
sessid, kwargs = loads(packed_data)
|
||||
session = self.factory.portal.sessions.get(sessid, None)
|
||||
if session:
|
||||
self.factory.portal.sessions.data_out(session, **kwargs)
|
||||
return {}
|
||||
|
||||
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.send_data(MsgServer2Portal, session.sessid, **kwargs)
|
||||
|
||||
# Server administration from the Portal side
|
||||
@AdminPortal2Server.responder
|
||||
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 = loads(packed_data)
|
||||
operation = kwargs.pop("operation", "")
|
||||
server_sessionhandler = self.factory.server.sessions
|
||||
|
||||
if operation == PCONN: # portal_session_connect
|
||||
# create a new session and sync it
|
||||
server_sessionhandler.portal_connect(kwargs.get("sessiondata"))
|
||||
|
||||
elif operation == PCONNSYNC: # portal_session_sync
|
||||
server_sessionhandler.portal_session_sync(kwargs.get("sessiondata"))
|
||||
|
||||
elif operation == PDISCONN: # portal_session_disconnect
|
||||
# session closed from portal sid
|
||||
session = server_sessionhandler.get(sessid)
|
||||
if session:
|
||||
server_sessionhandler.portal_disconnect(session)
|
||||
|
||||
elif operation == PDISCONNALL: # portal_disconnect_all
|
||||
# portal orders all sessions to close
|
||||
server_sessionhandler.portal_disconnect_all()
|
||||
|
||||
elif operation == PSYNC: # portal_session_sync
|
||||
# force a resync of sessions when portal reconnects to
|
||||
# server (e.g. after a server reboot) the data kwarg
|
||||
# contains a dict {sessid: {arg1:val1,...}}
|
||||
# representing the attributes to sync for each
|
||||
# session.
|
||||
server_sessionhandler.portal_sessions_sync(kwargs.get("sessiondata"))
|
||||
else:
|
||||
raise Exception("operation %(op)s not recognized." % {'op': operation})
|
||||
return {}
|
||||
|
||||
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.send_data(AdminPortal2Server, session.sessid, operation=operation, **kwargs)
|
||||
|
||||
# Portal administration from the Server side
|
||||
|
||||
@AdminServer2Portal.responder
|
||||
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).
|
||||
|
||||
"""
|
||||
sessid, kwargs = loads(packed_data)
|
||||
operation = kwargs.pop("operation")
|
||||
portal_sessionhandler = self.factory.portal.sessions
|
||||
|
||||
if operation == 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 == 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 == SDISCONNALL: # server_session_disconnect_all
|
||||
# server orders all sessions to disconnect
|
||||
portal_sessionhandler.server_disconnect_all(reason=kwargs.get("reason"))
|
||||
|
||||
elif operation == SSHUTD: # server_shutdown
|
||||
# the server orders the portal to shut down
|
||||
self.factory.portal.shutdown(restart=False)
|
||||
|
||||
elif operation == 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 == SCONN: # server_force_connection (for irc/etc)
|
||||
portal_sessionhandler.server_connect(**kwargs)
|
||||
|
||||
else:
|
||||
raise Exception("operation %(op)s not recognized." % {'op': operation})
|
||||
return {}
|
||||
|
||||
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`.
|
||||
data (str or dict, optional): Data going into the adminstrative.
|
||||
|
||||
"""
|
||||
return self.send_data(AdminServer2Portal, session.sessid, operation=operation, **kwargs)
|
||||
|
||||
# Extra functions
|
||||
|
||||
@FunctionCall.responder
|
||||
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)}
|
||||
|
||||
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")
|
||||
241
evennia/server/amp_client.py
Normal file
241
evennia/server/amp_client.py
Normal 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
|
|
@ -1,357 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
"""
|
||||
|
||||
This runner is controlled by the evennia launcher and should normally
|
||||
not be launched directly. It manages the two main Evennia processes
|
||||
(Server and Portal) and most importantly runs a passive, threaded loop
|
||||
that makes sure to restart Server whenever it shuts down.
|
||||
|
||||
Since twistd does not allow for returning an optional exit code we
|
||||
need to handle the current reload state for server and portal with
|
||||
flag-files instead. The files, one each for server and portal either
|
||||
contains True or False indicating if the process should be restarted
|
||||
upon returning, or not. A process returning != 0 will always stop, no
|
||||
matter the value of this file.
|
||||
|
||||
"""
|
||||
from __future__ import print_function
|
||||
import os
|
||||
import sys
|
||||
from argparse import ArgumentParser
|
||||
from subprocess import Popen
|
||||
import Queue
|
||||
import thread
|
||||
import evennia
|
||||
|
||||
try:
|
||||
# check if launched with pypy
|
||||
import __pypy__ as is_pypy
|
||||
except ImportError:
|
||||
is_pypy = False
|
||||
|
||||
SERVER = None
|
||||
PORTAL = None
|
||||
|
||||
EVENNIA_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
EVENNIA_BIN = os.path.join(EVENNIA_ROOT, "bin")
|
||||
EVENNIA_LIB = os.path.dirname(evennia.__file__)
|
||||
|
||||
SERVER_PY_FILE = os.path.join(EVENNIA_LIB, 'server', 'server.py')
|
||||
PORTAL_PY_FILE = os.path.join(EVENNIA_LIB, 'server', 'portal', 'portal.py')
|
||||
|
||||
GAMEDIR = None
|
||||
SERVERDIR = "server"
|
||||
SERVER_PIDFILE = None
|
||||
PORTAL_PIDFILE = None
|
||||
SERVER_RESTART = None
|
||||
PORTAL_RESTART = None
|
||||
SERVER_LOGFILE = None
|
||||
PORTAL_LOGFILE = None
|
||||
HTTP_LOGFILE = None
|
||||
PPROFILER_LOGFILE = None
|
||||
SPROFILER_LOGFILE = None
|
||||
|
||||
# messages
|
||||
|
||||
CMDLINE_HELP = \
|
||||
"""
|
||||
This program manages the running Evennia processes. It is called
|
||||
by evennia and should not be started manually. Its main task is to
|
||||
sit and watch the Server and restart it whenever the user reloads.
|
||||
The runner depends on four files for its operation, two PID files
|
||||
and two RESTART files for Server and Portal respectively; these
|
||||
are stored in the game's server/ directory.
|
||||
"""
|
||||
|
||||
PROCESS_ERROR = \
|
||||
"""
|
||||
{component} process error: {traceback}.
|
||||
"""
|
||||
|
||||
PROCESS_IOERROR = \
|
||||
"""
|
||||
{component} IOError: {traceback}
|
||||
One possible explanation is that 'twistd' was not found.
|
||||
"""
|
||||
|
||||
PROCESS_RESTART = "{component} restarting ..."
|
||||
|
||||
PROCESS_DOEXIT = "Deferring to external runner."
|
||||
|
||||
# Functions
|
||||
|
||||
|
||||
def set_restart_mode(restart_file, flag="reload"):
|
||||
"""
|
||||
This sets a flag file for the restart mode.
|
||||
"""
|
||||
with open(restart_file, 'w') as f:
|
||||
f.write(str(flag))
|
||||
|
||||
|
||||
def getenv():
|
||||
"""
|
||||
Get current environment and add PYTHONPATH
|
||||
"""
|
||||
sep = ";" if os.name == "nt" else ":"
|
||||
env = os.environ.copy()
|
||||
sys.path.insert(0, GAMEDIR)
|
||||
env['PYTHONPATH'] = sep.join(sys.path)
|
||||
return env
|
||||
|
||||
|
||||
def get_restart_mode(restart_file):
|
||||
"""
|
||||
Parse the server/portal restart status
|
||||
"""
|
||||
if os.path.exists(restart_file):
|
||||
with open(restart_file, 'r') as f:
|
||||
return f.read()
|
||||
return "shutdown"
|
||||
|
||||
|
||||
def get_pid(pidfile):
|
||||
"""
|
||||
Get the PID (Process ID) by trying to access
|
||||
an PID file.
|
||||
"""
|
||||
pid = None
|
||||
if os.path.exists(pidfile):
|
||||
with open(pidfile, 'r') as f:
|
||||
pid = f.read()
|
||||
return pid
|
||||
|
||||
|
||||
def cycle_logfile(logfile):
|
||||
"""
|
||||
Rotate the old log files to <filename>.old
|
||||
"""
|
||||
logfile_old = logfile + '.old'
|
||||
if os.path.exists(logfile):
|
||||
# Cycle the old logfiles to *.old
|
||||
if os.path.exists(logfile_old):
|
||||
# E.g. Windows don't support rename-replace
|
||||
os.remove(logfile_old)
|
||||
os.rename(logfile, logfile_old)
|
||||
|
||||
# Start program management
|
||||
|
||||
|
||||
def start_services(server_argv, portal_argv, doexit=False):
|
||||
"""
|
||||
This calls a threaded loop that launches the Portal and Server
|
||||
and then restarts them when they finish.
|
||||
"""
|
||||
global SERVER, PORTAL
|
||||
processes = Queue.Queue()
|
||||
|
||||
def server_waiter(queue):
|
||||
try:
|
||||
rc = Popen(server_argv, env=getenv()).wait()
|
||||
except Exception as e:
|
||||
print(PROCESS_ERROR.format(component="Server", traceback=e))
|
||||
return
|
||||
# this signals the controller that the program finished
|
||||
queue.put(("server_stopped", rc))
|
||||
|
||||
def portal_waiter(queue):
|
||||
try:
|
||||
rc = Popen(portal_argv, env=getenv()).wait()
|
||||
except Exception as e:
|
||||
print(PROCESS_ERROR.format(component="Portal", traceback=e))
|
||||
return
|
||||
# this signals the controller that the program finished
|
||||
queue.put(("portal_stopped", rc))
|
||||
|
||||
if portal_argv:
|
||||
try:
|
||||
if not doexit and get_restart_mode(PORTAL_RESTART) == "True":
|
||||
# start portal as interactive, reloadable thread
|
||||
PORTAL = thread.start_new_thread(portal_waiter, (processes, ))
|
||||
else:
|
||||
# normal operation: start portal as a daemon;
|
||||
# we don't care to monitor it for restart
|
||||
PORTAL = Popen(portal_argv, env=getenv())
|
||||
except IOError as e:
|
||||
print(PROCESS_IOERROR.format(component="Portal", traceback=e))
|
||||
return
|
||||
|
||||
try:
|
||||
if server_argv:
|
||||
if doexit:
|
||||
SERVER = Popen(server_argv, env=getenv())
|
||||
else:
|
||||
# start server as a reloadable thread
|
||||
SERVER = thread.start_new_thread(server_waiter, (processes, ))
|
||||
except IOError as e:
|
||||
print(PROCESS_IOERROR.format(component="Server", traceback=e))
|
||||
return
|
||||
|
||||
if doexit:
|
||||
# Exit immediately
|
||||
return
|
||||
|
||||
# Reload loop
|
||||
while True:
|
||||
|
||||
# this blocks until something is actually returned.
|
||||
from twisted.internet.error import ReactorNotRunning
|
||||
try:
|
||||
try:
|
||||
message, rc = processes.get()
|
||||
except KeyboardInterrupt:
|
||||
# this only matters in interactive mode
|
||||
break
|
||||
|
||||
# restart only if process stopped cleanly
|
||||
if (message == "server_stopped" and int(rc) == 0 and
|
||||
get_restart_mode(SERVER_RESTART) in ("True", "reload", "reset")):
|
||||
print(PROCESS_RESTART.format(component="Server"))
|
||||
SERVER = thread.start_new_thread(server_waiter, (processes, ))
|
||||
continue
|
||||
|
||||
# normally the portal is not reloaded since it's run as a daemon.
|
||||
if (message == "portal_stopped" and int(rc) == 0 and
|
||||
get_restart_mode(PORTAL_RESTART) == "True"):
|
||||
print(PROCESS_RESTART.format(component="Portal"))
|
||||
PORTAL = thread.start_new_thread(portal_waiter, (processes, ))
|
||||
continue
|
||||
break
|
||||
except ReactorNotRunning:
|
||||
break
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
This handles the command line input of the runner, usually created by
|
||||
the evennia launcher
|
||||
"""
|
||||
|
||||
parser = ArgumentParser(description=CMDLINE_HELP)
|
||||
parser.add_argument('--noserver', action='store_true', dest='noserver',
|
||||
default=False, help='Do not start Server process')
|
||||
parser.add_argument('--noportal', action='store_true', dest='noportal',
|
||||
default=False, help='Do not start Portal process')
|
||||
parser.add_argument('--logserver', action='store_true', dest='logserver',
|
||||
default=False, help='Log Server output to logfile')
|
||||
parser.add_argument('--iserver', action='store_true', dest='iserver',
|
||||
default=False, help='Server in interactive mode')
|
||||
parser.add_argument('--iportal', action='store_true', dest='iportal',
|
||||
default=False, help='Portal in interactive mode')
|
||||
parser.add_argument('--pserver', action='store_true', dest='pserver',
|
||||
default=False, help='Profile Server')
|
||||
parser.add_argument('--pportal', action='store_true', dest='pportal',
|
||||
default=False, help='Profile Portal')
|
||||
parser.add_argument('--nologcycle', action='store_false', dest='nologcycle',
|
||||
default=True, help='Do not cycle log files')
|
||||
parser.add_argument('--doexit', action='store_true', dest='doexit',
|
||||
default=False, help='Immediately exit after processes have started.')
|
||||
parser.add_argument('gamedir', help="path to game dir")
|
||||
parser.add_argument('twistdbinary', help="path to twistd binary")
|
||||
parser.add_argument('slogfile', help="path to server log file")
|
||||
parser.add_argument('plogfile', help="path to portal log file")
|
||||
parser.add_argument('hlogfile', help="path to http log file")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
global GAMEDIR
|
||||
global SERVER_LOGFILE, PORTAL_LOGFILE, HTTP_LOGFILE
|
||||
global SERVER_PIDFILE, PORTAL_PIDFILE
|
||||
global SERVER_RESTART, PORTAL_RESTART
|
||||
global SPROFILER_LOGFILE, PPROFILER_LOGFILE
|
||||
|
||||
GAMEDIR = args.gamedir
|
||||
sys.path.insert(1, os.path.join(GAMEDIR, SERVERDIR))
|
||||
|
||||
SERVER_PIDFILE = os.path.join(GAMEDIR, SERVERDIR, "server.pid")
|
||||
PORTAL_PIDFILE = os.path.join(GAMEDIR, SERVERDIR, "portal.pid")
|
||||
SERVER_RESTART = os.path.join(GAMEDIR, SERVERDIR, "server.restart")
|
||||
PORTAL_RESTART = os.path.join(GAMEDIR, SERVERDIR, "portal.restart")
|
||||
SERVER_LOGFILE = args.slogfile
|
||||
PORTAL_LOGFILE = args.plogfile
|
||||
HTTP_LOGFILE = args.hlogfile
|
||||
TWISTED_BINARY = args.twistdbinary
|
||||
SPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "server.prof")
|
||||
PPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "portal.prof")
|
||||
|
||||
# set up default project calls
|
||||
server_argv = [TWISTED_BINARY,
|
||||
'--nodaemon',
|
||||
'--logfile=%s' % SERVER_LOGFILE,
|
||||
'--pidfile=%s' % SERVER_PIDFILE,
|
||||
'--python=%s' % SERVER_PY_FILE]
|
||||
portal_argv = [TWISTED_BINARY,
|
||||
'--logfile=%s' % PORTAL_LOGFILE,
|
||||
'--pidfile=%s' % PORTAL_PIDFILE,
|
||||
'--python=%s' % PORTAL_PY_FILE]
|
||||
|
||||
# Profiling settings (read file from python shell e.g with
|
||||
# p = pstats.Stats('server.prof')
|
||||
pserver_argv = ['--savestats',
|
||||
'--profiler=cprofile',
|
||||
'--profile=%s' % SPROFILER_LOGFILE]
|
||||
pportal_argv = ['--savestats',
|
||||
'--profiler=cprofile',
|
||||
'--profile=%s' % PPROFILER_LOGFILE]
|
||||
|
||||
# Server
|
||||
|
||||
pid = get_pid(SERVER_PIDFILE)
|
||||
if pid and not args.noserver:
|
||||
print("\nEvennia Server is already running as process %(pid)s. Not restarted." % {'pid': pid})
|
||||
args.noserver = True
|
||||
if args.noserver:
|
||||
server_argv = None
|
||||
else:
|
||||
set_restart_mode(SERVER_RESTART, "shutdown")
|
||||
if not args.logserver:
|
||||
# don't log to server logfile
|
||||
del server_argv[2]
|
||||
print("\nStarting Evennia Server (output to stdout).")
|
||||
else:
|
||||
if not args.nologcycle:
|
||||
cycle_logfile(SERVER_LOGFILE)
|
||||
print("\nStarting Evennia Server (output to server logfile).")
|
||||
if args.pserver:
|
||||
server_argv.extend(pserver_argv)
|
||||
print("\nRunning Evennia Server under cProfile.")
|
||||
|
||||
# Portal
|
||||
|
||||
pid = get_pid(PORTAL_PIDFILE)
|
||||
if pid and not args.noportal:
|
||||
print("\nEvennia Portal is already running as process %(pid)s. Not restarted." % {'pid': pid})
|
||||
args.noportal = True
|
||||
if args.noportal:
|
||||
portal_argv = None
|
||||
else:
|
||||
if args.iportal:
|
||||
# make portal interactive
|
||||
portal_argv[1] = '--nodaemon'
|
||||
set_restart_mode(PORTAL_RESTART, True)
|
||||
print("\nStarting Evennia Portal in non-Daemon mode (output to stdout).")
|
||||
else:
|
||||
if not args.nologcycle:
|
||||
cycle_logfile(PORTAL_LOGFILE)
|
||||
cycle_logfile(HTTP_LOGFILE)
|
||||
set_restart_mode(PORTAL_RESTART, False)
|
||||
print("\nStarting Evennia Portal in Daemon mode (output to portal logfile).")
|
||||
if args.pportal:
|
||||
portal_argv.extend(pportal_argv)
|
||||
print("\nRunning Evennia Portal under cProfile.")
|
||||
if args.doexit:
|
||||
print(PROCESS_DOEXIT)
|
||||
|
||||
# Windows fixes (Windows don't support pidfiles natively)
|
||||
if os.name == 'nt':
|
||||
if server_argv:
|
||||
del server_argv[-2]
|
||||
if portal_argv:
|
||||
del portal_argv[-2]
|
||||
|
||||
# Start processes
|
||||
start_services(server_argv, portal_argv, doexit=args.doexit)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
438
evennia/server/portal/amp.py
Normal file
438
evennia/server/portal/amp.py
Normal 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)}
|
||||
458
evennia/server/portal/amp_server.py
Normal file
458
evennia/server/portal/amp_server.py
Normal 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 {}
|
||||
|
|
@ -7,14 +7,16 @@ sets up all the networking features. (this is done automatically
|
|||
by game/evennia.py).
|
||||
|
||||
"""
|
||||
from __future__ import print_function
|
||||
from builtins import object
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
from os.path import dirname, abspath
|
||||
from twisted.application import internet, service
|
||||
from twisted.internet import protocol, reactor
|
||||
from twisted.python.log import ILogObserver
|
||||
|
||||
import django
|
||||
django.setup()
|
||||
from django.conf import settings
|
||||
|
|
@ -24,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
|
||||
|
||||
|
|
@ -37,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
|
||||
# -------------------------------------------------------------
|
||||
|
|
@ -77,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):
|
||||
|
||||
"""
|
||||
|
|
@ -105,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
|
||||
|
|
@ -150,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.
|
||||
|
|
@ -172,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:
|
||||
|
||||
|
|
@ -187,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' % 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.
|
||||
|
|
@ -212,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
|
||||
|
|
@ -220,12 +240,12 @@ if TELNET_ENABLED:
|
|||
telnet_service.setName('EvenniaTelnet%s' % pstring)
|
||||
PORTAL.services.addService(telnet_service)
|
||||
|
||||
print(' telnet%s: %s' % (ifacestr, port))
|
||||
INFO_DICT["telnet"].append('telnet%s: %s' % (ifacestr, port))
|
||||
|
||||
|
||||
if SSL_ENABLED:
|
||||
|
||||
# Start Telnet SSL game connection (requires PyOpenSSL).
|
||||
# Start Telnet+SSL game connection (requires PyOpenSSL).
|
||||
|
||||
from evennia.server.portal import telnet_ssl
|
||||
|
||||
|
|
@ -249,9 +269,10 @@ if SSL_ENABLED:
|
|||
ssl_service.setName('EvenniaSSL%s' % pstring)
|
||||
PORTAL.services.addService(ssl_service)
|
||||
|
||||
print(" ssl%s: %s" % (ifacestr, port))
|
||||
INFO_DICT["telnet_ssl"].append("telnet+ssl%s: %s" % (ifacestr, port))
|
||||
else:
|
||||
print(" ssl%s: %s (deactivated - keys/certificate unset)" % (ifacestr, port))
|
||||
INFO_DICT["telnet_ssl"].append(
|
||||
"telnet+ssl%s: %s (deactivated - keys/cert unset)" % (ifacestr, port))
|
||||
|
||||
|
||||
if SSH_ENABLED:
|
||||
|
|
@ -275,7 +296,7 @@ if SSH_ENABLED:
|
|||
ssh_service.setName('EvenniaSSH%s' % pstring)
|
||||
PORTAL.services.addService(ssh_service)
|
||||
|
||||
print(" ssh%s: %s" % (ifacestr, port))
|
||||
INFO_DICT["ssh"].append("ssh%s: %s" % (ifacestr, port))
|
||||
|
||||
|
||||
if WEBSERVER_ENABLED:
|
||||
|
|
@ -289,7 +310,6 @@ if WEBSERVER_ENABLED:
|
|||
if interface not in ('0.0.0.0', '::') or len(WEBSERVER_INTERFACES) > 1:
|
||||
ifacestr = "-%s" % interface
|
||||
for proxyport, serverport in WEBSERVER_PORTS:
|
||||
pstring = "%s:%s<->%s" % (ifacestr, proxyport, serverport)
|
||||
web_root = EvenniaReverseProxyResource('127.0.0.1', serverport, '')
|
||||
webclientstr = ""
|
||||
if WEBCLIENT_ENABLED:
|
||||
|
|
@ -299,7 +319,7 @@ if WEBSERVER_ENABLED:
|
|||
ajax_webclient = webclient_ajax.AjaxWebClient()
|
||||
ajax_webclient.sessionhandler = PORTAL_SESSIONS
|
||||
web_root.putChild("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
|
||||
|
|
@ -307,38 +327,39 @@ if WEBSERVER_ENABLED:
|
|||
from evennia.server.portal import webclient
|
||||
from evennia.utils.txws import WebSocketFactory
|
||||
|
||||
interface = WEBSOCKET_CLIENT_INTERFACE
|
||||
w_interface = WEBSOCKET_CLIENT_INTERFACE
|
||||
w_ifacestr = ''
|
||||
if w_interface not in ('0.0.0.0', '::') or len(WEBSERVER_INTERFACES) > 1:
|
||||
w_ifacestr = "-%s" % interface
|
||||
port = WEBSOCKET_CLIENT_PORT
|
||||
ifacestr = ""
|
||||
if interface not in ('0.0.0.0', '::'):
|
||||
ifacestr = "-%s" % interface
|
||||
pstring = "%s:%s" % (ifacestr, port)
|
||||
factory = protocol.ServerFactory()
|
||||
|
||||
class Websocket(protocol.ServerFactory):
|
||||
"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, WebSocketFactory(factory), interface=interface)
|
||||
websocket_service.setName('EvenniaWebSocket%s' % pstring)
|
||||
websocket_service = internet.TCPServer(port, WebSocketFactory(factory),
|
||||
interface=w_interface)
|
||||
websocket_service.setName('EvenniaWebSocket%s:%s' % (w_ifacestr, port))
|
||||
PORTAL.services.addService(websocket_service)
|
||||
websocket_started = True
|
||||
webclientstr = "\n + webclient%s" % pstring
|
||||
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(" webproxy%s:%s (<-> %s)%s" % (ifacestr, proxyport, serverport, 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()))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -72,6 +72,15 @@ 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):
|
||||
"""
|
||||
Each account connecting over ssh gets this protocol assigned to
|
||||
|
|
|
|||
|
|
@ -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 = 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
|
||||
|
|
@ -49,10 +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_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)
|
||||
|
|
@ -72,7 +84,7 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
|
|||
|
||||
from evennia.utils.utils import delay
|
||||
# timeout the handshakes in case the client doesn't reply at all
|
||||
delay(2, callback=self.handshake_done, timeout=True)
|
||||
self._handshake_delay = delay(2, callback=self.handshake_done, timeout=True)
|
||||
|
||||
# TCP/IP keepalive watches for dead links
|
||||
self.transport.setTcpKeepAlive(1)
|
||||
|
|
|
|||
|
|
@ -8,9 +8,24 @@ try:
|
|||
except ImportError:
|
||||
import unittest
|
||||
|
||||
from mock import Mock
|
||||
import string
|
||||
from evennia.server.portal import irc
|
||||
|
||||
from twisted.conch.telnet import IAC, WILL, DONT, SB, SE, NAWS, DO
|
||||
from twisted.test import proto_helpers
|
||||
from twisted.trial.unittest import TestCase as TwistedTestCase
|
||||
|
||||
from .telnet import TelnetServerFactory, TelnetProtocol
|
||||
from .portal import PORTAL_SESSIONS
|
||||
from .suppress_ga import SUPPRESS_GA
|
||||
from .naws import DEFAULT_HEIGHT, DEFAULT_WIDTH
|
||||
from .ttype import TTYPE, IS
|
||||
from .mccp import MCCP
|
||||
from .mssp import MSSP
|
||||
from .mxp import MXP
|
||||
from .telnet_oob import MSDP, MSDP_VAL, MSDP_VAR
|
||||
|
||||
|
||||
class TestIRC(TestCase):
|
||||
|
||||
|
|
@ -73,3 +88,64 @@ class TestIRC(TestCase):
|
|||
s = r'|wthis|Xis|gis|Ma|C|complex|*string'
|
||||
|
||||
self.assertEqual(irc.parse_irc_to_ansi(irc.parse_ansi_to_irc(s)), s)
|
||||
|
||||
|
||||
class TestTelnet(TwistedTestCase):
|
||||
def setUp(self):
|
||||
super(TestTelnet, self).setUp()
|
||||
factory = TelnetServerFactory()
|
||||
factory.protocol = TelnetProtocol
|
||||
factory.sessionhandler = PORTAL_SESSIONS
|
||||
factory.sessionhandler.portal = Mock()
|
||||
self.proto = factory.buildProtocol(("localhost", 0))
|
||||
self.transport = proto_helpers.StringTransport()
|
||||
self.addCleanup(factory.sessionhandler.disconnect_all)
|
||||
|
||||
def test_mudlet_ttype(self):
|
||||
self.transport.client = ["localhost"]
|
||||
self.transport.setTcpKeepAlive = Mock()
|
||||
d = self.proto.makeConnection(self.transport)
|
||||
# test suppress_ga
|
||||
self.assertTrue(self.proto.protocol_flags["NOGOAHEAD"])
|
||||
self.proto.dataReceived(IAC + DONT + SUPPRESS_GA)
|
||||
self.assertFalse(self.proto.protocol_flags["NOGOAHEAD"])
|
||||
self.assertEqual(self.proto.handshakes, 7)
|
||||
# test naws
|
||||
self.assertEqual(self.proto.protocol_flags['SCREENWIDTH'], {0: DEFAULT_WIDTH})
|
||||
self.assertEqual(self.proto.protocol_flags['SCREENHEIGHT'], {0: DEFAULT_HEIGHT})
|
||||
self.proto.dataReceived(IAC + WILL + NAWS)
|
||||
self.proto.dataReceived([IAC, SB, NAWS, '', 'x', '', 'd', IAC, SE])
|
||||
self.assertEqual(self.proto.protocol_flags['SCREENWIDTH'][0], 120)
|
||||
self.assertEqual(self.proto.protocol_flags['SCREENHEIGHT'][0], 100)
|
||||
self.assertEqual(self.proto.handshakes, 6)
|
||||
# test ttype
|
||||
self.assertTrue(self.proto.protocol_flags["FORCEDENDLINE"])
|
||||
self.assertFalse(self.proto.protocol_flags["TTYPE"])
|
||||
self.assertTrue(self.proto.protocol_flags["ANSI"])
|
||||
self.proto.dataReceived(IAC + WILL + TTYPE)
|
||||
self.proto.dataReceived([IAC, SB, TTYPE, IS, "MUDLET", IAC, SE])
|
||||
self.assertTrue(self.proto.protocol_flags["XTERM256"])
|
||||
self.assertEqual(self.proto.protocol_flags["CLIENTNAME"], "MUDLET")
|
||||
self.proto.dataReceived([IAC, SB, TTYPE, IS, "XTERM", IAC, SE])
|
||||
self.proto.dataReceived([IAC, SB, TTYPE, IS, "MTTS 137", IAC, SE])
|
||||
self.assertEqual(self.proto.handshakes, 5)
|
||||
# test mccp
|
||||
self.proto.dataReceived(IAC + DONT + MCCP)
|
||||
self.assertFalse(self.proto.protocol_flags['MCCP'])
|
||||
self.assertEqual(self.proto.handshakes, 4)
|
||||
# test mssp
|
||||
self.proto.dataReceived(IAC + DONT + MSSP)
|
||||
self.assertEqual(self.proto.handshakes, 3)
|
||||
# test oob
|
||||
self.proto.dataReceived(IAC + DO + MSDP)
|
||||
self.proto.dataReceived([IAC, SB, MSDP, MSDP_VAR, "LIST", MSDP_VAL, "COMMANDS", IAC, SE])
|
||||
self.assertTrue(self.proto.protocol_flags['OOB'])
|
||||
self.assertEqual(self.proto.handshakes, 2)
|
||||
# test mxp
|
||||
self.proto.dataReceived(IAC + DONT + MXP)
|
||||
self.assertFalse(self.proto.protocol_flags['MXP'])
|
||||
self.assertEqual(self.proto.handshakes, 1)
|
||||
# clean up to prevent Unclean reactor
|
||||
self.proto.nop_keep_alive.stop()
|
||||
self.proto._handshake_delay.cancel()
|
||||
return d
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ class Ttype(object):
|
|||
self.protocol.protocol_flags["FORCEDENDLINE"] = False
|
||||
|
||||
if cupper.startswith("TINTIN++"):
|
||||
self.protocol.protocol_flags["FORCEDENDLINE"] = False
|
||||
self.protocol.protocol_flags["FORCEDENDLINE"] = True
|
||||
|
||||
if (cupper.startswith("XTERM") or
|
||||
cupper.endswith("-256COLOR") or
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
"""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,14 +13,14 @@ import time
|
|||
# TODO!
|
||||
#sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
|
||||
#os.environ['DJANGO_SETTINGS_MODULE'] = 'game.settings'
|
||||
import ev
|
||||
from evennia.utils.idmapper import base as _idmapper
|
||||
import evennia
|
||||
from evennia.utils.idmapper import models as _idmapper
|
||||
|
||||
LOGFILE = "logs/memoryusage.log"
|
||||
INTERVAL = 30 # log every 30 seconds
|
||||
|
||||
|
||||
class Memplot(ev.Script):
|
||||
class Memplot(evennia.DefaultScript):
|
||||
"""
|
||||
Describes a memory plotting action.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
from django.test import TestCase
|
||||
from mock import Mock
|
||||
from mock import Mock, patch, mock_open
|
||||
from .dummyrunner_settings import (c_creates_button, c_creates_obj, c_digs, c_examines, c_help, c_idles, c_login,
|
||||
c_login_nodig, c_logout, c_looks, c_moves, c_moves_n, c_moves_s, c_socialize)
|
||||
import memplot
|
||||
|
||||
|
||||
class TestDummyrunnerSettings(TestCase):
|
||||
|
|
@ -91,3 +92,21 @@ class TestDummyrunnerSettings(TestCase):
|
|||
|
||||
def test_c_move_s(self):
|
||||
self.assertEqual(c_moves_s(self.client), "south")
|
||||
|
||||
|
||||
class TestMemPlot(TestCase):
|
||||
@patch.object(memplot, "_idmapper")
|
||||
@patch.object(memplot, "os")
|
||||
@patch.object(memplot, "open", new_callable=mock_open, create=True)
|
||||
@patch.object(memplot, "time")
|
||||
def test_memplot(self, mock_time, mocked_open, mocked_os, mocked_idmapper):
|
||||
from evennia.utils.create import create_script
|
||||
mocked_idmapper.cache_size.return_value = (9, 5000)
|
||||
mock_time.time = Mock(return_value=6000.0)
|
||||
script = create_script(memplot.Memplot)
|
||||
script.db.starttime = 0.0
|
||||
mocked_os.popen.read.return_value = 5000.0
|
||||
script.at_repeat()
|
||||
handle = mocked_open()
|
||||
handle.write.assert_called_with('100.0, 0.001, 0.001, 9\n')
|
||||
script.stop()
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ sets up all the networking features. (this is done automatically
|
|||
by evennia/server/server_runner.py).
|
||||
|
||||
"""
|
||||
from __future__ import print_function
|
||||
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
|
||||
|
||||
|
|
@ -172,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()
|
||||
|
||||
|
|
@ -192,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):
|
||||
|
|
@ -211,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')):
|
||||
|
|
@ -229,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",
|
||||
|
|
@ -247,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:
|
||||
|
|
@ -273,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()
|
||||
|
|
@ -307,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.
|
||||
|
||||
|
|
@ -358,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
|
||||
|
|
@ -369,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
|
||||
|
||||
|
|
@ -381,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
|
||||
|
|
@ -411,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()
|
||||
|
||||
|
|
@ -426,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):
|
||||
|
|
@ -451,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')
|
||||
|
|
@ -529,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
|
||||
|
|
@ -545,20 +526,20 @@ if AMP_ENABLED:
|
|||
ifacestr = ""
|
||||
if AMP_INTERFACE != '127.0.0.1':
|
||||
ifacestr = "-%s" % AMP_INTERFACE
|
||||
print(' amp (to Portal)%s: %s' % (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
|
||||
|
|
@ -578,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" % serverport)
|
||||
INFO_DICT["webserver"] += "webserver: %s" % serverport
|
||||
|
||||
ENABLED = []
|
||||
if IRC_ENABLED:
|
||||
|
|
@ -597,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()))
|
||||
|
|
|
|||
|
|
@ -407,7 +407,7 @@ class ServerSession(Session):
|
|||
else:
|
||||
self.data_out(**kwargs)
|
||||
|
||||
def execute_cmd(self, raw_string, **kwargs):
|
||||
def execute_cmd(self, raw_string, session=None, **kwargs):
|
||||
"""
|
||||
Do something as this object. This method is normally never
|
||||
called directly, instead incoming command instructions are
|
||||
|
|
@ -417,6 +417,9 @@ class ServerSession(Session):
|
|||
|
||||
Args:
|
||||
raw_string (string): Raw command input
|
||||
session (Session): This is here to make API consistent with
|
||||
Account/Object.execute_cmd. If given, data is passed to
|
||||
that Session, otherwise use self.
|
||||
Kwargs:
|
||||
Other keyword arguments will be added to the found command
|
||||
object instace as variables before it executes. This is
|
||||
|
|
@ -426,7 +429,7 @@ class ServerSession(Session):
|
|||
"""
|
||||
# inject instruction into input stream
|
||||
kwargs["text"] = ((raw_string,), {})
|
||||
self.sessionhandler.data_in(self, **kwargs)
|
||||
self.sessionhandler.data_in(session or self, **kwargs)
|
||||
|
||||
def __eq__(self, other):
|
||||
"""Handle session comparisons"""
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ from builtins import object
|
|||
|
||||
import time
|
||||
|
||||
|
||||
#------------------------------------------------------------
|
||||
# Server Session
|
||||
#------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@ 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 _
|
||||
|
|
@ -272,7 +278,7 @@ class ServerSessionHandler(SessionHandler):
|
|||
|
||||
"""
|
||||
super(ServerSessionHandler, self).__init__(*args, **kwargs)
|
||||
self.server = None
|
||||
self.server = None # set at server initialization
|
||||
self.server_data = {"servername": _SERVERNAME}
|
||||
|
||||
def _run_cmd_login(self, session):
|
||||
|
|
@ -284,7 +290,6 @@ class ServerSessionHandler(SessionHandler):
|
|||
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.
|
||||
|
|
@ -373,8 +378,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."))
|
||||
|
||||
|
|
@ -432,13 +439,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):
|
||||
"""
|
||||
|
|
@ -564,8 +586,6 @@ class ServerSessionHandler(SessionHandler):
|
|||
sessiondata=session_data,
|
||||
clean=False)
|
||||
|
||||
|
||||
|
||||
def disconnect_all_sessions(self, reason="You have been disconnected."):
|
||||
"""
|
||||
Cleanly disconnect all of the connected sessions.
|
||||
|
|
|
|||
|
|
@ -24,8 +24,13 @@ 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
|
||||
|
||||
|
||||
|
|
@ -62,18 +67,80 @@ 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")
|
||||
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.
|
||||
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"]))
|
||||
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))
|
||||
|
|
|
|||
101
evennia/server/throttle.py
Normal file
101
evennia/server/throttle.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
from collections import defaultdict, deque
|
||||
import time
|
||||
|
||||
class Throttle(object):
|
||||
"""
|
||||
Keeps a running count of failed actions per IP address.
|
||||
|
||||
Available methods indicate whether or not the number of failures exceeds a
|
||||
particular threshold.
|
||||
|
||||
This version of the throttle is usable by both the terminal server as well
|
||||
as the web server, imposes limits on memory consumption by using deques
|
||||
with length limits instead of open-ended lists, and removes sparse keys when
|
||||
no recent failures have been recorded.
|
||||
"""
|
||||
|
||||
error_msg = 'Too many failed attempts; you must wait a few minutes before trying again.'
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""
|
||||
Allows setting of throttle parameters.
|
||||
|
||||
Kwargs:
|
||||
limit (int): Max number of failures before imposing limiter
|
||||
timeout (int): number of timeout seconds after
|
||||
max number of tries has been reached.
|
||||
cache_size (int): Max number of attempts to record per IP within a
|
||||
rolling window; this is NOT the same as the limit after which
|
||||
the throttle is imposed!
|
||||
"""
|
||||
self.storage = defaultdict(deque)
|
||||
self.cache_size = self.limit = kwargs.get('limit', 5)
|
||||
self.timeout = kwargs.get('timeout', 5 * 60)
|
||||
|
||||
def get(self, ip=None):
|
||||
"""
|
||||
Convenience function that returns the storage table, or part of.
|
||||
|
||||
Args:
|
||||
ip (str, optional): IP address of requestor
|
||||
|
||||
Returns:
|
||||
storage (dict): When no IP is provided, returns a dict of all
|
||||
current IPs being tracked and the timestamps of their recent
|
||||
failures.
|
||||
timestamps (deque): When an IP is provided, returns a deque of
|
||||
timestamps of recent failures only for that IP.
|
||||
|
||||
"""
|
||||
if ip: return self.storage.get(ip, deque(maxlen=self.cache_size))
|
||||
else: return self.storage
|
||||
|
||||
def update(self, ip):
|
||||
"""
|
||||
Store the time of the latest failure/
|
||||
|
||||
Args:
|
||||
ip (str): IP address of requestor
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
# Enforce length limits
|
||||
if not self.storage[ip].maxlen:
|
||||
self.storage[ip] = deque(maxlen=self.cache_size)
|
||||
|
||||
self.storage[ip].append(time.time())
|
||||
|
||||
def check(self, ip):
|
||||
"""
|
||||
This will check the session's address against the
|
||||
storage dictionary to check they haven't spammed too many
|
||||
fails recently.
|
||||
|
||||
Args:
|
||||
ip (str): IP address of requestor
|
||||
|
||||
Returns:
|
||||
throttled (bool): True if throttling is active,
|
||||
False otherwise.
|
||||
|
||||
"""
|
||||
now = time.time()
|
||||
ip = str(ip)
|
||||
|
||||
# checking mode
|
||||
latest_fails = self.storage[ip]
|
||||
if latest_fails and len(latest_fails) >= self.limit:
|
||||
# too many fails recently
|
||||
if now - latest_fails[-1] < self.timeout:
|
||||
# too soon - timeout in play
|
||||
return True
|
||||
else:
|
||||
# timeout has passed. clear faillist
|
||||
del(self.storage[ip])
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
51
evennia/server/validators.py
Normal file
51
evennia/server/validators.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext as _
|
||||
import re
|
||||
|
||||
class EvenniaPasswordValidator:
|
||||
|
||||
def __init__(self, regex=r"^[\w. @+\-',]+$", policy="Password should contain a mix of letters, spaces, digits and @/./+/-/_/'/, only."):
|
||||
"""
|
||||
Constructs a standard Django password validator.
|
||||
|
||||
Args:
|
||||
regex (str): Regex pattern of valid characters to allow.
|
||||
policy (str): Brief explanation of what the defined regex permits.
|
||||
|
||||
"""
|
||||
self.regex = regex
|
||||
self.policy = policy
|
||||
|
||||
def validate(self, password, user=None):
|
||||
"""
|
||||
Validates a password string to make sure it meets predefined Evennia
|
||||
acceptable character policy.
|
||||
|
||||
Args:
|
||||
password (str): Password to validate
|
||||
user (None): Unused argument but required by Django
|
||||
|
||||
Returns:
|
||||
None (None): None if password successfully validated,
|
||||
raises ValidationError otherwise.
|
||||
|
||||
"""
|
||||
# Check complexity
|
||||
if not re.findall(self.regex, password):
|
||||
raise ValidationError(
|
||||
_(self.policy),
|
||||
code='evennia_password_policy',
|
||||
)
|
||||
|
||||
def get_help_text(self):
|
||||
"""
|
||||
Returns a user-facing explanation of the password policy defined
|
||||
by this validator.
|
||||
|
||||
Returns:
|
||||
text (str): Explanation of password policy.
|
||||
|
||||
"""
|
||||
return _(
|
||||
"%s From a terminal client, you can also use a phrase of multiple words if you enclose the password in double quotes." % self.policy
|
||||
)
|
||||
|
|
@ -212,6 +212,12 @@ class Website(server.Site):
|
|||
"""
|
||||
noisy = False
|
||||
|
||||
def logPrefix(self):
|
||||
"How to be named in logs"
|
||||
if hasattr(self, "is_portal") and self.is_portal:
|
||||
return "Webserver-proxy"
|
||||
return "Webserver"
|
||||
|
||||
def log(self, request):
|
||||
"""Conditional logging"""
|
||||
if _DEBUG:
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ ALLOWED_HOSTS = ["*"]
|
|||
# the Portal proxy presents to the world. The serverports are
|
||||
# the internal ports the proxy uses to forward data to the Server-side
|
||||
# webserver (these should not be publicly open)
|
||||
WEBSERVER_PORTS = [(4001, 4002)]
|
||||
WEBSERVER_PORTS = [(4001, 4005)]
|
||||
# Interface addresses to listen to. If 0.0.0.0, listen to all. Use :: for IPv6.
|
||||
WEBSERVER_INTERFACES = ['0.0.0.0']
|
||||
# IP addresses that may talk to the server in a reverse proxy configuration,
|
||||
|
|
@ -83,15 +83,20 @@ WEBCLIENT_ENABLED = True
|
|||
# default webclient will use this and only use the ajax version if the browser
|
||||
# is too old to support websockets. Requires WEBCLIENT_ENABLED.
|
||||
WEBSOCKET_CLIENT_ENABLED = True
|
||||
# Server-side websocket port to open for the webclient.
|
||||
WEBSOCKET_CLIENT_PORT = 4005
|
||||
# Server-side websocket port to open for the webclient. Note that this value will
|
||||
# be dynamically encoded in the webclient html page to allow the webclient to call
|
||||
# home. If the external encoded value needs to be different than this, due to
|
||||
# working through a proxy or docker port-remapping, the environment variable
|
||||
# WEBCLIENT_CLIENT_PROXY_PORT can be used to override this port only for the
|
||||
# front-facing client's sake.
|
||||
WEBSOCKET_CLIENT_PORT = 4002
|
||||
# Interface addresses to listen to. If 0.0.0.0, listen to all. Use :: for IPv6.
|
||||
WEBSOCKET_CLIENT_INTERFACE = '0.0.0.0'
|
||||
# Actual URL for webclient component to reach the websocket. You only need
|
||||
# to set this if you know you need it, like using some sort of proxy setup.
|
||||
# If given it must be on the form "ws://hostname" (WEBSOCKET_CLIENT_PORT will
|
||||
# be automatically appended). If left at None, the client will itself
|
||||
# figure out this url based on the server's hostname.
|
||||
# If given it must be on the form "ws[s]://hostname[:port]". If left at None,
|
||||
# the client will itself figure out this url based on the server's hostname.
|
||||
# e.g. ws://external.example.com or wss://external.example.com:443
|
||||
WEBSOCKET_CLIENT_URL = None
|
||||
# This determine's whether Evennia's custom admin page is used, or if the
|
||||
# standard Django admin is used.
|
||||
|
|
@ -137,7 +142,7 @@ LOCKWARNING_LOG_FILE = os.path.join(LOG_DIR, 'lockwarnings.log')
|
|||
CYCLE_LOGFILES = True
|
||||
# Number of lines to append to rotating channel logs when they rotate
|
||||
CHANNEL_LOG_NUM_TAIL_LINES = 20
|
||||
# Max size of channel log files before they rotate
|
||||
# Max size (in bytes) of channel log files before they rotate
|
||||
CHANNEL_LOG_ROTATE_SIZE = 1000000
|
||||
# Local time zone for this installation. All choices can be found here:
|
||||
# http://www.postgresql.org/docs/8.0/interactive/datetime-keywords.html#DATETIME-TIMEZONE-SET-TABLE
|
||||
|
|
@ -166,6 +171,7 @@ IDLE_COMMAND = "idle"
|
|||
# given, this list is tried, in order, aborting on the first match.
|
||||
# Add sets for languages/regions your accounts are likely to use.
|
||||
# (see http://en.wikipedia.org/wiki/Character_encoding)
|
||||
# Telnet default encoding, unless specified by the client, will be ENCODINGS[0].
|
||||
ENCODINGS = ["utf-8", "latin-1", "ISO-8859-1"]
|
||||
# Regular expression applied to all output to a given session in order
|
||||
# to strip away characters (usually various forms of decorations) for the benefit
|
||||
|
|
@ -348,6 +354,9 @@ LOCK_FUNC_MODULES = ("evennia.locks.lockfuncs", "server.conf.lockfuncs",)
|
|||
INPUT_FUNC_MODULES = ["evennia.server.inputfuncs", "server.conf.inputfuncs"]
|
||||
# Modules that contain prototypes for use with the spawner mechanism.
|
||||
PROTOTYPE_MODULES = ["world.prototypes"]
|
||||
# Modules containining Prototype functions able to be embedded in prototype
|
||||
# definitions from in-game.
|
||||
PROT_FUNC_MODULES = ["evennia.prototypes.protfuncs"]
|
||||
# Module holding settings/actions for the dummyrunner program (see the
|
||||
# dummyrunner for more information)
|
||||
DUMMYRUNNER_SETTINGS_MODULE = "evennia.server.profiling.dummyrunner_settings"
|
||||
|
|
@ -464,7 +473,7 @@ BASE_SCRIPT_TYPECLASS = "typeclasses.scripts.Script"
|
|||
DEFAULT_HOME = "#2"
|
||||
# The start position for new characters. Default is Limbo (#2).
|
||||
# MULTISESSION_MODE = 0, 1 - used by default unloggedin create command
|
||||
# MULTISESSION_MODE = 2,3 - used by default character_create command
|
||||
# MULTISESSION_MODE = 2, 3 - used by default character_create command
|
||||
START_LOCATION = "#2"
|
||||
# Lookups of Attributes, Tags, Nicks, Aliases can be aggressively
|
||||
# cached to avoid repeated database hits. This often gives noticeable
|
||||
|
|
@ -498,11 +507,16 @@ TIME_FACTOR = 2.0
|
|||
# The starting point of your game time (the epoch), in seconds.
|
||||
# In Python a value of 0 means Jan 1 1970 (use negatives for earlier
|
||||
# start date). This will affect the returns from the utils.gametime
|
||||
# module.
|
||||
# module. If None, the server's first start-time is used as the epoch.
|
||||
TIME_GAME_EPOCH = None
|
||||
# Normally, game time will only increase when the server runs. If this is True,
|
||||
# game time will not pause when the server reloads or goes offline. This setting
|
||||
# together with a time factor of 1 should keep the game in sync with
|
||||
# the real time (add a different epoch to shift time)
|
||||
TIME_IGNORE_DOWNTIMES = False
|
||||
|
||||
######################################################################
|
||||
# Inlinefunc
|
||||
# Inlinefunc & PrototypeFuncs
|
||||
######################################################################
|
||||
# Evennia supports inline function preprocessing. This allows users
|
||||
# to supply inline calls on the form $func(arg, arg, ...) to do
|
||||
|
|
@ -514,6 +528,10 @@ INLINEFUNC_ENABLED = False
|
|||
# is loaded from left-to-right, same-named functions will overload
|
||||
INLINEFUNC_MODULES = ["evennia.utils.inlinefuncs",
|
||||
"server.conf.inlinefuncs"]
|
||||
# Module holding handlers for OLCFuncs. These allow for embedding
|
||||
# functional code in prototypes
|
||||
PROTOTYPEFUNC_MODULES = ["evennia.utils.prototypefuncs",
|
||||
"server.conf.prototypefuncs"]
|
||||
|
||||
######################################################################
|
||||
# Default Account setup and access
|
||||
|
|
@ -534,9 +552,7 @@ INLINEFUNC_MODULES = ["evennia.utils.inlinefuncs",
|
|||
# 3 - like mode 2, except multiple sessions can puppet one character, each
|
||||
# session getting the same data.
|
||||
MULTISESSION_MODE = 0
|
||||
# The maximum number of characters allowed for MULTISESSION_MODE 2,3. This is
|
||||
# checked by the default ooc char-creation command. Forced to 1 for
|
||||
# MULTISESSION_MODE 0 and 1.
|
||||
# The maximum number of characters allowed by the default ooc char-creation command
|
||||
MAX_NR_CHARACTERS = 1
|
||||
# The access hierarchy, in climbing order. A higher permission in the
|
||||
# hierarchy includes access of all levels below it. Used by the perm()/pperm()
|
||||
|
|
@ -784,6 +800,16 @@ INSTALLED_APPS = (
|
|||
# This should usually not be changed.
|
||||
AUTH_USER_MODEL = "accounts.AccountDB"
|
||||
|
||||
# Password validation plugins
|
||||
# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
|
||||
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
'OPTIONS': {'min_length': 8}},
|
||||
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
|
||||
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
|
||||
{'NAME': 'evennia.server.validators.EvenniaPasswordValidator'}]
|
||||
|
||||
# Use a custom test runner that just tests Evennia-specific apps.
|
||||
TEST_RUNNER = 'evennia.server.tests.EvenniaTestSuiteRunner'
|
||||
|
||||
|
|
|
|||
|
|
@ -245,7 +245,7 @@ class AttributeHandler(object):
|
|||
found from cache or database.
|
||||
Notes:
|
||||
When given a category only, a search for all objects
|
||||
of that cateogory is done and a the category *name* is is
|
||||
of that cateogory is done and the category *name* is
|
||||
stored. This tells the system on subsequent calls that the
|
||||
list of cached attributes of this category is up-to-date
|
||||
and that the cache can be queried for category matches
|
||||
|
|
@ -435,6 +435,7 @@ class AttributeHandler(object):
|
|||
def __init__(self):
|
||||
self.key = None
|
||||
self.value = default
|
||||
self.category = None
|
||||
self.strvalue = str(default) if default is not None else None
|
||||
|
||||
ret = []
|
||||
|
|
@ -530,8 +531,8 @@ class AttributeHandler(object):
|
|||
repeat-calling add when having many Attributes to add.
|
||||
|
||||
Args:
|
||||
indata (tuple): Tuples of varying length representing the
|
||||
Attribute to add to this object.
|
||||
indata (list): List of tuples of varying length representing the
|
||||
Attribute to add to this object. Supported tuples are
|
||||
- `(key, value)`
|
||||
- `(key, value, category)`
|
||||
- `(key, value, category, lockstring)`
|
||||
|
|
@ -563,7 +564,7 @@ class AttributeHandler(object):
|
|||
ntup = len(tup)
|
||||
keystr = str(tup[0]).strip().lower()
|
||||
new_value = tup[1]
|
||||
category = str(tup[2]).strip().lower() if ntup > 2 else None
|
||||
category = str(tup[2]).strip().lower() if ntup > 2 and tup[2] is not None else None
|
||||
lockstring = tup[3] if ntup > 3 else ""
|
||||
|
||||
attr_objs = self._getcache(keystr, category)
|
||||
|
|
@ -572,7 +573,7 @@ class AttributeHandler(object):
|
|||
attr_obj = attr_objs[0]
|
||||
# update an existing attribute object
|
||||
attr_obj.db_category = category
|
||||
attr_obj.db_lock_storage = lockstring
|
||||
attr_obj.db_lock_storage = lockstring or ''
|
||||
attr_obj.save(update_fields=["db_category", "db_lock_storage"])
|
||||
if strattr:
|
||||
# store as a simple string (will not notify OOB handlers)
|
||||
|
|
@ -589,7 +590,7 @@ class AttributeHandler(object):
|
|||
"db_attrtype": self._attrtype,
|
||||
"db_value": None if strattr else to_pickle(new_value),
|
||||
"db_strvalue": new_value if strattr else None,
|
||||
"db_lock_storage": lockstring}
|
||||
"db_lock_storage": lockstring or ''}
|
||||
new_attr = Attribute(**kwargs)
|
||||
new_attr.save()
|
||||
new_attrobjs.append(new_attr)
|
||||
|
|
@ -667,7 +668,7 @@ class AttributeHandler(object):
|
|||
|
||||
def all(self, accessing_obj=None, default_access=True):
|
||||
"""
|
||||
Return all Attribute objects on this object.
|
||||
Return all Attribute objects on this object, regardless of category.
|
||||
|
||||
Args:
|
||||
accessing_obj (object, optional): Check the `attrread`
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
|
|||
|
||||
# Tag manager methods
|
||||
|
||||
|
||||
def get_tag(self, key=None, category=None, obj=None, tagtype=None, global_search=False):
|
||||
"""
|
||||
Return Tag objects by key, by category, by object (it is
|
||||
|
|
@ -228,25 +229,58 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
|
|||
|
||||
def get_by_tag(self, key=None, category=None, tagtype=None):
|
||||
"""
|
||||
Return objects having tags with a given key or category or
|
||||
combination of the two.
|
||||
Return objects having tags with a given key or category or combination of the two.
|
||||
Also accepts multiple tags/category/tagtype
|
||||
|
||||
Args:
|
||||
key (str, optional): Tag key. Not case sensitive.
|
||||
category (str, optional): Tag category. Not case sensitive.
|
||||
tagtype (str or None, optional): 'type' of Tag, by default
|
||||
key (str or list, optional): Tag key or list of keys. Not case sensitive.
|
||||
category (str or list, optional): Tag category. Not case sensitive. If `key` is
|
||||
a list, a single category can either apply to all keys in that list or this
|
||||
must be a list matching the `key` list element by element. If no `key` is given,
|
||||
all objects with tags of this category are returned.
|
||||
tagtype (str, optional): 'type' of Tag, by default
|
||||
this is either `None` (a normal Tag), `alias` or
|
||||
`permission`.
|
||||
`permission`. This always apply to all queried tags.
|
||||
|
||||
Returns:
|
||||
objects (list): Objects with matching tag.
|
||||
|
||||
Raises:
|
||||
IndexError: If `key` and `category` are both lists and `category` is shorter
|
||||
than `key`.
|
||||
|
||||
"""
|
||||
if not (key or category):
|
||||
return []
|
||||
|
||||
keys = make_iter(key) if key else []
|
||||
categories = make_iter(category) if category else []
|
||||
n_keys = len(keys)
|
||||
n_categories = len(categories)
|
||||
|
||||
dbmodel = self.model.__dbclass__.__name__.lower()
|
||||
query = [("db_tags__db_tagtype", tagtype), ("db_tags__db_model", dbmodel)]
|
||||
if key:
|
||||
query.append(("db_tags__db_key", key.lower()))
|
||||
if category:
|
||||
query.append(("db_tags__db_category", category.lower()))
|
||||
return self.filter(**dict(query))
|
||||
query = self.filter(db_tags__db_tagtype__iexact=tagtype,
|
||||
db_tags__db_model__iexact=dbmodel).distinct()
|
||||
|
||||
if n_keys > 0:
|
||||
# keys and/or categories given
|
||||
if n_categories == 0:
|
||||
categories = [None for _ in range(n_keys)]
|
||||
elif n_categories == 1 and n_keys > 1:
|
||||
cat = categories[0]
|
||||
categories = [cat for _ in range(n_keys)]
|
||||
elif 1 < n_categories < n_keys:
|
||||
raise IndexError("get_by_tag needs a single category or a list of categories "
|
||||
"the same length as the list of tags.")
|
||||
for ikey, key in enumerate(keys):
|
||||
query = query.filter(db_tags__db_key__iexact=key,
|
||||
db_tags__db_category__iexact=categories[ikey])
|
||||
else:
|
||||
# only one or more categories given
|
||||
for category in categories:
|
||||
query = query.filter(db_tags__db_category__iexact=category)
|
||||
|
||||
return query
|
||||
|
||||
def get_by_permission(self, key=None, category=None):
|
||||
"""
|
||||
|
|
@ -619,6 +653,42 @@ class TypeclassManager(TypedObjectManager):
|
|||
"""
|
||||
return super(TypeclassManager, self).filter(db_typeclass_path=self.model.path).count()
|
||||
|
||||
def annotate(self, *args, **kwargs):
|
||||
"""
|
||||
Overload annotate method to filter on typeclass before annotating.
|
||||
Args:
|
||||
*args (any): Positional arguments passed along to queryset annotate method.
|
||||
**kwargs (any): Keyword arguments passed along to queryset annotate method.
|
||||
|
||||
Returns:
|
||||
Annotated queryset.
|
||||
"""
|
||||
return super(TypeclassManager, self).filter(db_typeclass_path=self.model.path).annotate(*args, **kwargs)
|
||||
|
||||
def values(self, *args, **kwargs):
|
||||
"""
|
||||
Overload values method to filter on typeclass first.
|
||||
Args:
|
||||
*args (any): Positional arguments passed along to values method.
|
||||
**kwargs (any): Keyword arguments passed along to values method.
|
||||
|
||||
Returns:
|
||||
Queryset of values dictionaries, just filtered by typeclass first.
|
||||
"""
|
||||
return super(TypeclassManager, self).filter(db_typeclass_path=self.model.path).values(*args, **kwargs)
|
||||
|
||||
def values_list(self, *args, **kwargs):
|
||||
"""
|
||||
Overload values method to filter on typeclass first.
|
||||
Args:
|
||||
*args (any): Positional arguments passed along to values_list method.
|
||||
**kwargs (any): Keyword arguments passed along to values_list method.
|
||||
|
||||
Returns:
|
||||
Queryset of value_list tuples, just filtered by typeclass first.
|
||||
"""
|
||||
return super(TypeclassManager, self).filter(db_typeclass_path=self.model.path).values_list(*args, **kwargs)
|
||||
|
||||
def _get_subclasses(self, cls):
|
||||
"""
|
||||
Recursively get all subclasses to a class.
|
||||
|
|
|
|||
|
|
@ -269,14 +269,15 @@ class TagHandler(object):
|
|||
|
||||
def get(self, key=None, default=None, category=None, return_tagobj=False, return_list=False):
|
||||
"""
|
||||
Get the tag for the given key or list of tags.
|
||||
Get the tag for the given key, category or combination of the two.
|
||||
|
||||
Args:
|
||||
key (str or list): The tag or tags to retrieve.
|
||||
key (str or list, optional): The tag or tags to retrieve.
|
||||
default (any, optional): The value to return in case of no match.
|
||||
category (str, optional): The Tag category to limit the
|
||||
request to. Note that `None` is the valid, default
|
||||
category.
|
||||
category. If no `key` is given, all tags of this category will be
|
||||
returned.
|
||||
return_tagobj (bool, optional): Return the Tag object itself
|
||||
instead of a string representation of the Tag.
|
||||
return_list (bool, optional): Always return a list, regardless
|
||||
|
|
@ -344,13 +345,14 @@ class TagHandler(object):
|
|||
self._catcache = {}
|
||||
self._cache_complete = False
|
||||
|
||||
def all(self, return_key_and_category=False):
|
||||
def all(self, return_key_and_category=False, return_objs=False):
|
||||
"""
|
||||
Get all tags in this handler, regardless of category.
|
||||
|
||||
Args:
|
||||
return_key_and_category (bool, optional): Return a list of
|
||||
tuples `[(key, category), ...]`.
|
||||
return_objs (bool, optional): Return tag objects.
|
||||
|
||||
Returns:
|
||||
tags (list): A list of tag keys `[tagkey, tagkey, ...]` or
|
||||
|
|
@ -364,6 +366,8 @@ class TagHandler(object):
|
|||
if return_key_and_category:
|
||||
# return tuple (key, category)
|
||||
return [(to_str(tag.db_key), to_str(tag.db_category)) for tag in tags]
|
||||
elif return_objs:
|
||||
return tags
|
||||
else:
|
||||
return [to_str(tag.db_key) for tag in tags]
|
||||
|
||||
|
|
|
|||
59
evennia/typeclasses/tests.py
Normal file
59
evennia/typeclasses/tests.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""
|
||||
Unit tests for typeclass base system
|
||||
|
||||
"""
|
||||
|
||||
from evennia.utils.test_resources import EvenniaTest
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Manager tests
|
||||
# ------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTypedObjectManager(EvenniaTest):
|
||||
def _manager(self, methodname, *args, **kwargs):
|
||||
return list(getattr(self.obj1.__class__.objects, methodname)(*args, **kwargs))
|
||||
|
||||
def test_get_by_tag_no_category(self):
|
||||
self.obj1.tags.add("tag1")
|
||||
self.obj1.tags.add("tag2")
|
||||
self.obj1.tags.add("tag2c")
|
||||
self.obj2.tags.add("tag2")
|
||||
self.obj2.tags.add("tag2a")
|
||||
self.obj2.tags.add("tag2b")
|
||||
self.obj2.tags.add("tag3 with spaces")
|
||||
self.obj2.tags.add("tag4")
|
||||
self.obj2.tags.add("tag2c")
|
||||
self.assertEquals(self._manager("get_by_tag", "tag1"), [self.obj1])
|
||||
self.assertEquals(self._manager("get_by_tag", "tag2"), [self.obj1, self.obj2])
|
||||
self.assertEquals(self._manager("get_by_tag", "tag2a"), [self.obj2])
|
||||
self.assertEquals(self._manager("get_by_tag", "tag3 with spaces"), [self.obj2])
|
||||
self.assertEquals(self._manager("get_by_tag", ["tag2a", "tag2b"]), [self.obj2])
|
||||
self.assertEquals(self._manager("get_by_tag", ["tag2a", "tag1"]), [])
|
||||
self.assertEquals(self._manager("get_by_tag", ["tag2a", "tag4", "tag2c"]), [self.obj2])
|
||||
|
||||
def test_get_by_tag_and_category(self):
|
||||
self.obj1.tags.add("tag5", "category1")
|
||||
self.obj1.tags.add("tag6", )
|
||||
self.obj1.tags.add("tag7", "category1")
|
||||
self.obj1.tags.add("tag6", "category3")
|
||||
self.obj1.tags.add("tag7", "category4")
|
||||
self.obj2.tags.add("tag5", "category1")
|
||||
self.obj2.tags.add("tag5", "category2")
|
||||
self.obj2.tags.add("tag6", "category3")
|
||||
self.obj2.tags.add("tag7", "category1")
|
||||
self.obj2.tags.add("tag7", "category5")
|
||||
self.assertEquals(self._manager("get_by_tag", "tag5", "category1"), [self.obj1, self.obj2])
|
||||
self.assertEquals(self._manager("get_by_tag", "tag6", "category1"), [])
|
||||
self.assertEquals(self._manager("get_by_tag", "tag6", "category3"), [self.obj1, self.obj2])
|
||||
self.assertEquals(self._manager("get_by_tag", ["tag5", "tag6"],
|
||||
["category1", "category3"]), [self.obj1, self.obj2])
|
||||
self.assertEquals(self._manager("get_by_tag", ["tag5", "tag7"],
|
||||
"category1"), [self.obj1, self.obj2])
|
||||
self.assertEquals(self._manager("get_by_tag", category="category1"), [self.obj1, self.obj2])
|
||||
self.assertEquals(self._manager("get_by_tag", category="category2"), [self.obj2])
|
||||
self.assertEquals(self._manager("get_by_tag", category=["category1", "category3"]),
|
||||
[self.obj1, self.obj2])
|
||||
self.assertEquals(self._manager("get_by_tag", category=["category1", "category2"]),
|
||||
[self.obj2])
|
||||
self.assertEquals(self._manager("get_by_tag", category=["category5", "category4"]), [])
|
||||
|
|
@ -54,7 +54,8 @@ _GA = object.__getattribute__
|
|||
|
||||
def create_object(typeclass=None, key=None, location=None, home=None,
|
||||
permissions=None, locks=None, aliases=None, tags=None,
|
||||
destination=None, report_to=None, nohome=False):
|
||||
destination=None, report_to=None, nohome=False, attributes=None,
|
||||
nattributes=None):
|
||||
"""
|
||||
|
||||
Create a new in-game object.
|
||||
|
|
@ -68,13 +69,18 @@ def create_object(typeclass=None, key=None, location=None, home=None,
|
|||
permissions (list): A list of permission strings or tuples (permstring, category).
|
||||
locks (str): one or more lockstrings, separated by semicolons.
|
||||
aliases (list): A list of alternative keys or tuples (aliasstring, category).
|
||||
tags (list): List of tag keys or tuples (tagkey, category).
|
||||
tags (list): List of tag keys or tuples (tagkey, category) or (tagkey, category, data).
|
||||
destination (Object or str): Obj or #dbref to use as an Exit's
|
||||
target.
|
||||
report_to (Object): The object to return error messages to.
|
||||
nohome (bool): This allows the creation of objects without a
|
||||
default home location; only used when creating the default
|
||||
location itself or during unittests.
|
||||
attributes (list): Tuples on the form (key, value) or (key, value, category),
|
||||
(key, value, lockstring) or (key, value, lockstring, default_access).
|
||||
to set as Attributes on the new object.
|
||||
nattributes (list): Non-persistent tuples on the form (key, value). Note that
|
||||
adding this rarely makes sense since this data will not survive a reload.
|
||||
|
||||
Returns:
|
||||
object (Object): A newly created object of the given typeclass.
|
||||
|
|
@ -95,6 +101,7 @@ def create_object(typeclass=None, key=None, location=None, home=None,
|
|||
locks = make_iter(locks) if locks is not None else None
|
||||
aliases = make_iter(aliases) if aliases is not None else None
|
||||
tags = make_iter(tags) if tags is not None else None
|
||||
attributes = make_iter(attributes) if attributes is not None else None
|
||||
|
||||
|
||||
if isinstance(typeclass, basestring):
|
||||
|
|
@ -122,7 +129,8 @@ def create_object(typeclass=None, key=None, location=None, home=None,
|
|||
# store the call signature for the signal
|
||||
new_object._createdict = dict(key=key, location=location, destination=destination, home=home,
|
||||
typeclass=typeclass.path, permissions=permissions, locks=locks,
|
||||
aliases=aliases, tags=tags, report_to=report_to, nohome=nohome)
|
||||
aliases=aliases, tags=tags, report_to=report_to, nohome=nohome,
|
||||
attributes=attributes, nattributes=nattributes)
|
||||
# this will trigger the save signal which in turn calls the
|
||||
# at_first_save hook on the typeclass, where the _createdict can be
|
||||
# used.
|
||||
|
|
@ -139,7 +147,8 @@ object = create_object
|
|||
|
||||
def create_script(typeclass=None, key=None, obj=None, account=None, locks=None,
|
||||
interval=None, start_delay=None, repeats=None,
|
||||
persistent=None, autostart=True, report_to=None, desc=None):
|
||||
persistent=None, autostart=True, report_to=None, desc=None,
|
||||
tags=None, attributes=None):
|
||||
"""
|
||||
Create a new script. All scripts are a combination of a database
|
||||
object that communicates with the database, and an typeclass that
|
||||
|
|
@ -169,7 +178,9 @@ def create_script(typeclass=None, key=None, obj=None, account=None, locks=None,
|
|||
created or if the `start` method must be called explicitly.
|
||||
report_to (Object): The object to return error messages to.
|
||||
desc (str): Optional description of script
|
||||
|
||||
tags (list): List of tags or tuples (tag, category).
|
||||
attributes (list): List if tuples (key, value) or (key, value, category)
|
||||
(key, value, lockstring) or (key, value, lockstring, default_access).
|
||||
|
||||
See evennia.scripts.manager for methods to manipulate existing
|
||||
scripts in the database.
|
||||
|
|
@ -190,9 +201,9 @@ def create_script(typeclass=None, key=None, obj=None, account=None, locks=None,
|
|||
if key:
|
||||
kwarg["db_key"] = key
|
||||
if account:
|
||||
kwarg["db_account"] = dbid_to_obj(account, _ScriptDB)
|
||||
kwarg["db_account"] = dbid_to_obj(account, _AccountDB)
|
||||
if obj:
|
||||
kwarg["db_obj"] = dbid_to_obj(obj, _ScriptDB)
|
||||
kwarg["db_obj"] = dbid_to_obj(obj, _ObjectDB)
|
||||
if interval:
|
||||
kwarg["db_interval"] = interval
|
||||
if start_delay:
|
||||
|
|
@ -203,6 +214,8 @@ def create_script(typeclass=None, key=None, obj=None, account=None, locks=None,
|
|||
kwarg["db_persistent"] = persistent
|
||||
if desc:
|
||||
kwarg["db_desc"] = desc
|
||||
tags = make_iter(tags) if tags is not None else None
|
||||
attributes = make_iter(attributes) if attributes is not None else None
|
||||
|
||||
# create new instance
|
||||
new_script = typeclass(**kwarg)
|
||||
|
|
@ -210,7 +223,8 @@ def create_script(typeclass=None, key=None, obj=None, account=None, locks=None,
|
|||
# store the call signature for the signal
|
||||
new_script._createdict = dict(key=key, obj=obj, account=account, locks=locks, interval=interval,
|
||||
start_delay=start_delay, repeats=repeats, persistent=persistent,
|
||||
autostart=autostart, report_to=report_to)
|
||||
autostart=autostart, report_to=report_to, desc=desc,
|
||||
tags=tags, attributes=attributes)
|
||||
# this will trigger the save signal which in turn calls the
|
||||
# at_first_save hook on the typeclass, where the _createdict
|
||||
# can be used.
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue