Start refactor contrib folder
This commit is contained in:
parent
7f0d314e7f
commit
f5f75bd04d
107 changed files with 34 additions and 2 deletions
3
evennia/contrib/base_systems/README.md
Normal file
3
evennia/contrib/base_systems/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Base-system contribs
|
||||
|
||||
Server/admin-related functionality changes and systems.
|
||||
227
evennia/contrib/base_systems/awsstorage/README.md
Normal file
227
evennia/contrib/base_systems/awsstorage/README.md
Normal 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.
|
||||
3
evennia/contrib/base_systems/awsstorage/__init__.py
Normal file
3
evennia/contrib/base_systems/awsstorage/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Intended to be a collecting folder for Django-specific contribs that do not have observable effects to players.
|
||||
"""
|
||||
863
evennia/contrib/base_systems/awsstorage/aws_s3_cdn.py
Normal file
863
evennia/contrib/base_systems/awsstorage/aws_s3_cdn.py
Normal 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)
|
||||
608
evennia/contrib/base_systems/awsstorage/tests.py
Normal file
608
evennia/contrib/base_systems/awsstorage/tests.py
Normal 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")
|
||||
1267
evennia/contrib/base_systems/building_menu.py
Normal file
1267
evennia/contrib/base_systems/building_menu.py
Normal file
File diff suppressed because it is too large
Load diff
231
evennia/contrib/base_systems/color_markups.py
Normal file
231
evennia/contrib/base_systems/color_markups.py
Normal 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
|
||||
]
|
||||
330
evennia/contrib/base_systems/custom_gametime.py
Normal file
330
evennia/contrib/base_systems/custom_gametime.py
Normal 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)
|
||||
363
evennia/contrib/base_systems/email_login.py
Normal file
363
evennia/contrib/base_systems/email_login.py
Normal 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())
|
||||
869
evennia/contrib/base_systems/ingame_python/README.md
Normal file
869
evennia/contrib/base_systems/ingame_python/README.md
Normal 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.
|
||||
0
evennia/contrib/base_systems/ingame_python/__init__.py
Normal file
0
evennia/contrib/base_systems/ingame_python/__init__.py
Normal file
225
evennia/contrib/base_systems/ingame_python/callbackhandler.py
Normal file
225
evennia/contrib/base_systems/ingame_python/callbackhandler.py
Normal 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",
|
||||
),
|
||||
)
|
||||
583
evennia/contrib/base_systems/ingame_python/commands.py
Normal file
583
evennia/contrib/base_systems/ingame_python/commands.py
Normal 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.")
|
||||
91
evennia/contrib/base_systems/ingame_python/eventfuncs.py
Normal file
91
evennia/contrib/base_systems/ingame_python/eventfuncs.py
Normal 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)
|
||||
669
evennia/contrib/base_systems/ingame_python/scripts.py
Normal file
669
evennia/contrib/base_systems/ingame_python/scripts.py
Normal 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)
|
||||
543
evennia/contrib/base_systems/ingame_python/tests.py
Normal file
543
evennia/contrib/base_systems/ingame_python/tests.py
Normal 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
|
||||
919
evennia/contrib/base_systems/ingame_python/typeclasses.py
Normal file
919
evennia/contrib/base_systems/ingame_python/typeclasses.py
Normal 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
|
||||
262
evennia/contrib/base_systems/ingame_python/utils.py
Normal file
262
evennia/contrib/base_systems/ingame_python/utils.py
Normal 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
|
||||
254
evennia/contrib/base_systems/menu_login.py
Normal file
254
evennia/contrib/base_systems/menu_login.py
Normal 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,
|
||||
)
|
||||
545
evennia/contrib/base_systems/mux_comms_cmds.py
Normal file
545
evennia/contrib/base_systems/mux_comms_cmds.py
Normal 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())
|
||||
814
evennia/contrib/base_systems/puzzles.py
Normal file
814
evennia/contrib/base_systems/puzzles.py
Normal 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())
|
||||
295
evennia/contrib/base_systems/unixcommand.py
Normal file
295
evennia/contrib/base_systems/unixcommand.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue