Merge pull request #2 from evennia/master

Merge Evennia changes
This commit is contained in:
Kenneth Aalberg 2020-03-26 09:55:08 +01:00 committed by GitHub
commit 8ce6abaa6f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
107 changed files with 2222 additions and 1218 deletions

View file

@ -8,6 +8,7 @@ services:
python:
- "3.7"
- "3.8"
env:
- TESTING_DB=sqlite3

View file

@ -39,25 +39,25 @@ SERVERNAME = "testing_mygame"
# Testing database types
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'evennia',
'USER': 'evennia',
'PASSWORD': 'password',
'HOST': 'localhost',
'PORT': '', # use default port
'OPTIONS': {
'charset': 'utf8mb4',
'init_command': 'set collation_connection=utf8mb4_unicode_ci'
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": "evennia",
"USER": "evennia",
"PASSWORD": "password",
"HOST": "localhost",
"PORT": "", # use default port
"OPTIONS": {
"charset": "utf8mb4",
"init_command": "set collation_connection=utf8mb4_unicode_ci",
},
'TEST': {
'NAME': 'default',
'OPTIONS': {
'charset': 'utf8mb4',
"TEST": {
"NAME": "default",
"OPTIONS": {
"charset": "utf8mb4",
# 'init_command': 'set collation_connection=utf8mb4_unicode_ci'
'init_command': "SET NAMES 'utf8mb4'"
}
}
"init_command": "SET NAMES 'utf8mb4'",
},
},
}
}

View file

@ -39,16 +39,14 @@ SERVERNAME = "testing_mygame"
# Testing database types
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': 'evennia',
'USER': 'evennia',
'PASSWORD': 'password',
'HOST': 'localhost',
'PORT': '', # use default
'TEST': {
'NAME': 'default'
}
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "evennia",
"USER": "evennia",
"PASSWORD": "password",
"HOST": "localhost",
"PORT": "", # use default
"TEST": {"NAME": "default"},
}
}

View file

@ -6,7 +6,7 @@
defaults to True for backwards-compatibility in 0.9, will be False in 1.0
### Already in master
- `is_typeclass(obj (Object), exact (bool))` now defaults to exact=False
- `py` command now reroutes stdout to output results in-game client. `py`
without arguments starts a full interactive Python console.
- Webclient default to a single input pane instead of two. Now defaults to no help-popup.
@ -27,13 +27,33 @@ without arguments starts a full interactive Python console.
- `AttributeHandler.get(return_list=True)` will return `[]` if there are no
Attributes instead of `[None]`.
- Remove `pillow` requirement (install especially if using imagefield)
- Add Simplified Korean translation (user aceamro)
- Add Simplified Korean translation (aceamro)
- Show warning on `start -l` if settings contains values unsafe for production.
- Make code auto-formatted with Black.
- Make default `set` command able to edit nested structures (PR by Aaron McMillan)
- Allow running Evennia test suite from core repo with `make test`.
- Return `store_key` from `TickerHandler.add` and add `store_key` as a kwarg to
the `TickerHandler.remove` method. This makes it easier to manage tickers.
- Return `store_key` from `TickerHandler.add` and add `store_key` as a kwarg to
the `TickerHandler.remove` method. This makes it easier to manage tickers.
- EvMore `text` argument can now also be a list - each entry in the list is run
through str(eval()) and ends up on its own line. Good for paginated object lists.
- EvMore auto-justify now defaults to False since this works better with all types
of texts (such as tables). New `justify` bool. Old `justify_kwargs` remains
but is now only used to pass extra kwargs into the justify function.
- Improve performance of `find` and `objects` commands on large data sets (strikaco)
- New `CHANNEL_HANDLER_CLASS` setting allows for replacing the ChannelHandler entirely.
- Made `py` interactive mode support regular quit() and more verbose.
- Made `Account.options.get` accept `default=None` kwarg to mimic other uses of get. Set
the new `raise_exception` boolean if ranting to raise KeyError on a missing key.
- Moved behavior of unmodified `Command` and `MuxCommand` `.func()` to new
`.get_command_info()` method for easier overloading and access. (Volund)
- Removed unused `CYCLE_LOGFILES` setting. Added `SERVER_LOG_DAY_ROTATION`
and `SERVER_LOG_MAX_SIZE` (and equivalent for PORTAL) to control log rotation.
- Addded `inside_rec` lockfunc - if room is locked, the normal `inside()` lockfunc will
fail e.g. for your inventory objs (since their loc is you), whereas this will pass.
- RPSystem contrib's CmdRecog will now list all recogs if no arg is given. Also multiple
bugfixes.
- Remove `dummy@example.com` as a default account email when unset, a string is no longer
required by Django.
## Evennia 0.9 (2018-2019)

View file

@ -1,137 +1,5 @@
# Evennia installation
The latest and more detailed installation instructions can be found
[here](https://github.com/evennia/evennia/wiki/Getting-Started).
## Installing Python
First install [Python](https://www.python.org/). Linux users should
have it in their repositories, Windows/Mac users can get it from the
Python homepage. You need the 2.7.x version (Python 3 is not yet
supported). Windows users, make sure to select the option to make
Python available in your path - this is so you can call it everywhere
as `python`. Python 2.7.9 and later also includes the
[pip](https://pypi.python.org/pypi/pip/) installer out of the box,
otherwise install this separately (in linux it's usually found as the
`python-pip` package).
### installing virtualenv
This step is optional, but *highly* recommended. For installing
up-to-date Python packages we recommend using
[virtualenv](https://pypi.python.org/pypi/virtualenv), this makes it
easy to keep your Python packages up-to-date without interfering with
the defaults for your system.
```
pip install virtualenv
```
Go to the place where you want to make your virtual python library
storage. This does not need to be near where you plan to install
Evennia. Then do
```
virtualenv vienv
```
A new folder `vienv` will be created (you could also name it something
else if you prefer). Activate the virtual environment like this:
```
# for Linux/Unix/Mac:
source vienv/bin/activate
# for Windows:
vienv\Scripts\activate.bat
```
You should see `(vienv)` next to your prompt to show you the
environment is active. You need to activate it whenever you open a new
terminal, but you *don't* have to be inside the `vienv` folder henceforth.
## Get the developer's version of Evennia
This is currently the only Evennia version available. First download
and install [Git](http://git-scm.com/) from the homepage or via the
package manager in Linux. Next, go to the place where you want the
`evennia` folder to be created and run
```
git clone https://github.com/evennia/evennia.git
```
If you have a github account and have [set up SSH
keys](https://help.github.com/articles/generating-ssh-keys/), you want
to use this instead:
```
git clone git@github.com:evennia/evennia.git
```
In the future you just enter the new `evennia` folder and do
```
git pull
```
to get the latest Evennia updates.
## Evennia package install
Stand at the root of your new `evennia` directory and run
```
pip install -e .
```
(note the period "." at the end, this tells pip to install from the
current directory). This will install Evennia and all its dependencies
(into your virtualenv if you are using that) and make the `evennia`
command available on the command line. You can find Evennia's
dependencies in `evennia/requirements.txt`.
## Creating your game project
To create your new game you need to initialize a new game project.
This should be done somewhere *outside* of your `evennia` folder.
```
evennia --init mygame
```
This will create a new game project named "mygame" in a folder of the
same name. If you want to change the settings for your project, you
will need to edit `mygame/server/conf/settings.py`.
## Starting Evennia
Enter your new game directory and run
```
evennia migrate
evennia start
```
Follow the instructions to create your superuser account. A lot of
information will scroll past as the database is created and the server
initializes. After this Evennia will be running. Use
```
evennia -h
```
for help with starting, stopping and other operations.
Start up your MUD client of choice and point it to your server and
port *4000*. If you are just running locally the server name is
*localhost*.
Alternatively, you can find the web interface and webclient by
pointing your web browser to *http://localhost:4001*.
Finally, login with the superuser account and password you provided
earlier. Welcome to Evennia!
You can find the latest updated installation instructions and
requirements [here](https://github.com/evennia/evennia/wiki/Getting-Started).

View file

@ -23,7 +23,7 @@ USE_COLOR = True
FAKE_MODE = False
# if these words are longer than output word, retain given case
CASE_WORD_EXCEPTIONS = ('an', )
CASE_WORD_EXCEPTIONS = ("an",)
_HELP_TEXT = """This program interactively renames words in all files of your project. It's
currently renaming {sources} to {targets}.
@ -80,6 +80,7 @@ def _case_sensitive_replace(string, old, new):
`old` has been replaced with `new`, retaining case.
"""
def repl(match):
current = match.group()
# treat multi-word sentences word-by-word
@ -99,15 +100,21 @@ def _case_sensitive_replace(string, old, new):
all_upper = False
# special cases - keep remaing case)
if new_word.lower() in CASE_WORD_EXCEPTIONS:
result.append(new_word[ind + 1:])
result.append(new_word[ind + 1 :])
# append any remaining characters from new
elif all_upper:
result.append(new_word[ind + 1:].upper())
result.append(new_word[ind + 1 :].upper())
else:
result.append(new_word[ind + 1:].lower())
result.append(new_word[ind + 1 :].lower())
out.append("".join(result))
# if we have more new words than old ones, just add them verbatim
out.extend([new_word for ind, new_word in enumerate(new_words) if ind >= len(old_words)])
out.extend(
[
new_word
for ind, new_word in enumerate(new_words)
if ind >= len(old_words)
]
)
return " ".join(out)
regex = re.compile(re.escape(old), re.I)
@ -147,7 +154,9 @@ def rename_in_tree(path, in_list, out_list, excl_list, fileend_list, is_interact
print("%s skipped (excluded)." % full_path)
continue
if not fileend_list or any(file.endswith(ending) for ending in fileend_list):
if not fileend_list or any(
file.endswith(ending) for ending in fileend_list
):
rename_in_file(full_path, in_list, out_list, is_interactive)
# rename file - always ask
@ -155,8 +164,10 @@ def rename_in_tree(path, in_list, out_list, excl_list, fileend_list, is_interact
for src, dst in repl_mapping:
new_file = _case_sensitive_replace(new_file, src, dst)
if new_file != file:
inp = input(_green("Rename %s\n -> %s\n Y/[N]? > " % (file, new_file)))
if inp.upper() == 'Y':
inp = input(
_green("Rename %s\n -> %s\n Y/[N]? > " % (file, new_file))
)
if inp.upper() == "Y":
new_full_path = os.path.join(root, new_file)
try:
os.rename(full_path, new_full_path)
@ -171,8 +182,10 @@ def rename_in_tree(path, in_list, out_list, excl_list, fileend_list, is_interact
for src, dst in repl_mapping:
new_root = _case_sensitive_replace(new_root, src, dst)
if new_root != root:
inp = input(_green("Dir Rename %s\n -> %s\n Y/[N]? > " % (root, new_root)))
if inp.upper() == 'Y':
inp = input(
_green("Dir Rename %s\n -> %s\n Y/[N]? > " % (root, new_root))
)
if inp.upper() == "Y":
try:
os.rename(root, new_root)
except OSError as err:
@ -201,7 +214,7 @@ def rename_in_file(path, in_list, out_list, is_interactive):
print("%s is a directory. You should use the --recursive option." % path)
sys.exit()
with open(path, 'r') as fil:
with open(path, "r") as fil:
org_text = fil.read()
repl_mapping = list(zip(in_list, out_list))
@ -215,7 +228,7 @@ def rename_in_file(path, in_list, out_list, is_interactive):
if FAKE_MODE:
print(" ... Saved changes to %s. (faked)" % path)
else:
with open(path, 'w') as fil:
with open(path, "w") as fil:
fil.write(new_text)
print(" ... Saved changes to %s." % path)
else:
@ -239,18 +252,24 @@ def rename_in_file(path, in_list, out_list, is_interactive):
while True:
for iline, renamed_line in sorted(list(renamed.items()), key=lambda tup: tup[0]):
for iline, renamed_line in sorted(
list(renamed.items()), key=lambda tup: tup[0]
):
print("%3i orig: %s" % (iline + 1, org_lines[iline]))
print(" new : %s" % (_yellow(renamed_line)))
print(_green("%s (%i lines changed)" % (path, len(renamed))))
ret = input(_green("Choose: "
"[q]uit, "
"[h]elp, "
"[s]kip file, "
"[i]gnore lines, "
"[c]lear ignores, "
"[a]ccept/save file: ".lower()))
ret = input(
_green(
"Choose: "
"[q]uit, "
"[h]elp, "
"[s]kip file, "
"[i]gnore lines, "
"[c]lear ignores, "
"[a]ccept/save file: ".lower()
)
)
if ret == "s":
# skip file entirely
@ -267,7 +286,7 @@ def rename_in_file(path, in_list, out_list, is_interactive):
if FAKE_MODE:
print(" ... Saved file %s (faked)" % path)
return
with open(path, 'w') as fil:
with open(path, "w") as fil:
fil.writelines("\n".join(org_lines))
print(" ... Saved file %s" % path)
return
@ -278,7 +297,11 @@ def rename_in_file(path, in_list, out_list, is_interactive):
input(_HELP_TEXT.format(sources=in_list, targets=out_list))
elif ret.startswith("i"):
# ignore one or more lines
ignores = [int(ind) - 1 for ind in ret[1:].split(',') if ind.strip().isdigit()]
ignores = [
int(ind) - 1
for ind in ret[1:].split(",")
if ind.strip().isdigit()
]
if not ignores:
input("Ignore example: i 2,7,34,133\n (return to continue)")
continue
@ -291,36 +314,57 @@ if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="Rename text in a source tree, or a single file")
description="Rename text in a source tree, or a single file"
)
parser.add_argument('-i', '--input', action='append',
help="Source word to rename (quote around multiple words)")
parser.add_argument('-o', '--output', action='append',
help="Word to rename a matching src-word to")
parser.add_argument('-x', '--exc', action='append',
help="File path patterns to exclude")
parser.add_argument('-a', '--auto', action='store_true',
help="Automatic mode, don't ask to rename")
parser.add_argument('-r', '--recursive', action='store_true',
help="Recurse subdirs")
parser.add_argument('-f', '--fileending', action='append',
help="Change which file endings to allow (default .py and .html)")
parser.add_argument('--nocolor', action='store_true',
help="Turn off in-program color")
parser.add_argument('--fake', action='store_true',
help="Simulate run but don't actually save")
parser.add_argument('path',
help="File or directory in which to rename text")
parser.add_argument(
"-i",
"--input",
action="append",
help="Source word to rename (quote around multiple words)",
)
parser.add_argument(
"-o", "--output", action="append", help="Word to rename a matching src-word to"
)
parser.add_argument(
"-x", "--exc", action="append", help="File path patterns to exclude"
)
parser.add_argument(
"-a", "--auto", action="store_true", help="Automatic mode, don't ask to rename"
)
parser.add_argument(
"-r", "--recursive", action="store_true", help="Recurse subdirs"
)
parser.add_argument(
"-f",
"--fileending",
action="append",
help="Change which file endings to allow (default .py and .html)",
)
parser.add_argument(
"--nocolor", action="store_true", help="Turn off in-program color"
)
parser.add_argument(
"--fake", action="store_true", help="Simulate run but don't actually save"
)
parser.add_argument("path", help="File or directory in which to rename text")
args = parser.parse_args()
in_list, out_list, exc_list, fileend_list = args.input, args.output, args.exc, args.fileending
in_list, out_list, exc_list, fileend_list = (
args.input,
args.output,
args.exc,
args.fileending,
)
if not (in_list and out_list):
print('At least one source- and destination word must be given.')
print("At least one source- and destination word must be given.")
sys.exit()
if len(in_list) != len(out_list):
print('Number of sources must be identical to the number of destination arguments.')
print(
"Number of sources must be identical to the number of destination arguments."
)
sys.exit()
exc_list = exc_list or []
@ -332,6 +376,8 @@ if __name__ == "__main__":
FAKE_MODE = args.fake
if is_recursive:
rename_in_tree(args.path, in_list, out_list, exc_list, fileend_list, is_interactive)
rename_in_tree(
args.path, in_list, out_list, exc_list, fileend_list, is_interactive
)
else:
rename_in_file(args.path, in_list, out_list, is_interactive)

View file

@ -14,4 +14,5 @@ sys.path.insert(0, os.path.abspath(os.getcwd()))
sys.path.insert(0, os.path.join(sys.prefix, "Lib", "site-packages"))
from evennia.server.evennia_launcher import main
main()

View file

@ -231,6 +231,7 @@ def _init():
from . import contrib
from .utils.evmenu import EvMenu
from .utils.evtable import EvTable
from .utils.evmore import EvMore
from .utils.evform import EvForm
from .utils.eveditor import EvEditor
from .utils.ansi import ANSIString

View file

@ -37,7 +37,7 @@ from evennia.scripts.scripthandler import ScriptHandler
from evennia.commands.cmdsethandler import CmdSetHandler
from evennia.utils.optionhandler import OptionHandler
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from random import getrandbits
__all__ = ("DefaultAccount",)
@ -51,8 +51,12 @@ _CMDSET_ACCOUNT = settings.CMDSET_ACCOUNT
_MUDINFO_CHANNEL = None
# Create throttles for too many account-creations and login attempts
CREATION_THROTTLE = Throttle(limit=2, timeout=10 * 60)
LOGIN_THROTTLE = Throttle(limit=5, timeout=5 * 60)
CREATION_THROTTLE = Throttle(
limit=settings.CREATION_THROTTLE_LIMIT, timeout=settings.CREATION_THROTTLE_TIMEOUT
)
LOGIN_THROTTLE = Throttle(
limit=settings.LOGIN_THROTTLE_LIMIT, timeout=settings.LOGIN_THROTTLE_TIMEOUT
)
class AccountSessionHandler(object):
@ -216,12 +220,16 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
@property
def characters(self):
# Get playable characters list
objs = self.db._playable_characters
objs = self.db._playable_characters or []
# Rebuild the list if legacy code left null values after deletion
if None in objs:
objs = [x for x in self.db._playable_characters if x]
self.db._playable_characters = objs
try:
if None in objs:
objs = [x for x in self.db._playable_characters if x]
self.db._playable_characters = objs
except Exception as e:
logger.log_trace(e)
logger.log_err(e)
return objs
@ -820,7 +828,10 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
server.
Args:
text (str, optional): text data to send
text (str or tuple, optional): The message to send. This
is treated internally like any send-command, so its
value can be a tuple if sending multiple arguments to
the `text` oob command.
from_obj (Object or Account or list, optional): Object sending. If given, its
at_msg_send() hook will be called. If iterable, call on all entities.
session (Session or list, optional): Session object or a list of
@ -851,7 +862,13 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
kwargs["options"] = options
if text is not None:
kwargs["text"] = to_str(text)
if not (isinstance(text, str) or isinstance(text, tuple)):
# sanitize text before sending across the wire
try:
text = to_str(text)
except Exception:
text = repr(text)
kwargs["text"] = text
# session relay
sessions = make_iter(session) if session else self.sessions.all()

View file

@ -105,9 +105,9 @@ class AccountDB(TypedObject, AbstractUser):
objects = AccountDBManager()
# defaults
__settingsclasspath__ = settings.BASE_SCRIPT_TYPECLASS
__defaultclasspath__ = "evennia.accounts.accounts.DefaultAccount"
__applabel__ = "accounts"
__settingsclasspath__ = settings.BASE_SCRIPT_TYPECLASS
class Meta(object):
verbose_name = "Account"

View file

@ -48,7 +48,7 @@ from evennia.comms.channelhandler import CHANNELHANDLER
from evennia.utils import logger, utils
from evennia.utils.utils import string_suggestions
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
_IN_GAME_ERRORS = settings.IN_GAME_ERRORS
@ -733,7 +733,7 @@ def cmdhandler(
if len(matches) == 1:
# We have a unique command match. But it may still be invalid.
match = matches[0]
cmdname, args, cmd, raw_cmdname = match[0], match[1], match[2], match[5]
cmdname, args, cmd, raw_cmdname = (match[0], match[1], match[2], match[5])
if not matches:
# No commands match our entered command

View file

@ -125,7 +125,7 @@ def try_num_prefixes(raw_string):
# the user might be trying to identify the command
# with a #num-command style syntax. We expect the regex to
# contain the groups "number" and "name".
mindex, new_raw_string = num_ref_match.group("number"), num_ref_match.group("name")
mindex, new_raw_string = (num_ref_match.group("number"), num_ref_match.group("name"))
return mindex, new_raw_string
else:
return None, None

View file

@ -27,7 +27,7 @@ Set theory.
"""
from weakref import WeakKeyDictionary
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from evennia.utils.utils import inherits_from, is_iter
__all__ = ("CmdSet",)

View file

@ -72,7 +72,7 @@ from evennia.utils import logger, utils
from evennia.commands.cmdset import CmdSet
from evennia.server.models import ServerConfig
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
__all__ = ("import_cmdset", "CmdSetHandler")

View file

@ -6,6 +6,7 @@ All commands in Evennia inherit from the 'Command' class in this module.
"""
import re
import math
import inspect
from django.conf import settings
@ -74,6 +75,13 @@ def _init_command(cls, **kwargs):
cls.is_exit = False
if not hasattr(cls, "help_category"):
cls.help_category = "general"
# make sure to pick up the parent's docstring if the child class is
# missing one (important for auto-help)
if cls.__doc__ is None:
for parent_class in inspect.getmro(cls):
if parent_class.__doc__ is not None:
cls.__doc__ = parent_class.__doc__
break
cls.help_category = cls.help_category.lower()
@ -401,12 +409,11 @@ class Command(object, metaclass=CommandMeta):
"""
pass
def func(self):
def get_command_info(self):
"""
This is the actual executing part of the command. It is
called directly after self.parse(). See the docstring of this
module for which object properties are available (beyond those
set in self.parse())
This is the default output of func() if no func() overload is done.
Provided here as a separate method so that it can be called for debugging
purposes when making commands.
"""
variables = "\n".join(
@ -416,11 +423,8 @@ class Command(object, metaclass=CommandMeta):
Command {self} has no defined `func()` - showing on-command variables:
{variables}
"""
self.caller.msg(string)
return
# a simple test command to show the available properties
string = "-" * 50
string += "-" * 50
string += "\n|w%s|n - Command variables from evennia:\n" % self.key
string += "-" * 50
string += "\nname of cmd (self.key): |w%s|n\n" % self.key
@ -438,6 +442,16 @@ Command {self} has no defined `func()` - showing on-command variables:
self.caller.msg(string)
def func(self):
"""
This is the actual executing part of the command. It is
called directly after self.parse(). See the docstring of this
module for which object properties are available (beyond those
set in self.parse())
"""
self.get_command_info()
def get_extra_info(self, caller, **kwargs):
"""
Display some extra information that may help distinguish this
@ -484,12 +498,14 @@ Command {self} has no defined `func()` - showing on-command variables:
Get the client screenwidth for the session using this command.
Returns:
client width (int or None): The width (in characters) of the client window. None
if this command is run without a Session (such as by an NPC).
client width (int): The width (in characters) of the client window.
"""
if self.session:
return self.session.protocol_flags["SCREENWIDTH"][0]
return self.session.protocol_flags.get(
"SCREENWIDTH", {0: settings.CLIENT_DEFAULT_WIDTH}
)[0]
return settings.CLIENT_DEFAULT_WIDTH
def styled_table(self, *args, **kwargs):
"""

View file

@ -3,7 +3,7 @@ Building and world design commands
"""
import re
from django.conf import settings
from django.db.models import Q
from django.db.models import Q, Min, Max
from evennia.objects.models import ObjectDB
from evennia.locks.lockhandler import LockException
from evennia.commands.cmdhandler import get_and_merge_cmdsets
@ -13,11 +13,13 @@ from evennia.utils.utils import (
class_from_module,
get_all_typeclasses,
variable_from_module,
dbref,
)
from evennia.utils.eveditor import EvEditor
from evennia.utils.evmore import EvMore
from evennia.prototypes import spawner, prototypes as protlib, menus as olc_menus
from evennia.utils.ansi import raw
from evennia.prototypes.menus import _format_diff_text_and_options
COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
@ -1911,8 +1913,8 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS):
Usage:
typeclass[/switch] <object> [= typeclass.path]
type ''
parent ''
typeclass/prototype <object> = prototype_key
typeclass/list/show [typeclass.path]
swap - this is a shorthand for using /force/reset flags.
update - this is a shorthand for using the /force/reload flag.
@ -1929,9 +1931,12 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS):
list - show available typeclasses. Only typeclasses in modules actually
imported or used from somewhere in the code will show up here
(those typeclasses are still available if you know the path)
prototype - clean and overwrite the object with the specified
prototype key - effectively making a whole new object.
Example:
type button = examples.red_button.RedButton
type/prototype button=a red button
If the typeclass_path is not given, the current object's typeclass is
assumed.
@ -1953,7 +1958,7 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS):
key = "typeclass"
aliases = ["type", "parent", "swap", "update"]
switch_options = ("show", "examine", "update", "reset", "force", "list")
switch_options = ("show", "examine", "update", "reset", "force", "list", "prototype")
locks = "cmd:perm(typeclass) or perm(Builder)"
help_category = "Building"
@ -2037,6 +2042,27 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS):
new_typeclass = self.rhs or obj.path
prototype = None
if "prototype" in self.switches:
key = self.rhs
prototype = protlib.search_prototype(key=key)
if len(prototype) > 1:
caller.msg(
"More than one match for {}:\n{}".format(
key, "\n".join(proto.get("prototype_key", "") for proto in prototype)
)
)
return
elif prototype:
# one match
prototype = prototype[0]
else:
# no match
caller.msg("No prototype '{}' was found.".format(key))
return
new_typeclass = prototype["typeclass"]
self.switches.append("force")
if "show" in self.switches or "examine" in self.switches:
string = "%s's current typeclass is %s." % (obj.name, obj.__class__)
caller.msg(string)
@ -2069,11 +2095,34 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS):
hooks = "at_object_creation" if update else "all"
old_typeclass_path = obj.typeclass_path
# special prompt for the user in cases where we want
# to confirm changes.
if "prototype" in self.switches:
diff, _ = spawner.prototype_diff_from_object(prototype, obj)
txt, options = _format_diff_text_and_options(diff, objects=[obj])
prompt = (
"Applying prototype '%s' over '%s' will cause the follow changes:\n%s\n"
% (prototype["key"], obj.name, "\n".join(txt))
)
if not reset:
prompt += "\n|yWARNING:|n Use the /reset switch to apply the prototype over a blank state."
prompt += "\nAre you sure you want to apply these changes [yes]/no?"
answer = yield (prompt)
if answer and answer in ("no", "n"):
caller.msg("Canceled: No changes were applied.")
return
# we let this raise exception if needed
obj.swap_typeclass(
new_typeclass, clean_attributes=reset, clean_cmdsets=reset, run_start_hooks=hooks
)
if "prototype" in self.switches:
modified = spawner.batch_update_objects_with_prototype(prototype, objects=[obj])
prototype_success = modified > 0
if not prototype_success:
caller.msg("Prototype %s failed to apply." % prototype["key"])
if is_same:
string = "%s updated its existing typeclass (%s).\n" % (obj.name, obj.path)
else:
@ -2090,6 +2139,11 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS):
string += " All old attributes where deleted before the swap."
else:
string += " Attributes set before swap were not removed."
if "prototype" in self.switches and prototype_success:
string += (
" Prototype '%s' was successfully applied over the object type."
% prototype["key"]
)
caller.msg(string)
@ -2641,7 +2695,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
caller = self.caller
switches = self.switches
if not self.args:
if not self.args or (not self.lhs and not self.rhs):
caller.msg("Usage: find <string> [= low [-high]]")
return
@ -2649,18 +2703,46 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
switches.append("loc")
searchstring = self.lhs
low, high = 1, ObjectDB.objects.all().order_by("-id")[0].id
try:
# Try grabbing the actual min/max id values by database aggregation
qs = ObjectDB.objects.values("id").aggregate(low=Min("id"), high=Max("id"))
low, high = sorted(qs.values())
if not (low and high):
raise ValueError(
f"{self.__class__.__name__}: Min and max ID not returned by aggregation; falling back to queryset slicing."
)
except Exception as e:
logger.log_trace(e)
# If that doesn't work for some reason (empty DB?), guess the lower
# bound and do a less-efficient query to find the upper.
low, high = 1, ObjectDB.objects.all().order_by("-id").first().id
if self.rhs:
if "-" in self.rhs:
# also support low-high syntax
limlist = [part.lstrip("#").strip() for part in self.rhs.split("-", 1)]
else:
# otherwise split by space
limlist = [part.lstrip("#") for part in self.rhs.split(None, 1)]
if limlist and limlist[0].isdigit():
low = max(low, int(limlist[0]))
if len(limlist) > 1 and limlist[1].isdigit():
high = min(high, int(limlist[1]))
try:
# Check that rhs is either a valid dbref or dbref range
bounds = tuple(
sorted(dbref(x, False) for x in re.split("[-\s]+", self.rhs.strip()))
)
# dbref() will return either a valid int or None
assert bounds
# None should not exist in the bounds list
assert None not in bounds
low = bounds[0]
if len(bounds) > 1:
high = bounds[-1]
except AssertionError:
caller.msg("Invalid dbref range provided (not a number).")
return
except IndexError as e:
logger.log_err(
f"{self.__class__.__name__}: Error parsing upper and lower bounds of query."
)
logger.log_trace(e)
low = min(low, high)
high = max(low, high)
@ -2672,7 +2754,6 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
restrictions = ", %s" % (", ".join(self.switches))
if is_dbref or is_account:
if is_dbref:
# a dbref search
result = caller.search(searchstring, global_search=True, quiet=True)
@ -2703,7 +2784,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
)
else:
# Not an account/dbref search but a wider search; build a queryset.
# Searchs for key and aliases
# Searches for key and aliases
if "exact" in switches:
keyquery = Q(db_key__iexact=searchstring, id__gte=low, id__lte=high)
aliasquery = Q(
@ -2729,39 +2810,52 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
id__lte=high,
)
results = ObjectDB.objects.filter(keyquery | aliasquery).distinct()
nresults = results.count()
# Keep the initial queryset handy for later reuse
result_qs = ObjectDB.objects.filter(keyquery | aliasquery).distinct()
nresults = result_qs.count()
if nresults:
# convert result to typeclasses.
results = [result for result in results]
if "room" in switches:
results = [obj for obj in results if inherits_from(obj, ROOM_TYPECLASS)]
if "exit" in switches:
results = [obj for obj in results if inherits_from(obj, EXIT_TYPECLASS)]
if "char" in switches:
results = [obj for obj in results if inherits_from(obj, CHAR_TYPECLASS)]
nresults = len(results)
# Use iterator to minimize memory ballooning on large result sets
results = result_qs.iterator()
# Check and see if type filtering was requested; skip it if not
if any(x in switches for x in ("room", "exit", "char")):
obj_ids = set()
for obj in results:
if (
("room" in switches and inherits_from(obj, ROOM_TYPECLASS))
or ("exit" in switches and inherits_from(obj, EXIT_TYPECLASS))
or ("char" in switches and inherits_from(obj, CHAR_TYPECLASS))
):
obj_ids.add(obj.id)
# Filter previous queryset instead of requesting another
filtered_qs = result_qs.filter(id__in=obj_ids).distinct()
nresults = filtered_qs.count()
# Use iterator again to minimize memory ballooning
results = filtered_qs.iterator()
# still results after type filtering?
if nresults:
if nresults > 1:
string = "|w%i Matches|n(#%i-#%i%s):" % (nresults, low, high, restrictions)
for res in results:
string += "\n |g%s - %s|n" % (res.get_display_name(caller), res.path)
header = f"{nresults} Matches"
else:
string = "|wOne Match|n(#%i-#%i%s):" % (low, high, restrictions)
string += "\n |g%s - %s|n" % (
results[0].get_display_name(caller),
results[0].path,
)
if "loc" in self.switches and nresults == 1 and results[0].location:
string += " (|wlocation|n: |g{}|n)".format(
results[0].location.get_display_name(caller)
)
header = "One Match"
string = f"|w{header}|n(#{low}-#{high}{restrictions}):"
res = None
for res in results:
string += f"\n |g{res.get_display_name(caller)} - {res.path}|n"
if (
"loc" in self.switches
and nresults == 1
and res
and getattr(res, "location", None)
):
string += f" (|wlocation|n: |g{res.location.get_display_name(caller)}|n)"
else:
string = "|wMatch|n(#%i-#%i%s):" % (low, high, restrictions)
string += "\n |RNo matches found for '%s'|n" % searchstring
string = f"|wNo Matches|n(#{low}-#{high}{restrictions}):"
string += f"\n |RNo matches found for '{searchstring}'|n"
# send result
caller.msg(string.strip())
@ -2791,8 +2885,8 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS):
reference. A puppeted object cannot be moved to None.
loc - teleport object to the target's location instead of its contents
Teleports an object somewhere. If no object is given, you yourself
is teleported to the target location.
Teleports an object somewhere. If no object is given, you yourself are
teleported to the target location.
"""
key = "tel"
@ -2957,7 +3051,8 @@ class CmdScript(COMMAND_DEFAULT_CLASS):
ok = obj.scripts.add(self.rhs, autostart=True)
if not ok:
result.append(
"\nScript %s could not be added and/or started on %s."
"\nScript %s could not be added and/or started on %s "
"(or it started and immediately shut down)."
% (self.rhs, obj.get_display_name(caller))
)
else:
@ -2988,7 +3083,8 @@ class CmdScript(COMMAND_DEFAULT_CLASS):
else:
result = ["Script started successfully."]
break
caller.msg("".join(result).strip())
EvMore(caller, "".join(result).strip())
class CmdTag(COMMAND_DEFAULT_CLASS):

View file

@ -79,7 +79,7 @@ class CmdHelp(Command):
evmore.msg(self.caller, text, session=self.session)
return
self.msg((text, {"type": "help"}))
self.msg(text=(text, {"type": "help"}))
@staticmethod
def format_help_entry(title, help_text, aliases=None, suggested=None):
@ -376,7 +376,7 @@ class CmdSetHelp(COMMAND_DEFAULT_CLASS):
self.msg("You have to define a topic!")
return
topicstrlist = topicstr.split(";")
topicstr, aliases = topicstrlist[0], topicstrlist[1:] if len(topicstr) > 1 else []
topicstr, aliases = (topicstrlist[0], topicstrlist[1:] if len(topicstr) > 1 else [])
aliastxt = ("(aliases: %s)" % ", ".join(aliases)) if aliases else ""
old_entry = None

View file

@ -202,11 +202,15 @@ class MuxCommand(Command):
else:
self.character = None
def func(self):
def get_command_info(self):
"""
This is the hook function that actually does all the work. It is called
by the cmdhandler right after self.parser() finishes, and so has access
to all the variables defined therein.
Update of parent class's get_command_info() for MuxCommand.
"""
self.get_command_info()
def get_command_info(self):
"""
Update of parent class's get_command_info() for MuxCommand.
"""
variables = "\n".join(
" |w{}|n ({}): {}".format(key, type(val), val) for key, val in self.__dict__.items()
@ -245,6 +249,14 @@ Command {self} has no defined `func()` - showing on-command variables: No child
string += "-" * 50
self.caller.msg(string)
def func(self):
"""
This is the hook function that actually does all the work. It is called
by the cmdhandler right after self.parser() finishes, and so has access
to all the variables defined therein.
"""
self.get_command_info()
class MuxAccountCommand(MuxCommand):
"""

View file

@ -23,6 +23,7 @@ from evennia.accounts.models import AccountDB
from evennia.utils import logger, utils, gametime, create, search
from evennia.utils.eveditor import EvEditor
from evennia.utils.evtable import EvTable
from evennia.utils.evmore import EvMore
from evennia.utils.utils import crop, class_from_module
COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
@ -232,6 +233,10 @@ def _run_code_snippet(
if ret is None:
return
elif isinstance(ret, tuple):
# we must convert here to allow msg to pass it (a tuple is confused
# with a outputfunc structure)
ret = str(ret)
for session in sessions:
try:
@ -284,8 +289,6 @@ class EvenniaPythonConsole(code.InteractiveConsole):
result = None
try:
result = super().push(line)
except SystemExit:
pass
finally:
sys.stdout = old_stdout
sys.stderr = old_stderr
@ -301,6 +304,7 @@ class CmdPy(COMMAND_DEFAULT_CLASS):
py/edit
py/time <cmd>
py/clientraw <cmd>
py/noecho
Switches:
time - output an approximate execution time for <cmd>
@ -308,6 +312,8 @@ class CmdPy(COMMAND_DEFAULT_CLASS):
clientraw - turn off all client-specific escaping. Note that this may
lead to different output depending on prototocol (such as angular brackets
being parsed as HTML in the webclient but not in telnet clients)
noecho - in Python console mode, turn off the input echo (e.g. if your client
does this for you already)
Without argument, open a Python console in-game. This is a full console,
accepting multi-line Python code for testing and debugging. Type `exit()` to
@ -339,7 +345,7 @@ class CmdPy(COMMAND_DEFAULT_CLASS):
key = "py"
aliases = ["!"]
switch_options = ("time", "edit", "clientraw")
switch_options = ("time", "edit", "clientraw", "noecho")
locks = "cmd:perm(py) or perm(Developer)"
help_category = "System"
@ -349,6 +355,8 @@ class CmdPy(COMMAND_DEFAULT_CLASS):
caller = self.caller
pycode = self.args
noecho = "noecho" in self.switches
if "edit" in self.switches:
caller.db._py_measure_time = "time" in self.switches
caller.db._py_clientraw = "clientraw" in self.switches
@ -367,15 +375,26 @@ class CmdPy(COMMAND_DEFAULT_CLASS):
# Run in interactive mode
console = EvenniaPythonConsole(self.caller)
banner = (
f"|gPython {sys.version} on {sys.platform}\n"
"Evennia interactive console mode - type 'exit()' to leave.|n"
"|gEvennia Interactive Python mode{echomode}\n"
"Python {version} on {platform}".format(
echomode=" (no echoing of prompts)" if noecho else "",
version=sys.version,
platform=sys.platform,
)
)
self.msg(banner)
line = ""
prompt = ">>>"
main_prompt = "|x[py mode - quit() to exit]|n"
prompt = main_prompt
while line.lower() not in ("exit", "exit()"):
line = yield (prompt)
prompt = "..." if console.push(line) else ">>>"
try:
line = yield (prompt)
if noecho:
prompt = "..." if console.push(line) else main_prompt
else:
prompt = line if console.push(line) else f"{line}\n{main_prompt}"
except SystemExit:
break
self.msg("|gClosing the Python console.|n")
return
@ -409,16 +428,19 @@ def format_script_list(scripts):
align="r",
border="tablecols",
)
for script in scripts:
nextrep = script.time_until_next_repeat()
if nextrep is None:
nextrep = "PAUS" if script.db._paused_time else "--"
nextrep = "PAUSED" if script.db._paused_time else "--"
else:
nextrep = "%ss" % nextrep
maxrepeat = script.repeats
remaining = script.remaining_repeats() or 0
if maxrepeat:
rept = "%i/%i" % (maxrepeat - script.remaining_repeats(), maxrepeat)
rept = "%i/%i" % (maxrepeat - remaining, maxrepeat)
else:
rept = "-/-"
@ -433,6 +455,7 @@ def format_script_list(scripts):
script.typeclass_path.rsplit(".", 1)[-1],
crop(script.desc, width=20),
)
return "%s" % table
@ -527,7 +550,7 @@ class CmdScripts(COMMAND_DEFAULT_CLASS):
else:
# No stopping or validation. We just want to view things.
string = format_script_list(scripts)
caller.msg(string)
EvMore(caller, string)
class CmdObjects(COMMAND_DEFAULT_CLASS):
@ -592,9 +615,13 @@ class CmdObjects(COMMAND_DEFAULT_CLASS):
"|wtypeclass|n", "|wcount|n", "|w%|n", border="table", align="l"
)
typetable.align = "l"
dbtotals = ObjectDB.objects.object_totals()
for path, count in dbtotals.items():
typetable.add_row(path, count, "%.2f" % ((float(count) / nobjs) * 100))
dbtotals = ObjectDB.objects.get_typeclass_totals()
for stat in dbtotals:
typetable.add_row(
stat.get("typeclass", "<error>"),
stat.get("count", -1),
"%.2f" % stat.get("percent", -1),
)
# last N table
objs = ObjectDB.objects.all().order_by("db_date_created")[max(0, nobjs - nlim) :]

