Start refactor contrib folder

This commit is contained in:
Griatch 2021-12-18 11:32:34 +01:00
parent 7f0d314e7f
commit f5f75bd04d
107 changed files with 34 additions and 2 deletions

View file

@ -0,0 +1,3 @@
# Base-system contribs
Server/admin-related functionality changes and systems.

View file

@ -0,0 +1,227 @@
# AWSstorage system
Contrib by The Right Honourable Reverend (trhr) 2020
## What is this for?
This plugin migrates the Web-based portion of Evennia, namely images,
javascript, and other items located inside staticfiles into Amazon AWS (S3) for hosting.
Files hosted on S3 are "in the cloud," and while your personal
server may be sufficient for serving multimedia to a minimal number of users,
the perfect use case for this plugin would be:
- Servers supporting heavy web-based traffic (webclient, etc) ...
- With a sizable number of users ...
- Where the users are globally distributed ...
- Where multimedia files are served to users as a part of gameplay
Bottom line - if you're sending an image to a player every time they traverse a
map, the bandwidth reduction of using this will be substantial. If not,
probably skip this contrib.
## On costs
Note that storing and serving files via S3 is not technically free outside of
Amazon's "free tier" offering, which you may or may not be eligible for;
setting up a vanilla evennia server with this contrib currently requires 1.5MB
of storage space on S3, making the current total cost of running this plugin
~$0.0005 per year. If you have substantial media assets and intend to serve
them to many users, caveat emptor on a total cost of ownership - check AWS's
pricing structure.
# Technical details
This is a drop-in replacement that operates deeper than all of Evennia's code,
so your existing code does not need to change at all to support it.
For example, when Evennia (or Django), tries to save a file permanently
(say, an image uploaded by a user), the save (or load) communication follows the path:
Evennia -> Django
Django -> Storage backend
Storage backend -> file storage location (e.g. hard drive)
https://docs.djangoproject.com/en/3.0/ref/settings/#std:setting-STATICFILES_STORAGE
This plugin, when enabled, overrides the default storage backend,
which defaults to saving files at mygame/website/, instead,
sending the files to S3 via the storage backend defined herein.
There is no way (or need) to directly access or use the functions here with
other contributions or custom code. Simply work how you would normally, Django
will handle the rest.
# Installation
## Set up AWS account
If you don't have an AWS S3 account, you should create one at
https://aws.amazon.com/ - documentation for AWS S3 is available at:
https://docs.aws.amazon.com/AmazonS3/latest/gsg/GetStartedWithS3.html
Credentials required within the app are AWS IAM Access Key and Secret Keys,
which can be generated/found in the AWS Console.
The following example IAM Control Policy Permissions can be added to
the IAM service inside AWS. Documentation for this can be found here:
https://docs.aws.amazon.com/IAM/latest/UserGuide/introduction.html
Note that this is only required if you want to tightly secure the roles
that this plugin has access to.
```
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "evennia",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObjectAcl",
"s3:GetObject",
"s3:ListBucket",
"s3:DeleteObject",
"s3:PutObjectAcl"
],
"Resource": [
"arn:aws:s3:::YOUR_BUCKET_NAME/*",
"arn:aws:s3:::YOUR_BUCKET_NAME"
]
}
],
[
{
"Sid":"evennia",
"Effect":"Allow",
"Action":[
"s3:CreateBucket",
],
"Resource":[
"arn:aws:s3:::*"
]
}
]
}
```
Advanced Users: The second IAM statement, CreateBucket, is only needed
for initial installation. You can remove it later, or you can
create the bucket and set the ACL yourself before you continue.
## Dependencies
This package requires the dependency "boto3 >= 1.4.4", the official
AWS python package. To install, it's easiest to just install Evennia's
extra requirements;
- Activate your `virtualenv`
- `cd` to the root of the Evennia repository. There should be an `requirements_extra.txt`
file here.
- `pip install -r requirements_extra.txt`
## Configure Evennia
Customize the variables defined below in `secret_settings.py`. No further
configuration is needed. Note the three lines that you need to set to your
actual values.
```python
# START OF SECRET_SETTINGS.PY COPY/PASTE >>>
AWS_ACCESS_KEY_ID = 'THIS_IS_PROVIDED_BY_AMAZON'
AWS_SECRET_ACCESS_KEY = 'THIS_IS_PROVIDED_BY_AMAZON'
AWS_STORAGE_BUCKET_NAME = 'mygame-evennia' # CHANGE ME! I suggest yourgamename-evennia
# The settings below need to go in secret_settings,py as well, but will
# not need customization unless you want to do something particularly fancy.
AWS_S3_REGION_NAME = 'us-east-1' # N. Virginia
AWS_S3_OBJECT_PARAMETERS = { 'Expires': 'Thu, 31 Dec 2099 20:00:00 GMT',
'CacheControl': 'max-age=94608000', }
AWS_DEFAULT_ACL = 'public-read'
AWS_S3_CUSTOM_DOMAIN = '%s.s3.amazonaws.com' % settings.AWS_BUCKET_NAME
AWS_AUTO_CREATE_BUCKET = True
STATICFILES_STORAGE = 'evennia.contrib.awsstorage.aws-s3-cdn.S3Boto3Storage'
# <<< END OF SECRET_SETTINGS.PY COPY/PASTE
```
You may also store these keys as environment variables of the same name.
For advanced configuration, refer to the docs for django-storages.
After copying the above, run `evennia reboot`.
## Check that it works
Confirm that web assets are being served from S3 by visiting your website, then
checking the source of any image (for instance, the logo). It should read
`https://your-bucket-name.s3.amazonaws.com/path/to/file`. If so, the system
works and you shouldn't need to do anything else.
# Uninstallation
If you haven't made changes to your static files (uploaded images, etc),
you can simply remove the lines you added to `secret_settings.py`. If you
have made changes and want to uninstall at a later date, you can export
your files from your S3 bucket and put them in /static/ in the evennia
directory.
# License
Draws heavily from code provided by django-storages, for which these contributors
are authors:
Marty Alchin (S3)
David Larlet (S3)
Arne Brodowski (S3)
Sebastian Serrano (S3)
Andrew McClain (MogileFS)
Rafal Jonca (FTP)
Chris McCormick (S3 with Boto)
Ivanov E. (Database)
Ariel Núñez (packaging)
Wim Leers (SymlinkOrCopy + patches)
Michael Elsdörfer (Overwrite + PEP8 compatibility)
Christian Klein (CouchDB)
Rich Leland (Mosso Cloud Files)
Jason Christa (patches)
Adam Nelson (patches)
Erik CW (S3 encryption)
Axel Gembe (Hash path)
Waldemar Kornewald (MongoDB)
Russell Keith-Magee (Apache LibCloud patches)
Jannis Leidel (S3 and GS with Boto)
Andrei Coman (Azure)
Chris Streeter (S3 with Boto)
Josh Schneier (Fork maintainer, Bugfixes, Py3K)
Anthony Monthe (Dropbox)
EunPyo (Andrew) Hong (Azure)
Michael Barrientos (S3 with Boto3)
piglei (patches)
Matt Braymer-Hayes (S3 with Boto3)
Eirik Martiniussen Sylliaas (Google Cloud Storage native support)
Jody McIntyre (Google Cloud Storage native support)
Stanislav Kaledin (Bug fixes in SFTPStorage)
Filip Vavera (Google Cloud MIME types support)
Max Malysh (Dropbox large file support)
Scott White (Google Cloud updates)
Alex Watt (Google Cloud Storage patch)
Jumpei Yoshimura (S3 docs)
Jon Dufresne
Rodrigo Gadea (Dropbox fixes)
Martey Dodoo
Chris Rink
Shaung Cheng (S3 docs)
Andrew Perry (Bug fixes in SFTPStorage)
The repurposed code from django-storages is released under BSD 3-Clause,
same as Evennia, so for detailed licensing, refer to the Evennia license.
# Versioning
This is confirmed to work for Django 2 and Django 3.

View file

@ -0,0 +1,3 @@
"""
Intended to be a collecting folder for Django-specific contribs that do not have observable effects to players.
"""

View file

@ -0,0 +1,863 @@
"""
AWS Storage System
The Right Honourable Reverend (trhr) 2020
ABOUT THIS PLUGIN:
This plugin migrates the Web-based portion of Evennia, namely images,
javascript, and other items located inside staticfiles into Amazon AWS (S3) for hosting.
Files hosted on S3 are "in the cloud," and while your personal
server may be sufficient for serving multimedia to a minimal number of users,
the perfect use case for this plugin would be:
1) Servers supporting heavy web-based traffic (webclient, etc)
2) With a sizable number of users
3) Where the users are globally distributed
4) Where multimedia files are served to users as a part of gameplay
Bottom line - if you're sending an image to a player every time they traverse a
map, the bandwidth reduction will be substantial. If not, probably skip
this one.
Note that storing and serving files via S3 is not technically free outside of
Amazon's "free tier" offering, which you may or may not be eligible for;
evennia's base install currently requires 1.5MB of storage space on S3,
making the current total cost to install this plugin ~$0.0005 per year. If
you have substantial media assets and intend to serve them to many users,
caveat emptor on a total cost of ownership - check AWS's pricing structure.
See the ./README.md file for details and install instructions.
"""
from django.core.exceptions import (
ImproperlyConfigured,
SuspiciousOperation,
SuspiciousFileOperation,
)
try:
from django.conf import settings as ev_settings
if (
not ev_settings.AWS_ACCESS_KEY_ID
or not ev_settings.AWS_SECRET_ACCESS_KEY
or not ev_settings.AWS_STORAGE_BUCKET_NAME
or not ev_settings.AWS_S3_REGION_NAME
):
raise ImproperlyConfigured(
(
"You must add AWS-specific settings"
"to mygame/server/conf/secret_settings.py to use this plugin."
)
)
if "mygame-evennia" == ev_settings.AWS_STORAGE_BUCKET_NAME:
raise ImproperlyConfigured(
(
"You must customize your AWS_STORAGE_BUCKET_NAME"
"in mygame/server/conf/secret_settings.py;"
"it must be unique among ALL other S3 users"
)
)
except Exception as e:
print(e)
import io
import mimetypes
import os
import posixpath
import threading
from gzip import GzipFile
from tempfile import SpooledTemporaryFile
from django.core.files.base import File
from django.core.files.storage import Storage
from django.utils.deconstruct import deconstructible
from django.utils.encoding import filepath_to_uri, force_bytes, force_text, smart_text
from django.utils.timezone import is_naive, make_naive
try:
from django.utils.six.moves.urllib import parse as urlparse
except ImportError:
from urllib import parse as urlparse
try:
import boto3.session
from boto3 import __version__ as boto3_version
from botocore.client import Config
from botocore.exceptions import ClientError
except ImportError as e:
raise ImproperlyConfigured("Couldn't load S3 bindings. %s Did you run 'pip install boto3?'" % e)
boto3_version_info = tuple([int(i) for i in boto3_version.split(".")])
def setting(name, default=None):
"""
Helper function to get a Django setting by name. If setting doesn't exist
it will return a default.
Args:
name (str): A Django setting name
Returns:
The value of the setting variable by that name
"""
return getattr(ev_settings, name, default)
def safe_join(base, *paths):
"""
Helper function, a version of django.utils._os.safe_join for S3 paths.
Joins one or more path components to the base path component
intelligently. Returns a normalized version of the final path.
The final path must be located inside of the base path component
(otherwise a ValueError is raised). Paths outside the base path
indicate a possible security sensitive operation.
Args:
base (str): A path string to the base of the staticfiles
*paths (list): A list of paths as referenced from the base path
Returns:
final_path (str): A joined path, base + filepath
"""
base_path = force_text(base)
base_path = base_path.rstrip("/")
paths = [force_text(p) for p in paths]
final_path = base_path + "/"
for path in paths:
_final_path = posixpath.normpath(posixpath.join(final_path, path))
# posixpath.normpath() strips the trailing /. Add it back.
if path.endswith("/") or _final_path + "/" == final_path:
_final_path += "/"
final_path = _final_path
if final_path == base_path:
final_path += "/"
# Ensure final_path starts with base_path and that the next character after
# the base path is /.
base_path_len = len(base_path)
if not final_path.startswith(base_path) or final_path[base_path_len] != "/":
raise ValueError("the joined path is located outside of the base path" " component")
return final_path.lstrip("/")
def check_location(storage):
"""
Helper function to make sure that the storage location is configured correctly.
Args:
storage (Storage): A Storage object (Django)
Raises:
ImproperlyConfigured: If the storage location is not configured correctly,
this is raised.
"""
if storage.location.startswith("/"):
correct = storage.location.lstrip("/")
raise ImproperlyConfigured(
"{}.location cannot begin with a leading slash. Found '{}'. Use '{}' instead.".format(
storage.__class__.__name__, storage.location, correct,
)
)
def lookup_env(names):
"""
Helper function for looking up names in env vars. Returns the first element found.
Args:
names (str): A list of environment variables
Returns:
value (str): The value of the found environment variable.
"""
for name in names:
value = os.environ.get(name)
if value:
return value
def get_available_overwrite_name(name, max_length):
"""
Helper function indicating files that will be overwritten during trunc.
Args:
name (str): The name of the file
max_length (int): The maximum length of a filename
Returns:
joined (path): A joined path including directory, file, and extension
"""
if max_length is None or len(name) <= max_length:
return name
# Adapted from Django
dir_name, file_name = os.path.split(name)
file_root, file_ext = os.path.splitext(file_name)
truncation = len(name) - max_length
file_root = file_root[:-truncation]
if not file_root:
raise SuspiciousFileOperation(
'aws-s3-cdn tried to truncate away entire filename "%s". '
"Please make sure that the corresponding file field "
'allows sufficient "max_length".' % name
)
return os.path.join(dir_name, "{}{}".format(file_root, file_ext))
@deconstructible
class S3Boto3StorageFile(File):
"""
The default file object used by the S3Boto3Storage backend.
This file implements file streaming using boto's multipart
uploading functionality. The file can be opened in read or
write mode.
This class extends Django's File class. However, the contained
data is only the data contained in the current buffer. So you
should not access the contained file object directly. You should
access the data via this class.
Warning: This file *must* be closed using the close() method in
order to properly write the file to S3. Be sure to close the file
in your application.
"""
buffer_size = setting("AWS_S3_FILE_BUFFER_SIZE", 5242880)
def __init__(self, name, mode, storage, buffer_size=None):
"""
Initializes the File object.
Args:
name (str): The name of the file
mode (str): The access mode ('r' or 'w')
storage (Storage): The Django Storage object
buffer_size (int): The buffer size, for multipart uploads
"""
if "r" in mode and "w" in mode:
raise ValueError("Can't combine 'r' and 'w' in mode.")
self._storage = storage
self.name = name[len(self._storage.location) :].lstrip("/")
self._mode = mode
self._force_mode = (lambda b: b) if "b" in mode else force_text
self.obj = storage.bucket.Object(storage._encode_name(name))
if "w" not in mode:
# Force early RAII-style exception if object does not exist
self.obj.load()
self._is_dirty = False
self._raw_bytes_written = 0
self._file = None
self._multipart = None
# 5 MB is the minimum part size (if there is more than one part).
# Amazon allows up to 10,000 parts. The default supports uploads
# up to roughly 50 GB. Increase the part size to accommodate
# for files larger than this.
if buffer_size is not None:
self.buffer_size = buffer_size
self._write_counter = 0
@property
def size(self):
"""
Helper property to return filesize
"""
return self.obj.content_length
def _get_file(self):
"""
Helper function to manage zipping and temporary files
"""
if self._file is None:
self._file = SpooledTemporaryFile(
max_size=self._storage.max_memory_size,
suffix=".S3Boto3StorageFile",
dir=setting("FILE_UPLOAD_TEMP_DIR"),
)
if "r" in self._mode:
self._is_dirty = False
self.obj.download_fileobj(self._file)
self._file.seek(0)
if self._storage.gzip and self.obj.content_encoding == "gzip":
self._file = GzipFile(mode=self._mode, fileobj=self._file, mtime=0.0)
return self._file
def _set_file(self, value):
self._file = value
file = property(_get_file, _set_file)
def read(self, *args, **kwargs):
"""
Checks if file is in read mode; then continues to boto3 operation
"""
if "r" not in self._mode:
raise AttributeError("File was not opened in read mode.")
return self._force_mode(super().read(*args, **kwargs))
def readline(self, *args, **kwargs):
"""
Checks if file is in read mode; then continues to boto3 operation
"""
if "r" not in self._mode:
raise AttributeError("File was not opened in read mode.")
return self._force_mode(super().readline(*args, **kwargs))
def write(self, content):
"""
Checks if file is in write mode or needs multipart handling,
then continues to boto3 operation.
"""
if "w" not in self._mode:
raise AttributeError("File was not opened in write mode.")
self._is_dirty = True
if self._multipart is None:
self._multipart = self.obj.initiate_multipart_upload(
**self._storage._get_write_parameters(self.obj.key)
)
if self.buffer_size <= self._buffer_file_size:
self._flush_write_buffer()
bstr = force_bytes(content)
self._raw_bytes_written += len(bstr)
return super().write(bstr)
@property
def _buffer_file_size(self):
pos = self.file.tell()
self.file.seek(0, os.SEEK_END)
length = self.file.tell()
self.file.seek(pos)
return length
def _flush_write_buffer(self):
"""
Flushes the write buffer.
"""
if self._buffer_file_size:
self._write_counter += 1
self.file.seek(0)
part = self._multipart.Part(self._write_counter)
part.upload(Body=self.file.read())
self.file.seek(0)
self.file.truncate()
def _create_empty_on_close(self):
"""
Attempt to create an empty file for this key when this File is closed if no bytes
have been written and no object already exists on S3 for this key.
This behavior is meant to mimic the behavior of Django's builtin FileSystemStorage,
where files are always created after they are opened in write mode:
f = storage.open("file.txt", mode="w")
f.close()
Raises:
Exception: Raised if a 404 error occurs
"""
assert "w" in self._mode
assert self._raw_bytes_written == 0
try:
# Check if the object exists on the server; if so, don't do anything
self.obj.load()
except ClientError as err:
if err.response["ResponseMetadata"]["HTTPStatusCode"] == 404:
self.obj.put(Body=b"", **self._storage._get_write_parameters(self.obj.key))
else:
raise
def close(self):
"""
Manages file closing after multipart uploads
"""
if self._is_dirty:
self._flush_write_buffer()
parts = [
{"ETag": part.e_tag, "PartNumber": part.part_number}
for part in self._multipart.parts.all()
]
self._multipart.complete(MultipartUpload={"Parts": parts})
else:
if self._multipart is not None:
self._multipart.abort()
if "w" in self._mode and self._raw_bytes_written == 0:
self._create_empty_on_close()
if self._file is not None:
self._file.close()
self._file = None
@deconstructible
class S3Boto3Storage(Storage):
"""
Amazon Simple Storage Service using Boto3
This storage backend supports opening files in read or write
mode and supports streaming(buffering) data in chunks to S3
when writing.
"""
default_content_type = "application/octet-stream"
# If config provided in init, signature_version and addressing_style settings/args are ignored.
config = None
# used for looking up the access and secret key from env vars
access_key_names = ["AWS_S3_ACCESS_KEY_ID", "AWS_ACCESS_KEY_ID"]
secret_key_names = ["AWS_S3_SECRET_ACCESS_KEY", "AWS_SECRET_ACCESS_KEY"]
security_token_names = ["AWS_SESSION_TOKEN", "AWS_SECURITY_TOKEN"]
security_token = None
access_key = setting("AWS_S3_ACCESS_KEY_ID", setting("AWS_ACCESS_KEY_ID", ""))
secret_key = setting("AWS_S3_SECRET_ACCESS_KEY", setting("AWS_SECRET_ACCESS_KEY", ""))
file_overwrite = setting("AWS_S3_FILE_OVERWRITE", True)
object_parameters = setting("AWS_S3_OBJECT_PARAMETERS", {})
bucket_name = setting("AWS_STORAGE_BUCKET_NAME")
auto_create_bucket = setting("AWS_AUTO_CREATE_BUCKET", False)
default_acl = setting("AWS_DEFAULT_ACL", "public-read")
bucket_acl = setting("AWS_BUCKET_ACL", default_acl)
querystring_auth = setting("AWS_QUERYSTRING_AUTH", True)
querystring_expire = setting("AWS_QUERYSTRING_EXPIRE", 3600)
signature_version = setting("AWS_S3_SIGNATURE_VERSION")
reduced_redundancy = setting("AWS_REDUCED_REDUNDANCY", False)
location = setting("AWS_LOCATION", "")
encryption = setting("AWS_S3_ENCRYPTION", False)
custom_domain = setting("AWS_S3_CUSTOM_DOMAIN")
addressing_style = setting("AWS_S3_ADDRESSING_STYLE")
secure_urls = setting("AWS_S3_SECURE_URLS", True)
file_name_charset = setting("AWS_S3_FILE_NAME_CHARSET", "utf-8")
gzip = setting("AWS_IS_GZIPPED", False)
preload_metadata = setting("AWS_PRELOAD_METADATA", False)
gzip_content_types = setting(
"GZIP_CONTENT_TYPES",
(
"text/css",
"text/javascript",
"application/javascript",
"application/x-javascript",
"image/svg+xml",
),
)
url_protocol = setting("AWS_S3_URL_PROTOCOL", "http:")
endpoint_url = setting("AWS_S3_ENDPOINT_URL")
proxies = setting("AWS_S3_PROXIES")
region_name = setting("AWS_S3_REGION_NAME")
use_ssl = setting("AWS_S3_USE_SSL", True)
verify = setting("AWS_S3_VERIFY", None)
max_memory_size = setting("AWS_S3_MAX_MEMORY_SIZE", 0)
def __init__(self, acl=None, bucket=None, **settings):
"""
Check if some of the settings we've provided as class attributes
need to be overwritten with values passed in here.
"""
for name, value in settings.items():
if hasattr(self, name):
setattr(self, name, value)
check_location(self)
# Backward-compatibility: given the anteriority of the SECURE_URL setting
# we fall back to https if specified in order to avoid the construction
# of unsecure urls.
if self.secure_urls:
self.url_protocol = "https:"
self._entries = {}
self._bucket = None
self._connections = threading.local()
self.access_key, self.secret_key = self._get_access_keys()
self.security_token = self._get_security_token()
if not self.config:
kwargs = dict(
s3={"addressing_style": self.addressing_style},
signature_version=self.signature_version,
)
if boto3_version_info >= (1, 4, 4):
kwargs["proxies"] = self.proxies
self.config = Config(**kwargs)
def __getstate__(self):
state = self.__dict__.copy()
state.pop("_connections", None)
state.pop("_bucket", None)
return state
def __setstate__(self, state):
state["_connections"] = threading.local()
state["_bucket"] = None
self.__dict__ = state
@property
def connection(self):
"""
Creates the actual connection to S3
"""
connection = getattr(self._connections, "connection", None)
if connection is None:
session = boto3.session.Session()
self._connections.connection = session.resource(
"s3",
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
aws_session_token=self.security_token,
region_name=self.region_name,
use_ssl=self.use_ssl,
endpoint_url=self.endpoint_url,
config=self.config,
verify=self.verify,
)
return self._connections.connection
@property
def bucket(self):
"""
Get the current bucket. If there is no current bucket object
create it.
"""
if self._bucket is None:
self._bucket = self._get_or_create_bucket(self.bucket_name)
return self._bucket
@property
def entries(self):
"""
Get the locally cached files for the bucket.
"""
if self.preload_metadata and not self._entries:
self._entries = {
self._decode_name(entry.key): entry
for entry in self.bucket.objects.filter(Prefix=self.location)
}
return self._entries
def _get_access_keys(self):
"""
Gets the access keys to use when accessing S3. If none is
provided in the settings then get them from the environment
variables.
"""
access_key = self.access_key or lookup_env(S3Boto3Storage.access_key_names)
secret_key = self.secret_key or lookup_env(S3Boto3Storage.secret_key_names)
return access_key, secret_key
def _get_security_token(self):
"""
Gets the security token to use when accessing S3. Get it from
the environment variables.
"""
security_token = self.security_token or lookup_env(S3Boto3Storage.security_token_names)
return security_token
def _get_or_create_bucket(self, name):
"""
Retrieves a bucket if it exists, otherwise creates it.
"""
bucket = self.connection.Bucket(name)
if self.auto_create_bucket:
try:
# Directly call head_bucket instead of bucket.load() because head_bucket()
# fails on wrong region, while bucket.load() does not.
bucket.meta.client.head_bucket(Bucket=name)
except ClientError as err:
if err.response["ResponseMetadata"]["HTTPStatusCode"] == 301:
raise ImproperlyConfigured(
"Bucket %s exists, but in a different "
"region than we are connecting to. Set "
"the region to connect to by setting "
"AWS_S3_REGION_NAME to the correct region." % name
)
elif err.response["ResponseMetadata"]["HTTPStatusCode"] == 404:
# Notes: When using the us-east-1 Standard endpoint, you can create
# buckets in other regions. The same is not true when hitting region specific
# endpoints. However, when you create the bucket not in the same region, the
# connection will fail all future requests to the Bucket after the creation
# (301 Moved Permanently).
#
# For simplicity, we enforce in S3Boto3Storage that any auto-created
# bucket must match the region that the connection is for.
#
# Also note that Amazon specifically disallows "us-east-1" when passing bucket
# region names; LocationConstraint *must* be blank to create in US Standard.
if self.bucket_acl:
bucket_params = {"ACL": self.bucket_acl}
else:
bucket_params = {}
region_name = self.connection.meta.client.meta.region_name
if region_name != "us-east-1":
bucket_params["CreateBucketConfiguration"] = {
"LocationConstraint": region_name
}
bucket.create(**bucket_params)
else:
raise
return bucket
def _clean_name(self, name):
"""
Cleans the name so that Windows style paths work
"""
# Normalize Windows style paths
clean_name = posixpath.normpath(name).replace("\\", "/")
# os.path.normpath() can strip trailing slashes so we implement
# a workaround here.
if name.endswith("/") and not clean_name.endswith("/"):
# Add a trailing slash as it was stripped.
clean_name += "/"
return clean_name
def _normalize_name(self, name):
"""
Normalizes the name so that paths like /path/to/ignored/../something.txt
work. We check to make sure that the path pointed to is not outside
the directory specified by the LOCATION setting.
"""
try:
return safe_join(self.location, name)
except ValueError:
raise SuspiciousOperation("Attempted access to '%s' denied." % name)
def _encode_name(self, name):
return smart_text(name, encoding=self.file_name_charset)
def _decode_name(self, name):
return force_text(name, encoding=self.file_name_charset)
def _compress_content(self, content):
"""Gzip a given string content."""
content.seek(0)
zbuf = io.BytesIO()
# The GZIP header has a modification time attribute (see http://www.zlib.org/rfc-gzip.html)
# Each time a file is compressed it changes even if the other contents don't change
# For S3 this defeats detection of changes using MD5 sums on gzipped files
# Fixing the mtime at 0.0 at compression time avoids this problem
zfile = GzipFile(mode="wb", fileobj=zbuf, mtime=0.0)
try:
zfile.write(force_bytes(content.read()))
finally:
zfile.close()
zbuf.seek(0)
# Boto 2 returned the InMemoryUploadedFile with the file pointer replaced,
# but Boto 3 seems to have issues with that. No need for fp.name in Boto3
# so just returning the BytesIO directly
return zbuf
def _open(self, name, mode="rb"):
"""
Opens the file, if it exists.
"""
name = self._normalize_name(self._clean_name(name))
try:
f = S3Boto3StorageFile(name, mode, self)
except ClientError as err:
if err.response["ResponseMetadata"]["HTTPStatusCode"] == 404:
raise IOError("File does not exist: %s" % name)
raise # Let it bubble up if it was some other error
return f
def _save(self, name, content):
"""
Stitches and cleans multipart uploads; normalizes file paths.
"""
cleaned_name = self._clean_name(name)
name = self._normalize_name(cleaned_name)
params = self._get_write_parameters(name, content)
if (
self.gzip
and params["ContentType"] in self.gzip_content_types
and "ContentEncoding" not in params
):
content = self._compress_content(content)
params["ContentEncoding"] = "gzip"
encoded_name = self._encode_name(name)
obj = self.bucket.Object(encoded_name)
if self.preload_metadata:
self._entries[encoded_name] = obj
content.seek(0, os.SEEK_SET)
obj.upload_fileobj(content, ExtraArgs=params)
return cleaned_name
def delete(self, name):
"""
Deletes a file from S3.
"""
name = self._normalize_name(self._clean_name(name))
self.bucket.Object(self._encode_name(name)).delete()
if name in self._entries:
del self._entries[name]
def exists(self, name):
"""
Checks if file exists.
"""
name = self._normalize_name(self._clean_name(name))
if self.entries:
return name in self.entries
try:
self.connection.meta.client.head_object(Bucket=self.bucket_name, Key=name)
return True
except ClientError:
return False
def listdir(self, name):
"""
Translational function to go from S3 file paths to the format
Django's listdir expects.
"""
path = self._normalize_name(self._clean_name(name))
# The path needs to end with a slash, but if the root is empty, leave
# it.
if path and not path.endswith("/"):
path += "/"
directories = []
files = []
paginator = self.connection.meta.client.get_paginator("list_objects")
pages = paginator.paginate(Bucket=self.bucket_name, Delimiter="/", Prefix=path)
for page in pages:
for entry in page.get("CommonPrefixes", ()):
directories.append(posixpath.relpath(entry["Prefix"], path))
for entry in page.get("Contents", ()):
files.append(posixpath.relpath(entry["Key"], path))
return directories, files
def size(self, name):
"""
Gets the filesize of a remote file.
"""
name = self._normalize_name(self._clean_name(name))
if self.entries:
entry = self.entries.get(name)
if entry:
return entry.size if hasattr(entry, "size") else entry.content_length
return 0
return self.bucket.Object(self._encode_name(name)).content_length
def _get_write_parameters(self, name, content=None):
params = {}
if self.encryption:
params["ServerSideEncryption"] = "AES256"
if self.reduced_redundancy:
params["StorageClass"] = "REDUCED_REDUNDANCY"
if self.default_acl:
params["ACL"] = self.default_acl
_type, encoding = mimetypes.guess_type(name)
content_type = getattr(content, "content_type", None)
content_type = content_type or _type or self.default_content_type
params["ContentType"] = content_type
if encoding:
params["ContentEncoding"] = encoding
params.update(self.get_object_parameters(name))
return params
def get_object_parameters(self, name):
"""
Returns a dictionary that is passed to file upload. Override this
method to adjust this on a per-object basis to set e.g ContentDisposition.
By default, returns the value of AWS_S3_OBJECT_PARAMETERS.
Setting ContentEncoding will prevent objects from being automatically gzipped.
"""
return self.object_parameters.copy()
def get_modified_time(self, name):
"""
Returns an (aware) datetime object containing the last modified time if
USE_TZ is True, otherwise returns a naive datetime in the local timezone.
"""
name = self._normalize_name(self._clean_name(name))
entry = self.entries.get(name)
# only call self.bucket.Object() if the key is not found
# in the preloaded metadata.
if entry is None:
entry = self.bucket.Object(self._encode_name(name))
if setting("USE_TZ"):
# boto3 returns TZ aware timestamps
return entry.last_modified
else:
return make_naive(entry.last_modified)
def modified_time(self, name):
"""Returns a naive datetime object containing the last modified time.
If USE_TZ=False then get_modified_time will return a naive datetime
so we just return that, else we have to localize and strip the tz
"""
mtime = self.get_modified_time(name)
return mtime if is_naive(mtime) else make_naive(mtime)
def _strip_signing_parameters(self, url):
"""
Boto3 does not currently support generating URLs that are unsigned. Instead we
take the signed URLs and strip any querystring params related to signing and expiration.
Note that this may end up with URLs that are still invalid, especially if params are
passed in that only work with signed URLs, e.g. response header params.
The code attempts to strip all query parameters that match names of known parameters
from v2 and v4 signatures, regardless of the actual signature version used.
"""
split_url = urlparse.urlsplit(url)
qs = urlparse.parse_qsl(split_url.query, keep_blank_values=True)
blacklist = {
"x-amz-algorithm",
"x-amz-credential",
"x-amz-date",
"x-amz-expires",
"x-amz-signedheaders",
"x-amz-signature",
"x-amz-security-token",
"awsaccesskeyid",
"expires",
"signature",
}
filtered_qs = ((key, val) for key, val in qs if key.lower() not in blacklist)
# Note: Parameters that did not have a value in the original query string will have
# an '=' sign appended to it, e.g ?foo&bar becomes ?foo=&bar=
joined_qs = ("=".join(keyval) for keyval in filtered_qs)
split_url = split_url._replace(query="&".join(joined_qs))
return split_url.geturl()
def url(self, name, parameters=None, expire=None):
"""
Returns the URL of a remotely-hosted file
"""
# Preserve the trailing slash after normalizing the path.
name = self._normalize_name(self._clean_name(name))
if self.custom_domain:
return "{}//{}/{}".format(self.url_protocol, self.custom_domain, filepath_to_uri(name))
if expire is None:
expire = self.querystring_expire
params = parameters.copy() if parameters else {}
params["Bucket"] = self.bucket.name
params["Key"] = self._encode_name(name)
url = self.bucket.meta.client.generate_presigned_url(
"get_object", Params=params, ExpiresIn=expire
)
if self.querystring_auth:
return url
return self._strip_signing_parameters(url)
def get_available_name(self, name, max_length=None):
"""Overwrite existing file with the same name."""
name = self._clean_name(name)
if self.file_overwrite:
return get_available_overwrite_name(name, max_length)
return super().get_available_name(name, max_length)

