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 ce63922739
107 changed files with 2222 additions and 1218 deletions

View file

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

View file

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

View file

@ -6,7 +6,7 @@
defaults to True for backwards-compatibility in 0.9, will be False in 1.0 defaults to True for backwards-compatibility in 0.9, will be False in 1.0
### Already in master ### 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` - `py` command now reroutes stdout to output results in-game client. `py`
without arguments starts a full interactive Python console. without arguments starts a full interactive Python console.
- Webclient default to a single input pane instead of two. Now defaults to no help-popup. - 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 - `AttributeHandler.get(return_list=True)` will return `[]` if there are no
Attributes instead of `[None]`. Attributes instead of `[None]`.
- Remove `pillow` requirement (install especially if using imagefield) - 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. - Show warning on `start -l` if settings contains values unsafe for production.
- Make code auto-formatted with Black. - Make code auto-formatted with Black.
- Make default `set` command able to edit nested structures (PR by Aaron McMillan) - Make default `set` command able to edit nested structures (PR by Aaron McMillan)
- Allow running Evennia test suite from core repo with `make test`. - 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 - 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. 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) ## Evennia 0.9 (2018-2019)

View file

@ -1,137 +1,5 @@
# Evennia installation # Evennia installation
The latest and more detailed installation instructions can be found You can find the latest updated installation instructions and
[here](https://github.com/evennia/evennia/wiki/Getting-Started). requirements [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!

View file

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

View file

@ -231,6 +231,7 @@ def _init():
from . import contrib from . import contrib
from .utils.evmenu import EvMenu from .utils.evmenu import EvMenu
from .utils.evtable import EvTable from .utils.evtable import EvTable
from .utils.evmore import EvMore
from .utils.evform import EvForm from .utils.evform import EvForm
from .utils.eveditor import EvEditor from .utils.eveditor import EvEditor
from .utils.ansi import ANSIString 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.commands.cmdsethandler import CmdSetHandler
from evennia.utils.optionhandler import OptionHandler from evennia.utils.optionhandler import OptionHandler
from django.utils.translation import ugettext as _ from django.utils.translation import gettext as _
from random import getrandbits from random import getrandbits
__all__ = ("DefaultAccount",) __all__ = ("DefaultAccount",)
@ -51,8 +51,12 @@ _CMDSET_ACCOUNT = settings.CMDSET_ACCOUNT
_MUDINFO_CHANNEL = None _MUDINFO_CHANNEL = None
# Create throttles for too many account-creations and login attempts # Create throttles for too many account-creations and login attempts
CREATION_THROTTLE = Throttle(limit=2, timeout=10 * 60) CREATION_THROTTLE = Throttle(
LOGIN_THROTTLE = Throttle(limit=5, timeout=5 * 60) 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): class AccountSessionHandler(object):
@ -216,12 +220,16 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
@property @property
def characters(self): def characters(self):
# Get playable characters list # 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 # Rebuild the list if legacy code left null values after deletion
if None in objs: try:
objs = [x for x in self.db._playable_characters if x] if None in objs:
self.db._playable_characters = 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 return objs
@ -820,7 +828,10 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
server. server.
Args: 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 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. at_msg_send() hook will be called. If iterable, call on all entities.
session (Session or list, optional): Session object or a list of session (Session or list, optional): Session object or a list of
@ -851,7 +862,13 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
kwargs["options"] = options kwargs["options"] = options
if text is not None: 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 # session relay
sessions = make_iter(session) if session else self.sessions.all() sessions = make_iter(session) if session else self.sessions.all()

View file

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

View file

@ -48,7 +48,7 @@ from evennia.comms.channelhandler import CHANNELHANDLER
from evennia.utils import logger, utils from evennia.utils import logger, utils
from evennia.utils.utils import string_suggestions 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 _IN_GAME_ERRORS = settings.IN_GAME_ERRORS
@ -733,7 +733,7 @@ def cmdhandler(
if len(matches) == 1: if len(matches) == 1:
# We have a unique command match. But it may still be invalid. # We have a unique command match. But it may still be invalid.
match = matches[0] 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: if not matches:
# No commands match our entered command # 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 # the user might be trying to identify the command
# with a #num-command style syntax. We expect the regex to # with a #num-command style syntax. We expect the regex to
# contain the groups "number" and "name". # 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 return mindex, new_raw_string
else: else:
return None, None return None, None

View file

@ -27,7 +27,7 @@ Set theory.
""" """
from weakref import WeakKeyDictionary 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 from evennia.utils.utils import inherits_from, is_iter
__all__ = ("CmdSet",) __all__ = ("CmdSet",)

View file

@ -72,7 +72,7 @@ from evennia.utils import logger, utils
from evennia.commands.cmdset import CmdSet from evennia.commands.cmdset import CmdSet
from evennia.server.models import ServerConfig from evennia.server.models import ServerConfig
from django.utils.translation import ugettext as _ from django.utils.translation import gettext as _
__all__ = ("import_cmdset", "CmdSetHandler") __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 re
import math import math
import inspect
from django.conf import settings from django.conf import settings
@ -74,6 +75,13 @@ def _init_command(cls, **kwargs):
cls.is_exit = False cls.is_exit = False
if not hasattr(cls, "help_category"): if not hasattr(cls, "help_category"):
cls.help_category = "general" 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() cls.help_category = cls.help_category.lower()
@ -401,12 +409,11 @@ class Command(object, metaclass=CommandMeta):
""" """
pass pass
def func(self): def get_command_info(self):
""" """
This is the actual executing part of the command. It is This is the default output of func() if no func() overload is done.
called directly after self.parse(). See the docstring of this Provided here as a separate method so that it can be called for debugging
module for which object properties are available (beyond those purposes when making commands.
set in self.parse())
""" """
variables = "\n".join( variables = "\n".join(
@ -416,11 +423,8 @@ class Command(object, metaclass=CommandMeta):
Command {self} has no defined `func()` - showing on-command variables: Command {self} has no defined `func()` - showing on-command variables:
{variables} {variables}
""" """
self.caller.msg(string)
return
# a simple test command to show the available properties # 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 += "\n|w%s|n - Command variables from evennia:\n" % self.key
string += "-" * 50 string += "-" * 50
string += "\nname of cmd (self.key): |w%s|n\n" % self.key 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) 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): def get_extra_info(self, caller, **kwargs):
""" """
Display some extra information that may help distinguish this 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. Get the client screenwidth for the session using this command.
Returns: Returns:
client width (int or None): The width (in characters) of the client window. None client width (int): The width (in characters) of the client window.
if this command is run without a Session (such as by an NPC).
""" """
if self.session: 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): def styled_table(self, *args, **kwargs):
""" """

View file

@ -3,7 +3,7 @@ Building and world design commands
""" """
import re import re
from django.conf import settings 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.objects.models import ObjectDB
from evennia.locks.lockhandler import LockException from evennia.locks.lockhandler import LockException
from evennia.commands.cmdhandler import get_and_merge_cmdsets from evennia.commands.cmdhandler import get_and_merge_cmdsets
@ -13,11 +13,13 @@ from evennia.utils.utils import (
class_from_module, class_from_module,
get_all_typeclasses, get_all_typeclasses,
variable_from_module, variable_from_module,
dbref,
) )
from evennia.utils.eveditor import EvEditor from evennia.utils.eveditor import EvEditor
from evennia.utils.evmore import EvMore from evennia.utils.evmore import EvMore
from evennia.prototypes import spawner, prototypes as protlib, menus as olc_menus from evennia.prototypes import spawner, prototypes as protlib, menus as olc_menus
from evennia.utils.ansi import raw 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) COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
@ -1911,8 +1913,8 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS):
Usage: Usage:
typeclass[/switch] <object> [= typeclass.path] typeclass[/switch] <object> [= typeclass.path]
type '' typeclass/prototype <object> = prototype_key
parent ''
typeclass/list/show [typeclass.path] typeclass/list/show [typeclass.path]
swap - this is a shorthand for using /force/reset flags. swap - this is a shorthand for using /force/reset flags.
update - this is a shorthand for using the /force/reload flag. 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 list - show available typeclasses. Only typeclasses in modules actually
imported or used from somewhere in the code will show up here imported or used from somewhere in the code will show up here
(those typeclasses are still available if you know the path) (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: Example:
type button = examples.red_button.RedButton 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 If the typeclass_path is not given, the current object's typeclass is
assumed. assumed.
@ -1953,7 +1958,7 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS):
key = "typeclass" key = "typeclass"
aliases = ["type", "parent", "swap", "update"] 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)" locks = "cmd:perm(typeclass) or perm(Builder)"
help_category = "Building" help_category = "Building"
@ -2037,6 +2042,27 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS):
new_typeclass = self.rhs or obj.path 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: if "show" in self.switches or "examine" in self.switches:
string = "%s's current typeclass is %s." % (obj.name, obj.__class__) string = "%s's current typeclass is %s." % (obj.name, obj.__class__)
caller.msg(string) caller.msg(string)
@ -2069,11 +2095,34 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS):
hooks = "at_object_creation" if update else "all" hooks = "at_object_creation" if update else "all"
old_typeclass_path = obj.typeclass_path 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 # we let this raise exception if needed
obj.swap_typeclass( obj.swap_typeclass(
new_typeclass, clean_attributes=reset, clean_cmdsets=reset, run_start_hooks=hooks 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: if is_same:
string = "%s updated its existing typeclass (%s).\n" % (obj.name, obj.path) string = "%s updated its existing typeclass (%s).\n" % (obj.name, obj.path)
else: else:
@ -2090,6 +2139,11 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS):
string += " All old attributes where deleted before the swap." string += " All old attributes where deleted before the swap."
else: else:
string += " Attributes set before swap were not removed." 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) caller.msg(string)
@ -2641,7 +2695,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
caller = self.caller caller = self.caller
switches = self.switches 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]]") caller.msg("Usage: find <string> [= low [-high]]")
return return
@ -2649,18 +2703,46 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
switches.append("loc") switches.append("loc")
searchstring = self.lhs 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 self.rhs:
if "-" in self.rhs: try:
# also support low-high syntax # Check that rhs is either a valid dbref or dbref range
limlist = [part.lstrip("#").strip() for part in self.rhs.split("-", 1)] bounds = tuple(
else: sorted(dbref(x, False) for x in re.split("[-\s]+", self.rhs.strip()))
# otherwise split by space )
limlist = [part.lstrip("#") for part in self.rhs.split(None, 1)]
if limlist and limlist[0].isdigit(): # dbref() will return either a valid int or None
low = max(low, int(limlist[0])) assert bounds
if len(limlist) > 1 and limlist[1].isdigit(): # None should not exist in the bounds list
high = min(high, int(limlist[1])) 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) low = min(low, high)
high = max(low, high) high = max(low, high)
@ -2672,7 +2754,6 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
restrictions = ", %s" % (", ".join(self.switches)) restrictions = ", %s" % (", ".join(self.switches))
if is_dbref or is_account: if is_dbref or is_account:
if is_dbref: if is_dbref:
# a dbref search # a dbref search
result = caller.search(searchstring, global_search=True, quiet=True) result = caller.search(searchstring, global_search=True, quiet=True)
@ -2703,7 +2784,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
) )
else: else:
# Not an account/dbref search but a wider search; build a queryset. # 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: if "exact" in switches:
keyquery = Q(db_key__iexact=searchstring, id__gte=low, id__lte=high) keyquery = Q(db_key__iexact=searchstring, id__gte=low, id__lte=high)
aliasquery = Q( aliasquery = Q(
@ -2729,39 +2810,52 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
id__lte=high, id__lte=high,
) )
results = ObjectDB.objects.filter(keyquery | aliasquery).distinct() # Keep the initial queryset handy for later reuse
nresults = results.count() result_qs = ObjectDB.objects.filter(keyquery | aliasquery).distinct()
nresults = result_qs.count()
if nresults: # Use iterator to minimize memory ballooning on large result sets
# convert result to typeclasses. results = result_qs.iterator()
results = [result for result in results]
if "room" in switches: # Check and see if type filtering was requested; skip it if not
results = [obj for obj in results if inherits_from(obj, ROOM_TYPECLASS)] if any(x in switches for x in ("room", "exit", "char")):
if "exit" in switches: obj_ids = set()
results = [obj for obj in results if inherits_from(obj, EXIT_TYPECLASS)] for obj in results:
if "char" in switches: if (
results = [obj for obj in results if inherits_from(obj, CHAR_TYPECLASS)] ("room" in switches and inherits_from(obj, ROOM_TYPECLASS))
nresults = len(results) 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? # still results after type filtering?
if nresults: if nresults:
if nresults > 1: if nresults > 1:
string = "|w%i Matches|n(#%i-#%i%s):" % (nresults, low, high, restrictions) header = f"{nresults} Matches"
for res in results:
string += "\n |g%s - %s|n" % (res.get_display_name(caller), res.path)
else: else:
string = "|wOne Match|n(#%i-#%i%s):" % (low, high, restrictions) header = "One Match"
string += "\n |g%s - %s|n" % (
results[0].get_display_name(caller), string = f"|w{header}|n(#{low}-#{high}{restrictions}):"
results[0].path, res = None
) for res in results:
if "loc" in self.switches and nresults == 1 and results[0].location: string += f"\n |g{res.get_display_name(caller)} - {res.path}|n"
string += " (|wlocation|n: |g{}|n)".format( if (
results[0].location.get_display_name(caller) "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: else:
string = "|wMatch|n(#%i-#%i%s):" % (low, high, restrictions) string = f"|wNo Matches|n(#{low}-#{high}{restrictions}):"
string += "\n |RNo matches found for '%s'|n" % searchstring string += f"\n |RNo matches found for '{searchstring}'|n"
# send result # send result
caller.msg(string.strip()) caller.msg(string.strip())
@ -2791,8 +2885,8 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS):
reference. A puppeted object cannot be moved to None. reference. A puppeted object cannot be moved to None.
loc - teleport object to the target's location instead of its contents loc - teleport object to the target's location instead of its contents
Teleports an object somewhere. If no object is given, you yourself Teleports an object somewhere. If no object is given, you yourself are
is teleported to the target location. teleported to the target location.
""" """
key = "tel" key = "tel"
@ -2957,7 +3051,8 @@ class CmdScript(COMMAND_DEFAULT_CLASS):
ok = obj.scripts.add(self.rhs, autostart=True) ok = obj.scripts.add(self.rhs, autostart=True)
if not ok: if not ok:
result.append( 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)) % (self.rhs, obj.get_display_name(caller))
) )
else: else:
@ -2988,7 +3083,8 @@ class CmdScript(COMMAND_DEFAULT_CLASS):
else: else:
result = ["Script started successfully."] result = ["Script started successfully."]
break break
caller.msg("".join(result).strip())
EvMore(caller, "".join(result).strip())
class CmdTag(COMMAND_DEFAULT_CLASS): class CmdTag(COMMAND_DEFAULT_CLASS):