View file

@ -19,7 +19,7 @@ from anything import Anything
from django.conf import settings
from mock import Mock, mock
from evennia import DefaultRoom, DefaultExit
from evennia import DefaultRoom, DefaultExit, ObjectDB
from evennia.commands.default.cmdset_character import CharacterCmdSet
from evennia.utils.test_resources import EvenniaTest
from evennia.commands.default import (
@ -991,6 +991,34 @@ class TestBuilding(CommandTest):
"All object creation hooks were run. All old attributes where deleted before the swap.",
)
from evennia.prototypes.prototypes import homogenize_prototype
test_prototype = [
homogenize_prototype(
{
"prototype_key": "testkey",
"prototype_tags": [],
"typeclass": "typeclasses.objects.Object",
"key": "replaced_obj",
"attrs": [("foo", "bar", None, ""), ("desc", "protdesc", None, "")],
}
)
]
with mock.patch(
"evennia.commands.default.building.protlib.search_prototype",
new=mock.MagicMock(return_value=test_prototype),
) as mprot:
self.call(
building.CmdTypeclass(),
"/prototype Obj=testkey",
"replaced_obj changed typeclass from "
"evennia.objects.objects.DefaultObject to "
"typeclasses.objects.Object.\nAll object creation hooks were "
"run. Attributes set before swap were not removed. Prototype "
"'replaced_obj' was successfully applied over the object type.",
)
assert self.obj1.db.desc == "protdesc"
def test_lock(self):
self.call(building.CmdLock(), "", "Usage: ")
self.call(building.CmdLock(), "Obj = test:all()", "Added lock 'test:all()' to Obj.")
@ -1038,11 +1066,41 @@ class TestBuilding(CommandTest):
self.call(building.CmdFind(), self.char1.dbref, "Exact dbref match")
self.call(building.CmdFind(), "*TestAccount", "Match")
self.call(building.CmdFind(), "/char Obj")
self.call(building.CmdFind(), "/room Obj")
self.call(building.CmdFind(), "/exit Obj")
self.call(building.CmdFind(), "/char Obj", "No Matches")
self.call(building.CmdFind(), "/room Obj", "No Matches")
self.call(building.CmdFind(), "/exit Obj", "No Matches")
self.call(building.CmdFind(), "/exact Obj", "One Match")
# Test multitype filtering
with mock.patch(
"evennia.commands.default.building.CHAR_TYPECLASS",
"evennia.objects.objects.DefaultCharacter",
):
self.call(building.CmdFind(), "/char/room Obj", "No Matches")
self.call(building.CmdFind(), "/char/room/exit Char", "2 Matches")
self.call(building.CmdFind(), "/char/room/exit/startswith Cha", "2 Matches")
# Test null search
self.call(building.CmdFind(), "=", "Usage: ")
# Test bogus dbref range with no search term
self.call(building.CmdFind(), "= obj", "Invalid dbref range provided (not a number).")
self.call(building.CmdFind(), "= #1a", "Invalid dbref range provided (not a number).")
# Test valid dbref ranges with no search term
id1 = self.obj1.id
id2 = self.obj2.id
maxid = ObjectDB.objects.latest("id").id
maxdiff = maxid - id1 + 1
mdiff = id2 - id1 + 1
self.call(building.CmdFind(), f"=#{id1}", f"{maxdiff} Matches(#{id1}-#{maxid}")
self.call(building.CmdFind(), f"={id1}-{id2}", f"{mdiff} Matches(#{id1}-#{id2}):")
self.call(building.CmdFind(), f"={id1} - {id2}", f"{mdiff} Matches(#{id1}-#{id2}):")
self.call(building.CmdFind(), f"={id1}- #{id2}", f"{mdiff} Matches(#{id1}-#{id2}):")
self.call(building.CmdFind(), f"={id1}-#{id2}", f"{mdiff} Matches(#{id1}-#{id2}):")
self.call(building.CmdFind(), f"=#{id1}-{id2}", f"{mdiff} Matches(#{id1}-#{id2}):")
def test_script(self):
self.call(building.CmdScript(), "Obj = ", "No scripts defined on Obj")
self.call(

View file

@ -27,8 +27,12 @@ from django.conf import settings
from evennia.commands import cmdset, command
from evennia.utils.logger import tail_log_file
from evennia.utils.utils import class_from_module
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
# we must late-import these since any overloads are likely to
# themselves be using these classes leading to a circular import.
_CHANNEL_HANDLER_CLASS = None
_CHANNEL_COMMAND_CLASS = None
_CHANNELDB = None
@ -314,5 +318,6 @@ class ChannelHandler(object):
return chan_cmdset
CHANNEL_HANDLER = ChannelHandler()
# set up the singleton
CHANNEL_HANDLER = class_from_module(settings.CHANNEL_HANDLER_CLASS)()
CHANNELHANDLER = CHANNEL_HANDLER # legacy

View file

@ -8,6 +8,7 @@ Comm system components.
from django.db.models import Q
from evennia.typeclasses.managers import TypedObjectManager, TypeclassManager
from evennia.utils import logger
from evennia.utils.utils import dbref
_GA = object.__getattribute__
_AccountDB = None
@ -31,32 +32,6 @@ class CommError(Exception):
#
def dbref(inp, reqhash=True):
"""
Valid forms of dbref (database reference number) are either a
string '#N' or an integer N.
Args:
inp (int or str): A possible dbref to check syntactically.
reqhash (bool): Require an initial hash `#` to accept.
Returns:
is_dbref (int or None): The dbref integer part if a valid
dbref, otherwise `None`.
"""
if reqhash and not (isinstance(inp, str) and inp.startswith("#")):
return None
if isinstance(inp, str):
inp = inp.lstrip("#")
try:
if int(inp) < 0:
return None
except Exception:
return None
return inp
def identify_object(inp):
"""
Helper function. Identifies if an object is an account or an object;

View file

@ -400,9 +400,11 @@ class Msg(SharedMemoryModel):
def __str__(self):
"This handles what is shown when e.g. printing the message"
senders = ",".join(obj.key for obj in self.senders)
senders = ",".join(getattr(obj, "key", str(obj)) for obj in self.senders)
receivers = ",".join(
["[%s]" % obj.key for obj in self.channels] + [obj.key for obj in self.receivers]
["[%s]" % getattr(obj, "key", str(obj)) for obj in self.channels]
+ [getattr(obj, "key", str(obj)) for obj in self.receivers]
)
return "%s->%s: %s" % (senders, receivers, crop(self.message, width=40))

View file

@ -1,5 +1,6 @@
from evennia.utils.test_resources import EvenniaTest
from evennia import DefaultChannel
from evennia.utils.create import create_message
class ObjectCreationTest(EvenniaTest):
@ -10,3 +11,8 @@ class ObjectCreationTest(EvenniaTest):
self.assertTrue(obj, errors)
self.assertFalse(errors, errors)
self.assertEqual(description, obj.db.desc)
def test_message_create(self):
msg = create_message("peewee herman", "heh-heh!", header="mail time!")
self.assertTrue(msg)
self.assertEqual(str(msg), "peewee herman->: heh-heh!")

View file

@ -298,6 +298,9 @@ class GametimeScript(DefaultScript):
def at_repeat(self):
"""Call the callback and reset interval."""
from evennia.utils.utils import calledby
callback = self.db.callback
if callback:
callback()

View file

@ -8,26 +8,39 @@ insert custom markers in their text to indicate gender-aware
messaging. It relies on a modified msg() and is meant as an
inspiration and starting point to how to do stuff like this.
When in use, all messages being sent to the character will make use of
the character's gender, for example the echo
An object can have the following genders:
- male (he/his)
- female (her/hers)
- neutral (it/its)
- ambiguous (they/them/their/theirs)
When in use, messages can contain special tags to indicate pronouns gendered
based on the one being addressed. Capitalization will be retained.
- `|s`, `|S`: Subjective form: he, she, it, He, She, It, They
- `|o`, `|O`: Objective form: him, her, it, Him, Her, It, Them
- `|p`, `|P`: Possessive form: his, her, its, His, Her, Its, Their
- `|a`, `|A`: Absolute Possessive form: his, hers, its, His, Hers, Its, Theirs
For example,
```
char.msg("%s falls on |p face with a thud." % char.key)
"Tom falls on his face with a thud"
```
will result in "Tom falls on his|her|its|their face with a thud"
depending on the gender of the object being messaged. Default gender
is "ambiguous" (they).
The default gender is "ambiguous" (they/them/their/theirs).
To use, have DefaultCharacter inherit from this, or change
setting.DEFAULT_CHARACTER to point to this class.
The `@gender` command needs to be added to the default cmdset before
it becomes available.
The `@gender` command is used to set the gender. It needs to be added to the
default cmdset before it becomes available.
"""
import re
from evennia.utils import logger
from evennia import DefaultCharacter
from evennia import Command
@ -114,7 +127,10 @@ class GenderCharacter(DefaultCharacter):
gender-aware markers in output.
Args:
text (str, optional): The message to send
text (str or tuple, optional): The message to send. This
is treated internally like any send-command, so its
value can be a tuple if sending multiple arguments to
the `text` oob command.
from_obj (obj, optional): object that is sending. If
given, at_msg_send will be called
session (Session or list, optional): session or list of
@ -125,9 +141,13 @@ class GenderCharacter(DefaultCharacter):
All extra kwargs will be passed on to the protocol.
"""
# pre-process the text before continuing
try:
text = _RE_GENDER_PRONOUN.sub(self._get_pronoun, text)
if text and isinstance(text, tuple):
text = (self._RE_GENDER_PRONOUN.sub(self._get_pronoun, text[0]), *text[1:])
else:
text = self._RE_GENDER_PRONOUN.sub(self._get_pronoun, text)
except TypeError:
pass
except Exception as e:
logger.log_trace(e)
super().msg(text, from_obj=from_obj, session=session, **kwargs)

View file

@ -238,7 +238,7 @@ class CmdMail(default_cmds.MuxAccountCommand):
else:
raise IndexError
except IndexError:
self.caller.msg("Message does not exixt.")
self.caller.msg("Message does not exist.")
except ValueError:
self.caller.msg("Usage: @mail/forward <account list>=<#>[/<Message>]")
elif "reply" in self.switches or "rep" in self.switches:

View file

@ -331,7 +331,7 @@ class LanguageHandler(DefaultScript):
# find out what preceeded this word
wpos = match.start()
preceeding = match.string[:wpos].strip()
start_sentence = preceeding.endswith(".") or not preceeding
start_sentence = preceeding.endswith((".", "!", "?")) or not preceeding
# make up translation on the fly. Length can
# vary from un-translated word.

View file

@ -798,6 +798,16 @@ class RecogHandler(object):
# recog_mask log not passed, disable recog
return obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key
def all(self):
"""
Get a mapping of the recogs stored in handler.
Returns:
recogs (dict): A mapping of {recog: obj} stored in handler.
"""
return {self.obj2recog[obj]: obj for obj in self.obj2recog.keys()}
def remove(self, obj):
"""
Clear recog for a given object.
@ -896,10 +906,9 @@ class CmdSay(RPCommand): # replaces standard say
caller.msg("Say what?")
return
# calling the speech hook on the location
speech = caller.location.at_before_say(self.args)
# calling the speech modifying hook
speech = caller.at_before_say(self.args)
# preparing the speech with sdesc/speech parsing.
speech = '/me says, "{speech}"'.format(speech=speech)
targets = self.caller.location.contents
send_emote(self.caller, targets, speech, anonymous_add=None)
@ -932,6 +941,9 @@ class CmdSdesc(RPCommand): # set/look at own sdesc
except SdescError as err:
caller.msg(err)
return
except AttributeError:
caller.msg(f"Cannot set sdesc on {caller.key}.")
return
caller.msg("%s's sdesc was set to '%s'." % (caller.key, sdesc))
@ -1041,6 +1053,7 @@ class CmdRecog(RPCommand): # assign personal alias to object in room
Recognize another person in the same room.
Usage:
recog
recog sdesc as alias
forget alias
@ -1048,8 +1061,8 @@ class CmdRecog(RPCommand): # assign personal alias to object in room
recog tall man as Griatch
forget griatch
This will assign a personal alias for a person, or
forget said alias.
This will assign a personal alias for a person, or forget said alias.
Using the command without arguments will list all current recogs.
"""
@ -1058,6 +1071,7 @@ class CmdRecog(RPCommand): # assign personal alias to object in room
def parse(self):
"Parse for the sdesc as alias structure"
self.sdesc, self.alias = "", ""
if " as " in self.args:
self.sdesc, self.alias = [part.strip() for part in self.args.split(" as ", 2)]
elif self.args:
@ -1070,22 +1084,47 @@ class CmdRecog(RPCommand): # assign personal alias to object in room
def func(self):
"Assign the recog"
caller = self.caller
if not self.args:
caller.msg("Usage: recog <sdesc> as <alias> or forget <alias>")
return
sdesc = self.sdesc
alias = self.alias.rstrip(".?!")
sdesc = self.sdesc
recog_mode = self.cmdstring != "forget" and alias and sdesc
forget_mode = self.cmdstring == "forget" and sdesc
list_mode = not self.args
if not (recog_mode or forget_mode or list_mode):
caller.msg("Usage: recog, recog <sdesc> as <alias> or forget <alias>")
return
if list_mode:
# list all previously set recogs
all_recogs = caller.recog.all()
if not all_recogs:
caller.msg(
"You recognize no-one. " "(Use 'recog <sdesc> as <alias>' to recognize people."
)
else:
# note that we don't skip those failing enable_recog lock here,
# because that would actually reveal more than we want.
lst = "\n".join(
" {} ({})".format(key, obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key)
for key, obj in all_recogs.items()
)
caller.msg(
f"Currently recognized (use 'recog <sdesc> as <alias>' to add "
f"new and 'forget <alias>' to remove):\n{lst}"
)
return
prefixed_sdesc = sdesc if sdesc.startswith(_PREFIX) else _PREFIX + sdesc
candidates = caller.location.contents
matches = parse_sdescs_and_recogs(caller, candidates, prefixed_sdesc, search_mode=True)
nmatches = len(matches)
# handle 0, 1 and >1 matches
# handle 0 and >1 matches
if nmatches == 0:
caller.msg(_EMOTE_NOMATCH_ERROR.format(ref=sdesc))
elif nmatches > 1:
reflist = [
"%s%s%s (%s%s)"
% (
"{}{}{} ({}{})".format(
inum + 1,
_NUM_SEP,
_RE_PREFIX.sub("", sdesc),
@ -1095,17 +1134,20 @@ class CmdRecog(RPCommand): # assign personal alias to object in room
for inum, obj in enumerate(matches)
]
caller.msg(_EMOTE_MULTIMATCH_ERROR.format(ref=sdesc, reflist="\n ".join(reflist)))
else:
# one single match
obj = matches[0]
if not obj.access(self.obj, "enable_recog", default=True):
# don't apply recog if object doesn't allow it (e.g. by being masked).
caller.msg("Can't recognize someone who is masked.")
caller.msg("It's impossible to recognize them.")
return
if self.cmdstring == "forget":
if forget_mode:
# remove existing recog
caller.recog.remove(obj)
caller.msg("%s will now know only '%s'." % (caller.key, obj.recog.get(obj)))
caller.msg("%s will now know them only as '%s'." % (caller.key, obj.recog.get(obj)))
else:
# set recog
sdesc = obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key
try:
alias = caller.recog.add(obj, alias)
@ -1509,6 +1551,20 @@ class ContribRPCharacter(DefaultCharacter, ContribRPObject):
# initializing sdesc
self.sdesc.add("A normal person")
def at_before_say(self, message, **kwargs):
"""
Called before the object says or whispers anything, return modified message.
Args:
message (str): The suggested say/whisper text spoken by self.
Kwargs:
whisper (bool): If True, this is a whisper rather than a say.
"""
if kwargs.get("whisper"):
return f'/me whispers "{message}"'
return f'/me says, "{message}"'
def process_sdesc(self, sdesc, obj, **kwargs):
"""
Allows to customize how your sdesc is displayed (primarily by

View file

@ -169,6 +169,8 @@ class TestRPSystem(EvenniaTest):
self.speaker.recog.remove(self.receiver1)
self.assertEqual(self.speaker.recog.get(self.receiver1), sdesc1)
self.assertEqual(self.speaker.recog.all(), {"Mr Receiver2": self.receiver2})
def test_parse_language(self):
self.assertEqual(
rpsystem.parse_language(self.speaker, emote),
@ -233,6 +235,49 @@ class TestRPSystem(EvenniaTest):
self.assertEqual(self.speaker.search("colliding"), self.receiver2)
class TestRPSystemCommands(CommandTest):
def setUp(self):
super().setUp()
self.char1.swap_typeclass(rpsystem.ContribRPCharacter)
self.char2.swap_typeclass(rpsystem.ContribRPCharacter)
def test_commands(self):
self.call(
rpsystem.CmdSdesc(), "Foobar Character", "Char's sdesc was set to 'Foobar Character'."
)
self.call(
rpsystem.CmdSdesc(),
"BarFoo Character",
"Char2's sdesc was set to 'BarFoo Character'.",
caller=self.char2,
)
self.call(rpsystem.CmdSay(), "Hello!", 'Char says, "Hello!"')
self.call(rpsystem.CmdEmote(), "/me smiles to /barfoo.", "Char smiles to BarFoo Character")
self.call(
rpsystem.CmdPose(),
"stands by the bar",
"Pose will read 'Foobar Character stands by the bar.'.",
)
self.call(
rpsystem.CmdRecog(),
"barfoo as friend",
"Char will now remember BarFoo Character as friend.",
)
self.call(
rpsystem.CmdRecog(),
"",
"Currently recognized (use 'recog <sdesc> as <alias>' to add new "
"and 'forget <alias>' to remove):\n friend (BarFoo Character)",
)
self.call(
rpsystem.CmdRecog(),
"friend",
"Char will now know them only as 'BarFoo Character'",
cmdstring="forget",
)
# Testing of ExtendedRoom contrib
from django.conf import settings
@ -607,7 +652,7 @@ class TestWilderness(EvenniaTest):
"west": (0, 1),
"northwest": (0, 2),
}
for direction, correct_loc in directions.items(): # Not compatible with Python 3
for (direction, correct_loc) in directions.items(): # Not compatible with Python 3
new_loc = wilderness.get_new_coordinates(loc, direction)
self.assertEqual(new_loc, correct_loc, direction)

View file

@ -441,11 +441,11 @@ class TBBasicTurnHandler(DefaultScript):
"""
combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case.
character.db.combat_actionsleft = (
0
) # Actions remaining - start of turn adds to this, turn ends when it reaches 0
0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0
)
character.db.combat_turnhandler = (
self
) # Add a reference to this turn handler script to the character
self # Add a reference to this turn handler script to the character
)
character.db.combat_lastaction = "null" # Track last action taken in combat
def start_turn(self, character):

View file

@ -218,10 +218,10 @@ def apply_damage(defender, damage):
def at_defeat(defeated):
"""
Announces the defeat of a fighter in combat.
Args:
defeated (obj): Fighter that's been defeated.
Notes:
All this does is announce a defeat message by default, but if you
want anything else to happen to defeated fighters (like putting them
@ -438,11 +438,11 @@ class TBEquipTurnHandler(DefaultScript):
"""
combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case.
character.db.combat_actionsleft = (
0
) # Actions remaining - start of turn adds to this, turn ends when it reaches 0
0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0
)
character.db.combat_turnhandler = (
self
) # Add a reference to this turn handler script to the character
self # Add a reference to this turn handler script to the character
)
character.db.combat_lastaction = "null" # Track last action taken in combat
def start_turn(self, character):
@ -553,8 +553,8 @@ class TBEWeapon(DefaultObject):
self.db.damage_range = (15, 25) # Minimum and maximum damage on hit
self.db.accuracy_bonus = 0 # Bonus to attack rolls (or penalty if negative)
self.db.weapon_type_name = (
"weapon"
) # Single word for weapon - I.E. "dagger", "staff", "scimitar"
"weapon" # Single word for weapon - I.E. "dagger", "staff", "scimitar"
)
def at_drop(self, dropper):
"""
@ -903,10 +903,10 @@ class CmdCombatHelp(CmdHelp):
class CmdWield(Command):
"""
Wield a weapon you are carrying
Usage:
wield <weapon>
Select a weapon you are carrying to wield in combat. If
you are already wielding another weapon, you will switch
to the weapon you specify instead. Using this command in
@ -933,7 +933,7 @@ class CmdWield(Command):
weapon = self.caller.search(self.args, candidates=self.caller.contents)
if not weapon:
return
if not weapon.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEWeapon"):
if not weapon.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEWeapon", exact=True):
self.caller.msg("That's not a weapon!")
# Remember to update the path to the weapon typeclass if you move this module!
return
@ -955,10 +955,10 @@ class CmdWield(Command):
class CmdUnwield(Command):
"""
Stop wielding a weapon.
Usage:
unwield
After using this command, you will stop wielding any
weapon you are currently wielding and become unarmed.
"""
@ -986,12 +986,12 @@ class CmdUnwield(Command):
class CmdDon(Command):
"""
Don armor that you are carrying
Usage:
don <armor>
Select armor to wear in combat. You can't use this
command in the middle of a fight. Use the "doff"
command in the middle of a fight. Use the "doff"
command to remove any armor you are wearing.
"""
@ -1012,7 +1012,7 @@ class CmdDon(Command):
armor = self.caller.search(self.args, candidates=self.caller.contents)
if not armor:
return
if not armor.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEArmor"):
if not armor.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEArmor", exact=True):
self.caller.msg("That's not armor!")
# Remember to update the path to the armor typeclass if you move this module!
return
@ -1031,10 +1031,10 @@ class CmdDon(Command):
class CmdDoff(Command):
"""
Stop wearing armor.
Usage:
doff
After using this command, you will stop wearing any
armor you are currently using and become unarmored.
You can't use this command in combat.

View file

@ -718,11 +718,11 @@ class TBItemsTurnHandler(DefaultScript):
"""
combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case.
character.db.combat_actionsleft = (
0
) # Actions remaining - start of turn adds to this, turn ends when it reaches 0
0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0
)
character.db.combat_turnhandler = (
self
) # Add a reference to this turn handler script to the character
self # Add a reference to this turn handler script to the character
)
character.db.combat_lastaction = "null" # Track last action taken in combat
def start_turn(self, character):

View file

@ -44,6 +44,12 @@ instead of the default:
class Character(TBMagicCharacter):
Note: If your character already existed you need to also make sure
to re-run the creation hooks on it to set the needed Attributes.
Use `update self` to try on yourself or use py to call `at_object_creation()`
on all existing Characters.
Next, import this module into your default_cmdsets.py module:
from evennia.contrib.turnbattle import tb_magic
@ -199,10 +205,10 @@ def apply_damage(defender, damage):
def at_defeat(defeated):
"""
Announces the defeat of a fighter in combat.
Args:
defeated (obj): Fighter that's been defeated.
Notes:
All this does is announce a defeat message by default, but if you
want anything else to happen to defeated fighters (like putting them
@ -332,7 +338,7 @@ class TBMagicCharacter(DefaultCharacter):
"""
Called once, when this object is first created. This is the
normal hook to overload for most object types.
Adds attributes for a character's current and maximum HP.
We're just going to set this value at '100' by default.
@ -464,11 +470,11 @@ class TBMagicTurnHandler(DefaultScript):
"""
combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case.
character.db.combat_actionsleft = (
0
) # Actions remaining - start of turn adds to this, turn ends when it reaches 0
0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0
)
character.db.combat_turnhandler = (
self
) # Add a reference to this turn handler script to the character
self # Add a reference to this turn handler script to the character
)
character.db.combat_lastaction = "null" # Track last action taken in combat
def start_turn(self, character):
@ -731,26 +737,26 @@ class CmdDisengage(Command):
class CmdLearnSpell(Command):
"""
Learn a magic spell.
Usage:
learnspell <spell name>
Adds a spell by name to your list of spells known.
The following spells are provided as examples:
|wmagic missile|n (3 MP): Fires three missiles that never miss. Can target
up to three different enemies.
|wflame shot|n (3 MP): Shoots a high-damage jet of flame at one target.
|wcure wounds|n (5 MP): Heals damage on one target.
|wmass cure wounds|n (10 MP): Like 'cure wounds', but can heal up to 5
targets at once.
|wfull heal|n (12 MP): Heals one target back to full HP.
|wcactus conjuration|n (2 MP): Creates a cactus.
"""
@ -803,10 +809,10 @@ class CmdCast(MuxCommand):
"""
Cast a magic spell that you know, provided you have the MP
to spend on its casting.
Usage:
cast <spellname> [= <target1>, <target2>, etc...]
Some spells can be cast on multiple targets, some can be cast
on only yourself, and some don't need a target specified at all.
Typing 'cast' by itself will give you a list of spells you know.
@ -818,7 +824,7 @@ class CmdCast(MuxCommand):
def func(self):
"""
This performs the actual command.
Note: This is a quite long command, since it has to cope with all
the different circumstances in which you may or may not be able
to cast a spell. None of the spell's effects are handled by the
@ -1123,7 +1129,7 @@ in the docstring for each function.
def spell_healing(caster, spell_name, targets, cost, **kwargs):
"""
Spell that restores HP to a target or targets.
kwargs:
healing_range (tuple): Minimum and maximum amount healed to
each target. (20, 40) by default.
@ -1156,7 +1162,7 @@ def spell_healing(caster, spell_name, targets, cost, **kwargs):
def spell_attack(caster, spell_name, targets, cost, **kwargs):
"""
Spell that deals damage in combat. Similar to resolve_attack.
kwargs:
attack_name (tuple): Single and plural describing the sort of
attack or projectile that strikes each enemy.
@ -1250,12 +1256,12 @@ def spell_attack(caster, spell_name, targets, cost, **kwargs):
def spell_conjure(caster, spell_name, targets, cost, **kwargs):
"""
Spell that creates an object.
kwargs:
obj_key (str): Key of the created object.
obj_desc (str): Desc of the created object.
obj_typeclass (str): Typeclass path of the object.
If you want to make more use of this particular spell funciton,
you may want to modify it to use the spawner (in evennia.utils.spawner)
instead of creating objects directly.
@ -1300,7 +1306,7 @@ parameters, some of which are required and others which are optional.
Required values for spells:
cost (int): MP cost of casting the spell
cost (int): MP cost of casting the spell
target (str): Valid targets for the spell. Can be any of:
"none" - No target needed
"self" - Self only
@ -1312,9 +1318,9 @@ Required values for spells:
spellfunc (callable): Function that performs the action of the spell.
Must take the following arguments: caster (obj), spell_name (str),
targets (list), and cost (int), as well as **kwargs.
Optional values for spells:
combat_spell (bool): If the spell can be cast in combat. True by default.
noncombat_spell (bool): If the spell can be cast out of combat. True by default.
max_targets (int): Maximum number of objects that can be targeted by the spell.

View file

@ -674,11 +674,11 @@ class TBRangeTurnHandler(DefaultScript):
"""
combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case.
character.db.combat_actionsleft = (
0
) # Actions remaining - start of turn adds to this, turn ends when it reaches 0
0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0
)
character.db.combat_turnhandler = (
self
) # Add a reference to this turn handler script to the character
self # Add a reference to this turn handler script to the character
)
character.db.combat_lastaction = "null" # Track last action taken in combat
def start_turn(self, character):

View file

@ -26,3 +26,16 @@ def at_webserver_root_creation(web_root):
"""
return web_root
def at_webproxy_root_creation(web_root):
"""
This function can modify the portal proxy service.
Args:
web_root (evennia.server.webserver.Website): The Evennia
Website application. Use .putChild() to add new
subdomains that are Portal-accessible over TCP;
primarily for new protocol development, but suitable
for other shenanigans.
"""
return web_root

View file

@ -547,11 +547,39 @@ def inside(accessing_obj, accessed_obj, *args, **kwargs):
Usage:
inside()
Only true if accessing_obj is "inside" accessed_obj
True if accessing_obj is 'inside' accessing_obj. Note that this only checks
one level down. So if if the lock is on a room, you will pass but not your
inventory (since their location is you, not the locked object). If you
want also nested objects to pass the lock, use the `insiderecursive`
lockfunc.
"""
return accessing_obj.location == accessed_obj
def inside_rec(accessing_obj, accessed_obj, *args, **kwargs):
"""
Usage:
inside_rec()
True if accessing_obj is inside the accessed obj, at up to 10 levels
of recursion (so if this lock is on a room, then an object inside a box
in your inventory will also pass the lock).
"""
def _recursive_inside(obj, accessed_obj, lvl=1):
if obj.location:
if obj.location == accessed_obj:
return True
elif lvl >= 10:
# avoid infinite recursions
return False
else:
return _recursive_inside(obj.location, accessed_obj, lvl + 1)
return False
return _recursive_inside(accessing_obj, accessed_obj)
def holds(accessing_obj, accessed_obj, *args, **kwargs):
"""
Usage:
@ -604,7 +632,7 @@ def holds(accessing_obj, accessed_obj, *args, **kwargs):
if len(args) == 1:
# command is holds(dbref/key) - check if given objname/dbref is held by accessing_ob
return check_holds(args[0])
elif len(args=2):
elif len(args) > 1:
# command is holds(attrname, value) check if any held object has the given attribute and value
for obj in contents:
if obj.attributes.get(args[0]) == args[1]:

View file

@ -107,7 +107,7 @@ to any other identifier you can use.
import re
from django.conf import settings
from evennia.utils import logger, utils
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
__all__ = ("LockHandler", "LockException")

View file

@ -17,6 +17,7 @@ except ImportError:
from evennia import settings_default
from evennia.locks import lockfuncs
from evennia.utils.create import create_object
# ------------------------------------------------------------
# Lock testing
@ -179,6 +180,13 @@ class TestLockfuncs(EvenniaTest):
self.assertEqual(False, lockfuncs.inside(self.char1, self.room2))
self.assertEqual(True, lockfuncs.holds(self.room1, self.char1))
self.assertEqual(False, lockfuncs.holds(self.room2, self.char1))
# test recursively
self.assertEqual(True, lockfuncs.inside_rec(self.char1, self.room1))
self.assertEqual(False, lockfuncs.inside_rec(self.char1, self.room2))
inventory_item = create_object(key="InsideTester", location=self.char1)
self.assertEqual(True, lockfuncs.inside_rec(inventory_item, self.room1))
self.assertEqual(False, lockfuncs.inside_rec(inventory_item, self.room2))
inventory_item.delete()
def test_has_account(self):
self.assertEqual(True, lockfuncs.has_account(self.char1, None))

View file

@ -575,8 +575,10 @@ class ObjectDBManager(TypedObjectManager):
return None
# copy over all attributes from old to new.
for attr in original_object.attributes.all():
new_object.attributes.add(attr.key, attr.value)
attrs = (
(a.key, a.value, a.category, a.lock_storage) for a in original_object.attributes.all()
)
new_object.attributes.batch_add(*attrs)
# copy over all cmdsets, if any
for icmdset, cmdset in enumerate(original_object.cmdset.all()):
@ -590,8 +592,10 @@ class ObjectDBManager(TypedObjectManager):
ScriptDB.objects.copy_script(script, new_obj=new_object)
# copy over all tags, if any
for tag in original_object.tags.get(return_tagobj=True, return_list=True):
new_object.tags.add(tag=tag.db_key, category=tag.db_category, data=tag.db_data)
tags = (
(t.db_key, t.db_category, t.db_data) for t in original_object.tags.all(return_objs=True)
)
new_object.tags.batch_add(*tags)
return new_object

View file

@ -31,7 +31,7 @@ from evennia.utils.utils import (
list_to_string,
to_str,
)
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
_INFLECT = inflect.engine()
_MULTISESSION_MODE = settings.MULTISESSION_MODE
@ -341,8 +341,12 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
"""
key = kwargs.get("key", self.key)
key = ansi.ANSIString(key) # this is needed to allow inflection of colored names
plural = _INFLECT.plural(key, 2)
plural = "%s %s" % (_INFLECT.number_to_words(count, threshold=12), plural)
try:
plural = _INFLECT.plural(key, 2)
plural = "%s %s" % (_INFLECT.number_to_words(count, threshold=12), plural)
except IndexError:
# this is raised by inflect if the input is not a proper noun
plural = key
singular = _INFLECT.an(key)
if not self.aliases.get(plural, category="plural_key"):
# we need to wipe any old plurals/an/a in case key changed in the interrim
@ -2062,9 +2066,6 @@ class DefaultCharacter(DefaultObject):
# Set the supplied key as the name of the intended object
kwargs["key"] = key
# Get home for character
kwargs["home"] = ObjectDB.objects.get_id(kwargs.get("home", settings.DEFAULT_HOME))
# Get permissions
kwargs["permissions"] = kwargs.get("permissions", settings.PERMISSION_ACCOUNT_DEFAULT)
@ -2076,9 +2077,10 @@ class DefaultCharacter(DefaultObject):
try:
# Check to make sure account does not have too many chars
if len(account.characters) >= settings.MAX_NR_CHARACTERS:
errors.append("There are too many characters associated with this account.")
return obj, errors
if account:
if len(account.characters) >= settings.MAX_NR_CHARACTERS:
errors.append("There are too many characters associated with this account.")
return obj, errors
# Create the Character
obj = create.create_object(**kwargs)
@ -2524,10 +2526,10 @@ class DefaultExit(DefaultObject):
[
"puppet:false()", # would be weird to puppet an exit ...
"traverse:all()", # who can pass through exit by default
"get:false()",
"get:false()", # noone can pick up the exit
]
)
) # noone can pick up the exit
)
# an exit should have a destination (this is replaced at creation time)
if self.location:

View file

@ -9,23 +9,35 @@ class DefaultObjectTest(EvenniaTest):
def test_object_create(self):
description = "A home for a grouch."
home = self.room1.dbref
obj, errors = DefaultObject.create(
"trashcan", self.account, description=description, ip=self.ip
"trashcan", self.account, description=description, ip=self.ip, home=home
)
self.assertTrue(obj, errors)
self.assertFalse(errors, errors)
self.assertEqual(description, obj.db.desc)
self.assertEqual(obj.db.creator_ip, self.ip)
self.assertEqual(obj.db_home, self.room1)
def test_character_create(self):
description = "A furry green monster, reeking of garbage."
home = self.room1.dbref
obj, errors = DefaultCharacter.create(
"oscar", self.account, description=description, ip=self.ip
"oscar", self.account, description=description, ip=self.ip, home=home
)
self.assertTrue(obj, errors)
self.assertFalse(errors, errors)
self.assertEqual(description, obj.db.desc)
self.assertEqual(obj.db.creator_ip, self.ip)
self.assertEqual(obj.db_home, self.room1)
def test_character_create_noaccount(self):
obj, errors = DefaultCharacter.create("oscar", None, home=self.room1.dbref)
self.assertTrue(obj, errors)
self.assertFalse(errors, errors)
self.assertEqual(obj.db_home, self.room1)
def test_room_create(self):
description = "A dimly-lit alley behind the local Chinese restaurant."
@ -101,3 +113,27 @@ class TestObjectManager(EvenniaTest):
self.assertEqual(list(query), [self.obj1])
query = ObjectDB.objects.get_objs_with_attr("NotFound", candidates=[self.char1, self.obj1])
self.assertFalse(query)
def test_copy_object(self):
"Test that all attributes and tags properly copy across objects"
# Add some tags
self.obj1.tags.add("plugh", category="adventure")
self.obj1.tags.add("xyzzy")
# Add some attributes
self.obj1.attributes.add("phrase", "plugh", category="adventure")
self.obj1.attributes.add("phrase", "xyzzy")
# Create object copy
obj2 = self.obj1.copy()
# Make sure each of the tags were replicated
self.assertTrue("plugh" in obj2.tags.all())
self.assertTrue("plugh" in obj2.tags.get(category="adventure"))
self.assertTrue("xyzzy" in obj2.tags.all())
# Make sure each of the attributes were replicated
self.assertEqual(obj2.attributes.get(key="phrase"), "xyzzy")
self.assertEqual(self.obj1.attributes.get(key="phrase", category="adventure"), "plugh")
self.assertEqual(obj2.attributes.get(key="phrase", category="adventure"), "plugh")

View file

@ -1235,7 +1235,7 @@ def _attr_select(caller, attrstr):
attr_tup = _get_tup_by_attrname(caller, attrname)
if attr_tup:
return "node_examine_entity", {"text": _display_attribute(attr_tup), "back": "attrs"}
return ("node_examine_entity", {"text": _display_attribute(attr_tup), "back": "attrs"})
else:
caller.msg("Attribute not found.")
return "node_attrs"
@ -1260,7 +1260,7 @@ def _attrs_actions(caller, raw_inp, **kwargs):
if action and attr_tup:
if action == "examine":
return "node_examine_entity", {"text": _display_attribute(attr_tup), "back": "attrs"}
return ("node_examine_entity", {"text": _display_attribute(attr_tup), "back": "attrs"})
elif action == "remove":
res = _add_attr(caller, attrname, delete=True)
caller.msg(res)
@ -1439,7 +1439,7 @@ def _tags_actions(caller, raw_inp, **kwargs):
if tag_tup:
if action == "examine":
return "node_examine_entity", {"text": _display_tag(tag_tup), "back": "tags"}
return ("node_examine_entity", {"text": _display_tag(tag_tup), "back": "tags"})
elif action == "remove":
res = _add_tag(caller, tagname, delete=True)
caller.msg(res)
@ -1510,7 +1510,7 @@ def _locks_display(caller, lock):
def _lock_select(caller, lockstr):
return "node_examine_entity", {"text": _locks_display(caller, lockstr), "back": "locks"}
return ("node_examine_entity", {"text": _locks_display(caller, lockstr), "back": "locks"})
def _lock_add(caller, lock, **kwargs):
@ -1552,7 +1552,7 @@ def _locks_actions(caller, raw_inp, **kwargs):
if lock:
if action == "examine":
return "node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"}
return ("node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"})
elif action == "remove":
ret = _lock_add(caller, lock, delete=True)
caller.msg(ret)
@ -1645,7 +1645,10 @@ def _display_perm(caller, permission, only_hierarchy=False):
def _permission_select(caller, permission, **kwargs):
return "node_examine_entity", {"text": _display_perm(caller, permission), "back": "permissions"}
return (
"node_examine_entity",
{"text": _display_perm(caller, permission), "back": "permissions"},
)
def _add_perm(caller, perm, **kwargs):
@ -2051,7 +2054,7 @@ def _prototype_locks_actions(caller, raw_inp, **kwargs):
if lock:
if action == "examine":
return "node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"}
return ("node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"})
elif action == "remove":
ret = _prototype_lock_add(caller, lock.strip(), delete=True)
caller.msg(ret)

View file

@ -9,7 +9,7 @@ from evennia.scripts.models import ScriptDB
from evennia.utils import create
from evennia.utils import logger
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
class ScriptHandler(object):
@ -78,11 +78,20 @@ class ScriptHandler(object):
scriptclass, key=key, account=self.obj, autostart=autostart
)
else:
# the normal - adding to an Object
script = create.create_script(scriptclass, key=key, obj=self.obj, autostart=autostart)
# the normal - adding to an Object. We wait to autostart so we can differentiate
# a failing creation from a script that immediately starts/stops.
script = create.create_script(scriptclass, key=key, obj=self.obj, autostart=False)
if not script:
logger.log_err("Script %s could not be created and/or started." % scriptclass)
logger.log_err("Script %s failed to be created/started." % scriptclass)
return False
if autostart:
script.start()
if not script.id:
# this can happen if the script has repeats=1 or calls stop() in at_repeat.
logger.log_info(
"Script %s started and then immediately stopped; "
"it could probably be a normal function." % scriptclass
)
return True
def start(self, key):

View file

@ -8,7 +8,7 @@ ability to run timers.
from twisted.internet.defer import Deferred, maybeDeferred
from twisted.internet.task import LoopingCall
from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from evennia.typeclasses.models import TypeclassBase
from evennia.scripts.models import ScriptDB
from evennia.scripts.manager import ScriptManager
@ -69,7 +69,7 @@ class ExtendedLoopingCall(LoopingCall):
steps if we want.
"""
assert not self.running, "Tried to start an already running " "ExtendedLoopingCall."
assert not self.running, "Tried to start an already running ExtendedLoopingCall."
if interval < 0:
raise ValueError("interval must be >= 0")
self.running = True
@ -107,7 +107,8 @@ class ExtendedLoopingCall(LoopingCall):
if self.start_delay:
self.start_delay = None
self.starttime = self.clock.seconds()
LoopingCall.__call__(self)
if self._deferred:
LoopingCall.__call__(self)
def force_repeat(self):
"""
@ -118,7 +119,7 @@ class ExtendedLoopingCall(LoopingCall):
running.
"""
assert self.running, "Tried to fire an ExtendedLoopingCall " "that was not running."
assert self.running, "Tried to fire an ExtendedLoopingCall that was not running."
self.call.cancel()
self.call = None
self.starttime = self.clock.seconds()
@ -135,11 +136,10 @@ class ExtendedLoopingCall(LoopingCall):
the task is not running.
"""
if self.running:
if self.running and self.interval > 0:
total_runtime = self.clock.seconds() - self.starttime
interval = self.start_delay or self.interval
return interval - (total_runtime % self.interval)
return None
class ScriptBase(ScriptDB, metaclass=TypeclassBase):
@ -162,9 +162,8 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase):
Start task runner.
"""
if self.ndb._task:
return
self.ndb._task = ExtendedLoopingCall(self._step_task)
if not self.ndb._task:
self.ndb._task = ExtendedLoopingCall(self._step_task)
if self.db._paused_time:
# the script was paused; restarting
@ -174,7 +173,8 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase):
)
del self.db._paused_time
del self.db._paused_repeats
else:
elif not self.ndb._task.running:
# starting script anew
self.ndb._task.start(self.db_interval, now=not self.db_start_delay)
@ -186,6 +186,7 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase):
task = self.ndb._task
if task and task.running:
task.stop()
self.ndb._task = None
def _step_errback(self, e):
"""
@ -208,6 +209,9 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase):
Step task runner. No try..except needed due to defer wrap.
"""
if not self.ndb._task:
# if there is no task, we have no business using this method
return
if not self.is_valid():
self.stop()
@ -217,10 +221,13 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase):
self.at_repeat()
# check repeats
callcount = self.ndb._task.callcount
maxcount = self.db_repeats
if maxcount > 0 and maxcount <= callcount:
self.stop()
if self.ndb._task:
# we need to check for the task in case stop() was called
# inside at_repeat() and it already went away.
callcount = self.ndb._task.callcount
maxcount = self.db_repeats
if maxcount > 0 and maxcount <= callcount:
self.stop()
def _step_task(self):
"""
@ -267,13 +274,13 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase):
self.db_key = cdict["key"]
updates.append("db_key")
if cdict.get("interval") and self.interval != cdict["interval"]:
self.db_interval = cdict["interval"]
self.db_interval = max(0, cdict["interval"])
updates.append("db_interval")
if cdict.get("start_delay") and self.start_delay != cdict["start_delay"]:
self.db_start_delay = cdict["start_delay"]
updates.append("db_start_delay")
if cdict.get("repeats") and self.repeats != cdict["repeats"]:
self.db_repeats = cdict["repeats"]
self.db_repeats = max(0, cdict["repeats"])
updates.append("db_repeats")
if cdict.get("persistent") and self.persistent != cdict["persistent"]:
self.db_persistent = cdict["persistent"]
@ -338,9 +345,9 @@ class DefaultScript(ScriptBase):
try:
obj = create.create_script(**kwargs)
except Exception as e:
except Exception:
logger.log_trace()
errors.append("The script '%s' encountered errors and could not be created." % key)
logger.log_err(e)
return obj, errors
@ -562,11 +569,9 @@ class DefaultScript(ScriptBase):
Restarts an already existing/running Script from the
beginning, optionally using different settings. This will
first call the stop hooks, and then the start hooks again.
Args:
interval (int, optional): Allows for changing the interval
of the Script. Given in seconds. if `None`, will use the
already stored interval.
of the Script. Given in seconds. if `None`, will use the already stored interval.
repeats (int, optional): The number of repeats. If unset, will
use the previous setting.
start_delay (bool, optional): If we should wait `interval` seconds
@ -585,6 +590,7 @@ class DefaultScript(ScriptBase):
del self.db._paused_callcount
# set new flags and start over
if interval is not None:
interval = max(0, interval)
self.interval = interval
if repeats is not None:
self.repeats = repeats

View file

@ -96,6 +96,12 @@ def check_errors(settings):
"must now be either None or a dict "
"specifying the properties of the channel to create."
)
if hasattr(settings, "CYCLE_LOGFILES"):
raise DeprecationWarning(
"settings.CYCLE_LOGFILES is unused and should be removed. "
"Use PORTAL/SERVER_LOG_DAY_ROTATION and PORTAL/SERVER_LOG_MAX_SIZE "
"to control log cycling."
)
def check_warnings(settings):
@ -109,3 +115,10 @@ def check_warnings(settings):
print(" [Devel: settings.IN_GAME_ERRORS is True. Turn off in production.]")
if settings.ALLOWED_HOSTS == ["*"]:
print(" [Devel: settings.ALLOWED_HOSTS set to '*' (all). Limit in production.]")
for dbentry in settings.DATABASES.values():
if "psycopg" in dbentry.get("ENGINE", ""):
print(
'Deprecation: postgresql_psycopg2 backend is deprecated". '
"Switch settings.DATABASES to use "
'"ENGINE": "django.db.backends.postgresql instead"'
)

View file

@ -27,6 +27,7 @@ from twisted.protocols import amp
from twisted.internet import reactor, endpoints
import django
from django.core.management import execute_from_command_line
from django.db.utils import ProgrammingError
# Signal processing
SIG = signal.SIGINT
@ -93,7 +94,7 @@ SRESET = chr(19) # shutdown server in reset mode
PYTHON_MIN = "3.7"
TWISTED_MIN = "18.0.0"
DJANGO_MIN = "2.1"
DJANGO_REC = "2.2.5"
DJANGO_REC = "2.2"
try:
sys.path[1] = EVENNIA_ROOT
@ -1152,7 +1153,7 @@ def tail_log_files(filename1, filename2, start_lines1=20, start_lines2=20, rate=
# this happens if the file was cycled or manually deleted/edited.
print(
" ** Log file {filename} has cycled or been edited. "
"Restarting log. ".format(filehandle.name)
"Restarting log. ".format(filename=filehandle.name)
)
new_linecount = 0
old_linecount = 0
@ -1280,7 +1281,7 @@ def check_main_evennia_dependencies():
try:
dversion = ".".join(str(num) for num in django.VERSION if isinstance(num, int))
# only the main version (1.5, not 1.5.4.0)
dversion_main = ".".join(dversion.split(".")[:3])
dversion_main = ".".join(dversion.split(".")[:2])
if LooseVersion(dversion) < LooseVersion(DJANGO_MIN):
print(ERROR_DJANGO_MIN.format(dversion=dversion_main, django_min=DJANGO_MIN))
error = True
@ -1428,12 +1429,17 @@ def create_superuser():
django.core.management.call_command("createsuperuser", interactive=True)
def check_database():
def check_database(always_return=False):
"""
Check so the database exists.
Args:
always_return (bool, optional): If set, will always return True/False
also on critical errors. No output will be printed.
Returns:
exists (bool): `True` if the database exists, otherwise `False`.
"""
# Check so a database exists and is accessible
from django.db import connection
@ -1449,7 +1455,9 @@ def check_database():
try:
AccountDB.objects.get(id=1)
except django.db.utils.OperationalError as e:
except (django.db.utils.OperationalError, ProgrammingError) as e:
if always_return:
return False
print(ERROR_DATABASE.format(traceback=e))
sys.exit()
except AccountDB.DoesNotExist:
@ -1484,7 +1492,7 @@ def check_database():
new.save()
else:
create_superuser()
check_database()
check_database(always_return=always_return)
return True
@ -2246,14 +2254,15 @@ def main():
# pass-through to django manager, but set things up first
check_db = False
need_gamedir = True
# some commands don't require the presence of a game directory to work
if option in ("makemessages", "compilemessages"):
need_gamedir = False
# handle special django commands
if option in ("runserver", "testserver"):
# we don't want the django test-webserver
print(WARNING_RUNSERVER)
if option in ("shell", "check"):
if option in ("makemessages", "compilemessages"):
# some commands don't require the presence of a game directory to work
need_gamedir = False
if option in ("shell", "check", "makemigrations"):
# some django commands requires the database to exist,
# or evennia._init to have run before they work right.
check_db = True
@ -2263,16 +2272,17 @@ def main():
init_game_directory(CURRENT_DIR, check_db=check_db, need_gamedir=need_gamedir)
if option in ("migrate", "makemigrations"):
# we have to launch migrate within the program to make sure migrations
# run within the scope of the launcher (otherwise missing a db will cause errors)
django.core.management.call_command(*([option] + unknown_args))
else:
# pass on to the core django manager - re-parse the entire input line
# but keep 'evennia' as the name instead of django-admin. This is
# an exit condition.
sys.argv[0] = re.sub(r"(-script\.pyw?|\.exe)?$", "", sys.argv[0])
sys.exit(execute_from_command_line())
if option == "migrate":
# we need to bypass some checks here for the first db creation
if not check_database(always_return=True):
django.core.management.call_command(*([option] + unknown_args))
sys.exit(0)
# pass on to the core django manager - re-parse the entire input line
# but keep 'evennia' as the name instead of django-admin. This is
# an exit condition.
sys.argv[0] = re.sub(r"(-script\.pyw?|\.exe)?$", "", sys.argv[0])
sys.exit(execute_from_command_line())
elif not args.tail_log:
# no input; print evennia info (don't pring if we're tailing log)

View file

@ -1,403 +0,0 @@
#!/usr/bin/env python
"""
This runner is controlled by the evennia launcher and should normally
not be launched directly. It manages the two main Evennia processes
(Server and Portal) and most importantly runs a passive, threaded loop
that makes sure to restart Server whenever it shuts down.
Since twistd does not allow for returning an optional exit code we
need to handle the current reload state for server and portal with
flag-files instead. The files, one each for server and portal either
contains True or False indicating if the process should be restarted
upon returning, or not. A process returning != 0 will always stop, no
matter the value of this file.
"""
import os
import sys
from argparse import ArgumentParser
from subprocess import Popen
import queue
import _thread
import evennia
try:
# check if launched with pypy
import __pypy__ as is_pypy
except ImportError:
is_pypy = False
SERVER = None
PORTAL = None
EVENNIA_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
EVENNIA_BIN = os.path.join(EVENNIA_ROOT, "bin")
EVENNIA_LIB = os.path.dirname(evennia.__file__)
SERVER_PY_FILE = os.path.join(EVENNIA_LIB, "server", "server.py")
PORTAL_PY_FILE = os.path.join(EVENNIA_LIB, "server", "portal", "portal.py")
GAMEDIR = None
SERVERDIR = "server"
SERVER_PIDFILE = None
PORTAL_PIDFILE = None
SERVER_RESTART = None
PORTAL_RESTART = None
SERVER_LOGFILE = None
PORTAL_LOGFILE = None
HTTP_LOGFILE = None
PPROFILER_LOGFILE = None
SPROFILER_LOGFILE = None
# messages
CMDLINE_HELP = """
This program manages the running Evennia processes. It is called
by evennia and should not be started manually. Its main task is to
sit and watch the Server and restart it whenever the user reloads.
The runner depends on four files for its operation, two PID files
and two RESTART files for Server and Portal respectively; these
are stored in the game's server/ directory.
"""
PROCESS_ERROR = """
{component} process error: {traceback}.
"""
PROCESS_IOERROR = """
{component} IOError: {traceback}
One possible explanation is that 'twistd' was not found.
"""
PROCESS_RESTART = "{component} restarting ..."
PROCESS_DOEXIT = "Deferring to external runner."
# Functions
def set_restart_mode(restart_file, flag="reload"):
"""
This sets a flag file for the restart mode.
"""
with open(restart_file, "w") as f:
f.write(str(flag))
def getenv():
"""
Get current environment and add PYTHONPATH
"""
sep = ";" if os.name == "nt" else ":"
env = os.environ.copy()
sys.path.insert(0, GAMEDIR)
env["PYTHONPATH"] = sep.join(sys.path)
return env
def get_restart_mode(restart_file):
"""
Parse the server/portal restart status
"""
if os.path.exists(restart_file):
with open(restart_file, "r") as f:
return f.read()
return "shutdown"
def get_pid(pidfile):
"""
Get the PID (Process ID) by trying to access
an PID file.
"""
pid = None
if os.path.exists(pidfile):
with open(pidfile, "r") as f:
pid = f.read()
return pid
def cycle_logfile(logfile):
"""
Rotate the old log files to <filename>.old
"""
logfile_old = logfile + ".old"
if os.path.exists(logfile):
# Cycle the old logfiles to *.old
if os.path.exists(logfile_old):
# E.g. Windows don't support rename-replace
os.remove(logfile_old)
os.rename(logfile, logfile_old)
# Start program management
def start_services(server_argv, portal_argv, doexit=False):
"""
This calls a threaded loop that launches the Portal and Server
and then restarts them when they finish.
"""
global SERVER, PORTAL
processes = queue.Queue()
def server_waiter(queue):
try:
rc = Popen(server_argv, env=getenv()).wait()
except Exception as e:
print(PROCESS_ERROR.format(component="Server", traceback=e))
return
# this signals the controller that the program finished
queue.put(("server_stopped", rc))
def portal_waiter(queue):
try:
rc = Popen(portal_argv, env=getenv()).wait()
except Exception as e:
print(PROCESS_ERROR.format(component="Portal", traceback=e))
return
# this signals the controller that the program finished
queue.put(("portal_stopped", rc))
if portal_argv:
try:
if not doexit and get_restart_mode(PORTAL_RESTART) == "True":
# start portal as interactive, reloadable thread
PORTAL = _thread.start_new_thread(portal_waiter, (processes,))
else:
# normal operation: start portal as a daemon;
# we don't care to monitor it for restart
PORTAL = Popen(portal_argv, env=getenv())
except IOError as e:
print(PROCESS_IOERROR.format(component="Portal", traceback=e))
return
try:
if server_argv:
if doexit:
SERVER = Popen(server_argv, env=getenv())
else:
# start server as a reloadable thread
SERVER = _thread.start_new_thread(server_waiter, (processes,))
except IOError as e:
print(PROCESS_IOERROR.format(component="Server", traceback=e))
return
if doexit:
# Exit immediately
return
# Reload loop
while True:
# this blocks until something is actually returned.
from twisted.internet.error import ReactorNotRunning
try:
try:
message, rc = processes.get()
except KeyboardInterrupt:
# this only matters in interactive mode
break
# restart only if process stopped cleanly
if (
message == "server_stopped"
and int(rc) == 0
and get_restart_mode(SERVER_RESTART) in ("True", "reload", "reset")
):
print(PROCESS_RESTART.format(component="Server"))
SERVER = _thread.start_new_thread(server_waiter, (processes,))
continue
# normally the portal is not reloaded since it's run as a daemon.
if (
message == "portal_stopped"
and int(rc) == 0
and get_restart_mode(PORTAL_RESTART) == "True"
):
print(PROCESS_RESTART.format(component="Portal"))
PORTAL = _thread.start_new_thread(portal_waiter, (processes,))
continue
break
except ReactorNotRunning:
break
def main():
"""
This handles the command line input of the runner, usually created by
the evennia launcher
"""
parser = ArgumentParser(description=CMDLINE_HELP)
parser.add_argument(
"--noserver",
action="store_true",
dest="noserver",
default=False,
help="Do not start Server process",
)
parser.add_argument(
"--noportal",
action="store_true",
dest="noportal",
default=False,
help="Do not start Portal process",
)
parser.add_argument(
"--logserver",
action="store_true",
dest="logserver",
default=False,
help="Log Server output to logfile",
)
parser.add_argument(
"--iserver",
action="store_true",
dest="iserver",
default=False,
help="Server in interactive mode",
)
parser.add_argument(
"--iportal",
action="store_true",
dest="iportal",
default=False,
help="Portal in interactive mode",
)
parser.add_argument(
"--pserver", action="store_true", dest="pserver", default=False, help="Profile Server"
)
parser.add_argument(
"--pportal", action="store_true", dest="pportal", default=False, help="Profile Portal"
)
parser.add_argument(
"--nologcycle",
action="store_false",
dest="nologcycle",
default=True,
help="Do not cycle log files",
)
parser.add_argument(
"--doexit",
action="store_true",
dest="doexit",
default=False,
help="Immediately exit after processes have started.",
)
parser.add_argument("gamedir", help="path to game dir")
parser.add_argument("twistdbinary", help="path to twistd binary")
parser.add_argument("slogfile", help="path to server log file")
parser.add_argument("plogfile", help="path to portal log file")
parser.add_argument("hlogfile", help="path to http log file")
args = parser.parse_args()
global GAMEDIR
global SERVER_LOGFILE, PORTAL_LOGFILE, HTTP_LOGFILE
global SERVER_PIDFILE, PORTAL_PIDFILE
global SERVER_RESTART, PORTAL_RESTART
global SPROFILER_LOGFILE, PPROFILER_LOGFILE
GAMEDIR = args.gamedir
sys.path.insert(1, os.path.join(GAMEDIR, SERVERDIR))
SERVER_PIDFILE = os.path.join(GAMEDIR, SERVERDIR, "server.pid")
PORTAL_PIDFILE = os.path.join(GAMEDIR, SERVERDIR, "portal.pid")
SERVER_RESTART = os.path.join(GAMEDIR, SERVERDIR, "server.restart")
PORTAL_RESTART = os.path.join(GAMEDIR, SERVERDIR, "portal.restart")
SERVER_LOGFILE = args.slogfile
PORTAL_LOGFILE = args.plogfile
HTTP_LOGFILE = args.hlogfile
TWISTED_BINARY = args.twistdbinary
SPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "server.prof")
PPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "portal.prof")
# set up default project calls
server_argv = [
TWISTED_BINARY,
"--nodaemon",
"--logfile=%s" % SERVER_LOGFILE,
"--pidfile=%s" % SERVER_PIDFILE,
"--python=%s" % SERVER_PY_FILE,
]
portal_argv = [
TWISTED_BINARY,
"--logfile=%s" % PORTAL_LOGFILE,
"--pidfile=%s" % PORTAL_PIDFILE,
"--python=%s" % PORTAL_PY_FILE,
]
# Profiling settings (read file from python shell e.g with
# p = pstats.Stats('server.prof')
pserver_argv = ["--savestats", "--profiler=cprofile", "--profile=%s" % SPROFILER_LOGFILE]
pportal_argv = ["--savestats", "--profiler=cprofile", "--profile=%s" % PPROFILER_LOGFILE]
# Server
pid = get_pid(SERVER_PIDFILE)
if pid and not args.noserver:
print(
"\nEvennia Server is already running as process %(pid)s. Not restarted." % {"pid": pid}
)
args.noserver = True
if args.noserver:
server_argv = None
else:
set_restart_mode(SERVER_RESTART, "shutdown")
if not args.logserver:
# don't log to server logfile
del server_argv[2]
print("\nStarting Evennia Server (output to stdout).")
else:
if not args.nologcycle:
cycle_logfile(SERVER_LOGFILE)
print("\nStarting Evennia Server (output to server logfile).")
if args.pserver:
server_argv.extend(pserver_argv)
print("\nRunning Evennia Server under cProfile.")
# Portal
pid = get_pid(PORTAL_PIDFILE)
if pid and not args.noportal:
print(
"\nEvennia Portal is already running as process %(pid)s. Not restarted." % {"pid": pid}
)
args.noportal = True
if args.noportal:
portal_argv = None
else:
if args.iportal:
# make portal interactive
portal_argv[1] = "--nodaemon"
set_restart_mode(PORTAL_RESTART, True)
print("\nStarting Evennia Portal in non-Daemon mode (output to stdout).")
else:
if not args.nologcycle:
cycle_logfile(PORTAL_LOGFILE)
cycle_logfile(HTTP_LOGFILE)
set_restart_mode(PORTAL_RESTART, False)
print("\nStarting Evennia Portal in Daemon mode (output to portal logfile).")
if args.pportal:
portal_argv.extend(pportal_argv)
print("\nRunning Evennia Portal under cProfile.")
if args.doexit:
print(PROCESS_DOEXIT)
# Windows fixes (Windows don't support pidfiles natively)
if os.name == "nt":
if server_argv:
del server_argv[-2]
if portal_argv:
del portal_argv[-2]
# Start processes
start_services(server_argv, portal_argv, doexit=args.doexit)
if __name__ == "__main__":
main()