View file

@ -0,0 +1,608 @@
from unittest import skipIf
from django.test import override_settings
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.core.files.base import ContentFile
from django.test import TestCase
from django.utils.timezone import is_aware, utc
import datetime, gzip, pickle, threading
_SKIP = False
try:
from botocore.exceptions import ClientError
from evennia.contrib.awsstorage import aws_s3_cdn as s3boto3
except ImportError:
_SKIP = True
try:
from django.utils.six.moves.urllib import parse as urlparse
except ImportError:
from urllib import parse as urlparse
try:
from unittest import mock
except ImportError: # Python 3.2 and below
import mock
@skipIf(_SKIP, "botocore not installed")
class S3Boto3TestCase(TestCase):
def setUp(self):
self.storage = s3boto3.S3Boto3Storage(access_key="foo", secret_key="bar")
self.storage._connections.connection = mock.MagicMock()
@skipIf(_SKIP, "botocore not installed")
class S3Boto3StorageTests(S3Boto3TestCase):
def test_clean_name(self):
"""
Test the base case of _clean_name
"""
path = self.storage._clean_name("path/to/somewhere")
self.assertEqual(path, "path/to/somewhere")
def test_clean_name_normalize(self):
"""
Test the normalization of _clean_name
"""
path = self.storage._clean_name("path/to/../somewhere")
self.assertEqual(path, "path/somewhere")
def test_clean_name_trailing_slash(self):
"""
Test the _clean_name when the path has a trailing slash
"""
path = self.storage._clean_name("path/to/somewhere/")
self.assertEqual(path, "path/to/somewhere/")
def test_clean_name_windows(self):
"""
Test the _clean_name when the path has a trailing slash
"""
path = self.storage._clean_name("path\\to\\somewhere")
self.assertEqual(path, "path/to/somewhere")
def test_pickle_with_bucket(self):
"""
Test that the storage can be pickled with a bucket attached
"""
# Ensure the bucket has been used
self.storage.bucket
self.assertIsNotNone(self.storage._bucket)
# Can't pickle MagicMock, but you can't pickle a real Bucket object either
p = pickle.dumps(self.storage)
new_storage = pickle.loads(p)
self.assertIsInstance(new_storage._connections, threading.local)
# Put the mock connection back in
new_storage._connections.connection = mock.MagicMock()
self.assertIsNone(new_storage._bucket)
new_storage.bucket
self.assertIsNotNone(new_storage._bucket)
def test_pickle_without_bucket(self):
"""
Test that the storage can be pickled, without a bucket instance
"""
# Can't pickle a threadlocal
p = pickle.dumps(self.storage)
new_storage = pickle.loads(p)
self.assertIsInstance(new_storage._connections, threading.local)
def test_storage_url_slashes(self):
"""
Test URL generation.
"""
self.storage.custom_domain = "example.com"
# We expect no leading slashes in the path,
# and trailing slashes should be preserved.
self.assertEqual(self.storage.url(""), "https://example.com/")
self.assertEqual(self.storage.url("path"), "https://example.com/path")
self.assertEqual(self.storage.url("path/"), "https://example.com/path/")
self.assertEqual(self.storage.url("path/1"), "https://example.com/path/1")
self.assertEqual(self.storage.url("path/1/"), "https://example.com/path/1/")
def test_storage_save(self):
"""
Test saving a file
"""
name = "test_storage_save.txt"
content = ContentFile("new content")
self.storage.save(name, content)
self.storage.bucket.Object.assert_called_once_with(name)
obj = self.storage.bucket.Object.return_value
obj.upload_fileobj.assert_called_with(
content, ExtraArgs={"ContentType": "text/plain", "ACL": self.storage.default_acl,}
)
def test_storage_save_with_acl(self):
"""
Test saving a file with user defined ACL.
"""
name = "test_storage_save.txt"
content = ContentFile("new content")
self.storage.default_acl = "private"
self.storage.save(name, content)
self.storage.bucket.Object.assert_called_once_with(name)
obj = self.storage.bucket.Object.return_value
obj.upload_fileobj.assert_called_with(
content, ExtraArgs={"ContentType": "text/plain", "ACL": "private",}
)
def test_content_type(self):
"""
Test saving a file with a None content type.
"""
name = "test_image.jpg"
content = ContentFile("data")
content.content_type = None
self.storage.save(name, content)
self.storage.bucket.Object.assert_called_once_with(name)
obj = self.storage.bucket.Object.return_value
obj.upload_fileobj.assert_called_with(
content, ExtraArgs={"ContentType": "image/jpeg", "ACL": self.storage.default_acl,}
)
def test_storage_save_gzipped(self):
"""
Test saving a gzipped file
"""
name = "test_storage_save.gz"
content = ContentFile("I am gzip'd")
self.storage.save(name, content)
obj = self.storage.bucket.Object.return_value
obj.upload_fileobj.assert_called_with(
content,
ExtraArgs={
"ContentType": "application/octet-stream",
"ContentEncoding": "gzip",
"ACL": self.storage.default_acl,
},
)
def test_storage_save_gzip(self):
"""
Test saving a file with gzip enabled.
"""
self.storage.gzip = True
name = "test_storage_save.css"
content = ContentFile("I should be gzip'd")
self.storage.save(name, content)
obj = self.storage.bucket.Object.return_value
obj.upload_fileobj.assert_called_with(
mock.ANY,
ExtraArgs={
"ContentType": "text/css",
"ContentEncoding": "gzip",
"ACL": self.storage.default_acl,
},
)
args, kwargs = obj.upload_fileobj.call_args
content = args[0]
zfile = gzip.GzipFile(mode="rb", fileobj=content)
self.assertEqual(zfile.read(), b"I should be gzip'd")
def test_storage_save_gzip_twice(self):
"""
Test saving the same file content twice with gzip enabled.
"""
# Given
self.storage.gzip = True
name = "test_storage_save.css"
content = ContentFile("I should be gzip'd")
# When
self.storage.save(name, content)
self.storage.save("test_storage_save_2.css", content)
# Then
obj = self.storage.bucket.Object.return_value
obj.upload_fileobj.assert_called_with(
mock.ANY,
ExtraArgs={
"ContentType": "text/css",
"ContentEncoding": "gzip",
"ACL": self.storage.default_acl,
},
)
args, kwargs = obj.upload_fileobj.call_args
content = args[0]
zfile = gzip.GzipFile(mode="rb", fileobj=content)
self.assertEqual(zfile.read(), b"I should be gzip'd")
def test_compress_content_len(self):
"""
Test that file returned by _compress_content() is readable.
"""
self.storage.gzip = True
content = ContentFile("I should be gzip'd")
content = self.storage._compress_content(content)
self.assertTrue(len(content.read()) > 0)
def test_storage_open_write(self):
"""
Test opening a file in write mode
"""
name = "test_open_for_writïng.txt"
content = "new content"
# Set the encryption flag used for multipart uploads
self.storage.encryption = True
self.storage.reduced_redundancy = True
self.storage.default_acl = "public-read"
file = self.storage.open(name, "w")
self.storage.bucket.Object.assert_called_with(name)
obj = self.storage.bucket.Object.return_value
# Set the name of the mock object
obj.key = name
file.write(content)
obj.initiate_multipart_upload.assert_called_with(
ACL="public-read",
ContentType="text/plain",
ServerSideEncryption="AES256",
StorageClass="REDUCED_REDUNDANCY",
)
# Save the internal file before closing
multipart = obj.initiate_multipart_upload.return_value
multipart.parts.all.return_value = [mock.MagicMock(e_tag="123", part_number=1)]
file.close()
multipart.Part.assert_called_with(1)
part = multipart.Part.return_value
part.upload.assert_called_with(Body=content.encode("utf-8"))
multipart.complete.assert_called_once_with(
MultipartUpload={"Parts": [{"ETag": "123", "PartNumber": 1}]}
)
def test_storage_open_no_write(self):
"""
Test opening file in write mode and closing without writing.
A file should be created as by obj.put(...).
"""
name = "test_open_no_write.txt"
# Set the encryption flag used for puts
self.storage.encryption = True
self.storage.reduced_redundancy = True
self.storage.default_acl = "public-read"
file = self.storage.open(name, "w")
self.storage.bucket.Object.assert_called_with(name)
obj = self.storage.bucket.Object.return_value
obj.load.side_effect = ClientError(
{"Error": {}, "ResponseMetadata": {"HTTPStatusCode": 404}}, "head_bucket"
)
# Set the name of the mock object
obj.key = name
# Save the internal file before closing
file.close()
obj.load.assert_called_once_with()
obj.put.assert_called_once_with(
ACL="public-read",
Body=b"",
ContentType="text/plain",
ServerSideEncryption="AES256",
StorageClass="REDUCED_REDUNDANCY",
)
def test_storage_open_no_overwrite_existing(self):
"""
Test opening an existing file in write mode and closing without writing.
"""
name = "test_open_no_overwrite_existing.txt"
# Set the encryption flag used for puts
self.storage.encryption = True
self.storage.reduced_redundancy = True
self.storage.default_acl = "public-read"
file = self.storage.open(name, "w")
self.storage.bucket.Object.assert_called_with(name)
obj = self.storage.bucket.Object.return_value
# Set the name of the mock object
obj.key = name
# Save the internal file before closing
file.close()
obj.load.assert_called_once_with()
obj.put.assert_not_called()
def test_storage_write_beyond_buffer_size(self):
"""
Test writing content that exceeds the buffer size
"""
name = "test_open_for_writïng_beyond_buffer_size.txt"
# Set the encryption flag used for multipart uploads
self.storage.encryption = True
self.storage.reduced_redundancy = True
self.storage.default_acl = "public-read"
file = self.storage.open(name, "w")
self.storage.bucket.Object.assert_called_with(name)
obj = self.storage.bucket.Object.return_value
# Set the name of the mock object
obj.key = name
# Initiate the multipart upload
file.write("")
obj.initiate_multipart_upload.assert_called_with(
ACL="public-read",
ContentType="text/plain",
ServerSideEncryption="AES256",
StorageClass="REDUCED_REDUNDANCY",
)
multipart = obj.initiate_multipart_upload.return_value
# Write content at least twice as long as the buffer size
written_content = ""
counter = 1
while len(written_content) < 2 * file.buffer_size:
content = "hello, aws {counter}\n".format(counter=counter)
# Write more than just a few bytes in each iteration to keep the
# test reasonably fast
content += "*" * int(file.buffer_size / 10)
file.write(content)
written_content += content
counter += 1
# Save the internal file before closing
multipart.parts.all.return_value = [
mock.MagicMock(e_tag="123", part_number=1),
mock.MagicMock(e_tag="456", part_number=2),
]
file.close()
self.assertListEqual(multipart.Part.call_args_list, [mock.call(1), mock.call(2)])
part = multipart.Part.return_value
uploaded_content = "".join(
args_list[1]["Body"].decode("utf-8") for args_list in part.upload.call_args_list
)
self.assertEqual(uploaded_content, written_content)
multipart.complete.assert_called_once_with(
MultipartUpload={
"Parts": [{"ETag": "123", "PartNumber": 1}, {"ETag": "456", "PartNumber": 2},]
}
)
def test_auto_creating_bucket(self):
self.storage.auto_create_bucket = True
Bucket = mock.MagicMock()
self.storage._connections.connection.Bucket.return_value = Bucket
self.storage._connections.connection.meta.client.meta.region_name = "sa-east-1"
Bucket.meta.client.head_bucket.side_effect = ClientError(
{"Error": {}, "ResponseMetadata": {"HTTPStatusCode": 404}}, "head_bucket"
)
self.storage._get_or_create_bucket("testbucketname")
Bucket.create.assert_called_once_with(
ACL="public-read", CreateBucketConfiguration={"LocationConstraint": "sa-east-1",}
)
def test_auto_creating_bucket_with_acl(self):
self.storage.auto_create_bucket = True
self.storage.bucket_acl = "public-read"
Bucket = mock.MagicMock()
self.storage._connections.connection.Bucket.return_value = Bucket
self.storage._connections.connection.meta.client.meta.region_name = "sa-east-1"
Bucket.meta.client.head_bucket.side_effect = ClientError(
{"Error": {}, "ResponseMetadata": {"HTTPStatusCode": 404}}, "head_bucket"
)
self.storage._get_or_create_bucket("testbucketname")
Bucket.create.assert_called_once_with(
ACL="public-read", CreateBucketConfiguration={"LocationConstraint": "sa-east-1",}
)
def test_storage_exists(self):
self.assertTrue(self.storage.exists("file.txt"))
self.storage.connection.meta.client.head_object.assert_called_with(
Bucket=self.storage.bucket_name, Key="file.txt",
)
def test_storage_exists_false(self):
self.storage.connection.meta.client.head_object.side_effect = ClientError(
{"Error": {"Code": "404", "Message": "Not Found"}}, "HeadObject",
)
self.assertFalse(self.storage.exists("file.txt"))
self.storage.connection.meta.client.head_object.assert_called_with(
Bucket=self.storage.bucket_name, Key="file.txt",
)
def test_storage_exists_doesnt_create_bucket(self):
with mock.patch.object(self.storage, "_get_or_create_bucket") as method:
self.storage.exists("file.txt")
self.assertFalse(method.called)
def test_storage_delete(self):
self.storage.delete("path/to/file.txt")
self.storage.bucket.Object.assert_called_with("path/to/file.txt")
self.storage.bucket.Object.return_value.delete.assert_called_with()
def test_storage_listdir_base(self):
# Files:
# some/path/1.txt
# 2.txt
# other/path/3.txt
# 4.txt
pages = [
{
"CommonPrefixes": [{"Prefix": "some"}, {"Prefix": "other"},],
"Contents": [{"Key": "2.txt"}, {"Key": "4.txt"},],
},
]
paginator = mock.MagicMock()
paginator.paginate.return_value = pages
self.storage._connections.connection.meta.client.get_paginator.return_value = paginator
dirs, files = self.storage.listdir("")
paginator.paginate.assert_called_with(Bucket=None, Delimiter="/", Prefix="")
self.assertEqual(dirs, ["some", "other"])
self.assertEqual(files, ["2.txt", "4.txt"])
def test_storage_listdir_subdir(self):
# Files:
# some/path/1.txt
# some/2.txt
pages = [
{"CommonPrefixes": [{"Prefix": "some/path"},], "Contents": [{"Key": "some/2.txt"},],},
]
paginator = mock.MagicMock()
paginator.paginate.return_value = pages
self.storage._connections.connection.meta.client.get_paginator.return_value = paginator
dirs, files = self.storage.listdir("some/")
paginator.paginate.assert_called_with(Bucket=None, Delimiter="/", Prefix="some/")
self.assertEqual(dirs, ["path"])
self.assertEqual(files, ["2.txt"])
def test_storage_size(self):
obj = self.storage.bucket.Object.return_value
obj.content_length = 4098
name = "file.txt"
self.assertEqual(self.storage.size(name), obj.content_length)
def test_storage_mtime(self):
# Test both USE_TZ cases
for use_tz in (True, False):
with self.settings(USE_TZ=use_tz):
self._test_storage_mtime(use_tz)
def _test_storage_mtime(self, use_tz):
obj = self.storage.bucket.Object.return_value
obj.last_modified = datetime.datetime.now(utc)
name = "file.txt"
self.assertFalse(
is_aware(self.storage.modified_time(name)),
"Naive datetime object expected from modified_time()",
)
self.assertIs(
settings.USE_TZ,
is_aware(self.storage.get_modified_time(name)),
"{} datetime object expected from get_modified_time() when USE_TZ={}".format(
("Naive", "Aware")[settings.USE_TZ], settings.USE_TZ
),
)
def test_storage_url(self):
name = "test_storage_size.txt"
url = "http://aws.amazon.com/%s" % name
self.storage.bucket.meta.client.generate_presigned_url.return_value = url
self.storage.bucket.name = "bucket"
self.assertEqual(self.storage.url(name), url)
self.storage.bucket.meta.client.generate_presigned_url.assert_called_with(
"get_object",
Params={"Bucket": self.storage.bucket.name, "Key": name},
ExpiresIn=self.storage.querystring_expire,
)
custom_expire = 123
self.assertEqual(self.storage.url(name, expire=custom_expire), url)
self.storage.bucket.meta.client.generate_presigned_url.assert_called_with(
"get_object",
Params={"Bucket": self.storage.bucket.name, "Key": name},
ExpiresIn=custom_expire,
)
def test_generated_url_is_encoded(self):
self.storage.custom_domain = "mock.cloudfront.net"
filename = "whacky & filename.mp4"
url = self.storage.url(filename)
parsed_url = urlparse.urlparse(url)
self.assertEqual(parsed_url.path, "/whacky%20%26%20filename.mp4")
self.assertFalse(self.storage.bucket.meta.client.generate_presigned_url.called)
def test_special_characters(self):
self.storage.custom_domain = "mock.cloudfront.net"
name = "ãlöhâ.jpg"
content = ContentFile("new content")
self.storage.save(name, content)
self.storage.bucket.Object.assert_called_once_with(name)
url = self.storage.url(name)
parsed_url = urlparse.urlparse(url)
self.assertEqual(parsed_url.path, "/%C3%A3l%C3%B6h%C3%A2.jpg")
def test_strip_signing_parameters(self):
expected = "http://bucket.s3-aws-region.amazonaws.com/foo/bar"
self.assertEqual(
self.storage._strip_signing_parameters(
"%s?X-Amz-Date=12345678&X-Amz-Signature=Signature" % expected
),
expected,
)
self.assertEqual(
self.storage._strip_signing_parameters(
"%s?expires=12345678&signature=Signature" % expected
),
expected,
)
@skipIf(threading is None, "Test requires threading")
def test_connection_threading(self):
connections = []
def thread_storage_connection():
connections.append(self.storage.connection)
for x in range(2):
t = threading.Thread(target=thread_storage_connection)
t.start()
t.join()
# Connection for each thread needs to be unique
self.assertIsNot(connections[0], connections[1])
def test_location_leading_slash(self):
msg = (
"S3Boto3Storage.location cannot begin with a leading slash. "
"Found '/'. Use '' instead."
)
with self.assertRaises(ImproperlyConfigured, msg=msg):
s3boto3.S3Boto3Storage(location="/")
def test_override_class_variable(self):
class MyStorage1(s3boto3.S3Boto3Storage):
location = "foo1"
storage = MyStorage1()
self.assertEqual(storage.location, "foo1")
class MyStorage2(s3boto3.S3Boto3Storage):
location = "foo2"
storage = MyStorage2()
self.assertEqual(storage.location, "foo2")
def test_override_init_argument(self):
storage = s3boto3.S3Boto3Storage(location="foo1")
self.assertEqual(storage.location, "foo1")
storage = s3boto3.S3Boto3Storage(location="foo2")
self.assertEqual(storage.location, "foo2")

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,231 @@
"""
Color markups
Contribution, Griatch 2017
Additional color markup styles for Evennia (extending or replacing the default |r, |234 etc).
Installation:
Import the desired style variables from this module into mygame/server/conf/settings.py and add them
to these settings variables. Each are specified as a list, and multiple such lists can be added to
each variable to support multiple formats. Note that list order affects which regexes are applied
first. You must restart both Portal and Server for color tags to update.
Assign to the following settings variables:
COLOR_ANSI_EXTRA_MAP - a mapping between regexes and ANSI colors
COLOR_XTERM256_EXTRA_FG - regex for defining XTERM256 foreground colors
COLOR_XTERM256_EXTRA_BG - regex for defining XTERM256 background colors
COLOR_XTERM256_EXTRA_GFG - regex for defining XTERM256 grayscale foreground colors
COLOR_XTERM256_EXTRA_GBG - regex for defining XTERM256 grayscale background colors
COLOR_ANSI_BRIGHT_BG_EXTRA_MAP = ANSI does not support bright backgrounds; we fake
this by mapping ANSI markup to matching bright XTERM256 backgrounds
COLOR_NO_DEFAULT - Set True/False. If False (default), extend the default markup, otherwise
replace it completely.
To add the {- "curly-bracket" style, add the following to your settings file, then reboot both
Server and Portal:
from evennia.contrib import color_markups
COLOR_ANSI_EXTRA_MAP = color_markups.CURLY_COLOR_ANSI_EXTRA_MAP
COLOR_XTERM256_EXTRA_FG = color_markups.CURLY_COLOR_XTERM256_EXTRA_FG
COLOR_XTERM256_EXTRA_BG = color_markups.CURLY_COLOR_XTERM256_EXTRA_BG
COLOR_XTERM256_EXTRA_GFG = color_markups.CURLY_COLOR_XTERM256_EXTRA_GFG
COLOR_XTERM256_EXTRA_GBG = color_markups.CURLY_COLOR_XTERM256_EXTRA_GBG
COLOR_ANSI_BRIGHT_BG_EXTRA_MAP = color_markups.CURLY_COLOR_ANSI_BRIGHT_BG_EXTRA_MAP
To add the %c- "mux/mush" style, add the following to your settings file, then reboot both Server
and Portal:
from evennia.contrib import color_markups
COLOR_ANSI_EXTRA_MAP = color_markups.MUX_COLOR_ANSI_EXTRA_MAP
COLOR_XTERM256_EXTRA_FG = color_markups.MUX_COLOR_XTERM256_EXTRA_FG
COLOR_XTERM256_EXTRA_BG = color_markups.MUX_COLOR_XTERM256_EXTRA_BG
COLOR_XTERM256_EXTRA_GFG = color_markups.MUX_COLOR_XTERM256_EXTRA_GFG
COLOR_XTERM256_EXTRA_GBG = color_markups.MUX_COLOR_XTERM256_EXTRA_GBG
COLOR_ANSI_BRIGHT_BGS_EXTRA_MAP = color_markups.CURLY_COLOR_ANSI_BRIGHT_BGS_EXTRA_MAP
"""
# ANSI constants (copied from evennia.utils.ansi to avoid import)
_ANSI_BEEP = "\07"
_ANSI_ESCAPE = "\033"
_ANSI_NORMAL = "\033[0m"
_ANSI_UNDERLINE = "\033[4m"
_ANSI_HILITE = "\033[1m"
_ANSI_UNHILITE = "\033[22m"
_ANSI_BLINK = "\033[5m"
_ANSI_INVERSE = "\033[7m"
_ANSI_INV_HILITE = "\033[1;7m"
_ANSI_INV_BLINK = "\033[7;5m"
_ANSI_BLINK_HILITE = "\033[1;5m"
_ANSI_INV_BLINK_HILITE = "\033[1;5;7m"
# Foreground colors
_ANSI_BLACK = "\033[30m"
_ANSI_RED = "\033[31m"
_ANSI_GREEN = "\033[32m"
_ANSI_YELLOW = "\033[33m"
_ANSI_BLUE = "\033[34m"
_ANSI_MAGENTA = "\033[35m"
_ANSI_CYAN = "\033[36m"
_ANSI_WHITE = "\033[37m"
# Background colors
_ANSI_BACK_BLACK = "\033[40m"
_ANSI_BACK_RED = "\033[41m"
_ANSI_BACK_GREEN = "\033[42m"
_ANSI_BACK_YELLOW = "\033[43m"
_ANSI_BACK_BLUE = "\033[44m"
_ANSI_BACK_MAGENTA = "\033[45m"
_ANSI_BACK_CYAN = "\033[46m"
_ANSI_BACK_WHITE = "\033[47m"
# Formatting Characters
_ANSI_RETURN = "\r\n"
_ANSI_TAB = "\t"
_ANSI_SPACE = " "
#############################################################
#
# {- style MUD markup (old Evennia default). This is
# basically identical to the default |-style except using
# a curly bracket instead. This was removed because {}
# are used in Python string formatting.
#
# {r, {R - bright/dark red foreground
# {[r, {[R - bright/dark red background
# {500, {[500 - XTERM256 red foreground/background
# {=w, {[=w - XTERM256 greyscale foreground/background
#
#############################################################
CURLY_COLOR_ANSI_EXTRA_MAP = [
(r"{n", _ANSI_NORMAL), # reset
(r"{/", _ANSI_RETURN), # line break
(r"{-", _ANSI_TAB), # tab
(r"{_", _ANSI_SPACE), # space
(r"{*", _ANSI_INVERSE), # invert
(r"{^", _ANSI_BLINK), # blinking text (very annoying and not supported by all clients)
(r"{u", _ANSI_UNDERLINE), # underline
(r"{r", _ANSI_HILITE + _ANSI_RED),
(r"{g", _ANSI_HILITE + _ANSI_GREEN),
(r"{y", _ANSI_HILITE + _ANSI_YELLOW),
(r"{b", _ANSI_HILITE + _ANSI_BLUE),
(r"{m", _ANSI_HILITE + _ANSI_MAGENTA),
(r"{c", _ANSI_HILITE + _ANSI_CYAN),
(r"{w", _ANSI_HILITE + _ANSI_WHITE), # pure white
(r"{x", _ANSI_HILITE + _ANSI_BLACK), # dark grey
(r"{R", _ANSI_UNHILITE + _ANSI_RED),
(r"{G", _ANSI_UNHILITE + _ANSI_GREEN),
(r"{Y", _ANSI_UNHILITE + _ANSI_YELLOW),
(r"{B", _ANSI_UNHILITE + _ANSI_BLUE),
(r"{M", _ANSI_UNHILITE + _ANSI_MAGENTA),
(r"{C", _ANSI_UNHILITE + _ANSI_CYAN),
(r"{W", _ANSI_UNHILITE + _ANSI_WHITE), # light grey
(r"{X", _ANSI_UNHILITE + _ANSI_BLACK), # pure black
# hilight-able colors
(r"{h", _ANSI_HILITE),
(r"{H", _ANSI_UNHILITE),
(r"{!R", _ANSI_RED),
(r"{!G", _ANSI_GREEN),
(r"{!Y", _ANSI_YELLOW),
(r"{!B", _ANSI_BLUE),
(r"{!M", _ANSI_MAGENTA),
(r"{!C", _ANSI_CYAN),
(r"{!W", _ANSI_WHITE), # light grey
(r"{!X", _ANSI_BLACK), # pure black
# normal ANSI backgrounds
(r"{[R", _ANSI_BACK_RED),
(r"{[G", _ANSI_BACK_GREEN),
(r"{[Y", _ANSI_BACK_YELLOW),
(r"{[B", _ANSI_BACK_BLUE),
(r"{[M", _ANSI_BACK_MAGENTA),
(r"{[C", _ANSI_BACK_CYAN),
(r"{[W", _ANSI_BACK_WHITE), # light grey background
(r"{[X", _ANSI_BACK_BLACK), # pure black background
]
CURLY_COLOR_XTERM256_EXTRA_FG = [r"\{([0-5])([0-5])([0-5])"] # |123 - foreground colour
CURLY_COLOR_XTERM256_EXTRA_BG = [r"\{\[([0-5])([0-5])([0-5])"] # |[123 - background colour
CURLY_COLOR_XTERM256_EXTRA_GFG = [r"\{=([a-z])"] # |=a - greyscale foreground
CURLY_COLOR_XTERM256_EXTRA_GBG = [r"\{\[=([a-z])"] # |[=a - greyscale background
CURLY_COLOR_ANSI_XTERM256_BRIGHT_BG_EXTRA_MAP = [
(r"{[r", r"{[500"),
(r"{[g", r"{[050"),
(r"{[y", r"{[550"),
(r"{[b", r"{[005"),
(r"{[m", r"{[505"),
(r"{[c", r"{[055"),
(r"{[w", r"{[555"), # white background
(r"{[x", r"{[222"), # dark grey background
]
#############################################################
#
# %c - MUX/MUSH style markup. This was Evennia's first
# color markup style. It was phased out due to % being used
# in Python formatting operations.
#
# %ch%cr, %cr - bright/dark red foreground
# %ch%cR, %cR- bright/dark red background
# %c500, %c[500 - XTERM256 red foreground/background
# %c=w, %c[=w - XTERM256 greyscale foreground/background
#
#############################################################
MUX_COLOR_ANSI_EXTRA_MAP = [
(r"%cn", _ANSI_NORMAL), # reset
(r"%ch", _ANSI_HILITE), # highlight
(r"%r", _ANSI_RETURN), # line break
(r"%R", _ANSI_RETURN), #
(r"%t", _ANSI_TAB), # tab
(r"%T", _ANSI_TAB), #
(r"%b", _ANSI_SPACE), # space
(r"%B", _ANSI_SPACE),
(r"%cf", _ANSI_BLINK), # annoying and not supported by all clients
(r"%ci", _ANSI_INVERSE), # invert
(r"%cr", _ANSI_RED),
(r"%cg", _ANSI_GREEN),
(r"%cy", _ANSI_YELLOW),
(r"%cb", _ANSI_BLUE),
(r"%cm", _ANSI_MAGENTA),
(r"%cc", _ANSI_CYAN),
(r"%cw", _ANSI_WHITE),
(r"%cx", _ANSI_BLACK),
(r"%cR", _ANSI_BACK_RED),
(r"%cG", _ANSI_BACK_GREEN),
(r"%cY", _ANSI_BACK_YELLOW),
(r"%cB", _ANSI_BACK_BLUE),
(r"%cM", _ANSI_BACK_MAGENTA),
(r"%cC", _ANSI_BACK_CYAN),
(r"%cW", _ANSI_BACK_WHITE),
(r"%cX", _ANSI_BACK_BLACK),
]
MUX_COLOR_XTERM256_EXTRA_FG = [r"%c([0-5])([0-5])([0-5])"] # %c123 - foreground colour
MUX_COLOR_XTERM256_EXTRA_BG = [r"%c\[([0-5])([0-5])([0-5])"] # %c[123 - background colour
MUX_COLOR_XTERM256_EXTRA_GFG = [r"%c=([a-z])"] # %c=a - greyscale foreground
MUX_COLOR_XTERM256_EXTRA_GBG = [r"%c\[=([a-z])"] # %c[=a - greyscale background
MUX_COLOR_ANSI_XTERM256_BRIGHT_BG_EXTRA_MAP = [
(r"%ch%cR", r"%c[500"),
(r"%ch%cG", r"%c[050"),
(r"%ch%cY", r"%c[550"),
(r"%ch%cB", r"%c[005"),
(r"%ch%cM", r"%c[505"),
(r"%ch%cC", r"%c[055"),
(r"%ch%cW", r"%c[555"), # white background
(r"%ch%cX", r"%c[222"), # dark grey background
]

View file

@ -0,0 +1,330 @@
"""
Custom gametime
Contrib - Griatch 2017, vlgeoff 2017
This implements the evennia.utils.gametime module but supporting
a custom calendar for your game world. It allows for scheduling
events to happen at given in-game times, taking this custom
calendar into account.
Usage:
Use as the normal gametime module, that is by importing and using the
helper functions in this module in your own code. The calendar can be
customized by adding the `TIME_UNITS` dictionary to your settings
file. This maps unit names to their length, expressed in the smallest
unit. Here's the default as an example:
TIME_UNITS = {
"sec": 1,
"min": 60,
"hr": 60 * 60,
"hour": 60 * 60,
"day": 60 * 60 * 24,
"week": 60 * 60 * 24 * 7,
"month": 60 * 60 * 24 * 7 * 4,
"yr": 60 * 60 * 24 * 7 * 4 * 12,
"year": 60 * 60 * 24 * 7 * 4 * 12, }
When using a custom calendar, these time unit names are used as kwargs to
the converter functions in this module.
"""
# change these to fit your game world
from django.conf import settings
from evennia import DefaultScript
from evennia.utils.create import create_script
from evennia.utils import gametime
# The game time speedup / slowdown relative real time
TIMEFACTOR = settings.TIME_FACTOR
# These are the unit names understood by the scheduler.
# Each unit must be consistent and expressed in seconds.
UNITS = getattr(
settings,
"TIME_UNITS",
{
# default custom calendar
"sec": 1,
"min": 60,
"hr": 60 * 60,
"hour": 60 * 60,
"day": 60 * 60 * 24,
"week": 60 * 60 * 24 * 7,
"month": 60 * 60 * 24 * 7 * 4,
"yr": 60 * 60 * 24 * 7 * 4 * 12,
"year": 60 * 60 * 24 * 7 * 4 * 12,
},
)
def time_to_tuple(seconds, *divisors):
"""
Helper function. Creates a tuple of even dividends given a range
of divisors.
Args:
seconds (int): Number of seconds to format
*divisors (int): a sequence of numbers of integer dividends. The
number of seconds will be integer-divided by the first number in
this sequence, the remainder will be divided with the second and
so on.
Returns:
time (tuple): This tuple has length len(*args)+1, with the
last element being the last remaining seconds not evenly
divided by the supplied dividends.
"""
results = []
seconds = int(seconds)
for divisor in divisors:
results.append(seconds // divisor)
seconds %= divisor
results.append(seconds)
return tuple(results)
def gametime_to_realtime(format=False, **kwargs):
"""
This method helps to figure out the real-world time it will take until an
in-game time has passed. E.g. if an event should take place a month later
in-game, you will be able to find the number of real-world seconds this
corresponds to (hint: Interval events deal with real life seconds).
Keyword Args:
format (bool): Formatting the output.
days, month etc (int): These are the names of time units that must
match the `settings.TIME_UNITS` dict keys.
Returns:
time (float or tuple): The realtime difference or the same
time split up into time units.
Example:
gametime_to_realtime(days=2) -> number of seconds in real life from
now after which 2 in-game days will have passed.
"""
# Dynamically creates the list of units based on kwarg names and UNITs list
rtime = 0
for name, value in kwargs.items():
# Allow plural names (like mins instead of min)
if name not in UNITS and name.endswith("s"):
name = name[:-1]
if name not in UNITS:
raise ValueError("the unit {} isn't defined as a valid " "game time unit".format(name))
rtime += value * UNITS[name]
rtime /= TIMEFACTOR
if format:
return time_to_tuple(rtime, 31536000, 2628000, 604800, 86400, 3600, 60)
return rtime
def realtime_to_gametime(secs=0, mins=0, hrs=0, days=1, weeks=1, months=1, yrs=0, format=False):
"""
This method calculates how much in-game time a real-world time
interval would correspond to. This is usually a lot less
interesting than the other way around.
Keyword Args:
times (int): The various components of the time.
format (bool): Formatting the output.
Returns:
time (float or tuple): The gametime difference or the same
time split up into time units.
Note:
days/weeks/months start from 1 (there is no day/week/month 0). This makes it
consistent with the real world datetime.
Raises:
ValueError: If trying to add a days/weeks/months of <=0.
Example:
realtime_to_gametime(days=2) -> number of game-world seconds
"""
if days <= 0 or weeks <= 0 or months <= 0:
raise ValueError("realtime_to_gametime: days/weeks/months cannot be set <= 0, "
"they start from 1.")
# days/weeks/months start from 1, we need to adjust them to work mathematically.
days, weeks, months = days - 1, weeks - 1, months - 1
gtime = TIMEFACTOR * (
secs
+ mins * 60
+ hrs * 3600
+ days * 86400
+ weeks * 604800
+ months * 2628000
+ yrs * 31536000
)
if format:
units = sorted(set(UNITS.values()), reverse=True)
# Remove seconds from the tuple
del units[-1]
return time_to_tuple(gtime, *units)
return gtime
def custom_gametime(absolute=False):
"""
Return the custom game time as a tuple of units, as defined in settings.
Args:
absolute (bool, optional): return the relative or absolute time.
Returns:
The tuple describing the game time. The length of the tuple
is related to the number of unique units defined in the
settings. By default, the tuple would be (year, month,
week, day, hour, minute, second).
"""
current = gametime.gametime(absolute=absolute)
units = sorted(set(UNITS.values()), reverse=True)
del units[-1]
return time_to_tuple(current, *units)
def real_seconds_until(**kwargs):
"""
Return the real seconds until game time.
If the game time is 5:00, TIME_FACTOR is set to 2 and you ask
the number of seconds until it's 5:10, then this function should
return 300 (5 minutes).
Args:
times (str: int): the time units.
Example:
real_seconds_until(hour=5, min=10, sec=0)
Returns:
The number of real seconds before the given game time is up.
Notes:
day/week/month start from 1, not from 0 (there is no month 0 for example)
"""
current = gametime.gametime(absolute=True)
units = sorted(set(UNITS.values()), reverse=True)
# Remove seconds from the tuple
del units[-1]
divisors = list(time_to_tuple(current, *units))
# For each keyword, add in the unit's
units.append(1)
higher_unit = None
for unit, value in kwargs.items():
if unit in ("day", "week", "month"):
# these start from 1 so we must adjust
value -= 1
# Get the unit's index
if unit not in UNITS:
raise ValueError(f"Unknown unit '{unit}'. Allowed: {', '.join(UNITS)}")
seconds = UNITS[unit]
index = units.index(seconds)
divisors[index] = value
if higher_unit is None or higher_unit > index:
higher_unit = index
# Check the projected time
# Note that it can be already passed (the given time may be in the past)
projected = 0
for i, value in enumerate(divisors):
seconds = units[i]
projected += value * seconds
if projected <= current:
# The time is in the past, increase the higher unit
if higher_unit:
divisors[higher_unit - 1] += 1
else:
divisors[0] += 1
# Get the projected time again
projected = 0
for i, value in enumerate(divisors):
seconds = units[i]
projected += value * seconds
return (projected - current) / TIMEFACTOR
def schedule(callback, repeat=False, **kwargs):
"""
Call the callback when the game time is up.
Args:
callback (function): The callback function that will be called. This
must be a top-level function since the script will be persistent.
repeat (bool, optional): Should the callback be called regularly?
day, month, etc (str: int): The time units to call the callback; should
match the keys of TIME_UNITS.
Returns:
script (Script): The created script.
Examples:
schedule(func, min=5, sec=0) # Will call next hour at :05.
schedule(func, hour=2, min=30, sec=0) # Will call the next day at 02:30.
Notes:
This function will setup a script that will be called when the
time corresponds to the game time. If the game is stopped for
more than a few seconds, the callback may be called with a
slight delay. If `repeat` is set to True, the callback will be
called again next time the game time matches the given time.
The time is given in units as keyword arguments.
"""
seconds = real_seconds_until(**kwargs)
script = create_script(
"evennia.contrib.custom_gametime.GametimeScript",
key="GametimeScript",
desc="A timegame-sensitive script",
interval=seconds,
start_delay=True,
repeats=-1 if repeat else 1,
)
script.db.callback = callback
script.db.gametime = kwargs
return script
# Scripts dealing in gametime (use `schedule` to create it)
class GametimeScript(DefaultScript):
"""Gametime-sensitive script."""
def at_script_creation(self):
"""The script is created."""
self.key = "unknown scr"
self.interval = 100
self.start_delay = True
self.persistent = True
def at_repeat(self):
"""Call the callback and reset interval."""
from evennia.utils.utils import calledby
callback = self.db.callback
if callback:
callback()
seconds = real_seconds_until(**self.db.gametime)
self.restart(interval=seconds)

View file

@ -0,0 +1,363 @@
"""
Email-based login system
Evennia contrib - Griatch 2012
This is a variant of the login system that requires an email-address
instead of a username to login.
This used to be the default Evennia login before replacing it with a
more standard username + password system (having to supply an email
for some reason caused a lot of confusion when people wanted to expand
on it. The email is not strictly needed internally, nor is any
confirmation email sent out anyway).
Installation is simple:
To your settings file, add/edit the line:
```python
CMDSET_UNLOGGEDIN = "contrib.email_login.UnloggedinCmdSet"
```
That's it. Reload the server and try to log in to see it.
The initial login "graphic" will still not mention email addresses
after this change. The login splashscreen is taken from strings in
the module given by settings.CONNECTION_SCREEN_MODULE.
"""
import re
from django.conf import settings
from evennia.accounts.models import AccountDB
from evennia.objects.models import ObjectDB
from evennia.server.models import ServerConfig
from evennia.commands.cmdset import CmdSet
from evennia.utils import logger, utils, ansi
from evennia.commands.default.muxcommand import MuxCommand
from evennia.commands.cmdhandler import CMD_LOGINSTART
from evennia.commands.default import (
unloggedin as default_unloggedin,
) # Used in CmdUnconnectedCreate
# limit symbol import for API
__all__ = (
"CmdUnconnectedConnect",
"CmdUnconnectedCreate",
"CmdUnconnectedQuit",
"CmdUnconnectedLook",
"CmdUnconnectedHelp",
)
MULTISESSION_MODE = settings.MULTISESSION_MODE
CONNECTION_SCREEN_MODULE = settings.CONNECTION_SCREEN_MODULE
CONNECTION_SCREEN = ""
try:
CONNECTION_SCREEN = ansi.parse_ansi(utils.random_string_from_module(CONNECTION_SCREEN_MODULE))
except Exception:
# malformed connection screen or no screen given
pass
if not CONNECTION_SCREEN:
CONNECTION_SCREEN = (
"\nEvennia: Error in CONNECTION_SCREEN MODULE"
" (randomly picked connection screen variable is not a string). \nEnter 'help' for aid."
)
class CmdUnconnectedConnect(MuxCommand):
"""
Connect to the game.
Usage (at login screen):
connect <email> <password>
Use the create command to first create an account before logging in.
"""
key = "connect"
aliases = ["conn", "con", "co"]
locks = "cmd:all()" # not really needed
def func(self):
"""
Uses the Django admin api. Note that unlogged-in commands
have a unique position in that their `func()` receives
a session object instead of a `source_object` like all
other types of logged-in commands (this is because
there is no object yet before the account has logged in)
"""
session = self.caller
arglist = self.arglist
if not arglist or len(arglist) < 2:
session.msg("\n\r Usage (without <>): connect <email> <password>")
return
email = arglist[0]
password = arglist[1]
# Match an email address to an account.
account = AccountDB.objects.get_account_from_email(email)
# No accountname match
if not account:
string = "The email '%s' does not match any accounts." % email
string += "\n\r\n\rIf you are new you should first create a new account "
string += "using the 'create' command."
session.msg(string)
return
# We have at least one result, so we can check the password.
if not account[0].check_password(password):
session.msg("Incorrect password.")
return
# Check IP and/or name bans
bans = ServerConfig.objects.conf("server_bans")
if bans and (
any(tup[0] == account.name for tup in bans)
or any(tup[2].match(session.address[0]) for tup in bans if tup[2])
):
# this is a banned IP or name!
string = "|rYou have been banned and cannot continue from here."
string += "\nIf you feel this ban is in error, please email an admin.|x"
session.msg(string)
session.execute_cmd("quit")
return
# actually do the login. This will call all hooks.
session.sessionhandler.login(session, account)
class CmdUnconnectedCreate(MuxCommand):
"""
Create a new account.
Usage (at login screen):
create \"accountname\" <email> <password>
This creates a new account account.
"""
key = "create"
aliases = ["cre", "cr"]
locks = "cmd:all()"
def parse(self):
"""
The parser must handle the multiple-word account
name enclosed in quotes:
connect "Long name with many words" my@myserv.com mypassw
"""
super().parse()
self.accountinfo = []
if len(self.arglist) < 3:
return
if len(self.arglist) > 3:
# this means we have a multi_word accountname. pop from the back.
password = self.arglist.pop()
email = self.arglist.pop()
# what remains is the accountname.
accountname = " ".join(self.arglist)
else:
accountname, email, password = self.arglist
accountname = accountname.replace('"', "") # remove "
accountname = accountname.replace("'", "")
self.accountinfo = (accountname, email, password)
def func(self):
"""Do checks and create account"""
session = self.caller
try:
accountname, email, password = self.accountinfo
except ValueError:
string = '\n\r Usage (without <>): create "<accountname>" <email> <password>'
session.msg(string)
return
if not email or not password:
session.msg("\n\r You have to supply an e-mail address followed by a password.")
return
if not utils.validate_email_address(email):
# check so the email at least looks ok.
session.msg("'%s' is not a valid e-mail address." % email)
return
# sanity checks
if not re.findall(r"^[\w. @+\-']+$", accountname) or not (0 < len(accountname) <= 30):
# this echoes the restrictions made by django's auth
# module (except not allowing spaces, for convenience of
# logging in).
string = "\n\r Accountname can max be 30 characters or fewer. Letters, spaces, digits and @/./+/-/_/' only."
session.msg(string)
return
# strip excessive spaces in accountname
accountname = re.sub(r"\s+", " ", accountname).strip()
if AccountDB.objects.filter(username__iexact=accountname):
# account already exists (we also ignore capitalization here)
session.msg("Sorry, there is already an account with the name '%s'." % accountname)
return
if AccountDB.objects.get_account_from_email(email):
# email already set on an account
session.msg("Sorry, there is already an account with that email address.")
return
# Reserve accountnames found in GUEST_LIST
if settings.GUEST_LIST and accountname.lower() in (
guest.lower() for guest in settings.GUEST_LIST
):
string = "\n\r That name is reserved. Please choose another Accountname."
session.msg(string)
return
if not re.findall(r"^[\w. @+\-']+$", password) or not (3 < len(password)):
string = (
"\n\r Password should be longer than 3 characters. Letters, spaces, digits and @/./+/-/_/' only."
"\nFor best security, make it longer than 8 characters. You can also use a phrase of"
"\nmany words if you enclose the password in double quotes."
)
session.msg(string)
return
# Check IP and/or name bans
bans = ServerConfig.objects.conf("server_bans")
if bans and (
any(tup[0] == accountname.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."
"\nIf you feel this ban is in error, please email an admin.|x"
)
session.msg(string)
session.sessionhandler.disconnect(session, "Good bye! Disconnecting.")
return
# everything's ok. Create the new player account.
try:
permissions = settings.PERMISSION_ACCOUNT_DEFAULT
typeclass = settings.BASE_CHARACTER_TYPECLASS
new_account = default_unloggedin._create_account(
session, accountname, password, permissions, email=email
)
if new_account:
if MULTISESSION_MODE < 2:
default_home = ObjectDB.objects.get_id(settings.DEFAULT_HOME)
default_unloggedin._create_character(
session, new_account, typeclass, default_home, permissions
)
# tell the caller everything went well.
string = "A new account '%s' was created. Welcome!"
if " " in accountname:
string += (
"\n\nYou can now log in with the command 'connect \"%s\" <your password>'."
)
else:
string += "\n\nYou can now log with the command 'connect %s <your password>'."
session.msg(string % (accountname, email))
except Exception:
# We are in the middle between logged in and -not, so we have
# to handle tracebacks ourselves at this point. If we don't,
# we won't see any errors at all.
session.msg("An error occurred. Please e-mail an admin if the problem persists.")
logger.log_trace()
raise
class CmdUnconnectedQuit(MuxCommand):
"""
We maintain a different version of the `quit` command
here for unconnected accounts for the sake of simplicity. The logged in
version is a bit more complicated.
"""
key = "quit"
aliases = ["q", "qu"]
locks = "cmd:all()"
def func(self):
"""Simply close the connection."""
session = self.caller
session.sessionhandler.disconnect(session, "Good bye! Disconnecting.")
class CmdUnconnectedLook(MuxCommand):
"""
This is an unconnected version of the `look` command for simplicity.
This is called by the server and kicks everything in gear.
All it does is display the connect screen.
"""
key = CMD_LOGINSTART
aliases = ["look", "l"]
locks = "cmd:all()"
def func(self):
"""Show the connect screen."""
self.caller.msg(CONNECTION_SCREEN)
class CmdUnconnectedHelp(MuxCommand):
"""
This is an unconnected version of the help command,
for simplicity. It shows a pane of info.
"""
key = "help"
aliases = ["h", "?"]
locks = "cmd:all()"
def func(self):
"""Shows help"""
string = """
You are not yet logged into the game. Commands available at this point:
|wcreate, connect, look, help, quit|n
To login to the system, you need to do one of the following:
|w1)|n If you have no previous account, you need to use the 'create'
command like this:
|wcreate "Anna the Barbarian" anna@myemail.com c67jHL8p|n
It's always a good idea (not only here, but everywhere on the net)
to not use a regular word for your password. Make it longer than
3 characters (ideally 6 or more) and mix numbers and capitalization
into it.
|w2)|n If you have an account already, either because you just created
one in |w1)|n above or you are returning, use the 'connect' command:
|wconnect anna@myemail.com c67jHL8p|n
This should log you in. Run |whelp|n again once you're logged in
to get more aid. Hope you enjoy your stay!
You can use the |wlook|n command if you want to see the connect screen again.
"""
self.caller.msg(string)
# command set for the mux-like login
class UnloggedinCmdSet(CmdSet):
"""
Sets up the unlogged cmdset.
"""
key = "Unloggedin"
priority = 0
def at_cmdset_creation(self):
"""Populate the cmdset"""
self.add(CmdUnconnectedConnect())
self.add(CmdUnconnectedCreate())
self.add(CmdUnconnectedQuit())
self.add(CmdUnconnectedLook())
self.add(CmdUnconnectedHelp())

View file

@ -0,0 +1,869 @@
# Evennia in-game Python system
Vincent Le Goff 2017
This contrib adds the system of in-game Python in Evennia, allowing immortals (or other trusted builders) to
dynamically add features to individual objects. Using custom Python set in-game, every immortal or privileged users
could have a specific room, exit, character, object or something else behave differently from its
"cousins". For these familiar with the use of softcode in MU`*`, like SMAUG MudProgs, the ability to
add arbitrary behavior to individual objects is a step toward freedom. Keep in mind, however, the
warning below, and read it carefully before the rest of the documentation.
## A WARNING REGARDING SECURITY
Evennia's in-game Python system will run arbitrary Python code without much restriction. Such a system is as
powerful as potentially dangerous, and you will have to keep in mind these points before deciding to
install it:
1. Untrusted people can run Python code on your game server with this system. Be careful about who
can use this system (see the permissions below).
2. You can do all of this in Python outside the game. The in-game Python system is not to replace all your
game feature.
## Basic structure and vocabulary
- At the basis of the in-game Python system are **events**. An **event** defines the context in which we
would like to call some arbitrary code. For instance, one event is
defined on exits and will fire every time a character traverses through this exit. Events are described
on a [typeclass](https://github.com/evennia/evennia/wiki/Typeclasses) (like
[exits](https://github.com/evennia/evennia/wiki/Objects#exits) in our example). All objects inheriting
from this typeclass will have access to this event.
- **Callbacks** can be set on individual objects, on events defined in code. These **callbacks**
can contain arbitrary code and describe a specific behavior for an object. When the event fires,
all callbacks connected to this object's event are executed.
To see the system in context, when an object is picked up (using the default `get` command), a
specific event is fired:
1. The event "get" is set on objects (on the `Object` typeclass).
2. When using the "get" command to pick up an object, this object's `at_get` hook is called.
3. A modified hook of DefaultObject is set by the event system. This hook will execute (or call)
the "get" event on this object.
4. All callbacks tied to this object's "get" event will be executed in order. These callbacks act
as functions containing Python code that you can write in-game, using specific variables that
will be listed when you edit the callback itself.
5. In individual callbacks, you can add multiple lines of Python code that will be fired at this
point. In this example, the `character` variable will contain the character who has picked up
the object, while `obj` will contain the object that was picked up.
Following this example, if you create a callback "get" on the object "a sword", and put in it:
```python
character.msg("You have picked up {} and have completed this quest!".format(obj.get_display_name(character)))
```
When you pick up this object you should see something like:
You pick up a sword.
You have picked up a sword and have completed this quest!
## Installation
Being in a separate contrib, the in-game Python system isn't installed by default. You need to do it
manually, following these steps:
1. Launch the main script (important!):
```@py evennia.create_script("evennia.contrib.ingame_python.scripts.EventHandler")```
2. Set the permissions (optional):
- `EVENTS_WITH_VALIDATION`: a group that can edit callbacks, but will need approval (default to
`None`).
- `EVENTS_WITHOUT_VALIDATION`: a group with permission to edit callbacks without need of
validation (default to `"immortals"`).
- `EVENTS_VALIDATING`: a group that can validate callbacks (default to `"immortals"`).
- `EVENTS_CALENDAR`: type of the calendar to be used (either `None`, `"standard"` or `"custom"`,
default to `None`).
3. Add the `@call` command.
4. Inherit from the custom typeclasses of the in-game Python system.
- `evennia.contrib.ingame_python.typeclasses.EventCharacter`: to replace `DefaultCharacter`.
- `evennia.contrib.ingame_python.typeclasses.EventExit`: to replace `DefaultExit`.
- `evennia.contrib.ingame_python.typeclasses.EventObject`: to replace `DefaultObject`.
- `evennia.contrib.ingame_python.typeclasses.EventRoom`: to replace `DefaultRoom`.
The following sections describe in details each step of the installation.
> Note: If you were to start the game without having started the main script (such as when
resetting your database) you will most likely face a traceback when logging in, telling you
that a 'callback' property is not defined. After performing step `1` the error will go away.
### Starting the event script
To start the event script, you only need a single command, using `@py`.
@py evennia.create_script("evennia.contrib.ingame_python.scripts.EventHandler")
This command will create a global script (that is, a script independent from any object). This
script will hold basic configuration, individual callbacks and so on. You may access it directly,
but you will probably use the callback handler. Creating this script will also create a `callback`
handler on all objects (see below for details).
### Editing permissions
This contrib comes with its own set of permissions. They define who can edit callbacks without
validation, and who can edit callbacks but needs validation. Validation is a process in which an
administrator (or somebody trusted as such) will check the callbacks produced by others and will
accept or reject them. If accepted, the callbacks are connected, otherwise they are never run.
By default, callbacks can only be created by immortals: no one except the immortals can edit
callbacks, and immortals don't need validation. It can easily be changed, either through settings
or dynamically by changing permissions of users.
The events contrib adds three
[permissions](https://github.com/evennia/evennia/wiki/Locks#permissions) in the settings. You can
override them by changing the settings into your `server/conf/settings.py` file (see below for an
example). The settings defined in the events contrib are:
- `EVENTS_WITH_VALIDATION`: this defines a permission that can edit callbacks, but will need
approval. If you set this to `"wizards"`, for instance, users with the permission `"wizards"`
will be able to edit callbacks. These callbacks will not be connected, though, and will need to be
checked and approved by an administrator. This setting can contain `None`, meaning that no user is
allowed to edit callbacks with validation.
- `EVENTS_WITHOUT_VALIDATION`: this setting defines a permission allowing editing of callbacks
without needing validation. By default, this setting is set to `"immortals"`. It means that
immortals can edit callbacks, and they will be connected when they leave the editor, without needing
approval.
- `EVENTS_VALIDATING`: this last setting defines who can validate callbacks. By default, this is
set to `"immortals"`, meaning only immortals can see callbacks needing validation, accept or
reject them.
You can override all these settings in your `server/conf/settings.py` file. For instance:
```python
# ... other settings ...
# Event settings
EVENTS_WITH_VALIDATION = "wizards"
EVENTS_WITHOUT_VALIDATION = "immortals"
EVENTS_VALIDATING = "immortals"
```
In addition, there is another setting that must be set if you plan on using the time-related events
(events that are scheduled at specific, in-game times). You would need to specify the type of
calendar you are using. By default, time-related events are disabled. You can change the
`EVENTS_CALENDAR` to set it to:
- `"standard"`: the standard calendar, with standard days, months, years and so on.
- `"custom"`: a custom calendar that will use the
[custom_gametime](https://github.com/evennia/evennia/blob/master/evennia/contrib/custom_gametime.py)
contrib to schedule events.
This contrib defines two additional permissions that can be set on individual users:
- `events_without_validation`: this would give this user the rights to edit callbacks but not
require validation before they are connected.
- `events_validating`: this permission allows this user to run validation checks on callbacks
needing to be validated.
For instance, to give the right to edit callbacks without needing approval to the player 'kaldara',
you might do something like:
@perm *kaldara = events_without_validation
To remove this same permission, just use the `/del` switch:
@perm/del *kaldara = events_without_validation
The rights to use the `@call` command are directly related to these permissions: by default, only
users who have the `events_without_validation` permission or are in (or above) the group defined in
the `EVENTS_WITH_VALIDATION` setting will be able to call the command (with different switches).
### Adding the `@call` command
You also have to add the `@call` command to your Character CmdSet. This command allows your users
to add, edit and delete callbacks in-game. In your `commands/default_cmdsets, it might look like
this:
```python
from evennia import default_cmds
from evennia.contrib.ingame_python.commands import CmdCallback
class CharacterCmdSet(default_cmds.CharacterCmdSet):
"""
The `CharacterCmdSet` contains general in-game commands like `look`,
`get`, etc available on in-game Character objects. It is merged with
the `PlayerCmdSet` when a Player puppets a Character.
"""
key = "DefaultCharacter"
def at_cmdset_creation(self):
"""
Populates the cmdset
"""
super(CharacterCmdSet, self).at_cmdset_creation()
self.add(CmdCallback())
```
### Changing parent classes of typeclasses
Finally, to use the in-game Python system, you need to have your typeclasses inherit from the modified event
classes. For instance, in your `typeclasses/characters.py` module, you should change inheritance
like this:
```python
from evennia.contrib.ingame_python.typeclasses import EventCharacter
class Character(EventCharacter):
# ...
```
You should do the same thing for your rooms, exits and objects. Note that the in-game Python system works by
overriding some hooks. Some of these features might not be accessible in your game if you don't
call the parent methods when overriding hooks.
## Using the `@call` command
The in-game Python system relies, to a great extent, on its `@call` command. Who can execute this command,
and who can do what with it, will depend on your set of permissions.
The `@call` command allows to add, edit and delete callbacks on specific objects' events. The event
system can be used on most Evennia objects, mostly typeclassed objects (excluding players). The
first argument of the `@call` command is the name of the object you want to edit. It can also be
used to know what events are available for this specific object.
### Examining callbacks and events
To see the events connected to an object, use the `@call` command and give the name or ID of the
object to examine. For instance, @call here` to examine the events on your current location. Or
`@call self` to see the events on yourself.
This command will display a table, containing:
- The name of each event in the first column.
- The number of callbacks of this name, and the number of total lines of these callbacks in the
second column.
- A short help to tell you when the event is triggered in the third column.
If you execute `@call #1` for instance, you might see a table like this:
```
+------------------+---------+-----------------------------------------------+
| Event name | Number | Description |
+~~~~~~~~~~~~~~~~~~+~~~~~~~~~+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
| can_delete | 0 (0) | Can the character be deleted? |
| can_move | 0 (0) | Can the character move? |
| can_part | 0 (0) | Can the departing character leave this room? |
| delete | 0 (0) | Before deleting the character. |
| greet | 0 (0) | A new character arrives in the location of |
| | | this character. |
| move | 0 (0) | After the character has moved into its new |
| | | room. |
| puppeted | 0 (0) | When the character has been puppeted by a |
| | | player. |
| time | 0 (0) | A repeated event to be called regularly. |
| unpuppeted | 0 (0) | When the character is about to be un- |
| | | puppeted. |
+------------------+---------+-----------------------------------------------+
```
### Creating a new callback
The `/add` switch should be used to add a callback. It takes two arguments beyond the object's
name/DBREF:
1. After an = sign, the name of the event to be edited (if not supplied, will display the list of
possible events, like above).
2. The parameters (optional).
We'll see callbacks with parameters later. For the time being, let's try to prevent a character
from going through the "north" exit of this room:
```
@call north
+------------------+---------+-----------------------------------------------+
| Event name | Number | Description |
+~~~~~~~~~~~~~~~~~~+~~~~~~~~~+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
| can_traverse | 0 (0) | Can the character traverse through this exit? |
| msg_arrive | 0 (0) | Customize the message when a character |
| | | arrives through this exit. |
| msg_leave | 0 (0) | Customize the message when a character leaves |
| | | through this exit. |
| time | 0 (0) | A repeated event to be called regularly. |
| traverse | 0 (0) | After the character has traversed through |
| | | this exit. |
+------------------+---------+-----------------------------------------------+
```
If we want to prevent a character from traversing through this exit, the best event for us would be
"can_traverse".
> Why not "traverse"? If you read the description of both events, you will see "traverse" is called
**after** the character has traversed through this exit. It would be too late to prevent it. On
> the other hand, "can_traverse" is obviously checked before the character traverses.
When we edit the event, we have some more information:
@call/add north = can_traverse
Can the character traverse through this exit?
This event is called when a character is about to traverse this
exit. You can use the deny() eventfunc to deny the character from
exiting for this time.
Variables you can use in this event:
- character: the character that wants to traverse this exit.
- exit: the exit to be traversed.
- room: the room in which stands the character before moving.
The section dedicated to [eventfuncs](#the-eventfuncs) will elaborate on the `deny()` function and
other eventfuncs. Let us say, for the time being, that it can prevent an action (in this case, it
can prevent the character from traversing through this exit). In the editor that opened when you
used `@call/add`, you can type something like:
```python
if character.id == 1:
character.msg("You're the superuser, 'course I'll let you pass.")
else:
character.msg("Hold on, what do you think you're doing?")
deny()
```
You can now enter `:wq` to leave the editor by saving the callback.
If you enter `@call north`, you should see that "can_traverse" now has an active callback. You can
use `@call north = can_traverse` to see more details on the connected callbacks:
```
@call north = can_traverse
+--------------+--------------+----------------+--------------+--------------+
| Number | Author | Updated | Param | Valid |
+~~~~~~~~~~~~~~+~~~~~~~~~~~~~~+~~~~~~~~~~~~~~~~+~~~~~~~~~~~~~~+~~~~~~~~~~~~~~+
| 1 | XXXXX | 5 seconds ago | | Yes |
+--------------+--------------+----------------+--------------+--------------+
```
The left column contains callback numbers. You can use them to have even more information on a
specific event. Here, for instance:
```
@call north = can_traverse 1
Callback can_traverse 1 of north:
Created by XXXXX on 2017-04-02 17:58:05.
Updated by XXXXX on 2017-04-02 18:02:50
This callback is connected and active.
Callback code:
if character.id == 1:
character.msg("You're the superuser, 'course I'll let you pass.")
else:
character.msg("Hold on, what do you think you're doing?")
deny()
```
Then try to walk through this exit. Do it with another character if possible, too, to see the
difference.
### Editing and removing a callback
You can use the `/edit` switch to the `@call` command to edit a callback. You should provide, after
the name of the object to edit and the equal sign:
1. The name of the event (as seen above).
2. A number, if several callbacks are connected at this location.
You can type `@call/edit <object> = <event name>` to see the callbacks that are linked at this
location. If there is only one callback, it will be opened in the editor; if more are defined, you
will be asked for a number to provide (for instance, `@call/edit north = can_traverse 2`).
The command `@call` also provides a `/del` switch to remove a callback. It takes the same arguments
as the `/edit` switch.
When removed, callbacks are logged, so an administrator can retrieve its content, assuming the
`/del` was an error.
### The code editor
When adding or editing a callback, the event editor should open in code mode. The additional
options supported by the editor in this mode are describe in [a dedicated section of the EvEditor's
documentation](https://github.com/evennia/evennia/wiki/EvEditor#the-eveditor-to-edit-code).
## Using events
The following sections describe how to use events for various tasks, from the most simple to the
most complex.
### The eventfuncs
In order to make development a little easier, the in-game Python system provides eventfuncs to be used in
callbacks themselves. You don't have to use them, they are just shortcuts. An eventfunc is just a
simple function that can be used inside of your callback code.
Function | Argument | Description | Example
-----------|--------------------------|-----------------------------------|--------
deny | `()` | Prevent an action from happening. | `deny()`
get | `(**kwargs)` | Get a single object. | `char = get(id=1)`
call_event | `(obj, name, seconds=0)` | Call another event. | `call_event(char, "chain_1", 20)`
#### deny
The `deny()` function allows to interrupt the callback and the action that called it. In the
`can_*` events, it can be used to prevent the action from happening. For instance, in `can_say` on
rooms, it can prevent the character from saying something in the room. One could have a `can_eat`
event set on food that would prevent this character from eating this food.
Behind the scenes, the `deny()` function raises an exception that is being intercepted by the
handler of events. The handler will then report that the action was cancelled.
#### get
The `get` eventfunc is a shortcut to get a single object with a specific identity. It's often used
to retrieve an object with a given ID. In the section dedicated to [chained
events](#chained-events), you will see a concrete example of this function in action.
#### call_event
Some callbacks will call other events. It is particularly useful for [chained
events](#chained-events) that are described in a dedicated section. This eventfunc is used to call
another event, immediately or in a defined time.
You need to specify as first parameter the object containing the event. The second parameter is the
name of the event to call. The third parameter is the number of seconds before calling this event.
By default, this parameter is set to 0 (the event is called immediately).
### Variables in callbacks
In the Python code you will enter in individual callbacks, you will have access to variables in your
locals. These variables will depend on the event, and will be clearly listed when you add or edit a
callback. As you've seen in the previous example, when we manipulate characters or character
actions, we often have a `character` variable that holds the character doing the action.
In most cases, when an event is fired, all callbacks from this event are called. Variables are
created for each event. Sometimes, however, the callback will execute and then ask for a variable
in your locals: in other words, some callbacks can alter the actions being performed by changing
values of variables. This is always clearly specified in the help of the event.
One example that will illustrate this system is the "msg_leave" event that can be set on exits.
This event can alter the message that will be sent to other characters when someone leaves through
this exit.
@call/add down = msg_leave
Which should display:
```
Customize the message when a character leaves through this exit.
This event is called when a character leaves through this exit.
To customize the message that will be sent to the room where the
character came from, change the value of the variable "message"
to give it your custom message. The character itself will not be
notified. You can use mapping between braces, like this:
message = "{character} falls into a hole!"
In your mapping, you can use {character} (the character who is
about to leave), {exit} (the exit), {origin} (the room in which
the character is), and {destination} (the room in which the character
is heading for). If you need to customize the message with other
information, you can also set "message" to None and send something
else instead.
Variables you can use in this event:
character: the character who is leaving through this exit.
exit: the exit being traversed.
origin: the location of the character.
destination: the destination of the character.
message: the message to be displayed in the location.
mapping: a dictionary containing additional mapping.
```
If you write something like this in your event:
```python
message = "{character} falls into a hole in the ground!"
```
And if the character Wilfred takes this exit, others in the room will see:
Wildred falls into a hole in the ground!
In this case, the in-game Python system placed the variable "message" in the callback locals, but will read
from it when the event has been executed.
### Callbacks with parameters
Some callbacks are called without parameter. It has been the case for all examples we have seen
before. In some cases, you can create callbacks that are triggered under only some conditions. A
typical example is the room's "say" event. This event is triggered when somebody says something in
the room. Individual callbacks set on this event can be configured to fire only when some words are
used in the sentence.
For instance, let's say we want to create a cool voice-operated elevator. You enter into the
elevator and say the floor number... and the elevator moves in the right direction. In this case,
we could create an callback with the parameter "one":
@call/add here = say one
This callback will only fire when the user says a sentence that contains "one".
But what if we want to have a callback that would fire if the user says 1 or one? We can provide
several parameters, separated by a comma.
@call/add here = say 1, one
Or, still more keywords:
@call/add here = say 1, one, ground
This time, the user could say something like "take me to the ground floor" ("ground" is one of our
keywords defined in the above callback).
Not all events can take parameters, and these who do have different ways of handling them. There
isn't a single meaning to parameters that could apply to all events. Refer to the event
documentation for details.
> If you get confused between callback variables and parameters, think of parameters as checks
> performed before the callback is run. Event with parameters will only fire some specific
> callbacks, not all of them.
### Time-related events
Events are usually linked to commands, as we saw before. However, this is not always the case.
Events can be triggered by other actions and, as we'll see later, could even be called from inside
other events!
There is a specific event, on all objects, that can trigger at a specific time. It's an event with
a mandatory parameter, which is the time you expect this event to fire.
For instance, let's add an event on this room that should trigger every day, at precisely 12:00 PM
(the time is given as game time, not real time):
@call here = time 12:00
```python
# This will be called every MUD day at 12:00 PM
room.msg_contents("It's noon, time to have lunch!")
```
Now, at noon every MUD day, this event will fire and this callback will be executed. You can use
this event on every kind of typeclassed object, to have a specific action done every MUD day at the
same time.
Time-related events can be much more complex than this. They can trigger every in-game hour or more
often (it might not be a good idea to have events trigger that often on a lot of objects). You can
have events that run every in-game week or month or year. It will greatly vary depending on the
type of calendar used in your game. The number of time units is described in the game
configuration.
With a standard calendar, for instance, you have the following units: minutes, hours, days, months
and years. You will specify them as numbers separated by either a colon (:), a space ( ), or a dash
(-). Pick whatever feels more appropriate (usually, we separate hours and minutes with a colon, the
other units with a dash).
Some examples of syntax:
- `18:30`: every day at 6:30 PM.
- `01 12:00`: every month, the first day, at 12 PM.
- `06-15 09:58`: every year, on the 15th of June (month comes before day), at 9:58 AM.
- `2025-01-01 00:00`: January 1st, 2025 at midnight (obviously, this will trigger only once).
Notice that we specify units in the reverse order (year, month, day, hour and minute) and separate
them with logical separators. The smallest unit that is not defined is going to set how often the
event should fire. That's why, if you use `12:00`, the smallest unit that is not defined is "day":
the event will fire every day at the specified time.
> You can use chained events (see below) in conjunction with time-related events to create more
random or frequent actions in events.
### Chained events
Callbacks can call other events, either now or a bit later. It is potentially very powerful.
To use chained events, just use the `call_event` eventfunc. It takes 2-3 arguments:
- The object containing the event.
- The name of the event to call.
- Optionally, the number of seconds to wait before calling this event.
All objects have events that are not triggered by commands or game-related operations. They are
called "chain_X", like "chain_1", "chain_2", "chain_3" and so on. You can give them more specific
names, as long as it begins by "chain_", like "chain_flood_room".
Rather than a long explanation, let's look at an example: a subway that will go from one place to
the next at regular times. Connecting exits (opening its doors), waiting a bit, closing them,
rolling around and stopping at a different station. That's quite a complex set of callbacks, as it
is, but let's only look at the part that opens and closes the doors:
@call/add here = time 10:00
```python
# At 10:00 AM, the subway arrives in the room of ID 22.
# Notice that exit #23 and #24 are respectively the exit leading
# on the platform and back in the subway.
station = get(id=22)
to_exit = get(id=23)
back_exit = get(id=24)
# Open the door
to_exit.name = "platform"
to_exit.aliases = ["p"]
to_exit.location = room
to_exit.destination = station
back_exit.name = "subway"
back_exit.location = station
back_exit.destination = room
# Display some messages
room.msg_contents("The doors open and wind gushes in the subway")
station.msg_contents("The doors of the subway open with a dull clank.")
# Set the doors to close in 20 seconds
call_event(room, "chain_1", 20)
```
This callback will:
1. Be called at 10:00 AM (specify 22:00 to set it to 10:00 PM).
2. Set an exit between the subway and the station. Notice that the exits already exist (you will
not have to create them), but they don't need to have specific location and destination.
3. Display a message both in the subway and on the platform.
4. Call the event "chain_1" to execute in 20 seconds.
And now, what should we have in "chain_1"?
@call/add here = chain_1
```python
# Close the doors
to_exit.location = None
to_exit.destination = None
back_exit.location = None
back_exit.destination = None
room.msg_content("After a short warning signal, the doors close and the subway begins moving.")
station.msg_content("After a short warning signal, the doors close and the subway begins moving.")
```
Behind the scenes, the `call_event` function freezes all variables ("room", "station", "to_exit",
"back_exit" in our example), so you don't need to define them again.
A word of caution on callbacks that call chained events: it isn't impossible for a callback to call
itself at some recursion level. If `chain_1` calls `chain_2` that calls `chain_3` that calls
`chain_`, particularly if there's no pause between them, you might run into an infinite loop.
Be also careful when it comes to handling characters or objects that may very well move during your
pause between event calls. When you use `call_event()`, the MUD doesn't pause and commands can be
entered by players, fortunately. It also means that, a character could start an event that pauses
for awhile, but be gone when the chained event is called. You need to check that, even lock the
character into place while you are pausing (some actions should require locking) or at least,
checking that the character is still in the room, for it might create illogical situations if you
don't.
> Chained events are a special case: contrary to standard events, they are created in-game, not
through code. They usually contain only one callback, although nothing prevents you from creating
several chained events in the same object.
## Using events in code
This section describes callbacks and events from code, how to create new events, how to call them in
a command, and how to handle specific cases like parameters.
Along this section, we will see how to implement the following example: we would like to create a
"push" command that could be used to push objects. Objects could react to this command and have
specific events fired.
### Adding new events
Adding new events should be done in your typeclasses. Events are contained in the `_events` class
variable, a dictionary of event names as keys, and tuples to describe these events as values. You
also need to register this class, to tell the in-game Python system that it contains events to be added to
this typeclass.
Here, we want to add a "push" event on objects. In your `typeclasses/objects.py` file, you should
write something like:
```python
from evennia.contrib.ingame_python.utils import register_events
from evennia.contrib.ingame_python.typeclasses import EventObject
EVENT_PUSH = """
A character push the object.
This event is called when a character uses the "push" command on
an object in the same room.
Variables you can use in this event:
character: the character that pushes this object.
obj: the object connected to this event.
"""
@register_events
class Object(EventObject):
"""
Class representing objects.
"""
_events = {
"push": (["character", "obj"], EVENT_PUSH),
}
```
- Line 1-2: we import several things we will need from the in-game Python system. Note that we use
`EventObject` as a parent instead of `DefaultObject`, as explained in the installation.
- Line 4-12: we usually define the help of the event in a separate variable, this is more readable,
though there's no rule against doing it another way. Usually, the help should contain a short
explanation on a single line, a longer explanation on several lines, and then the list of variables
with explanations.
- Line 14: we call a decorator on the class to indicate it contains events. If you're not familiar
with decorators, you don't really have to worry about it, just remember to put this line just
above the class definition if your class contains events.
- Line 15: we create the class inheriting from `EventObject`.
- Line 20-22: we define the events of our objects in an `_events` class variable. It is a
dictionary. Keys are event names. Values are a tuple containing:
- The list of variable names (list of str). This will determine what variables are needed when
the event triggers. These variables will be used in callbacks (as we'll see below).
- The event help (a str, the one we have defined above).
If you add this code and reload your game, create an object and examine its events with `@call`, you
should see the "push" event with its help. Of course, right now, the event exists, but it's not
fired.
### Calling an event in code
The in-game Python system is accessible through a handler on all objects. This handler is named `callbacks`
and can be accessed from any typeclassed object (your character, a room, an exit...). This handler
offers several methods to examine and call an event or callback on this object.
To call an event, use the `callbacks.call` method in an object. It takes as argument:
- The name of the event to call.
- All variables that will be accessible in the event as positional arguments. They should be
specified in the order chosen when [creating new events](#adding-new-events).
Following the same example, so far, we have created an event on all objects, called "push". This
event is never fired for the time being. We could add a "push" command, taking as argument the name
of an object. If this object is valid, it will call its "push" event.
```python
from commands.command import Command
class CmdPush(Command):
"""
Push something.
Usage:
push <something>
Push something where you are, like an elevator button.
"""
key = "push"
def func(self):
"""Called when pushing something."""
if not self.args.strip():
self.msg("Usage: push <something>")
return
# Search for this object
obj = self.caller.search(self.args)
if not obj:
return
self.msg("You push {}.".format(obj.get_display_name(self.caller)))
# Call the "push" event of this object
obj.callbacks.call("push", self.caller, obj)
```
Here we use `callbacks.call` with the following arguments:
- `"push"`: the name of the event to be called.
- `self.caller`: the one who pushed the button (this is our first variable, `character`).
- `obj`: the object being pushed (our second variable, `obj`).
In the "push" callbacks of our objects, we then can use the "character" variable (containing the one
who pushed the object), and the "obj" variable (containing the object that was pushed).
### See it all work
To see the effect of the two modifications above (the added event and the "push" command), let us
create a simple object:
@create/drop rock
@desc rock = It's a single rock, apparently pretty heavy. Perhaps you can try to push it though.
@call/add rock = push
In the callback you could write:
```python
from random import randint
number = randint(1, 6)
character.msg("You push a rock... is... it... going... to... move?")
if number == 6:
character.msg("The rock topples over to reveal a beautiful ant-hill!")
```
You can now try to "push rock". You'll try to push the rock, and once out of six times, you will
see a message about a "beautiful ant-hill".
### Adding new eventfuncs
Eventfuncs, like `deny(), are defined in `contrib/events/eventfuncs.py`. You can add your own
eventfuncs by creating a file named `eventfuncs.py` in your `world` directory. The functions
defined in this file will be added as helpers.
You can also decide to create your eventfuncs in another location, or even in several locations. To
do so, edit the `EVENTFUNCS_LOCATION` setting in your `server/conf/settings.py` file, specifying
either a python path or a list of Python paths in which your helper functions are defined. For
instance:
```python
EVENTFUNCS_LOCATIONS = [
"world.events.functions",
]
```
### Creating events with parameters
If you want to create events with parameters (if you create a "whisper" or "ask" command, for
instance, and need to have some characters automatically react to words), you can set an additional
argument in the tuple of events in your typeclass' ```_events``` class variable. This third argument
must contain a callback that will be called to filter through the list of callbacks when the event
fires. Two types of parameters are commonly used (but you can define more parameter types, although
this is out of the scope of this documentation).
- Keyword parameters: callbacks of this event will be filtered based on specific keywords. This is
useful if you want the user to specify a word and compare this word to a list.
- Phrase parameters: callbacks will be filtered using an entire phrase and checking all its words.
The "say" command uses phrase parameters (you can set a "say" callback to fires if a phrase
contains one specific word).
In both cases, you need to import a function from `evennia.contrib.ingame_python.utils` and use it as third
parameter in your event definition.
- `keyword_event` should be used for keyword parameters.
- `phrase_event` should be used for phrase parameters.
For example, here is the definition of the "say" event:
```python
from evennia.contrib.ingame_python.utils import register_events, phrase_event
# ...
@register_events
class SomeTypeclass:
_events = {
"say": (["speaker", "character", "message"], CHARACTER_SAY, phrase_event),
}
```
When you call an event using the `obj.callbacks.call` method, you should also provide the parameter,
using the `parameters` keyword:
```python
obj.callbacks.call(..., parameters="<put parameters here>")
```
It is necessary to specifically call the event with parameters, otherwise the system will not be
able to know how to filter down the list of callbacks.
## Disabling all events at once
When callbacks are running in an infinite loop, for instance, or sending unwanted information to
players or other sources, you, as the game administrator, have the power to restart without events.
The best way to do this is to use a custom setting, in your setting file
(`server/conf/settings.py`):
```python
# Disable all events
EVENTS_DISABLED = True
```
The in-game Python system will still be accessible (you will have access to the `@call` command, to debug),
but no event will be called automatically.

View file

@ -0,0 +1,225 @@
"""
Module containing the CallbackHandler for individual objects.
"""
from collections import namedtuple
class CallbackHandler(object):
"""
The callback handler for a specific object.
The script that contains all callbacks will be reached through this
handler. This handler is therefore a shortcut to be used by
developers. This handler (accessible through `obj.callbacks`) is a
shortcut to manipulating callbacks within this object, getting,
adding, editing, deleting and calling them.
"""
script = None
def __init__(self, obj):
self.obj = obj
def all(self):
"""
Return all callbacks linked to this object.
Returns:
All callbacks in a dictionary callback_name: callback}. The callback
is returned as a namedtuple to simplify manipulation.
"""
callbacks = {}
handler = type(self).script
if handler:
dicts = handler.get_callbacks(self.obj)
for callback_name, in_list in dicts.items():
new_list = []
for callback in in_list:
callback = self.format_callback(callback)
new_list.append(callback)
if new_list:
callbacks[callback_name] = new_list
return callbacks
def get(self, callback_name):
"""
Return the callbacks associated with this name.
Args:
callback_name (str): the name of the callback.
Returns:
A list of callbacks associated with this object and of this name.
Note:
This method returns a list of callback objects (namedtuple
representations). If the callback name cannot be found in the
object's callbacks, return an empty list.
"""
return self.all().get(callback_name, [])
def get_variable(self, variable_name):
"""
Return the variable value or None.
Args:
variable_name (str): the name of the variable.
Returns:
Either the variable's value or None.
"""
handler = type(self).script
if handler:
return handler.get_variable(variable_name)
return None
def add(self, callback_name, code, author=None, valid=False, parameters=""):
"""
Add a new callback for this object.
Args:
callback_name (str): the name of the callback to add.
code (str): the Python code associated with this callback.
author (Character or Account, optional): the author of the callback.
valid (bool, optional): should the callback be connected?
parameters (str, optional): optional parameters.
Returns:
The callback definition that was added or None.
"""
handler = type(self).script
if handler:
return self.format_callback(
handler.add_callback(
self.obj, callback_name, code, author=author, valid=valid, parameters=parameters
)
)
def edit(self, callback_name, number, code, author=None, valid=False):
"""
Edit an existing callback bound to this object.
Args:
callback_name (str): the name of the callback to edit.
number (int): the callback number to be changed.
code (str): the Python code associated with this callback.
author (Character or Account, optional): the author of the callback.
valid (bool, optional): should the callback be connected?
Returns:
The callback definition that was edited or None.
Raises:
RuntimeError if the callback is locked.
"""
handler = type(self).script
if handler:
return self.format_callback(
handler.edit_callback(
self.obj, callback_name, number, code, author=author, valid=valid
)
)
def remove(self, callback_name, number):
"""
Delete the specified callback bound to this object.
Args:
callback_name (str): the name of the callback to delete.
number (int): the number of the callback to delete.
Raises:
RuntimeError if the callback is locked.
"""
handler = type(self).script
if handler:
handler.del_callback(self.obj, callback_name, number)
def call(self, callback_name, *args, **kwargs):
"""
Call the specified callback(s) bound to this object.
Args:
callback_name (str): the callback name to call.
*args: additional variables for this callback.
Keyword Args:
number (int, optional): call just a specific callback.
parameters (str, optional): call a callback with parameters.
locals (dict, optional): a locals replacement.
Returns:
True to report the callback was called without interruption,
False otherwise. If the callbackHandler isn't found, return
None.
"""
handler = type(self).script
if handler:
return handler.call(self.obj, callback_name, *args, **kwargs)
return None
@staticmethod
def format_callback(callback):
"""
Return the callback namedtuple to represent the specified callback.
Args:
callback (dict): the callback definition.
The callback given in argument should be a dictionary containing
the expected fields for a callback (code, author, valid...).
"""
if "obj" not in callback:
callback["obj"] = None
if "name" not in callback:
callback["name"] = "unknown"
if "number" not in callback:
callback["number"] = -1
if "code" not in callback:
callback["code"] = ""
if "author" not in callback:
callback["author"] = None
if "valid" not in callback:
callback["valid"] = False
if "parameters" not in callback:
callback["parameters"] = ""
if "created_on" not in callback:
callback["created_on"] = None
if "updated_by" not in callback:
callback["updated_by"] = None
if "updated_on" not in callback:
callback["updated_on"] = None
return Callback(**callback)
Callback = namedtuple(
"Callback",
(
"obj",
"name",
"number",
"code",
"author",
"valid",
"parameters",
"created_on",
"updated_by",
"updated_on",
),
)

View file

@ -0,0 +1,583 @@
"""
Module containing the commands of the in-game Python system.
"""
from datetime import datetime
from django.conf import settings
from evennia import Command
from evennia.utils.ansi import raw
from evennia.utils.eveditor import EvEditor
from evennia.utils.evtable import EvTable
from evennia.utils.utils import class_from_module, time_format
from evennia.contrib.ingame_python.utils import get_event_handler
COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
# Permissions
WITH_VALIDATION = getattr(settings, "callbackS_WITH_VALIDATION", None)
WITHOUT_VALIDATION = getattr(settings, "callbackS_WITHOUT_VALIDATION", "developer")
VALIDATING = getattr(settings, "callbackS_VALIDATING", "developer")
# Split help text
BASIC_HELP = "Add, edit or delete callbacks."
BASIC_USAGES = [
"@call <object name> [= <callback name>]",
"@call/add <object name> = <callback name> [parameters]",
"@call/edit <object name> = <callback name> [callback number]",
"@call/del <object name> = <callback name> [callback number]",
"@call/tasks [object name [= <callback name>]]",
]
BASIC_SWITCHES = [
"add - add and edit a new callback",
"edit - edit an existing callback",
"del - delete an existing callback",
"tasks - show the list of differed tasks",
]
VALIDATOR_USAGES = ["@call/accept [object name = <callback name> [callback number]]"]
VALIDATOR_SWITCHES = ["accept - show callbacks to be validated or accept one"]
BASIC_TEXT = """
This command is used to manipulate callbacks. A callback can be linked to
an object, to fire at a specific moment. You can use the command without
switches to see what callbacks are active on an object:
@call self
You can also specify a callback name if you want the list of callbacks
associated with this object of this name:
@call north = can_traverse
You can also add a number after the callback name to see details on one callback:
@call here = say 2
You can also add, edit or remove callbacks using the add, edit or del switches.
Additionally, you can see the list of differed tasks created by callbacks
(chained events to be called) using the /tasks switch.
"""
VALIDATOR_TEXT = """
You can also use this command to validate callbacks. Depending on your game
setting, some users might be allowed to add new callbacks, but these callbacks
will not be fired until you accept them. To see the callbacks needing
validation, enter the /accept switch without argument:
@call/accept
A table will show you the callbacks that are not validated yet, who created
them and when. You can then accept a specific callback:
@call here = enter 1
Use the /del switch to remove callbacks that should not be connected.
"""
class CmdCallback(COMMAND_DEFAULT_CLASS):
"""
Command to edit callbacks.
"""
key = "@call"
aliases = ["@callback", "@callbacks", "@calls"]
locks = "cmd:perm({})".format(VALIDATING)
if WITH_VALIDATION:
locks += " or perm({})".format(WITH_VALIDATION)
help_category = "Building"
def get_help(self, caller, cmdset):
"""
Return the help message for this command and this caller.
The help text of this specific command will vary depending
on user permission.
Args:
caller (Object or Account): the caller asking for help on the command.
cmdset (CmdSet): the command set (if you need additional commands).
Returns:
docstring (str): the help text to provide the caller for this command.
"""
lock = "perm({}) or perm(callbacks_validating)".format(VALIDATING)
validator = caller.locks.check_lockstring(caller, lock)
text = "\n" + BASIC_HELP + "\n\nUsages:\n "
# Usages
text += "\n ".join(BASIC_USAGES)
if validator:
text += "\n " + "\n ".join(VALIDATOR_USAGES)
# Switches
text += "\n\nSwitches:\n "
text += "\n ".join(BASIC_SWITCHES)
if validator:
text += "\n " + "\n ".join(VALIDATOR_SWITCHES)
# Text
text += "\n" + BASIC_TEXT
if validator:
text += "\n" + VALIDATOR_TEXT
return text
def func(self):
"""Command body."""
caller = self.caller
lock = "perm({}) or perm(events_validating)".format(VALIDATING)
validator = caller.locks.check_lockstring(caller, lock)
lock = "perm({}) or perm(events_without_validation)".format(WITHOUT_VALIDATION)
autovalid = caller.locks.check_lockstring(caller, lock)
# First and foremost, get the callback handler and set other variables
self.handler = get_event_handler()
self.obj = None
rhs = self.rhs or ""
self.callback_name, sep, self.parameters = rhs.partition(" ")
self.callback_name = self.callback_name.lower()
self.is_validator = validator
self.autovalid = autovalid
if self.handler is None:
caller.msg("The event handler is not running, can't " "access the event system.")
return
# Before the equal sign, there is an object name or nothing
if self.lhs:
self.obj = caller.search(self.lhs)
if not self.obj:
return
# Switches are mutually exclusive
switch = self.switches and self.switches[0] or ""
if switch in ("", "add", "edit", "del") and self.obj is None:
caller.msg("Specify an object's name or #ID.")
return
if switch == "":
self.list_callbacks()
elif switch == "add":
self.add_callback()
elif switch == "edit":
self.edit_callback()
elif switch == "del":
self.del_callback()
elif switch == "accept" and validator:
self.accept_callback()
elif switch in ["tasks", "task"]:
self.list_tasks()
else:
caller.msg("Mutually exclusive or invalid switches were " "used, cannot proceed.")
def list_callbacks(self):
"""Display the list of callbacks connected to the object."""
obj = self.obj
callback_name = self.callback_name
parameters = self.parameters
callbacks = self.handler.get_callbacks(obj)
types = self.handler.get_events(obj)
if callback_name:
# Check that the callback name can be found in this object
created = callbacks.get(callback_name)
if created is None:
self.msg("No callback {} has been set on {}.".format(callback_name, obj))
return
if parameters:
# Check that the parameter points to an existing callback
try:
number = int(parameters) - 1
assert number >= 0
callback = callbacks[callback_name][number]
except (ValueError, AssertionError, IndexError):
self.msg(
"The callback {} {} cannot be found in {}.".format(
callback_name, parameters, obj
)
)
return
# Display the callback's details
author = callback.get("author")
author = author.key if author else "|gUnknown|n"
updated_by = callback.get("updated_by")
updated_by = updated_by.key if updated_by else "|gUnknown|n"
created_on = callback.get("created_on")
created_on = (
created_on.strftime("%Y-%m-%d %H:%M:%S") if created_on else "|gUnknown|n"
)
updated_on = callback.get("updated_on")
updated_on = (
updated_on.strftime("%Y-%m-%d %H:%M:%S") if updated_on else "|gUnknown|n"
)
msg = "Callback {} {} of {}:".format(callback_name, parameters, obj)
msg += "\nCreated by {} on {}.".format(author, created_on)
msg += "\nUpdated by {} on {}".format(updated_by, updated_on)
if self.is_validator:
if callback.get("valid"):
msg += "\nThis callback is |rconnected|n and active."
else:
msg += "\nThis callback |rhasn't been|n accepted yet."
msg += "\nCallback code:\n"
msg += raw(callback["code"])
self.msg(msg)
return
# No parameter has been specified, display the table of callbacks
cols = ["Number", "Author", "Updated", "Param"]
if self.is_validator:
cols.append("Valid")
table = EvTable(*cols, width=78)
table.reformat_column(0, align="r")
now = datetime.now()
for i, callback in enumerate(created):
author = callback.get("author")
author = author.key if author else "|gUnknown|n"
updated_on = callback.get("updated_on")
if updated_on is None:
updated_on = callback.get("created_on")
if updated_on:
updated_on = "{} ago".format(
time_format((now - updated_on).total_seconds(), 4).capitalize()
)
else:
updated_on = "|gUnknown|n"
parameters = callback.get("parameters", "")
row = [str(i + 1), author, updated_on, parameters]
if self.is_validator:
row.append("Yes" if callback.get("valid") else "No")
table.add_row(*row)
self.msg(str(table))
else:
names = list(set(list(types.keys()) + list(callbacks.keys())))
table = EvTable("Callback name", "Number", "Description", valign="t", width=78)
table.reformat_column(0, width=20)
table.reformat_column(1, width=10, align="r")
table.reformat_column(2, width=48)
for name in sorted(names):
number = len(callbacks.get(name, []))
lines = sum(len(e["code"].splitlines()) for e in callbacks.get(name, []))
no = "{} ({})".format(number, lines)
description = types.get(name, (None, "Chained event."))[1]
description = description.strip("\n").splitlines()[0]
table.add_row(name, no, description)
self.msg(str(table))
def add_callback(self):
"""Add a callback."""
obj = self.obj
callback_name = self.callback_name
types = self.handler.get_events(obj)
# Check that the callback exists
if not callback_name.startswith("chain_") and callback_name not in types:
self.msg(
"The callback name {} can't be found in {} of "
"typeclass {}.".format(callback_name, obj, type(obj))
)
return
definition = types.get(callback_name, (None, "Chained event."))
description = definition[1]
self.msg(raw(description.strip("\n")))
# Open the editor
callback = self.handler.add_callback(
obj, callback_name, "", self.caller, False, parameters=self.parameters
)
# Lock this callback right away
self.handler.db.locked.append((obj, callback_name, callback["number"]))
# Open the editor for this callback
self.caller.db._callback = callback
EvEditor(
self.caller,
loadfunc=_ev_load,
savefunc=_ev_save,
quitfunc=_ev_quit,
key="Callback {} of {}".format(callback_name, obj),
persistent=True,
codefunc=_ev_save,
)
def edit_callback(self):
"""Edit a callback."""
obj = self.obj
callback_name = self.callback_name
parameters = self.parameters
callbacks = self.handler.get_callbacks(obj)
types = self.handler.get_events(obj)
# If no callback name is specified, display the list of callbacks
if not callback_name:
self.list_callbacks()
return
# Check that the callback exists
if callback_name not in callbacks:
self.msg("The callback name {} can't be found in {}.".format(callback_name, obj))
return
# If there's only one callback, just edit it
if len(callbacks[callback_name]) == 1:
number = 0
callback = callbacks[callback_name][0]
else:
if not parameters:
self.msg("Which callback do you wish to edit? Specify a number.")
self.list_callbacks()
return
# Check that the parameter points to an existing callback
try:
number = int(parameters) - 1
assert number >= 0
callback = callbacks[callback_name][number]
except (ValueError, AssertionError, IndexError):
self.msg(
"The callback {} {} cannot be found in {}.".format(
callback_name, parameters, obj
)
)
return
# If caller can't edit without validation, forbid editing
# others' works
if not self.autovalid and callback["author"] is not self.caller:
self.msg("You cannot edit this callback created by someone else.")
return
# If the callback is locked (edited by someone else)
if (obj, callback_name, number) in self.handler.db.locked:
self.msg("This callback is locked, you cannot edit it.")
return
self.handler.db.locked.append((obj, callback_name, number))
# Check the definition of the callback
definition = types.get(callback_name, (None, "Chained event."))
description = definition[1]
self.msg(raw(description.strip("\n")))
# Open the editor
callback = dict(callback)
self.caller.db._callback = callback
EvEditor(
self.caller,
loadfunc=_ev_load,
savefunc=_ev_save,
quitfunc=_ev_quit,
key="Callback {} of {}".format(callback_name, obj),
persistent=True,
codefunc=_ev_save,
)
def del_callback(self):
"""Delete a callback."""
obj = self.obj
callback_name = self.callback_name
parameters = self.parameters
callbacks = self.handler.get_callbacks(obj)
types = self.handler.get_events(obj)
# If no callback name is specified, display the list of callbacks
if not callback_name:
self.list_callbacks()
return
# Check that the callback exists
if callback_name not in callbacks:
self.msg("The callback name {} can't be found in {}.".format(callback_name, obj))
return
# If there's only one callback, just delete it
if len(callbacks[callback_name]) == 1:
number = 0
callback = callbacks[callback_name][0]
else:
if not parameters:
self.msg("Which callback do you wish to delete? Specify " "a number.")
self.list_callbacks()
return
# Check that the parameter points to an existing callback
try:
number = int(parameters) - 1
assert number >= 0
callback = callbacks[callback_name][number]
except (ValueError, AssertionError, IndexError):
self.msg(
"The callback {} {} cannot be found in {}.".format(
callback_name, parameters, obj
)
)
return
# If caller can't edit without validation, forbid deleting
# others' works
if not self.autovalid and callback["author"] is not self.caller:
self.msg("You cannot delete this callback created by someone else.")
return
# If the callback is locked (edited by someone else)
if (obj, callback_name, number) in self.handler.db.locked:
self.msg("This callback is locked, you cannot delete it.")
return
# Delete the callback
self.handler.del_callback(obj, callback_name, number)
self.msg("The callback {}[{}] of {} was deleted.".format(callback_name, number + 1, obj))
def accept_callback(self):
"""Accept a callback."""
obj = self.obj
callback_name = self.callback_name
parameters = self.parameters
# If no object, display the list of callbacks to be checked
if obj is None:
table = EvTable("ID", "Type", "Object", "Name", "Updated by", "On", width=78)
table.reformat_column(0, align="r")
now = datetime.now()
for obj, name, number in self.handler.db.to_valid:
callbacks = self.handler.get_callbacks(obj).get(name)
if callbacks is None:
continue
try:
callback = callbacks[number]
except IndexError:
continue
type_name = obj.typeclass_path.split(".")[-1]
by = callback.get("updated_by")
by = by.key if by else "|gUnknown|n"
updated_on = callback.get("updated_on")
if updated_on is None:
updated_on = callback.get("created_on")
if updated_on:
updated_on = "{} ago".format(
time_format((now - updated_on).total_seconds(), 4).capitalize()
)
else:
updated_on = "|gUnknown|n"
table.add_row(obj.id, type_name, obj, name, by, updated_on)
self.msg(str(table))
return
# An object was specified
callbacks = self.handler.get_callbacks(obj)
types = self.handler.get_events(obj)
# If no callback name is specified, display the list of callbacks
if not callback_name:
self.list_callbacks()
return
# Check that the callback exists
if callback_name not in callbacks:
self.msg("The callback name {} can't be found in {}.".format(callback_name, obj))
return
if not parameters:
self.msg("Which callback do you wish to accept? Specify a number.")
self.list_callbacks()
return
# Check that the parameter points to an existing callback
try:
number = int(parameters) - 1
assert number >= 0
callback = callbacks[callback_name][number]
except (ValueError, AssertionError, IndexError):
self.msg(
"The callback {} {} cannot be found in {}.".format(callback_name, parameters, obj)
)
return
# Accept the callback
if callback["valid"]:
self.msg("This callback has already been accepted.")
else:
self.handler.accept_callback(obj, callback_name, number)
self.msg(
"The callback {} {} of {} has been accepted.".format(callback_name, parameters, obj)
)
def list_tasks(self):
"""List the active tasks."""
obj = self.obj
callback_name = self.callback_name
handler = self.handler
tasks = [(k, v[0], v[1], v[2]) for k, v in handler.db.tasks.items()]
if obj:
tasks = [task for task in tasks if task[2] is obj]
if callback_name:
tasks = [task for task in tasks if task[3] == callback_name]
tasks.sort()
table = EvTable("ID", "Object", "Callback", "In", width=78)
table.reformat_column(0, align="r")
now = datetime.now()
for task_id, future, obj, callback_name in tasks:
key = obj.get_display_name(self.caller)
delta = time_format((future - now).total_seconds(), 1)
table.add_row(task_id, key, callback_name, delta)
self.msg(str(table))
# Private functions to handle editing
def _ev_load(caller):
return caller.db._callback and caller.db._callback.get("code", "") or ""
def _ev_save(caller, buf):
"""Save and add the callback."""
lock = "perm({}) or perm(events_without_validation)".format(WITHOUT_VALIDATION)
autovalid = caller.locks.check_lockstring(caller, lock)
callback = caller.db._callback
handler = get_event_handler()
if (
not handler
or not callback
or not all(key in callback for key in ("obj", "name", "number", "valid"))
):
caller.msg("Couldn't save this callback.")
return False
if (callback["obj"], callback["name"], callback["number"]) in handler.db.locked:
handler.db.locked.remove((callback["obj"], callback["name"], callback["number"]))
handler.edit_callback(
callback["obj"], callback["name"], callback["number"], buf, caller, valid=autovalid
)
return True
def _ev_quit(caller):
callback = caller.db._callback
handler = get_event_handler()
if (
not handler
or not callback
or not all(key in callback for key in ("obj", "name", "number", "valid"))
):
caller.msg("Couldn't save this callback.")
return False
if (callback["obj"], callback["name"], callback["number"]) in handler.db.locked:
handler.db.locked.remove((callback["obj"], callback["name"], callback["number"]))
del caller.db._callback
caller.msg("Exited the code editor.")

View file

@ -0,0 +1,91 @@
"""
Module defining basic eventfuncs for the event system.
Eventfuncs are just Python functions that can be used inside of calllbacks.
"""
from evennia import ObjectDB, ScriptDB
from evennia.contrib.ingame_python.utils import InterruptEvent
def deny():
"""
Deny, that is stop, the callback here.
Notes:
This function will raise an exception to terminate the callback
in a controlled way. If you use this function in an event called
prior to a command, the command will be cancelled as well. Good
situations to use the `deny()` function are in events that begins
by `can_`, because they usually can be cancelled as easily as that.
"""
raise InterruptEvent
def get(**kwargs):
"""
Return an object with the given search option or None if None is found.
Keyword Args:
Any searchable data or property (id, db_key, db_location...).
Returns:
The object found that meet these criteria for research, or
None if none is found.
Notes:
This function is very useful to retrieve objects with a specific
ID. You know that room #32 exists, but you don't have it in
the callback variables. Quite simple:
room = get(id=32)
This function doesn't perform a search on objects, but a direct
search in the database. It's recommended to use it for objects
you know exist, using their IDs or other unique attributes.
Looking for objects by key is possible (use `db_key` as an
argument) but remember several objects can share the same key.
"""
try:
object = ObjectDB.objects.get(**kwargs)
except ObjectDB.DoesNotExist:
object = None
return object
def call_event(obj, event_name, seconds=0):
"""
Call the specified event in X seconds.
Args:
obj (Object): the typeclassed object containing the event.
event_name (str): the event name to be called.
seconds (int or float): the number of seconds to wait before calling
the event.
Notes:
This eventfunc can be used to call other events from inside of an
event in a given time. This will create a pause between events. This
will not freeze the game, and you can expect characters to move
around (unless you prevent them from doing so).
Variables that are accessible in your event using 'call()' will be
kept and passed on to the event to call.
Chained callbacks are designed for this very purpose: they
are never called automatically by the game, rather, they need
to be called from inside another event.
"""
script = type(obj.callbacks).script
if script:
# If seconds is 0, call the event immediately
if seconds == 0:
locals = dict(script.ndb.current_locals)
obj.callbacks.call(event_name, locals=locals)
else:
# Schedule the task
script.set_task(seconds, obj, event_name)

View file

@ -0,0 +1,669 @@
"""
Scripts for the in-game Python system.
"""
from datetime import datetime, timedelta
from queue import Queue
import re
import sys
import traceback
from django.conf import settings
from evennia import DefaultObject, DefaultScript, ChannelDB, ScriptDB
from evennia import logger, ObjectDB
from evennia.utils.ansi import raw
from evennia.utils.create import create_channel
from evennia.utils.dbserialize import dbserialize
from evennia.utils.utils import all_from_module, delay, pypath_to_realpath
from evennia.contrib.ingame_python.callbackhandler import CallbackHandler
from evennia.contrib.ingame_python.utils import get_next_wait, EVENTS, InterruptEvent
# Constants
RE_LINE_ERROR = re.compile(r'^ File "\<string\>", line (\d+)')
class EventHandler(DefaultScript):
"""
The event handler that contains all events in a global script.
This script shouldn't be created more than once. It contains
event (in a non-persistent attribute) and callbacks (in a
persistent attribute). The script method would help adding,
editing and deleting these events and callbacks.
"""
def at_script_creation(self):
"""Hook called when the script is created."""
self.key = "event_handler"
self.desc = "Global event handler"
self.persistent = True
# Permanent data to be stored
self.db.callbacks = {}
self.db.to_valid = []
self.db.locked = []
# Tasks
self.db.tasks = {}
self.at_server_start()
def at_server_start(self):
"""Set up the event system when starting.
Note that this hook is called every time the server restarts
(including when it's reloaded). This hook performs the following
tasks:
- Create temporarily stored events.
- Generate locals (individual events' namespace).
- Load eventfuncs, including user-defined ones.
- Re-schedule tasks that aren't set to fire anymore.
- Effectively connect the handler to the main script.
"""
self.ndb.events = {}
for typeclass, name, variables, help_text, custom_call, custom_add in EVENTS:
self.add_event(typeclass, name, variables, help_text, custom_call, custom_add)
# Generate locals
self.ndb.current_locals = {}
self.ndb.fresh_locals = {}
addresses = ["evennia.contrib.ingame_python.eventfuncs"]
addresses.extend(getattr(settings, "EVENTFUNCS_LOCATIONS", ["world.eventfuncs"]))
for address in addresses:
if pypath_to_realpath(address):
self.ndb.fresh_locals.update(all_from_module(address))
# Restart the delayed tasks
now = datetime.now()
for task_id, definition in tuple(self.db.tasks.items()):
future, obj, event_name, locals = definition
seconds = (future - now).total_seconds()
if seconds < 0:
seconds = 0
delay(seconds, complete_task, task_id)
# Place the script in the CallbackHandler
from evennia.contrib.ingame_python import typeclasses
CallbackHandler.script = self
DefaultObject.callbacks = typeclasses.EventObject.callbacks
# Create the channel if non-existent
try:
self.ndb.channel = ChannelDB.objects.get(db_key="everror")
except ChannelDB.DoesNotExist:
self.ndb.channel = create_channel(
"everror",
desc="Event errors",
locks="control:false();listen:perm(Builders);send:false()",
)
def get_events(self, obj):
"""
Return a dictionary of events on this object.
Args:
obj (Object or typeclass): the connected object or a general typeclass.
Returns:
A dictionary of the object's events.
Notes:
Events would define what the object can have as
callbacks. Note, however, that chained callbacks will not
appear in events and are handled separately.
You can also request the events of a typeclass, not a
connected object. This is useful to get the global list
of events for a typeclass that has no object yet.
"""
events = {}
all_events = self.ndb.events
classes = Queue()
if isinstance(obj, type):
classes.put(obj)
else:
classes.put(type(obj))
invalid = []
while not classes.empty():
typeclass = classes.get()
typeclass_name = typeclass.__module__ + "." + typeclass.__name__
for key, etype in all_events.get(typeclass_name, {}).items():
if key in invalid:
continue
if etype[0] is None: # Invalidate
invalid.append(key)
continue
if key not in events:
events[key] = etype
# Look for the parent classes
for parent in typeclass.__bases__:
classes.put(parent)
return events
def get_variable(self, variable_name):
"""
Return the variable defined in the locals.
This can be very useful to check the value of a variable that can be modified in an event, and whose value will be used in code. This system allows additional customization.
Args:
variable_name (str): the name of the variable to return.
Returns:
The variable if found in the locals.
None if not found in the locals.
Note:
This will return the variable from the current locals.
Keep in mind that locals are shared between events. As
every event is called one by one, this doesn't pose
additional problems if you get the variable right after
an event has been executed. If, however, you differ,
there's no guarantee the variable will be here or will
mean the same thing.
"""
return self.ndb.current_locals.get(variable_name)
def get_callbacks(self, obj):
"""
Return a dictionary of the object's callbacks.
Args:
obj (Object): the connected objects.
Returns:
A dictionary of the object's callbacks.
Note:
This method can be useful to override in some contexts,
when several objects would share callbacks.
"""
obj_callbacks = self.db.callbacks.get(obj, {})
callbacks = {}
for callback_name, callback_list in obj_callbacks.items():
new_list = []
for i, callback in enumerate(callback_list):
callback = dict(callback)
callback["obj"] = obj
callback["name"] = callback_name
callback["number"] = i
new_list.append(callback)
if new_list:
callbacks[callback_name] = new_list
return callbacks
def add_callback(self, obj, callback_name, code, author=None, valid=False, parameters=""):
"""
Add the specified callback.
Args:
obj (Object): the Evennia typeclassed object to be extended.
callback_name (str): the name of the callback to add.
code (str): the Python code associated with this callback.
author (Character or Account, optional): the author of the callback.
valid (bool, optional): should the callback be connected?
parameters (str, optional): optional parameters.
Note:
This method doesn't check that the callback type exists.
"""
obj_callbacks = self.db.callbacks.get(obj, {})
if not obj_callbacks:
self.db.callbacks[obj] = {}
obj_callbacks = self.db.callbacks[obj]
callbacks = obj_callbacks.get(callback_name, [])
if not callbacks:
obj_callbacks[callback_name] = []
callbacks = obj_callbacks[callback_name]
# Add the callback in the list
callbacks.append(
{
"created_on": datetime.now(),
"author": author,
"valid": valid,
"code": code,
"parameters": parameters,
}
)
# If not valid, set it in 'to_valid'
if not valid:
self.db.to_valid.append((obj, callback_name, len(callbacks) - 1))
# Call the custom_add if needed
custom_add = self.get_events(obj).get(callback_name, [None, None, None, None])[3]
if custom_add:
custom_add(obj, callback_name, len(callbacks) - 1, parameters)
# Build the definition to return (a dictionary)
definition = dict(callbacks[-1])
definition["obj"] = obj
definition["name"] = callback_name
definition["number"] = len(callbacks) - 1
return definition
def edit_callback(self, obj, callback_name, number, code, author=None, valid=False):
"""
Edit the specified callback.
Args:
obj (Object): the Evennia typeclassed object to be edited.
callback_name (str): the name of the callback to edit.
number (int): the callback number to be changed.
code (str): the Python code associated with this callback.
author (Character or Account, optional): the author of the callback.
valid (bool, optional): should the callback be connected?
Raises:
RuntimeError if the callback is locked.
Note:
This method doesn't check that the callback type exists.
"""
obj_callbacks = self.db.callbacks.get(obj, {})
if not obj_callbacks:
self.db.callbacks[obj] = {}
obj_callbacks = self.db.callbacks[obj]
callbacks = obj_callbacks.get(callback_name, [])
if not callbacks:
obj_callbacks[callback_name] = []
callbacks = obj_callbacks[callback_name]
# If locked, don't edit it
if (obj, callback_name, number) in self.db.locked:
raise RuntimeError("this callback is locked.")
# Edit the callback
callbacks[number].update(
{"updated_on": datetime.now(), "updated_by": author, "valid": valid, "code": code}
)
# If not valid, set it in 'to_valid'
if not valid and (obj, callback_name, number) not in self.db.to_valid:
self.db.to_valid.append((obj, callback_name, number))
elif valid and (obj, callback_name, number) in self.db.to_valid:
self.db.to_valid.remove((obj, callback_name, number))
# Build the definition to return (a dictionary)
definition = dict(callbacks[number])
definition["obj"] = obj
definition["name"] = callback_name
definition["number"] = number
return definition
def del_callback(self, obj, callback_name, number):
"""
Delete the specified callback.
Args:
obj (Object): the typeclassed object containing the callback.
callback_name (str): the name of the callback to delete.
number (int): the number of the callback to delete.
Raises:
RuntimeError if the callback is locked.
"""
obj_callbacks = self.db.callbacks.get(obj, {})
callbacks = obj_callbacks.get(callback_name, [])
# If locked, don't edit it
if (obj, callback_name, number) in self.db.locked:
raise RuntimeError("this callback is locked.")
# Delete the callback itself
try:
code = callbacks[number]["code"]
except IndexError:
return
else:
logger.log_info(
"Deleting callback {} {} of {}:\n{}".format(callback_name, number, obj, code)
)
del callbacks[number]
# Change IDs of callbacks to be validated
i = 0
while i < len(self.db.to_valid):
t_obj, t_callback_name, t_number = self.db.to_valid[i]
if obj is t_obj and callback_name == t_callback_name:
if t_number == number:
# Strictly equal, delete the callback
del self.db.to_valid[i]
i -= 1
elif t_number > number:
# Change the ID for this callback
self.db.to_valid.insert(i, (t_obj, t_callback_name, t_number - 1))
del self.db.to_valid[i + 1]
i += 1
# Update locked callback
for i, line in enumerate(self.db.locked):
t_obj, t_callback_name, t_number = line
if obj is t_obj and callback_name == t_callback_name:
if number < t_number:
self.db.locked[i] = (t_obj, t_callback_name, t_number - 1)
# Delete time-related callbacks associated with this object
for script in obj.scripts.all():
if isinstance(script, TimecallbackScript):
if script.obj is obj and script.db.callback_name == callback_name:
if script.db.number == number:
script.stop()
elif script.db.number > number:
script.db.number -= 1
def accept_callback(self, obj, callback_name, number):
"""
Valid a callback.
Args:
obj (Object): the object containing the callback.
callback_name (str): the name of the callback.
number (int): the number of the callback.
"""
obj_callbacks = self.db.callbacks.get(obj, {})
callbacks = obj_callbacks.get(callback_name, [])
# Accept and connect the callback
callbacks[number].update({"valid": True})
if (obj, callback_name, number) in self.db.to_valid:
self.db.to_valid.remove((obj, callback_name, number))
def call(self, obj, callback_name, *args, **kwargs):
"""
Call the connected callbacks.
Args:
obj (Object): the Evennia typeclassed object.
callback_name (str): the callback name to call.
*args: additional variables for this callback.
Keyword Args:
number (int, optional): call just a specific callback.
parameters (str, optional): call a callback with parameters.
locals (dict, optional): a locals replacement.
Returns:
True to report the callback was called without interruption,
False otherwise.
"""
# First, look for the callback type corresponding to this name
number = kwargs.get("number")
parameters = kwargs.get("parameters")
locals = kwargs.get("locals")
# Errors should not pass silently
allowed = ("number", "parameters", "locals")
if any(k for k in kwargs if k not in allowed):
raise TypeError(
"Unknown keyword arguments were specified " "to call callbacks: {}".format(kwargs)
)
event = self.get_events(obj).get(callback_name)
if locals is None and not event:
logger.log_err(
"The callback {} for the object {} (typeclass "
"{}) can't be found".format(callback_name, obj, type(obj))
)
return False
# Prepare the locals if necessary
if locals is None:
locals = self.ndb.fresh_locals.copy()
for i, variable in enumerate(event[0]):
try:
locals[variable] = args[i]
except IndexError:
logger.log_trace(
"callback {} of {} ({}): need variable "
"{} in position {}".format(callback_name, obj, type(obj), variable, i)
)
return False
else:
locals = {key: value for key, value in locals.items()}
callbacks = self.get_callbacks(obj).get(callback_name, [])
if event:
custom_call = event[2]
if custom_call:
callbacks = custom_call(callbacks, parameters)
# Now execute all the valid callbacks linked at this address
self.ndb.current_locals = locals
for i, callback in enumerate(callbacks):
if not callback["valid"]:
continue
if number is not None and callback["number"] != number:
continue
try:
exec(callback["code"], locals, locals)
except InterruptEvent:
return False
except Exception:
etype, evalue, tb = sys.exc_info()
trace = traceback.format_exception(etype, evalue, tb)
self.handle_error(callback, trace)
return True
def handle_error(self, callback, trace):
"""
Handle an error in a callback.
Args:
callback (dict): the callback representation.
trace (list): the traceback containing the exception.
Notes:
This method can be useful to override to change the default
handling of errors. By default, the error message is sent to
the character who last updated the callback, if connected.
If not, display to the everror channel.
"""
callback_name = callback["name"]
number = callback["number"]
obj = callback["obj"]
oid = obj.id
logger.log_err(
"An error occurred during the callback {} of "
"{} (#{}), number {}\n{}".format(callback_name, obj, oid, number + 1, "\n".join(trace))
)
# Create the error message
line = "|runknown|n"
lineno = "|runknown|n"
for error in trace:
if error.startswith(' File "<string>", line '):
res = RE_LINE_ERROR.search(error)
if res:
lineno = int(res.group(1))
# Try to extract the line
try:
line = raw(callback["code"].splitlines()[lineno - 1])
except IndexError:
continue
else:
break
exc = raw(trace[-1].strip("\n").splitlines()[-1])
err_msg = "Error in {} of {} (#{})[{}], line {}:" " {}\n{}".format(
callback_name, obj, oid, number + 1, lineno, line, exc
)
# Inform the last updater if connected
updater = callback.get("updated_by")
if updater is None:
updater = callback["created_by"]
if updater and updater.sessions.all():
updater.msg(err_msg)
else:
err_msg = "Error in {} of {} (#{})[{}], line {}:" " {}\n {}".format(
callback_name, obj, oid, number + 1, lineno, line, exc
)
self.ndb.channel.msg(err_msg)
def add_event(self, typeclass, name, variables, help_text, custom_call, custom_add):
"""
Add a new event for a defined typeclass.
Args:
typeclass (str): the path leading to the typeclass.
name (str): the name of the event to add.
variables (list of str): list of variable names for this event.
help_text (str): the long help text of the event.
custom_call (callable or None): the function to be called
when the event fires.
custom_add (callable or None): the function to be called when
a callback is added.
"""
if typeclass not in self.ndb.events:
self.ndb.events[typeclass] = {}
events = self.ndb.events[typeclass]
if name not in events:
events[name] = (variables, help_text, custom_call, custom_add)
def set_task(self, seconds, obj, callback_name):
"""
Set and schedule a task to run.
Args:
seconds (int, float): the delay in seconds from now.
obj (Object): the typecalssed object connected to the event.
callback_name (str): the callback's name.
Notes:
This method allows to schedule a "persistent" task.
'utils.delay' is called, but a copy of the task is kept in
the event handler, and when the script restarts (after reload),
the differed delay is called again.
The dictionary of locals is frozen and will be available
again when the task runs. This feature, however, is limited
by the database: all data cannot be saved. Lambda functions,
class methods, objects inside an instance and so on will
not be kept in the locals dictionary.
"""
now = datetime.now()
delta = timedelta(seconds=seconds)
# Choose a free task_id
used_ids = list(self.db.tasks.keys())
task_id = 1
while task_id in used_ids:
task_id += 1
# Collect and freeze current locals
locals = {}
for key, value in self.ndb.current_locals.items():
try:
dbserialize(value)
except TypeError:
continue
else:
locals[key] = value
self.db.tasks[task_id] = (now + delta, obj, callback_name, locals)
delay(seconds, complete_task, task_id)
# Script to call time-related events
class TimeEventScript(DefaultScript):
"""Gametime-sensitive script."""
def at_script_creation(self):
"""The script is created."""
self.start_delay = True
self.persistent = True
# Script attributes
self.db.time_format = None
self.db.event_name = "time"
self.db.number = None
def at_repeat(self):
"""
Call the event and reset interval.
It is necessary to restart the script to reset its interval
only twice after a reload. When the script has undergone
down time, there's usually a slight shift in game time. Once
the script restarts once, it will set the average time it
needs for all its future intervals and should not need to be
restarted. In short, a script that is created shouldn't need
to restart more than once, and a script that is reloaded should
restart only twice.
"""
if self.db.time_format:
# If the 'usual' time is set, use it
seconds = self.ndb.usual
if seconds is None:
seconds, usual, details = get_next_wait(self.db.time_format)
self.ndb.usual = usual
if self.interval != seconds:
self.restart(interval=seconds)
if self.db.event_name and self.db.number is not None:
obj = self.obj
if not obj.callbacks:
return
event_name = self.db.event_name
number = self.db.number
obj.callbacks.call(event_name, obj, number=number)
# Functions to manipulate tasks
def complete_task(task_id):
"""
Mark the task in the event handler as complete.
Args:
task_id (int): the task ID.
Note:
This function should be called automatically for individual tasks.
"""
try:
script = ScriptDB.objects.get(db_key="event_handler")
except ScriptDB.DoesNotExist:
logger.log_trace("Can't get the event handler.")
return
if task_id not in script.db.tasks:
logger.log_err("The task #{} was scheduled, but it cannot be " "found".format(task_id))
return
delta, obj, callback_name, locals = script.db.tasks.pop(task_id)
script.call(obj, callback_name, locals=locals)

View file

@ -0,0 +1,543 @@
"""
Module containing the test cases for the in-game Python system.
"""
from mock import Mock
from textwrap import dedent
from django.conf import settings
from evennia import ScriptDB
from evennia.commands.default.tests import CommandTest
from evennia.objects.objects import ExitCommand
from evennia.utils import ansi, utils
from evennia.utils.create import create_object, create_script
from evennia.utils.test_resources import EvenniaTest
from evennia.contrib.ingame_python.commands import CmdCallback
from evennia.contrib.ingame_python.callbackhandler import CallbackHandler
# Force settings
settings.EVENTS_CALENDAR = "standard"
# Constants
OLD_EVENTS = {}
class TestEventHandler(EvenniaTest):
"""
Test cases of the event handler to add, edit or delete events.
"""
def setUp(self):
"""Create the event handler."""
super().setUp()
self.handler = create_script("evennia.contrib.ingame_python.scripts.EventHandler")
# Copy old events if necessary
if OLD_EVENTS:
self.handler.ndb.events = dict(OLD_EVENTS)
# Alter typeclasses
self.char1.swap_typeclass("evennia.contrib.ingame_python.typeclasses.EventCharacter")
self.char2.swap_typeclass("evennia.contrib.ingame_python.typeclasses.EventCharacter")
self.room1.swap_typeclass("evennia.contrib.ingame_python.typeclasses.EventRoom")
self.room2.swap_typeclass("evennia.contrib.ingame_python.typeclasses.EventRoom")
self.exit.swap_typeclass("evennia.contrib.ingame_python.typeclasses.EventExit")
def tearDown(self):
"""Stop the event handler."""
OLD_EVENTS.clear()
OLD_EVENTS.update(self.handler.ndb.events)
self.handler.delete()
CallbackHandler.script = None
super().tearDown()
def test_start(self):
"""Simply make sure the handler runs with proper initial values."""
self.assertEqual(self.handler.db.callbacks, {})
self.assertEqual(self.handler.db.to_valid, [])
self.assertEqual(self.handler.db.locked, [])
self.assertEqual(self.handler.db.tasks, {})
self.assertIsNotNone(self.handler.ndb.events)
def test_add_validation(self):
"""Add a callback while needing validation."""
author = self.char1
self.handler.add_callback(
self.room1, "dummy", "character.db.strength = 40", author=author, valid=False
)
callback = self.handler.get_callbacks(self.room1).get("dummy")
callback = callback[0]
self.assertIsNotNone(callback)
self.assertEqual(callback["author"], author)
self.assertEqual(callback["valid"], False)
# Since this callback is not valid, it should appear in 'to_valid'
self.assertIn((self.room1, "dummy", 0), self.handler.db.to_valid)
# Run this dummy callback (shouldn't do anything)
self.char1.db.strength = 10
locals = {"character": self.char1}
self.assertTrue(self.handler.call(self.room1, "dummy", locals=locals))
self.assertEqual(self.char1.db.strength, 10)
def test_edit(self):
"""Test editing a callback."""
author = self.char1
self.handler.add_callback(
self.room1, "dummy", "character.db.strength = 60", author=author, valid=True
)
# Edit it right away
self.handler.edit_callback(
self.room1, "dummy", 0, "character.db.strength = 65", author=self.char2, valid=True
)
# Check that the callback was written
callback = self.handler.get_callbacks(self.room1).get("dummy")
callback = callback[0]
self.assertIsNotNone(callback)
self.assertEqual(callback["author"], author)
self.assertEqual(callback["valid"], True)
self.assertEqual(callback["updated_by"], self.char2)
# Run this dummy callback
self.char1.db.strength = 10
locals = {"character": self.char1}
self.assertTrue(self.handler.call(self.room1, "dummy", locals=locals))
self.assertEqual(self.char1.db.strength, 65)
def test_edit_validation(self):
"""Edit a callback when validation isn't automatic."""
author = self.char1
self.handler.add_callback(
self.room1, "dummy", "character.db.strength = 70", author=author, valid=True
)
# Edit it right away
self.handler.edit_callback(
self.room1, "dummy", 0, "character.db.strength = 80", author=self.char2, valid=False
)
# Run this dummy callback (shouldn't do anything)
self.char1.db.strength = 10
locals = {"character": self.char1}
self.assertTrue(self.handler.call(self.room1, "dummy", locals=locals))
self.assertEqual(self.char1.db.strength, 10)
def test_del(self):
"""Try to delete a callback."""
# Add 3 callbacks
self.handler.add_callback(
self.room1, "dummy", "character.db.strength = 5", author=self.char1, valid=True
)
self.handler.add_callback(
self.room1, "dummy", "character.db.strength = 8", author=self.char2, valid=False
)
self.handler.add_callback(
self.room1, "dummy", "character.db.strength = 9", author=self.char1, valid=True
)
# Note that the second callback isn't valid
self.assertIn((self.room1, "dummy", 1), self.handler.db.to_valid)
# Lock the third callback
self.handler.db.locked.append((self.room1, "dummy", 2))
# Delete the first callback
self.handler.del_callback(self.room1, "dummy", 0)
# The callback #1 that was to valid should be #0 now
self.assertIn((self.room1, "dummy", 0), self.handler.db.to_valid)
self.assertNotIn((self.room1, "dummy", 1), self.handler.db.to_valid)
# The lock has been updated too
self.assertIn((self.room1, "dummy", 1), self.handler.db.locked)
self.assertNotIn((self.room1, "dummy", 2), self.handler.db.locked)
# Now delete the first (not valid) callback
self.handler.del_callback(self.room1, "dummy", 0)
self.assertEqual(self.handler.db.to_valid, [])
self.assertIn((self.room1, "dummy", 0), self.handler.db.locked)
self.assertNotIn((self.room1, "dummy", 1), self.handler.db.locked)
# Call the remaining callback
self.char1.db.strength = 10
locals = {"character": self.char1}
self.assertTrue(self.handler.call(self.room1, "dummy", locals=locals))
self.assertEqual(self.char1.db.strength, 9)
def test_accept(self):
"""Accept an callback."""
# Add 2 callbacks
self.handler.add_callback(
self.room1, "dummy", "character.db.strength = 5", author=self.char1, valid=True
)
self.handler.add_callback(
self.room1, "dummy", "character.db.strength = 8", author=self.char2, valid=False
)
# Note that the second callback isn't valid
self.assertIn((self.room1, "dummy", 1), self.handler.db.to_valid)
# Accept the second callback
self.handler.accept_callback(self.room1, "dummy", 1)
callback = self.handler.get_callbacks(self.room1).get("dummy")
callback = callback[1]
self.assertIsNotNone(callback)
self.assertEqual(callback["valid"], True)
# Call the dummy callback
self.char1.db.strength = 10
locals = {"character": self.char1}
self.assertTrue(self.handler.call(self.room1, "dummy", locals=locals))
self.assertEqual(self.char1.db.strength, 8)
def test_call(self):
"""Test to call amore complex callback."""
self.char1.key = "one"
self.char2.key = "two"
# Add an callback
code = dedent(
"""
if character.key == "one":
character.db.health = 50
else:
character.db.health = 0
""".strip(
"\n"
)
)
self.handler.add_callback(self.room1, "dummy", code, author=self.char1, valid=True)
# Call the dummy callback
self.assertTrue(self.handler.call(self.room1, "dummy", locals={"character": self.char1}))
self.assertEqual(self.char1.db.health, 50)
self.assertTrue(self.handler.call(self.room1, "dummy", locals={"character": self.char2}))
self.assertEqual(self.char2.db.health, 0)
def test_handler(self):
"""Test the object handler."""
self.assertIsNotNone(self.char1.callbacks)
# Add an callback
callback = self.room1.callbacks.add("dummy", "pass", author=self.char1, valid=True)
self.assertEqual(callback.obj, self.room1)
self.assertEqual(callback.name, "dummy")
self.assertEqual(callback.code, "pass")
self.assertEqual(callback.author, self.char1)
self.assertEqual(callback.valid, True)
self.assertIn([callback], list(self.room1.callbacks.all().values()))
# Edit this very callback
new = self.room1.callbacks.edit(
"dummy", 0, "character.db.say = True", author=self.char1, valid=True
)
self.assertIn([new], list(self.room1.callbacks.all().values()))
self.assertNotIn([callback], list(self.room1.callbacks.all().values()))
# Try to call this callback
self.assertTrue(self.room1.callbacks.call("dummy", locals={"character": self.char2}))
self.assertTrue(self.char2.db.say)
# Delete the callback
self.room1.callbacks.remove("dummy", 0)
self.assertEqual(self.room1.callbacks.all(), {})
class TestCmdCallback(CommandTest):
"""Test the @callback command."""
def setUp(self):
"""Create the callback handler."""
super().setUp()
self.handler = create_script("evennia.contrib.ingame_python.scripts.EventHandler")
# Copy old events if necessary
if OLD_EVENTS:
self.handler.ndb.events = dict(OLD_EVENTS)
# Alter typeclasses
self.char1.swap_typeclass("evennia.contrib.ingame_python.typeclasses.EventCharacter")
self.char2.swap_typeclass("evennia.contrib.ingame_python.typeclasses.EventCharacter")
self.room1.swap_typeclass("evennia.contrib.ingame_python.typeclasses.EventRoom")
self.room2.swap_typeclass("evennia.contrib.ingame_python.typeclasses.EventRoom")
self.exit.swap_typeclass("evennia.contrib.ingame_python.typeclasses.EventExit")
def tearDown(self):
"""Stop the callback handler."""
OLD_EVENTS.clear()
OLD_EVENTS.update(self.handler.ndb.events)
self.handler.delete()
for script in ScriptDB.objects.filter(
db_typeclass_path="evennia.contrib.ingame_python.scripts.TimeEventScript"
):
script.delete()
CallbackHandler.script = None
super().tearDown()
def test_list(self):
"""Test listing callbacks with different rights."""
table = self.call(CmdCallback(), "out")
lines = table.splitlines()[3:-1]
self.assertNotEqual(lines, [])
# Check that the second column only contains 0 (0) (no callback yet)
for line in lines:
cols = line.split("|")
self.assertIn(cols[2].strip(), ("0 (0)", ""))
# Add some callback
self.handler.add_callback(self.exit, "traverse", "pass", author=self.char1, valid=True)
# Try to obtain more details on a specific callback on exit
table = self.call(CmdCallback(), "out = traverse")
lines = table.splitlines()[3:-1]
self.assertEqual(len(lines), 1)
line = lines[0]
cols = line.split("|")
self.assertIn(cols[1].strip(), ("1", ""))
self.assertIn(cols[2].strip(), (str(self.char1), ""))
self.assertIn(cols[-1].strip(), ("Yes", "No", ""))
# Run the same command with char2
# char2 shouldn't see the last column (Valid)
table = self.call(CmdCallback(), "out = traverse", caller=self.char2)
lines = table.splitlines()[3:-1]
self.assertEqual(len(lines), 1)
line = lines[0]
cols = line.split("|")
self.assertEqual(cols[1].strip(), "1")
self.assertNotIn(cols[-1].strip(), ("Yes", "No"))
# In any case, display the callback
# The last line should be "pass" (the callback code)
details = self.call(CmdCallback(), "out = traverse 1")
self.assertEqual(details.splitlines()[-1], "pass")
def test_add(self):
"""Test to add an callback."""
self.call(CmdCallback(), "/add out = traverse")
editor = self.char1.ndb._eveditor
self.assertIsNotNone(editor)
# Edit the callback
editor.update_buffer(
dedent(
"""
if character.key == "one":
character.msg("You can pass.")
else:
character.msg("You can't pass.")
deny()
""".strip(
"\n"
)
)
)
editor.save_buffer()
editor.quit()
callback = self.exit.callbacks.get("traverse")[0]
self.assertEqual(callback.author, self.char1)
self.assertEqual(callback.valid, True)
self.assertTrue(len(callback.code) > 0)
# We're going to try the same thing but with char2
# char2 being a player for our test, the callback won't be validated.
self.call(CmdCallback(), "/add out = traverse", caller=self.char2)
editor = self.char2.ndb._eveditor
self.assertIsNotNone(editor)
# Edit the callback
editor.update_buffer(
dedent(
"""
character.msg("No way.")
""".strip(
"\n"
)
)
)
editor.save_buffer()
editor.quit()
callback = self.exit.callbacks.get("traverse")[1]
self.assertEqual(callback.author, self.char2)
self.assertEqual(callback.valid, False)
self.assertTrue(len(callback.code) > 0)
def test_del(self):
"""Add and remove an callback."""
self.handler.add_callback(self.exit, "traverse", "pass", author=self.char1, valid=True)
# Try to delete the callback
# char2 shouldn't be allowed to do so (that's not HIS callback)
self.call(CmdCallback(), "/del out = traverse 1", caller=self.char2)
self.assertTrue(len(self.handler.get_callbacks(self.exit).get("traverse", [])) == 1)
# Now, char1 should be allowed to delete it
self.call(CmdCallback(), "/del out = traverse 1")
self.assertTrue(len(self.handler.get_callbacks(self.exit).get("traverse", [])) == 0)
def test_lock(self):
"""Test the lock of multiple editing."""
self.call(CmdCallback(), "/add here = time 8:00", caller=self.char2)
self.assertIsNotNone(self.char2.ndb._eveditor)
# Now ask char1 to edit
line = self.call(CmdCallback(), "/edit here = time 1")
self.assertIsNone(self.char1.ndb._eveditor)
# Try to delete this callback while char2 is editing it
line = self.call(CmdCallback(), "/del here = time 1")
def test_accept(self):
"""Accept an callback."""
self.call(CmdCallback(), "/add here = time 8:00", caller=self.char2)
editor = self.char2.ndb._eveditor
self.assertIsNotNone(editor)
# Edit the callback
editor.update_buffer(
dedent(
"""
room.msg_contents("It's 8 PM, everybody up!")
""".strip(
"\n"
)
)
)
editor.save_buffer()
editor.quit()
callback = self.room1.callbacks.get("time")[0]
self.assertEqual(callback.valid, False)
# chars shouldn't be allowed to the callback
self.call(CmdCallback(), "/accept here = time 1", caller=self.char2)
callback = self.room1.callbacks.get("time")[0]
self.assertEqual(callback.valid, False)
# char1 will accept the callback
self.call(CmdCallback(), "/accept here = time 1")
callback = self.room1.callbacks.get("time")[0]
self.assertEqual(callback.valid, True)
class TestDefaultCallbacks(CommandTest):
"""Test the default callbacks."""
def setUp(self):
"""Create the callback handler."""
super().setUp()
self.handler = create_script("evennia.contrib.ingame_python.scripts.EventHandler")
# Copy old events if necessary
if OLD_EVENTS:
self.handler.ndb.events = dict(OLD_EVENTS)
# Alter typeclasses
self.char1.swap_typeclass("evennia.contrib.ingame_python.typeclasses.EventCharacter")
self.char2.swap_typeclass("evennia.contrib.ingame_python.typeclasses.EventCharacter")
self.room1.swap_typeclass("evennia.contrib.ingame_python.typeclasses.EventRoom")
self.room2.swap_typeclass("evennia.contrib.ingame_python.typeclasses.EventRoom")
self.exit.swap_typeclass("evennia.contrib.ingame_python.typeclasses.EventExit")
def tearDown(self):
"""Stop the callback handler."""
OLD_EVENTS.clear()
OLD_EVENTS.update(self.handler.ndb.events)
self.handler.delete()
CallbackHandler.script = None
super().tearDown()
def test_exit(self):
"""Test the callbacks of an exit."""
self.char1.key = "char1"
code = dedent(
"""
if character.key == "char1":
character.msg("You can leave.")
else:
character.msg("You cannot leave.")
deny()
""".strip(
"\n"
)
)
# Enforce self.exit.destination since swapping typeclass lose it
self.exit.destination = self.room2
# Try the can_traverse callback
self.handler.add_callback(self.exit, "can_traverse", code, author=self.char1, valid=True)
# Have char1 move through the exit
self.call(ExitCommand(), "", "You can leave.", obj=self.exit)
self.assertIs(self.char1.location, self.room2)
# Have char2 move through this exit
self.call(ExitCommand(), "", "You cannot leave.", obj=self.exit, caller=self.char2)
self.assertIs(self.char2.location, self.room1)
# Try the traverse callback
self.handler.del_callback(self.exit, "can_traverse", 0)
self.handler.add_callback(
self.exit, "traverse", "character.msg('Fine!')", author=self.char1, valid=True
)
# Have char2 move through the exit
self.call(ExitCommand(), "", obj=self.exit, caller=self.char2)
self.assertIs(self.char2.location, self.room2)
self.handler.del_callback(self.exit, "traverse", 0)
# Move char1 and char2 back
self.char1.location = self.room1
self.char2.location = self.room1
# Test msg_arrive and msg_leave
code = 'message = "{character} goes out."'
self.handler.add_callback(self.exit, "msg_leave", code, author=self.char1, valid=True)
# Have char1 move through the exit
old_msg = self.char2.msg
try:
self.char2.msg = Mock()
self.call(ExitCommand(), "", obj=self.exit)
stored_msg = [
args[0] if args and args[0] else kwargs.get("text", utils.to_str(kwargs))
for name, args, kwargs in self.char2.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]
returned_msg = ansi.parse_ansi("\n".join(stored_msg), strip_ansi=True)
self.assertEqual(returned_msg, "char1 goes out.")
finally:
self.char2.msg = old_msg
# Create a return exit
back = create_object(
"evennia.objects.objects.DefaultExit",
key="in",
location=self.room2,
destination=self.room1,
)
code = 'message = "{character} goes in."'
self.handler.add_callback(self.exit, "msg_arrive", code, author=self.char1, valid=True)
# Have char1 move through the exit
old_msg = self.char2.msg
try:
self.char2.msg = Mock()
self.call(ExitCommand(), "", obj=back)
stored_msg = [
args[0] if args and args[0] else kwargs.get("text", utils.to_str(kwargs))
for name, args, kwargs in self.char2.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]
returned_msg = ansi.parse_ansi("\n".join(stored_msg), strip_ansi=True)
self.assertEqual(returned_msg, "char1 goes in.")
finally:
self.char2.msg = old_msg

View file

@ -0,0 +1,919 @@
"""
Typeclasses for the in-game Python system.
To use them, change your base typeclasses to inherit from the classes in this
module (EventObject, EventRoom, EventCharacter and EventExit) instead of the
default ones in evennia core.
"""
from evennia import DefaultCharacter, DefaultExit, DefaultObject, DefaultRoom
from evennia import ScriptDB
from evennia.utils.utils import delay, inherits_from, lazy_property
from evennia.contrib.ingame_python.callbackhandler import CallbackHandler
from evennia.contrib.ingame_python.utils import register_events, time_event, phrase_event
# Character help
CHARACTER_CAN_DELETE = """
Can the character be deleted?
This event is called before the character is deleted. You can use
'deny()' in this event to prevent this character from being deleted.
If this event doesn't prevent the character from being deleted, its
'delete' event is called right away.
Variables you can use in this event:
character: the character connected to this event.
"""
CHARACTER_CAN_MOVE = """
Can the character move?
This event is called before the character moves into another
location. You can prevent the character from moving
using the 'deny()' eventfunc.
Variables you can use in this event:
character: the character connected to this event.
origin: the current location of the character.
destination: the future location of the character.
"""
CHARACTER_CAN_PART = """
Can the departing charaacter leave this room?
This event is called before another character can move from the
location where the current character also is. This event can be
used to prevent someone to leave this room if, for instance, he/she
hasn't paid, or he/she is going to a protected area, past a guard,
and so on. Use 'deny()' to prevent the departing character from
moving.
Variables you can use in this event:
departing: the character who wants to leave this room.
character: the character connected to this event.
"""
CHARACTER_CAN_SAY = """
Before another character can say something in the same location.
This event is called before another character says something in the
character's location. The "something" in question can be modified,
or the action can be prevented by using 'deny()'. To change the
content of what the character says, simply change the variable
'message' to another string of characters.
Variables you can use in this event:
speaker: the character who is using the say command.
character: the character connected to this event.
message: the text spoken by the character.
"""
CHARACTER_DELETE = """
Before deleting the character.
This event is called just before deleting this character. It shouldn't
be prevented (using the `deny()` function at this stage doesn't
have any effect). If you want to prevent deletion of this character,
use the event `can_delete` instead.
Variables you can use in this event:
character: the character connected to this event.
"""
CHARACTER_GREET = """
A new character arrives in the location of this character.
This event is called when another character arrives in the location
where the current character is. For instance, a puppeted character
arrives in the shop of a shopkeeper (assuming the shopkeeper is
a character). As its name suggests, this event can be very useful
to have NPC greeting one another, or accounts, who come to visit.
Variables you can use in this event:
character: the character connected to this event.
newcomer: the character arriving in the same location.
"""
CHARACTER_MOVE = """
After the character has moved into its new room.
This event is called when the character has moved into a new
room. It is too late to prevent the move at this point.
Variables you can use in this event:
character: the character connected to this event.
origin: the old location of the character.
destination: the new location of the character.
"""
CHARACTER_PUPPETED = """
When the character has been puppeted by an account.
This event is called when an account has just puppeted this character.
This can commonly happen when an account connects onto this character,
or when puppeting to a NPC or free character.
Variables you can use in this event:
character: the character connected to this event.
"""
CHARACTER_SAY = """
After another character has said something in the character's room.
This event is called right after another character has said
something in the same location.. The action cannot be prevented
at this moment. Instead, this event is ideal to create keywords
that would trigger a character (like a NPC) in doing something
if a specific phrase is spoken in the same location.
To use this event, you have to specify a list of keywords as
parameters that should be present, as separate words, in the
spoken phrase. For instance, you can set an event tthat would
fire if the phrase spoken by the character contains "menu" or
"dinner" or "lunch":
@call/add ... = say menu, dinner, lunch
Then if one of the words is present in what the character says,
this event will fire.
Variables you can use in this event:
speaker: the character speaking in this room.
character: the character connected to this event.
message: the text having been spoken by the character.
"""
CHARACTER_TIME = """
A repeated event to be called regularly.
This event is scheduled to repeat at different times, specified
as parameters. You can set it to run every day at 8:00 AM (game
time). You have to specify the time as an argument to @call/add, like:
@call/add here = time 8:00
The parameter (8:00 here) must be a suite of digits separated by
spaces, colons or dashes. Keep it as close from a recognizable
date format, like this:
@call/add here = time 06-15 12:20
This event will fire every year on June the 15th at 12 PM (still
game time). Units have to be specified depending on your set calendar
(ask a developer for more details).
Variables you can use in this event:
character: the character connected to this event.
"""
CHARACTER_UNPUPPETED = """
When the character is about to be un-puppeted.
This event is called when an account is about to un-puppet the
character, which can happen if the account is disconnecting or
changing puppets.
Variables you can use in this event:
character: the character connected to this event.
"""
@register_events
class EventCharacter(DefaultCharacter):
"""Typeclass to represent a character and call event types."""
_events = {
"can_delete": (["character"], CHARACTER_CAN_DELETE),
"can_move": (["character", "origin", "destination"], CHARACTER_CAN_MOVE),
"can_part": (["character", "departing"], CHARACTER_CAN_PART),
"can_say": (["speaker", "character", "message"], CHARACTER_CAN_SAY, phrase_event),
"delete": (["character"], CHARACTER_DELETE),
"greet": (["character", "newcomer"], CHARACTER_GREET),
"move": (["character", "origin", "destination"], CHARACTER_MOVE),
"puppeted": (["character"], CHARACTER_PUPPETED),
"say": (["speaker", "character", "message"], CHARACTER_SAY, phrase_event),
"time": (["character"], CHARACTER_TIME, None, time_event),
"unpuppeted": (["character"], CHARACTER_UNPUPPETED),
}
@lazy_property
def callbacks(self):
"""Return the CallbackHandler."""
return CallbackHandler(self)
def announce_move_from(self, destination, msg=None, mapping=None):
"""
Called if the move is to be announced. This is
called while we are still standing in the old
location.
Args:
destination (Object): The place we are going to.
msg (str, optional): a replacement message.
mapping (dict, optional): additional mapping objects.
You can override this method and call its parent with a
message to simply change the default message. In the string,
you can use the following as mappings (between braces):
object: the object which is moving.
exit: the exit from which the object is moving (if found).
origin: the location of the object before the move.
destination: the location of the object after moving.
"""
if not self.location:
return
string = msg or "{object} is leaving {origin}, heading for {destination}."
# Get the exit from location to destination
location = self.location
exits = [
o for o in location.contents if o.location is location and o.destination is destination
]
mapping = mapping or {}
mapping.update({"character": self})
if exits:
exits[0].callbacks.call(
"msg_leave", self, exits[0], location, destination, string, mapping
)
string = exits[0].callbacks.get_variable("message")
mapping = exits[0].callbacks.get_variable("mapping")
# If there's no string, don't display anything
# It can happen if the "message" variable in events is set to None
if not string:
return
super().announce_move_from(destination, msg=string, mapping=mapping)
def announce_move_to(self, source_location, msg=None, mapping=None):
"""
Called after the move if the move was not quiet. At this point
we are standing in the new location.
Args:
source_location (Object): The place we came from
msg (str, optional): the replacement message if location.
mapping (dict, optional): additional mapping objects.
You can override this method and call its parent with a
message to simply change the default message. In the string,
you can use the following as mappings (between braces):
object: the object which is moving.
exit: the exit from which the object is moving (if found).
origin: the location of the object before the move.
destination: the location of the object after moving.
"""
if not source_location and self.location.has_account:
# This was created from nowhere and added to an account's
# inventory; it's probably the result of a create command.
string = "You now have %s in your possession." % self.get_display_name(self.location)
self.location.msg(string)
return
if source_location:
string = msg or "{character} arrives to {destination} from {origin}."
else:
string = "{character} arrives to {destination}."
origin = source_location
destination = self.location
exits = []
mapping = mapping or {}
mapping.update({"character": self})
if origin:
exits = [
o
for o in destination.contents
if o.location is destination and o.destination is origin
]
if exits:
exits[0].callbacks.call(
"msg_arrive", self, exits[0], origin, destination, string, mapping
)
string = exits[0].callbacks.get_variable("message")
mapping = exits[0].callbacks.get_variable("mapping")
# If there's no string, don't display anything
# It can happen if the "message" variable in events is set to None
if not string:
return
super().announce_move_to(source_location, msg=string, mapping=mapping)
def at_pre_move(self, destination):
"""
Called just before starting to move this object to
destination.
Args:
destination (Object): The object we are moving to
Returns:
shouldmove (bool): If we should move or not.
Notes:
If this method returns False/None, the move is cancelled
before it is even started.
"""
origin = self.location
Room = DefaultRoom
if isinstance(origin, Room) and isinstance(destination, Room):
can = self.callbacks.call("can_move", self, origin, destination)
if can:
can = origin.callbacks.call("can_move", self, origin)
if can:
# Call other character's 'can_part' event
for present in [
o
for o in origin.contents
if isinstance(o, DefaultCharacter) and o is not self
]:
can = present.callbacks.call("can_part", present, self)
if not can:
break
if can is None:
return True
return can
return True
def at_post_move(self, source_location):
"""
Called after move has completed, regardless of quiet mode or
not. Allows changes to the object due to the location it is
now in.
Args:
source_location (Object): Wwhere we came from. This may be `None`.
"""
super().at_post_move(source_location)
origin = source_location
destination = self.location
Room = DefaultRoom
if isinstance(origin, Room) and isinstance(destination, Room):
self.callbacks.call("move", self, origin, destination)
destination.callbacks.call("move", self, origin, destination)
# Call the 'greet' event of characters in the location
for present in [
o for o in destination.contents if isinstance(o, DefaultCharacter) and o is not self
]:
present.callbacks.call("greet", present, self)
def at_object_delete(self):
"""
Called just before the database object is permanently
delete()d from the database. If this method returns False,
deletion is aborted.
"""
if not self.callbacks.call("can_delete", self):
return False
self.callbacks.call("delete", self)
return True
def at_post_puppet(self):
"""
Called just after puppeting has been completed and all
Account<->Object links have been established.
Note:
You can use `self.account` and `self.sessions.get()` to get
account and sessions at this point; the last entry in the
list from `self.sessions.get()` is the latest Session
puppeting this Object.
"""
super().at_post_puppet()
self.callbacks.call("puppeted", self)
# Call the room's puppeted_in event
location = self.location
if location and isinstance(location, DefaultRoom):
location.callbacks.call("puppeted_in", self, location)
def at_pre_unpuppet(self):
"""
Called just before beginning to un-connect a puppeting from
this Account.
Note:
You can use `self.account` and `self.sessions.get()` to get
account and sessions at this point; the last entry in the
list from `self.sessions.get()` is the latest Session
puppeting this Object.
"""
self.callbacks.call("unpuppeted", self)
# Call the room's unpuppeted_in event
location = self.location
if location and isinstance(location, DefaultRoom):
location.callbacks.call("unpuppeted_in", self, location)
super().at_pre_unpuppet()
def at_pre_say(self, message, **kwargs):
"""
Before the object says something.
This hook is by default used by the 'say' and 'whisper'
commands as used by this command it is called before the text
is said/whispered and can be used to customize the outgoing
text from the object. Returning `None` aborts the command.
Args:
message (str): The suggested say/whisper text spoken by self.
Keyword Args:
whisper (bool): If True, this is a whisper rather than
a say. This is sent by the whisper command by default.
Other verbal commands could use this hook in similar
ways.
receiver (Object): If set, this is a target for the say/whisper.
Returns:
message (str): The (possibly modified) text to be spoken.
"""
# First, try the location
location = getattr(self, "location", None)
location = (
location
if location and inherits_from(location, "evennia.objects.objects.DefaultRoom")
else None
)
if location and not kwargs.get("whisper", False):
allow = location.callbacks.call("can_say", self, location, message, parameters=message)
message = location.callbacks.get_variable("message")
if not allow or not message:
return
# Browse all the room's other characters
for obj in location.contents:
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)
message = obj.callbacks.get_variable("message")
if not allow or not message:
return
return message
def at_say(self, message, **kwargs):
"""
Display the actual say (or whisper) of self.
This hook should display the actual say/whisper of the object in its
location. It should both alert the object (self) and its
location that some text is spoken. The overriding of messages or
`mapping` allows for simple customization of the hook without
re-writing it completely.
Args:
message (str): The text to be conveyed by self.
msg_self (str, optional): The message to echo to self.
msg_location (str, optional): The message to echo to self's location.
receiver (Object, optional): An eventual receiver of the message
(by default only used by whispers).
msg_receiver(str, optional): Specific message for receiver only.
mapping (dict, optional): Additional mapping in messages.
Keyword Args:
whisper (bool): If this is a whisper rather than a say. Kwargs
can be used by other verbal commands in a similar way.
Notes:
Messages can contain {} markers, which must
If used, `msg_self`, `msg_receiver` and `msg_location` should contain
references to other objects between braces, the way `location.msg_contents`
would allow. For instance:
msg_self = 'You say: "{speech}"'
msg_location = '{object} says: "{speech}"'
msg_receiver = '{object} whispers: "{speech}"'
The following mappings can be used in both messages:
object: the object speaking.
location: the location where object is.
speech: the text spoken by self.
You can use additional mappings if you want to add other
information in your messages.
"""
super().at_say(message, **kwargs)
location = getattr(self, "location", None)
location = (
location
if location and inherits_from(location, "evennia.objects.objects.DefaultRoom")
else None
)
if location and not kwargs.get("whisper", False):
location.callbacks.call("say", self, location, message, parameters=message)
# Call the other characters' "say" event
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)
# Exit help
EXIT_CAN_TRAVERSE = """
Can the character traverse through this exit?
This event is called when a character is about to traverse this
exit. You can use the deny() function to deny the character from
exitting for this time.
Variables you can use in this event:
character: the character that wants to traverse this exit.
exit: the exit to be traversed.
room: the room in which stands the character before moving.
"""
EXIT_MSG_ARRIVE = """
Customize the message when a character arrives through this exit.
This event is called when a character arrives through this exit.
To customize the message that will be sent to the room where the
character arrives, change the value of the variable "message"
to give it your custom message. The character itself will not be
notified. You can use mapping between braces, like this:
message = "{character} climbs out of a hole."
In your mapping, you can use {character} (the character who has
arrived), {exit} (the exit), {origin} (the room in which
the character was), and {destination} (the room in which the character
now is). If you need to customize the message with other information,
you can also set "message" to None and send something else instead.
Variables you can use in this event:
character: the character who is arriving through this exit.
exit: the exit having been traversed.
origin: the past location of the character.
destination: the current location of the character.
message: the message to be displayed in the destination.
mapping: a dictionary containing the mapping of the message.
"""
EXIT_MSG_LEAVE = """
Customize the message when a character leaves through this exit.
This event is called when a character leaves through this exit.
To customize the message that will be sent to the room where the
character came from, change the value of the variable "message"
to give it your custom message. The character itself will not be
notified. You can use mapping between braces, like this:
message = "{character} falls into a hole!"
In your mapping, you can use {character} (the character who is
about to leave), {exit} (the exit), {origin} (the room in which
the character is), and {destination} (the room in which the character
is heading for). If you need to customize the message with other
information, you can also set "message" to None and send something
else instead.
Variables you can use in this event:
character: the character who is leaving through this exit.
exit: the exit being traversed.
origin: the location of the character.
destination: the destination of the character.
message: the message to be displayed in the location.
mapping: a dictionary containing additional mapping.
"""
EXIT_TIME = """
A repeated event to be called regularly.
This event is scheduled to repeat at different times, specified
as parameters. You can set it to run every day at 8:00 AM (game
time). You have to specify the time as an argument to @call/add, like:
@call/add north = time 8:00
The parameter (8:00 here) must be a suite of digits separated by
spaces, colons or dashes. Keep it as close from a recognizable
date format, like this:
@call/add south = time 06-15 12:20
This event will fire every year on June the 15th at 12 PM (still
game time). Units have to be specified depending on your set calendar
(ask a developer for more details).
Variables you can use in this event:
exit: the exit connected to this event.
"""
EXIT_TRAVERSE = """
After the characer has traversed through this exit.
This event is called after a character has traversed through this
exit. Traversing cannot be prevented using 'deny()' at this
point. The character will be in a different room and she will
have received the room's description when this event is called.
Variables you can use in this event:
character: the character who has traversed through this exit.
exit: the exit that was just traversed through.
origin: the exit's location (where the character was before moving).
destination: the character's location after moving.
"""
@register_events
class EventExit(DefaultExit):
"""Modified exit including management of events."""
_events = {
"can_traverse": (["character", "exit", "room"], EXIT_CAN_TRAVERSE),
"msg_arrive": (
["character", "exit", "origin", "destination", "message", "mapping"],
EXIT_MSG_ARRIVE,
),
"msg_leave": (
["character", "exit", "origin", "destination", "message", "mapping"],
EXIT_MSG_LEAVE,
),
"time": (["exit"], EXIT_TIME, None, time_event),
"traverse": (["character", "exit", "origin", "destination"], EXIT_TRAVERSE),
}
@lazy_property
def callbacks(self):
"""Return the CallbackHandler."""
return CallbackHandler(self)
def at_traverse(self, traversing_object, target_location):
"""
This hook is responsible for handling the actual traversal,
normally by calling
`traversing_object.move_to(target_location)`. It is normally
only implemented by Exit objects. If it returns False (usually
because `move_to` returned False), `at_post_traverse` below
should not be called and instead `at_failed_traverse` should be
called.
Args:
traversing_object (Object): Object traversing us.
target_location (Object): Where target is going.
"""
is_character = inherits_from(traversing_object, DefaultCharacter)
if is_character:
allow = self.callbacks.call("can_traverse", traversing_object, self, self.location)
if not allow:
return
super().at_traverse(traversing_object, target_location)
# After traversing
if is_character:
self.callbacks.call(
"traverse", traversing_object, self, self.location, self.destination
)
# Object help
OBJECT_DROP = """
When a character drops this object.
This event is called when a character drops this object. It is
called after the command has ended and displayed its message, and
the action cannot be prevented at this time.
Variables you can use in this event:
character: the character having dropped the object.
obj: the object connected to this event.
"""
OBJECT_GET = """
When a character gets this object.
This event is called when a character gets this object. It is
called after the command has ended and displayed its message, and
the action cannot be prevented at this time.
Variables you can use in this event:
character: the character having picked up the object.
obj: the object connected to this event.
"""
OBJECT_TIME = """
A repeated event to be called regularly.
This event is scheduled to repeat at different times, specified
as parameters. You can set it to run every day at 8:00 AM (game
time). You have to specify the time as an argument to @call/add, like:
@call/add here = time 8:00
The parameter (8:00 here) must be a suite of digits separated by
spaces, colons or dashes. Keep it as close from a recognizable
date format, like this:
@call/add here = time 06-15 12:20
This event will fire every year on June the 15th at 12 PM (still
game time). Units have to be specified depending on your set calendar
(ask a developer for more details).
Variables you can use in this event:
object: the object connected to this event.
"""
@register_events
class EventObject(DefaultObject):
"""Default object with management of events."""
_events = {
"drop": (["character", "obj"], OBJECT_DROP),
"get": (["character", "obj"], OBJECT_GET),
"time": (["object"], OBJECT_TIME, None, time_event),
}
@lazy_property
def callbacks(self):
"""Return the CallbackHandler."""
return CallbackHandler(self)
def at_get(self, getter):
"""
Called by the default `get` command when this object has been
picked up.
Args:
getter (Object): The object getting this object.
Notes:
This hook cannot stop the pickup from happening. Use
permissions for that.
"""
super().at_get(getter)
self.callbacks.call("get", getter, self)
def at_drop(self, dropper):
"""
Called by the default `drop` command when this object has been
dropped.
Args:
dropper (Object): The object which just dropped this object.
Notes:
This hook cannot stop the drop from happening. Use
permissions from that.
"""
super().at_drop(dropper)
self.callbacks.call("drop", dropper, self)
# Room help
ROOM_CAN_DELETE = """
Can the room be deleted?
This event is called before the room is deleted. You can use
'deny()' in this event to prevent this room from being deleted.
If this event doesn't prevent the room from being deleted, its
'delete' event is called right away.
Variables you can use in this event:
room: the room connected to this event.
"""
ROOM_CAN_MOVE = """
Can the character move into this room?
This event is called before the character can move into this
specific room. You can prevent the move by using the 'deny()'
function.
Variables you can use in this event:
character: the character who wants to move in this room.
room: the room connected to this event.
"""
ROOM_CAN_SAY = """
Before a character can say something in this room.
This event is called before a character says something in this
room. The "something" in question can be modified, or the action
can be prevented by using 'deny()'. To change the content of what
the character says, simply change the variable 'message' to another
string of characters.
Variables you can use in this event:
character: the character who is using the say command.
room: the room connected to this event.
message: the text spoken by the character.
"""
ROOM_DELETE = """
Before deleting the room.
This event is called just before deleting this room. It shouldn't
be prevented (using the `deny()` function at this stage doesn't
have any effect). If you want to prevent deletion of this room,
use the event `can_delete` instead.
Variables you can use in this event:
room: the room connected to this event.
"""
ROOM_MOVE = """
After the character has moved into this room.
This event is called when the character has moved into this
room. It is too late to prevent the move at this point.
Variables you can use in this event:
character: the character connected to this event.
origin: the old location of the character.
destination: the new location of the character.
"""
ROOM_PUPPETED_IN = """
After the character has been puppeted in this room.
This event is called after a character has been puppeted in this
room. This can happen when an account, having connected, begins
to puppet a character. The character's location at this point,
if it's a room, will see this event fire.
Variables you can use in this event:
character: the character who have just been puppeted in this room.
room: the room connected to this event.
"""
ROOM_SAY = """
After the character has said something in the room.
This event is called right after a character has said something
in this room. The action cannot be prevented at this moment.
Instead, this event is ideal to create actions that will respond
to something being said aloud. To use this event, you have to
specify a list of keywords as parameters that should be present,
as separate words, in the spoken phrase. For instance, you can
set an event tthat would fire if the phrase spoken by the character
contains "menu" or "dinner" or "lunch":
@call/add ... = say menu, dinner, lunch
Then if one of the words is present in what the character says,
this event will fire.
Variables you can use in this event:
character: the character having spoken in this room.
room: the room connected to this event.
message: the text having been spoken by the character.
"""
ROOM_TIME = """
A repeated event to be called regularly.
This event is scheduled to repeat at different times, specified
as parameters. You can set it to run every day at 8:00 AM (game
time). You have to specify the time as an argument to @call/add, like:
@call/add here = time 8:00
The parameter (8:00 here) must be a suite of digits separated by
spaces, colons or dashes. Keep it as close from a recognizable
date format, like this:
@call/add here = time 06-15 12:20
This event will fire every year on June the 15th at 12 PM (still
game time). Units have to be specified depending on your set calendar
(ask a developer for more details).
Variables you can use in this event:
room: the room connected to this event.
"""
ROOM_UNPUPPETED_IN = """
Before the character is un-puppeted in this room.
This event is called before a character is un-puppeted in this
room. This can happen when an account, puppeting a character, is
disconnecting. The character's location at this point, if it's a
room, will see this event fire.
Variables you can use in this event:
character: the character who is about to be un-puppeted in this room.
room: the room connected to this event.
"""
@register_events
class EventRoom(DefaultRoom):
"""Default room with management of events."""
_events = {
"can_delete": (["room"], ROOM_CAN_DELETE),
"can_move": (["character", "room"], ROOM_CAN_MOVE),
"can_say": (["character", "room", "message"], ROOM_CAN_SAY, phrase_event),
"delete": (["room"], ROOM_DELETE),
"move": (["character", "origin", "destination"], ROOM_MOVE),
"puppeted_in": (["character", "room"], ROOM_PUPPETED_IN),
"say": (["character", "room", "message"], ROOM_SAY, phrase_event),
"time": (["room"], ROOM_TIME, None, time_event),
"unpuppeted_in": (["character", "room"], ROOM_UNPUPPETED_IN),
}
@lazy_property
def callbacks(self):
"""Return the CallbackHandler."""
return CallbackHandler(self)
def at_object_delete(self):
"""
Called just before the database object is permanently
delete()d from the database. If this method returns False,
deletion is aborted.
"""
if not self.callbacks.call("can_delete", self):
return False
self.callbacks.call("delete", self)
return True

View file

@ -0,0 +1,262 @@
"""
Functions to extend the event system.
These functions are to be used by developers to customize events and callbacks.
"""
from textwrap import dedent
from django.conf import settings
from evennia import logger
from evennia import ScriptDB
from evennia.utils.create import create_script
from evennia.utils.gametime import real_seconds_until as standard_rsu
from evennia.utils.utils import class_from_module
from evennia.contrib.custom_gametime import UNITS
from evennia.contrib.custom_gametime import gametime_to_realtime
from evennia.contrib.custom_gametime import real_seconds_until as custom_rsu
# Temporary storage for events waiting for the script to be started
EVENTS = []
def get_event_handler():
"""Return the event handler or None."""
try:
script = ScriptDB.objects.get(db_key="event_handler")
except ScriptDB.DoesNotExist:
logger.log_trace("Can't get the event handler.")
script = None
return script
def register_events(path_or_typeclass):
"""
Register the events in this typeclass.
Args:
path_or_typeclass (str or type): the Python path leading to the
class containing events, or the class itself.
Returns:
The typeclass itself.
Notes:
This function will read events from the `_events` class variable
defined in the typeclass given in parameters. It will add
the events, either to the script if it exists, or to some
temporary storage, waiting for the script to be initialized.
"""
if isinstance(path_or_typeclass, str):
typeclass = class_from_module(path_or_typeclass)
else:
typeclass = path_or_typeclass
typeclass_name = typeclass.__module__ + "." + typeclass.__name__
try:
storage = ScriptDB.objects.get(db_key="event_handler")
assert storage.ndb.events is not None
except (ScriptDB.DoesNotExist, AssertionError):
storage = EVENTS
# If the script is started, add the event directly.
# Otherwise, add it to the temporary storage.
for name, tup in getattr(typeclass, "_events", {}).items():
if len(tup) == 4:
variables, help_text, custom_call, custom_add = tup
elif len(tup) == 3:
variables, help_text, custom_call = tup
custom_add = None
elif len(tup) == 2:
variables, help_text = tup
custom_call = None
custom_add = None
else:
variables = help_text = custom_call = custom_add = None
if isinstance(storage, list):
storage.append((typeclass_name, name, variables, help_text, custom_call, custom_add))
else:
storage.add_event(typeclass_name, name, variables, help_text, custom_call, custom_add)
return typeclass
# Custom callbacks for specific event types
def get_next_wait(format):
"""
Get the length of time in seconds before format.
Args:
format (str): a time format matching the set calendar.
Returns:
until (int or float): the number of seconds until the event.
usual (int or float): the usual number of seconds between events.
format (str): a string format representing the time.
Notes:
The time format could be something like "2018-01-08 12:00". The
number of units set in the calendar affects the way seconds are
calculated.
"""
calendar = getattr(settings, "EVENTS_CALENDAR", None)
if calendar is None:
logger.log_err(
"A time-related event has been set whereas "
"the gametime calendar has not been set in the settings."
)
return
elif calendar == "standard":
rsu = standard_rsu
units = ["min", "hour", "day", "month", "year"]
elif calendar == "custom":
rsu = custom_rsu
back = dict([(value, name) for name, value in UNITS.items()])
sorted_units = sorted(back.items())
del sorted_units[0]
units = [n for v, n in sorted_units]
params = {}
for delimiter in ("-", ":"):
format = format.replace(delimiter, " ")
pieces = list(reversed(format.split()))
details = []
i = 0
for uname in units:
try:
piece = pieces[i]
except IndexError:
break
if not piece.isdigit():
logger.log_trace(
"The time specified '{}' in {} isn't " "a valid number".format(piece, format)
)
return
# Convert the piece to int
piece = int(piece)
params[uname] = piece
details.append("{}={}".format(uname, piece))
if i < len(units):
next_unit = units[i + 1]
else:
next_unit = None
i += 1
params["sec"] = 0
details = " ".join(details)
until = rsu(**params)
usual = -1
if next_unit:
kwargs = {next_unit: 1}
usual = gametime_to_realtime(**kwargs)
return until, usual, details
def time_event(obj, event_name, number, parameters):
"""
Create a time-related event.
Args:
obj (Object): the object on which sits the event.
event_name (str): the event's name.
number (int): the number of the event.
parameters (str): the parameter of the event.
"""
seconds, usual, key = get_next_wait(parameters)
script = create_script(
"evennia.contrib.ingame_python.scripts.TimeEventScript", interval=seconds, obj=obj
)
script.key = key
script.desc = "event on {}".format(key)
script.db.time_format = parameters
script.db.number = number
script.ndb.usual = usual
def keyword_event(callbacks, parameters):
"""
Custom call for events with keywords (like push, or pull, or turn...).
Args:
callbacks (list of dict): the list of callbacks to be called.
parameters (str): the actual parameters entered to trigger the callback.
Returns:
A list containing the callback dictionaries to be called.
Notes:
This function should be imported and added as a custom_call
parameter to add the event when the event supports keywords
as parameters. Keywords in parameters are one or more words
separated by a comma. For instance, a 'push 1, one' callback can
be set to trigger when the player 'push 1' or 'push one'.
"""
key = parameters.strip().lower()
to_call = []
for callback in callbacks:
keys = callback["parameters"]
if not keys or key in [p.strip().lower() for p in keys.split(",")]:
to_call.append(callback)
return to_call
def phrase_event(callbacks, parameters):
"""
Custom call for events with keywords in sentences (like say or whisper).
Args:
callbacks (list of dict): the list of callbacks to be called.
parameters (str): the actual parameters entered to trigger the callback.
Returns:
A list containing the callback dictionaries to be called.
Notes:
This function should be imported and added as a custom_call
parameter to add the event when the event supports keywords
in phrases as parameters. Keywords in parameters are one or more
words separated by a comma. For instance, a 'say yes, okay' callback
can be set to trigger when the player says something containing
either "yes" or "okay" (maybe 'say I don't like it, but okay').
"""
phrase = parameters.strip().lower()
# Remove punctuation marks
punctuations = ',.";?!'
for p in punctuations:
phrase = phrase.replace(p, " ")
words = phrase.split()
words = [w.strip("' ") for w in words if w.strip("' ")]
to_call = []
for callback in callbacks:
keys = callback["parameters"]
if not keys or any(key.strip().lower() in words for key in keys.split(",")):
to_call.append(callback)
return to_call
class InterruptEvent(RuntimeError):
"""
Interrupt the current event.
You shouldn't have to use this exception directly, probably use the
`deny()` function that handles it instead.
"""
pass

View file

@ -0,0 +1,254 @@
"""
A login menu using EvMenu.
Contribution - Vincent-lg 2016, Griatch 2019 (rework for modern EvMenu)
This changes the Evennia login to ask for the account name and password in
sequence instead of requiring you to enter both at once.
To install, add this line to the settings file (`mygame/server/conf/settings.py`):
CMDSET_UNLOGGEDIN = "evennia.contrib.menu_login.UnloggedinCmdSet"
Reload the server and the new connection method will be active. Note that you must
independently change the connection screen to match this login style, by editing
`mygame/server/conf/connection_screens.py`.
This uses Evennia's menu system EvMenu and is triggered by a command that is
called automatically when a new user connects.
"""
from django.conf import settings
from evennia import Command, CmdSet
from evennia import syscmdkeys
from evennia.utils.evmenu import EvMenu
from evennia.utils.utils import random_string_from_module, class_from_module, callables_from_module
_CONNECTION_SCREEN_MODULE = settings.CONNECTION_SCREEN_MODULE
_GUEST_ENABLED = settings.GUEST_ENABLED
_ACCOUNT = class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
_GUEST = class_from_module(settings.BASE_GUEST_TYPECLASS)
_ACCOUNT_HELP = (
"Enter the name you used to log into the game before, " "or a new account-name if you are new."
)
_PASSWORD_HELP = (
"Password should be a minimum of 8 characters (preferably longer) and "
"can contain a mix of letters, spaces, digits and @/./+/-/_/'/, only."
)
# Menu nodes
def _show_help(caller, raw_string, **kwargs):
"""Echo help message, then re-run node that triggered it"""
help_entry = kwargs["help_entry"]
caller.msg(help_entry)
return None # re-run calling node
def node_enter_username(caller, raw_text, **kwargs):
"""
Start node of menu
Start login by displaying the connection screen and ask for a user name.
"""
def _check_input(caller, username, **kwargs):
"""
'Goto-callable', set up to be called from the _default option below.
Called when user enters a username string. Check if this username already exists and set the flag
'new_user' if not. Will also directly login if the username is 'guest'
and GUEST_ENABLED is True.
The return from this goto-callable determines which node we go to next
and what kwarg it will be called with.
"""
username = username.rstrip("\n")
if username == "guest" and _GUEST_ENABLED:
# do an immediate guest login
session = caller
address = session.address
account, errors = _GUEST.authenticate(ip=address)
if account:
return "node_quit_or_login", {"login": True, "account": account}
else:
session.msg("|R{}|n".format("\n".join(errors)))
return None # re-run the username node
try:
_ACCOUNT.objects.get(username__iexact=username)
except _ACCOUNT.DoesNotExist:
new_user = True
else:
new_user = False
# pass username/new_user into next node as kwargs
return "node_enter_password", {"new_user": new_user, "username": username}
callables = callables_from_module(_CONNECTION_SCREEN_MODULE)
if "connection_screen" in callables:
connection_screen = callables["connection_screen"]()
else:
connection_screen = random_string_from_module(_CONNECTION_SCREEN_MODULE)
if _GUEST_ENABLED:
text = "Enter a new or existing user name to login (write 'guest' for a guest login):"
else:
text = "Enter a new or existing user name to login:"
text = "{}\n\n{}".format(connection_screen, text)
options = (
{"key": "", "goto": "node_enter_username"},
{"key": ("quit", "q"), "goto": "node_quit_or_login"},
{"key": ("help", "h"), "goto": (_show_help, {"help_entry": _ACCOUNT_HELP, **kwargs})},
{"key": "_default", "goto": _check_input},
)
return text, options
def node_enter_password(caller, raw_string, **kwargs):
"""
Handle password input.
"""
def _check_input(caller, password, **kwargs):
"""
'Goto-callable', set up to be called from the _default option below.
Called when user enters a password string. Check username + password
viability. If it passes, the account will have been created and login
will be initiated.
The return from this goto-callable determines which node we go to next
and what kwarg it will be called with.
"""
# these flags were set by the goto-callable
username = kwargs["username"]
new_user = kwargs["new_user"]
password = password.rstrip("\n")
session = caller
address = session.address
if new_user:
# create a new account
account, errors = _ACCOUNT.create(
username=username, password=password, ip=address, session=session
)
else:
# check password against existing account
account, errors = _ACCOUNT.authenticate(
username=username, password=password, ip=address, session=session
)
if account:
if new_user:
session.msg("|gA new account |c{}|g was created. Welcome!|n".format(username))
# pass login info to login node
return "node_quit_or_login", {"login": True, "account": account}
else:
# restart due to errors
session.msg("|R{}".format("\n".join(errors)))
kwargs["retry_password"] = True
return "node_enter_password", kwargs
def _restart_login(caller, *args, **kwargs):
caller.msg("|yCancelled login.|n")
return "node_enter_username"
username = kwargs["username"]
if kwargs["new_user"]:
if kwargs.get("retry_password"):
# Attempting to fix password
text = "Enter a new password:"
else:
text = "Creating a new account |c{}|n. " "Enter a password (empty to abort):".format(
username
)
else:
text = "Enter the password for account |c{}|n (empty to abort):".format(username)
options = (
{"key": "", "goto": _restart_login},
{"key": ("quit", "q"), "goto": "node_quit_or_login"},
{"key": ("help", "h"), "goto": (_show_help, {"help_entry": _PASSWORD_HELP, **kwargs})},
{"key": "_default", "goto": (_check_input, kwargs)},
)
return text, options
def node_quit_or_login(caller, raw_text, **kwargs):
"""
Exit menu, either by disconnecting or logging in.
"""
session = caller
if kwargs.get("login"):
account = kwargs.get("account")
session.msg("|gLogging in ...|n")
session.sessionhandler.login(session, account)
else:
session.sessionhandler.disconnect(session, "Goodbye! Logging off.")
return "", {}
# EvMenu helper function
def _node_formatter(nodetext, optionstext, caller=None):
"""Do not display the options, only the text.
This function is used by EvMenu to format the text of nodes. The menu login
is just a series of prompts so we disable all automatic display decoration
and let the nodes handle everything on their own.
"""
return nodetext
# Commands and CmdSets
class UnloggedinCmdSet(CmdSet):
"Cmdset for the unloggedin state"
key = "DefaultUnloggedin"
priority = 0
def at_cmdset_creation(self):
"Called when cmdset is first created."
self.add(CmdUnloggedinLook())
class CmdUnloggedinLook(Command):
"""
An unloggedin version of the look command. This is called by the server
when the account first connects. It sets up the menu before handing off
to the menu's own look command.
"""
key = syscmdkeys.CMD_LOGINSTART
locks = "cmd:all()"
arg_regex = r"^$"
def func(self):
"""
Run the menu using the nodes in this module.
"""
EvMenu(
self.caller,
"evennia.contrib.menu_login",
startnode="node_enter_username",
auto_look=False,
auto_quit=False,
cmd_on_exit=None,
node_formatter=_node_formatter,
)

View file

@ -0,0 +1,545 @@
"""
Legacy Comms-commands
Griatch 2021
In Evennia 1.0, the old Channel commands (originally inspired by MUX) were
replaced by the single `channel` command that performs all these function.
That command is still required to talk on channels. This contrib (extracted
from Evennia 0.9.5) reuses the channel-management of the base Channel command
but breaks out its functionality into separate Commands with MUX-familiar names.
- `allcom` - `channel/all` and `channel`
- `addcom` - `channel/alias`, `channel/sub` and `channel/unmute`
- `delcom` - `channel/unalias`, `alias/unsub` and `channel/mute`
- `cboot` - `channel/boot` (`channel/ban` and `/unban` not supported)
- `cwho` - `channel/who`
- `ccreate` - `channel/create`
- `cdestroy` - `channel/destroy`
- `clock` - `channel/lock`
- `cdesc` - `channel/desc`
Installation:
- Import the `CmdSetLegacyComms` cmdset from this module into `mygame/commands/default_cmdsets.py`
- Add it to the CharacterCmdSet's `at_cmdset_creation` method.
- Reload the server.
Example:
```python
# in mygame/commands/default_cmdsets.py
# ...
from evennia.contrib.legacy_comms import CmdSetLegacyComms # <----
class CharacterCmdSet(default_cmds.CharacterCmdSet):
# ...
def at_cmdset_creation(self):
# ...
self.add(CmdSetLegacyComms) # <----
```
"""
from django.conf import settings
from evennia.commands.cmdset import CmdSet
from evennia.commands.default.comms import CmdChannel
from evennia.utils import logger
CHANNEL_DEFAULT_TYPECLASS = settings.BASE_CHANNEL_TYPECLASS
class CmdAddCom(CmdChannel):
"""
Add a channel alias and/or subscribe to a channel
Usage:
addcom [alias=] <channel>
Joins a given channel. If alias is given, this will allow you to
refer to the channel by this alias rather than the full channel
name. Subsequent calls of this command can be used to add multiple
aliases to an already joined channel.
"""
key = "addcom"
aliases = ["aliaschan", "chanalias"]
help_category = "Comms"
locks = "cmd:not pperm(channel_banned)"
# this is used by the COMMAND_DEFAULT_CLASS parent
account_caller = True
def func(self):
"""Implement the command"""
caller = self.caller
args = self.args
if not args:
self.msg("Usage: addcom [alias =] channelname.")
return
if self.rhs:
# rhs holds the channelname
channelname = self.rhs
alias = self.lhs
else:
channelname = self.args
alias = None
channel = self.search_channel(channelname)
if not channel:
return
string = ""
if not channel.has_connection(caller):
# we want to connect as well.
success, err = self.sub_to_channel(channel)
if success:
# if this would have returned True, the account is connected
self.msg(f"You now listen to the channel {channel.key}")
else:
self.msg(f"{channel.key}: You are not allowed to join this channel.")
return
if channel.unmute(caller):
self.msg(f"You unmute channel {channel.key}.")
else:
self.msg(f"You are already connected to channel {channel.key}.")
if alias:
# create a nick and add it to the caller.
self.add_alias(channel, alias)
self.msg(f" You can now refer to the channel {channel} with the alias '{alias}'.")
else:
string += " No alias added."
self.msg(string)
class CmdDelCom(CmdChannel):
"""
remove a channel alias and/or unsubscribe from channel
Usage:
delcom <alias or channel>
delcom/all <channel>
If the full channel name is given, unsubscribe from the
channel. If an alias is given, remove the alias but don't
unsubscribe. If the 'all' switch is used, remove all aliases
for that channel.
"""
key = "delcom"
aliases = ["delaliaschan", "delchanalias"]
help_category = "Comms"
locks = "cmd:not perm(channel_banned)"
# this is used by the COMMAND_DEFAULT_CLASS parent
account_caller = True
def func(self):
"""Implementing the command. """
caller = self.caller
if not self.args:
self.msg("Usage: delcom <alias or channel>")
return
ostring = self.args.lower().strip()
channel = self.search_channel(ostring)
if not channel:
return
if not channel.has_connection(caller):
self.msg("You are not listening to that channel.")
return
if ostring == channel.key.lower():
# an exact channel name - unsubscribe
delnicks = "all" in self.switches
# find all nicks linked to this channel and delete them
if delnicks:
aliases = self.get_channel_aliases(channel)
for alias in aliases:
self.remove_alias(alias)
success, err = self.unsub_from_channel(channel)
if success:
wipednicks = " Eventual aliases were removed." if delnicks else ""
self.msg(f"You stop listening to channel '{channel.key}'.{wipednicks}")
else:
self.msg(err)
return
else:
# we are removing a channel nick
self.remove_alias(ostring)
self.msg(f"Any alias '{ostring}' for channel {channel.key} was cleared.")
class CmdAllCom(CmdChannel):
"""
perform admin operations on all channels
Usage:
allcom [on | off | who | destroy]
Allows the user to universally turn off or on all channels they are on, as
well as perform a 'who' for all channels they are on. Destroy deletes all
channels that you control.
Without argument, works like comlist.
"""
key = "allcom"
aliases = [] # important to not inherit parent's aliases
locks = "cmd: not pperm(channel_banned)"
help_category = "Comms"
# this is used by the COMMAND_DEFAULT_CLASS parent
account_caller = True
def func(self):
"""Runs the function"""
caller = self.caller
args = self.args
if not args:
subscribed, available = self.list_channels()
self.msg(
"\n|wAvailable channels:\n{table}")
return
return
if args == "on":
# get names of all channels available to listen to
# and activate them all
channels = [
chan
for chan in CHANNEL_DEFAULT_TYPECLASS.objects.get_all_channels()
if chan.access(caller, "listen")
]
for channel in channels:
self.execute_cmd("addcom %s" % channel.key)
elif args == "off":
# get names all subscribed channels and disconnect from them all
channels = CHANNEL_DEFAULT_TYPECLASS.objects.get_subscriptions(caller)
for channel in channels:
self.execute_cmd("delcom %s" % channel.key)
elif args == "destroy":
# destroy all channels you control
channels = [
chan
for chan in CHANNEL_DEFAULT_TYPECLASS.objects.get_all_channels()
if chan.access(caller, "control")
]
for channel in channels:
self.execute_cmd("cdestroy %s" % channel.key)
elif args == "who":
# run a who, listing the subscribers on visible channels.
string = "\n|CChannel subscriptions|n"
channels = [
chan
for chan in CHANNEL_DEFAULT_TYPECLASS.objects.get_all_channels()
if chan.access(caller, "listen")
]
if not channels:
string += "No channels."
for channel in channels:
string += "\n|w%s:|n\n %s" % (channel.key, channel.wholist)
self.msg(string.strip())
else:
# wrong input
self.msg("Usage: allcom on | off | who | clear")
class CmdCdestroy(CmdChannel):
"""
destroy a channel you created
Usage:
cdestroy <channel>
Destroys a channel that you control.
"""
key = "cdestroy"
aliases = []
help_category = "Comms"
locks = "cmd: not pperm(channel_banned)"
# this is used by the COMMAND_DEFAULT_CLASS parent
account_caller = True
def func(self):
"""Destroy objects cleanly."""
caller = self.caller
if not self.args:
self.msg("Usage: cdestroy <channelname>")
return
channel = self.search_channel(self.args)
if not channel:
self.msg("Could not find channel %s." % self.args)
return
if not channel.access(caller, "control"):
self.msg("You are not allowed to do that.")
return
channel_key = channel.key
message = f"{channel.key} is being destroyed. Make sure to change your aliases."
self.destroy_channel(channel, message)
self.msg("Channel '%s' was destroyed." % channel_key)
logger.log_sec(
"Channel Deleted: %s (Caller: %s, IP: %s)."
% (channel_key, caller, self.session.address)
)
class CmdCBoot(CmdChannel):
"""
kick an account from a channel you control
Usage:
cboot[/quiet] <channel> = <account> [:reason]
Switch:
quiet - don't notify the channel
Kicks an account or object from a channel you control.
"""
key = "cboot"
aliases = []
switch_options = ("quiet",)
locks = "cmd: not pperm(channel_banned)"
help_category = "Comms"
# this is used by the COMMAND_DEFAULT_CLASS parent
account_caller = True
def func(self):
"""implement the function"""
if not self.args or not self.rhs:
string = "Usage: cboot[/quiet] <channel> = <account> [:reason]"
self.msg(string)
return
channel = self.search_channel(self.lhs)
if not channel:
return
reason = ""
if ":" in self.rhs:
target, reason = self.rhs.rsplit(":", 1)
is_account = target.strip().startswith("*")
searchstring = target.lstrip("*")
else:
is_account = target.strip().startswith("*")
searchstring = self.rhs.lstrip("*")
target = self.caller.search(searchstring, account=is_account)
if not target:
return
if reason:
reason = " (reason: %s)" % reason
if not channel.access(self.caller, "control"):
string = "You don't control this channel."
self.msg(string)
return
success, err = self.boot_user(target, quiet='quiet' in self.switches)
if success:
self.msg(f"Booted {target.key} from {channel.key}")
logger.log_sec(
"Channel Boot: %s (Channel: %s, Reason: %s, Caller: %s, IP: %s)."
% (self.caller, channel, reason, self.caller, self.session.address)
)
else:
self.msg(err)
class CmdCWho(CmdChannel):
"""
show who is listening to a channel
Usage:
cwho <channel>
List who is connected to a given channel you have access to.
"""
key = "cwho"
aliases = []
locks = "cmd: not pperm(channel_banned)"
help_category = "Comms"
# this is used by the COMMAND_DEFAULT_CLASS parent
account_caller = True
def func(self):
"""implement function"""
if not self.args:
string = "Usage: cwho <channel>"
self.msg(string)
return
channel = self.search_channel(self.lhs)
if not channel:
return
if not channel.access(self.caller, "listen"):
string = "You can't access this channel."
self.msg(string)
return
string = "\n|CChannel subscriptions|n"
string += "\n|w%s:|n\n %s" % (channel.key, channel.wholist)
self.msg(string.strip())
class CmdChannelCreate(CmdChannel):
"""
create a new channel
Usage:
ccreate <new channel>[;alias;alias...] = description
Creates a new channel owned by you.
"""
key = "ccreate"
aliases = "channelcreate"
locks = "cmd:not pperm(channel_banned) and pperm(Player)"
help_category = "Comms"
# this is used by the COMMAND_DEFAULT_CLASS parent
account_caller = True
def func(self):
"""Implement the command"""
if not self.args:
self.msg("Usage ccreate <channelname>[;alias;alias..] = description")
return
description = ""
if self.rhs:
description = self.rhs
lhs = self.lhs
channame = lhs
aliases = None
if ";" in lhs:
channame, aliases = lhs.split(";", 1)
aliases = [alias.strip().lower() for alias in aliases.split(";")]
new_chan, err = self.create_channel(channame, description, aliases=aliases)
if new_chan:
self.msg(f"Created channel {new_chan.key} and connected to it.")
else:
self.msg(err)
class CmdClock(CmdChannel):
"""
change channel locks of a channel you control
Usage:
clock <channel> [= <lockstring>]
Changes the lock access restrictions of a channel. If no
lockstring was given, view the current lock definitions.
"""
key = "clock"
aliases = ["clock"]
locks = "cmd:not pperm(channel_banned) and perm(Admin)"
help_category = "Comms"
# this is used by the COMMAND_DEFAULT_CLASS parent
account_caller = True
def func(self):
"""run the function"""
if not self.args:
string = "Usage: clock channel [= lockstring]"
self.msg(string)
return
channel = self.search_channel(self.lhs)
if not channel:
return
if not self.rhs:
# no =, so just view the current locks
self.msg(f"Current locks on {channel.key}\n{channel.locks}")
return
# we want to add/change a lock.
if not channel.access(self.caller, "control"):
string = "You don't control this channel."
self.msg(string)
return
# Try to add the lock
success, err = self.set_lock(channel, self.rhs)
if success:
self.msg(f"Lock(s) applied. Current locks on {channel.key}:\n{channel.locks}")
else:
self.msg(err)
class CmdCdesc(CmdChannel):
"""
describe a channel you control
Usage:
cdesc <channel> = <description>
Changes the description of the channel as shown in
channel lists.
"""
key = "cdesc"
aliases = []
locks = "cmd:not pperm(channel_banned)"
help_category = "Comms"
# this is used by the COMMAND_DEFAULT_CLASS parent
account_caller = True
def func(self):
"""Implement command"""
caller = self.caller
if not self.rhs:
self.msg("Usage: cdesc <channel> = <description>")
return
channel = self.search_channel(self.lhs)
if not channel:
return
# check permissions
if not channel.access(caller, "control"):
self.msg("You cannot admin this channel.")
return
self.set_desc(channel, self.rhs)
self.msg(f"Description of channel '{channel.key}' set to '{self.rhs}'.")
class CmdSetLegacyComms(CmdSet):
def at_cmdset_createion(self):
self.add(CmdAddCom())
self.add(CmdAllCom())
self.add(CmdDelCom())
self.add(CmdCdestroy())
self.add(CmdCBoot())
self.add(CmdCWho())
self.add(CmdChannelCreate())
self.add(CmdClock())
self.add(CmdCdesc())

View file

@ -0,0 +1,814 @@
"""
Puzzles System - Provides a typeclass and commands for
objects that can be combined (i.e. 'use'd) to produce
new objects.
Evennia contribution - Henddher 2018
A Puzzle is a recipe of what objects (aka parts) must
be combined by a player so a new set of objects
(aka results) are automatically created.
Consider this simple Puzzle:
orange, mango, yogurt, blender = fruit smoothie
As a Builder:
@create/drop orange
@create/drop mango
@create/drop yogurt
@create/drop blender
@create/drop fruit smoothie
@puzzle smoothie, orange, mango, yogurt, blender = fruit smoothie
...
Puzzle smoothie(#1234) created successfuly.
@destroy/force orange, mango, yogurt, blender, fruit smoothie
@armpuzzle #1234
Part orange is spawned at ...
Part mango is spawned at ...
....
Puzzle smoothie(#1234) has been armed successfully
As Player:
use orange, mango, yogurt, blender
...
Genius, you blended all fruits to create a fruit smoothie!
Details:
Puzzles are created from existing objects. The given
objects are introspected to create prototypes for the
puzzle parts and results. These prototypes become the
puzzle recipe. (See PuzzleRecipe and @puzzle
command). Once the recipe is created, all parts and result
can be disposed (i.e. destroyed).
At a later time, a Builder or a Script can arm the puzzle
and spawn all puzzle parts in their respective
locations (See @armpuzzle).
A regular player can collect the puzzle parts and combine
them (See use command). If player has specified
all pieces, the puzzle is considered solved and all
its puzzle parts are destroyed while the puzzle results
are spawened on their corresponding location.
Installation:
Add the PuzzleSystemCmdSet to all players.
Alternatively:
@py self.cmdset.add('evennia.contrib.puzzles.PuzzleSystemCmdSet')
"""
import itertools
from random import choice
from evennia import create_script
from evennia import CmdSet
from evennia import DefaultScript
from evennia import DefaultCharacter
from evennia import DefaultRoom
from evennia import DefaultExit
from evennia.commands.default.muxcommand import MuxCommand
from evennia.utils.utils import inherits_from
from evennia.utils import search, utils, logger
from evennia.prototypes.spawner import spawn
# Tag used by puzzles
_PUZZLES_TAG_CATEGORY = "puzzles"
_PUZZLES_TAG_RECIPE = "puzzle_recipe"
# puzzle part and puzzle result
_PUZZLES_TAG_MEMBER = "puzzle_member"
_PUZZLE_DEFAULT_FAIL_USE_MESSAGE = "You try to utilize %s but nothing happens ... something amiss?"
_PUZZLE_DEFAULT_SUCCESS_USE_MESSAGE = "You are a Genius!!!"
_PUZZLE_DEFAULT_SUCCESS_USE_LOCATION_MESSAGE = "|c{caller}|n performs some kind of tribal dance and |y{result_names}|n seems to appear from thin air"
# ----------- UTILITY FUNCTIONS ------------
def proto_def(obj, with_tags=True):
"""
Basic properties needed to spawn
and compare recipe with candidate part
"""
protodef = {
# TODO: Don't we need to honor ALL properties? attributes, contents, etc.
"prototype_key": "%s(%s)" % (obj.key, obj.dbref),
"key": obj.key,
"typeclass": obj.typeclass_path,
"desc": obj.db.desc,
"location": obj.location,
"home": obj.home,
"locks": ";".join(obj.locks.all()),
"permissions": obj.permissions.all()[:],
}
if with_tags:
tags = obj.tags.all(return_key_and_category=True)
tags = [(t[0], t[1], None) for t in tags]
tags.append((_PUZZLES_TAG_MEMBER, _PUZZLES_TAG_CATEGORY, None))
protodef["tags"] = tags
return protodef
def maskout_protodef(protodef, mask):
"""
Returns a new protodef after removing protodef values based on mask
"""
protodef = dict(protodef)
for m in mask:
if m in protodef:
protodef.pop(m)
return protodef
# Colorize the default success message
def _colorize_message(msg):
_i = 0
_colors = ["|r", "|g", "|y"]
_msg = []
for l in msg:
_msg += _colors[_i] + l
_i = (_i + 1) % len(_colors)
msg = "".join(_msg) + "|n"
return msg
_PUZZLE_DEFAULT_SUCCESS_USE_MESSAGE = _colorize_message(_PUZZLE_DEFAULT_SUCCESS_USE_MESSAGE)
# ------------------------------------------
class PuzzleRecipe(DefaultScript):
"""
Definition of a Puzzle Recipe
"""
def save_recipe(self, puzzle_name, parts, results):
self.db.puzzle_name = str(puzzle_name)
self.db.parts = tuple(parts)
self.db.results = tuple(results)
self.db.mask = tuple()
self.tags.add(_PUZZLES_TAG_RECIPE, category=_PUZZLES_TAG_CATEGORY)
self.db.use_success_message = _PUZZLE_DEFAULT_SUCCESS_USE_MESSAGE
self.db.use_success_location_message = _PUZZLE_DEFAULT_SUCCESS_USE_LOCATION_MESSAGE
class CmdCreatePuzzleRecipe(MuxCommand):
"""
Creates a puzzle recipe. A puzzle consists of puzzle-parts that
the player can 'use' together to create a specified result.
Usage:
@puzzle name,<part1[,part2,...>] = <result1[,result2,...]>
Example:
create/drop balloon
create/drop glass of water
create/drop water balloon
@puzzle waterballon,balloon,glass of water = water balloon
@del ballon, glass of water, water balloon
@armpuzzle #1
Notes:
Each part and result are objects that must (temporarily) exist and be placed in their
corresponding location in order to create the puzzle. After the creation of the puzzle,
these objects are not needed anymore and can be deleted. Components of the puzzle
will be re-created by use of the `@armpuzzle` command later.
"""
key = "@puzzle"
aliases = "@puzzlerecipe"
locks = "cmd:perm(puzzle) or perm(Builder)"
help_category = "Puzzles"
confirm = True
default_confirm = "no"
def func(self):
caller = self.caller
if len(self.lhslist) < 2 or not self.rhs:
string = "Usage: @puzzle name,<part1[,...]> = <result1[,...]>"
caller.msg(string)
return
puzzle_name = self.lhslist[0]
if len(puzzle_name) == 0:
caller.msg("Invalid puzzle name %r." % puzzle_name)
return
# if there is another puzzle with same name
# warn user that parts and results will be
# interchangable
_puzzles = search.search_script_attribute(key="puzzle_name", value=puzzle_name)
_puzzles = list(filter(lambda p: isinstance(p, PuzzleRecipe), _puzzles))
if _puzzles:
confirm = (
"There are %d puzzles with the same name.\n" % len(_puzzles)
+ "Its parts and results will be interchangeable.\n"
+ "Continue yes/[no]? "
)
answer = ""
while answer.strip().lower() not in ("y", "yes", "n", "no"):
answer = yield (confirm)
answer = self.default_confirm if answer == "" else answer
if answer.strip().lower() in ("n", "no"):
caller.msg("Cancelled: no puzzle created.")
return
def is_valid_obj_location(obj):
valid = True
# Rooms are the only valid locations.
# TODO: other valid locations could be added here.
# Certain locations can be handled accordingly: e.g,
# a part is located in a character's inventory,
# perhaps will translate into the player character
# having the part in his/her inventory while being
# located in the same room where the builder was
# located.
# Parts and results may have different valid locations
if not inherits_from(obj.location, DefaultRoom):
caller.msg("Invalid location for %s" % (obj.key))
valid = False
return valid
def is_valid_part_location(part):
return is_valid_obj_location(part)
def is_valid_result_location(part):
return is_valid_obj_location(part)
def is_valid_inheritance(obj):
valid = (
not inherits_from(obj, DefaultCharacter)
and not inherits_from(obj, DefaultRoom)
and not inherits_from(obj, DefaultExit)
)
if not valid:
caller.msg("Invalid typeclass for %s" % (obj))
return valid
def is_valid_part(part):
return is_valid_inheritance(part) and is_valid_part_location(part)
def is_valid_result(result):
return is_valid_inheritance(result) and is_valid_result_location(result)
parts = []
for objname in self.lhslist[1:]:
obj = caller.search(objname)
if not obj:
return
if not is_valid_part(obj):
return
parts.append(obj)
results = []
for objname in self.rhslist:
obj = caller.search(objname)
if not obj:
return
if not is_valid_result(obj):
return
results.append(obj)
for part in parts:
caller.msg("Part %s(%s)" % (part.name, part.dbref))
for result in results:
caller.msg("Result %s(%s)" % (result.name, result.dbref))
proto_parts = [proto_def(obj) for obj in parts]
proto_results = [proto_def(obj) for obj in results]
puzzle = create_script(PuzzleRecipe, key=puzzle_name, persistent=True)
puzzle.save_recipe(puzzle_name, proto_parts, proto_results)
puzzle.locks.add("control:id(%s) or perm(Builder)" % caller.dbref[1:])
caller.msg(
"Puzzle |y'%s' |w%s(%s)|n has been created |gsuccessfully|n."
% (puzzle.db.puzzle_name, puzzle.name, puzzle.dbref)
)
caller.msg(
"You may now dispose of all parts and results. \n"
"Use @puzzleedit #{dbref} to customize this puzzle further. \n"
"Use @armpuzzle #{dbref} to arm a new puzzle instance.".format(dbref=puzzle.dbref)
)
class CmdEditPuzzle(MuxCommand):
"""
Edits puzzle properties
Usage:
@puzzleedit[/delete] <#dbref>
@puzzleedit <#dbref>/use_success_message = <Custom message>
@puzzleedit <#dbref>/use_success_location_message = <Custom message from {caller} producing {result_names}>
@puzzleedit <#dbref>/mask = attr1[,attr2,...]>
@puzzleedit[/addpart] <#dbref> = <obj[,obj2,...]>
@puzzleedit[/delpart] <#dbref> = <obj[,obj2,...]>
@puzzleedit[/addresult] <#dbref> = <obj[,obj2,...]>
@puzzleedit[/delresult] <#dbref> = <obj[,obj2,...]>
Switches:
addpart - adds parts to the puzzle
delpart - removes parts from the puzzle
addresult - adds results to the puzzle
delresult - removes results from the puzzle
delete - deletes the recipe. Existing parts and results aren't modified
mask - attributes to exclude during matching (e.g. location, desc, etc.)
use_success_location_message containing {result_names} and {caller} will
automatically be replaced with correct values. Both are optional.
When removing parts/results, it's possible to remove all.
"""
key = "@puzzleedit"
locks = "cmd:perm(puzzleedit) or perm(Builder)"
help_category = "Puzzles"
def func(self):
self._USAGE = "Usage: @puzzleedit[/switches] <dbref>[/attribute = <value>]"
caller = self.caller
if not self.lhslist:
caller.msg(self._USAGE)
return
if "/" in self.lhslist[0]:
recipe_dbref, attr = self.lhslist[0].split("/")
else:
recipe_dbref = self.lhslist[0]
if not utils.dbref(recipe_dbref):
caller.msg("A puzzle recipe's #dbref must be specified.\n" + self._USAGE)
return
puzzle = search.search_script(recipe_dbref)
if not puzzle or not inherits_from(puzzle[0], PuzzleRecipe):
caller.msg("%s(%s) is not a puzzle" % (puzzle[0].name, recipe_dbref))
return
puzzle = puzzle[0]
puzzle_name_id = "%s(%s)" % (puzzle.name, puzzle.dbref)
if "delete" in self.switches:
if not (puzzle.access(caller, "control") or puzzle.access(caller, "delete")):
caller.msg("You don't have permission to delete %s." % puzzle_name_id)
return
puzzle.delete()
caller.msg("%s was deleted" % puzzle_name_id)
return
elif "addpart" in self.switches:
objs = self._get_objs()
if objs:
added = self._add_parts(objs, puzzle)
caller.msg("%s were added to parts" % (", ".join(added)))
return
elif "delpart" in self.switches:
objs = self._get_objs()
if objs:
removed = self._remove_parts(objs, puzzle)
caller.msg("%s were removed from parts" % (", ".join(removed)))
return
elif "addresult" in self.switches:
objs = self._get_objs()
if objs:
added = self._add_results(objs, puzzle)
caller.msg("%s were added to results" % (", ".join(added)))
return
elif "delresult" in self.switches:
objs = self._get_objs()
if objs:
removed = self._remove_results(objs, puzzle)
caller.msg("%s were removed from results" % (", ".join(removed)))
return
else:
# edit attributes
if not (puzzle.access(caller, "control") or puzzle.access(caller, "edit")):
caller.msg("You don't have permission to edit %s." % puzzle_name_id)
return
if attr == "use_success_message":
puzzle.db.use_success_message = self.rhs
caller.msg(
"%s use_success_message = %s\n"
% (puzzle_name_id, puzzle.db.use_success_message)
)
return
elif attr == "use_success_location_message":
puzzle.db.use_success_location_message = self.rhs
caller.msg(
"%s use_success_location_message = %s\n"
% (puzzle_name_id, puzzle.db.use_success_location_message)
)
return
elif attr == "mask":
puzzle.db.mask = tuple(self.rhslist)
caller.msg("%s mask = %r\n" % (puzzle_name_id, puzzle.db.mask))
return
def _get_objs(self):
if not self.rhslist:
self.caller.msg(self._USAGE)
return
objs = []
for o in self.rhslist:
obj = self.caller.search(o)
if obj:
objs.append(obj)
return objs
def _add_objs_to(self, objs, to):
"""Adds propto objs to the given set (parts or results)"""
added = []
toobjs = list(to[:])
for obj in objs:
protoobj = proto_def(obj)
toobjs.append(protoobj)
added.append(obj.key)
return added, toobjs
def _remove_objs_from(self, objs, frm):
"""Removes propto objs from the given set (parts or results)"""
removed = []
fromobjs = list(frm[:])
for obj in objs:
protoobj = proto_def(obj)
if protoobj in fromobjs:
fromobjs.remove(protoobj)
removed.append(obj.key)
return removed, fromobjs
def _add_parts(self, objs, puzzle):
added, toobjs = self._add_objs_to(objs, puzzle.db.parts)
puzzle.db.parts = tuple(toobjs)
return added
def _remove_parts(self, objs, puzzle):
removed, fromobjs = self._remove_objs_from(objs, puzzle.db.parts)
puzzle.db.parts = tuple(fromobjs)
return removed
def _add_results(self, objs, puzzle):
added, toobjs = self._add_objs_to(objs, puzzle.db.results)
puzzle.db.results = tuple(toobjs)
return added
def _remove_results(self, objs, puzzle):
removed, fromobjs = self._remove_objs_from(objs, puzzle.db.results)
puzzle.db.results = tuple(fromobjs)
return removed
class CmdArmPuzzle(MuxCommand):
"""
Arms a puzzle by spawning all its parts.
Usage:
@armpuzzle <puzzle #dbref>
Notes:
Create puzzles with `@puzzle`; get list of
defined puzzles using `@lspuzzlerecipes`.
"""
key = "@armpuzzle"
locks = "cmd:perm(armpuzzle) or perm(Builder)"
help_category = "Puzzles"
def func(self):
caller = self.caller
if self.args is None or not utils.dbref(self.args):
caller.msg("A puzzle recipe's #dbref must be specified")
return
puzzle = search.search_script(self.args)
if not puzzle or not inherits_from(puzzle[0], PuzzleRecipe):
caller.msg("Invalid puzzle %r" % (self.args))
return
puzzle = puzzle[0]
caller.msg(
"Puzzle Recipe %s(%s) '%s' found.\nSpawning %d parts ..."
% (puzzle.name, puzzle.dbref, puzzle.db.puzzle_name, len(puzzle.db.parts))
)
for proto_part in puzzle.db.parts:
part = spawn(proto_part)[0]
caller.msg(
"Part %s(%s) spawned and placed at %s(%s)"
% (part.name, part.dbref, part.location, part.location.dbref)
)
part.tags.add(puzzle.db.puzzle_name, category=_PUZZLES_TAG_CATEGORY)
part.db.puzzle_name = puzzle.db.puzzle_name
caller.msg("Puzzle armed |gsuccessfully|n.")
def _lookups_parts_puzzlenames_protodefs(parts):
# Create lookup dicts by part's dbref and by puzzle_name(tags)
parts_dict = dict()
puzzlename_tags_dict = dict()
puzzle_ingredients = dict()
for part in parts:
parts_dict[part.dbref] = part
protodef = proto_def(part, with_tags=False)
# remove 'prototype_key' as it will prevent equality
del protodef["prototype_key"]
puzzle_ingredients[part.dbref] = protodef
tags_categories = part.tags.all(return_key_and_category=True)
for tag, category in tags_categories:
if category != _PUZZLES_TAG_CATEGORY:
continue
if tag not in puzzlename_tags_dict:
puzzlename_tags_dict[tag] = []
puzzlename_tags_dict[tag].append(part.dbref)
return parts_dict, puzzlename_tags_dict, puzzle_ingredients
def _puzzles_by_names(names):
# Find all puzzles by puzzle name (i.e. tag name)
puzzles = []
for puzzle_name in names:
_puzzles = search.search_script_attribute(key="puzzle_name", value=puzzle_name)
_puzzles = list(filter(lambda p: isinstance(p, PuzzleRecipe), _puzzles))
if not _puzzles:
continue
else:
puzzles.extend(_puzzles)
return puzzles
def _matching_puzzles(puzzles, puzzlename_tags_dict, puzzle_ingredients):
# Check if parts can be combined to solve a puzzle
matched_puzzles = dict()
for puzzle in puzzles:
puzzle_protoparts = list(puzzle.db.parts[:])
puzzle_mask = puzzle.db.mask[:]
# remove tags and prototype_key as they prevent equality
for i, puzzle_protopart in enumerate(puzzle_protoparts[:]):
del puzzle_protopart["tags"]
del puzzle_protopart["prototype_key"]
puzzle_protopart = maskout_protodef(puzzle_protopart, puzzle_mask)
puzzle_protoparts[i] = puzzle_protopart
matched_dbrefparts = []
parts_dbrefs = puzzlename_tags_dict[puzzle.db.puzzle_name]
for part_dbref in parts_dbrefs:
protopart = puzzle_ingredients[part_dbref]
protopart = maskout_protodef(protopart, puzzle_mask)
if protopart in puzzle_protoparts:
puzzle_protoparts.remove(protopart)
matched_dbrefparts.append(part_dbref)
else:
if len(puzzle_protoparts) == 0:
matched_puzzles[puzzle.dbref] = matched_dbrefparts
return matched_puzzles
class CmdUsePuzzleParts(MuxCommand):
"""
Use an object, or a group of objects at once.
Example:
You look around you and see a pole, a long string, and a needle.
use pole, long string, needle
Genius! You built a fishing pole.
Usage:
use <obj1> [,obj2,...]
"""
# Technical explanation
"""
Searches for all puzzles whose parts match the given set of objects. If there are matching
puzzles, the result objects are spawned in their corresponding location if all parts have been
passed in.
"""
key = "use"
aliases = "combine"
locks = "cmd:pperm(use) or pperm(Player)"
help_category = "Puzzles"
def func(self):
caller = self.caller
if not self.lhs:
caller.msg("Use what?")
return
many = "these" if len(self.lhslist) > 1 else "this"
# either all are parts, or abort finding matching puzzles
parts = []
partnames = self.lhslist[:]
for partname in partnames:
part = caller.search(
partname,
multimatch_string="Which %s. There are many.\n" % (partname),
nofound_string="There is no %s around." % (partname),
)
if not part:
return
if not part.tags.get(_PUZZLES_TAG_MEMBER, category=_PUZZLES_TAG_CATEGORY):
# not a puzzle part ... abort
caller.msg("You have no idea how %s can be used" % (many))
return
# a valid part
parts.append(part)
# Create lookup dicts by part's dbref and by puzzle_name(tags)
parts_dict, puzzlename_tags_dict, puzzle_ingredients = _lookups_parts_puzzlenames_protodefs(
parts
)
# Find all puzzles by puzzle name (i.e. tag name)
puzzles = _puzzles_by_names(puzzlename_tags_dict.keys())
logger.log_info("PUZZLES %r" % ([(p.dbref, p.db.puzzle_name) for p in puzzles]))
# Create lookup dict of puzzles by dbref
puzzles_dict = dict((puzzle.dbref, puzzle) for puzzle in puzzles)
# Check if parts can be combined to solve a puzzle
matched_puzzles = _matching_puzzles(puzzles, puzzlename_tags_dict, puzzle_ingredients)
if len(matched_puzzles) == 0:
# TODO: we could use part.fail_message instead, if there was one
# random part falls and lands on your feet
# random part hits you square on the face
caller.msg(_PUZZLE_DEFAULT_FAIL_USE_MESSAGE % (many))
return
puzzletuples = sorted(matched_puzzles.items(), key=lambda t: len(t[1]), reverse=True)
logger.log_info("MATCHED PUZZLES %r" % (puzzletuples))
# sort all matched puzzles and pick largest one(s)
puzzledbref, matched_dbrefparts = puzzletuples[0]
nparts = len(matched_dbrefparts)
puzzle = puzzles_dict[puzzledbref]
largest_puzzles = list(itertools.takewhile(lambda t: len(t[1]) == nparts, puzzletuples))
# if there are more than one, choose one at random.
# we could show the names of all those that can be resolved
# but that would give away that there are other puzzles that
# can be resolved with the same parts.
# just hint how many.
if len(largest_puzzles) > 1:
caller.msg(
"Your gears start turning and %d different ideas come to your mind ...\n"
% (len(largest_puzzles))
)
puzzletuple = choice(largest_puzzles)
puzzle = puzzles_dict[puzzletuple[0]]
caller.msg("You try %s ..." % (puzzle.db.puzzle_name))
# got one, spawn its results
result_names = []
for proto_result in puzzle.db.results:
result = spawn(proto_result)[0]
result.tags.add(puzzle.db.puzzle_name, category=_PUZZLES_TAG_CATEGORY)
result.db.puzzle_name = puzzle.db.puzzle_name
result_names.append(result.name)
# Destroy all parts used
for dbref in matched_dbrefparts:
parts_dict[dbref].delete()
result_names = ", ".join(result_names)
caller.msg(puzzle.db.use_success_message)
caller.location.msg_contents(
puzzle.db.use_success_location_message.format(caller=caller, result_names=result_names),
exclude=(caller,),
)
class CmdListPuzzleRecipes(MuxCommand):
"""
Searches for all puzzle recipes
Usage:
@lspuzzlerecipes
"""
key = "@lspuzzlerecipes"
locks = "cmd:perm(lspuzzlerecipes) or perm(Builder)"
help_category = "Puzzles"
def func(self):
caller = self.caller
recipes = search.search_script_tag(_PUZZLES_TAG_RECIPE, category=_PUZZLES_TAG_CATEGORY)
div = "-" * 60
text = [div]
msgf_recipe = "Puzzle |y'%s' %s(%s)|n"
msgf_item = "%2s|c%15s|n: |w%s|n"
for recipe in recipes:
text.append(msgf_recipe % (recipe.db.puzzle_name, recipe.name, recipe.dbref))
text.append("Success Caller message:\n" + recipe.db.use_success_message + "\n")
text.append(
"Success Location message:\n" + recipe.db.use_success_location_message + "\n"
)
text.append("Mask:\n" + str(recipe.db.mask) + "\n")
text.append("Parts")
for protopart in recipe.db.parts[:]:
mark = "-"
for k, v in protopart.items():
text.append(msgf_item % (mark, k, v))
mark = ""
text.append("Results")
for protoresult in recipe.db.results[:]:
mark = "-"
for k, v in protoresult.items():
text.append(msgf_item % (mark, k, v))
mark = ""
else:
text.append(div)
text.append("Found |r%d|n puzzle(s)." % (len(recipes)))
text.append(div)
caller.msg("\n".join(text))
class CmdListArmedPuzzles(MuxCommand):
"""
Searches for all armed puzzles
Usage:
@lsarmedpuzzles
"""
key = "@lsarmedpuzzles"
locks = "cmd:perm(lsarmedpuzzles) or perm(Builder)"
help_category = "Puzzles"
def func(self):
caller = self.caller
armed_puzzles = search.search_tag(_PUZZLES_TAG_MEMBER, category=_PUZZLES_TAG_CATEGORY)
armed_puzzles = dict(
(k, list(g)) for k, g in itertools.groupby(armed_puzzles, lambda ap: ap.db.puzzle_name)
)
div = "-" * 60
msgf_pznm = "Puzzle name: |y%s|n"
msgf_item = "|m%25s|w(%s)|n at |c%25s|w(%s)|n"
text = [div]
for pzname, items in armed_puzzles.items():
text.append(msgf_pznm % (pzname))
for item in items:
text.append(
msgf_item % (item.name, item.dbref, item.location.name, item.location.dbref)
)
else:
text.append(div)
text.append("Found |r%d|n armed puzzle(s)." % (len(armed_puzzles)))
text.append(div)
caller.msg("\n".join(text))
class PuzzleSystemCmdSet(CmdSet):
"""
CmdSet to create, arm and resolve Puzzles
"""
def at_cmdset_creation(self):
super(PuzzleSystemCmdSet, self).at_cmdset_creation()
self.add(CmdCreatePuzzleRecipe())
self.add(CmdEditPuzzle())
self.add(CmdArmPuzzle())
self.add(CmdListPuzzleRecipes())
self.add(CmdListArmedPuzzles())
self.add(CmdUsePuzzleParts())

View file

@ -0,0 +1,295 @@
"""
Unix-like Command style parent
Evennia contribution, Vincent Le Geoff 2017
This module contains a command class that allows for unix-style command syntax in-game, using
--options, positional arguments and stuff like -n 10 etc similarly to a unix command. It might not
the best syntax for the average player but can be really useful for builders when they need to have
a single command do many things with many options. It uses the ArgumentParser from Python's standard
library under the hood.
To use, inherit `UnixCommand` from this module from your own commands. You need
to override two methods:
- The `init_parser` method, which adds options to the parser. Note that you should normally
*not* override the normal `parse` method when inheriting from `UnixCommand`.
- The `func` method, called to execute the command once parsed (like any Command).
Here's a short example:
```python
class CmdPlant(UnixCommand):
'''
Plant a tree or plant.
This command is used to plant something in the room you are in.
Examples:
plant orange -a 8
plant strawberry --hidden
plant potato --hidden --age 5
'''
key = "plant"
def init_parser(self):
"Add the arguments to the parser."
# 'self.parser' inherits `argparse.ArgumentParser`
self.parser.add_argument("key",
help="the key of the plant to be planted here")
self.parser.add_argument("-a", "--age", type=int,
default=1, help="the age of the plant to be planted")
self.parser.add_argument("--hidden", action="store_true",
help="should the newly-planted plant be hidden to players?")
def func(self):
"func is called only if the parser succeeded."
# 'self.opts' contains the parsed options
key = self.opts.key
age = self.opts.age
hidden = self.opts.hidden
self.msg("Going to plant '{}', age={}, hidden={}.".format(
key, age, hidden))
```
To see the full power of argparse and the types of supported options, visit
[the documentation of argparse](https://docs.python.org/2/library/argparse.html).
"""
import argparse
import shlex
from textwrap import dedent
from evennia import Command, InterruptCommand
from evennia.utils.ansi import raw
class ParseError(Exception):
"""An error occurred during parsing."""
pass
class UnixCommandParser(argparse.ArgumentParser):
"""A modifier command parser for unix commands.
This parser is used to replace `argparse.ArgumentParser`. It
is aware of the command calling it, and can more easily report to
the caller. Some features (like the "brutal exit" of the original
parser) are disabled or replaced. This parser is used by UnixCommand
and creating one directly isn't recommended nor necessary. Even
adding a sub-command will use this replaced parser automatically.
"""
def __init__(self, prog, description="", epilog="", command=None, **kwargs):
"""
Build a UnixCommandParser with a link to the command using it.
Args:
prog (str): the program name (usually the command key).
description (str): a very brief line to show in the usage text.
epilog (str): the epilog to show below options.
command (Command): the command calling the parser.
Keyword Args:
Additional keyword arguments are directly sent to
`argparse.ArgumentParser`. You will find them on the
[parser's documentation](https://docs.python.org/2/library/argparse.html).
Note:
It's doubtful you would need to create this parser manually.
The `UnixCommand` does that automatically. If you create
sub-commands, this class will be used.
"""
prog = prog or command.key
super().__init__(
prog=prog, description=description, conflict_handler="resolve", add_help=False, **kwargs
)
self.command = command
self.post_help = epilog
def n_exit(code=None, msg=None):
raise ParseError(msg)
self.exit = n_exit
# Replace the -h/--help
self.add_argument(
"-h", "--help", nargs=0, action=HelpAction, help="display the command help"
)
def format_usage(self):
"""Return the usage line.
Note:
This method is present to return the raw-escaped usage line,
in order to avoid unintentional color codes.
"""
return raw(super().format_usage())
def format_help(self):
"""Return the parser help, including its epilog.
Note:
This method is present to return the raw-escaped help,
in order to avoid unintentional color codes. Color codes
in the epilog (the command docstring) are supported.
"""
autohelp = raw(super().format_help())
return "\n" + autohelp + "\n" + self.post_help
def print_usage(self, file=None):
"""Print the usage to the caller.
Args:
file (file-object): not used here, the caller is used.
Note:
This method will override `argparse.ArgumentParser`'s in order
to not display the help on stdout or stderr, but to the
command's caller.
"""
if self.command:
self.command.msg(self.format_usage().strip())
def print_help(self, file=None):
"""Print the help to the caller.
Args:
file (file-object): not used here, the caller is used.
Note:
This method will override `argparse.ArgumentParser`'s in order
to not display the help on stdout or stderr, but to the
command's caller.
"""
if self.command:
self.command.msg(self.format_help().strip())
class HelpAction(argparse.Action):
"""Override the -h/--help action in the default parser.
Using the default -h/--help will call the exit function in different
ways, preventing the entire help message to be provided. Hence
this override.
"""
def __call__(self, parser, namespace, values, option_string=None):
"""If asked for help, display to the caller."""
if parser.command:
parser.command.msg(parser.format_help().strip())
parser.exit(0, "")
class UnixCommand(Command):
"""
Unix-type commands, supporting short and long options.
This command syntax uses the Unix-style commands with short options
(-X) and long options (--something). The `argparse` module is
used to parse the command.
In order to use it, you should override two methods:
- `init_parser`: this method is called when the command is created.
It can be used to set options in the parser. `self.parser`
contains the `argparse.ArgumentParser`, so you can add arguments
here.
- `func`: this method is called to execute the command, but after
the parser has checked the arguments given to it are valid.
You can access the namespace of valid arguments in `self.opts`
at this point.
The help of UnixCommands is derived from the docstring, in a
slightly different way than usual: the first line of the docstring
is used to represent the program description (the very short
line at the top of the help message). The other lines below are
used as the program's "epilog", displayed below the options. It
means in your docstring, you don't have to write the options.
They will be automatically provided by the parser and displayed
accordingly. The `argparse` module provides a default '-h' or
'--help' option on the command. Typing |whelp commandname|n will
display the same as |wcommandname -h|n, though this behavior can
be changed.
"""
def __init__(self, **kwargs):
"""
The lockhandler works the same as for objects.
optional kwargs will be set as properties on the Command at runtime,
overloading evential same-named class properties.
"""
super().__init__(**kwargs)
# Create the empty UnixCommandParser, inheriting argparse.ArgumentParser
lines = dedent(self.__doc__.strip("\n")).splitlines()
description = lines[0].strip()
epilog = "\n".join(lines[1:]).strip()
self.parser = UnixCommandParser(None, description, epilog, command=self)
# Fill the argument parser
self.init_parser()
def init_parser(self):
"""
Configure the argument parser, adding in options.
Note:
This method is to be overridden in order to add options
to the argument parser. Use `self.parser`, which contains
the `argparse.ArgumentParser`. You can, for instance,
use its `add_argument` method.
"""
pass
def func(self):
"""Override to handle the command execution."""
pass
def get_help(self, caller, cmdset):
"""
Return the help message for this command and this caller.
Args:
caller (Object or Player): the caller asking for help on the command.
cmdset (CmdSet): the command set (if you need additional commands).
Returns:
docstring (str): the help text to provide the caller for this command.
"""
return self.parser.format_help()
def parse(self):
"""
Process arguments provided in `self.args`.
Note:
You should not override this method. Consider overriding
`init_parser` instead.
"""
try:
self.opts = self.parser.parse_args(shlex.split(self.args))
except ParseError as err:
msg = str(err)
if msg:
self.msg(msg)
raise InterruptCommand