View file

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

View file

@ -202,11 +202,15 @@ class MuxCommand(Command):
else: else:
self.character = None 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 Update of parent class's get_command_info() for MuxCommand.
by the cmdhandler right after self.parser() finishes, and so has access """
to all the variables defined therein. self.get_command_info()
def get_command_info(self):
"""
Update of parent class's get_command_info() for MuxCommand.
""" """
variables = "\n".join( variables = "\n".join(
" |w{}|n ({}): {}".format(key, type(val), val) for key, val in self.__dict__.items() " |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 string += "-" * 50
self.caller.msg(string) 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): 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 import logger, utils, gametime, create, search
from evennia.utils.eveditor import EvEditor from evennia.utils.eveditor import EvEditor
from evennia.utils.evtable import EvTable from evennia.utils.evtable import EvTable
from evennia.utils.evmore import EvMore
from evennia.utils.utils import crop, class_from_module from evennia.utils.utils import crop, class_from_module
COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
@ -232,6 +233,10 @@ def _run_code_snippet(
if ret is None: if ret is None:
return 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: for session in sessions:
try: try:
@ -284,8 +289,6 @@ class EvenniaPythonConsole(code.InteractiveConsole):
result = None result = None
try: try:
result = super().push(line) result = super().push(line)
except SystemExit:
pass
finally: finally:
sys.stdout = old_stdout sys.stdout = old_stdout
sys.stderr = old_stderr sys.stderr = old_stderr
@ -301,6 +304,7 @@ class CmdPy(COMMAND_DEFAULT_CLASS):
py/edit py/edit
py/time <cmd> py/time <cmd>
py/clientraw <cmd> py/clientraw <cmd>
py/noecho
Switches: Switches:
time - output an approximate execution time for <cmd> 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 clientraw - turn off all client-specific escaping. Note that this may
lead to different output depending on prototocol (such as angular brackets lead to different output depending on prototocol (such as angular brackets
being parsed as HTML in the webclient but not in telnet clients) 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, 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 accepting multi-line Python code for testing and debugging. Type `exit()` to
@ -339,7 +345,7 @@ class CmdPy(COMMAND_DEFAULT_CLASS):
key = "py" key = "py"
aliases = ["!"] aliases = ["!"]
switch_options = ("time", "edit", "clientraw") switch_options = ("time", "edit", "clientraw", "noecho")
locks = "cmd:perm(py) or perm(Developer)" locks = "cmd:perm(py) or perm(Developer)"
help_category = "System" help_category = "System"
@ -349,6 +355,8 @@ class CmdPy(COMMAND_DEFAULT_CLASS):
caller = self.caller caller = self.caller
pycode = self.args pycode = self.args
noecho = "noecho" in self.switches
if "edit" in self.switches: if "edit" in self.switches:
caller.db._py_measure_time = "time" in self.switches caller.db._py_measure_time = "time" in self.switches
caller.db._py_clientraw = "clientraw" in self.switches caller.db._py_clientraw = "clientraw" in self.switches
@ -367,15 +375,26 @@ class CmdPy(COMMAND_DEFAULT_CLASS):
# Run in interactive mode # Run in interactive mode
console = EvenniaPythonConsole(self.caller) console = EvenniaPythonConsole(self.caller)
banner = ( banner = (
f"|gPython {sys.version} on {sys.platform}\n" "|gEvennia Interactive Python mode{echomode}\n"
"Evennia interactive console mode - type 'exit()' to leave.|n" "Python {version} on {platform}".format(
echomode=" (no echoing of prompts)" if noecho else "",
version=sys.version,
platform=sys.platform,
)
) )
self.msg(banner) self.msg(banner)
line = "" line = ""
prompt = ">>>" main_prompt = "|x[py mode - quit() to exit]|n"
prompt = main_prompt
while line.lower() not in ("exit", "exit()"): while line.lower() not in ("exit", "exit()"):
line = yield (prompt) try:
prompt = "..." if console.push(line) else ">>>" 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") self.msg("|gClosing the Python console.|n")
return return
@ -409,16 +428,19 @@ def format_script_list(scripts):
align="r", align="r",
border="tablecols", border="tablecols",
) )
for script in scripts: for script in scripts:
nextrep = script.time_until_next_repeat() nextrep = script.time_until_next_repeat()
if nextrep is None: if nextrep is None:
nextrep = "PAUS" if script.db._paused_time else "--" nextrep = "PAUSED" if script.db._paused_time else "--"
else: else:
nextrep = "%ss" % nextrep nextrep = "%ss" % nextrep
maxrepeat = script.repeats maxrepeat = script.repeats
remaining = script.remaining_repeats() or 0
if maxrepeat: if maxrepeat:
rept = "%i/%i" % (maxrepeat - script.remaining_repeats(), maxrepeat) rept = "%i/%i" % (maxrepeat - remaining, maxrepeat)
else: else:
rept = "-/-" rept = "-/-"
@ -433,6 +455,7 @@ def format_script_list(scripts):
script.typeclass_path.rsplit(".", 1)[-1], script.typeclass_path.rsplit(".", 1)[-1],
crop(script.desc, width=20), crop(script.desc, width=20),
) )
return "%s" % table return "%s" % table
@ -527,7 +550,7 @@ class CmdScripts(COMMAND_DEFAULT_CLASS):
else: else:
# No stopping or validation. We just want to view things. # No stopping or validation. We just want to view things.
string = format_script_list(scripts) string = format_script_list(scripts)
caller.msg(string) EvMore(caller, string)
class CmdObjects(COMMAND_DEFAULT_CLASS): class CmdObjects(COMMAND_DEFAULT_CLASS):
@ -592,9 +615,13 @@ class CmdObjects(COMMAND_DEFAULT_CLASS):
"|wtypeclass|n", "|wcount|n", "|w%|n", border="table", align="l" "|wtypeclass|n", "|wcount|n", "|w%|n", border="table", align="l"
) )
typetable.align = "l" typetable.align = "l"
dbtotals = ObjectDB.objects.object_totals() dbtotals = ObjectDB.objects.get_typeclass_totals()
for path, count in dbtotals.items(): for stat in dbtotals:
typetable.add_row(path, count, "%.2f" % ((float(count) / nobjs) * 100)) typetable.add_row(
stat.get("typeclass", "<error>"),
stat.get("count", -1),
"%.2f" % stat.get("percent", -1),
)
# last N table # last N table
objs = ObjectDB.objects.all().order_by("db_date_created")[max(0, nobjs - nlim) :] 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 django.conf import settings
from mock import Mock, mock 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.commands.default.cmdset_character import CharacterCmdSet
from evennia.utils.test_resources import EvenniaTest from evennia.utils.test_resources import EvenniaTest
from evennia.commands.default import ( 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.", "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): def test_lock(self):
self.call(building.CmdLock(), "", "Usage: ") self.call(building.CmdLock(), "", "Usage: ")
self.call(building.CmdLock(), "Obj = test:all()", "Added lock 'test:all()' to Obj.") 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(), self.char1.dbref, "Exact dbref match")
self.call(building.CmdFind(), "*TestAccount", "Match") self.call(building.CmdFind(), "*TestAccount", "Match")
self.call(building.CmdFind(), "/char Obj") self.call(building.CmdFind(), "/char Obj", "No Matches")
self.call(building.CmdFind(), "/room Obj") self.call(building.CmdFind(), "/room Obj", "No Matches")
self.call(building.CmdFind(), "/exit Obj") self.call(building.CmdFind(), "/exit Obj", "No Matches")
self.call(building.CmdFind(), "/exact Obj", "One Match") 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): def test_script(self):
self.call(building.CmdScript(), "Obj = ", "No scripts defined on Obj") self.call(building.CmdScript(), "Obj = ", "No scripts defined on Obj")
self.call( self.call(

View file

@ -27,8 +27,12 @@ from django.conf import settings
from evennia.commands import cmdset, command from evennia.commands import cmdset, command
from evennia.utils.logger import tail_log_file from evennia.utils.logger import tail_log_file
from evennia.utils.utils import class_from_module 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 _CHANNEL_COMMAND_CLASS = None
_CHANNELDB = None _CHANNELDB = None
@ -314,5 +318,6 @@ class ChannelHandler(object):
return chan_cmdset return chan_cmdset
CHANNEL_HANDLER = ChannelHandler() # set up the singleton
CHANNEL_HANDLER = class_from_module(settings.CHANNEL_HANDLER_CLASS)()
CHANNELHANDLER = CHANNEL_HANDLER # legacy CHANNELHANDLER = CHANNEL_HANDLER # legacy

View file

@ -8,6 +8,7 @@ Comm system components.
from django.db.models import Q from django.db.models import Q
from evennia.typeclasses.managers import TypedObjectManager, TypeclassManager from evennia.typeclasses.managers import TypedObjectManager, TypeclassManager
from evennia.utils import logger from evennia.utils import logger
from evennia.utils.utils import dbref
_GA = object.__getattribute__ _GA = object.__getattribute__
_AccountDB = None _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): def identify_object(inp):
""" """
Helper function. Identifies if an object is an account or an object; Helper function. Identifies if an object is an account or an object;

View file

@ -400,9 +400,11 @@ class Msg(SharedMemoryModel):
def __str__(self): def __str__(self):
"This handles what is shown when e.g. printing the message" "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( 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)) 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.utils.test_resources import EvenniaTest
from evennia import DefaultChannel from evennia import DefaultChannel
from evennia.utils.create import create_message
class ObjectCreationTest(EvenniaTest): class ObjectCreationTest(EvenniaTest):
@ -10,3 +11,8 @@ class ObjectCreationTest(EvenniaTest):
self.assertTrue(obj, errors) self.assertTrue(obj, errors)
self.assertFalse(errors, errors) self.assertFalse(errors, errors)
self.assertEqual(description, obj.db.desc) 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): def at_repeat(self):
"""Call the callback and reset interval.""" """Call the callback and reset interval."""
from evennia.utils.utils import calledby
callback = self.db.callback callback = self.db.callback
if callback: if callback:
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 messaging. It relies on a modified msg() and is meant as an
inspiration and starting point to how to do stuff like this. 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 An object can have the following genders:
the character's gender, for example the echo - 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) 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" The default gender is "ambiguous" (they/them/their/theirs).
depending on the gender of the object being messaged. Default gender
is "ambiguous" (they).
To use, have DefaultCharacter inherit from this, or change To use, have DefaultCharacter inherit from this, or change
setting.DEFAULT_CHARACTER to point to this class. setting.DEFAULT_CHARACTER to point to this class.
The `@gender` command needs to be added to the default cmdset before The `@gender` command is used to set the gender. It needs to be added to the
it becomes available. default cmdset before it becomes available.
""" """
import re import re
from evennia.utils import logger
from evennia import DefaultCharacter from evennia import DefaultCharacter
from evennia import Command from evennia import Command
@ -114,7 +127,10 @@ class GenderCharacter(DefaultCharacter):
gender-aware markers in output. gender-aware markers in output.
Args: 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 from_obj (obj, optional): object that is sending. If
given, at_msg_send will be called given, at_msg_send will be called
session (Session or list, optional): session or list of 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. All extra kwargs will be passed on to the protocol.
""" """
# pre-process the text before continuing
try: 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: except TypeError:
pass pass
except Exception as e:
logger.log_trace(e)
super().msg(text, from_obj=from_obj, session=session, **kwargs) super().msg(text, from_obj=from_obj, session=session, **kwargs)