View file

@ -1,8 +1,8 @@
# Evennia Game Index Client
Greg Taylor 2016
Greg Taylor 2016, Griatch 2020
This contrib features a client for the [Evennia Game Index]
This is a client for the [Evennia Game Index]
(http://evennia-game-index.appspot.com/), a listing of games built on
Evennia. By listing your game on the index, you make it easy for other
people in the community to discover your creation.
@ -14,74 +14,24 @@ on remedying this.*
## Listing your Game
To list your game, you'll need to enable the Evennia Game Index client.
Start by `cd`'ing to your game directory. From there, open up
`server/conf/server_services_plugins.py`. It might look something like this
if you don't have any other optional add-ons enabled:
To list your game, go to your game dir and run
```python
"""
Server plugin services
evennia connections
This plugin module can define user-created services for the Server to
start.
Follow the prompts to add details to the listing. Use `evennia reload`. In your log (visible with `evennia --log`
you should see a note that info has been sent to the game index.
This module must handle all imports and setups required to start a
twisted service (see examples in evennia.server.server). It must also
contain a function start_plugin_services(application). Evennia will
call this function with the main Server application (so your services
can be added to it). The function should not return anything. Plugin
services are started last in the Server startup process.
"""
## Detailed settings
def start_plugin_services(server):
"""
This hook is called by Evennia, last in the Server startup process.
server - a reference to the main server application.
"""
pass
```
To enable the client, import `EvenniaGameIndexService` and fire it up after the
Evennia server has finished starting:
```python
"""
Server plugin services
This plugin module can define user-created services for the Server to
start.
This module must handle all imports and setups required to start a
twisted service (see examples in evennia.server.server). It must also
contain a function start_plugin_services(application). Evennia will
call this function with the main Server application (so your services
can be added to it). The function should not return anything. Plugin
services are started last in the Server startup process.
"""
from evennia.contrib.egi_client import EvenniaGameIndexService
def start_plugin_services(server):
"""
This hook is called by Evennia, last in the Server startup process.
server - a reference to the main server application.
"""
egi_service = EvenniaGameIndexService()
server.services.addService(egi_service)
```
Next, configure your game listing by opening up `server/conf/settings.py` and
If you don't want to use the wizard you can configure your game listing by opening up `server/conf/settings.py` and
using the following as a starting point:
```python
######################################################################
# Contrib config
# Game index
######################################################################
GAME_INDEX_ENABLED = True
GAME_INDEX_LISTING = {
'game_status': 'pre-alpha',
# Optional, comment out or remove if N/A

View file

@ -9,7 +9,7 @@ Everything starts at handle_setup()
import time
from django.conf import settings
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from evennia.accounts.models import AccountDB
from evennia.server.models import ServerConfig
from evennia.utils import create, logger

View file

@ -576,8 +576,7 @@ def msdp_list(session, *args, **kwargs):
fieldnames = [tup[1] for tup in monitor_infos]
session.msg(reported_variables=(fieldnames, {}))
if "sendable_variables" in args_lower:
# no default sendable variables
session.msg(sendable_variables=([], {}))
session.msg(sendable_variables=(_monitorable, {}))
def msdp_report(session, *args, **kwargs):
@ -597,6 +596,17 @@ def msdp_unreport(session, *args, **kwargs):
unmonitor(session, *args, **kwargs)
def msdp_send(session, *args, **kwargs):
"""
MSDP SEND command
"""
out = {}
for varname in args:
if varname.lower() in _monitorable:
out[varname] = _monitorable[varname.lower()]
session.msg(send=((), out))
# client specific

View file

@ -314,7 +314,9 @@ class AMPMultiConnectionProtocol(amp.AMP):
try:
super(AMPMultiConnectionProtocol, self).dataReceived(data)
except KeyError:
_get_logger().log_trace("Discarded incoming partial data: {}".format(to_str(data)))
_get_logger().log_trace(
"Discarded incoming partial (packed) data (len {})".format(len(data))
)
elif self.multibatches:
# invalid AMP, but we have a pending multi-batch that is not yet complete
if data[-2:] == NULNUL:
@ -323,7 +325,9 @@ class AMPMultiConnectionProtocol(amp.AMP):
try:
super(AMPMultiConnectionProtocol, self).dataReceived(data)
except KeyError:
_get_logger().log_trace("Discarded incoming multi-batch data:".format(to_str(data)))
_get_logger().log_trace(
"Discarded incoming multi-batch (packed) data (len {})".format(len(data))
)
else:
# not an AMP communication, return warning
self.transport.write(_HTTP_WARNING)

View file

@ -15,9 +15,10 @@ This protocol is implemented by the telnet protocol importing
mccp_compress and calling it from its write methods.
"""
import zlib
from twisted.python.compat import _bytesChr as chr
# negotiations for v1 and v2 of the protocol
MCCP = b"\x56"
MCCP = chr(86) # b"\x56"
FLUSH = zlib.Z_SYNC_FLUSH

View file

@ -12,10 +12,11 @@ active players and so on.
"""
from django.conf import settings
from evennia.utils import utils
from twisted.python.compat import _bytesChr as bchr
MSSP = b"\x46"
MSSP_VAR = b"\x01"
MSSP_VAL = b"\x02"
MSSP = bchr(70) # b"\x46"
MSSP_VAR = bchr(1) # b"\x01"
MSSP_VAL = bchr(2) # b"\x02"
# try to get the customized mssp info, if it exists.
MSSPTable_CUSTOM = utils.variable_from_module(settings.MSSP_META_MODULE, "MSSPTable", default={})
@ -86,7 +87,7 @@ class Mssp(object):
"PLAYERS": self.get_player_count,
"UPTIME": self.get_uptime,
"PORT": list(
reversed(settings.TELNET_PORTS)
str(port) for port in reversed(settings.TELNET_PORTS)
), # most important port should be last in list
# Evennia auto-filled
"CRAWL DELAY": "-1",
@ -119,10 +120,15 @@ class Mssp(object):
if utils.is_iter(value):
for partval in value:
varlist += (
MSSP_VAR + bytes(variable, "utf-8") + MSSP_VAL + bytes(partval, "utf-8")
MSSP_VAR
+ bytes(str(variable), "utf-8")
+ MSSP_VAL
+ bytes(str(partval), "utf-8")
)
else:
varlist += MSSP_VAR + bytes(variable, "utf-8") + MSSP_VAL + bytes(value, "utf-8")
varlist += (
MSSP_VAR + bytes(str(variable), "utf-8") + MSSP_VAL + bytes(str(value), "utf-8")
)
# send to crawler by subnegotiation
self.protocol.requestNegotiation(MSSP, varlist)

View file

@ -14,11 +14,12 @@ http://www.gammon.com.au/mushclient/addingservermxp.htm
"""
import re
from twisted.python.compat import _bytesChr as bchr
LINKS_SUB = re.compile(r"\|lc(.*?)\|lt(.*?)\|le", re.DOTALL)
# MXP Telnet option
MXP = b"\x5b"
MXP = bchr(91) # b"\x5b"
MXP_TEMPSECURE = "\x1B[4z"
MXP_SEND = MXP_TEMPSECURE + '<SEND HREF="\\1">' + "\\2" + MXP_TEMPSECURE + "</SEND>"

View file

@ -11,9 +11,10 @@ client and update it when the size changes
"""
from codecs import encode as codecs_encode
from django.conf import settings
from twisted.python.compat import _bytesChr as bchr
NAWS = b"\x1f"
IS = b"\x00"
NAWS = bchr(31) # b"\x1f"
IS = bchr(0) # b"\x00"
# default taken from telnet specification
DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
DEFAULT_HEIGHT = settings.CLIENT_DEFAULT_HEIGHT

View file

@ -95,6 +95,16 @@ INFO_DICT = {
"webserver_internal": [],
}
try:
WEB_PLUGINS_MODULE = mod_import(settings.WEB_PLUGINS_MODULE)
except ImportError:
WEB_PLUGINS_MODULE = None
INFO_DICT["errors"] = (
"WARNING: settings.WEB_PLUGINS_MODULE not found - "
"copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf."
)
# -------------------------------------------------------------
# Portal Service object
# -------------------------------------------------------------
@ -190,7 +200,6 @@ class Portal(object):
self.sessions.disconnect_all()
if _stop_server:
self.amp_protocol.stop_server(mode="shutdown")
if not _reactor_stopping:
# shutting down the reactor will trigger another signal. We set
# a flag to avoid loops.
@ -213,7 +222,10 @@ application = service.Application("Portal")
if "--nodaemon" not in sys.argv:
logfile = logger.WeeklyLogFile(
os.path.basename(settings.PORTAL_LOG_FILE), os.path.dirname(settings.PORTAL_LOG_FILE)
os.path.basename(settings.PORTAL_LOG_FILE),
os.path.dirname(settings.PORTAL_LOG_FILE),
day_rotation=settings.PORTAL_LOG_DAY_ROTATION,
max_size=settings.PORTAL_LOG_MAX_SIZE,
)
application.setComponent(ILogObserver, logger.PortalLogObserver(logfile).emit)
@ -376,6 +388,14 @@ if WEBSERVER_ENABLED:
webclientstr = "webclient-websocket%s: %s" % (w_ifacestr, port)
INFO_DICT["webclient"].append(webclientstr)
if WEB_PLUGINS_MODULE:
try:
web_root = WEB_PLUGINS_MODULE.at_webproxy_root_creation(web_root)
except Exception as e: # Legacy user has not added an at_webproxy_root_creation function in existing web plugins file
INFO_DICT["errors"] = (
"WARNING: WEB_PLUGINS_MODULE is enabled but at_webproxy_root_creation() not found - "
"copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf."
)
web_root = Website(web_root, logPath=settings.HTTP_LOG_FILE)
web_root.is_portal = True
proxy_service = internet.TCPServer(proxyport, web_root, interface=interface)

View file

@ -13,7 +13,9 @@ It is set as the NOGOAHEAD protocol_flag option.
http://www.faqs.org/rfcs/rfc858.html
"""
SUPPRESS_GA = b"\x03"
from twisted.python.compat import _bytesChr as bchr
SUPPRESS_GA = bchr(3) # b"\x03"
# default taken from telnet specification

View file

@ -40,6 +40,21 @@ _RE_SCREENREADER_REGEX = re.compile(
)
_IDLE_COMMAND = str.encode(settings.IDLE_COMMAND + "\n")
# identify HTTP indata
_HTTP_REGEX = re.compile(
b"(GET|HEAD|POST|PUT|DELETE|TRACE|OPTIONS|CONNECT|PATCH) (.*? HTTP/[0-9]\.[0-9])", re.I
)
_HTTP_WARNING = bytes(
"""
This is Evennia's Telnet port and cannot be used for regular HTTP traffic.
Use a telnet client to connect here and point your browser to the server's
dedicated web port instead.
""".strip(),
"utf-8",
)
class TelnetServerFactory(protocol.ServerFactory):
"This is only to name this better in logs"
@ -60,13 +75,21 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
self.protocol_key = "telnet"
super().__init__(*args, **kwargs)
def dataReceived(self, data):
"""
Unused by default, but a good place to put debug printouts
of incoming data.
"""
# print(f"telnet dataReceived: {data}")
super().dataReceived(data)
def connectionMade(self):
"""
This is called when the connection is first established.
"""
# important in order to work normally with standard telnet
self.do(LINEMODE)
self.do(LINEMODE).addErrback(self._wont_linemode)
# initialize the session
self.line_buffer = b""
client_address = self.transport.client
@ -111,6 +134,14 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
self.nop_keep_alive = None
self.toggle_nop_keepalive()
def _wont_linemode(self, *args):
"""
Client refuses do(linemode). This is common for MUD-specific
clients, but we must ask for the sake of raw telnet. We ignore
this error.
"""
pass
def _send_nop_keepalive(self):
"""Send NOP keepalive unless flag is set"""
if self.protocol_flags.get("NOPKEEPALIVE"):
@ -178,6 +209,16 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
or option == suppress_ga.SUPPRESS_GA
)
def disableRemote(self, option):
return (
option == LINEMODE
or option == ttype.TTYPE
or option == naws.NAWS
or option == MCCP
or option == mssp.MSSP
or option == suppress_ga.SUPPRESS_GA
)
def enableLocal(self, option):
"""
Call to allow the activation of options for this protocol
@ -204,13 +245,20 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
option (char): The telnet option to disable locally.
"""
if option == LINEMODE:
return True
if option == ECHO:
return True
if option == MCCP:
self.mccp.no_mccp(option)
return True
else:
return super().disableLocal(option)
try:
return super().disableLocal(option)
except Exception:
from evennia.utils import logger
logger.log_trace()
def connectionLost(self, reason):
"""
@ -246,6 +294,14 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
data = [_IDLE_COMMAND]
else:
data = _RE_LINEBREAK.split(data)
if len(data) > 2 and _HTTP_REGEX.match(data[0]):
# guard against HTTP request on the Telnet port; we
# block and kill the connection.
self.transport.write(_HTTP_WARNING)
self.transport.loseConnection()
return
if self.line_buffer and len(data) > 1:
# buffer exists, it is terminated by the first line feed
data[0] = self.line_buffer + data[0]

View file

@ -28,22 +28,24 @@ header where applicable.
import re
import json
from evennia.utils.utils import is_iter
# MSDP-relevant telnet cmd/opt-codes
MSDP = b"\x45"
MSDP_VAR = b"\x01" # ^A
MSDP_VAL = b"\x02" # ^B
MSDP_TABLE_OPEN = b"\x03" # ^C
MSDP_TABLE_CLOSE = b"\x04" # ^D
MSDP_ARRAY_OPEN = b"\x05" # ^E
MSDP_ARRAY_CLOSE = b"\x06" # ^F
# GMCP
GMCP = b"\xc9"
from twisted.python.compat import _bytesChr as bchr
# General Telnet
from twisted.conch.telnet import IAC, SB, SE
# MSDP-relevant telnet cmd/opt-codes
MSDP = bchr(69)
MSDP_VAR = bchr(1)
MSDP_VAL = bchr(2)
MSDP_TABLE_OPEN = bchr(3)
MSDP_TABLE_CLOSE = bchr(4)
MSDP_ARRAY_OPEN = bchr(5)
MSDP_ARRAY_CLOSE = bchr(6)
# GMCP
GMCP = bchr(201)
# pre-compiled regexes
# returns 2-tuple
@ -168,7 +170,7 @@ class TelnetOOB(object):
"""
msdp_cmdname = "{msdp_var}{msdp_cmdname}{msdp_val}".format(
msdp_var=MSDP_VAR, msdp_cmdname=cmdname, msdp_val=MSDP_VAL
msdp_var=MSDP_VAR.decode(), msdp_cmdname=cmdname, msdp_val=MSDP_VAL.decode()
)
if not (args or kwargs):
@ -186,9 +188,9 @@ class TelnetOOB(object):
"{msdp_array_open}"
"{msdp_args}"
"{msdp_array_close}".format(
msdp_array_open=MSDP_ARRAY_OPEN,
msdp_array_close=MSDP_ARRAY_CLOSE,
msdp_args="".join("%s%s" % (MSDP_VAL, json.dumps(val)) for val in args),
msdp_array_open=MSDP_ARRAY_OPEN.decode(),
msdp_array_close=MSDP_ARRAY_CLOSE.decode(),
msdp_args="".join("%s%s" % (MSDP_VAL.decode(), val) for val in args),
)
)
@ -199,10 +201,10 @@ class TelnetOOB(object):
"{msdp_table_open}"
"{msdp_kwargs}"
"{msdp_table_close}".format(
msdp_table_open=MSDP_TABLE_OPEN,
msdp_table_close=MSDP_TABLE_CLOSE,
msdp_table_open=MSDP_TABLE_OPEN.decode(),
msdp_table_close=MSDP_TABLE_CLOSE.decode(),
msdp_kwargs="".join(
"%s%s%s%s" % (MSDP_VAR, key, MSDP_VAL, json.dumps(val))
"%s%s%s%s" % (MSDP_VAR.decode(), key, MSDP_VAL.decode(), val)
for key, val in kwargs.items()
),
)

View file

@ -100,11 +100,11 @@ def verify_or_create_SSL_key_and_cert(keyfile, certfile):
keypair.generate_key(crypto.TYPE_RSA, _PRIVATE_KEY_LENGTH)
with open(_PRIVATE_KEY_FILE, "wt") as pfile:
pfile.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, keypair))
pfile.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, keypair).decode("utf-8"))
print("Created SSL private key in '{}'.".format(_PRIVATE_KEY_FILE))
with open(_PUBLIC_KEY_FILE, "wt") as pfile:
pfile.write(crypto.dump_publickey(crypto.FILETYPE_PEM, keypair))
pfile.write(crypto.dump_publickey(crypto.FILETYPE_PEM, keypair).decode("utf-8"))
print("Created SSL public key in '{}'.".format(_PUBLIC_KEY_FILE))
except Exception as err:
@ -128,7 +128,7 @@ def verify_or_create_SSL_key_and_cert(keyfile, certfile):
cert.sign(keypair, "sha1")
with open(_CERTIFICATE_FILE, "wt") as cfile:
cfile.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert))
cfile.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode("utf-8"))
print("Created SSL certificate in '{}'.".format(_CERTIFICATE_FILE))
except Exception as err:

View file

@ -11,6 +11,7 @@ except ImportError:
import sys
import string
import mock
import pickle
from mock import Mock, MagicMock
from evennia.server.portal import irc
@ -50,11 +51,21 @@ class TestAMPServer(TwistedTestCase):
self.proto.makeConnection(self.transport)
self.proto.data_to_server(MsgServer2Portal, 1, test=2)
byte_out = (
b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgServer2Portal\x00\x0b"
b"packed_data\x00 x\xdak`\x99*\xc8\x00\x01\xde\x8c\xb5SzXJR"
b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00V:\x07t\x00\x00"
)
if pickle.HIGHEST_PROTOCOL == 5:
# Python 3.8+
byte_out = (
b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgServer2Portal\x00\x0b"
b"packed_data\x00 x\xdak`\x9d*\xc8\x00\x01\xde\x8c\xb5SzXJR"
b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00VU\x07u\x00\x00"
)
elif pickle.HIGHEST_PROTOCOL == 4:
# Python 3.7
byte_out = (
b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgServer2Portal\x00\x0b"
b"packed_data\x00 x\xdak`\x99*\xc8\x00\x01\xde\x8c\xb5SzXJR"
b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00V:\x07t\x00\x00"
)
self.transport.write.assert_called_with(byte_out)
with mock.patch("evennia.server.portal.amp.amp.AMP.dataReceived") as mocked_amprecv:
self.proto.dataReceived(byte_out)
@ -64,11 +75,20 @@ class TestAMPServer(TwistedTestCase):
self.proto.makeConnection(self.transport)
self.proto.data_to_server(MsgPortal2Server, 1, test=2)
byte_out = (
b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgPortal2Server\x00\x0b"
b"packed_data\x00 x\xdak`\x99*\xc8\x00\x01\xde\x8c\xb5SzXJR"
b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00V:\x07t\x00\x00"
)
if pickle.HIGHEST_PROTOCOL == 5:
# Python 3.8+
byte_out = (
b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgPortal2Server\x00\x0b"
b"packed_data\x00 x\xdak`\x9d*\xc8\x00\x01\xde\x8c\xb5SzXJR"
b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00VU\x07u\x00\x00"
)
elif pickle.HIGHEST_PROTOCOL == 4:
# Python 3.7
byte_out = (
b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgPortal2Server\x00\x0b"
b"packed_data\x00 x\xdak`\x99*\xc8\x00\x01\xde\x8c\xb5SzXJR"
b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00V:\x07t\x00\x00"
)
self.transport.write.assert_called_with(byte_out)
with mock.patch("evennia.server.portal.amp.amp.AMP.dataReceived") as mocked_amprecv:
self.proto.dataReceived(byte_out)
@ -82,28 +102,28 @@ class TestAMPServer(TwistedTestCase):
outstr = "test" * AMP_MAXLEN
self.proto.data_to_server(MsgServer2Portal, 1, test=outstr)
if sys.version < "3.7":
if pickle.HIGHEST_PROTOCOL == 5:
# Python 3.8+
self.transport.write.assert_called_with(
b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgServer2Portal\x00\x0bpacked_data"
b"\x00xx\xda\xed\xc6\xc1\t\x800\x10\x00\xc1\x13\xaf\x01\xeb\xb2\x01\x1bH"
b'\x05\xe6+X\x80\xcf\xd8m@I\x1d\x99\x85\x81\xbd\xf3\xdd"c\xb4/W{'
b"\xb2\x96\xb3\xb6\xa3\x7fk\x8c\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00`\x0e?Pv\x02\x16\x00\r"
b"packed_data.2\x00Zx\xda\xed\xc3\x01\r\x00\x00\x08\xc0\xa0\xb4&\xf0\xfdg\x10a"
b"\xa3\xd9RUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU\xf5\xfb"
b"\x03m\xe0\x06\x1d\x00\rpacked_data.3\x00Zx\xda\xed\xc3\x01\r\x00\x00\x08\xc0\xa0"
b"\xb4&\xf0\xfdg\x10a\xa3fSUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU"
b"UUUUU\xf5\xfb\x03n\x1c\x06\x1e\x00\rpacked_data.4\x00Zx\xda\xed\xc3\x01\t\x00"
b"\x00\x0c\x03\xa0\xb4O\xb0\xf5gA\xae`\xda\x8b\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa"
b"\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa"
b"\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa"
b"\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xdf\x0fnI"
b"\x06,\x00\rpacked_data.5\x00\x14x\xdaK-.)I\xc5\x8e\xa7\x14\xb7M\xd1\x03\x00"
b"\xe7s\x0e\x1c\x00\x00"
b"\x00wx\xda\xed\xc6\xc1\t\x80 \x00@Q#=5Z\x0b\xb8\x80\x13\xe85h\x80\x8e\xbam`Dc\xf4><\xf8g"
b"\x1a[\xf8\xda\x97\xa3_\xb1\x95\xdaz\xbe\xe7\x1a\xde\x03\x00\x00\x00\x00\x00\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xe0\x1f\x1eP\x1d\x02\r\x00\rpacked_data.2"
b"\x00Zx\xda\xed\xc3\x01\r\x00\x00\x08\xc0\xa0\xb4&\xf0\xfdg\x10a\xa3"
b"\xd9RUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU\xf5\xfb\x03m\xe0\x06"
b"\x1d\x00\rpacked_data.3\x00Zx\xda\xed\xc3\x01\r\x00\x00\x08\xc0\xa0\xb4&\xf0\xfdg\x10a"
b"\xa3fSUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU\xf5\xfb\x03n\x1c"
b"\x06\x1e\x00\rpacked_data.4\x00Zx\xda\xed\xc3\x01\t\x00\x00\x0c\x03\xa0\xb4O\xb0\xf5gA"
b"\xae`\xda\x8b\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa"
b"\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa"
b"\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa"
b"\xaa\xaa\xaa\xdf\x0fnI\x06,\x00\rpacked_data.5\x00\x18x\xdaK-.)I\xc5\x8e\xa7\xb22@\xc0"
b"\x94\xe2\xb6)z\x00Z\x1e\x0e\xb6\x00\x00"
)
else:
elif pickle.HIGHEST_PROTOCOL == 4:
# Python 3.7
self.transport.write.assert_called_with(
b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgServer2Portal\x00\x0bpacked_data"
b"\x00wx\xda\xed\xc6\xc1\t\x80 \x00@Q#o\x8e\xd6\x02-\xe0\x04z\r\x1a\xa0\xa3m+$\xd2"

View file

@ -10,11 +10,12 @@ etc. If the client does not support TTYPE, this will be ignored.
All data will be stored on the protocol's protocol_flags dictionary,
under the 'TTYPE' key.
"""
from twisted.python.compat import _bytesChr as bchr
# telnet option codes
TTYPE = b"\x18"
IS = b"\x00"
SEND = b"\x01"
TTYPE = bchr(24) # b"\x18"
IS = bchr(0) # b"\x00"
SEND = bchr(1) # b"\x01"
# terminal capabilities and their codes
MTTS = [

View file

@ -29,16 +29,26 @@ _RE_SCREENREADER_REGEX = re.compile(
r"%s" % settings.SCREENREADER_REGEX_STRIP, re.DOTALL + re.MULTILINE
)
_CLIENT_SESSIONS = mod_import(settings.SESSION_ENGINE).SessionStore
_UPSTREAM_IPS = settings.UPSTREAM_IPS
# Status Code 1000: Normal Closure
# called when the connection was closed through JavaScript
CLOSE_NORMAL = WebSocketServerProtocol.CLOSE_STATUS_CODE_NORMAL
# Status Code 1001: Going Away
# called when the browser is navigating away from the page
GOING_AWAY = WebSocketServerProtocol.CLOSE_STATUS_CODE_GOING_AWAY
class WebSocketClient(WebSocketServerProtocol, Session):
"""
Implements the server-side of the Websocket connection.
"""
# nonce value, used to prevent the webclient from erasing the
# webclient_authenticated_uid value of csession on disconnect
nonce = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.protocol_key = "webclient/websocket"
@ -75,14 +85,26 @@ class WebSocketClient(WebSocketServerProtocol, Session):
"""
client_address = self.transport.client
client_address = client_address[0] if client_address else None
if client_address in _UPSTREAM_IPS and "x-forwarded-for" in self.http_headers:
addresses = [x.strip() for x in self.http_headers["x-forwarded-for"].split(",")]
addresses.reverse()
for addr in addresses:
if addr not in _UPSTREAM_IPS:
client_address = addr
break
self.init_session("websocket", client_address, self.factory.sessionhandler)
csession = self.get_client_session() # this sets self.csessid
csessid = self.csessid
uid = csession and csession.get("webclient_authenticated_uid", None)
nonce = csession and csession.get("webclient_authenticated_nonce", 0)
if uid:
# the client session is already logged in.
self.uid = uid
self.nonce = nonce
self.logged_in = True
for old_session in self.sessionhandler.sessions_from_csessid(csessid):
@ -111,12 +133,20 @@ class WebSocketClient(WebSocketServerProtocol, Session):
csession = self.get_client_session()
if csession:
csession["webclient_authenticated_uid"] = None
csession.save()
# if the nonce is different, webclient_authenticated_uid has been
# set *before* this disconnect (disconnect called after a new client
# connects, which occurs in some 'fast' browsers like Google Chrome
# and Mobile Safari)
if csession.get("webclient_authenticated_nonce", None) == self.nonce:
csession["webclient_authenticated_uid"] = None
csession["webclient_authenticated_nonce"] = 0
csession.save()
self.logged_in = False
self.sessionhandler.disconnect(self)
# autobahn-python: 1000 for a normal close, 3000-4999 for app. specific,
# autobahn-python:
# 1000 for a normal close, 1001 if the browser window is closed,
# 3000-4999 for app. specific,
# in case anyone wants to expose this functionality later.
#
# sendClose() under autobahn/websocket/interfaces.py
@ -134,7 +164,7 @@ class WebSocketClient(WebSocketServerProtocol, Session):
reason (str or None): Close reason as sent by the WebSocket peer.
"""
if code == CLOSE_NORMAL:
if code == CLOSE_NORMAL or code == GOING_AWAY:
self.disconnect(reason)
else:
self.websocket_close_code = code

View file

@ -38,7 +38,7 @@ from evennia.utils import logger
from evennia.comms import channelhandler
from evennia.server.sessionhandler import SESSIONS
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
_SA = object.__setattr__
@ -148,13 +148,17 @@ def _server_maintenance():
# handle idle timeouts
if _IDLE_TIMEOUT > 0:
reason = _("idle timeout exceeded")
to_disconnect = []
for session in (
sess for sess in SESSIONS.values() if (now - sess.cmd_last) > _IDLE_TIMEOUT
):
if not session.account or not session.account.access(
session.account, "noidletimeout", default=False
):
SESSIONS.disconnect(session, reason=reason)
to_disconnect.append(session)
for session in to_disconnect:
SESSIONS.disconnect(session, reason=reason)
# ------------------------------------------------------------
@ -416,7 +420,7 @@ class Evennia(object):
yield [
(s.pause(manual_pause=False), s.at_server_reload())
for s in ScriptDB.get_all_cached_instances()
if s.is_active or s.attributes.has("_manual_pause")
if s.id and (s.is_active or s.attributes.has("_manual_pause"))
]
yield self.sessions.all_sessions_portal_sync()
self.at_server_reload_stop()
@ -612,7 +616,10 @@ application = service.Application("Evennia")
if "--nodaemon" not in sys.argv:
# custom logging, but only if we are not running in interactive mode
logfile = logger.WeeklyLogFile(
os.path.basename(settings.SERVER_LOG_FILE), os.path.dirname(settings.SERVER_LOG_FILE)
os.path.basename(settings.SERVER_LOG_FILE),
os.path.dirname(settings.SERVER_LOG_FILE),
day_rotation=settings.SERVER_LOG_DAY_ROTATION,
max_size=settings.SERVER_LOG_MAX_SIZE,
)
application.setComponent(ILogObserver, logger.ServerLogObserver(logfile).emit)

View file

@ -23,7 +23,7 @@ _ObjectDB = None
_ANSI = None
# i18n
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
# Handlers for Session.db/ndb operation

View file

@ -67,7 +67,7 @@ PSTATUS = chr(18) # ping server or portal status
SRESET = chr(19) # server shutdown in reset mode
# i18n
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
_SERVERNAME = settings.SERVERNAME
_MULTISESSION_MODE = settings.MULTISESSION_MODE

View file

@ -70,14 +70,15 @@ class HTTPChannelWithXForwardedFor(http.HTTPChannel):
Check to see if this is a reverse proxied connection.
"""
CLIENT = 0
http.HTTPChannel.allHeadersReceived(self)
req = self.requests[-1]
client_ip, port = self.transport.client
proxy_chain = req.getHeader("X-FORWARDED-FOR")
if proxy_chain and client_ip in _UPSTREAM_IPS:
forwarded = proxy_chain.split(", ", 1)[CLIENT]
self.transport.client = (forwarded, port)
if self.requests:
CLIENT = 0
http.HTTPChannel.allHeadersReceived(self)
req = self.requests[-1]
client_ip, port = self.transport.client
proxy_chain = req.getHeader("X-FORWARDED-FOR")
if proxy_chain and client_ip in _UPSTREAM_IPS:
forwarded = proxy_chain.split(", ", 1)[CLIENT]
self.transport.client = (forwarded, port)
# Monkey-patch Twisted to handle X-Forwarded-For.

View file

@ -135,17 +135,19 @@ else:
break
os.chdir(os.pardir)
# Place to put log files
# Place to put log files, how often to rotate the log and how big each log file
# may become before rotating.
LOG_DIR = os.path.join(GAME_DIR, "server", "logs")
SERVER_LOG_FILE = os.path.join(LOG_DIR, "server.log")
SERVER_LOG_DAY_ROTATION = 7
SERVER_LOG_MAX_SIZE = 1000000
PORTAL_LOG_FILE = os.path.join(LOG_DIR, "portal.log")
PORTAL_LOG_DAY_ROTATION = 7
PORTAL_LOG_MAX_SIZE = 1000000
# The http log is usually only for debugging since it's very spammy
HTTP_LOG_FILE = os.path.join(LOG_DIR, "http_requests.log")
# if this is set to the empty string, lockwarnings will be turned off.
LOCKWARNING_LOG_FILE = os.path.join(LOG_DIR, "lockwarnings.log")
# Rotate log files when server and/or portal stops. This will keep log
# file sizes down. Turn off to get ever growing log files and never
# lose log info.
CYCLE_LOGFILES = True
# Number of lines to append to rotating channel logs when they rotate
CHANNEL_LOG_NUM_TAIL_LINES = 20
# Max size (in bytes) of channel log files before they rotate
@ -243,7 +245,7 @@ IN_GAME_ERRORS = True
# ENGINE - path to the the database backend. Possible choices are:
# 'django.db.backends.sqlite3', (default)
# 'django.db.backends.mysql',
# 'django.db.backends.postgresql_psycopg2',
# 'django.db.backends.postgresql',
# 'django.db.backends.oracle' (untested).
# NAME - database name, or path to the db file for sqlite3
# USER - db admin (unused in sqlite3)
@ -400,22 +402,23 @@ COLOR_NO_DEFAULT = False
######################################################################
# Default command sets
# Default command sets and commands
######################################################################
# Note that with the exception of the unloggedin set (which is not
# stored anywhere in the database), changing these paths will only affect
# NEW created characters/objects, not those already in play. So if you plan to
# change this, it's recommended you do it before having created a lot of objects
# (or simply reset the database after the change for simplicity).
# Command set used on session before account has logged in
CMDSET_UNLOGGEDIN = "commands.default_cmdsets.UnloggedinCmdSet"
# (Note that changing these three following cmdset paths will only affect NEW
# created characters/objects, not those already in play. So if you want to
# change this and have it apply to every object, it's recommended you do it
# before having created a lot of objects (or simply reset the database after
# the change for simplicity)).
# Command set used on the logged-in session
CMDSET_SESSION = "commands.default_cmdsets.SessionCmdSet"
# Default set for logged in account with characters (fallback)
CMDSET_CHARACTER = "commands.default_cmdsets.CharacterCmdSet"
# Command set for accounts without a character (ooc)
CMDSET_ACCOUNT = "commands.default_cmdsets.AccountCmdSet"
# Location to search for cmdsets if full path not given
CMDSET_PATHS = ["commands", "evennia", "evennia.contrib"]
# Fallbacks for cmdset paths that fail to load. Note that if you change the path for your
@ -445,10 +448,17 @@ COMMAND_DEFAULT_MSG_ALL_SESSIONS = False
COMMAND_DEFAULT_HELP_CATEGORY = "general"
# The default lockstring of a command.
COMMAND_DEFAULT_LOCKS = ""
# The Channel Handler will create a command to represent each channel,
# creating it with the key of the channel, its aliases, locks etc. The
# default class logs channel messages to a file and allows for /history.
# This setting allows to override the command class used with your own.
# The Channel Handler is responsible for managing all available channels. By
# default it builds the current channels into a channel-cmdset that it feeds
# to the cmdhandler. Overloading this can completely change how Channels
# are identified and called.
CHANNEL_HANDLER_CLASS = "evennia.comms.channelhandler.ChannelHandler"
# The (default) Channel Handler will create a command to represent each
# channel, creating it with the key of the channel, its aliases, locks etc. The
# default class logs channel messages to a file and allows for /history. This
# setting allows to override the command class used with your own.
# If you implement CHANNEL_HANDLER_CLASS, you can change this directly and will
# likely not need this setting.
CHANNEL_COMMAND_CLASS = "evennia.comms.channelhandler.ChannelCommand"
######################################################################
@ -640,6 +650,12 @@ CLIENT_DEFAULT_HEIGHT = 45
# (excluding webclient with separate help popups). If continuous scroll
# is preferred, change 'HELP_MORE' to False. EvMORE uses CLIENT_DEFAULT_HEIGHT
HELP_MORE = True
# Set rate limits per-IP on account creations and login attempts
CREATION_THROTTLE_LIMIT = 2
CREATION_THROTTLE_TIMEOUT = 10 * 60
LOGIN_THROTTLE_LIMIT = 5
LOGIN_THROTTLE_TIMEOUT = 5 * 60
######################################################################
# Guest accounts

View file

@ -235,17 +235,23 @@ class AttributeHandler(object):
# full cache was run on all attributes
self._cache_complete = False
def _fullcache(self):
"""Cache all attributes of this object"""
def _query_all(self):
"Fetch all Attributes on this object"
query = {
"%s__id" % self._model: self._objid,
"attribute__db_model__iexact": self._model,
"attribute__db_attrtype": self._attrtype,
}
attrs = [
return [
conn.attribute
for conn in getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query)
]
def _fullcache(self):
"""Cache all attributes of this object"""
if not _TYPECLASS_AGGRESSIVE_CACHE:
return
attrs = self._query_all()
self._cache = dict(
(
"%s-%s"
@ -298,7 +304,7 @@ class AttributeHandler(object):
attr = None
cachefound = False
del self._cache[cachekey]
if cachefound:
if cachefound and _TYPECLASS_AGGRESSIVE_CACHE:
if attr:
return [attr] # return cached entity
else:
@ -316,13 +322,15 @@ class AttributeHandler(object):
conn = getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query)
if conn:
attr = conn[0].attribute
self._cache[cachekey] = attr
if _TYPECLASS_AGGRESSIVE_CACHE:
self._cache[cachekey] = attr
return [attr] if attr.pk else []
else:
# There is no such attribute. We will explicitly save that
# in our cache to avoid firing another query if we try to
# retrieve that (non-existent) attribute again.
self._cache[cachekey] = None
if _TYPECLASS_AGGRESSIVE_CACHE:
self._cache[cachekey] = None
return []
else:
# only category given (even if it's None) - we can't
@ -345,12 +353,13 @@ class AttributeHandler(object):
**query
)
]
for attr in attrs:
if attr.pk:
cachekey = "%s-%s" % (attr.db_key, category)
self._cache[cachekey] = attr
# mark category cache as up-to-date
self._catcache[catkey] = True
if _TYPECLASS_AGGRESSIVE_CACHE:
for attr in attrs:
if attr.pk:
cachekey = "%s-%s" % (attr.db_key, category)
self._cache[cachekey] = attr
# mark category cache as up-to-date
self._catcache[catkey] = True
return attrs
def _setcache(self, key, category, attr_obj):
@ -363,6 +372,8 @@ class AttributeHandler(object):
attr_obj (Attribute): The newly saved attribute
"""
if not _TYPECLASS_AGGRESSIVE_CACHE:
return
if not key: # don't allow an empty key in cache
return
cachekey = "%s-%s" % (key, category)
@ -769,9 +780,13 @@ class AttributeHandler(object):
their values!) in the handler.
"""
if not self._cache_complete:
self._fullcache()
attrs = sorted([attr for attr in self._cache.values() if attr], key=lambda o: o.id)
if _TYPECLASS_AGGRESSIVE_CACHE:
if not self._cache_complete:
self._fullcache()
attrs = sorted([attr for attr in self._cache.values() if attr], key=lambda o: o.id)
else:
attrs = sorted([attr for attr in self._query_all() if attr], key=lambda o: o.id)
if accessing_obj:
return [
attr

View file

@ -5,7 +5,8 @@ all Attributes and TypedObjects).
"""
import shlex
from django.db.models import Q
from django.db.models import F, Q, Count, ExpressionWrapper, FloatField
from django.db.models.functions import Cast
from evennia.utils import idmapper
from evennia.utils.utils import make_iter, variable_from_module
from evennia.typeclasses.attributes import Attribute
@ -236,21 +237,29 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
"""
return self.get_tag(key=key, category=category, obj=obj, tagtype="alias")
def get_by_tag(self, key=None, category=None, tagtype=None):
def get_by_tag(self, key=None, category=None, tagtype=None, **kwargs):
"""
Return objects having tags with a given key or category or combination of the two.
Also accepts multiple tags/category/tagtype
Args:
key (str or list, optional): Tag key or list of keys. Not case sensitive.
category (str or list, optional): Tag category. Not case sensitive. If `key` is
a list, a single category can either apply to all keys in that list or this
must be a list matching the `key` list element by element. If no `key` is given,
all objects with tags of this category are returned.
category (str or list, optional): Tag category. Not case sensitive.
If `key` is a list, a single category can either apply to all
keys in that list or this must be a list matching the `key`
list element by element. If no `key` is given, all objects with
tags of this category are returned.
tagtype (str, optional): 'type' of Tag, by default
this is either `None` (a normal Tag), `alias` or
`permission`. This always apply to all queried tags.
Kwargs:
match (str): "all" (default) or "any"; determines whether the
target object must be tagged with ALL of the provided
tags/categories or ANY single one. ANY will perform a weighted
sort, so objects with more tag matches will outrank those with
fewer tag matches.
Returns:
objects (list): Objects with matching tag.
@ -262,10 +271,18 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
if not (key or category):
return []
global _Tag
if not _Tag:
from evennia.typeclasses.models import Tag as _Tag
match = kwargs.get("match", "all").lower().strip()
keys = make_iter(key) if key else []
categories = make_iter(category) if category else []
n_keys = len(keys)
n_categories = len(categories)
unique_categories = sorted(set(categories))
n_unique_categories = len(unique_categories)
dbmodel = self.model.__dbclass__.__name__.lower()
query = (
@ -286,14 +303,30 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
"get_by_tag needs a single category or a list of categories "
"the same length as the list of tags."
)
clauses = Q()
for ikey, key in enumerate(keys):
query = query.filter(
db_tags__db_key__iexact=key, db_tags__db_category__iexact=categories[ikey]
)
# Keep each key and category together, grouped by AND
clauses |= Q(db_key__iexact=key, db_category__iexact=categories[ikey])
else:
# only one or more categories given
for category in categories:
query = query.filter(db_tags__db_category__iexact=category)
# import evennia;evennia.set_trace()
clauses = Q()
for category in unique_categories:
clauses |= Q(db_category__iexact=category)
tags = _Tag.objects.filter(clauses)
query = query.filter(db_tags__in=tags).annotate(
matches=Count("db_tags__pk", filter=Q(db_tags__in=tags), distinct=True)
)
# Default ALL: Match all of the tags and optionally more
if match == "all":
n_req_tags = tags.count() if n_keys > 0 else n_unique_categories
query = query.filter(matches__gte=n_req_tags)
# ANY: Match any single tag, ordered by weight
elif match == "any":
query = query.order_by("-matches")
return query
@ -452,6 +485,34 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
retval = retval.filter(id__lte=self.dbref(max_dbref, reqhash=False))
return retval
def get_typeclass_totals(self, *args, **kwargs) -> object:
"""
Returns a queryset of typeclass composition statistics.
Returns:
qs (Queryset): A queryset of dicts containing the typeclass (name),
the count of objects with that typeclass and a float representing
the percentage of objects associated with the typeclass.
"""
return (
self.values("db_typeclass_path")
.distinct()
.annotate(
# Get count of how many objects for each typeclass exist
count=Count("db_typeclass_path")
)
.annotate(
# Rename db_typeclass_path field to something more human
typeclass=F("db_typeclass_path"),
# Calculate this class' percentage of total composition
percent=ExpressionWrapper(
((F("count") / float(self.count())) * 100.0), output_field=FloatField()
),
)
.values("typeclass", "count", "percent")
)
def object_totals(self):
"""
Get info about database statistics.
@ -463,11 +524,8 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
object having that typeclass set on themselves).
"""
dbtotals = {}
typeclass_paths = set(self.values_list("db_typeclass_path", flat=True))
for typeclass_path in typeclass_paths:
dbtotals[typeclass_path] = self.filter(db_typeclass_path=typeclass_path).count()
return dbtotals
stats = self.get_typeclass_totals().order_by("typeclass")
return {x.get("typeclass"): x.get("count") for x in stats}
def typeclass_search(self, typeclass, include_children=False, include_parents=False):
"""

View file

@ -27,7 +27,7 @@ def _drop_table(db_cursor, table_name):
db_cursor.execute("SET FOREIGN_KEY_CHECKS=0;")
db_cursor.execute("DROP TABLE {table};".format(table=table_name))
db_cursor.execute("SET FOREIGN_KEY_CHECKS=1;")
elif _ENGINE == "postgresql_psycopg2":
elif _ENGINE == "postgresql":
db_cursor.execute("ALTER TABLE {table} DISABLE TRIGGER ALL;".format(table=table_name))
db_cursor.execute("DROP TABLE {table};".format(table=table_name))
db_cursor.execute("ALTER TABLE {table} ENABLE TRIGGER ALL;".format(table=table_name))

View file

@ -458,7 +458,7 @@ class TypedObject(SharedMemoryModel):
# Object manipulation methods
#
def is_typeclass(self, typeclass, exact=True):
def is_typeclass(self, typeclass, exact=False):
"""
Returns true if this object has this type OR has a typeclass
which is an subclass of the given typeclass. This operates on

View file

@ -124,17 +124,23 @@ class TagHandler(object):
# full cache was run on all tags
self._cache_complete = False
def _fullcache(self):
"Cache all tags of this object"
def _query_all(self):
"Get all tags for this objects"
query = {
"%s__id" % self._model: self._objid,
"tag__db_model": self._model,
"tag__db_tagtype": self._tagtype,
}
tags = [
return [
conn.tag
for conn in getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query)
]
def _fullcache(self):
"Cache all tags of this object"
if not _TYPECLASS_AGGRESSIVE_CACHE:
return
tags = self._query_all()
self._cache = dict(
(
"%s-%s"
@ -193,7 +199,8 @@ class TagHandler(object):
conn = getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query)
if conn:
tag = conn[0].tag
self._cache[cachekey] = tag
if _TYPECLASS_AGGRESSIVE_CACHE:
self._cache[cachekey] = tag
return [tag]
else:
# only category given (even if it's None) - we can't
@ -216,11 +223,12 @@ class TagHandler(object):
**query
)
]
for tag in tags:
cachekey = "%s-%s" % (tag.db_key, category)
self._cache[cachekey] = tag
# mark category cache as up-to-date
self._catcache[catkey] = True
if _TYPECLASS_AGGRESSIVE_CACHE:
for tag in tags:
cachekey = "%s-%s" % (tag.db_key, category)
self._cache[cachekey] = tag
# mark category cache as up-to-date
self._catcache[catkey] = True
return tags
return []
@ -234,9 +242,11 @@ class TagHandler(object):
tag_obj (tag): The newly saved tag
"""
if not _TYPECLASS_AGGRESSIVE_CACHE:
return
if not key: # don't allow an empty key in cache
return
key, category = key.strip().lower(), category.strip().lower() if category else category
key, category = (key.strip().lower(), category.strip().lower() if category else category)
cachekey = "%s-%s" % (key, category)
catkey = "-%s" % category
self._cache[cachekey] = tag_obj
@ -253,7 +263,7 @@ class TagHandler(object):
category (str or None): A cleaned category name
"""
key, category = key.strip().lower(), category.strip().lower() if category else category
key, category = (key.strip().lower(), category.strip().lower() if category else category)
catkey = "-%s" % category
if key:
cachekey = "%s-%s" % (key, category)
@ -419,9 +429,13 @@ class TagHandler(object):
`return_key_and_category` is set.
"""
if not self._cache_complete:
self._fullcache()
tags = sorted(self._cache.values())
if _TYPECLASS_AGGRESSIVE_CACHE:
if not self._cache_complete:
self._fullcache()
tags = sorted(self._cache.values())
else:
tags = sorted(self._query_all())
if return_key_and_category:
# return tuple (key, category)
return [(to_str(tag.db_key), tag.db_category) for tag in tags]