View file

@ -238,7 +238,7 @@ class CmdMail(default_cmds.MuxAccountCommand):
else: else:
raise IndexError raise IndexError
except IndexError: except IndexError:
self.caller.msg("Message does not exixt.") self.caller.msg("Message does not exist.")
except ValueError: except ValueError:
self.caller.msg("Usage: @mail/forward <account list>=<#>[/<Message>]") self.caller.msg("Usage: @mail/forward <account list>=<#>[/<Message>]")
elif "reply" in self.switches or "rep" in self.switches: 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 # find out what preceeded this word
wpos = match.start() wpos = match.start()
preceeding = match.string[:wpos].strip() 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 # make up translation on the fly. Length can
# vary from un-translated word. # vary from un-translated word.

View file

@ -798,6 +798,16 @@ class RecogHandler(object):
# recog_mask log not passed, disable recog # recog_mask log not passed, disable recog
return obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key 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): def remove(self, obj):
""" """
Clear recog for a given object. Clear recog for a given object.
@ -896,10 +906,9 @@ class CmdSay(RPCommand): # replaces standard say
caller.msg("Say what?") caller.msg("Say what?")
return return
# calling the speech hook on the location # calling the speech modifying hook
speech = caller.location.at_before_say(self.args) speech = caller.at_before_say(self.args)
# preparing the speech with sdesc/speech parsing. # preparing the speech with sdesc/speech parsing.
speech = '/me says, "{speech}"'.format(speech=speech)
targets = self.caller.location.contents targets = self.caller.location.contents
send_emote(self.caller, targets, speech, anonymous_add=None) 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: except SdescError as err:
caller.msg(err) caller.msg(err)
return 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)) 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. Recognize another person in the same room.
Usage: Usage:
recog
recog sdesc as alias recog sdesc as alias
forget alias forget alias
@ -1048,8 +1061,8 @@ class CmdRecog(RPCommand): # assign personal alias to object in room
recog tall man as Griatch recog tall man as Griatch
forget griatch forget griatch
This will assign a personal alias for a person, or This will assign a personal alias for a person, or forget said alias.
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): def parse(self):
"Parse for the sdesc as alias structure" "Parse for the sdesc as alias structure"
self.sdesc, self.alias = "", ""
if " as " in self.args: if " as " in self.args:
self.sdesc, self.alias = [part.strip() for part in self.args.split(" as ", 2)] self.sdesc, self.alias = [part.strip() for part in self.args.split(" as ", 2)]
elif self.args: elif self.args:
@ -1070,22 +1084,47 @@ class CmdRecog(RPCommand): # assign personal alias to object in room
def func(self): def func(self):
"Assign the recog" "Assign the recog"
caller = self.caller 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(".?!") 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 prefixed_sdesc = sdesc if sdesc.startswith(_PREFIX) else _PREFIX + sdesc
candidates = caller.location.contents candidates = caller.location.contents
matches = parse_sdescs_and_recogs(caller, candidates, prefixed_sdesc, search_mode=True) matches = parse_sdescs_and_recogs(caller, candidates, prefixed_sdesc, search_mode=True)
nmatches = len(matches) nmatches = len(matches)
# handle 0, 1 and >1 matches # handle 0 and >1 matches
if nmatches == 0: if nmatches == 0:
caller.msg(_EMOTE_NOMATCH_ERROR.format(ref=sdesc)) caller.msg(_EMOTE_NOMATCH_ERROR.format(ref=sdesc))
elif nmatches > 1: elif nmatches > 1:
reflist = [ reflist = [
"%s%s%s (%s%s)" "{}{}{} ({}{})".format(
% (
inum + 1, inum + 1,
_NUM_SEP, _NUM_SEP,
_RE_PREFIX.sub("", sdesc), _RE_PREFIX.sub("", sdesc),
@ -1095,17 +1134,20 @@ class CmdRecog(RPCommand): # assign personal alias to object in room
for inum, obj in enumerate(matches) for inum, obj in enumerate(matches)
] ]
caller.msg(_EMOTE_MULTIMATCH_ERROR.format(ref=sdesc, reflist="\n ".join(reflist))) caller.msg(_EMOTE_MULTIMATCH_ERROR.format(ref=sdesc, reflist="\n ".join(reflist)))
else: else:
# one single match
obj = matches[0] obj = matches[0]
if not obj.access(self.obj, "enable_recog", default=True): 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). # 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 return
if self.cmdstring == "forget": if forget_mode:
# remove existing recog # remove existing recog
caller.recog.remove(obj) 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: else:
# set recog
sdesc = obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key sdesc = obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key
try: try:
alias = caller.recog.add(obj, alias) alias = caller.recog.add(obj, alias)
@ -1509,6 +1551,20 @@ class ContribRPCharacter(DefaultCharacter, ContribRPObject):
# initializing sdesc # initializing sdesc
self.sdesc.add("A normal person") 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): def process_sdesc(self, sdesc, obj, **kwargs):
""" """
Allows to customize how your sdesc is displayed (primarily by 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.speaker.recog.remove(self.receiver1)
self.assertEqual(self.speaker.recog.get(self.receiver1), sdesc1) self.assertEqual(self.speaker.recog.get(self.receiver1), sdesc1)
self.assertEqual(self.speaker.recog.all(), {"Mr Receiver2": self.receiver2})
def test_parse_language(self): def test_parse_language(self):
self.assertEqual( self.assertEqual(
rpsystem.parse_language(self.speaker, emote), rpsystem.parse_language(self.speaker, emote),
@ -233,6 +235,49 @@ class TestRPSystem(EvenniaTest):
self.assertEqual(self.speaker.search("colliding"), self.receiver2) 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 # Testing of ExtendedRoom contrib
from django.conf import settings from django.conf import settings
@ -607,7 +652,7 @@ class TestWilderness(EvenniaTest):
"west": (0, 1), "west": (0, 1),
"northwest": (0, 2), "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) new_loc = wilderness.get_new_coordinates(loc, direction)
self.assertEqual(new_loc, correct_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. combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case.
character.db.combat_actionsleft = ( character.db.combat_actionsleft = (
0 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0
) # Actions remaining - start of turn adds to this, turn ends when it reaches 0 )
character.db.combat_turnhandler = ( character.db.combat_turnhandler = (
self self # Add a reference to this turn handler script to the character
) # Add a reference to this turn handler script to the character )
character.db.combat_lastaction = "null" # Track last action taken in combat character.db.combat_lastaction = "null" # Track last action taken in combat
def start_turn(self, character): def start_turn(self, character):

View file

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

View file

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

View file

@ -26,3 +26,16 @@ def at_webserver_root_creation(web_root):
""" """
return 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: Usage:
inside() 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 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): def holds(accessing_obj, accessed_obj, *args, **kwargs):
""" """
Usage: Usage:
@ -604,7 +632,7 @@ def holds(accessing_obj, accessed_obj, *args, **kwargs):
if len(args) == 1: if len(args) == 1:
# command is holds(dbref/key) - check if given objname/dbref is held by accessing_ob # command is holds(dbref/key) - check if given objname/dbref is held by accessing_ob
return check_holds(args[0]) 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 # command is holds(attrname, value) check if any held object has the given attribute and value
for obj in contents: for obj in contents:
if obj.attributes.get(args[0]) == args[1]: if obj.attributes.get(args[0]) == args[1]:

View file

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

View file

@ -17,6 +17,7 @@ except ImportError:
from evennia import settings_default from evennia import settings_default
from evennia.locks import lockfuncs from evennia.locks import lockfuncs
from evennia.utils.create import create_object
# ------------------------------------------------------------ # ------------------------------------------------------------
# Lock testing # Lock testing
@ -179,6 +180,13 @@ class TestLockfuncs(EvenniaTest):
self.assertEqual(False, lockfuncs.inside(self.char1, self.room2)) self.assertEqual(False, lockfuncs.inside(self.char1, self.room2))
self.assertEqual(True, lockfuncs.holds(self.room1, self.char1)) self.assertEqual(True, lockfuncs.holds(self.room1, self.char1))
self.assertEqual(False, lockfuncs.holds(self.room2, 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): def test_has_account(self):
self.assertEqual(True, lockfuncs.has_account(self.char1, None)) self.assertEqual(True, lockfuncs.has_account(self.char1, None))

View file

@ -575,8 +575,10 @@ class ObjectDBManager(TypedObjectManager):
return None return None
# copy over all attributes from old to new. # copy over all attributes from old to new.
for attr in original_object.attributes.all(): attrs = (
new_object.attributes.add(attr.key, attr.value) (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 # copy over all cmdsets, if any
for icmdset, cmdset in enumerate(original_object.cmdset.all()): 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) ScriptDB.objects.copy_script(script, new_obj=new_object)
# copy over all tags, if any # copy over all tags, if any
for tag in original_object.tags.get(return_tagobj=True, return_list=True): tags = (
new_object.tags.add(tag=tag.db_key, category=tag.db_category, data=tag.db_data) (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 return new_object

View file

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

View file

@ -9,23 +9,35 @@ class DefaultObjectTest(EvenniaTest):
def test_object_create(self): def test_object_create(self):
description = "A home for a grouch." description = "A home for a grouch."
home = self.room1.dbref
obj, errors = DefaultObject.create( 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.assertTrue(obj, errors)
self.assertFalse(errors, errors) self.assertFalse(errors, errors)
self.assertEqual(description, obj.db.desc) self.assertEqual(description, obj.db.desc)
self.assertEqual(obj.db.creator_ip, self.ip) self.assertEqual(obj.db.creator_ip, self.ip)
self.assertEqual(obj.db_home, self.room1)
def test_character_create(self): def test_character_create(self):
description = "A furry green monster, reeking of garbage." description = "A furry green monster, reeking of garbage."
home = self.room1.dbref
obj, errors = DefaultCharacter.create( 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.assertTrue(obj, errors)
self.assertFalse(errors, errors) self.assertFalse(errors, errors)
self.assertEqual(description, obj.db.desc) self.assertEqual(description, obj.db.desc)
self.assertEqual(obj.db.creator_ip, self.ip) 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): def test_room_create(self):
description = "A dimly-lit alley behind the local Chinese restaurant." description = "A dimly-lit alley behind the local Chinese restaurant."
@ -101,3 +113,27 @@ class TestObjectManager(EvenniaTest):
self.assertEqual(list(query), [self.obj1]) self.assertEqual(list(query), [self.obj1])
query = ObjectDB.objects.get_objs_with_attr("NotFound", candidates=[self.char1, self.obj1]) query = ObjectDB.objects.get_objs_with_attr("NotFound", candidates=[self.char1, self.obj1])
self.assertFalse(query) 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) attr_tup = _get_tup_by_attrname(caller, attrname)
if attr_tup: 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: else:
caller.msg("Attribute not found.") caller.msg("Attribute not found.")
return "node_attrs" return "node_attrs"
@ -1260,7 +1260,7 @@ def _attrs_actions(caller, raw_inp, **kwargs):
if action and attr_tup: if action and attr_tup:
if action == "examine": 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": elif action == "remove":
res = _add_attr(caller, attrname, delete=True) res = _add_attr(caller, attrname, delete=True)
caller.msg(res) caller.msg(res)
@ -1439,7 +1439,7 @@ def _tags_actions(caller, raw_inp, **kwargs):
if tag_tup: if tag_tup:
if action == "examine": 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": elif action == "remove":
res = _add_tag(caller, tagname, delete=True) res = _add_tag(caller, tagname, delete=True)
caller.msg(res) caller.msg(res)
@ -1510,7 +1510,7 @@ def _locks_display(caller, lock):
def _lock_select(caller, lockstr): 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): def _lock_add(caller, lock, **kwargs):
@ -1552,7 +1552,7 @@ def _locks_actions(caller, raw_inp, **kwargs):
if lock: if lock:
if action == "examine": 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": elif action == "remove":
ret = _lock_add(caller, lock, delete=True) ret = _lock_add(caller, lock, delete=True)
caller.msg(ret) caller.msg(ret)
@ -1645,7 +1645,10 @@ def _display_perm(caller, permission, only_hierarchy=False):
def _permission_select(caller, permission, **kwargs): 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): def _add_perm(caller, perm, **kwargs):
@ -2051,7 +2054,7 @@ def _prototype_locks_actions(caller, raw_inp, **kwargs):
if lock: if lock:
if action == "examine": 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": elif action == "remove":
ret = _prototype_lock_add(caller, lock.strip(), delete=True) ret = _prototype_lock_add(caller, lock.strip(), delete=True)
caller.msg(ret) caller.msg(ret)

View file

@ -9,7 +9,7 @@ from evennia.scripts.models import ScriptDB
from evennia.utils import create from evennia.utils import create
from evennia.utils import logger from evennia.utils import logger
from django.utils.translation import ugettext as _ from django.utils.translation import gettext as _
class ScriptHandler(object): class ScriptHandler(object):
@ -78,11 +78,20 @@ class ScriptHandler(object):
scriptclass, key=key, account=self.obj, autostart=autostart scriptclass, key=key, account=self.obj, autostart=autostart
) )
else: else:
# the normal - adding to an Object # the normal - adding to an Object. We wait to autostart so we can differentiate
script = create.create_script(scriptclass, key=key, obj=self.obj, autostart=autostart) # 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: 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 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 return True
def start(self, key): def start(self, key):

View file

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

View file

@ -96,6 +96,12 @@ def check_errors(settings):
"must now be either None or a dict " "must now be either None or a dict "
"specifying the properties of the channel to create." "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): def check_warnings(settings):
@ -109,3 +115,10 @@ def check_warnings(settings):
print(" [Devel: settings.IN_GAME_ERRORS is True. Turn off in production.]") print(" [Devel: settings.IN_GAME_ERRORS is True. Turn off in production.]")
if settings.ALLOWED_HOSTS == ["*"]: if settings.ALLOWED_HOSTS == ["*"]:
print(" [Devel: settings.ALLOWED_HOSTS set to '*' (all). Limit in production.]") 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 from twisted.internet import reactor, endpoints
import django import django
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
from django.db.utils import ProgrammingError
# Signal processing # Signal processing
SIG = signal.SIGINT SIG = signal.SIGINT
@ -93,7 +94,7 @@ SRESET = chr(19) # shutdown server in reset mode
PYTHON_MIN = "3.7" PYTHON_MIN = "3.7"
TWISTED_MIN = "18.0.0" TWISTED_MIN = "18.0.0"
DJANGO_MIN = "2.1" DJANGO_MIN = "2.1"
DJANGO_REC = "2.2.5" DJANGO_REC = "2.2"
try: try:
sys.path[1] = EVENNIA_ROOT 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. # this happens if the file was cycled or manually deleted/edited.
print( print(
" ** Log file {filename} has cycled or been edited. " " ** Log file {filename} has cycled or been edited. "
"Restarting log. ".format(filehandle.name) "Restarting log. ".format(filename=filehandle.name)
) )
new_linecount = 0 new_linecount = 0
old_linecount = 0 old_linecount = 0
@ -1280,7 +1281,7 @@ def check_main_evennia_dependencies():
try: try:
dversion = ".".join(str(num) for num in django.VERSION if isinstance(num, int)) dversion = ".".join(str(num) for num in django.VERSION if isinstance(num, int))
# only the main version (1.5, not 1.5.4.0) # 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): if LooseVersion(dversion) < LooseVersion(DJANGO_MIN):
print(ERROR_DJANGO_MIN.format(dversion=dversion_main, django_min=DJANGO_MIN)) print(ERROR_DJANGO_MIN.format(dversion=dversion_main, django_min=DJANGO_MIN))
error = True error = True
@ -1428,12 +1429,17 @@ def create_superuser():
django.core.management.call_command("createsuperuser", interactive=True) django.core.management.call_command("createsuperuser", interactive=True)
def check_database(): def check_database(always_return=False):
""" """
Check so the database exists. 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: Returns:
exists (bool): `True` if the database exists, otherwise `False`. exists (bool): `True` if the database exists, otherwise `False`.
""" """
# Check so a database exists and is accessible # Check so a database exists and is accessible
from django.db import connection from django.db import connection
@ -1449,7 +1455,9 @@ def check_database():
try: try:
AccountDB.objects.get(id=1) 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)) print(ERROR_DATABASE.format(traceback=e))
sys.exit() sys.exit()
except AccountDB.DoesNotExist: except AccountDB.DoesNotExist:
@ -1484,7 +1492,7 @@ def check_database():
new.save() new.save()
else: else:
create_superuser() create_superuser()
check_database() check_database(always_return=always_return)
return True return True
@ -2246,14 +2254,15 @@ def main():
# pass-through to django manager, but set things up first # pass-through to django manager, but set things up first
check_db = False check_db = False
need_gamedir = True 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 # handle special django commands
if option in ("runserver", "testserver"): if option in ("runserver", "testserver"):
# we don't want the django test-webserver
print(WARNING_RUNSERVER) 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, # some django commands requires the database to exist,
# or evennia._init to have run before they work right. # or evennia._init to have run before they work right.
check_db = True check_db = True
@ -2263,16 +2272,17 @@ def main():
init_game_directory(CURRENT_DIR, check_db=check_db, need_gamedir=need_gamedir) init_game_directory(CURRENT_DIR, check_db=check_db, need_gamedir=need_gamedir)
if option in ("migrate", "makemigrations"): if option == "migrate":
# we have to launch migrate within the program to make sure migrations # we need to bypass some checks here for the first db creation
# run within the scope of the launcher (otherwise missing a db will cause errors) if not check_database(always_return=True):
django.core.management.call_command(*([option] + unknown_args)) django.core.management.call_command(*([option] + unknown_args))
else: 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 # pass on to the core django manager - re-parse the entire input line
# an exit condition. # but keep 'evennia' as the name instead of django-admin. This is
sys.argv[0] = re.sub(r"(-script\.pyw?|\.exe)?$", "", sys.argv[0]) # an exit condition.
sys.exit(execute_from_command_line()) sys.argv[0] = re.sub(r"(-script\.pyw?|\.exe)?$", "", sys.argv[0])
sys.exit(execute_from_command_line())
elif not args.tail_log: elif not args.tail_log:
# no input; print evennia info (don't pring if we're tailing 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 # 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 (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 Evennia. By listing your game on the index, you make it easy for other
people in the community to discover your creation. people in the community to discover your creation.
@ -14,74 +14,24 @@ on remedying this.*
## Listing your Game ## Listing your Game
To list your game, you'll need to enable the Evennia Game Index client. To list your game, go to your game dir and run
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:
```python evennia connections
"""
Server plugin services
This plugin module can define user-created services for the Server to Follow the prompts to add details to the listing. Use `evennia reload`. In your log (visible with `evennia --log`
start. 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 ## Detailed settings
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.
"""
If you don't want to use the wizard you can configure your game listing by opening up `server/conf/settings.py` and
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
using the following as a starting point: using the following as a starting point:
```python ```python
###################################################################### ######################################################################
# Contrib config # Game index
###################################################################### ######################################################################
GAME_INDEX_ENABLED = True
GAME_INDEX_LISTING = { GAME_INDEX_LISTING = {
'game_status': 'pre-alpha', 'game_status': 'pre-alpha',
# Optional, comment out or remove if N/A # Optional, comment out or remove if N/A

View file

@ -9,7 +9,7 @@ Everything starts at handle_setup()
import time import time
from django.conf import settings 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.accounts.models import AccountDB
from evennia.server.models import ServerConfig from evennia.server.models import ServerConfig
from evennia.utils import create, logger 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] fieldnames = [tup[1] for tup in monitor_infos]
session.msg(reported_variables=(fieldnames, {})) session.msg(reported_variables=(fieldnames, {}))
if "sendable_variables" in args_lower: if "sendable_variables" in args_lower:
# no default sendable variables session.msg(sendable_variables=(_monitorable, {}))
session.msg(sendable_variables=([], {}))
def msdp_report(session, *args, **kwargs): def msdp_report(session, *args, **kwargs):
@ -597,6 +596,17 @@ def msdp_unreport(session, *args, **kwargs):
unmonitor(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 # client specific

View file

@ -314,7 +314,9 @@ class AMPMultiConnectionProtocol(amp.AMP):
try: try:
super(AMPMultiConnectionProtocol, self).dataReceived(data) super(AMPMultiConnectionProtocol, self).dataReceived(data)
except KeyError: 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: elif self.multibatches:
# invalid AMP, but we have a pending multi-batch that is not yet complete # invalid AMP, but we have a pending multi-batch that is not yet complete
if data[-2:] == NULNUL: if data[-2:] == NULNUL:
@ -323,7 +325,9 @@ class AMPMultiConnectionProtocol(amp.AMP):
try: try:
super(AMPMultiConnectionProtocol, self).dataReceived(data) super(AMPMultiConnectionProtocol, self).dataReceived(data)
except KeyError: 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: else:
# not an AMP communication, return warning # not an AMP communication, return warning
self.transport.write(_HTTP_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. mccp_compress and calling it from its write methods.
""" """
import zlib import zlib
from twisted.python.compat import _bytesChr as chr
# negotiations for v1 and v2 of the protocol # negotiations for v1 and v2 of the protocol
MCCP = b"\x56" MCCP = chr(86) # b"\x56"
FLUSH = zlib.Z_SYNC_FLUSH FLUSH = zlib.Z_SYNC_FLUSH

View file

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

View file

@ -14,11 +14,12 @@ http://www.gammon.com.au/mushclient/addingservermxp.htm
""" """
import re import re
from twisted.python.compat import _bytesChr as bchr
LINKS_SUB = re.compile(r"\|lc(.*?)\|lt(.*?)\|le", re.DOTALL) LINKS_SUB = re.compile(r"\|lc(.*?)\|lt(.*?)\|le", re.DOTALL)
# MXP Telnet option # MXP Telnet option
MXP = b"\x5b" MXP = bchr(91) # b"\x5b"
MXP_TEMPSECURE = "\x1B[4z" MXP_TEMPSECURE = "\x1B[4z"
MXP_SEND = MXP_TEMPSECURE + '<SEND HREF="\\1">' + "\\2" + MXP_TEMPSECURE + "</SEND>" 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 codecs import encode as codecs_encode
from django.conf import settings from django.conf import settings
from twisted.python.compat import _bytesChr as bchr
NAWS = b"\x1f" NAWS = bchr(31) # b"\x1f"
IS = b"\x00" IS = bchr(0) # b"\x00"
# default taken from telnet specification # default taken from telnet specification
DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
DEFAULT_HEIGHT = settings.CLIENT_DEFAULT_HEIGHT DEFAULT_HEIGHT = settings.CLIENT_DEFAULT_HEIGHT

View file

@ -95,6 +95,16 @@ INFO_DICT = {
"webserver_internal": [], "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 # Portal Service object
# ------------------------------------------------------------- # -------------------------------------------------------------
@ -190,7 +200,6 @@ class Portal(object):
self.sessions.disconnect_all() self.sessions.disconnect_all()
if _stop_server: if _stop_server:
self.amp_protocol.stop_server(mode="shutdown") self.amp_protocol.stop_server(mode="shutdown")
if not _reactor_stopping: if not _reactor_stopping:
# shutting down the reactor will trigger another signal. We set # shutting down the reactor will trigger another signal. We set
# a flag to avoid loops. # a flag to avoid loops.
@ -213,7 +222,10 @@ application = service.Application("Portal")
if "--nodaemon" not in sys.argv: if "--nodaemon" not in sys.argv:
logfile = logger.WeeklyLogFile( 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) application.setComponent(ILogObserver, logger.PortalLogObserver(logfile).emit)
@ -376,6 +388,14 @@ if WEBSERVER_ENABLED:
webclientstr = "webclient-websocket%s: %s" % (w_ifacestr, port) webclientstr = "webclient-websocket%s: %s" % (w_ifacestr, port)
INFO_DICT["webclient"].append(webclientstr) 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 = Website(web_root, logPath=settings.HTTP_LOG_FILE)
web_root.is_portal = True web_root.is_portal = True
proxy_service = internet.TCPServer(proxyport, web_root, interface=interface) 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 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 # default taken from telnet specification

View file

@ -40,6 +40,21 @@ _RE_SCREENREADER_REGEX = re.compile(
) )
_IDLE_COMMAND = str.encode(settings.IDLE_COMMAND + "\n") _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): class TelnetServerFactory(protocol.ServerFactory):
"This is only to name this better in logs" "This is only to name this better in logs"
@ -60,13 +75,21 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
self.protocol_key = "telnet" self.protocol_key = "telnet"
super().__init__(*args, **kwargs) 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): def connectionMade(self):
""" """
This is called when the connection is first established. This is called when the connection is first established.
""" """
# important in order to work normally with standard telnet # important in order to work normally with standard telnet
self.do(LINEMODE) self.do(LINEMODE).addErrback(self._wont_linemode)
# initialize the session # initialize the session
self.line_buffer = b"" self.line_buffer = b""
client_address = self.transport.client client_address = self.transport.client
@ -111,6 +134,14 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
self.nop_keep_alive = None self.nop_keep_alive = None
self.toggle_nop_keepalive() 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): def _send_nop_keepalive(self):
"""Send NOP keepalive unless flag is set""" """Send NOP keepalive unless flag is set"""
if self.protocol_flags.get("NOPKEEPALIVE"): if self.protocol_flags.get("NOPKEEPALIVE"):
@ -178,6 +209,16 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
or option == suppress_ga.SUPPRESS_GA 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): def enableLocal(self, option):
""" """
Call to allow the activation of options for this protocol 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. option (char): The telnet option to disable locally.
""" """
if option == LINEMODE:
return True
if option == ECHO: if option == ECHO:
return True return True
if option == MCCP: if option == MCCP:
self.mccp.no_mccp(option) self.mccp.no_mccp(option)
return True return True
else: else:
return super().disableLocal(option) try:
return super().disableLocal(option)
except Exception:
from evennia.utils import logger
logger.log_trace()
def connectionLost(self, reason): def connectionLost(self, reason):
""" """
@ -246,6 +294,14 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
data = [_IDLE_COMMAND] data = [_IDLE_COMMAND]
else: else:
data = _RE_LINEBREAK.split(data) 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: if self.line_buffer and len(data) > 1:
# buffer exists, it is terminated by the first line feed # buffer exists, it is terminated by the first line feed
data[0] = self.line_buffer + data[0] data[0] = self.line_buffer + data[0]

View file

@ -28,22 +28,24 @@ header where applicable.
import re import re
import json import json
from evennia.utils.utils import is_iter from evennia.utils.utils import is_iter
from twisted.python.compat import _bytesChr as bchr
# 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"
# General Telnet # General Telnet
from twisted.conch.telnet import IAC, SB, SE 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 # pre-compiled regexes
# returns 2-tuple # returns 2-tuple
@ -168,7 +170,7 @@ class TelnetOOB(object):
""" """
msdp_cmdname = "{msdp_var}{msdp_cmdname}{msdp_val}".format( 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): if not (args or kwargs):
@ -186,9 +188,9 @@ class TelnetOOB(object):
"{msdp_array_open}" "{msdp_array_open}"
"{msdp_args}" "{msdp_args}"
"{msdp_array_close}".format( "{msdp_array_close}".format(
msdp_array_open=MSDP_ARRAY_OPEN, msdp_array_open=MSDP_ARRAY_OPEN.decode(),
msdp_array_close=MSDP_ARRAY_CLOSE, msdp_array_close=MSDP_ARRAY_CLOSE.decode(),
msdp_args="".join("%s%s" % (MSDP_VAL, json.dumps(val)) for val in args), msdp_args="".join("%s%s" % (MSDP_VAL.decode(), val) for val in args),
) )
) )
@ -199,10 +201,10 @@ class TelnetOOB(object):
"{msdp_table_open}" "{msdp_table_open}"
"{msdp_kwargs}" "{msdp_kwargs}"
"{msdp_table_close}".format( "{msdp_table_close}".format(
msdp_table_open=MSDP_TABLE_OPEN, msdp_table_open=MSDP_TABLE_OPEN.decode(),
msdp_table_close=MSDP_TABLE_CLOSE, msdp_table_close=MSDP_TABLE_CLOSE.decode(),
msdp_kwargs="".join( 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() 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) keypair.generate_key(crypto.TYPE_RSA, _PRIVATE_KEY_LENGTH)
with open(_PRIVATE_KEY_FILE, "wt") as pfile: 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)) print("Created SSL private key in '{}'.".format(_PRIVATE_KEY_FILE))
with open(_PUBLIC_KEY_FILE, "wt") as pfile: 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)) print("Created SSL public key in '{}'.".format(_PUBLIC_KEY_FILE))
except Exception as err: except Exception as err:
@ -128,7 +128,7 @@ def verify_or_create_SSL_key_and_cert(keyfile, certfile):
cert.sign(keypair, "sha1") cert.sign(keypair, "sha1")
with open(_CERTIFICATE_FILE, "wt") as cfile: 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)) print("Created SSL certificate in '{}'.".format(_CERTIFICATE_FILE))
except Exception as err: except Exception as err:

View file

@ -11,6 +11,7 @@ except ImportError:
import sys import sys
import string import string
import mock import mock
import pickle
from mock import Mock, MagicMock from mock import Mock, MagicMock
from evennia.server.portal import irc from evennia.server.portal import irc
@ -50,11 +51,21 @@ class TestAMPServer(TwistedTestCase):
self.proto.makeConnection(self.transport) self.proto.makeConnection(self.transport)
self.proto.data_to_server(MsgServer2Portal, 1, test=2) self.proto.data_to_server(MsgServer2Portal, 1, test=2)
byte_out = (
b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgServer2Portal\x00\x0b" if pickle.HIGHEST_PROTOCOL == 5:
b"packed_data\x00 x\xdak`\x99*\xc8\x00\x01\xde\x8c\xb5SzXJR" # Python 3.8+
b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00V:\x07t\x00\x00" 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) self.transport.write.assert_called_with(byte_out)
with mock.patch("evennia.server.portal.amp.amp.AMP.dataReceived") as mocked_amprecv: with mock.patch("evennia.server.portal.amp.amp.AMP.dataReceived") as mocked_amprecv:
self.proto.dataReceived(byte_out) self.proto.dataReceived(byte_out)
@ -64,11 +75,20 @@ class TestAMPServer(TwistedTestCase):
self.proto.makeConnection(self.transport) self.proto.makeConnection(self.transport)
self.proto.data_to_server(MsgPortal2Server, 1, test=2) self.proto.data_to_server(MsgPortal2Server, 1, test=2)
byte_out = ( if pickle.HIGHEST_PROTOCOL == 5:
b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgPortal2Server\x00\x0b" # Python 3.8+
b"packed_data\x00 x\xdak`\x99*\xc8\x00\x01\xde\x8c\xb5SzXJR" byte_out = (
b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00V:\x07t\x00\x00" 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) self.transport.write.assert_called_with(byte_out)
with mock.patch("evennia.server.portal.amp.amp.AMP.dataReceived") as mocked_amprecv: with mock.patch("evennia.server.portal.amp.amp.AMP.dataReceived") as mocked_amprecv:
self.proto.dataReceived(byte_out) self.proto.dataReceived(byte_out)
@ -82,28 +102,28 @@ class TestAMPServer(TwistedTestCase):
outstr = "test" * AMP_MAXLEN outstr = "test" * AMP_MAXLEN
self.proto.data_to_server(MsgServer2Portal, 1, test=outstr) 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( self.transport.write.assert_called_with(
b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgServer2Portal\x00\x0bpacked_data" 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"\x00wx\xda\xed\xc6\xc1\t\x80 \x00@Q#=5Z\x0b\xb8\x80\x13\xe85h\x80\x8e\xbam`Dc\xf4><\xf8g"
b'\x05\xe6+X\x80\xcf\xd8m@I\x1d\x99\x85\x81\xbd\xf3\xdd"c\xb4/W{' b"\x1a[\xf8\xda\x97\xa3_\xb1\x95\xdaz\xbe\xe7\x1a\xde\x03\x00\x00\x00\x00\x00\x00\x00"
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\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\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\xe0\x1f\x1eP\x1d\x02\r\x00\rpacked_data.2"
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00`\x0e?Pv\x02\x16\x00\r" b"\x00Zx\xda\xed\xc3\x01\r\x00\x00\x08\xc0\xa0\xb4&\xf0\xfdg\x10a\xa3"
b"packed_data.2\x00Zx\xda\xed\xc3\x01\r\x00\x00\x08\xc0\xa0\xb4&\xf0\xfdg\x10a" b"\xd9RUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU\xf5\xfb\x03m\xe0\x06"
b"\xa3\xd9RUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU\xf5\xfb" b"\x1d\x00\rpacked_data.3\x00Zx\xda\xed\xc3\x01\r\x00\x00\x08\xc0\xa0\xb4&\xf0\xfdg\x10a"
b"\x03m\xe0\x06\x1d\x00\rpacked_data.3\x00Zx\xda\xed\xc3\x01\r\x00\x00\x08\xc0\xa0" b"\xa3fSUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU\xf5\xfb\x03n\x1c"
b"\xb4&\xf0\xfdg\x10a\xa3fSUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU" b"\x06\x1e\x00\rpacked_data.4\x00Zx\xda\xed\xc3\x01\t\x00\x00\x0c\x03\xa0\xb4O\xb0\xf5gA"
b"UUUUU\xf5\xfb\x03n\x1c\x06\x1e\x00\rpacked_data.4\x00Zx\xda\xed\xc3\x01\t\x00" b"\xae`\xda\x8b\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa"
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\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\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\xdf\x0fnI\x06,\x00\rpacked_data.5\x00\x18x\xdaK-.)I\xc5\x8e\xa7\xb22@\xc0"
b"\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xdf\x0fnI" b"\x94\xe2\xb6)z\x00Z\x1e\x0e\xb6\x00\x00"
b"\x06,\x00\rpacked_data.5\x00\x14x\xdaK-.)I\xc5\x8e\xa7\x14\xb7M\xd1\x03\x00"
b"\xe7s\x0e\x1c\x00\x00"
) )
else: elif pickle.HIGHEST_PROTOCOL == 4:
# Python 3.7
self.transport.write.assert_called_with( self.transport.write.assert_called_with(
b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgServer2Portal\x00\x0bpacked_data" 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" 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, All data will be stored on the protocol's protocol_flags dictionary,
under the 'TTYPE' key. under the 'TTYPE' key.
""" """
from twisted.python.compat import _bytesChr as bchr
# telnet option codes # telnet option codes
TTYPE = b"\x18" TTYPE = bchr(24) # b"\x18"
IS = b"\x00" IS = bchr(0) # b"\x00"
SEND = b"\x01" SEND = bchr(1) # b"\x01"
# terminal capabilities and their codes # terminal capabilities and their codes
MTTS = [ MTTS = [

View file

@ -29,16 +29,26 @@ _RE_SCREENREADER_REGEX = re.compile(
r"%s" % settings.SCREENREADER_REGEX_STRIP, re.DOTALL + re.MULTILINE r"%s" % settings.SCREENREADER_REGEX_STRIP, re.DOTALL + re.MULTILINE
) )
_CLIENT_SESSIONS = mod_import(settings.SESSION_ENGINE).SessionStore _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 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): class WebSocketClient(WebSocketServerProtocol, Session):
""" """
Implements the server-side of the Websocket connection. 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): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.protocol_key = "webclient/websocket" self.protocol_key = "webclient/websocket"
@ -75,14 +85,26 @@ class WebSocketClient(WebSocketServerProtocol, Session):
""" """
client_address = self.transport.client client_address = self.transport.client
client_address = client_address[0] if client_address else None 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) self.init_session("websocket", client_address, self.factory.sessionhandler)
csession = self.get_client_session() # this sets self.csessid csession = self.get_client_session() # this sets self.csessid
csessid = self.csessid csessid = self.csessid
uid = csession and csession.get("webclient_authenticated_uid", None) uid = csession and csession.get("webclient_authenticated_uid", None)
nonce = csession and csession.get("webclient_authenticated_nonce", 0)
if uid: if uid:
# the client session is already logged in. # the client session is already logged in.
self.uid = uid self.uid = uid
self.nonce = nonce
self.logged_in = True self.logged_in = True
for old_session in self.sessionhandler.sessions_from_csessid(csessid): for old_session in self.sessionhandler.sessions_from_csessid(csessid):
@ -111,12 +133,20 @@ class WebSocketClient(WebSocketServerProtocol, Session):
csession = self.get_client_session() csession = self.get_client_session()
if csession: if csession:
csession["webclient_authenticated_uid"] = None # if the nonce is different, webclient_authenticated_uid has been
csession.save() # 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.logged_in = False
self.sessionhandler.disconnect(self) 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. # in case anyone wants to expose this functionality later.
# #
# sendClose() under autobahn/websocket/interfaces.py # 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. 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) self.disconnect(reason)
else: else:
self.websocket_close_code = code self.websocket_close_code = code

View file

@ -38,7 +38,7 @@ from evennia.utils import logger
from evennia.comms import channelhandler from evennia.comms import channelhandler
from evennia.server.sessionhandler import SESSIONS from evennia.server.sessionhandler import SESSIONS
from django.utils.translation import ugettext as _ from django.utils.translation import gettext as _
_SA = object.__setattr__ _SA = object.__setattr__
@ -148,13 +148,17 @@ def _server_maintenance():
# handle idle timeouts # handle idle timeouts
if _IDLE_TIMEOUT > 0: if _IDLE_TIMEOUT > 0:
reason = _("idle timeout exceeded") reason = _("idle timeout exceeded")
to_disconnect = []
for session in ( for session in (
sess for sess in SESSIONS.values() if (now - sess.cmd_last) > _IDLE_TIMEOUT sess for sess in SESSIONS.values() if (now - sess.cmd_last) > _IDLE_TIMEOUT
): ):
if not session.account or not session.account.access( if not session.account or not session.account.access(
session.account, "noidletimeout", default=False 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 [ yield [
(s.pause(manual_pause=False), s.at_server_reload()) (s.pause(manual_pause=False), s.at_server_reload())
for s in ScriptDB.get_all_cached_instances() 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() yield self.sessions.all_sessions_portal_sync()
self.at_server_reload_stop() self.at_server_reload_stop()
@ -612,7 +616,10 @@ application = service.Application("Evennia")
if "--nodaemon" not in sys.argv: if "--nodaemon" not in sys.argv:
# custom logging, but only if we are not running in interactive mode # custom logging, but only if we are not running in interactive mode
logfile = logger.WeeklyLogFile( 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) application.setComponent(ILogObserver, logger.ServerLogObserver(logfile).emit)

View file

@ -23,7 +23,7 @@ _ObjectDB = None
_ANSI = None _ANSI = None
# i18n # i18n
from django.utils.translation import ugettext as _ from django.utils.translation import gettext as _
# Handlers for Session.db/ndb operation # 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 SRESET = chr(19) # server shutdown in reset mode
# i18n # i18n
from django.utils.translation import ugettext as _ from django.utils.translation import gettext as _
_SERVERNAME = settings.SERVERNAME _SERVERNAME = settings.SERVERNAME
_MULTISESSION_MODE = settings.MULTISESSION_MODE _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. Check to see if this is a reverse proxied connection.
""" """
CLIENT = 0 if self.requests:
http.HTTPChannel.allHeadersReceived(self) CLIENT = 0
req = self.requests[-1] http.HTTPChannel.allHeadersReceived(self)
client_ip, port = self.transport.client req = self.requests[-1]
proxy_chain = req.getHeader("X-FORWARDED-FOR") client_ip, port = self.transport.client
if proxy_chain and client_ip in _UPSTREAM_IPS: proxy_chain = req.getHeader("X-FORWARDED-FOR")
forwarded = proxy_chain.split(", ", 1)[CLIENT] if proxy_chain and client_ip in _UPSTREAM_IPS:
self.transport.client = (forwarded, port) forwarded = proxy_chain.split(", ", 1)[CLIENT]
self.transport.client = (forwarded, port)
# Monkey-patch Twisted to handle X-Forwarded-For. # Monkey-patch Twisted to handle X-Forwarded-For.

View file

@ -135,17 +135,19 @@ else:
break break
os.chdir(os.pardir) 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") LOG_DIR = os.path.join(GAME_DIR, "server", "logs")
SERVER_LOG_FILE = os.path.join(LOG_DIR, "server.log") 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_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") HTTP_LOG_FILE = os.path.join(LOG_DIR, "http_requests.log")
# if this is set to the empty string, lockwarnings will be turned off. # if this is set to the empty string, lockwarnings will be turned off.
LOCKWARNING_LOG_FILE = os.path.join(LOG_DIR, "lockwarnings.log") 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 # Number of lines to append to rotating channel logs when they rotate
CHANNEL_LOG_NUM_TAIL_LINES = 20 CHANNEL_LOG_NUM_TAIL_LINES = 20
# Max size (in bytes) of channel log files before they rotate # 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: # ENGINE - path to the the database backend. Possible choices are:
# 'django.db.backends.sqlite3', (default) # 'django.db.backends.sqlite3', (default)
# 'django.db.backends.mysql', # 'django.db.backends.mysql',
# 'django.db.backends.postgresql_psycopg2', # 'django.db.backends.postgresql',
# 'django.db.backends.oracle' (untested). # 'django.db.backends.oracle' (untested).
# NAME - database name, or path to the db file for sqlite3 # NAME - database name, or path to the db file for sqlite3
# USER - db admin (unused in 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 # Command set used on session before account has logged in
CMDSET_UNLOGGEDIN = "commands.default_cmdsets.UnloggedinCmdSet" 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 # Command set used on the logged-in session
CMDSET_SESSION = "commands.default_cmdsets.SessionCmdSet" CMDSET_SESSION = "commands.default_cmdsets.SessionCmdSet"
# Default set for logged in account with characters (fallback) # Default set for logged in account with characters (fallback)
CMDSET_CHARACTER = "commands.default_cmdsets.CharacterCmdSet" CMDSET_CHARACTER = "commands.default_cmdsets.CharacterCmdSet"
# Command set for accounts without a character (ooc) # Command set for accounts without a character (ooc)
CMDSET_ACCOUNT = "commands.default_cmdsets.AccountCmdSet" CMDSET_ACCOUNT = "commands.default_cmdsets.AccountCmdSet"
# Location to search for cmdsets if full path not given # Location to search for cmdsets if full path not given
CMDSET_PATHS = ["commands", "evennia", "evennia.contrib"] CMDSET_PATHS = ["commands", "evennia", "evennia.contrib"]
# Fallbacks for cmdset paths that fail to load. Note that if you change the path for your # 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" COMMAND_DEFAULT_HELP_CATEGORY = "general"
# The default lockstring of a command. # The default lockstring of a command.
COMMAND_DEFAULT_LOCKS = "" COMMAND_DEFAULT_LOCKS = ""
# The Channel Handler will create a command to represent each channel, # The Channel Handler is responsible for managing all available channels. By
# creating it with the key of the channel, its aliases, locks etc. The # default it builds the current channels into a channel-cmdset that it feeds
# default class logs channel messages to a file and allows for /history. # to the cmdhandler. Overloading this can completely change how Channels
# This setting allows to override the command class used with your own. # 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" CHANNEL_COMMAND_CLASS = "evennia.comms.channelhandler.ChannelCommand"
###################################################################### ######################################################################
@ -640,6 +650,12 @@ CLIENT_DEFAULT_HEIGHT = 45
# (excluding webclient with separate help popups). If continuous scroll # (excluding webclient with separate help popups). If continuous scroll
# is preferred, change 'HELP_MORE' to False. EvMORE uses CLIENT_DEFAULT_HEIGHT # is preferred, change 'HELP_MORE' to False. EvMORE uses CLIENT_DEFAULT_HEIGHT
HELP_MORE = True 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 # Guest accounts

View file

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

View file

@ -5,7 +5,8 @@ all Attributes and TypedObjects).
""" """
import shlex 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 import idmapper
from evennia.utils.utils import make_iter, variable_from_module from evennia.utils.utils import make_iter, variable_from_module
from evennia.typeclasses.attributes import Attribute 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") 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. Return objects having tags with a given key or category or combination of the two.
Also accepts multiple tags/category/tagtype Also accepts multiple tags/category/tagtype
Args: Args:
key (str or list, optional): Tag key or list of keys. Not case sensitive. 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 category (str or list, optional): Tag category. Not case sensitive.
a list, a single category can either apply to all keys in that list or this If `key` is a list, a single category can either apply to all
must be a list matching the `key` list element by element. If no `key` is given, keys in that list or this must be a list matching the `key`
all objects with tags of this category are returned. 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 tagtype (str, optional): 'type' of Tag, by default
this is either `None` (a normal Tag), `alias` or this is either `None` (a normal Tag), `alias` or
`permission`. This always apply to all queried tags. `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: Returns:
objects (list): Objects with matching tag. objects (list): Objects with matching tag.
@ -262,10 +271,18 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
if not (key or category): if not (key or category):
return [] 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 [] keys = make_iter(key) if key else []
categories = make_iter(category) if category else [] categories = make_iter(category) if category else []
n_keys = len(keys) n_keys = len(keys)
n_categories = len(categories) n_categories = len(categories)
unique_categories = sorted(set(categories))
n_unique_categories = len(unique_categories)
dbmodel = self.model.__dbclass__.__name__.lower() dbmodel = self.model.__dbclass__.__name__.lower()
query = ( query = (
@ -286,14 +303,30 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
"get_by_tag needs a single category or a list of categories " "get_by_tag needs a single category or a list of categories "
"the same length as the list of tags." "the same length as the list of tags."
) )
clauses = Q()
for ikey, key in enumerate(keys): for ikey, key in enumerate(keys):
query = query.filter( # Keep each key and category together, grouped by AND
db_tags__db_key__iexact=key, db_tags__db_category__iexact=categories[ikey] clauses |= Q(db_key__iexact=key, db_category__iexact=categories[ikey])
)
else: else:
# only one or more categories given # only one or more categories given
for category in categories: # import evennia;evennia.set_trace()
query = query.filter(db_tags__db_category__iexact=category) 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 return query
@ -452,6 +485,34 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
retval = retval.filter(id__lte=self.dbref(max_dbref, reqhash=False)) retval = retval.filter(id__lte=self.dbref(max_dbref, reqhash=False))
return retval 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): def object_totals(self):
""" """
Get info about database statistics. Get info about database statistics.
@ -463,11 +524,8 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
object having that typeclass set on themselves). object having that typeclass set on themselves).
""" """
dbtotals = {} stats = self.get_typeclass_totals().order_by("typeclass")
typeclass_paths = set(self.values_list("db_typeclass_path", flat=True)) return {x.get("typeclass"): x.get("count") for x in stats}
for typeclass_path in typeclass_paths:
dbtotals[typeclass_path] = self.filter(db_typeclass_path=typeclass_path).count()
return dbtotals
def typeclass_search(self, typeclass, include_children=False, include_parents=False): 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("SET FOREIGN_KEY_CHECKS=0;")
db_cursor.execute("DROP TABLE {table};".format(table=table_name)) db_cursor.execute("DROP TABLE {table};".format(table=table_name))
db_cursor.execute("SET FOREIGN_KEY_CHECKS=1;") 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("ALTER TABLE {table} DISABLE TRIGGER ALL;".format(table=table_name))
db_cursor.execute("DROP TABLE {table};".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)) 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 # 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 Returns true if this object has this type OR has a typeclass
which is an subclass of the given typeclass. This operates on 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 # full cache was run on all tags
self._cache_complete = False self._cache_complete = False
def _fullcache(self): def _query_all(self):
"Cache all tags of this object" "Get all tags for this objects"
query = { query = {
"%s__id" % self._model: self._objid, "%s__id" % self._model: self._objid,
"tag__db_model": self._model, "tag__db_model": self._model,
"tag__db_tagtype": self._tagtype, "tag__db_tagtype": self._tagtype,
} }
tags = [ return [
conn.tag conn.tag
for conn in getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query) 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( self._cache = dict(
( (
"%s-%s" "%s-%s"
@ -193,7 +199,8 @@ class TagHandler(object):
conn = getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query) conn = getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query)
if conn: if conn:
tag = conn[0].tag tag = conn[0].tag
self._cache[cachekey] = tag if _TYPECLASS_AGGRESSIVE_CACHE:
self._cache[cachekey] = tag
return [tag] return [tag]
else: else:
# only category given (even if it's None) - we can't # only category given (even if it's None) - we can't
@ -216,11 +223,12 @@ class TagHandler(object):
**query **query
) )
] ]
for tag in tags: if _TYPECLASS_AGGRESSIVE_CACHE:
cachekey = "%s-%s" % (tag.db_key, category) for tag in tags:
self._cache[cachekey] = tag cachekey = "%s-%s" % (tag.db_key, category)
# mark category cache as up-to-date self._cache[cachekey] = tag
self._catcache[catkey] = True # mark category cache as up-to-date
self._catcache[catkey] = True
return tags return tags
return [] return []
@ -234,9 +242,11 @@ class TagHandler(object):
tag_obj (tag): The newly saved tag tag_obj (tag): The newly saved tag
""" """
if not _TYPECLASS_AGGRESSIVE_CACHE:
return
if not key: # don't allow an empty key in cache if not key: # don't allow an empty key in cache
return 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) cachekey = "%s-%s" % (key, category)
catkey = "-%s" % category catkey = "-%s" % category
self._cache[cachekey] = tag_obj self._cache[cachekey] = tag_obj
@ -253,7 +263,7 @@ class TagHandler(object):
category (str or None): A cleaned category name 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 catkey = "-%s" % category
if key: if key:
cachekey = "%s-%s" % (key, category) cachekey = "%s-%s" % (key, category)
@ -419,9 +429,13 @@ class TagHandler(object):
`return_key_and_category` is set. `return_key_and_category` is set.
""" """
if not self._cache_complete: if _TYPECLASS_AGGRESSIVE_CACHE:
self._fullcache() if not self._cache_complete:
tags = sorted(self._cache.values()) self._fullcache()
tags = sorted(self._cache.values())
else:
tags = sorted(self._query_all())
if return_key_and_category: if return_key_and_category:
# return tuple (key, category) # return tuple (key, category)
return [(to_str(tag.db_key), tag.db_category) for tag in tags] 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 Unit tests for typeclass base system
""" """
from django.test import override_settings
from evennia.utils.test_resources import EvenniaTest from evennia.utils.test_resources import EvenniaTest
from mock import patch
# ------------------------------------------------------------ # ------------------------------------------------------------
# Manager tests # Manager tests
@ -19,6 +20,19 @@ class TestAttributes(EvenniaTest):
self.obj1.db.testattr = value self.obj1.db.testattr = value
self.assertEqual(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): def test_weird_text_save(self):
"test 'weird' text type (different in py2 vs py3)" "test 'weird' text type (different in py2 vs py3)"
from django.utils.safestring import SafeText from django.utils.safestring import SafeText
@ -62,6 +76,9 @@ class TestTypedObjectManager(EvenniaTest):
self.obj2.tags.add("tag6", "category3") self.obj2.tags.add("tag6", "category3")
self.obj2.tags.add("tag7", "category1") self.obj2.tags.add("tag7", "category1")
self.obj2.tags.add("tag7", "category5") 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", "tag5", "category1"), [self.obj1, self.obj2])
self.assertEqual(self._manager("get_by_tag", "tag6", "category1"), []) self.assertEqual(self._manager("get_by_tag", "tag6", "category1"), [])
self.assertEqual(self._manager("get_by_tag", "tag6", "category3"), [self.obj1, self.obj2]) 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._manager("get_by_tag", category=["category1", "category3"]), [self.obj1, self.obj2]
) )
self.assertEqual( 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=["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): def __new__(cls, *args, **kwargs):
""" """
When creating a new ANSIString, you may use a custom parser that has 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): def __str__(self):
return self._raw_string 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): def __repr__(self):
""" """
Let's make the repr the command that would actually be used to Let's make the repr the command that would actually be used to

View file

@ -255,11 +255,11 @@ def create_script(
if obj: if obj:
kwarg["db_obj"] = dbid_to_obj(obj, _ObjectDB) kwarg["db_obj"] = dbid_to_obj(obj, _ObjectDB)
if interval: if interval:
kwarg["db_interval"] = interval kwarg["db_interval"] = max(0, interval)
if start_delay: if start_delay:
kwarg["db_start_delay"] = start_delay kwarg["db_start_delay"] = start_delay
if repeats: if repeats:
kwarg["db_repeats"] = repeats kwarg["db_repeats"] = max(0, repeats)
if persistent: if persistent:
kwarg["db_persistent"] = persistent kwarg["db_persistent"] = persistent
if desc: if desc:
@ -486,9 +486,8 @@ def create_account(
Args: Args:
key (str): The account's name. This should be unique. key (str): The account's name. This should be unique.
email (str): Email on valid addr@addr.domain form. This is email (str or None): Email on valid addr@addr.domain form. If
technically required but if set to `None`, an email of the empty string, will be set to None.
`dummy@example.com` will be used as a placeholder.
password (str): Password in cleartext. password (str): Password in cleartext.
Kwargs: Kwargs:
@ -532,7 +531,7 @@ def create_account(
# correctly when each object is recovered). # correctly when each object is recovered).
if not email: if not email:
email = "dummy@example.com" email = None
if _AccountDB.objects.filter(username__iexact=key): if _AccountDB.objects.filter(username__iexact=key):
raise ValueError("An Account with the name '%s' already exists." % 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 pickle import dumps, loads
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.contrib.contenttypes.models import ContentType 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.utils import uses_database, is_iter, to_str, to_bytes
from evennia.utils import logger from evennia.utils import logger
@ -549,7 +549,7 @@ def to_pickle(data):
def process_item(item): def process_item(item):
"""Recursive processor and identification of data""" """Recursive processor and identification of data"""
dtype = type(item) dtype = type(item)
if dtype in (str, int, float, bool, bytes, SafeString, SafeBytes): if dtype in (str, int, float, bool, bytes, SafeString):
return item return item
elif dtype == tuple: elif dtype == tuple:
return tuple(process_item(val) for val in item) return tuple(process_item(val) for val in item)
@ -577,7 +577,7 @@ def to_pickle(data):
except TypeError: except TypeError:
return item return item
except Exception: 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 raise
return process_item(data) return process_item(data)
@ -609,7 +609,7 @@ def from_pickle(data, db_obj=None):
def process_item(item): def process_item(item):
"""Recursive processor and identification of data""" """Recursive processor and identification of data"""
dtype = type(item) dtype = type(item)
if dtype in (str, int, float, bool, bytes, SafeString, SafeBytes): if dtype in (str, int, float, bool, bytes, SafeString):
return item return item
elif _IS_PACKED_DBOBJ(item): elif _IS_PACKED_DBOBJ(item):
# this must be checked before tuple # this must be checked before tuple
@ -638,7 +638,7 @@ def from_pickle(data, db_obj=None):
def process_tree(item, parent): def process_tree(item, parent):
"""Recursive processor, building a parent-tree from iterable data""" """Recursive processor, building a parent-tree from iterable data"""
dtype = type(item) dtype = type(item)
if dtype in (str, int, float, bool, bytes, SafeString, SafeBytes): if dtype in (str, int, float, bool, bytes, SafeString):
return item return item
elif _IS_PACKED_DBOBJ(item): elif _IS_PACKED_DBOBJ(item):
# this must be checked before tuple # this must be checked before tuple
@ -716,7 +716,7 @@ def do_pickle(data):
try: try:
return dumps(data, protocol=PICKLE_PROTOCOL) return dumps(data, protocol=PICKLE_PROTOCOL)
except Exception: 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 raise
@ -725,7 +725,7 @@ def do_unpickle(data):
try: try:
return loads(to_bytes(data)) return loads(to_bytes(data))
except Exception: 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 raise

View file

@ -187,7 +187,7 @@ _CMD_NOINPUT = cmdhandler.CMD_NOINPUT
# Return messages # Return messages
# i18n # i18n
from django.utils.translation import ugettext as _ from django.utils.translation import gettext as _
_ERR_NOT_IMPLEMENTED = _( _ERR_NOT_IMPLEMENTED = _(
"Menu node '{nodename}' is either not implemented or " "caused an error. Make another choice." "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 django.conf import settings
from evennia import Command, CmdSet from evennia import Command, CmdSet
from evennia.commands import cmdhandler 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_NOMATCH = cmdhandler.CMD_NOMATCH
_CMD_NOINPUT = cmdhandler.CMD_NOINPUT _CMD_NOINPUT = cmdhandler.CMD_NOINPUT
@ -39,6 +39,8 @@ _CMD_NOINPUT = cmdhandler.CMD_NOINPUT
_SCREEN_WIDTH = settings.CLIENT_DEFAULT_WIDTH _SCREEN_WIDTH = settings.CLIENT_DEFAULT_WIDTH
_SCREEN_HEIGHT = settings.CLIENT_DEFAULT_HEIGHT _SCREEN_HEIGHT = settings.CLIENT_DEFAULT_HEIGHT
_EVTABLE = None
# text # text
_DISPLAY = """{text} _DISPLAY = """{text}
@ -126,26 +128,38 @@ class EvMore(object):
text, text,
always_page=False, always_page=False,
session=None, session=None,
justify=False,
justify_kwargs=None, justify_kwargs=None,
exit_on_lastpage=False, exit_on_lastpage=False,
exit_cmd=None, exit_cmd=None,
**kwargs, **kwargs,
): ):
""" """
Initialization of the text handler. Initialization of the text handler.
Args: Args:
caller (Object or Account): Entity reading the text. 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 always_page (bool, optional): If `False`, the
pager will only kick in if `text` is too big pager will only kick in if `text` is too big
to fit the screen. to fit the screen.
session (Session, optional): If given, this session will be used session (Session, optional): If given, this session will be used
to determine the screen width and will receive all output. to determine the screen width and will receive all output.
justify_kwargs (dict, bool or None, optional): If given, this should justify (bool, optional): If set, auto-justify long lines. This must be turned
be valid keyword arguments to the utils.justify() function. If False, off for fixed-width or formatted output, like tables. It's force-disabled
no justification will be done (especially important for handling if `text` is an EvTable.
fixed-width text content, like tables!). 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 exit_on_lastpage (bool, optional): If reaching the last page without the
page being completely filled, exit pager immediately. If unset, page being completely filled, exit pager immediately. If unset,
another move forward is required to exit. If set, the pager 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 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 cmdset the user had *before* the evmore pager was activated (so none of
the evmore commands will be available when this is run). the evmore commands will be available when this is run).
kwargs (any, optional): These will be passed on kwargs (any, optional): These will be passed on to the `caller.msg` method.
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._caller = caller
self._kwargs = kwargs self._kwargs = kwargs
self._pages = [] self._pages = []
self._npages = [] self._npages = 1
self._npos = [] self._npos = 0
self.exit_on_lastpage = exit_on_lastpage self.exit_on_lastpage = exit_on_lastpage
self.exit_cmd = exit_cmd self.exit_cmd = exit_cmd
self._exit_msg = "Exited |wmore|n pager." self._exit_msg = "Exited |wmore|n pager."
if not session: if not session:
# if not supplied, use the first session to # if not supplied, use the first session to
# determine screen size # determine screen size
@ -179,16 +207,33 @@ class EvMore(object):
height = max(4, session.protocol_flags.get("SCREENHEIGHT", {0: _SCREEN_HEIGHT})[0] - 4) height = max(4, session.protocol_flags.get("SCREENHEIGHT", {0: _SCREEN_HEIGHT})[0] - 4)
width = session.protocol_flags.get("SCREENWIDTH", {0: _SCREEN_WIDTH})[0] 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: 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._pages = text.split("\f")
self._npages = len(self._pages) self._npages = len(self._pages)
self._npos = 0
else: else:
if justify_kwargs is False: if justify:
# no justification. Simple division by line # we must break very long lines into multiple ones. Note that this
lines = text.split("\n") # will also remove spurious whitespace.
else:
# we must break very long lines into multiple ones
justify_kwargs = justify_kwargs or {} justify_kwargs = justify_kwargs or {}
width = justify_kwargs.get("width", width) width = justify_kwargs.get("width", width)
justify_kwargs["width"] = width justify_kwargs["width"] = width
@ -201,17 +246,20 @@ class EvMore(object):
lines.extend(justify(line, **justify_kwargs).split("\n")) lines.extend(justify(line, **justify_kwargs).split("\n"))
else: else:
lines.append(line) lines.append(line)
else:
# no justification. Simple division by line
lines = text.split("\n")
# always limit number of chars to 10 000 per page # always limit number of chars to 10 000 per page
height = min(10000 // max(1, width), height) 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._pages = ["\n".join(lines[i : i + height]) for i in range(0, len(lines), height)]
self._npages = len(self._pages) self._npages = len(self._pages)
self._npos = 0
if self._npages <= 1 and not always_page: if self._npages <= 1 and not always_page:
# no need for paging; just pass-through. # 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: else:
# go into paging mode # go into paging mode
# first pass on the msg kwargs # first pass on the msg kwargs
@ -221,12 +269,15 @@ class EvMore(object):
# goto top of the text # goto top of the text
self.page_top() self.page_top()
def _get_page(self, pos):
return self._pages[pos]
def display(self, show_footer=True): def display(self, show_footer=True):
""" """
Pretty-print the page. Pretty-print the page.
""" """
pos = self._pos pos = self._npos
text = self._pages[pos] text = self._get_page(pos)
if show_footer: if show_footer:
page = _DISPLAY.format(text=text, pageno=pos + 1, pagemax=self._npages) page = _DISPLAY.format(text=text, pageno=pos + 1, pagemax=self._npages)
else: else:
@ -245,14 +296,14 @@ class EvMore(object):
""" """
Display the top page Display the top page
""" """
self._pos = 0 self._npos = 0
self.display() self.display()
def page_end(self): def page_end(self):
""" """
Display the bottom page. Display the bottom page.
""" """
self._pos = self._npages - 1 self._npos = self._npages - 1
self.display() self.display()
def page_next(self): def page_next(self):
@ -260,12 +311,12 @@ class EvMore(object):
Scroll the text to the next page. Quit if already at the end Scroll the text to the next page. Quit if already at the end
of the page. of the page.
""" """
if self._pos >= self._npages - 1: if self._npos >= self._npages - 1:
# exit if we are already at the end # exit if we are already at the end
self.page_quit() self.page_quit()
else: else:
self._pos += 1 self._npos += 1
if self.exit_on_lastpage and self._pos >= (self._npages - 1): if self.exit_on_lastpage and self._npos >= (self._npages - 1):
self.display(show_footer=False) self.display(show_footer=False)
self.page_quit(quiet=True) self.page_quit(quiet=True)
else: else:
@ -275,7 +326,7 @@ class EvMore(object):
""" """
Scroll the text back up, at the most to the top. 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() self.display()
def page_quit(self, quiet=False): def page_quit(self, quiet=False):
@ -290,30 +341,51 @@ class EvMore(object):
self._caller.execute_cmd(self.exit_cmd, session=self._session) self._caller.execute_cmd(self.exit_cmd, session=self._session)
# helper function
def msg( def msg(
caller, caller,
text="", text="",
always_page=False, always_page=False,
session=None, session=None,
justify=False,
justify_kwargs=None, justify_kwargs=None,
exit_on_lastpage=True, exit_on_lastpage=True,
**kwargs, **kwargs,
): ):
""" """
More-supported version of msg, mimicking the normal msg method. EvMore-supported version of msg, mimicking the normal msg method.
Args: Args:
caller (Object or Account): Entity reading the text. 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 always_page (bool, optional): If `False`, the
pager will only kick in if `text` is too big pager will only kick in if `text` is too big
to fit the screen. to fit the screen.
session (Session, optional): If given, this session will be used session (Session, optional): If given, this session will be used
to determine the screen width and will receive all output. 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 justify_kwargs (dict, bool or None, optional): If given, this should
be valid keyword arguments to the utils.justify() function. If False, be valid keyword arguments to the utils.justify() function. If False,
no justification will be done. no justification will be done.
exit_on_lastpage (bool, optional): Immediately exit pager when reaching the last page. 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 kwargs (any, optional): These will be passed on
to the `caller.msg` method. to the `caller.msg` method.
@ -323,6 +395,7 @@ def msg(
text, text,
always_page=always_page, always_page=always_page,
session=session, session=session,
justify=justify,
justify_kwargs=justify_kwargs, justify_kwargs=justify_kwargs,
exit_on_lastpage=exit_on_lastpage, exit_on_lastpage=exit_on_lastpage,
**kwargs, **kwargs,

View file

@ -1107,8 +1107,9 @@ class EvTable(object):
Exception: If given erroneous input or width settings for the data. Exception: If given erroneous input or width settings for the data.
Notes: Notes:
Beyond those table-specific keywords, the non-overlapping keywords of `EcCell.__init__` are Beyond those table-specific keywords, the non-overlapping keywords
also available. These will be passed down to every cell in the table. 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 # 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 os
import time import time
import glob
from datetime import datetime from datetime import datetime
from traceback import format_exc from traceback import format_exc
from twisted.python import log, logfile from twisted.python import log, logfile
@ -76,33 +77,79 @@ def timeformat(when=None):
class WeeklyLogFile(logfile.DailyLogFile): 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): def shouldRotate(self):
"""Rotate when the date has changed since last write""" """Rotate when the date has changed since last write"""
# all dates here are tuples (year, month, day) # all dates here are tuples (year, month, day)
now = self.toDate() now = self.toDate()
then = self.lastDate 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): def suffix(self, tupledate):
"""Return the suffix given a (year, month, day) tuple or unixtime. """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: suffix = ""
return "_".join(["{:02d}".format(part) for part in tupledate]) copy_suffix = 0
except Exception: while True:
# try taking a float unixtime try:
return "_".join(["{:02d}".format(part) for part in self.toDate(tupledate)]) 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): def write(self, data):
"Write data to log file" "Write data to log file"
logfile.BaseLogFile.write(self, data) logfile.BaseLogFile.write(self, data)
self.lastDate = max(self.lastDate, self.toDate()) self.lastDate = max(self.lastDate, self.toDate())
self.size += len(data)
class PortalLogObserver(log.FileLogObserver): class PortalLogObserver(log.FileLogObserver):

View file

@ -24,8 +24,7 @@ class InMemorySaveHandler(object):
class OptionHandler(object): class OptionHandler(object):
""" """
This is a generic Option handler. It is commonly used This is a generic Option handler. Retrieve options either as properties on
implements AttributeHandler. Retrieve options eithers as properties on
this handler or by using the .get method. this handler or by using the .get method.
This is used for Account.options but it could be used by Scripts or Objects 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 It will be called as `savefunc(key, value, **save_kwargs)`. A common one
to pass would be AttributeHandler.add. to pass would be AttributeHandler.add.
loadfunc (callable): A callable for all options to call when loading data into 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. A common one to pass would be AttributeHandler.get.
save_kwargs (any): Optional extra kwargs to pass into `savefunc` above. save_kwargs (any): Optional extra kwargs to pass into `savefunc` above.
load_kwargs (any): Optional extra kwargs to pass into `loadfunc` above. load_kwargs (any): Optional extra kwargs to pass into `loadfunc` above.
@ -116,14 +115,16 @@ class OptionHandler(object):
self.options[key] = loaded_option self.options[key] = loaded_option
return 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. Retrieves an Option stored in the handler. Will load it if it doesn't exist.
Args: Args:
key (str): The option key to retrieve. 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 return_obj (bool, optional): If True, returns the actual option
object instead of its value. object instead of its value.
raise_error (bool, optional): Raise Exception if key is not found in options.
Returns: Returns:
option_value (any or Option): An option value the Option itself. option_value (any or Option): An option value the Option itself.
Raises: Raises:
@ -131,7 +132,10 @@ class OptionHandler(object):
""" """
if key not in self.options_dict: 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) op_found = self.options.get(key) or self._load_option(key)
return op_found if return_obj else op_found.value 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 django.forms.widgets import Textarea
from pickle import loads, dumps from pickle import loads, dumps
from django.utils.encoding import force_text from django.utils.encoding import force_str
DEFAULT_PROTOCOL = 4 DEFAULT_PROTOCOL = 4
@ -133,8 +133,9 @@ class PickledWidget(Textarea):
try: try:
# necessary to convert it back after repr(), otherwise validation errors will mutate it # necessary to convert it back after repr(), otherwise validation errors will mutate it
value = literal_eval(repr_value) value = literal_eval(repr_value)
except ValueError: except (ValueError, SyntaxError):
pass # we could not eval it, just show its prepresentation
value = repr_value
return super().render(name, value, attrs=attrs, renderer=renderer) return super().render(name, value, attrs=attrs, renderer=renderer)
def value_from_datadict(self, data, files, name): def value_from_datadict(self, data, files, name):
@ -209,10 +210,10 @@ class PickledObjectField(models.Field):
""" """
Returns the default value for this 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 on the default, which means you can't set arbitrary Python
objects as the default. To fix this, we just return the value 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 callable as a default, the field will still call it. It will
*not* try to pickle and encode it. *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): if value is not None and not isinstance(value, PickledObject):
# We call force_text here explicitly, so that the encoded string # We call force_str here explicitly, so that the encoded string
# isn't rejected by the postgresql_psycopg2 backend. Alternatively, # isn't rejected by the postgresql backend. Alternatively,
# we could have just registered PickledObject with the psycopg # we could have just registered PickledObject with the psycopg
# marshaller (telling it to store it like it would a string), but # marshaller (telling it to store it like it would a string), but
# since both of these methods result in the same value being stored, # since both of these methods result in the same value being stored,
# doing things this way is much easier. # 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 return value
def value_to_string(self, obj): def value_to_string(self, obj):

View file

@ -95,4 +95,4 @@ class TestGametime(TestCase):
self.timescripts.append(script) self.timescripts.append(script)
self.assertIsInstance(script, gametime.TimeScript) self.assertIsInstance(script, gametime.TimeScript)
self.assertAlmostEqual(script.interval, 12) 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) 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): class TestTimeformat(TestCase):
""" """
Default function header from utils.py: Default function header from utils.py:

View file

@ -32,6 +32,11 @@ class TestValidatorFuncs(TestCase):
self.assertTrue( self.assertTrue(
isinstance(validatorfuncs.datetime(dt, from_tz=pytz.UTC), datetime.datetime) 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): def test_datetime_raises_ValueError(self):
for dt in ["", "January 1, 2019", "1/1/2019", "Jan 1 2019"]: for dt in ["", "January 1, 2019", "1/1/2019", "Jan 1 2019"]:
@ -121,6 +126,7 @@ class TestValidatorFuncs(TestCase):
validatorfuncs.boolean(b) validatorfuncs.boolean(b)
def test_timezone_ok(self): def test_timezone_ok(self):
for tz in ["America/Chicago", "GMT", "UTC"]: for tz in ["America/Chicago", "GMT", "UTC"]:
self.assertEqual(tz, validatorfuncs.timezone(tz).zone) 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 twisted.internet import threads, reactor
from django.conf import settings from django.conf import settings
from django.utils import timezone 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 django.apps import apps
from evennia.utils import logger from evennia.utils import logger
@ -1036,7 +1036,7 @@ def uses_database(name="sqlite3"):
shortcut to having to use the full backend name. shortcut to having to use the full backend name.
Args: Args:
name (str): One of 'sqlite3', 'mysql', 'postgresql_psycopg2' name (str): One of 'sqlite3', 'mysql', 'postgresql'
or 'oracle'. or 'oracle'.
Returns: 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): 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: Args:
entry (str): A date string from a user. entry (str): A date string from a user.
option_key (str): Name to display this datetime as. option_key (str): Name to display this datetime as.
account (AccountDB): The Account performing this lookup. Unless from_tz is provided, 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 the account's timezone option will be used.
to UTC. from_tz (pytz.timezone): An instance of a pytz timezone object from the
from_tz (pytz): An instance of pytz from the user. If not provided, defaults to whatever user. If not provided, tries to use the timezone option of the `account'.
the Account uses. If neither one is provided, defaults to UTC. If neither one is provided, defaults to UTC.
Returns: Returns:
datetime in utc. datetime in UTC.
Raises:
ValueError: If encountering a malformed timezone, date string or other format error.
""" """
if not entry: if not entry:
raise ValueError(f"No {option_key} entered!") raise ValueError(f"No {option_key} entered!")
if not from_tz: if not from_tz:
from_tz = _pytz.UTC 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 utc = _pytz.UTC
now = _dt.datetime.utcnow().replace(tzinfo=utc) now = _dt.datetime.utcnow().replace(tzinfo=utc)
cur_year = now.strftime("%Y") cur_year = now.strftime("%Y")

View file

@ -6,7 +6,7 @@
# http://diveintopython.org/regular_expressions/street_addresses.html#re.matching.2.3 # 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 from django.views.generic import RedirectView
# Setup the root url tree from / # Setup the root url tree from /
@ -14,9 +14,9 @@ from django.views.generic import RedirectView
urlpatterns = [ urlpatterns = [
# Front page (note that we shouldn't specify namespace here since we will # 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) # 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 # webclient
url(r"^webclient/", include("evennia.web.webclient.urls", namespace="webclient")), path("webclient/", include("evennia.web.webclient.urls")),
# favicon # 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) login(request, account)
except AttributeError: except AttributeError:
logger.log_trace() 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%; height: 100%;
} }
.card {
background-color: #333;
}
/* Container surrounding entire client */ /* Container surrounding entire client */
#clientwrapper { #clientwrapper {
height: 100%; 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 // 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, onKeydown: onKeydown,
onText: onText, onText: onText,
getGL: function () { return myLayout; }, getGL: function () { return myLayout; },
addKnownType: function (newtype) { knownTypes.push(newtype); }, addKnownType: addKnownType,
} }
}()); }());
window.plugin_handler.add("goldenlayout", goldenlayout); 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