View file

@ -2,8 +2,9 @@
Unit tests for typeclass base system
"""
from django.test import override_settings
from evennia.utils.test_resources import EvenniaTest
from mock import patch
# ------------------------------------------------------------
# Manager tests
@ -19,6 +20,19 @@ class TestAttributes(EvenniaTest):
self.obj1.db.testattr = value
self.assertEqual(self.obj1.db.testattr, value)
@override_settings(TYPECLASS_AGGRESSIVE_CACHE=False)
@patch("evennia.typeclasses.attributes._TYPECLASS_AGGRESSIVE_CACHE", False)
def test_attrhandler_nocache(self):
key = "testattr"
value = "test attr value "
self.obj1.attributes.add(key, value)
self.assertFalse(self.obj1.attributes._cache)
self.assertEqual(self.obj1.attributes.get(key), value)
self.obj1.db.testattr = value
self.assertEqual(self.obj1.db.testattr, value)
self.assertFalse(self.obj1.attributes._cache)
def test_weird_text_save(self):
"test 'weird' text type (different in py2 vs py3)"
from django.utils.safestring import SafeText
@ -62,6 +76,9 @@ class TestTypedObjectManager(EvenniaTest):
self.obj2.tags.add("tag6", "category3")
self.obj2.tags.add("tag7", "category1")
self.obj2.tags.add("tag7", "category5")
self.obj1.tags.add("tag8", "category6")
self.obj2.tags.add("tag9", "category6")
self.assertEqual(self._manager("get_by_tag", "tag5", "category1"), [self.obj1, self.obj2])
self.assertEqual(self._manager("get_by_tag", "tag6", "category1"), [])
self.assertEqual(self._manager("get_by_tag", "tag6", "category3"), [self.obj1, self.obj2])
@ -78,6 +95,8 @@ class TestTypedObjectManager(EvenniaTest):
self._manager("get_by_tag", category=["category1", "category3"]), [self.obj1, self.obj2]
)
self.assertEqual(
self._manager("get_by_tag", category=["category1", "category2"]), [self.obj2]
self._manager("get_by_tag", category=["category1", "category2"]), [self.obj1, self.obj2]
)
self.assertEqual(self._manager("get_by_tag", category=["category5", "category4"]), [])
self.assertEqual(self._manager("get_by_tag", category="category1"), [self.obj1, self.obj2])
self.assertEqual(self._manager("get_by_tag", category="category6"), [self.obj1, self.obj2])

View file

@ -674,6 +674,13 @@ class ANSIString(str, metaclass=ANSIMeta):
"""
# A compiled Regex for the format mini-language: https://docs.python.org/3/library/string.html#formatspec
re_format = re.compile(
r"(?i)(?P<just>(?P<fill>.)?(?P<align>\<|\>|\=|\^))?(?P<sign>\+|\-| )?(?P<alt>\#)?"
r"(?P<zero>0)?(?P<width>\d+)?(?P<grouping>\_|\,)?(?:\.(?P<precision>\d+))?"
r"(?P<type>b|c|d|e|E|f|F|g|G|n|o|s|x|X|%)?"
)
def __new__(cls, *args, **kwargs):
"""
When creating a new ANSIString, you may use a custom parser that has
@ -733,6 +740,47 @@ class ANSIString(str, metaclass=ANSIMeta):
def __str__(self):
return self._raw_string
def __format__(self, format_spec):
"""
This magic method covers ANSIString's behavior within a str.format() or f-string.
Current features supported: fill, align, width.
Args:
format_spec (str): The format specification passed by f-string or str.format(). This is a string such as
"0<30" which would mean "left justify to 30, filling with zeros". The full specification can be found
at https://docs.python.org/3/library/string.html#formatspec
Returns:
ansi_str (str): The formatted ANSIString's .raw() form, for display.
"""
# This calls the compiled regex stored on ANSIString's class to analyze the format spec.
# It returns a dictionary.
format_data = self.re_format.match(format_spec).groupdict()
clean = self.clean()
base_output = ANSIString(self.raw())
align = format_data.get("align", "<")
fill = format_data.get("fill", " ")
# Need to coerce width into an integer. We can be certain that it's numeric thanks to regex.
width = format_data.get("width", None)
if width is None:
width = len(clean)
else:
width = int(width)
if align == "<":
base_output = self.ljust(width, fill)
elif align == ">":
base_output = self.rjust(width, fill)
elif align == "^":
base_output = self.center(width, fill)
elif align == "=":
pass
# Return the raw string with ANSI markup, ready to be displayed.
return base_output.raw()
def __repr__(self):
"""
Let's make the repr the command that would actually be used to

View file

@ -255,11 +255,11 @@ def create_script(
if obj:
kwarg["db_obj"] = dbid_to_obj(obj, _ObjectDB)
if interval:
kwarg["db_interval"] = interval
kwarg["db_interval"] = max(0, interval)
if start_delay:
kwarg["db_start_delay"] = start_delay
if repeats:
kwarg["db_repeats"] = repeats
kwarg["db_repeats"] = max(0, repeats)
if persistent:
kwarg["db_persistent"] = persistent
if desc:
@ -486,9 +486,8 @@ def create_account(
Args:
key (str): The account's name. This should be unique.
email (str): Email on valid addr@addr.domain form. This is
technically required but if set to `None`, an email of
`dummy@example.com` will be used as a placeholder.
email (str or None): Email on valid addr@addr.domain form. If
the empty string, will be set to None.
password (str): Password in cleartext.
Kwargs:
@ -532,7 +531,7 @@ def create_account(
# correctly when each object is recovered).
if not email:
email = "dummy@example.com"
email = None
if _AccountDB.objects.filter(username__iexact=key):
raise ValueError("An Account with the name '%s' already exists." % key)

View file

@ -28,7 +28,7 @@ except ImportError:
from pickle import dumps, loads
from django.core.exceptions import ObjectDoesNotExist
from django.contrib.contenttypes.models import ContentType
from django.utils.safestring import SafeString, SafeBytes
from django.utils.safestring import SafeString
from evennia.utils.utils import uses_database, is_iter, to_str, to_bytes
from evennia.utils import logger
@ -549,7 +549,7 @@ def to_pickle(data):
def process_item(item):
"""Recursive processor and identification of data"""
dtype = type(item)
if dtype in (str, int, float, bool, bytes, SafeString, SafeBytes):
if dtype in (str, int, float, bool, bytes, SafeString):
return item
elif dtype == tuple:
return tuple(process_item(val) for val in item)
@ -577,7 +577,7 @@ def to_pickle(data):
except TypeError:
return item
except Exception:
logger.log_error(f"The object {item} of type {type(item)} could not be stored.")
logger.log_err(f"The object {item} of type {type(item)} could not be stored.")
raise
return process_item(data)
@ -609,7 +609,7 @@ def from_pickle(data, db_obj=None):
def process_item(item):
"""Recursive processor and identification of data"""
dtype = type(item)
if dtype in (str, int, float, bool, bytes, SafeString, SafeBytes):
if dtype in (str, int, float, bool, bytes, SafeString):
return item
elif _IS_PACKED_DBOBJ(item):
# this must be checked before tuple
@ -638,7 +638,7 @@ def from_pickle(data, db_obj=None):
def process_tree(item, parent):
"""Recursive processor, building a parent-tree from iterable data"""
dtype = type(item)
if dtype in (str, int, float, bool, bytes, SafeString, SafeBytes):
if dtype in (str, int, float, bool, bytes, SafeString):
return item
elif _IS_PACKED_DBOBJ(item):
# this must be checked before tuple
@ -716,7 +716,7 @@ def do_pickle(data):
try:
return dumps(data, protocol=PICKLE_PROTOCOL)
except Exception:
logger.log_error(f"Could not pickle data for storage: {data}")
logger.log_err(f"Could not pickle data for storage: {data}")
raise
@ -725,7 +725,7 @@ def do_unpickle(data):
try:
return loads(to_bytes(data))
except Exception:
logger.log_error(f"Could not unpickle data from storage: {data}")
logger.log_err(f"Could not unpickle data from storage: {data}")
raise

View file

@ -187,7 +187,7 @@ _CMD_NOINPUT = cmdhandler.CMD_NOINPUT
# Return messages
# i18n
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
_ERR_NOT_IMPLEMENTED = _(
"Menu node '{nodename}' is either not implemented or " "caused an error. Make another choice."

View file

@ -30,7 +30,7 @@ caller.msg() construct every time the page is updated.
from django.conf import settings
from evennia import Command, CmdSet
from evennia.commands import cmdhandler
from evennia.utils.utils import justify
from evennia.utils.utils import justify, make_iter
_CMD_NOMATCH = cmdhandler.CMD_NOMATCH
_CMD_NOINPUT = cmdhandler.CMD_NOINPUT
@ -39,6 +39,8 @@ _CMD_NOINPUT = cmdhandler.CMD_NOINPUT
_SCREEN_WIDTH = settings.CLIENT_DEFAULT_WIDTH
_SCREEN_HEIGHT = settings.CLIENT_DEFAULT_HEIGHT
_EVTABLE = None
# text
_DISPLAY = """{text}
@ -126,26 +128,38 @@ class EvMore(object):
text,
always_page=False,
session=None,
justify=False,
justify_kwargs=None,
exit_on_lastpage=False,
exit_cmd=None,
**kwargs,
):
"""
Initialization of the text handler.
Args:
caller (Object or Account): Entity reading the text.
text (str): The text to put under paging.
text (str, EvTable or iterator): The text or data to put under paging.
- If a string, paginage normally. If this text contains
one or more `\f` format symbol, automatic pagination and justification
are force-disabled and page-breaks will only happen after each `\f`.
- If `EvTable`, the EvTable will be paginated with the same
setting on each page if it is too long. The table
decorations will be considered in the size of the page.
- Otherwise `text` is converted to an iterator, where each step is
expected to be a line in the final display. Each line
will be run through repr() (so one could pass a list of objects).
always_page (bool, optional): If `False`, the
pager will only kick in if `text` is too big
to fit the screen.
session (Session, optional): If given, this session will be used
to determine the screen width and will receive all output.
justify_kwargs (dict, bool or None, optional): If given, this should
be valid keyword arguments to the utils.justify() function. If False,
no justification will be done (especially important for handling
fixed-width text content, like tables!).
justify (bool, optional): If set, auto-justify long lines. This must be turned
off for fixed-width or formatted output, like tables. It's force-disabled
if `text` is an EvTable.
justify_kwargs (dict, optional): Keywords for the justifiy function. Used only
if `justify` is True. If this is not set, default arguments will be used.
exit_on_lastpage (bool, optional): If reaching the last page without the
page being completely filled, exit pager immediately. If unset,
another move forward is required to exit. If set, the pager
@ -154,18 +168,32 @@ class EvMore(object):
the caller when the more page exits. Note that this will be using whatever
cmdset the user had *before* the evmore pager was activated (so none of
the evmore commands will be available when this is run).
kwargs (any, optional): These will be passed on
to the `caller.msg` method.
kwargs (any, optional): These will be passed on to the `caller.msg` method.
Examples:
super_long_text = " ... "
EvMore(caller, super_long_text)
from django.core.paginator import Paginator
query = ObjectDB.objects.all()
pages = Paginator(query, 10) # 10 objs per page
EvMore(caller, pages) # will repr() each object per line, 10 to a page
multi_page_table = [ [[..],[..]], ...]
EvMore(caller, multi_page_table, use_evtable=True,
evtable_args=("Header1", "Header2"),
evtable_kwargs={"align": "r", "border": "tablecols"})
"""
self._caller = caller
self._kwargs = kwargs
self._pages = []
self._npages = []
self._npos = []
self._npages = 1
self._npos = 0
self.exit_on_lastpage = exit_on_lastpage
self.exit_cmd = exit_cmd
self._exit_msg = "Exited |wmore|n pager."
if not session:
# if not supplied, use the first session to
# determine screen size
@ -179,16 +207,33 @@ class EvMore(object):
height = max(4, session.protocol_flags.get("SCREENHEIGHT", {0: _SCREEN_HEIGHT})[0] - 4)
width = session.protocol_flags.get("SCREENWIDTH", {0: _SCREEN_WIDTH})[0]
if hasattr(text, "table") and hasattr(text, "get"):
# This is an EvTable.
table = text
if table.height:
# enforced height of each paged table, plus space for evmore extras
height = table.height - 4
# convert table to string
text = str(text)
justify_kwargs = None # enforce
if not isinstance(text, str):
# not a string - pre-set pages of some form
text = "\n".join(str(repr(element)) for element in make_iter(text))
if "\f" in text:
# we use \f to indicate the user wants to enforce their line breaks
# on their own. If so, we do no automatic line-breaking/justification
# at all.
self._pages = text.split("\f")
self._npages = len(self._pages)
self._npos = 0
else:
if justify_kwargs is False:
# no justification. Simple division by line
lines = text.split("\n")
else:
# we must break very long lines into multiple ones
if justify:
# we must break very long lines into multiple ones. Note that this
# will also remove spurious whitespace.
justify_kwargs = justify_kwargs or {}
width = justify_kwargs.get("width", width)
justify_kwargs["width"] = width
@ -201,17 +246,20 @@ class EvMore(object):
lines.extend(justify(line, **justify_kwargs).split("\n"))
else:
lines.append(line)
else:
# no justification. Simple division by line
lines = text.split("\n")
# always limit number of chars to 10 000 per page
height = min(10000 // max(1, width), height)
# figure out the pagination
self._pages = ["\n".join(lines[i : i + height]) for i in range(0, len(lines), height)]
self._npages = len(self._pages)
self._npos = 0
if self._npages <= 1 and not always_page:
# no need for paging; just pass-through.
caller.msg(text=text, session=self._session, **kwargs)
caller.msg(text=self._get_page(0), session=self._session, **kwargs)
else:
# go into paging mode
# first pass on the msg kwargs
@ -221,12 +269,15 @@ class EvMore(object):
# goto top of the text
self.page_top()
def _get_page(self, pos):
return self._pages[pos]
def display(self, show_footer=True):
"""
Pretty-print the page.
"""
pos = self._pos
text = self._pages[pos]
pos = self._npos
text = self._get_page(pos)
if show_footer:
page = _DISPLAY.format(text=text, pageno=pos + 1, pagemax=self._npages)
else:
@ -245,14 +296,14 @@ class EvMore(object):
"""
Display the top page
"""
self._pos = 0
self._npos = 0
self.display()
def page_end(self):
"""
Display the bottom page.
"""
self._pos = self._npages - 1
self._npos = self._npages - 1
self.display()
def page_next(self):
@ -260,12 +311,12 @@ class EvMore(object):
Scroll the text to the next page. Quit if already at the end
of the page.
"""
if self._pos >= self._npages - 1:
if self._npos >= self._npages - 1:
# exit if we are already at the end
self.page_quit()
else:
self._pos += 1
if self.exit_on_lastpage and self._pos >= (self._npages - 1):
self._npos += 1
if self.exit_on_lastpage and self._npos >= (self._npages - 1):
self.display(show_footer=False)
self.page_quit(quiet=True)
else:
@ -275,7 +326,7 @@ class EvMore(object):
"""
Scroll the text back up, at the most to the top.
"""
self._pos = max(0, self._pos - 1)
self._npos = max(0, self._npos - 1)
self.display()
def page_quit(self, quiet=False):
@ -290,30 +341,51 @@ class EvMore(object):
self._caller.execute_cmd(self.exit_cmd, session=self._session)
# helper function
def msg(
caller,
text="",
always_page=False,
session=None,
justify=False,
justify_kwargs=None,
exit_on_lastpage=True,
**kwargs,
):
"""
More-supported version of msg, mimicking the normal msg method.
EvMore-supported version of msg, mimicking the normal msg method.
Args:
caller (Object or Account): Entity reading the text.
text (str): The text to put under paging.
text (str, EvTable or iterator): The text or data to put under paging.
- If a string, paginage normally. If this text contains
one or more `\f` format symbol, automatic pagination is disabled
and page-breaks will only happen after each `\f`.
- If `EvTable`, the EvTable will be paginated with the same
setting on each page if it is too long. The table
decorations will be considered in the size of the page.
- Otherwise `text` is converted to an iterator, where each step is
is expected to be a line in the final display, and each line
will be run through repr().
always_page (bool, optional): If `False`, the
pager will only kick in if `text` is too big
to fit the screen.
session (Session, optional): If given, this session will be used
to determine the screen width and will receive all output.
justify (bool, optional): If set, justify long lines in output. Disable for
fixed-format output, like tables.
justify_kwargs (dict, bool or None, optional): If given, this should
be valid keyword arguments to the utils.justify() function. If False,
no justification will be done.
exit_on_lastpage (bool, optional): Immediately exit pager when reaching the last page.
use_evtable (bool, optional): If True, each page will be rendered as an
EvTable. For this to work, `text` must be an iterable, where each element
is the table (list of list) to render on that page.
evtable_args (tuple, optional): The args to use for EvTable on each page.
evtable_kwargs (dict, optional): The kwargs to use for EvTable on each
page (except `table`, which is supplied by EvMore per-page).
kwargs (any, optional): These will be passed on
to the `caller.msg` method.
@ -323,6 +395,7 @@ def msg(
text,
always_page=always_page,
session=session,
justify=justify,
justify_kwargs=justify_kwargs,
exit_on_lastpage=exit_on_lastpage,
**kwargs,

View file

@ -1107,8 +1107,9 @@ class EvTable(object):
Exception: If given erroneous input or width settings for the data.
Notes:
Beyond those table-specific keywords, the non-overlapping keywords of `EcCell.__init__` are
also available. These will be passed down to every cell in the table.
Beyond those table-specific keywords, the non-overlapping keywords
of `EcCell.__init__` are also available. These will be passed down
to every cell in the table.
"""
# at this point table is a 2D grid - a list of columns

View file

@ -16,6 +16,7 @@ log_typemsg(). This is for historical, back-compatible reasons.
import os
import time
import glob
from datetime import datetime
from traceback import format_exc
from twisted.python import log, logfile
@ -76,33 +77,79 @@ def timeformat(when=None):
class WeeklyLogFile(logfile.DailyLogFile):
"""
Log file that rotates once per week. Overrides key methods to change format
Log file that rotates once per week by default. Overrides key methods to change format.
"""
day_rotation = 7
def __init__(self, name, directory, defaultMode=None, day_rotation=7, max_size=1000000):
"""
Args:
name (str): Name of log file.
directory (str): Directory holding the file.
defaultMode (str): Permissions used to create file. Defaults to
current permissions of this file if it exists.
day_rotation (int): How often to rotate the file.
max_size (int): Max size of log file before rotation (regardless of
time). Defaults to 1M.
"""
self.day_rotation = day_rotation
self.max_size = max_size
self.size = 0
logfile.DailyLogFile.__init__(self, name, directory, defaultMode=defaultMode)
def _openFile(self):
logfile.DailyLogFile._openFile(self)
self.size = self._file.tell()
def shouldRotate(self):
"""Rotate when the date has changed since last write"""
# all dates here are tuples (year, month, day)
now = self.toDate()
then = self.lastDate
return now[0] > then[0] or now[1] > then[1] or now[2] > (then[2] + self.day_rotation)
return (
now[0] > then[0]
or now[1] > then[1]
or now[2] > (then[2] + self.day_rotation)
or self.size >= self.max_size
)
def suffix(self, tupledate):
"""Return the suffix given a (year, month, day) tuple or unixtime.
Format changed to have 03 for march instead of 3 etc (retaining unix file order)
Format changed to have 03 for march instead of 3 etc (retaining unix
file order)
If we get duplicate suffixes in location (due to hitting size limit),
we append __1, __2 etc.
Examples:
server.log.2020_01_29
server.log.2020_01_29__1
server.log.2020_01_29__2
"""
try:
return "_".join(["{:02d}".format(part) for part in tupledate])
except Exception:
# try taking a float unixtime
return "_".join(["{:02d}".format(part) for part in self.toDate(tupledate)])
suffix = ""
copy_suffix = 0
while True:
try:
suffix = "_".join(["{:02d}".format(part) for part in tupledate])
except Exception:
# try taking a float unixtime
suffix = "_".join(["{:02d}".format(part) for part in self.toDate(tupledate)])
suffix += f"__{copy_suffix}" if copy_suffix else ""
if os.path.exists(f"{self.path}.{suffix}"):
# Append a higher copy_suffix to try to break the tie (starting from 2)
copy_suffix += 1
else:
break
return suffix
def write(self, data):
"Write data to log file"
logfile.BaseLogFile.write(self, data)
self.lastDate = max(self.lastDate, self.toDate())
self.size += len(data)
class PortalLogObserver(log.FileLogObserver):

View file

@ -24,8 +24,7 @@ class InMemorySaveHandler(object):
class OptionHandler(object):
"""
This is a generic Option handler. It is commonly used
implements AttributeHandler. Retrieve options eithers as properties on
This is a generic Option handler. Retrieve options either as properties on
this handler or by using the .get method.
This is used for Account.options but it could be used by Scripts or Objects
@ -54,7 +53,7 @@ class OptionHandler(object):
It will be called as `savefunc(key, value, **save_kwargs)`. A common one
to pass would be AttributeHandler.add.
loadfunc (callable): A callable for all options to call when loading data into
itself. It will be called as `loadfunc(key, default=default, **load_kwargs)`.
itself. It will be called as `loadfunc(key, default=default, **load_kwargs)`.
A common one to pass would be AttributeHandler.get.
save_kwargs (any): Optional extra kwargs to pass into `savefunc` above.
load_kwargs (any): Optional extra kwargs to pass into `loadfunc` above.
@ -116,14 +115,16 @@ class OptionHandler(object):
self.options[key] = loaded_option
return loaded_option
def get(self, key, return_obj=False):
def get(self, key, default=None, return_obj=False, raise_error=False):
"""
Retrieves an Option stored in the handler. Will load it if it doesn't exist.
Args:
key (str): The option key to retrieve.
default (any): What to return if the option is defined.
return_obj (bool, optional): If True, returns the actual option
object instead of its value.
raise_error (bool, optional): Raise Exception if key is not found in options.
Returns:
option_value (any or Option): An option value the Option itself.
Raises:
@ -131,7 +132,10 @@ class OptionHandler(object):
"""
if key not in self.options_dict:
raise KeyError("Option not found!")
if raise_error:
raise KeyError("Option not found!")
return default
# get the options or load/recache it
op_found = self.options.get(key) or self._load_option(key)
return op_found if return_obj else op_found.value

View file

@ -43,7 +43,7 @@ from django.forms.fields import CharField
from django.forms.widgets import Textarea
from pickle import loads, dumps
from django.utils.encoding import force_text
from django.utils.encoding import force_str
DEFAULT_PROTOCOL = 4
@ -133,8 +133,9 @@ class PickledWidget(Textarea):
try:
# necessary to convert it back after repr(), otherwise validation errors will mutate it
value = literal_eval(repr_value)
except ValueError:
pass
except (ValueError, SyntaxError):
# we could not eval it, just show its prepresentation
value = repr_value
return super().render(name, value, attrs=attrs, renderer=renderer)
def value_from_datadict(self, data, files, name):
@ -209,10 +210,10 @@ class PickledObjectField(models.Field):
"""
Returns the default value for this field.
The default implementation on models.Field calls force_text
The default implementation on models.Field calls force_str
on the default, which means you can't set arbitrary Python
objects as the default. To fix this, we just return the value
without calling force_text on it. Note that if you set a
without calling force_str on it. Note that if you set a
callable as a default, the field will still call it. It will
*not* try to pickle and encode it.
@ -266,13 +267,13 @@ class PickledObjectField(models.Field):
"""
if value is not None and not isinstance(value, PickledObject):
# We call force_text here explicitly, so that the encoded string
# isn't rejected by the postgresql_psycopg2 backend. Alternatively,
# We call force_str here explicitly, so that the encoded string
# isn't rejected by the postgresql backend. Alternatively,
# we could have just registered PickledObject with the psycopg
# marshaller (telling it to store it like it would a string), but
# since both of these methods result in the same value being stored,
# doing things this way is much easier.
value = force_text(dbsafe_encode(value, self.compress, self.protocol))
value = force_str(dbsafe_encode(value, self.compress, self.protocol))
return value
def value_to_string(self, obj):

View file

@ -95,4 +95,4 @@ class TestGametime(TestCase):
self.timescripts.append(script)
self.assertIsInstance(script, gametime.TimeScript)
self.assertAlmostEqual(script.interval, 12)
self.assertEqual(script.repeats, -1)
self.assertEqual(script.repeats, 0)

View file

@ -98,6 +98,30 @@ class TestMLen(TestCase):
self.assertEqual(utils.m_len({"hello": True, "Goodbye": False}), 2)
class TestANSIString(TestCase):
"""
Verifies that ANSIString's string-API works as intended.
"""
def setUp(self):
self.example_raw = "|relectric |cboogaloo|n"
self.example_ansi = ANSIString(self.example_raw)
self.example_str = "electric boogaloo"
self.example_output = "\x1b[1m\x1b[31melectric \x1b[1m\x1b[36mboogaloo\x1b[0m"
def test_length(self):
self.assertEqual(len(self.example_ansi), 17)
def test_clean(self):
self.assertEqual(self.example_ansi.clean(), self.example_str)
def test_raw(self):
self.assertEqual(self.example_ansi.raw(), self.example_output)
def test_format(self):
self.assertEqual(f"{self.example_ansi:0<20}", self.example_output + "000")
class TestTimeformat(TestCase):
"""
Default function header from utils.py:

View file

@ -32,6 +32,11 @@ class TestValidatorFuncs(TestCase):
self.assertTrue(
isinstance(validatorfuncs.datetime(dt, from_tz=pytz.UTC), datetime.datetime)
)
account = mock.MagicMock()
account.options.get = mock.MagicMock(return_value="America/Chicago")
expected = datetime.datetime(1492, 10, 12, 6, 51, tzinfo=pytz.UTC)
self.assertEqual(expected, validatorfuncs.datetime("Oct 12 1:00 1492", account=account))
account.options.get.assert_called_with("timezone", "UTC")
def test_datetime_raises_ValueError(self):
for dt in ["", "January 1, 2019", "1/1/2019", "Jan 1 2019"]:
@ -121,6 +126,7 @@ class TestValidatorFuncs(TestCase):
validatorfuncs.boolean(b)
def test_timezone_ok(self):
for tz in ["America/Chicago", "GMT", "UTC"]:
self.assertEqual(tz, validatorfuncs.timezone(tz).zone)

View file

@ -27,7 +27,7 @@ from collections import defaultdict, OrderedDict
from twisted.internet import threads, reactor
from django.conf import settings
from django.utils import timezone
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from django.apps import apps
from evennia.utils import logger
@ -1036,7 +1036,7 @@ def uses_database(name="sqlite3"):
shortcut to having to use the full backend name.
Args:
name (str): One of 'sqlite3', 'mysql', 'postgresql_psycopg2'
name (str): One of 'sqlite3', 'mysql', 'postgresql'
or 'oracle'.
Returns:

View file

@ -40,24 +40,36 @@ def color(entry, option_key="Color", **kwargs):
def datetime(entry, option_key="Datetime", account=None, from_tz=None, **kwargs):
"""
Process a datetime string in standard forms while accounting for the inputter's timezone.
Process a datetime string in standard forms while accounting for the inputer's timezone. Always
returns a result in UTC.
Args:
entry (str): A date string from a user.
option_key (str): Name to display this datetime as.
account (AccountDB): The Account performing this lookup. Unless from_tz is provided,
account's timezone will be used (if found) for local time and convert the results
to UTC.
from_tz (pytz): An instance of pytz from the user. If not provided, defaults to whatever
the Account uses. If neither one is provided, defaults to UTC.
account (AccountDB): The Account performing this lookup. Unless `from_tz` is provided,
the account's timezone option will be used.
from_tz (pytz.timezone): An instance of a pytz timezone object from the
user. If not provided, tries to use the timezone option of the `account'.
If neither one is provided, defaults to UTC.
Returns:
datetime in utc.
datetime in UTC.
Raises:
ValueError: If encountering a malformed timezone, date string or other format error.
"""
if not entry:
raise ValueError(f"No {option_key} entered!")
if not from_tz:
from_tz = _pytz.UTC
if account:
acct_tz = account.options.get("timezone", "UTC")
try:
from_tz = _pytz.timezone(acct_tz)
except Exception as err:
raise ValueError(f"Timezone string '{acct_tz}' is not a valid timezone ({err})")
else:
from_tz = _pytz.UTC
utc = _pytz.UTC
now = _dt.datetime.utcnow().replace(tzinfo=utc)
cur_year = now.strftime("%Y")

View file

@ -6,7 +6,7 @@
# http://diveintopython.org/regular_expressions/street_addresses.html#re.matching.2.3
#
from django.conf.urls import url, include
from django.urls import path, include
from django.views.generic import RedirectView
# Setup the root url tree from /
@ -14,9 +14,9 @@ from django.views.generic import RedirectView
urlpatterns = [
# Front page (note that we shouldn't specify namespace here since we will
# not be able to load django-auth/admin stuff (will probably work in Django>1.9)
url(r"^", include("evennia.web.website.urls")), # , namespace='website', app_name='website')),
path("", include("evennia.web.website.urls")),
# webclient
url(r"^webclient/", include("evennia.web.webclient.urls", namespace="webclient")),
path("webclient/", include("evennia.web.webclient.urls")),
# favicon
url(r"^favicon\.ico$", RedirectView.as_view(url="/media/images/favicon.ico", permanent=False)),
path("favicon.ico", RedirectView.as_view(url="/media/images/favicon.ico", permanent=False)),
]

View file

@ -61,3 +61,12 @@ class SharedLoginMiddleware(object):
login(request, account)
except AttributeError:
logger.log_trace()
if csession.get("webclient_authenticated_uid", None):
# set a nonce to prevent the webclient from erasing the webclient_authenticated_uid value
csession["webclient_authenticated_nonce"] = (
csession.get("webclient_authenticated_nonce", 0) + 1
)
# wrap around to prevent integer overflows
if csession["webclient_authenticated_nonce"] > 32:
csession["webclient_authenticated_nonce"] = 0

View file

@ -88,6 +88,10 @@ div {margin:0px;}
height: 100%;
}
.card {
background-color: #333;
}
/* Container surrounding entire client */
#clientwrapper {
height: 100%;

View file

@ -0,0 +1,6 @@
@font-face {
font-family: 'DejaVu Sans Mono';
src: url('/static/webclient/fonts/DejaVuSansMono-webfont.woff') format('woff');
font-weight: normal;
font-style: normal;
}

View file

@ -0,0 +1,25 @@
/*
*
* Evennia Webclient help plugin
*
*/
let clienthelp_plugin = (function () {
//
//
//
var onOptionsUI = function (parentdiv) {
var help_text = $( [
"<div style='font-weight: bold;'>",
"<a href='http://evennia.com'>Evennia</a>",
" Webclient Settings:",
"</div>"
].join(""));
parentdiv.append(help_text);
}
return {
init: function () {},
onOptionsUI: onOptionsUI,
}
})();
window.plugin_handler.add("clienthelp", clienthelp_plugin);

View file

@ -0,0 +1,79 @@
/*
*
* Evennia Webclient default "send-text-on-enter-key" IO plugin
*
*/
let font_plugin = (function () {
const font_urls = {
'B612 Mono': 'https://fonts.googleapis.com/css?family=B612+Mono&display=swap',
'Consolas': 'https://fonts.googleapis.com/css?family=Consolas&display=swap',
'DejaVu Sans Mono': '/static/webclient/fonts/DejaVuSansMono.css',
'Fira Mono': 'https://fonts.googleapis.com/css?family=Fira+Mono&display=swap',
'Inconsolata': 'https://fonts.googleapis.com/css?family=Inconsolata&display=swap',
'Monospace': '',
'Roboto Mono': 'https://fonts.googleapis.com/css?family=Roboto+Mono&display=swap',
'Source Code Pro': 'https://fonts.googleapis.com/css?family=Source+Code+Pro&display=swap',
'Ubuntu Mono': 'https://fonts.googleapis.com/css?family=Ubuntu+Mono&display=swap',
};
//
//
//
var onOptionsUI = function (parentdiv) {
var fontselect = $('<select>');
var sizeselect = $('<select>');
var fonts = Object.keys(font_urls);
for (var x = 0; x < fonts.length; x++) {
var option = $('<option value="'+fonts[x]+'">'+fonts[x]+'</option>');
fontselect.append(option);
}
for (var x = 4; x < 21; x++) {
var val = (x/10.0);
var option = $('<option value="'+val+'">'+x+'</option>');
sizeselect.append(option);
}
fontselect.val('DejaVu Sans Mono'); // default value
sizeselect.val('0.9'); // default scaling factor
// font-family change callback
fontselect.on('change', function () {
$(document.body).css('font-family', $(this).val());
});
// font size change callback
sizeselect.on('change', function () {
$(document.body).css('font-size', $(this).val()+"em");
});
// add the font selection dialog control to our parentdiv
parentdiv.append('<div style="font-weight: bold">Font Selection:</div>');
parentdiv.append(fontselect);
parentdiv.append(sizeselect);
}
//
// Font plugin init function (adds the urls for the webfonts to the page)
//
var init = function () {
var head = $(document.head);
var fonts = Object.keys(font_urls);
for (var x = 0; x < fonts.length; x++) {
if ( fonts[x] != "Monospace" ) {
var url = font_urls[ fonts[x] ];
var link = $('<link href="'+url+'" rel="stylesheet">');
head.append( link );
}
}
}
return {
init: init,
onOptionsUI: onOptionsUI,
}
})();
window.plugin_handler.add("font", font_plugin);

View file

@ -383,6 +383,14 @@ let goldenlayout = (function () {
// Public
//
//
// helper accessor for other plugins to add new known-message types
var addKnownType = function (newtype) {
if( knownTypes.includes(newtype) == false ) {
knownTypes.push(newtype);
}
}
//
//
@ -526,7 +534,7 @@ let goldenlayout = (function () {
onKeydown: onKeydown,
onText: onText,
getGL: function () { return myLayout; },
addKnownType: function (newtype) { knownTypes.push(newtype); },
addKnownType: addKnownType,
}
}());
window.plugin_handler.add("goldenlayout", goldenlayout);

View file

@ -0,0 +1,68 @@
/*
* IFrame plugin
* REQUIRES: goldenlayout.js
*/
let iframe = (function () {
var url = window.location.origin;
//
// Create iframe component
var createIframeComponent = function () {
var myLayout = window.plugins["goldenlayout"].getGL();
myLayout.registerComponent( "iframe", function (container, componentState) {
// build the iframe
var div = $('<iframe src="' + url + '">');
div.css("width", "100%");
div.css("height", "inherit");
div.appendTo( container.getElement() );
});
}
// handler for the "iframe" button
var onOpenIframe = function () {
var iframeComponent = {
title: url,
type: "component",
componentName: "iframe",
componentState: {
},
};
// Create a new GoldenLayout tab filled with the iframeComponent above
var myLayout = window.plugins["goldenlayout"].getGL();
var main = myLayout.root.getItemsByType("stack")[0].getActiveContentItem();
main.parent.addChild( iframeComponent );
}
// Public
var onOptionsUI = function (parentdiv) {
var iframebutton = $('<input type="button" value="Open Game Website" />');
iframebutton.on('click', onOpenIframe);
parentdiv.append( '<div style="font-weight: bold">Restricted Browser-in-Browser:</div>' );
parentdiv.append( iframebutton );
}
//
//
var postInit = function() {
// Are we using GoldenLayout?
if( window.plugins["goldenlayout"] ) {
createIframeComponent();
$("#iframebutton").bind("click", onOpenIframe);
}
console.log('IFrame plugin Loaded');
}
return {
init: function () {},
postInit: postInit,
onOptionsUI: onOptionsUI,
}
})();
window.plugin_handler.add("iframe", iframe);

View file

@ -0,0 +1,154 @@
/*
* Spawns plugin
* REQUIRES: goldenlayout.js
*/
let spawns = (function () {
var ignoreDefaultKeydown = false;
var spawnmap = {}; // { id1: { r:regex, t:tag } } pseudo-array of regex-tag pairs
//
// changes the spawnmap row's contents to the new regex/tag provided,
// this avoids leaving stale regex/tag definitions in the spawnmap
var onAlterTag = function (evnt) {
var adult = $(evnt.target).parent();
var children = adult.children();
var id = $(adult).data('id');
var regex = $(children[0]).val();// spaces before/after are valid regex syntax, unfortunately
var mytag = $(children[1]).val().trim();
if( mytag != "" && regex != "" ) {
if( !(id in spawnmap) ) {
spawnmap[id] = {};
}
spawnmap[id]["r"] = regex;
spawnmap[id]["t"] = mytag;
localStorage.setItem( "evenniaMessageRoutingSavedState", JSON.stringify(spawnmap) );
window.plugins["goldenlayout"].addKnownType( mytag );
}
}
//
// deletes the entire regex/tag/delete button row.
var onDeleteTag = function (evnt) {
var adult = $(evnt.target).parent();
var children = adult.children();
var id = $(adult).data('id');
delete spawnmap[id];
localStorage.setItem( "evenniaMessageRoutingSavedState", JSON.stringify(spawnmap) );
adult.remove(); // remove this set of input boxes/etc from the DOM
}
//
var onFocusIn = function (evnt) {
ignoreDefaultKeydown = true;
}
//
var onFocusOut = function (evnt) {
ignoreDefaultKeydown = false;
onAlterTag(evnt); // percolate event so closing the pane, etc saves any last changes.
}
//
// display a row with proper editting hooks
var displayRow = function (formdiv, div, regexstring, tagstring) {
var regex = $('<input class="regex" type=text value="'+regexstring+'"/>');
var tag = $('<input class="tag" type=text value="'+tagstring+'"/>');
var del = $('<input class="delete-regex" type=button value="X"/>');
regex.on('change', onAlterTag );
regex.on('focusin', onFocusIn );
regex.on('focusout', onFocusOut );
tag.on('change', onAlterTag );
tag.on('focusin', onFocusIn );
tag.on('focusout', onFocusOut );
del.on('click', onDeleteTag );
div.append(regex);
div.append(tag);
div.append(del);
formdiv.append(div);
}
//
// generate a whole new regex/tag/delete button row
var onNewRegexRow = function (formdiv) {
var nextid = 1;
while( nextid in spawnmap ) { // pseudo-index spawnmap with id reuse
nextid++;
}
var div = $("<div data-id='"+nextid+"'>");
displayRow(formdiv, div, "", "");
}
// Public
//
// onOptionsUI -- display the existing spawnmap and a button to create more entries.
//
var onOptionsUI = function (parentdiv) {
var formdiv = $('<div>');
var button= $('<input type="button" value="New Regex/Tag Pair" />');
button.on('click', function () { onNewRegexRow(formdiv) });
formdiv.append(button);
// display the existing spawnmap
for( var id in spawnmap ) {
var div = $("<div data-id='"+id+"'>");
displayRow(formdiv, div, spawnmap[id]["r"], spawnmap[id]["t"] );
}
parentdiv.append('<div style="font-weight: bold">Message Routing:</div>');
parentdiv.append(formdiv);
}
//
// onText -- catch Text before it is routed by the goldenlayout router
// then test our list of regexes on the given text to see if it matches.
// If it does, rewrite the Text Type to be our tag value instead.
//
var onText = function (args, kwargs) {
var div = $("<div>" + args[0] + "</div>");
var txt = div.text();
for( var id in spawnmap ) {
var regex = spawnmap[id]["r"];
if ( txt.match(regex) != null ) {
kwargs['type'] = spawnmap[id]["t"];
}
}
return false;
}
//
// OnKeydown -- if the Options window is open, capture focus
//
var onKeydown = function(evnt) {
return ignoreDefaultKeydown;
}
//
// init
//
var init = function () {
var ls_spawnmap = localStorage.getItem( "evenniaMessageRoutingSavedState" );
if( ls_spawnmap ) {
spawnmap = JSON.parse(ls_spawnmap);
for( var id in spawnmap ) {
window.plugins["goldenlayout"].addKnownType( spawnmap[id]["t"] );
}
}
console.log('Client-Side Message Routing plugin initialized');
}
return {
init: init,
onOptionsUI: onOptionsUI,
onText: onText,
onKeydown: onKeydown,
}
})();
window.plugin_handler.add("spawns", spawns);

View file

@ -0,0 +1,184 @@
/*
* Options 2.0
* REQUIRES: goldenlayout.js
*/
let options2 = (function () {
var options_container = null ;
//
// When the user changes a setting from the interface
var onOptionCheckboxChanged = function (evnt) {
var name = $(evnt.target).data("setting");
var value = $(evnt.target).is(":checked");
options[name] = value;
Evennia.msg("webclient_options", [], options);
}
//
// Callback to display our basic OptionsUI
var onOptionsUI = function (parentdiv) {
var checked;
checked = options["gagprompt"] ? "checked='checked'" : "";
var gagprompt = $( [ "<label>",
"<input type='checkbox' data-setting='gagprompt' " + checked + "'>",
" Don't echo prompts to the main text area",
"</label>"
].join("") );
checked = options["notification_popup"] ? "checked='checked'" : "";
var notifypopup = $( [ "<label>",
"<input type='checkbox' data-setting='notification_popup' " + checked + "'>",
" Popup notification",
"</label>"
].join("") );
checked = options["notification_sound"] ? "checked='checked'" : "";
var notifysound = $( [ "<label>",
"<input type='checkbox' data-setting='notification_sound' " + checked + "'>",
" Play a sound",
"</label>"
].join("") );
gagprompt.on("change", onOptionCheckboxChanged);
notifypopup.on("change", onOptionCheckboxChanged);
notifysound.on("change", onOptionCheckboxChanged);
parentdiv.append(gagprompt);
parentdiv.append(notifypopup);
parentdiv.append(notifysound);
}
//
// Create and register the "options" golden-layout component
var createOptionsComponent = function () {
var myLayout = window.plugins["goldenlayout"].getGL();
myLayout.registerComponent( "options", function (container, componentState) {
var plugins = window.plugins;
options_container = container.getElement();
// build the buttons
var div = $("<div class='accordion' style='overflow-y:scroll; height:inherit;'>");
for( let plugin in plugins ) {
if( "onOptionsUI" in plugins[plugin] ) {
var card = $("<div class='card'>");
var body = $("<div>");
plugins[plugin].onOptionsUI( body );
card.append(body);
card.appendTo( div );
}
}
div.appendTo( options_container );
});
}
// handler for the "Options" button
var onOpenCloseOptions = function () {
var optionsComponent = {
title: "Options",
type: "component",
componentName: "options",
componentState: {
},
};
// Create a new GoldenLayout tab filled with the optionsComponent above
var myLayout = window.plugins["goldenlayout"].getGL();
if( ! options_container ) {
// open new optionsComponent
var main = myLayout.root.getItemsByType("stack")[0].getActiveContentItem();
myLayout.on( "tabCreated", function( tab ) {
if( tab.contentItem.componentName == "options" ) {
tab
.closeElement
.off("click")
.click( function () {
options_container = null;
tab.contentItem.remove();
});
options_container = tab.contentItem;
}
});
main.parent.addChild( optionsComponent );
} else {
options_container.remove();
options_container = null;
}
}
// Public
//
// Called when options settings are sent from server
var onGotOptions = function (args, kwargs) {
var addKnownType = window.plugins["goldenlayout"].addKnownType;
$.each(kwargs, function(key, value) {
options[key] = value;
// for "available_server_tags", addKnownType for each value ["tag1", "tag2", ... ]
if( (key === "available_server_tags") && addKnownType ) {
$.each( value, addKnownType );
}
});
}
//
// Called when the user logged in
var onLoggedIn = function (args, kwargs) {
Evennia.msg("webclient_options", [], {});
}
//
// Display a "prompt" command from the server
var onPrompt = function (args, kwargs) {
// display the prompt in the output window if gagging is disabled
if( options["gagprompt"] == false ) {
plugin_handler.onText(args, kwargs);
}
// don't claim this Prompt as completed.
return false;
}
//
//
var init = function() {
var optionsbutton = $("<button id='optionsbutton'>&#x2699;</button>");
$("#toolbar").append( optionsbutton );
options["gagprompt"] = true;
options["notification_popup"] = true;
options["notification_sound"] = true;
}
//
//
var postInit = function() {
// Are we using GoldenLayout?
if( window.plugins["goldenlayout"] ) {
createOptionsComponent();
$("#optionsbutton").bind("click", onOpenCloseOptions);
}
console.log("Options 2.0 Loaded");
}
return {
init: init,
postInit: postInit,
onGotOptions: onGotOptions,
onLoggedIn: onLoggedIn,
onOptionsUI: onOptionsUI,
onPrompt: onPrompt,
}
})();
window.plugin_handler.add("options2", options2);

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