mirror of
https://github.com/evennia/evennia.git
synced 2026-04-03 22:47:16 +02:00
commit
8ce6abaa6f
107 changed files with 2222 additions and 1218 deletions
|
|
@ -8,6 +8,7 @@ services:
|
|||
|
||||
python:
|
||||
- "3.7"
|
||||
- "3.8"
|
||||
|
||||
env:
|
||||
- TESTING_DB=sqlite3
|
||||
|
|
|
|||
|
|
@ -39,25 +39,25 @@ SERVERNAME = "testing_mygame"
|
|||
# Testing database types
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.mysql',
|
||||
'NAME': 'evennia',
|
||||
'USER': 'evennia',
|
||||
'PASSWORD': 'password',
|
||||
'HOST': 'localhost',
|
||||
'PORT': '', # use default port
|
||||
'OPTIONS': {
|
||||
'charset': 'utf8mb4',
|
||||
'init_command': 'set collation_connection=utf8mb4_unicode_ci'
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.mysql",
|
||||
"NAME": "evennia",
|
||||
"USER": "evennia",
|
||||
"PASSWORD": "password",
|
||||
"HOST": "localhost",
|
||||
"PORT": "", # use default port
|
||||
"OPTIONS": {
|
||||
"charset": "utf8mb4",
|
||||
"init_command": "set collation_connection=utf8mb4_unicode_ci",
|
||||
},
|
||||
'TEST': {
|
||||
'NAME': 'default',
|
||||
'OPTIONS': {
|
||||
'charset': 'utf8mb4',
|
||||
"TEST": {
|
||||
"NAME": "default",
|
||||
"OPTIONS": {
|
||||
"charset": "utf8mb4",
|
||||
# 'init_command': 'set collation_connection=utf8mb4_unicode_ci'
|
||||
'init_command': "SET NAMES 'utf8mb4'"
|
||||
}
|
||||
}
|
||||
"init_command": "SET NAMES 'utf8mb4'",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,16 +39,14 @@ SERVERNAME = "testing_mygame"
|
|||
# Testing database types
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql_psycopg2',
|
||||
'NAME': 'evennia',
|
||||
'USER': 'evennia',
|
||||
'PASSWORD': 'password',
|
||||
'HOST': 'localhost',
|
||||
'PORT': '', # use default
|
||||
'TEST': {
|
||||
'NAME': 'default'
|
||||
}
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": "evennia",
|
||||
"USER": "evennia",
|
||||
"PASSWORD": "password",
|
||||
"HOST": "localhost",
|
||||
"PORT": "", # use default
|
||||
"TEST": {"NAME": "default"},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
28
CHANGELOG.md
28
CHANGELOG.md
|
|
@ -6,7 +6,7 @@
|
|||
defaults to True for backwards-compatibility in 0.9, will be False in 1.0
|
||||
|
||||
### Already in master
|
||||
|
||||
- `is_typeclass(obj (Object), exact (bool))` now defaults to exact=False
|
||||
- `py` command now reroutes stdout to output results in-game client. `py`
|
||||
without arguments starts a full interactive Python console.
|
||||
- Webclient default to a single input pane instead of two. Now defaults to no help-popup.
|
||||
|
|
@ -27,13 +27,33 @@ without arguments starts a full interactive Python console.
|
|||
- `AttributeHandler.get(return_list=True)` will return `[]` if there are no
|
||||
Attributes instead of `[None]`.
|
||||
- Remove `pillow` requirement (install especially if using imagefield)
|
||||
- Add Simplified Korean translation (user aceamro)
|
||||
- Add Simplified Korean translation (aceamro)
|
||||
- Show warning on `start -l` if settings contains values unsafe for production.
|
||||
- Make code auto-formatted with Black.
|
||||
- Make default `set` command able to edit nested structures (PR by Aaron McMillan)
|
||||
- Allow running Evennia test suite from core repo with `make test`.
|
||||
- Return `store_key` from `TickerHandler.add` and add `store_key` as a kwarg to
|
||||
the `TickerHandler.remove` method. This makes it easier to manage tickers.
|
||||
- Return `store_key` from `TickerHandler.add` and add `store_key` as a kwarg to
|
||||
the `TickerHandler.remove` method. This makes it easier to manage tickers.
|
||||
- EvMore `text` argument can now also be a list - each entry in the list is run
|
||||
through str(eval()) and ends up on its own line. Good for paginated object lists.
|
||||
- EvMore auto-justify now defaults to False since this works better with all types
|
||||
of texts (such as tables). New `justify` bool. Old `justify_kwargs` remains
|
||||
but is now only used to pass extra kwargs into the justify function.
|
||||
- Improve performance of `find` and `objects` commands on large data sets (strikaco)
|
||||
- New `CHANNEL_HANDLER_CLASS` setting allows for replacing the ChannelHandler entirely.
|
||||
- Made `py` interactive mode support regular quit() and more verbose.
|
||||
- Made `Account.options.get` accept `default=None` kwarg to mimic other uses of get. Set
|
||||
the new `raise_exception` boolean if ranting to raise KeyError on a missing key.
|
||||
- Moved behavior of unmodified `Command` and `MuxCommand` `.func()` to new
|
||||
`.get_command_info()` method for easier overloading and access. (Volund)
|
||||
- Removed unused `CYCLE_LOGFILES` setting. Added `SERVER_LOG_DAY_ROTATION`
|
||||
and `SERVER_LOG_MAX_SIZE` (and equivalent for PORTAL) to control log rotation.
|
||||
- Addded `inside_rec` lockfunc - if room is locked, the normal `inside()` lockfunc will
|
||||
fail e.g. for your inventory objs (since their loc is you), whereas this will pass.
|
||||
- RPSystem contrib's CmdRecog will now list all recogs if no arg is given. Also multiple
|
||||
bugfixes.
|
||||
- Remove `dummy@example.com` as a default account email when unset, a string is no longer
|
||||
required by Django.
|
||||
|
||||
|
||||
## Evennia 0.9 (2018-2019)
|
||||
|
|
|
|||
136
INSTALL.md
136
INSTALL.md
|
|
@ -1,137 +1,5 @@
|
|||
|
||||
# Evennia installation
|
||||
|
||||
The latest and more detailed installation instructions can be found
|
||||
[here](https://github.com/evennia/evennia/wiki/Getting-Started).
|
||||
|
||||
## Installing Python
|
||||
|
||||
First install [Python](https://www.python.org/). Linux users should
|
||||
have it in their repositories, Windows/Mac users can get it from the
|
||||
Python homepage. You need the 2.7.x version (Python 3 is not yet
|
||||
supported). Windows users, make sure to select the option to make
|
||||
Python available in your path - this is so you can call it everywhere
|
||||
as `python`. Python 2.7.9 and later also includes the
|
||||
[pip](https://pypi.python.org/pypi/pip/) installer out of the box,
|
||||
otherwise install this separately (in linux it's usually found as the
|
||||
`python-pip` package).
|
||||
|
||||
### installing virtualenv
|
||||
|
||||
This step is optional, but *highly* recommended. For installing
|
||||
up-to-date Python packages we recommend using
|
||||
[virtualenv](https://pypi.python.org/pypi/virtualenv), this makes it
|
||||
easy to keep your Python packages up-to-date without interfering with
|
||||
the defaults for your system.
|
||||
|
||||
```
|
||||
pip install virtualenv
|
||||
```
|
||||
|
||||
Go to the place where you want to make your virtual python library
|
||||
storage. This does not need to be near where you plan to install
|
||||
Evennia. Then do
|
||||
|
||||
```
|
||||
virtualenv vienv
|
||||
```
|
||||
|
||||
A new folder `vienv` will be created (you could also name it something
|
||||
else if you prefer). Activate the virtual environment like this:
|
||||
|
||||
```
|
||||
# for Linux/Unix/Mac:
|
||||
source vienv/bin/activate
|
||||
# for Windows:
|
||||
vienv\Scripts\activate.bat
|
||||
```
|
||||
|
||||
You should see `(vienv)` next to your prompt to show you the
|
||||
environment is active. You need to activate it whenever you open a new
|
||||
terminal, but you *don't* have to be inside the `vienv` folder henceforth.
|
||||
|
||||
|
||||
## Get the developer's version of Evennia
|
||||
|
||||
This is currently the only Evennia version available. First download
|
||||
and install [Git](http://git-scm.com/) from the homepage or via the
|
||||
package manager in Linux. Next, go to the place where you want the
|
||||
`evennia` folder to be created and run
|
||||
|
||||
```
|
||||
git clone https://github.com/evennia/evennia.git
|
||||
```
|
||||
|
||||
If you have a github account and have [set up SSH
|
||||
keys](https://help.github.com/articles/generating-ssh-keys/), you want
|
||||
to use this instead:
|
||||
|
||||
```
|
||||
git clone git@github.com:evennia/evennia.git
|
||||
```
|
||||
|
||||
In the future you just enter the new `evennia` folder and do
|
||||
|
||||
```
|
||||
git pull
|
||||
```
|
||||
|
||||
to get the latest Evennia updates.
|
||||
|
||||
## Evennia package install
|
||||
|
||||
Stand at the root of your new `evennia` directory and run
|
||||
|
||||
```
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
(note the period "." at the end, this tells pip to install from the
|
||||
current directory). This will install Evennia and all its dependencies
|
||||
(into your virtualenv if you are using that) and make the `evennia`
|
||||
command available on the command line. You can find Evennia's
|
||||
dependencies in `evennia/requirements.txt`.
|
||||
|
||||
## Creating your game project
|
||||
|
||||
To create your new game you need to initialize a new game project.
|
||||
This should be done somewhere *outside* of your `evennia` folder.
|
||||
|
||||
|
||||
```
|
||||
evennia --init mygame
|
||||
```
|
||||
|
||||
This will create a new game project named "mygame" in a folder of the
|
||||
same name. If you want to change the settings for your project, you
|
||||
will need to edit `mygame/server/conf/settings.py`.
|
||||
|
||||
|
||||
## Starting Evennia
|
||||
|
||||
Enter your new game directory and run
|
||||
|
||||
```
|
||||
evennia migrate
|
||||
evennia start
|
||||
```
|
||||
|
||||
Follow the instructions to create your superuser account. A lot of
|
||||
information will scroll past as the database is created and the server
|
||||
initializes. After this Evennia will be running. Use
|
||||
|
||||
```
|
||||
evennia -h
|
||||
```
|
||||
|
||||
for help with starting, stopping and other operations.
|
||||
|
||||
Start up your MUD client of choice and point it to your server and
|
||||
port *4000*. If you are just running locally the server name is
|
||||
*localhost*.
|
||||
|
||||
Alternatively, you can find the web interface and webclient by
|
||||
pointing your web browser to *http://localhost:4001*.
|
||||
|
||||
Finally, login with the superuser account and password you provided
|
||||
earlier. Welcome to Evennia!
|
||||
You can find the latest updated installation instructions and
|
||||
requirements [here](https://github.com/evennia/evennia/wiki/Getting-Started).
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ USE_COLOR = True
|
|||
FAKE_MODE = False
|
||||
|
||||
# if these words are longer than output word, retain given case
|
||||
CASE_WORD_EXCEPTIONS = ('an', )
|
||||
CASE_WORD_EXCEPTIONS = ("an",)
|
||||
|
||||
_HELP_TEXT = """This program interactively renames words in all files of your project. It's
|
||||
currently renaming {sources} to {targets}.
|
||||
|
|
@ -80,6 +80,7 @@ def _case_sensitive_replace(string, old, new):
|
|||
`old` has been replaced with `new`, retaining case.
|
||||
|
||||
"""
|
||||
|
||||
def repl(match):
|
||||
current = match.group()
|
||||
# treat multi-word sentences word-by-word
|
||||
|
|
@ -99,15 +100,21 @@ def _case_sensitive_replace(string, old, new):
|
|||
all_upper = False
|
||||
# special cases - keep remaing case)
|
||||
if new_word.lower() in CASE_WORD_EXCEPTIONS:
|
||||
result.append(new_word[ind + 1:])
|
||||
result.append(new_word[ind + 1 :])
|
||||
# append any remaining characters from new
|
||||
elif all_upper:
|
||||
result.append(new_word[ind + 1:].upper())
|
||||
result.append(new_word[ind + 1 :].upper())
|
||||
else:
|
||||
result.append(new_word[ind + 1:].lower())
|
||||
result.append(new_word[ind + 1 :].lower())
|
||||
out.append("".join(result))
|
||||
# if we have more new words than old ones, just add them verbatim
|
||||
out.extend([new_word for ind, new_word in enumerate(new_words) if ind >= len(old_words)])
|
||||
out.extend(
|
||||
[
|
||||
new_word
|
||||
for ind, new_word in enumerate(new_words)
|
||||
if ind >= len(old_words)
|
||||
]
|
||||
)
|
||||
return " ".join(out)
|
||||
|
||||
regex = re.compile(re.escape(old), re.I)
|
||||
|
|
@ -147,7 +154,9 @@ def rename_in_tree(path, in_list, out_list, excl_list, fileend_list, is_interact
|
|||
print("%s skipped (excluded)." % full_path)
|
||||
continue
|
||||
|
||||
if not fileend_list or any(file.endswith(ending) for ending in fileend_list):
|
||||
if not fileend_list or any(
|
||||
file.endswith(ending) for ending in fileend_list
|
||||
):
|
||||
rename_in_file(full_path, in_list, out_list, is_interactive)
|
||||
|
||||
# rename file - always ask
|
||||
|
|
@ -155,8 +164,10 @@ def rename_in_tree(path, in_list, out_list, excl_list, fileend_list, is_interact
|
|||
for src, dst in repl_mapping:
|
||||
new_file = _case_sensitive_replace(new_file, src, dst)
|
||||
if new_file != file:
|
||||
inp = input(_green("Rename %s\n -> %s\n Y/[N]? > " % (file, new_file)))
|
||||
if inp.upper() == 'Y':
|
||||
inp = input(
|
||||
_green("Rename %s\n -> %s\n Y/[N]? > " % (file, new_file))
|
||||
)
|
||||
if inp.upper() == "Y":
|
||||
new_full_path = os.path.join(root, new_file)
|
||||
try:
|
||||
os.rename(full_path, new_full_path)
|
||||
|
|
@ -171,8 +182,10 @@ def rename_in_tree(path, in_list, out_list, excl_list, fileend_list, is_interact
|
|||
for src, dst in repl_mapping:
|
||||
new_root = _case_sensitive_replace(new_root, src, dst)
|
||||
if new_root != root:
|
||||
inp = input(_green("Dir Rename %s\n -> %s\n Y/[N]? > " % (root, new_root)))
|
||||
if inp.upper() == 'Y':
|
||||
inp = input(
|
||||
_green("Dir Rename %s\n -> %s\n Y/[N]? > " % (root, new_root))
|
||||
)
|
||||
if inp.upper() == "Y":
|
||||
try:
|
||||
os.rename(root, new_root)
|
||||
except OSError as err:
|
||||
|
|
@ -201,7 +214,7 @@ def rename_in_file(path, in_list, out_list, is_interactive):
|
|||
print("%s is a directory. You should use the --recursive option." % path)
|
||||
sys.exit()
|
||||
|
||||
with open(path, 'r') as fil:
|
||||
with open(path, "r") as fil:
|
||||
org_text = fil.read()
|
||||
|
||||
repl_mapping = list(zip(in_list, out_list))
|
||||
|
|
@ -215,7 +228,7 @@ def rename_in_file(path, in_list, out_list, is_interactive):
|
|||
if FAKE_MODE:
|
||||
print(" ... Saved changes to %s. (faked)" % path)
|
||||
else:
|
||||
with open(path, 'w') as fil:
|
||||
with open(path, "w") as fil:
|
||||
fil.write(new_text)
|
||||
print(" ... Saved changes to %s." % path)
|
||||
else:
|
||||
|
|
@ -239,18 +252,24 @@ def rename_in_file(path, in_list, out_list, is_interactive):
|
|||
|
||||
while True:
|
||||
|
||||
for iline, renamed_line in sorted(list(renamed.items()), key=lambda tup: tup[0]):
|
||||
for iline, renamed_line in sorted(
|
||||
list(renamed.items()), key=lambda tup: tup[0]
|
||||
):
|
||||
print("%3i orig: %s" % (iline + 1, org_lines[iline]))
|
||||
print(" new : %s" % (_yellow(renamed_line)))
|
||||
print(_green("%s (%i lines changed)" % (path, len(renamed))))
|
||||
|
||||
ret = input(_green("Choose: "
|
||||
"[q]uit, "
|
||||
"[h]elp, "
|
||||
"[s]kip file, "
|
||||
"[i]gnore lines, "
|
||||
"[c]lear ignores, "
|
||||
"[a]ccept/save file: ".lower()))
|
||||
ret = input(
|
||||
_green(
|
||||
"Choose: "
|
||||
"[q]uit, "
|
||||
"[h]elp, "
|
||||
"[s]kip file, "
|
||||
"[i]gnore lines, "
|
||||
"[c]lear ignores, "
|
||||
"[a]ccept/save file: ".lower()
|
||||
)
|
||||
)
|
||||
|
||||
if ret == "s":
|
||||
# skip file entirely
|
||||
|
|
@ -267,7 +286,7 @@ def rename_in_file(path, in_list, out_list, is_interactive):
|
|||
if FAKE_MODE:
|
||||
print(" ... Saved file %s (faked)" % path)
|
||||
return
|
||||
with open(path, 'w') as fil:
|
||||
with open(path, "w") as fil:
|
||||
fil.writelines("\n".join(org_lines))
|
||||
print(" ... Saved file %s" % path)
|
||||
return
|
||||
|
|
@ -278,7 +297,11 @@ def rename_in_file(path, in_list, out_list, is_interactive):
|
|||
input(_HELP_TEXT.format(sources=in_list, targets=out_list))
|
||||
elif ret.startswith("i"):
|
||||
# ignore one or more lines
|
||||
ignores = [int(ind) - 1 for ind in ret[1:].split(',') if ind.strip().isdigit()]
|
||||
ignores = [
|
||||
int(ind) - 1
|
||||
for ind in ret[1:].split(",")
|
||||
if ind.strip().isdigit()
|
||||
]
|
||||
if not ignores:
|
||||
input("Ignore example: i 2,7,34,133\n (return to continue)")
|
||||
continue
|
||||
|
|
@ -291,36 +314,57 @@ if __name__ == "__main__":
|
|||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Rename text in a source tree, or a single file")
|
||||
description="Rename text in a source tree, or a single file"
|
||||
)
|
||||
|
||||
parser.add_argument('-i', '--input', action='append',
|
||||
help="Source word to rename (quote around multiple words)")
|
||||
parser.add_argument('-o', '--output', action='append',
|
||||
help="Word to rename a matching src-word to")
|
||||
parser.add_argument('-x', '--exc', action='append',
|
||||
help="File path patterns to exclude")
|
||||
parser.add_argument('-a', '--auto', action='store_true',
|
||||
help="Automatic mode, don't ask to rename")
|
||||
parser.add_argument('-r', '--recursive', action='store_true',
|
||||
help="Recurse subdirs")
|
||||
parser.add_argument('-f', '--fileending', action='append',
|
||||
help="Change which file endings to allow (default .py and .html)")
|
||||
parser.add_argument('--nocolor', action='store_true',
|
||||
help="Turn off in-program color")
|
||||
parser.add_argument('--fake', action='store_true',
|
||||
help="Simulate run but don't actually save")
|
||||
parser.add_argument('path',
|
||||
help="File or directory in which to rename text")
|
||||
parser.add_argument(
|
||||
"-i",
|
||||
"--input",
|
||||
action="append",
|
||||
help="Source word to rename (quote around multiple words)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o", "--output", action="append", help="Word to rename a matching src-word to"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-x", "--exc", action="append", help="File path patterns to exclude"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-a", "--auto", action="store_true", help="Automatic mode, don't ask to rename"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-r", "--recursive", action="store_true", help="Recurse subdirs"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-f",
|
||||
"--fileending",
|
||||
action="append",
|
||||
help="Change which file endings to allow (default .py and .html)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--nocolor", action="store_true", help="Turn off in-program color"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fake", action="store_true", help="Simulate run but don't actually save"
|
||||
)
|
||||
parser.add_argument("path", help="File or directory in which to rename text")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
in_list, out_list, exc_list, fileend_list = args.input, args.output, args.exc, args.fileending
|
||||
in_list, out_list, exc_list, fileend_list = (
|
||||
args.input,
|
||||
args.output,
|
||||
args.exc,
|
||||
args.fileending,
|
||||
)
|
||||
|
||||
if not (in_list and out_list):
|
||||
print('At least one source- and destination word must be given.')
|
||||
print("At least one source- and destination word must be given.")
|
||||
sys.exit()
|
||||
if len(in_list) != len(out_list):
|
||||
print('Number of sources must be identical to the number of destination arguments.')
|
||||
print(
|
||||
"Number of sources must be identical to the number of destination arguments."
|
||||
)
|
||||
sys.exit()
|
||||
|
||||
exc_list = exc_list or []
|
||||
|
|
@ -332,6 +376,8 @@ if __name__ == "__main__":
|
|||
FAKE_MODE = args.fake
|
||||
|
||||
if is_recursive:
|
||||
rename_in_tree(args.path, in_list, out_list, exc_list, fileend_list, is_interactive)
|
||||
rename_in_tree(
|
||||
args.path, in_list, out_list, exc_list, fileend_list, is_interactive
|
||||
)
|
||||
else:
|
||||
rename_in_file(args.path, in_list, out_list, is_interactive)
|
||||
|
|
|
|||
|
|
@ -14,4 +14,5 @@ sys.path.insert(0, os.path.abspath(os.getcwd()))
|
|||
sys.path.insert(0, os.path.join(sys.prefix, "Lib", "site-packages"))
|
||||
|
||||
from evennia.server.evennia_launcher import main
|
||||
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -231,6 +231,7 @@ def _init():
|
|||
from . import contrib
|
||||
from .utils.evmenu import EvMenu
|
||||
from .utils.evtable import EvTable
|
||||
from .utils.evmore import EvMore
|
||||
from .utils.evform import EvForm
|
||||
from .utils.eveditor import EvEditor
|
||||
from .utils.ansi import ANSIString
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ from evennia.scripts.scripthandler import ScriptHandler
|
|||
from evennia.commands.cmdsethandler import CmdSetHandler
|
||||
from evennia.utils.optionhandler import OptionHandler
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from random import getrandbits
|
||||
|
||||
__all__ = ("DefaultAccount",)
|
||||
|
|
@ -51,8 +51,12 @@ _CMDSET_ACCOUNT = settings.CMDSET_ACCOUNT
|
|||
_MUDINFO_CHANNEL = None
|
||||
|
||||
# Create throttles for too many account-creations and login attempts
|
||||
CREATION_THROTTLE = Throttle(limit=2, timeout=10 * 60)
|
||||
LOGIN_THROTTLE = Throttle(limit=5, timeout=5 * 60)
|
||||
CREATION_THROTTLE = Throttle(
|
||||
limit=settings.CREATION_THROTTLE_LIMIT, timeout=settings.CREATION_THROTTLE_TIMEOUT
|
||||
)
|
||||
LOGIN_THROTTLE = Throttle(
|
||||
limit=settings.LOGIN_THROTTLE_LIMIT, timeout=settings.LOGIN_THROTTLE_TIMEOUT
|
||||
)
|
||||
|
||||
|
||||
class AccountSessionHandler(object):
|
||||
|
|
@ -216,12 +220,16 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
|
|||
@property
|
||||
def characters(self):
|
||||
# Get playable characters list
|
||||
objs = self.db._playable_characters
|
||||
objs = self.db._playable_characters or []
|
||||
|
||||
# Rebuild the list if legacy code left null values after deletion
|
||||
if None in objs:
|
||||
objs = [x for x in self.db._playable_characters if x]
|
||||
self.db._playable_characters = objs
|
||||
try:
|
||||
if None in objs:
|
||||
objs = [x for x in self.db._playable_characters if x]
|
||||
self.db._playable_characters = objs
|
||||
except Exception as e:
|
||||
logger.log_trace(e)
|
||||
logger.log_err(e)
|
||||
|
||||
return objs
|
||||
|
||||
|
|
@ -820,7 +828,10 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
|
|||
server.
|
||||
|
||||
Args:
|
||||
text (str, optional): text data to send
|
||||
text (str or tuple, optional): The message to send. This
|
||||
is treated internally like any send-command, so its
|
||||
value can be a tuple if sending multiple arguments to
|
||||
the `text` oob command.
|
||||
from_obj (Object or Account or list, optional): Object sending. If given, its
|
||||
at_msg_send() hook will be called. If iterable, call on all entities.
|
||||
session (Session or list, optional): Session object or a list of
|
||||
|
|
@ -851,7 +862,13 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase):
|
|||
kwargs["options"] = options
|
||||
|
||||
if text is not None:
|
||||
kwargs["text"] = to_str(text)
|
||||
if not (isinstance(text, str) or isinstance(text, tuple)):
|
||||
# sanitize text before sending across the wire
|
||||
try:
|
||||
text = to_str(text)
|
||||
except Exception:
|
||||
text = repr(text)
|
||||
kwargs["text"] = text
|
||||
|
||||
# session relay
|
||||
sessions = make_iter(session) if session else self.sessions.all()
|
||||
|
|
|
|||
|
|
@ -105,9 +105,9 @@ class AccountDB(TypedObject, AbstractUser):
|
|||
objects = AccountDBManager()
|
||||
|
||||
# defaults
|
||||
__settingsclasspath__ = settings.BASE_SCRIPT_TYPECLASS
|
||||
__defaultclasspath__ = "evennia.accounts.accounts.DefaultAccount"
|
||||
__applabel__ = "accounts"
|
||||
__settingsclasspath__ = settings.BASE_SCRIPT_TYPECLASS
|
||||
|
||||
class Meta(object):
|
||||
verbose_name = "Account"
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ from evennia.comms.channelhandler import CHANNELHANDLER
|
|||
from evennia.utils import logger, utils
|
||||
from evennia.utils.utils import string_suggestions
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
_IN_GAME_ERRORS = settings.IN_GAME_ERRORS
|
||||
|
||||
|
|
@ -733,7 +733,7 @@ def cmdhandler(
|
|||
if len(matches) == 1:
|
||||
# We have a unique command match. But it may still be invalid.
|
||||
match = matches[0]
|
||||
cmdname, args, cmd, raw_cmdname = match[0], match[1], match[2], match[5]
|
||||
cmdname, args, cmd, raw_cmdname = (match[0], match[1], match[2], match[5])
|
||||
|
||||
if not matches:
|
||||
# No commands match our entered command
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ def try_num_prefixes(raw_string):
|
|||
# the user might be trying to identify the command
|
||||
# with a #num-command style syntax. We expect the regex to
|
||||
# contain the groups "number" and "name".
|
||||
mindex, new_raw_string = num_ref_match.group("number"), num_ref_match.group("name")
|
||||
mindex, new_raw_string = (num_ref_match.group("number"), num_ref_match.group("name"))
|
||||
return mindex, new_raw_string
|
||||
else:
|
||||
return None, None
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ Set theory.
|
|||
|
||||
"""
|
||||
from weakref import WeakKeyDictionary
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from evennia.utils.utils import inherits_from, is_iter
|
||||
|
||||
__all__ = ("CmdSet",)
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ from evennia.utils import logger, utils
|
|||
from evennia.commands.cmdset import CmdSet
|
||||
from evennia.server.models import ServerConfig
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
__all__ = ("import_cmdset", "CmdSetHandler")
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ All commands in Evennia inherit from the 'Command' class in this module.
|
|||
"""
|
||||
import re
|
||||
import math
|
||||
import inspect
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
|
@ -74,6 +75,13 @@ def _init_command(cls, **kwargs):
|
|||
cls.is_exit = False
|
||||
if not hasattr(cls, "help_category"):
|
||||
cls.help_category = "general"
|
||||
# make sure to pick up the parent's docstring if the child class is
|
||||
# missing one (important for auto-help)
|
||||
if cls.__doc__ is None:
|
||||
for parent_class in inspect.getmro(cls):
|
||||
if parent_class.__doc__ is not None:
|
||||
cls.__doc__ = parent_class.__doc__
|
||||
break
|
||||
cls.help_category = cls.help_category.lower()
|
||||
|
||||
|
||||
|
|
@ -401,12 +409,11 @@ class Command(object, metaclass=CommandMeta):
|
|||
"""
|
||||
pass
|
||||
|
||||
def func(self):
|
||||
def get_command_info(self):
|
||||
"""
|
||||
This is the actual executing part of the command. It is
|
||||
called directly after self.parse(). See the docstring of this
|
||||
module for which object properties are available (beyond those
|
||||
set in self.parse())
|
||||
This is the default output of func() if no func() overload is done.
|
||||
Provided here as a separate method so that it can be called for debugging
|
||||
purposes when making commands.
|
||||
|
||||
"""
|
||||
variables = "\n".join(
|
||||
|
|
@ -416,11 +423,8 @@ class Command(object, metaclass=CommandMeta):
|
|||
Command {self} has no defined `func()` - showing on-command variables:
|
||||
{variables}
|
||||
"""
|
||||
self.caller.msg(string)
|
||||
return
|
||||
|
||||
# a simple test command to show the available properties
|
||||
string = "-" * 50
|
||||
string += "-" * 50
|
||||
string += "\n|w%s|n - Command variables from evennia:\n" % self.key
|
||||
string += "-" * 50
|
||||
string += "\nname of cmd (self.key): |w%s|n\n" % self.key
|
||||
|
|
@ -438,6 +442,16 @@ Command {self} has no defined `func()` - showing on-command variables:
|
|||
|
||||
self.caller.msg(string)
|
||||
|
||||
def func(self):
|
||||
"""
|
||||
This is the actual executing part of the command. It is
|
||||
called directly after self.parse(). See the docstring of this
|
||||
module for which object properties are available (beyond those
|
||||
set in self.parse())
|
||||
|
||||
"""
|
||||
self.get_command_info()
|
||||
|
||||
def get_extra_info(self, caller, **kwargs):
|
||||
"""
|
||||
Display some extra information that may help distinguish this
|
||||
|
|
@ -484,12 +498,14 @@ Command {self} has no defined `func()` - showing on-command variables:
|
|||
Get the client screenwidth for the session using this command.
|
||||
|
||||
Returns:
|
||||
client width (int or None): The width (in characters) of the client window. None
|
||||
if this command is run without a Session (such as by an NPC).
|
||||
client width (int): The width (in characters) of the client window.
|
||||
|
||||
"""
|
||||
if self.session:
|
||||
return self.session.protocol_flags["SCREENWIDTH"][0]
|
||||
return self.session.protocol_flags.get(
|
||||
"SCREENWIDTH", {0: settings.CLIENT_DEFAULT_WIDTH}
|
||||
)[0]
|
||||
return settings.CLIENT_DEFAULT_WIDTH
|
||||
|
||||
def styled_table(self, *args, **kwargs):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ Building and world design commands
|
|||
"""
|
||||
import re
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.db.models import Q, Min, Max
|
||||
from evennia.objects.models import ObjectDB
|
||||
from evennia.locks.lockhandler import LockException
|
||||
from evennia.commands.cmdhandler import get_and_merge_cmdsets
|
||||
|
|
@ -13,11 +13,13 @@ from evennia.utils.utils import (
|
|||
class_from_module,
|
||||
get_all_typeclasses,
|
||||
variable_from_module,
|
||||
dbref,
|
||||
)
|
||||
from evennia.utils.eveditor import EvEditor
|
||||
from evennia.utils.evmore import EvMore
|
||||
from evennia.prototypes import spawner, prototypes as protlib, menus as olc_menus
|
||||
from evennia.utils.ansi import raw
|
||||
from evennia.prototypes.menus import _format_diff_text_and_options
|
||||
|
||||
COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
|
||||
|
||||
|
|
@ -1911,8 +1913,8 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS):
|
|||
|
||||
Usage:
|
||||
typeclass[/switch] <object> [= typeclass.path]
|
||||
type ''
|
||||
parent ''
|
||||
typeclass/prototype <object> = prototype_key
|
||||
|
||||
typeclass/list/show [typeclass.path]
|
||||
swap - this is a shorthand for using /force/reset flags.
|
||||
update - this is a shorthand for using the /force/reload flag.
|
||||
|
|
@ -1929,9 +1931,12 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS):
|
|||
list - show available typeclasses. Only typeclasses in modules actually
|
||||
imported or used from somewhere in the code will show up here
|
||||
(those typeclasses are still available if you know the path)
|
||||
prototype - clean and overwrite the object with the specified
|
||||
prototype key - effectively making a whole new object.
|
||||
|
||||
Example:
|
||||
type button = examples.red_button.RedButton
|
||||
type/prototype button=a red button
|
||||
|
||||
If the typeclass_path is not given, the current object's typeclass is
|
||||
assumed.
|
||||
|
|
@ -1953,7 +1958,7 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS):
|
|||
|
||||
key = "typeclass"
|
||||
aliases = ["type", "parent", "swap", "update"]
|
||||
switch_options = ("show", "examine", "update", "reset", "force", "list")
|
||||
switch_options = ("show", "examine", "update", "reset", "force", "list", "prototype")
|
||||
locks = "cmd:perm(typeclass) or perm(Builder)"
|
||||
help_category = "Building"
|
||||
|
||||
|
|
@ -2037,6 +2042,27 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS):
|
|||
|
||||
new_typeclass = self.rhs or obj.path
|
||||
|
||||
prototype = None
|
||||
if "prototype" in self.switches:
|
||||
key = self.rhs
|
||||
prototype = protlib.search_prototype(key=key)
|
||||
if len(prototype) > 1:
|
||||
caller.msg(
|
||||
"More than one match for {}:\n{}".format(
|
||||
key, "\n".join(proto.get("prototype_key", "") for proto in prototype)
|
||||
)
|
||||
)
|
||||
return
|
||||
elif prototype:
|
||||
# one match
|
||||
prototype = prototype[0]
|
||||
else:
|
||||
# no match
|
||||
caller.msg("No prototype '{}' was found.".format(key))
|
||||
return
|
||||
new_typeclass = prototype["typeclass"]
|
||||
self.switches.append("force")
|
||||
|
||||
if "show" in self.switches or "examine" in self.switches:
|
||||
string = "%s's current typeclass is %s." % (obj.name, obj.__class__)
|
||||
caller.msg(string)
|
||||
|
|
@ -2069,11 +2095,34 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS):
|
|||
hooks = "at_object_creation" if update else "all"
|
||||
old_typeclass_path = obj.typeclass_path
|
||||
|
||||
# special prompt for the user in cases where we want
|
||||
# to confirm changes.
|
||||
if "prototype" in self.switches:
|
||||
diff, _ = spawner.prototype_diff_from_object(prototype, obj)
|
||||
txt, options = _format_diff_text_and_options(diff, objects=[obj])
|
||||
prompt = (
|
||||
"Applying prototype '%s' over '%s' will cause the follow changes:\n%s\n"
|
||||
% (prototype["key"], obj.name, "\n".join(txt))
|
||||
)
|
||||
if not reset:
|
||||
prompt += "\n|yWARNING:|n Use the /reset switch to apply the prototype over a blank state."
|
||||
prompt += "\nAre you sure you want to apply these changes [yes]/no?"
|
||||
answer = yield (prompt)
|
||||
if answer and answer in ("no", "n"):
|
||||
caller.msg("Canceled: No changes were applied.")
|
||||
return
|
||||
|
||||
# we let this raise exception if needed
|
||||
obj.swap_typeclass(
|
||||
new_typeclass, clean_attributes=reset, clean_cmdsets=reset, run_start_hooks=hooks
|
||||
)
|
||||
|
||||
if "prototype" in self.switches:
|
||||
modified = spawner.batch_update_objects_with_prototype(prototype, objects=[obj])
|
||||
prototype_success = modified > 0
|
||||
if not prototype_success:
|
||||
caller.msg("Prototype %s failed to apply." % prototype["key"])
|
||||
|
||||
if is_same:
|
||||
string = "%s updated its existing typeclass (%s).\n" % (obj.name, obj.path)
|
||||
else:
|
||||
|
|
@ -2090,6 +2139,11 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS):
|
|||
string += " All old attributes where deleted before the swap."
|
||||
else:
|
||||
string += " Attributes set before swap were not removed."
|
||||
if "prototype" in self.switches and prototype_success:
|
||||
string += (
|
||||
" Prototype '%s' was successfully applied over the object type."
|
||||
% prototype["key"]
|
||||
)
|
||||
|
||||
caller.msg(string)
|
||||
|
||||
|
|
@ -2641,7 +2695,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
|
|||
caller = self.caller
|
||||
switches = self.switches
|
||||
|
||||
if not self.args:
|
||||
if not self.args or (not self.lhs and not self.rhs):
|
||||
caller.msg("Usage: find <string> [= low [-high]]")
|
||||
return
|
||||
|
||||
|
|
@ -2649,18 +2703,46 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
|
|||
switches.append("loc")
|
||||
|
||||
searchstring = self.lhs
|
||||
low, high = 1, ObjectDB.objects.all().order_by("-id")[0].id
|
||||
|
||||
try:
|
||||
# Try grabbing the actual min/max id values by database aggregation
|
||||
qs = ObjectDB.objects.values("id").aggregate(low=Min("id"), high=Max("id"))
|
||||
low, high = sorted(qs.values())
|
||||
if not (low and high):
|
||||
raise ValueError(
|
||||
f"{self.__class__.__name__}: Min and max ID not returned by aggregation; falling back to queryset slicing."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.log_trace(e)
|
||||
# If that doesn't work for some reason (empty DB?), guess the lower
|
||||
# bound and do a less-efficient query to find the upper.
|
||||
low, high = 1, ObjectDB.objects.all().order_by("-id").first().id
|
||||
|
||||
if self.rhs:
|
||||
if "-" in self.rhs:
|
||||
# also support low-high syntax
|
||||
limlist = [part.lstrip("#").strip() for part in self.rhs.split("-", 1)]
|
||||
else:
|
||||
# otherwise split by space
|
||||
limlist = [part.lstrip("#") for part in self.rhs.split(None, 1)]
|
||||
if limlist and limlist[0].isdigit():
|
||||
low = max(low, int(limlist[0]))
|
||||
if len(limlist) > 1 and limlist[1].isdigit():
|
||||
high = min(high, int(limlist[1]))
|
||||
try:
|
||||
# Check that rhs is either a valid dbref or dbref range
|
||||
bounds = tuple(
|
||||
sorted(dbref(x, False) for x in re.split("[-\s]+", self.rhs.strip()))
|
||||
)
|
||||
|
||||
# dbref() will return either a valid int or None
|
||||
assert bounds
|
||||
# None should not exist in the bounds list
|
||||
assert None not in bounds
|
||||
|
||||
low = bounds[0]
|
||||
if len(bounds) > 1:
|
||||
high = bounds[-1]
|
||||
|
||||
except AssertionError:
|
||||
caller.msg("Invalid dbref range provided (not a number).")
|
||||
return
|
||||
except IndexError as e:
|
||||
logger.log_err(
|
||||
f"{self.__class__.__name__}: Error parsing upper and lower bounds of query."
|
||||
)
|
||||
logger.log_trace(e)
|
||||
|
||||
low = min(low, high)
|
||||
high = max(low, high)
|
||||
|
||||
|
|
@ -2672,7 +2754,6 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
|
|||
restrictions = ", %s" % (", ".join(self.switches))
|
||||
|
||||
if is_dbref or is_account:
|
||||
|
||||
if is_dbref:
|
||||
# a dbref search
|
||||
result = caller.search(searchstring, global_search=True, quiet=True)
|
||||
|
|
@ -2703,7 +2784,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
|
|||
)
|
||||
else:
|
||||
# Not an account/dbref search but a wider search; build a queryset.
|
||||
# Searchs for key and aliases
|
||||
# Searches for key and aliases
|
||||
if "exact" in switches:
|
||||
keyquery = Q(db_key__iexact=searchstring, id__gte=low, id__lte=high)
|
||||
aliasquery = Q(
|
||||
|
|
@ -2729,39 +2810,52 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
|
|||
id__lte=high,
|
||||
)
|
||||
|
||||
results = ObjectDB.objects.filter(keyquery | aliasquery).distinct()
|
||||
nresults = results.count()
|
||||
# Keep the initial queryset handy for later reuse
|
||||
result_qs = ObjectDB.objects.filter(keyquery | aliasquery).distinct()
|
||||
nresults = result_qs.count()
|
||||
|
||||
if nresults:
|
||||
# convert result to typeclasses.
|
||||
results = [result for result in results]
|
||||
if "room" in switches:
|
||||
results = [obj for obj in results if inherits_from(obj, ROOM_TYPECLASS)]
|
||||
if "exit" in switches:
|
||||
results = [obj for obj in results if inherits_from(obj, EXIT_TYPECLASS)]
|
||||
if "char" in switches:
|
||||
results = [obj for obj in results if inherits_from(obj, CHAR_TYPECLASS)]
|
||||
nresults = len(results)
|
||||
# Use iterator to minimize memory ballooning on large result sets
|
||||
results = result_qs.iterator()
|
||||
|
||||
# Check and see if type filtering was requested; skip it if not
|
||||
if any(x in switches for x in ("room", "exit", "char")):
|
||||
obj_ids = set()
|
||||
for obj in results:
|
||||
if (
|
||||
("room" in switches and inherits_from(obj, ROOM_TYPECLASS))
|
||||
or ("exit" in switches and inherits_from(obj, EXIT_TYPECLASS))
|
||||
or ("char" in switches and inherits_from(obj, CHAR_TYPECLASS))
|
||||
):
|
||||
obj_ids.add(obj.id)
|
||||
|
||||
# Filter previous queryset instead of requesting another
|
||||
filtered_qs = result_qs.filter(id__in=obj_ids).distinct()
|
||||
nresults = filtered_qs.count()
|
||||
|
||||
# Use iterator again to minimize memory ballooning
|
||||
results = filtered_qs.iterator()
|
||||
|
||||
# still results after type filtering?
|
||||
if nresults:
|
||||
if nresults > 1:
|
||||
string = "|w%i Matches|n(#%i-#%i%s):" % (nresults, low, high, restrictions)
|
||||
for res in results:
|
||||
string += "\n |g%s - %s|n" % (res.get_display_name(caller), res.path)
|
||||
header = f"{nresults} Matches"
|
||||
else:
|
||||
string = "|wOne Match|n(#%i-#%i%s):" % (low, high, restrictions)
|
||||
string += "\n |g%s - %s|n" % (
|
||||
results[0].get_display_name(caller),
|
||||
results[0].path,
|
||||
)
|
||||
if "loc" in self.switches and nresults == 1 and results[0].location:
|
||||
string += " (|wlocation|n: |g{}|n)".format(
|
||||
results[0].location.get_display_name(caller)
|
||||
)
|
||||
header = "One Match"
|
||||
|
||||
string = f"|w{header}|n(#{low}-#{high}{restrictions}):"
|
||||
res = None
|
||||
for res in results:
|
||||
string += f"\n |g{res.get_display_name(caller)} - {res.path}|n"
|
||||
if (
|
||||
"loc" in self.switches
|
||||
and nresults == 1
|
||||
and res
|
||||
and getattr(res, "location", None)
|
||||
):
|
||||
string += f" (|wlocation|n: |g{res.location.get_display_name(caller)}|n)"
|
||||
else:
|
||||
string = "|wMatch|n(#%i-#%i%s):" % (low, high, restrictions)
|
||||
string += "\n |RNo matches found for '%s'|n" % searchstring
|
||||
string = f"|wNo Matches|n(#{low}-#{high}{restrictions}):"
|
||||
string += f"\n |RNo matches found for '{searchstring}'|n"
|
||||
|
||||
# send result
|
||||
caller.msg(string.strip())
|
||||
|
|
@ -2791,8 +2885,8 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS):
|
|||
reference. A puppeted object cannot be moved to None.
|
||||
loc - teleport object to the target's location instead of its contents
|
||||
|
||||
Teleports an object somewhere. If no object is given, you yourself
|
||||
is teleported to the target location.
|
||||
Teleports an object somewhere. If no object is given, you yourself are
|
||||
teleported to the target location.
|
||||
"""
|
||||
|
||||
key = "tel"
|
||||
|
|
@ -2957,7 +3051,8 @@ class CmdScript(COMMAND_DEFAULT_CLASS):
|
|||
ok = obj.scripts.add(self.rhs, autostart=True)
|
||||
if not ok:
|
||||
result.append(
|
||||
"\nScript %s could not be added and/or started on %s."
|
||||
"\nScript %s could not be added and/or started on %s "
|
||||
"(or it started and immediately shut down)."
|
||||
% (self.rhs, obj.get_display_name(caller))
|
||||
)
|
||||
else:
|
||||
|
|
@ -2988,7 +3083,8 @@ class CmdScript(COMMAND_DEFAULT_CLASS):
|
|||
else:
|
||||
result = ["Script started successfully."]
|
||||
break
|
||||
caller.msg("".join(result).strip())
|
||||
|
||||
EvMore(caller, "".join(result).strip())
|
||||
|
||||
|
||||
class CmdTag(COMMAND_DEFAULT_CLASS):
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ class CmdHelp(Command):
|
|||
evmore.msg(self.caller, text, session=self.session)
|
||||
return
|
||||
|
||||
self.msg((text, {"type": "help"}))
|
||||
self.msg(text=(text, {"type": "help"}))
|
||||
|
||||
@staticmethod
|
||||
def format_help_entry(title, help_text, aliases=None, suggested=None):
|
||||
|
|
@ -376,7 +376,7 @@ class CmdSetHelp(COMMAND_DEFAULT_CLASS):
|
|||
self.msg("You have to define a topic!")
|
||||
return
|
||||
topicstrlist = topicstr.split(";")
|
||||
topicstr, aliases = topicstrlist[0], topicstrlist[1:] if len(topicstr) > 1 else []
|
||||
topicstr, aliases = (topicstrlist[0], topicstrlist[1:] if len(topicstr) > 1 else [])
|
||||
aliastxt = ("(aliases: %s)" % ", ".join(aliases)) if aliases else ""
|
||||
old_entry = None
|
||||
|
||||
|
|
|
|||
|
|
@ -202,11 +202,15 @@ class MuxCommand(Command):
|
|||
else:
|
||||
self.character = None
|
||||
|
||||
def func(self):
|
||||
def get_command_info(self):
|
||||
"""
|
||||
This is the hook function that actually does all the work. It is called
|
||||
by the cmdhandler right after self.parser() finishes, and so has access
|
||||
to all the variables defined therein.
|
||||
Update of parent class's get_command_info() for MuxCommand.
|
||||
"""
|
||||
self.get_command_info()
|
||||
|
||||
def get_command_info(self):
|
||||
"""
|
||||
Update of parent class's get_command_info() for MuxCommand.
|
||||
"""
|
||||
variables = "\n".join(
|
||||
" |w{}|n ({}): {}".format(key, type(val), val) for key, val in self.__dict__.items()
|
||||
|
|
@ -245,6 +249,14 @@ Command {self} has no defined `func()` - showing on-command variables: No child
|
|||
string += "-" * 50
|
||||
self.caller.msg(string)
|
||||
|
||||
def func(self):
|
||||
"""
|
||||
This is the hook function that actually does all the work. It is called
|
||||
by the cmdhandler right after self.parser() finishes, and so has access
|
||||
to all the variables defined therein.
|
||||
"""
|
||||
self.get_command_info()
|
||||
|
||||
|
||||
class MuxAccountCommand(MuxCommand):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from evennia.accounts.models import AccountDB
|
|||
from evennia.utils import logger, utils, gametime, create, search
|
||||
from evennia.utils.eveditor import EvEditor
|
||||
from evennia.utils.evtable import EvTable
|
||||
from evennia.utils.evmore import EvMore
|
||||
from evennia.utils.utils import crop, class_from_module
|
||||
|
||||
COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
|
||||
|
|
@ -232,6 +233,10 @@ def _run_code_snippet(
|
|||
|
||||
if ret is None:
|
||||
return
|
||||
elif isinstance(ret, tuple):
|
||||
# we must convert here to allow msg to pass it (a tuple is confused
|
||||
# with a outputfunc structure)
|
||||
ret = str(ret)
|
||||
|
||||
for session in sessions:
|
||||
try:
|
||||
|
|
@ -284,8 +289,6 @@ class EvenniaPythonConsole(code.InteractiveConsole):
|
|||
result = None
|
||||
try:
|
||||
result = super().push(line)
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
sys.stderr = old_stderr
|
||||
|
|
@ -301,6 +304,7 @@ class CmdPy(COMMAND_DEFAULT_CLASS):
|
|||
py/edit
|
||||
py/time <cmd>
|
||||
py/clientraw <cmd>
|
||||
py/noecho
|
||||
|
||||
Switches:
|
||||
time - output an approximate execution time for <cmd>
|
||||
|
|
@ -308,6 +312,8 @@ class CmdPy(COMMAND_DEFAULT_CLASS):
|
|||
clientraw - turn off all client-specific escaping. Note that this may
|
||||
lead to different output depending on prototocol (such as angular brackets
|
||||
being parsed as HTML in the webclient but not in telnet clients)
|
||||
noecho - in Python console mode, turn off the input echo (e.g. if your client
|
||||
does this for you already)
|
||||
|
||||
Without argument, open a Python console in-game. This is a full console,
|
||||
accepting multi-line Python code for testing and debugging. Type `exit()` to
|
||||
|
|
@ -339,7 +345,7 @@ class CmdPy(COMMAND_DEFAULT_CLASS):
|
|||
|
||||
key = "py"
|
||||
aliases = ["!"]
|
||||
switch_options = ("time", "edit", "clientraw")
|
||||
switch_options = ("time", "edit", "clientraw", "noecho")
|
||||
locks = "cmd:perm(py) or perm(Developer)"
|
||||
help_category = "System"
|
||||
|
||||
|
|
@ -349,6 +355,8 @@ class CmdPy(COMMAND_DEFAULT_CLASS):
|
|||
caller = self.caller
|
||||
pycode = self.args
|
||||
|
||||
noecho = "noecho" in self.switches
|
||||
|
||||
if "edit" in self.switches:
|
||||
caller.db._py_measure_time = "time" in self.switches
|
||||
caller.db._py_clientraw = "clientraw" in self.switches
|
||||
|
|
@ -367,15 +375,26 @@ class CmdPy(COMMAND_DEFAULT_CLASS):
|
|||
# Run in interactive mode
|
||||
console = EvenniaPythonConsole(self.caller)
|
||||
banner = (
|
||||
f"|gPython {sys.version} on {sys.platform}\n"
|
||||
"Evennia interactive console mode - type 'exit()' to leave.|n"
|
||||
"|gEvennia Interactive Python mode{echomode}\n"
|
||||
"Python {version} on {platform}".format(
|
||||
echomode=" (no echoing of prompts)" if noecho else "",
|
||||
version=sys.version,
|
||||
platform=sys.platform,
|
||||
)
|
||||
)
|
||||
self.msg(banner)
|
||||
line = ""
|
||||
prompt = ">>>"
|
||||
main_prompt = "|x[py mode - quit() to exit]|n"
|
||||
prompt = main_prompt
|
||||
while line.lower() not in ("exit", "exit()"):
|
||||
line = yield (prompt)
|
||||
prompt = "..." if console.push(line) else ">>>"
|
||||
try:
|
||||
line = yield (prompt)
|
||||
if noecho:
|
||||
prompt = "..." if console.push(line) else main_prompt
|
||||
else:
|
||||
prompt = line if console.push(line) else f"{line}\n{main_prompt}"
|
||||
except SystemExit:
|
||||
break
|
||||
self.msg("|gClosing the Python console.|n")
|
||||
return
|
||||
|
||||
|
|
@ -409,16 +428,19 @@ def format_script_list(scripts):
|
|||
align="r",
|
||||
border="tablecols",
|
||||
)
|
||||
|
||||
for script in scripts:
|
||||
|
||||
nextrep = script.time_until_next_repeat()
|
||||
if nextrep is None:
|
||||
nextrep = "PAUS" if script.db._paused_time else "--"
|
||||
nextrep = "PAUSED" if script.db._paused_time else "--"
|
||||
else:
|
||||
nextrep = "%ss" % nextrep
|
||||
|
||||
maxrepeat = script.repeats
|
||||
remaining = script.remaining_repeats() or 0
|
||||
if maxrepeat:
|
||||
rept = "%i/%i" % (maxrepeat - script.remaining_repeats(), maxrepeat)
|
||||
rept = "%i/%i" % (maxrepeat - remaining, maxrepeat)
|
||||
else:
|
||||
rept = "-/-"
|
||||
|
||||
|
|
@ -433,6 +455,7 @@ def format_script_list(scripts):
|
|||
script.typeclass_path.rsplit(".", 1)[-1],
|
||||
crop(script.desc, width=20),
|
||||
)
|
||||
|
||||
return "%s" % table
|
||||
|
||||
|
||||
|
|
@ -527,7 +550,7 @@ class CmdScripts(COMMAND_DEFAULT_CLASS):
|
|||
else:
|
||||
# No stopping or validation. We just want to view things.
|
||||
string = format_script_list(scripts)
|
||||
caller.msg(string)
|
||||
EvMore(caller, string)
|
||||
|
||||
|
||||
class CmdObjects(COMMAND_DEFAULT_CLASS):
|
||||
|
|
@ -592,9 +615,13 @@ class CmdObjects(COMMAND_DEFAULT_CLASS):
|
|||
"|wtypeclass|n", "|wcount|n", "|w%|n", border="table", align="l"
|
||||
)
|
||||
typetable.align = "l"
|
||||
dbtotals = ObjectDB.objects.object_totals()
|
||||
for path, count in dbtotals.items():
|
||||
typetable.add_row(path, count, "%.2f" % ((float(count) / nobjs) * 100))
|
||||
dbtotals = ObjectDB.objects.get_typeclass_totals()
|
||||
for stat in dbtotals:
|
||||
typetable.add_row(
|
||||
stat.get("typeclass", "<error>"),
|
||||
stat.get("count", -1),
|
||||
"%.2f" % stat.get("percent", -1),
|
||||
)
|
||||
|
||||
# last N table
|
||||
objs = ObjectDB.objects.all().order_by("db_date_created")[max(0, nobjs - nlim) :]
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from anything import Anything
|
|||
from django.conf import settings
|
||||
from mock import Mock, mock
|
||||
|
||||
from evennia import DefaultRoom, DefaultExit
|
||||
from evennia import DefaultRoom, DefaultExit, ObjectDB
|
||||
from evennia.commands.default.cmdset_character import CharacterCmdSet
|
||||
from evennia.utils.test_resources import EvenniaTest
|
||||
from evennia.commands.default import (
|
||||
|
|
@ -991,6 +991,34 @@ class TestBuilding(CommandTest):
|
|||
"All object creation hooks were run. All old attributes where deleted before the swap.",
|
||||
)
|
||||
|
||||
from evennia.prototypes.prototypes import homogenize_prototype
|
||||
|
||||
test_prototype = [
|
||||
homogenize_prototype(
|
||||
{
|
||||
"prototype_key": "testkey",
|
||||
"prototype_tags": [],
|
||||
"typeclass": "typeclasses.objects.Object",
|
||||
"key": "replaced_obj",
|
||||
"attrs": [("foo", "bar", None, ""), ("desc", "protdesc", None, "")],
|
||||
}
|
||||
)
|
||||
]
|
||||
with mock.patch(
|
||||
"evennia.commands.default.building.protlib.search_prototype",
|
||||
new=mock.MagicMock(return_value=test_prototype),
|
||||
) as mprot:
|
||||
self.call(
|
||||
building.CmdTypeclass(),
|
||||
"/prototype Obj=testkey",
|
||||
"replaced_obj changed typeclass from "
|
||||
"evennia.objects.objects.DefaultObject to "
|
||||
"typeclasses.objects.Object.\nAll object creation hooks were "
|
||||
"run. Attributes set before swap were not removed. Prototype "
|
||||
"'replaced_obj' was successfully applied over the object type.",
|
||||
)
|
||||
assert self.obj1.db.desc == "protdesc"
|
||||
|
||||
def test_lock(self):
|
||||
self.call(building.CmdLock(), "", "Usage: ")
|
||||
self.call(building.CmdLock(), "Obj = test:all()", "Added lock 'test:all()' to Obj.")
|
||||
|
|
@ -1038,11 +1066,41 @@ class TestBuilding(CommandTest):
|
|||
self.call(building.CmdFind(), self.char1.dbref, "Exact dbref match")
|
||||
self.call(building.CmdFind(), "*TestAccount", "Match")
|
||||
|
||||
self.call(building.CmdFind(), "/char Obj")
|
||||
self.call(building.CmdFind(), "/room Obj")
|
||||
self.call(building.CmdFind(), "/exit Obj")
|
||||
self.call(building.CmdFind(), "/char Obj", "No Matches")
|
||||
self.call(building.CmdFind(), "/room Obj", "No Matches")
|
||||
self.call(building.CmdFind(), "/exit Obj", "No Matches")
|
||||
self.call(building.CmdFind(), "/exact Obj", "One Match")
|
||||
|
||||
# Test multitype filtering
|
||||
with mock.patch(
|
||||
"evennia.commands.default.building.CHAR_TYPECLASS",
|
||||
"evennia.objects.objects.DefaultCharacter",
|
||||
):
|
||||
self.call(building.CmdFind(), "/char/room Obj", "No Matches")
|
||||
self.call(building.CmdFind(), "/char/room/exit Char", "2 Matches")
|
||||
self.call(building.CmdFind(), "/char/room/exit/startswith Cha", "2 Matches")
|
||||
|
||||
# Test null search
|
||||
self.call(building.CmdFind(), "=", "Usage: ")
|
||||
|
||||
# Test bogus dbref range with no search term
|
||||
self.call(building.CmdFind(), "= obj", "Invalid dbref range provided (not a number).")
|
||||
self.call(building.CmdFind(), "= #1a", "Invalid dbref range provided (not a number).")
|
||||
|
||||
# Test valid dbref ranges with no search term
|
||||
id1 = self.obj1.id
|
||||
id2 = self.obj2.id
|
||||
maxid = ObjectDB.objects.latest("id").id
|
||||
maxdiff = maxid - id1 + 1
|
||||
mdiff = id2 - id1 + 1
|
||||
|
||||
self.call(building.CmdFind(), f"=#{id1}", f"{maxdiff} Matches(#{id1}-#{maxid}")
|
||||
self.call(building.CmdFind(), f"={id1}-{id2}", f"{mdiff} Matches(#{id1}-#{id2}):")
|
||||
self.call(building.CmdFind(), f"={id1} - {id2}", f"{mdiff} Matches(#{id1}-#{id2}):")
|
||||
self.call(building.CmdFind(), f"={id1}- #{id2}", f"{mdiff} Matches(#{id1}-#{id2}):")
|
||||
self.call(building.CmdFind(), f"={id1}-#{id2}", f"{mdiff} Matches(#{id1}-#{id2}):")
|
||||
self.call(building.CmdFind(), f"=#{id1}-{id2}", f"{mdiff} Matches(#{id1}-#{id2}):")
|
||||
|
||||
def test_script(self):
|
||||
self.call(building.CmdScript(), "Obj = ", "No scripts defined on Obj")
|
||||
self.call(
|
||||
|
|
|
|||
|
|
@ -27,8 +27,12 @@ from django.conf import settings
|
|||
from evennia.commands import cmdset, command
|
||||
from evennia.utils.logger import tail_log_file
|
||||
from evennia.utils.utils import class_from_module
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
# we must late-import these since any overloads are likely to
|
||||
# themselves be using these classes leading to a circular import.
|
||||
|
||||
_CHANNEL_HANDLER_CLASS = None
|
||||
_CHANNEL_COMMAND_CLASS = None
|
||||
_CHANNELDB = None
|
||||
|
||||
|
|
@ -314,5 +318,6 @@ class ChannelHandler(object):
|
|||
return chan_cmdset
|
||||
|
||||
|
||||
CHANNEL_HANDLER = ChannelHandler()
|
||||
# set up the singleton
|
||||
CHANNEL_HANDLER = class_from_module(settings.CHANNEL_HANDLER_CLASS)()
|
||||
CHANNELHANDLER = CHANNEL_HANDLER # legacy
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ Comm system components.
|
|||
from django.db.models import Q
|
||||
from evennia.typeclasses.managers import TypedObjectManager, TypeclassManager
|
||||
from evennia.utils import logger
|
||||
from evennia.utils.utils import dbref
|
||||
|
||||
_GA = object.__getattribute__
|
||||
_AccountDB = None
|
||||
|
|
@ -31,32 +32,6 @@ class CommError(Exception):
|
|||
#
|
||||
|
||||
|
||||
def dbref(inp, reqhash=True):
|
||||
"""
|
||||
Valid forms of dbref (database reference number) are either a
|
||||
string '#N' or an integer N.
|
||||
|
||||
Args:
|
||||
inp (int or str): A possible dbref to check syntactically.
|
||||
reqhash (bool): Require an initial hash `#` to accept.
|
||||
|
||||
Returns:
|
||||
is_dbref (int or None): The dbref integer part if a valid
|
||||
dbref, otherwise `None`.
|
||||
|
||||
"""
|
||||
if reqhash and not (isinstance(inp, str) and inp.startswith("#")):
|
||||
return None
|
||||
if isinstance(inp, str):
|
||||
inp = inp.lstrip("#")
|
||||
try:
|
||||
if int(inp) < 0:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
return inp
|
||||
|
||||
|
||||
def identify_object(inp):
|
||||
"""
|
||||
Helper function. Identifies if an object is an account or an object;
|
||||
|
|
|
|||
|
|
@ -400,9 +400,11 @@ class Msg(SharedMemoryModel):
|
|||
|
||||
def __str__(self):
|
||||
"This handles what is shown when e.g. printing the message"
|
||||
senders = ",".join(obj.key for obj in self.senders)
|
||||
senders = ",".join(getattr(obj, "key", str(obj)) for obj in self.senders)
|
||||
|
||||
receivers = ",".join(
|
||||
["[%s]" % obj.key for obj in self.channels] + [obj.key for obj in self.receivers]
|
||||
["[%s]" % getattr(obj, "key", str(obj)) for obj in self.channels]
|
||||
+ [getattr(obj, "key", str(obj)) for obj in self.receivers]
|
||||
)
|
||||
return "%s->%s: %s" % (senders, receivers, crop(self.message, width=40))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from evennia.utils.test_resources import EvenniaTest
|
||||
from evennia import DefaultChannel
|
||||
from evennia.utils.create import create_message
|
||||
|
||||
|
||||
class ObjectCreationTest(EvenniaTest):
|
||||
|
|
@ -10,3 +11,8 @@ class ObjectCreationTest(EvenniaTest):
|
|||
self.assertTrue(obj, errors)
|
||||
self.assertFalse(errors, errors)
|
||||
self.assertEqual(description, obj.db.desc)
|
||||
|
||||
def test_message_create(self):
|
||||
msg = create_message("peewee herman", "heh-heh!", header="mail time!")
|
||||
self.assertTrue(msg)
|
||||
self.assertEqual(str(msg), "peewee herman->: heh-heh!")
|
||||
|
|
|
|||
|
|
@ -298,6 +298,9 @@ class GametimeScript(DefaultScript):
|
|||
|
||||
def at_repeat(self):
|
||||
"""Call the callback and reset interval."""
|
||||
|
||||
from evennia.utils.utils import calledby
|
||||
|
||||
callback = self.db.callback
|
||||
if callback:
|
||||
callback()
|
||||
|
|
|
|||
|
|
@ -8,26 +8,39 @@ insert custom markers in their text to indicate gender-aware
|
|||
messaging. It relies on a modified msg() and is meant as an
|
||||
inspiration and starting point to how to do stuff like this.
|
||||
|
||||
When in use, all messages being sent to the character will make use of
|
||||
the character's gender, for example the echo
|
||||
An object can have the following genders:
|
||||
- male (he/his)
|
||||
- female (her/hers)
|
||||
- neutral (it/its)
|
||||
- ambiguous (they/them/their/theirs)
|
||||
|
||||
When in use, messages can contain special tags to indicate pronouns gendered
|
||||
based on the one being addressed. Capitalization will be retained.
|
||||
|
||||
- `|s`, `|S`: Subjective form: he, she, it, He, She, It, They
|
||||
- `|o`, `|O`: Objective form: him, her, it, Him, Her, It, Them
|
||||
- `|p`, `|P`: Possessive form: his, her, its, His, Her, Its, Their
|
||||
- `|a`, `|A`: Absolute Possessive form: his, hers, its, His, Hers, Its, Theirs
|
||||
|
||||
For example,
|
||||
|
||||
```
|
||||
char.msg("%s falls on |p face with a thud." % char.key)
|
||||
"Tom falls on his face with a thud"
|
||||
```
|
||||
|
||||
will result in "Tom falls on his|her|its|their face with a thud"
|
||||
depending on the gender of the object being messaged. Default gender
|
||||
is "ambiguous" (they).
|
||||
The default gender is "ambiguous" (they/them/their/theirs).
|
||||
|
||||
To use, have DefaultCharacter inherit from this, or change
|
||||
setting.DEFAULT_CHARACTER to point to this class.
|
||||
|
||||
The `@gender` command needs to be added to the default cmdset before
|
||||
it becomes available.
|
||||
The `@gender` command is used to set the gender. It needs to be added to the
|
||||
default cmdset before it becomes available.
|
||||
|
||||
"""
|
||||
|
||||
import re
|
||||
from evennia.utils import logger
|
||||
from evennia import DefaultCharacter
|
||||
from evennia import Command
|
||||
|
||||
|
|
@ -114,7 +127,10 @@ class GenderCharacter(DefaultCharacter):
|
|||
gender-aware markers in output.
|
||||
|
||||
Args:
|
||||
text (str, optional): The message to send
|
||||
text (str or tuple, optional): The message to send. This
|
||||
is treated internally like any send-command, so its
|
||||
value can be a tuple if sending multiple arguments to
|
||||
the `text` oob command.
|
||||
from_obj (obj, optional): object that is sending. If
|
||||
given, at_msg_send will be called
|
||||
session (Session or list, optional): session or list of
|
||||
|
|
@ -125,9 +141,13 @@ class GenderCharacter(DefaultCharacter):
|
|||
All extra kwargs will be passed on to the protocol.
|
||||
|
||||
"""
|
||||
# pre-process the text before continuing
|
||||
try:
|
||||
text = _RE_GENDER_PRONOUN.sub(self._get_pronoun, text)
|
||||
if text and isinstance(text, tuple):
|
||||
text = (self._RE_GENDER_PRONOUN.sub(self._get_pronoun, text[0]), *text[1:])
|
||||
else:
|
||||
text = self._RE_GENDER_PRONOUN.sub(self._get_pronoun, text)
|
||||
except TypeError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.log_trace(e)
|
||||
super().msg(text, from_obj=from_obj, session=session, **kwargs)
|
||||
|
|
|
|||
|
|
@ -238,7 +238,7 @@ class CmdMail(default_cmds.MuxAccountCommand):
|
|||
else:
|
||||
raise IndexError
|
||||
except IndexError:
|
||||
self.caller.msg("Message does not exixt.")
|
||||
self.caller.msg("Message does not exist.")
|
||||
except ValueError:
|
||||
self.caller.msg("Usage: @mail/forward <account list>=<#>[/<Message>]")
|
||||
elif "reply" in self.switches or "rep" in self.switches:
|
||||
|
|
|
|||
|
|
@ -331,7 +331,7 @@ class LanguageHandler(DefaultScript):
|
|||
# find out what preceeded this word
|
||||
wpos = match.start()
|
||||
preceeding = match.string[:wpos].strip()
|
||||
start_sentence = preceeding.endswith(".") or not preceeding
|
||||
start_sentence = preceeding.endswith((".", "!", "?")) or not preceeding
|
||||
|
||||
# make up translation on the fly. Length can
|
||||
# vary from un-translated word.
|
||||
|
|
|
|||
|
|
@ -798,6 +798,16 @@ class RecogHandler(object):
|
|||
# recog_mask log not passed, disable recog
|
||||
return obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key
|
||||
|
||||
def all(self):
|
||||
"""
|
||||
Get a mapping of the recogs stored in handler.
|
||||
|
||||
Returns:
|
||||
recogs (dict): A mapping of {recog: obj} stored in handler.
|
||||
|
||||
"""
|
||||
return {self.obj2recog[obj]: obj for obj in self.obj2recog.keys()}
|
||||
|
||||
def remove(self, obj):
|
||||
"""
|
||||
Clear recog for a given object.
|
||||
|
|
@ -896,10 +906,9 @@ class CmdSay(RPCommand): # replaces standard say
|
|||
caller.msg("Say what?")
|
||||
return
|
||||
|
||||
# calling the speech hook on the location
|
||||
speech = caller.location.at_before_say(self.args)
|
||||
# calling the speech modifying hook
|
||||
speech = caller.at_before_say(self.args)
|
||||
# preparing the speech with sdesc/speech parsing.
|
||||
speech = '/me says, "{speech}"'.format(speech=speech)
|
||||
targets = self.caller.location.contents
|
||||
send_emote(self.caller, targets, speech, anonymous_add=None)
|
||||
|
||||
|
|
@ -932,6 +941,9 @@ class CmdSdesc(RPCommand): # set/look at own sdesc
|
|||
except SdescError as err:
|
||||
caller.msg(err)
|
||||
return
|
||||
except AttributeError:
|
||||
caller.msg(f"Cannot set sdesc on {caller.key}.")
|
||||
return
|
||||
caller.msg("%s's sdesc was set to '%s'." % (caller.key, sdesc))
|
||||
|
||||
|
||||
|
|
@ -1041,6 +1053,7 @@ class CmdRecog(RPCommand): # assign personal alias to object in room
|
|||
Recognize another person in the same room.
|
||||
|
||||
Usage:
|
||||
recog
|
||||
recog sdesc as alias
|
||||
forget alias
|
||||
|
||||
|
|
@ -1048,8 +1061,8 @@ class CmdRecog(RPCommand): # assign personal alias to object in room
|
|||
recog tall man as Griatch
|
||||
forget griatch
|
||||
|
||||
This will assign a personal alias for a person, or
|
||||
forget said alias.
|
||||
This will assign a personal alias for a person, or forget said alias.
|
||||
Using the command without arguments will list all current recogs.
|
||||
|
||||
"""
|
||||
|
||||
|
|
@ -1058,6 +1071,7 @@ class CmdRecog(RPCommand): # assign personal alias to object in room
|
|||
|
||||
def parse(self):
|
||||
"Parse for the sdesc as alias structure"
|
||||
self.sdesc, self.alias = "", ""
|
||||
if " as " in self.args:
|
||||
self.sdesc, self.alias = [part.strip() for part in self.args.split(" as ", 2)]
|
||||
elif self.args:
|
||||
|
|
@ -1070,22 +1084,47 @@ class CmdRecog(RPCommand): # assign personal alias to object in room
|
|||
def func(self):
|
||||
"Assign the recog"
|
||||
caller = self.caller
|
||||
if not self.args:
|
||||
caller.msg("Usage: recog <sdesc> as <alias> or forget <alias>")
|
||||
return
|
||||
sdesc = self.sdesc
|
||||
alias = self.alias.rstrip(".?!")
|
||||
sdesc = self.sdesc
|
||||
|
||||
recog_mode = self.cmdstring != "forget" and alias and sdesc
|
||||
forget_mode = self.cmdstring == "forget" and sdesc
|
||||
list_mode = not self.args
|
||||
|
||||
if not (recog_mode or forget_mode or list_mode):
|
||||
caller.msg("Usage: recog, recog <sdesc> as <alias> or forget <alias>")
|
||||
return
|
||||
|
||||
if list_mode:
|
||||
# list all previously set recogs
|
||||
all_recogs = caller.recog.all()
|
||||
if not all_recogs:
|
||||
caller.msg(
|
||||
"You recognize no-one. " "(Use 'recog <sdesc> as <alias>' to recognize people."
|
||||
)
|
||||
else:
|
||||
# note that we don't skip those failing enable_recog lock here,
|
||||
# because that would actually reveal more than we want.
|
||||
lst = "\n".join(
|
||||
" {} ({})".format(key, obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key)
|
||||
for key, obj in all_recogs.items()
|
||||
)
|
||||
caller.msg(
|
||||
f"Currently recognized (use 'recog <sdesc> as <alias>' to add "
|
||||
f"new and 'forget <alias>' to remove):\n{lst}"
|
||||
)
|
||||
return
|
||||
|
||||
prefixed_sdesc = sdesc if sdesc.startswith(_PREFIX) else _PREFIX + sdesc
|
||||
candidates = caller.location.contents
|
||||
matches = parse_sdescs_and_recogs(caller, candidates, prefixed_sdesc, search_mode=True)
|
||||
nmatches = len(matches)
|
||||
# handle 0, 1 and >1 matches
|
||||
# handle 0 and >1 matches
|
||||
if nmatches == 0:
|
||||
caller.msg(_EMOTE_NOMATCH_ERROR.format(ref=sdesc))
|
||||
elif nmatches > 1:
|
||||
reflist = [
|
||||
"%s%s%s (%s%s)"
|
||||
% (
|
||||
"{}{}{} ({}{})".format(
|
||||
inum + 1,
|
||||
_NUM_SEP,
|
||||
_RE_PREFIX.sub("", sdesc),
|
||||
|
|
@ -1095,17 +1134,20 @@ class CmdRecog(RPCommand): # assign personal alias to object in room
|
|||
for inum, obj in enumerate(matches)
|
||||
]
|
||||
caller.msg(_EMOTE_MULTIMATCH_ERROR.format(ref=sdesc, reflist="\n ".join(reflist)))
|
||||
|
||||
else:
|
||||
# one single match
|
||||
obj = matches[0]
|
||||
if not obj.access(self.obj, "enable_recog", default=True):
|
||||
# don't apply recog if object doesn't allow it (e.g. by being masked).
|
||||
caller.msg("Can't recognize someone who is masked.")
|
||||
caller.msg("It's impossible to recognize them.")
|
||||
return
|
||||
if self.cmdstring == "forget":
|
||||
if forget_mode:
|
||||
# remove existing recog
|
||||
caller.recog.remove(obj)
|
||||
caller.msg("%s will now know only '%s'." % (caller.key, obj.recog.get(obj)))
|
||||
caller.msg("%s will now know them only as '%s'." % (caller.key, obj.recog.get(obj)))
|
||||
else:
|
||||
# set recog
|
||||
sdesc = obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key
|
||||
try:
|
||||
alias = caller.recog.add(obj, alias)
|
||||
|
|
@ -1509,6 +1551,20 @@ class ContribRPCharacter(DefaultCharacter, ContribRPObject):
|
|||
# initializing sdesc
|
||||
self.sdesc.add("A normal person")
|
||||
|
||||
def at_before_say(self, message, **kwargs):
|
||||
"""
|
||||
Called before the object says or whispers anything, return modified message.
|
||||
|
||||
Args:
|
||||
message (str): The suggested say/whisper text spoken by self.
|
||||
Kwargs:
|
||||
whisper (bool): If True, this is a whisper rather than a say.
|
||||
|
||||
"""
|
||||
if kwargs.get("whisper"):
|
||||
return f'/me whispers "{message}"'
|
||||
return f'/me says, "{message}"'
|
||||
|
||||
def process_sdesc(self, sdesc, obj, **kwargs):
|
||||
"""
|
||||
Allows to customize how your sdesc is displayed (primarily by
|
||||
|
|
|
|||
|
|
@ -169,6 +169,8 @@ class TestRPSystem(EvenniaTest):
|
|||
self.speaker.recog.remove(self.receiver1)
|
||||
self.assertEqual(self.speaker.recog.get(self.receiver1), sdesc1)
|
||||
|
||||
self.assertEqual(self.speaker.recog.all(), {"Mr Receiver2": self.receiver2})
|
||||
|
||||
def test_parse_language(self):
|
||||
self.assertEqual(
|
||||
rpsystem.parse_language(self.speaker, emote),
|
||||
|
|
@ -233,6 +235,49 @@ class TestRPSystem(EvenniaTest):
|
|||
self.assertEqual(self.speaker.search("colliding"), self.receiver2)
|
||||
|
||||
|
||||
class TestRPSystemCommands(CommandTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.char1.swap_typeclass(rpsystem.ContribRPCharacter)
|
||||
self.char2.swap_typeclass(rpsystem.ContribRPCharacter)
|
||||
|
||||
def test_commands(self):
|
||||
|
||||
self.call(
|
||||
rpsystem.CmdSdesc(), "Foobar Character", "Char's sdesc was set to 'Foobar Character'."
|
||||
)
|
||||
self.call(
|
||||
rpsystem.CmdSdesc(),
|
||||
"BarFoo Character",
|
||||
"Char2's sdesc was set to 'BarFoo Character'.",
|
||||
caller=self.char2,
|
||||
)
|
||||
self.call(rpsystem.CmdSay(), "Hello!", 'Char says, "Hello!"')
|
||||
self.call(rpsystem.CmdEmote(), "/me smiles to /barfoo.", "Char smiles to BarFoo Character")
|
||||
self.call(
|
||||
rpsystem.CmdPose(),
|
||||
"stands by the bar",
|
||||
"Pose will read 'Foobar Character stands by the bar.'.",
|
||||
)
|
||||
self.call(
|
||||
rpsystem.CmdRecog(),
|
||||
"barfoo as friend",
|
||||
"Char will now remember BarFoo Character as friend.",
|
||||
)
|
||||
self.call(
|
||||
rpsystem.CmdRecog(),
|
||||
"",
|
||||
"Currently recognized (use 'recog <sdesc> as <alias>' to add new "
|
||||
"and 'forget <alias>' to remove):\n friend (BarFoo Character)",
|
||||
)
|
||||
self.call(
|
||||
rpsystem.CmdRecog(),
|
||||
"friend",
|
||||
"Char will now know them only as 'BarFoo Character'",
|
||||
cmdstring="forget",
|
||||
)
|
||||
|
||||
|
||||
# Testing of ExtendedRoom contrib
|
||||
|
||||
from django.conf import settings
|
||||
|
|
@ -607,7 +652,7 @@ class TestWilderness(EvenniaTest):
|
|||
"west": (0, 1),
|
||||
"northwest": (0, 2),
|
||||
}
|
||||
for direction, correct_loc in directions.items(): # Not compatible with Python 3
|
||||
for (direction, correct_loc) in directions.items(): # Not compatible with Python 3
|
||||
new_loc = wilderness.get_new_coordinates(loc, direction)
|
||||
self.assertEqual(new_loc, correct_loc, direction)
|
||||
|
||||
|
|
|
|||
|
|
@ -441,11 +441,11 @@ class TBBasicTurnHandler(DefaultScript):
|
|||
"""
|
||||
combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case.
|
||||
character.db.combat_actionsleft = (
|
||||
0
|
||||
) # Actions remaining - start of turn adds to this, turn ends when it reaches 0
|
||||
0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0
|
||||
)
|
||||
character.db.combat_turnhandler = (
|
||||
self
|
||||
) # Add a reference to this turn handler script to the character
|
||||
self # Add a reference to this turn handler script to the character
|
||||
)
|
||||
character.db.combat_lastaction = "null" # Track last action taken in combat
|
||||
|
||||
def start_turn(self, character):
|
||||
|
|
|
|||
|
|
@ -218,10 +218,10 @@ def apply_damage(defender, damage):
|
|||
def at_defeat(defeated):
|
||||
"""
|
||||
Announces the defeat of a fighter in combat.
|
||||
|
||||
|
||||
Args:
|
||||
defeated (obj): Fighter that's been defeated.
|
||||
|
||||
|
||||
Notes:
|
||||
All this does is announce a defeat message by default, but if you
|
||||
want anything else to happen to defeated fighters (like putting them
|
||||
|
|
@ -438,11 +438,11 @@ class TBEquipTurnHandler(DefaultScript):
|
|||
"""
|
||||
combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case.
|
||||
character.db.combat_actionsleft = (
|
||||
0
|
||||
) # Actions remaining - start of turn adds to this, turn ends when it reaches 0
|
||||
0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0
|
||||
)
|
||||
character.db.combat_turnhandler = (
|
||||
self
|
||||
) # Add a reference to this turn handler script to the character
|
||||
self # Add a reference to this turn handler script to the character
|
||||
)
|
||||
character.db.combat_lastaction = "null" # Track last action taken in combat
|
||||
|
||||
def start_turn(self, character):
|
||||
|
|
@ -553,8 +553,8 @@ class TBEWeapon(DefaultObject):
|
|||
self.db.damage_range = (15, 25) # Minimum and maximum damage on hit
|
||||
self.db.accuracy_bonus = 0 # Bonus to attack rolls (or penalty if negative)
|
||||
self.db.weapon_type_name = (
|
||||
"weapon"
|
||||
) # Single word for weapon - I.E. "dagger", "staff", "scimitar"
|
||||
"weapon" # Single word for weapon - I.E. "dagger", "staff", "scimitar"
|
||||
)
|
||||
|
||||
def at_drop(self, dropper):
|
||||
"""
|
||||
|
|
@ -903,10 +903,10 @@ class CmdCombatHelp(CmdHelp):
|
|||
class CmdWield(Command):
|
||||
"""
|
||||
Wield a weapon you are carrying
|
||||
|
||||
|
||||
Usage:
|
||||
wield <weapon>
|
||||
|
||||
|
||||
Select a weapon you are carrying to wield in combat. If
|
||||
you are already wielding another weapon, you will switch
|
||||
to the weapon you specify instead. Using this command in
|
||||
|
|
@ -933,7 +933,7 @@ class CmdWield(Command):
|
|||
weapon = self.caller.search(self.args, candidates=self.caller.contents)
|
||||
if not weapon:
|
||||
return
|
||||
if not weapon.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEWeapon"):
|
||||
if not weapon.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEWeapon", exact=True):
|
||||
self.caller.msg("That's not a weapon!")
|
||||
# Remember to update the path to the weapon typeclass if you move this module!
|
||||
return
|
||||
|
|
@ -955,10 +955,10 @@ class CmdWield(Command):
|
|||
class CmdUnwield(Command):
|
||||
"""
|
||||
Stop wielding a weapon.
|
||||
|
||||
|
||||
Usage:
|
||||
unwield
|
||||
|
||||
|
||||
After using this command, you will stop wielding any
|
||||
weapon you are currently wielding and become unarmed.
|
||||
"""
|
||||
|
|
@ -986,12 +986,12 @@ class CmdUnwield(Command):
|
|||
class CmdDon(Command):
|
||||
"""
|
||||
Don armor that you are carrying
|
||||
|
||||
|
||||
Usage:
|
||||
don <armor>
|
||||
|
||||
|
||||
Select armor to wear in combat. You can't use this
|
||||
command in the middle of a fight. Use the "doff"
|
||||
command in the middle of a fight. Use the "doff"
|
||||
command to remove any armor you are wearing.
|
||||
"""
|
||||
|
||||
|
|
@ -1012,7 +1012,7 @@ class CmdDon(Command):
|
|||
armor = self.caller.search(self.args, candidates=self.caller.contents)
|
||||
if not armor:
|
||||
return
|
||||
if not armor.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEArmor"):
|
||||
if not armor.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEArmor", exact=True):
|
||||
self.caller.msg("That's not armor!")
|
||||
# Remember to update the path to the armor typeclass if you move this module!
|
||||
return
|
||||
|
|
@ -1031,10 +1031,10 @@ class CmdDon(Command):
|
|||
class CmdDoff(Command):
|
||||
"""
|
||||
Stop wearing armor.
|
||||
|
||||
|
||||
Usage:
|
||||
doff
|
||||
|
||||
|
||||
After using this command, you will stop wearing any
|
||||
armor you are currently using and become unarmored.
|
||||
You can't use this command in combat.
|
||||
|
|
|
|||
|
|
@ -718,11 +718,11 @@ class TBItemsTurnHandler(DefaultScript):
|
|||
"""
|
||||
combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case.
|
||||
character.db.combat_actionsleft = (
|
||||
0
|
||||
) # Actions remaining - start of turn adds to this, turn ends when it reaches 0
|
||||
0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0
|
||||
)
|
||||
character.db.combat_turnhandler = (
|
||||
self
|
||||
) # Add a reference to this turn handler script to the character
|
||||
self # Add a reference to this turn handler script to the character
|
||||
)
|
||||
character.db.combat_lastaction = "null" # Track last action taken in combat
|
||||
|
||||
def start_turn(self, character):
|
||||
|
|
|
|||
|
|
@ -44,6 +44,12 @@ instead of the default:
|
|||
|
||||
class Character(TBMagicCharacter):
|
||||
|
||||
Note: If your character already existed you need to also make sure
|
||||
to re-run the creation hooks on it to set the needed Attributes.
|
||||
Use `update self` to try on yourself or use py to call `at_object_creation()`
|
||||
on all existing Characters.
|
||||
|
||||
|
||||
Next, import this module into your default_cmdsets.py module:
|
||||
|
||||
from evennia.contrib.turnbattle import tb_magic
|
||||
|
|
@ -199,10 +205,10 @@ def apply_damage(defender, damage):
|
|||
def at_defeat(defeated):
|
||||
"""
|
||||
Announces the defeat of a fighter in combat.
|
||||
|
||||
|
||||
Args:
|
||||
defeated (obj): Fighter that's been defeated.
|
||||
|
||||
|
||||
Notes:
|
||||
All this does is announce a defeat message by default, but if you
|
||||
want anything else to happen to defeated fighters (like putting them
|
||||
|
|
@ -332,7 +338,7 @@ class TBMagicCharacter(DefaultCharacter):
|
|||
"""
|
||||
Called once, when this object is first created. This is the
|
||||
normal hook to overload for most object types.
|
||||
|
||||
|
||||
Adds attributes for a character's current and maximum HP.
|
||||
We're just going to set this value at '100' by default.
|
||||
|
||||
|
|
@ -464,11 +470,11 @@ class TBMagicTurnHandler(DefaultScript):
|
|||
"""
|
||||
combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case.
|
||||
character.db.combat_actionsleft = (
|
||||
0
|
||||
) # Actions remaining - start of turn adds to this, turn ends when it reaches 0
|
||||
0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0
|
||||
)
|
||||
character.db.combat_turnhandler = (
|
||||
self
|
||||
) # Add a reference to this turn handler script to the character
|
||||
self # Add a reference to this turn handler script to the character
|
||||
)
|
||||
character.db.combat_lastaction = "null" # Track last action taken in combat
|
||||
|
||||
def start_turn(self, character):
|
||||
|
|
@ -731,26 +737,26 @@ class CmdDisengage(Command):
|
|||
class CmdLearnSpell(Command):
|
||||
"""
|
||||
Learn a magic spell.
|
||||
|
||||
|
||||
Usage:
|
||||
learnspell <spell name>
|
||||
|
||||
|
||||
Adds a spell by name to your list of spells known.
|
||||
|
||||
|
||||
The following spells are provided as examples:
|
||||
|
||||
|
||||
|wmagic missile|n (3 MP): Fires three missiles that never miss. Can target
|
||||
up to three different enemies.
|
||||
|
||||
|
||||
|wflame shot|n (3 MP): Shoots a high-damage jet of flame at one target.
|
||||
|
||||
|
||||
|wcure wounds|n (5 MP): Heals damage on one target.
|
||||
|
||||
|
||||
|wmass cure wounds|n (10 MP): Like 'cure wounds', but can heal up to 5
|
||||
targets at once.
|
||||
|
||||
|wfull heal|n (12 MP): Heals one target back to full HP.
|
||||
|
||||
|
||||
|wcactus conjuration|n (2 MP): Creates a cactus.
|
||||
"""
|
||||
|
||||
|
|
@ -803,10 +809,10 @@ class CmdCast(MuxCommand):
|
|||
"""
|
||||
Cast a magic spell that you know, provided you have the MP
|
||||
to spend on its casting.
|
||||
|
||||
|
||||
Usage:
|
||||
cast <spellname> [= <target1>, <target2>, etc...]
|
||||
|
||||
|
||||
Some spells can be cast on multiple targets, some can be cast
|
||||
on only yourself, and some don't need a target specified at all.
|
||||
Typing 'cast' by itself will give you a list of spells you know.
|
||||
|
|
@ -818,7 +824,7 @@ class CmdCast(MuxCommand):
|
|||
def func(self):
|
||||
"""
|
||||
This performs the actual command.
|
||||
|
||||
|
||||
Note: This is a quite long command, since it has to cope with all
|
||||
the different circumstances in which you may or may not be able
|
||||
to cast a spell. None of the spell's effects are handled by the
|
||||
|
|
@ -1123,7 +1129,7 @@ in the docstring for each function.
|
|||
def spell_healing(caster, spell_name, targets, cost, **kwargs):
|
||||
"""
|
||||
Spell that restores HP to a target or targets.
|
||||
|
||||
|
||||
kwargs:
|
||||
healing_range (tuple): Minimum and maximum amount healed to
|
||||
each target. (20, 40) by default.
|
||||
|
|
@ -1156,7 +1162,7 @@ def spell_healing(caster, spell_name, targets, cost, **kwargs):
|
|||
def spell_attack(caster, spell_name, targets, cost, **kwargs):
|
||||
"""
|
||||
Spell that deals damage in combat. Similar to resolve_attack.
|
||||
|
||||
|
||||
kwargs:
|
||||
attack_name (tuple): Single and plural describing the sort of
|
||||
attack or projectile that strikes each enemy.
|
||||
|
|
@ -1250,12 +1256,12 @@ def spell_attack(caster, spell_name, targets, cost, **kwargs):
|
|||
def spell_conjure(caster, spell_name, targets, cost, **kwargs):
|
||||
"""
|
||||
Spell that creates an object.
|
||||
|
||||
|
||||
kwargs:
|
||||
obj_key (str): Key of the created object.
|
||||
obj_desc (str): Desc of the created object.
|
||||
obj_typeclass (str): Typeclass path of the object.
|
||||
|
||||
|
||||
If you want to make more use of this particular spell funciton,
|
||||
you may want to modify it to use the spawner (in evennia.utils.spawner)
|
||||
instead of creating objects directly.
|
||||
|
|
@ -1300,7 +1306,7 @@ parameters, some of which are required and others which are optional.
|
|||
|
||||
Required values for spells:
|
||||
|
||||
cost (int): MP cost of casting the spell
|
||||
cost (int): MP cost of casting the spell
|
||||
target (str): Valid targets for the spell. Can be any of:
|
||||
"none" - No target needed
|
||||
"self" - Self only
|
||||
|
|
@ -1312,9 +1318,9 @@ Required values for spells:
|
|||
spellfunc (callable): Function that performs the action of the spell.
|
||||
Must take the following arguments: caster (obj), spell_name (str),
|
||||
targets (list), and cost (int), as well as **kwargs.
|
||||
|
||||
|
||||
Optional values for spells:
|
||||
|
||||
|
||||
combat_spell (bool): If the spell can be cast in combat. True by default.
|
||||
noncombat_spell (bool): If the spell can be cast out of combat. True by default.
|
||||
max_targets (int): Maximum number of objects that can be targeted by the spell.
|
||||
|
|
|
|||
|
|
@ -674,11 +674,11 @@ class TBRangeTurnHandler(DefaultScript):
|
|||
"""
|
||||
combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case.
|
||||
character.db.combat_actionsleft = (
|
||||
0
|
||||
) # Actions remaining - start of turn adds to this, turn ends when it reaches 0
|
||||
0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0
|
||||
)
|
||||
character.db.combat_turnhandler = (
|
||||
self
|
||||
) # Add a reference to this turn handler script to the character
|
||||
self # Add a reference to this turn handler script to the character
|
||||
)
|
||||
character.db.combat_lastaction = "null" # Track last action taken in combat
|
||||
|
||||
def start_turn(self, character):
|
||||
|
|
|
|||
|
|
@ -26,3 +26,16 @@ def at_webserver_root_creation(web_root):
|
|||
|
||||
"""
|
||||
return web_root
|
||||
|
||||
|
||||
def at_webproxy_root_creation(web_root):
|
||||
"""
|
||||
This function can modify the portal proxy service.
|
||||
Args:
|
||||
web_root (evennia.server.webserver.Website): The Evennia
|
||||
Website application. Use .putChild() to add new
|
||||
subdomains that are Portal-accessible over TCP;
|
||||
primarily for new protocol development, but suitable
|
||||
for other shenanigans.
|
||||
"""
|
||||
return web_root
|
||||
|
|
|
|||
|
|
@ -547,11 +547,39 @@ def inside(accessing_obj, accessed_obj, *args, **kwargs):
|
|||
Usage:
|
||||
inside()
|
||||
|
||||
Only true if accessing_obj is "inside" accessed_obj
|
||||
True if accessing_obj is 'inside' accessing_obj. Note that this only checks
|
||||
one level down. So if if the lock is on a room, you will pass but not your
|
||||
inventory (since their location is you, not the locked object). If you
|
||||
want also nested objects to pass the lock, use the `insiderecursive`
|
||||
lockfunc.
|
||||
"""
|
||||
return accessing_obj.location == accessed_obj
|
||||
|
||||
|
||||
def inside_rec(accessing_obj, accessed_obj, *args, **kwargs):
|
||||
"""
|
||||
Usage:
|
||||
inside_rec()
|
||||
|
||||
True if accessing_obj is inside the accessed obj, at up to 10 levels
|
||||
of recursion (so if this lock is on a room, then an object inside a box
|
||||
in your inventory will also pass the lock).
|
||||
"""
|
||||
|
||||
def _recursive_inside(obj, accessed_obj, lvl=1):
|
||||
if obj.location:
|
||||
if obj.location == accessed_obj:
|
||||
return True
|
||||
elif lvl >= 10:
|
||||
# avoid infinite recursions
|
||||
return False
|
||||
else:
|
||||
return _recursive_inside(obj.location, accessed_obj, lvl + 1)
|
||||
return False
|
||||
|
||||
return _recursive_inside(accessing_obj, accessed_obj)
|
||||
|
||||
|
||||
def holds(accessing_obj, accessed_obj, *args, **kwargs):
|
||||
"""
|
||||
Usage:
|
||||
|
|
@ -604,7 +632,7 @@ def holds(accessing_obj, accessed_obj, *args, **kwargs):
|
|||
if len(args) == 1:
|
||||
# command is holds(dbref/key) - check if given objname/dbref is held by accessing_ob
|
||||
return check_holds(args[0])
|
||||
elif len(args=2):
|
||||
elif len(args) > 1:
|
||||
# command is holds(attrname, value) check if any held object has the given attribute and value
|
||||
for obj in contents:
|
||||
if obj.attributes.get(args[0]) == args[1]:
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ to any other identifier you can use.
|
|||
import re
|
||||
from django.conf import settings
|
||||
from evennia.utils import logger, utils
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
__all__ = ("LockHandler", "LockException")
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ except ImportError:
|
|||
|
||||
from evennia import settings_default
|
||||
from evennia.locks import lockfuncs
|
||||
from evennia.utils.create import create_object
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Lock testing
|
||||
|
|
@ -179,6 +180,13 @@ class TestLockfuncs(EvenniaTest):
|
|||
self.assertEqual(False, lockfuncs.inside(self.char1, self.room2))
|
||||
self.assertEqual(True, lockfuncs.holds(self.room1, self.char1))
|
||||
self.assertEqual(False, lockfuncs.holds(self.room2, self.char1))
|
||||
# test recursively
|
||||
self.assertEqual(True, lockfuncs.inside_rec(self.char1, self.room1))
|
||||
self.assertEqual(False, lockfuncs.inside_rec(self.char1, self.room2))
|
||||
inventory_item = create_object(key="InsideTester", location=self.char1)
|
||||
self.assertEqual(True, lockfuncs.inside_rec(inventory_item, self.room1))
|
||||
self.assertEqual(False, lockfuncs.inside_rec(inventory_item, self.room2))
|
||||
inventory_item.delete()
|
||||
|
||||
def test_has_account(self):
|
||||
self.assertEqual(True, lockfuncs.has_account(self.char1, None))
|
||||
|
|
|
|||
|
|
@ -575,8 +575,10 @@ class ObjectDBManager(TypedObjectManager):
|
|||
return None
|
||||
|
||||
# copy over all attributes from old to new.
|
||||
for attr in original_object.attributes.all():
|
||||
new_object.attributes.add(attr.key, attr.value)
|
||||
attrs = (
|
||||
(a.key, a.value, a.category, a.lock_storage) for a in original_object.attributes.all()
|
||||
)
|
||||
new_object.attributes.batch_add(*attrs)
|
||||
|
||||
# copy over all cmdsets, if any
|
||||
for icmdset, cmdset in enumerate(original_object.cmdset.all()):
|
||||
|
|
@ -590,8 +592,10 @@ class ObjectDBManager(TypedObjectManager):
|
|||
ScriptDB.objects.copy_script(script, new_obj=new_object)
|
||||
|
||||
# copy over all tags, if any
|
||||
for tag in original_object.tags.get(return_tagobj=True, return_list=True):
|
||||
new_object.tags.add(tag=tag.db_key, category=tag.db_category, data=tag.db_data)
|
||||
tags = (
|
||||
(t.db_key, t.db_category, t.db_data) for t in original_object.tags.all(return_objs=True)
|
||||
)
|
||||
new_object.tags.batch_add(*tags)
|
||||
|
||||
return new_object
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ from evennia.utils.utils import (
|
|||
list_to_string,
|
||||
to_str,
|
||||
)
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
_INFLECT = inflect.engine()
|
||||
_MULTISESSION_MODE = settings.MULTISESSION_MODE
|
||||
|
|
@ -341,8 +341,12 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
|||
"""
|
||||
key = kwargs.get("key", self.key)
|
||||
key = ansi.ANSIString(key) # this is needed to allow inflection of colored names
|
||||
plural = _INFLECT.plural(key, 2)
|
||||
plural = "%s %s" % (_INFLECT.number_to_words(count, threshold=12), plural)
|
||||
try:
|
||||
plural = _INFLECT.plural(key, 2)
|
||||
plural = "%s %s" % (_INFLECT.number_to_words(count, threshold=12), plural)
|
||||
except IndexError:
|
||||
# this is raised by inflect if the input is not a proper noun
|
||||
plural = key
|
||||
singular = _INFLECT.an(key)
|
||||
if not self.aliases.get(plural, category="plural_key"):
|
||||
# we need to wipe any old plurals/an/a in case key changed in the interrim
|
||||
|
|
@ -2062,9 +2066,6 @@ class DefaultCharacter(DefaultObject):
|
|||
# Set the supplied key as the name of the intended object
|
||||
kwargs["key"] = key
|
||||
|
||||
# Get home for character
|
||||
kwargs["home"] = ObjectDB.objects.get_id(kwargs.get("home", settings.DEFAULT_HOME))
|
||||
|
||||
# Get permissions
|
||||
kwargs["permissions"] = kwargs.get("permissions", settings.PERMISSION_ACCOUNT_DEFAULT)
|
||||
|
||||
|
|
@ -2076,9 +2077,10 @@ class DefaultCharacter(DefaultObject):
|
|||
|
||||
try:
|
||||
# Check to make sure account does not have too many chars
|
||||
if len(account.characters) >= settings.MAX_NR_CHARACTERS:
|
||||
errors.append("There are too many characters associated with this account.")
|
||||
return obj, errors
|
||||
if account:
|
||||
if len(account.characters) >= settings.MAX_NR_CHARACTERS:
|
||||
errors.append("There are too many characters associated with this account.")
|
||||
return obj, errors
|
||||
|
||||
# Create the Character
|
||||
obj = create.create_object(**kwargs)
|
||||
|
|
@ -2524,10 +2526,10 @@ class DefaultExit(DefaultObject):
|
|||
[
|
||||
"puppet:false()", # would be weird to puppet an exit ...
|
||||
"traverse:all()", # who can pass through exit by default
|
||||
"get:false()",
|
||||
"get:false()", # noone can pick up the exit
|
||||
]
|
||||
)
|
||||
) # noone can pick up the exit
|
||||
)
|
||||
|
||||
# an exit should have a destination (this is replaced at creation time)
|
||||
if self.location:
|
||||
|
|
|
|||
|
|
@ -9,23 +9,35 @@ class DefaultObjectTest(EvenniaTest):
|
|||
|
||||
def test_object_create(self):
|
||||
description = "A home for a grouch."
|
||||
home = self.room1.dbref
|
||||
|
||||
obj, errors = DefaultObject.create(
|
||||
"trashcan", self.account, description=description, ip=self.ip
|
||||
"trashcan", self.account, description=description, ip=self.ip, home=home
|
||||
)
|
||||
self.assertTrue(obj, errors)
|
||||
self.assertFalse(errors, errors)
|
||||
self.assertEqual(description, obj.db.desc)
|
||||
self.assertEqual(obj.db.creator_ip, self.ip)
|
||||
self.assertEqual(obj.db_home, self.room1)
|
||||
|
||||
def test_character_create(self):
|
||||
description = "A furry green monster, reeking of garbage."
|
||||
home = self.room1.dbref
|
||||
|
||||
obj, errors = DefaultCharacter.create(
|
||||
"oscar", self.account, description=description, ip=self.ip
|
||||
"oscar", self.account, description=description, ip=self.ip, home=home
|
||||
)
|
||||
self.assertTrue(obj, errors)
|
||||
self.assertFalse(errors, errors)
|
||||
self.assertEqual(description, obj.db.desc)
|
||||
self.assertEqual(obj.db.creator_ip, self.ip)
|
||||
self.assertEqual(obj.db_home, self.room1)
|
||||
|
||||
def test_character_create_noaccount(self):
|
||||
obj, errors = DefaultCharacter.create("oscar", None, home=self.room1.dbref)
|
||||
self.assertTrue(obj, errors)
|
||||
self.assertFalse(errors, errors)
|
||||
self.assertEqual(obj.db_home, self.room1)
|
||||
|
||||
def test_room_create(self):
|
||||
description = "A dimly-lit alley behind the local Chinese restaurant."
|
||||
|
|
@ -101,3 +113,27 @@ class TestObjectManager(EvenniaTest):
|
|||
self.assertEqual(list(query), [self.obj1])
|
||||
query = ObjectDB.objects.get_objs_with_attr("NotFound", candidates=[self.char1, self.obj1])
|
||||
self.assertFalse(query)
|
||||
|
||||
def test_copy_object(self):
|
||||
"Test that all attributes and tags properly copy across objects"
|
||||
|
||||
# Add some tags
|
||||
self.obj1.tags.add("plugh", category="adventure")
|
||||
self.obj1.tags.add("xyzzy")
|
||||
|
||||
# Add some attributes
|
||||
self.obj1.attributes.add("phrase", "plugh", category="adventure")
|
||||
self.obj1.attributes.add("phrase", "xyzzy")
|
||||
|
||||
# Create object copy
|
||||
obj2 = self.obj1.copy()
|
||||
|
||||
# Make sure each of the tags were replicated
|
||||
self.assertTrue("plugh" in obj2.tags.all())
|
||||
self.assertTrue("plugh" in obj2.tags.get(category="adventure"))
|
||||
self.assertTrue("xyzzy" in obj2.tags.all())
|
||||
|
||||
# Make sure each of the attributes were replicated
|
||||
self.assertEqual(obj2.attributes.get(key="phrase"), "xyzzy")
|
||||
self.assertEqual(self.obj1.attributes.get(key="phrase", category="adventure"), "plugh")
|
||||
self.assertEqual(obj2.attributes.get(key="phrase", category="adventure"), "plugh")
|
||||
|
|
|
|||
|
|
@ -1235,7 +1235,7 @@ def _attr_select(caller, attrstr):
|
|||
|
||||
attr_tup = _get_tup_by_attrname(caller, attrname)
|
||||
if attr_tup:
|
||||
return "node_examine_entity", {"text": _display_attribute(attr_tup), "back": "attrs"}
|
||||
return ("node_examine_entity", {"text": _display_attribute(attr_tup), "back": "attrs"})
|
||||
else:
|
||||
caller.msg("Attribute not found.")
|
||||
return "node_attrs"
|
||||
|
|
@ -1260,7 +1260,7 @@ def _attrs_actions(caller, raw_inp, **kwargs):
|
|||
|
||||
if action and attr_tup:
|
||||
if action == "examine":
|
||||
return "node_examine_entity", {"text": _display_attribute(attr_tup), "back": "attrs"}
|
||||
return ("node_examine_entity", {"text": _display_attribute(attr_tup), "back": "attrs"})
|
||||
elif action == "remove":
|
||||
res = _add_attr(caller, attrname, delete=True)
|
||||
caller.msg(res)
|
||||
|
|
@ -1439,7 +1439,7 @@ def _tags_actions(caller, raw_inp, **kwargs):
|
|||
|
||||
if tag_tup:
|
||||
if action == "examine":
|
||||
return "node_examine_entity", {"text": _display_tag(tag_tup), "back": "tags"}
|
||||
return ("node_examine_entity", {"text": _display_tag(tag_tup), "back": "tags"})
|
||||
elif action == "remove":
|
||||
res = _add_tag(caller, tagname, delete=True)
|
||||
caller.msg(res)
|
||||
|
|
@ -1510,7 +1510,7 @@ def _locks_display(caller, lock):
|
|||
|
||||
|
||||
def _lock_select(caller, lockstr):
|
||||
return "node_examine_entity", {"text": _locks_display(caller, lockstr), "back": "locks"}
|
||||
return ("node_examine_entity", {"text": _locks_display(caller, lockstr), "back": "locks"})
|
||||
|
||||
|
||||
def _lock_add(caller, lock, **kwargs):
|
||||
|
|
@ -1552,7 +1552,7 @@ def _locks_actions(caller, raw_inp, **kwargs):
|
|||
|
||||
if lock:
|
||||
if action == "examine":
|
||||
return "node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"}
|
||||
return ("node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"})
|
||||
elif action == "remove":
|
||||
ret = _lock_add(caller, lock, delete=True)
|
||||
caller.msg(ret)
|
||||
|
|
@ -1645,7 +1645,10 @@ def _display_perm(caller, permission, only_hierarchy=False):
|
|||
|
||||
|
||||
def _permission_select(caller, permission, **kwargs):
|
||||
return "node_examine_entity", {"text": _display_perm(caller, permission), "back": "permissions"}
|
||||
return (
|
||||
"node_examine_entity",
|
||||
{"text": _display_perm(caller, permission), "back": "permissions"},
|
||||
)
|
||||
|
||||
|
||||
def _add_perm(caller, perm, **kwargs):
|
||||
|
|
@ -2051,7 +2054,7 @@ def _prototype_locks_actions(caller, raw_inp, **kwargs):
|
|||
|
||||
if lock:
|
||||
if action == "examine":
|
||||
return "node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"}
|
||||
return ("node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"})
|
||||
elif action == "remove":
|
||||
ret = _prototype_lock_add(caller, lock.strip(), delete=True)
|
||||
caller.msg(ret)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from evennia.scripts.models import ScriptDB
|
|||
from evennia.utils import create
|
||||
from evennia.utils import logger
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
|
||||
class ScriptHandler(object):
|
||||
|
|
@ -78,11 +78,20 @@ class ScriptHandler(object):
|
|||
scriptclass, key=key, account=self.obj, autostart=autostart
|
||||
)
|
||||
else:
|
||||
# the normal - adding to an Object
|
||||
script = create.create_script(scriptclass, key=key, obj=self.obj, autostart=autostart)
|
||||
# the normal - adding to an Object. We wait to autostart so we can differentiate
|
||||
# a failing creation from a script that immediately starts/stops.
|
||||
script = create.create_script(scriptclass, key=key, obj=self.obj, autostart=False)
|
||||
if not script:
|
||||
logger.log_err("Script %s could not be created and/or started." % scriptclass)
|
||||
logger.log_err("Script %s failed to be created/started." % scriptclass)
|
||||
return False
|
||||
if autostart:
|
||||
script.start()
|
||||
if not script.id:
|
||||
# this can happen if the script has repeats=1 or calls stop() in at_repeat.
|
||||
logger.log_info(
|
||||
"Script %s started and then immediately stopped; "
|
||||
"it could probably be a normal function." % scriptclass
|
||||
)
|
||||
return True
|
||||
|
||||
def start(self, key):
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ ability to run timers.
|
|||
from twisted.internet.defer import Deferred, maybeDeferred
|
||||
from twisted.internet.task import LoopingCall
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from evennia.typeclasses.models import TypeclassBase
|
||||
from evennia.scripts.models import ScriptDB
|
||||
from evennia.scripts.manager import ScriptManager
|
||||
|
|
@ -69,7 +69,7 @@ class ExtendedLoopingCall(LoopingCall):
|
|||
steps if we want.
|
||||
|
||||
"""
|
||||
assert not self.running, "Tried to start an already running " "ExtendedLoopingCall."
|
||||
assert not self.running, "Tried to start an already running ExtendedLoopingCall."
|
||||
if interval < 0:
|
||||
raise ValueError("interval must be >= 0")
|
||||
self.running = True
|
||||
|
|
@ -107,7 +107,8 @@ class ExtendedLoopingCall(LoopingCall):
|
|||
if self.start_delay:
|
||||
self.start_delay = None
|
||||
self.starttime = self.clock.seconds()
|
||||
LoopingCall.__call__(self)
|
||||
if self._deferred:
|
||||
LoopingCall.__call__(self)
|
||||
|
||||
def force_repeat(self):
|
||||
"""
|
||||
|
|
@ -118,7 +119,7 @@ class ExtendedLoopingCall(LoopingCall):
|
|||
running.
|
||||
|
||||
"""
|
||||
assert self.running, "Tried to fire an ExtendedLoopingCall " "that was not running."
|
||||
assert self.running, "Tried to fire an ExtendedLoopingCall that was not running."
|
||||
self.call.cancel()
|
||||
self.call = None
|
||||
self.starttime = self.clock.seconds()
|
||||
|
|
@ -135,11 +136,10 @@ class ExtendedLoopingCall(LoopingCall):
|
|||
the task is not running.
|
||||
|
||||
"""
|
||||
if self.running:
|
||||
if self.running and self.interval > 0:
|
||||
total_runtime = self.clock.seconds() - self.starttime
|
||||
interval = self.start_delay or self.interval
|
||||
return interval - (total_runtime % self.interval)
|
||||
return None
|
||||
|
||||
|
||||
class ScriptBase(ScriptDB, metaclass=TypeclassBase):
|
||||
|
|
@ -162,9 +162,8 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase):
|
|||
Start task runner.
|
||||
|
||||
"""
|
||||
if self.ndb._task:
|
||||
return
|
||||
self.ndb._task = ExtendedLoopingCall(self._step_task)
|
||||
if not self.ndb._task:
|
||||
self.ndb._task = ExtendedLoopingCall(self._step_task)
|
||||
|
||||
if self.db._paused_time:
|
||||
# the script was paused; restarting
|
||||
|
|
@ -174,7 +173,8 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase):
|
|||
)
|
||||
del self.db._paused_time
|
||||
del self.db._paused_repeats
|
||||
else:
|
||||
|
||||
elif not self.ndb._task.running:
|
||||
# starting script anew
|
||||
self.ndb._task.start(self.db_interval, now=not self.db_start_delay)
|
||||
|
||||
|
|
@ -186,6 +186,7 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase):
|
|||
task = self.ndb._task
|
||||
if task and task.running:
|
||||
task.stop()
|
||||
self.ndb._task = None
|
||||
|
||||
def _step_errback(self, e):
|
||||
"""
|
||||
|
|
@ -208,6 +209,9 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase):
|
|||
Step task runner. No try..except needed due to defer wrap.
|
||||
|
||||
"""
|
||||
if not self.ndb._task:
|
||||
# if there is no task, we have no business using this method
|
||||
return
|
||||
|
||||
if not self.is_valid():
|
||||
self.stop()
|
||||
|
|
@ -217,10 +221,13 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase):
|
|||
self.at_repeat()
|
||||
|
||||
# check repeats
|
||||
callcount = self.ndb._task.callcount
|
||||
maxcount = self.db_repeats
|
||||
if maxcount > 0 and maxcount <= callcount:
|
||||
self.stop()
|
||||
if self.ndb._task:
|
||||
# we need to check for the task in case stop() was called
|
||||
# inside at_repeat() and it already went away.
|
||||
callcount = self.ndb._task.callcount
|
||||
maxcount = self.db_repeats
|
||||
if maxcount > 0 and maxcount <= callcount:
|
||||
self.stop()
|
||||
|
||||
def _step_task(self):
|
||||
"""
|
||||
|
|
@ -267,13 +274,13 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase):
|
|||
self.db_key = cdict["key"]
|
||||
updates.append("db_key")
|
||||
if cdict.get("interval") and self.interval != cdict["interval"]:
|
||||
self.db_interval = cdict["interval"]
|
||||
self.db_interval = max(0, cdict["interval"])
|
||||
updates.append("db_interval")
|
||||
if cdict.get("start_delay") and self.start_delay != cdict["start_delay"]:
|
||||
self.db_start_delay = cdict["start_delay"]
|
||||
updates.append("db_start_delay")
|
||||
if cdict.get("repeats") and self.repeats != cdict["repeats"]:
|
||||
self.db_repeats = cdict["repeats"]
|
||||
self.db_repeats = max(0, cdict["repeats"])
|
||||
updates.append("db_repeats")
|
||||
if cdict.get("persistent") and self.persistent != cdict["persistent"]:
|
||||
self.db_persistent = cdict["persistent"]
|
||||
|
|
@ -338,9 +345,9 @@ class DefaultScript(ScriptBase):
|
|||
|
||||
try:
|
||||
obj = create.create_script(**kwargs)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
logger.log_trace()
|
||||
errors.append("The script '%s' encountered errors and could not be created." % key)
|
||||
logger.log_err(e)
|
||||
|
||||
return obj, errors
|
||||
|
||||
|
|
@ -562,11 +569,9 @@ class DefaultScript(ScriptBase):
|
|||
Restarts an already existing/running Script from the
|
||||
beginning, optionally using different settings. This will
|
||||
first call the stop hooks, and then the start hooks again.
|
||||
|
||||
Args:
|
||||
interval (int, optional): Allows for changing the interval
|
||||
of the Script. Given in seconds. if `None`, will use the
|
||||
already stored interval.
|
||||
of the Script. Given in seconds. if `None`, will use the already stored interval.
|
||||
repeats (int, optional): The number of repeats. If unset, will
|
||||
use the previous setting.
|
||||
start_delay (bool, optional): If we should wait `interval` seconds
|
||||
|
|
@ -585,6 +590,7 @@ class DefaultScript(ScriptBase):
|
|||
del self.db._paused_callcount
|
||||
# set new flags and start over
|
||||
if interval is not None:
|
||||
interval = max(0, interval)
|
||||
self.interval = interval
|
||||
if repeats is not None:
|
||||
self.repeats = repeats
|
||||
|
|
|
|||
|
|
@ -96,6 +96,12 @@ def check_errors(settings):
|
|||
"must now be either None or a dict "
|
||||
"specifying the properties of the channel to create."
|
||||
)
|
||||
if hasattr(settings, "CYCLE_LOGFILES"):
|
||||
raise DeprecationWarning(
|
||||
"settings.CYCLE_LOGFILES is unused and should be removed. "
|
||||
"Use PORTAL/SERVER_LOG_DAY_ROTATION and PORTAL/SERVER_LOG_MAX_SIZE "
|
||||
"to control log cycling."
|
||||
)
|
||||
|
||||
|
||||
def check_warnings(settings):
|
||||
|
|
@ -109,3 +115,10 @@ def check_warnings(settings):
|
|||
print(" [Devel: settings.IN_GAME_ERRORS is True. Turn off in production.]")
|
||||
if settings.ALLOWED_HOSTS == ["*"]:
|
||||
print(" [Devel: settings.ALLOWED_HOSTS set to '*' (all). Limit in production.]")
|
||||
for dbentry in settings.DATABASES.values():
|
||||
if "psycopg" in dbentry.get("ENGINE", ""):
|
||||
print(
|
||||
'Deprecation: postgresql_psycopg2 backend is deprecated". '
|
||||
"Switch settings.DATABASES to use "
|
||||
'"ENGINE": "django.db.backends.postgresql instead"'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from twisted.protocols import amp
|
|||
from twisted.internet import reactor, endpoints
|
||||
import django
|
||||
from django.core.management import execute_from_command_line
|
||||
from django.db.utils import ProgrammingError
|
||||
|
||||
# Signal processing
|
||||
SIG = signal.SIGINT
|
||||
|
|
@ -93,7 +94,7 @@ SRESET = chr(19) # shutdown server in reset mode
|
|||
PYTHON_MIN = "3.7"
|
||||
TWISTED_MIN = "18.0.0"
|
||||
DJANGO_MIN = "2.1"
|
||||
DJANGO_REC = "2.2.5"
|
||||
DJANGO_REC = "2.2"
|
||||
|
||||
try:
|
||||
sys.path[1] = EVENNIA_ROOT
|
||||
|
|
@ -1152,7 +1153,7 @@ def tail_log_files(filename1, filename2, start_lines1=20, start_lines2=20, rate=
|
|||
# this happens if the file was cycled or manually deleted/edited.
|
||||
print(
|
||||
" ** Log file {filename} has cycled or been edited. "
|
||||
"Restarting log. ".format(filehandle.name)
|
||||
"Restarting log. ".format(filename=filehandle.name)
|
||||
)
|
||||
new_linecount = 0
|
||||
old_linecount = 0
|
||||
|
|
@ -1280,7 +1281,7 @@ def check_main_evennia_dependencies():
|
|||
try:
|
||||
dversion = ".".join(str(num) for num in django.VERSION if isinstance(num, int))
|
||||
# only the main version (1.5, not 1.5.4.0)
|
||||
dversion_main = ".".join(dversion.split(".")[:3])
|
||||
dversion_main = ".".join(dversion.split(".")[:2])
|
||||
if LooseVersion(dversion) < LooseVersion(DJANGO_MIN):
|
||||
print(ERROR_DJANGO_MIN.format(dversion=dversion_main, django_min=DJANGO_MIN))
|
||||
error = True
|
||||
|
|
@ -1428,12 +1429,17 @@ def create_superuser():
|
|||
django.core.management.call_command("createsuperuser", interactive=True)
|
||||
|
||||
|
||||
def check_database():
|
||||
def check_database(always_return=False):
|
||||
"""
|
||||
Check so the database exists.
|
||||
|
||||
Args:
|
||||
always_return (bool, optional): If set, will always return True/False
|
||||
also on critical errors. No output will be printed.
|
||||
Returns:
|
||||
exists (bool): `True` if the database exists, otherwise `False`.
|
||||
|
||||
|
||||
"""
|
||||
# Check so a database exists and is accessible
|
||||
from django.db import connection
|
||||
|
|
@ -1449,7 +1455,9 @@ def check_database():
|
|||
|
||||
try:
|
||||
AccountDB.objects.get(id=1)
|
||||
except django.db.utils.OperationalError as e:
|
||||
except (django.db.utils.OperationalError, ProgrammingError) as e:
|
||||
if always_return:
|
||||
return False
|
||||
print(ERROR_DATABASE.format(traceback=e))
|
||||
sys.exit()
|
||||
except AccountDB.DoesNotExist:
|
||||
|
|
@ -1484,7 +1492,7 @@ def check_database():
|
|||
new.save()
|
||||
else:
|
||||
create_superuser()
|
||||
check_database()
|
||||
check_database(always_return=always_return)
|
||||
return True
|
||||
|
||||
|
||||
|
|
@ -2246,14 +2254,15 @@ def main():
|
|||
# pass-through to django manager, but set things up first
|
||||
check_db = False
|
||||
need_gamedir = True
|
||||
# some commands don't require the presence of a game directory to work
|
||||
if option in ("makemessages", "compilemessages"):
|
||||
need_gamedir = False
|
||||
|
||||
# handle special django commands
|
||||
if option in ("runserver", "testserver"):
|
||||
# we don't want the django test-webserver
|
||||
print(WARNING_RUNSERVER)
|
||||
if option in ("shell", "check"):
|
||||
if option in ("makemessages", "compilemessages"):
|
||||
# some commands don't require the presence of a game directory to work
|
||||
need_gamedir = False
|
||||
if option in ("shell", "check", "makemigrations"):
|
||||
# some django commands requires the database to exist,
|
||||
# or evennia._init to have run before they work right.
|
||||
check_db = True
|
||||
|
|
@ -2263,16 +2272,17 @@ def main():
|
|||
|
||||
init_game_directory(CURRENT_DIR, check_db=check_db, need_gamedir=need_gamedir)
|
||||
|
||||
if option in ("migrate", "makemigrations"):
|
||||
# we have to launch migrate within the program to make sure migrations
|
||||
# run within the scope of the launcher (otherwise missing a db will cause errors)
|
||||
django.core.management.call_command(*([option] + unknown_args))
|
||||
else:
|
||||
# pass on to the core django manager - re-parse the entire input line
|
||||
# but keep 'evennia' as the name instead of django-admin. This is
|
||||
# an exit condition.
|
||||
sys.argv[0] = re.sub(r"(-script\.pyw?|\.exe)?$", "", sys.argv[0])
|
||||
sys.exit(execute_from_command_line())
|
||||
if option == "migrate":
|
||||
# we need to bypass some checks here for the first db creation
|
||||
if not check_database(always_return=True):
|
||||
django.core.management.call_command(*([option] + unknown_args))
|
||||
sys.exit(0)
|
||||
|
||||
# pass on to the core django manager - re-parse the entire input line
|
||||
# but keep 'evennia' as the name instead of django-admin. This is
|
||||
# an exit condition.
|
||||
sys.argv[0] = re.sub(r"(-script\.pyw?|\.exe)?$", "", sys.argv[0])
|
||||
sys.exit(execute_from_command_line())
|
||||
|
||||
elif not args.tail_log:
|
||||
# no input; print evennia info (don't pring if we're tailing log)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
# Evennia Game Index Client
|
||||
|
||||
Greg Taylor 2016
|
||||
Greg Taylor 2016, Griatch 2020
|
||||
|
||||
This contrib features a client for the [Evennia Game Index]
|
||||
This is a client for the [Evennia Game Index]
|
||||
(http://evennia-game-index.appspot.com/), a listing of games built on
|
||||
Evennia. By listing your game on the index, you make it easy for other
|
||||
people in the community to discover your creation.
|
||||
|
|
@ -14,74 +14,24 @@ on remedying this.*
|
|||
|
||||
## Listing your Game
|
||||
|
||||
To list your game, you'll need to enable the Evennia Game Index client.
|
||||
Start by `cd`'ing to your game directory. From there, open up
|
||||
`server/conf/server_services_plugins.py`. It might look something like this
|
||||
if you don't have any other optional add-ons enabled:
|
||||
To list your game, go to your game dir and run
|
||||
|
||||
```python
|
||||
"""
|
||||
Server plugin services
|
||||
evennia connections
|
||||
|
||||
This plugin module can define user-created services for the Server to
|
||||
start.
|
||||
Follow the prompts to add details to the listing. Use `evennia reload`. In your log (visible with `evennia --log`
|
||||
you should see a note that info has been sent to the game index.
|
||||
|
||||
This module must handle all imports and setups required to start a
|
||||
twisted service (see examples in evennia.server.server). It must also
|
||||
contain a function start_plugin_services(application). Evennia will
|
||||
call this function with the main Server application (so your services
|
||||
can be added to it). The function should not return anything. Plugin
|
||||
services are started last in the Server startup process.
|
||||
"""
|
||||
## Detailed settings
|
||||
|
||||
|
||||
def start_plugin_services(server):
|
||||
"""
|
||||
This hook is called by Evennia, last in the Server startup process.
|
||||
|
||||
server - a reference to the main server application.
|
||||
"""
|
||||
pass
|
||||
```
|
||||
|
||||
To enable the client, import `EvenniaGameIndexService` and fire it up after the
|
||||
Evennia server has finished starting:
|
||||
|
||||
```python
|
||||
"""
|
||||
Server plugin services
|
||||
|
||||
This plugin module can define user-created services for the Server to
|
||||
start.
|
||||
|
||||
This module must handle all imports and setups required to start a
|
||||
twisted service (see examples in evennia.server.server). It must also
|
||||
contain a function start_plugin_services(application). Evennia will
|
||||
call this function with the main Server application (so your services
|
||||
can be added to it). The function should not return anything. Plugin
|
||||
services are started last in the Server startup process.
|
||||
"""
|
||||
|
||||
from evennia.contrib.egi_client import EvenniaGameIndexService
|
||||
|
||||
def start_plugin_services(server):
|
||||
"""
|
||||
This hook is called by Evennia, last in the Server startup process.
|
||||
|
||||
server - a reference to the main server application.
|
||||
"""
|
||||
egi_service = EvenniaGameIndexService()
|
||||
server.services.addService(egi_service)
|
||||
```
|
||||
|
||||
Next, configure your game listing by opening up `server/conf/settings.py` and
|
||||
If you don't want to use the wizard you can configure your game listing by opening up `server/conf/settings.py` and
|
||||
using the following as a starting point:
|
||||
|
||||
```python
|
||||
######################################################################
|
||||
# Contrib config
|
||||
# Game index
|
||||
######################################################################
|
||||
|
||||
GAME_INDEX_ENABLED = True
|
||||
GAME_INDEX_LISTING = {
|
||||
'game_status': 'pre-alpha',
|
||||
# Optional, comment out or remove if N/A
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ Everything starts at handle_setup()
|
|||
|
||||
import time
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from evennia.accounts.models import AccountDB
|
||||
from evennia.server.models import ServerConfig
|
||||
from evennia.utils import create, logger
|
||||
|
|
|
|||
|
|
@ -576,8 +576,7 @@ def msdp_list(session, *args, **kwargs):
|
|||
fieldnames = [tup[1] for tup in monitor_infos]
|
||||
session.msg(reported_variables=(fieldnames, {}))
|
||||
if "sendable_variables" in args_lower:
|
||||
# no default sendable variables
|
||||
session.msg(sendable_variables=([], {}))
|
||||
session.msg(sendable_variables=(_monitorable, {}))
|
||||
|
||||
|
||||
def msdp_report(session, *args, **kwargs):
|
||||
|
|
@ -597,6 +596,17 @@ def msdp_unreport(session, *args, **kwargs):
|
|||
unmonitor(session, *args, **kwargs)
|
||||
|
||||
|
||||
def msdp_send(session, *args, **kwargs):
|
||||
"""
|
||||
MSDP SEND command
|
||||
"""
|
||||
out = {}
|
||||
for varname in args:
|
||||
if varname.lower() in _monitorable:
|
||||
out[varname] = _monitorable[varname.lower()]
|
||||
session.msg(send=((), out))
|
||||
|
||||
|
||||
# client specific
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -314,7 +314,9 @@ class AMPMultiConnectionProtocol(amp.AMP):
|
|||
try:
|
||||
super(AMPMultiConnectionProtocol, self).dataReceived(data)
|
||||
except KeyError:
|
||||
_get_logger().log_trace("Discarded incoming partial data: {}".format(to_str(data)))
|
||||
_get_logger().log_trace(
|
||||
"Discarded incoming partial (packed) data (len {})".format(len(data))
|
||||
)
|
||||
elif self.multibatches:
|
||||
# invalid AMP, but we have a pending multi-batch that is not yet complete
|
||||
if data[-2:] == NULNUL:
|
||||
|
|
@ -323,7 +325,9 @@ class AMPMultiConnectionProtocol(amp.AMP):
|
|||
try:
|
||||
super(AMPMultiConnectionProtocol, self).dataReceived(data)
|
||||
except KeyError:
|
||||
_get_logger().log_trace("Discarded incoming multi-batch data:".format(to_str(data)))
|
||||
_get_logger().log_trace(
|
||||
"Discarded incoming multi-batch (packed) data (len {})".format(len(data))
|
||||
)
|
||||
else:
|
||||
# not an AMP communication, return warning
|
||||
self.transport.write(_HTTP_WARNING)
|
||||
|
|
|
|||
|
|
@ -15,9 +15,10 @@ This protocol is implemented by the telnet protocol importing
|
|||
mccp_compress and calling it from its write methods.
|
||||
"""
|
||||
import zlib
|
||||
from twisted.python.compat import _bytesChr as chr
|
||||
|
||||
# negotiations for v1 and v2 of the protocol
|
||||
MCCP = b"\x56"
|
||||
MCCP = chr(86) # b"\x56"
|
||||
FLUSH = zlib.Z_SYNC_FLUSH
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -12,10 +12,11 @@ active players and so on.
|
|||
"""
|
||||
from django.conf import settings
|
||||
from evennia.utils import utils
|
||||
from twisted.python.compat import _bytesChr as bchr
|
||||
|
||||
MSSP = b"\x46"
|
||||
MSSP_VAR = b"\x01"
|
||||
MSSP_VAL = b"\x02"
|
||||
MSSP = bchr(70) # b"\x46"
|
||||
MSSP_VAR = bchr(1) # b"\x01"
|
||||
MSSP_VAL = bchr(2) # b"\x02"
|
||||
|
||||
# try to get the customized mssp info, if it exists.
|
||||
MSSPTable_CUSTOM = utils.variable_from_module(settings.MSSP_META_MODULE, "MSSPTable", default={})
|
||||
|
|
@ -86,7 +87,7 @@ class Mssp(object):
|
|||
"PLAYERS": self.get_player_count,
|
||||
"UPTIME": self.get_uptime,
|
||||
"PORT": list(
|
||||
reversed(settings.TELNET_PORTS)
|
||||
str(port) for port in reversed(settings.TELNET_PORTS)
|
||||
), # most important port should be last in list
|
||||
# Evennia auto-filled
|
||||
"CRAWL DELAY": "-1",
|
||||
|
|
@ -119,10 +120,15 @@ class Mssp(object):
|
|||
if utils.is_iter(value):
|
||||
for partval in value:
|
||||
varlist += (
|
||||
MSSP_VAR + bytes(variable, "utf-8") + MSSP_VAL + bytes(partval, "utf-8")
|
||||
MSSP_VAR
|
||||
+ bytes(str(variable), "utf-8")
|
||||
+ MSSP_VAL
|
||||
+ bytes(str(partval), "utf-8")
|
||||
)
|
||||
else:
|
||||
varlist += MSSP_VAR + bytes(variable, "utf-8") + MSSP_VAL + bytes(value, "utf-8")
|
||||
varlist += (
|
||||
MSSP_VAR + bytes(str(variable), "utf-8") + MSSP_VAL + bytes(str(value), "utf-8")
|
||||
)
|
||||
|
||||
# send to crawler by subnegotiation
|
||||
self.protocol.requestNegotiation(MSSP, varlist)
|
||||
|
|
|
|||
|
|
@ -14,11 +14,12 @@ http://www.gammon.com.au/mushclient/addingservermxp.htm
|
|||
|
||||
"""
|
||||
import re
|
||||
from twisted.python.compat import _bytesChr as bchr
|
||||
|
||||
LINKS_SUB = re.compile(r"\|lc(.*?)\|lt(.*?)\|le", re.DOTALL)
|
||||
|
||||
# MXP Telnet option
|
||||
MXP = b"\x5b"
|
||||
MXP = bchr(91) # b"\x5b"
|
||||
|
||||
MXP_TEMPSECURE = "\x1B[4z"
|
||||
MXP_SEND = MXP_TEMPSECURE + '<SEND HREF="\\1">' + "\\2" + MXP_TEMPSECURE + "</SEND>"
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ client and update it when the size changes
|
|||
"""
|
||||
from codecs import encode as codecs_encode
|
||||
from django.conf import settings
|
||||
from twisted.python.compat import _bytesChr as bchr
|
||||
|
||||
NAWS = b"\x1f"
|
||||
IS = b"\x00"
|
||||
NAWS = bchr(31) # b"\x1f"
|
||||
IS = bchr(0) # b"\x00"
|
||||
# default taken from telnet specification
|
||||
DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
|
||||
DEFAULT_HEIGHT = settings.CLIENT_DEFAULT_HEIGHT
|
||||
|
|
|
|||
|
|
@ -95,6 +95,16 @@ INFO_DICT = {
|
|||
"webserver_internal": [],
|
||||
}
|
||||
|
||||
try:
|
||||
WEB_PLUGINS_MODULE = mod_import(settings.WEB_PLUGINS_MODULE)
|
||||
except ImportError:
|
||||
WEB_PLUGINS_MODULE = None
|
||||
INFO_DICT["errors"] = (
|
||||
"WARNING: settings.WEB_PLUGINS_MODULE not found - "
|
||||
"copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf."
|
||||
)
|
||||
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# Portal Service object
|
||||
# -------------------------------------------------------------
|
||||
|
|
@ -190,7 +200,6 @@ class Portal(object):
|
|||
self.sessions.disconnect_all()
|
||||
if _stop_server:
|
||||
self.amp_protocol.stop_server(mode="shutdown")
|
||||
|
||||
if not _reactor_stopping:
|
||||
# shutting down the reactor will trigger another signal. We set
|
||||
# a flag to avoid loops.
|
||||
|
|
@ -213,7 +222,10 @@ application = service.Application("Portal")
|
|||
|
||||
if "--nodaemon" not in sys.argv:
|
||||
logfile = logger.WeeklyLogFile(
|
||||
os.path.basename(settings.PORTAL_LOG_FILE), os.path.dirname(settings.PORTAL_LOG_FILE)
|
||||
os.path.basename(settings.PORTAL_LOG_FILE),
|
||||
os.path.dirname(settings.PORTAL_LOG_FILE),
|
||||
day_rotation=settings.PORTAL_LOG_DAY_ROTATION,
|
||||
max_size=settings.PORTAL_LOG_MAX_SIZE,
|
||||
)
|
||||
application.setComponent(ILogObserver, logger.PortalLogObserver(logfile).emit)
|
||||
|
||||
|
|
@ -376,6 +388,14 @@ if WEBSERVER_ENABLED:
|
|||
webclientstr = "webclient-websocket%s: %s" % (w_ifacestr, port)
|
||||
INFO_DICT["webclient"].append(webclientstr)
|
||||
|
||||
if WEB_PLUGINS_MODULE:
|
||||
try:
|
||||
web_root = WEB_PLUGINS_MODULE.at_webproxy_root_creation(web_root)
|
||||
except Exception as e: # Legacy user has not added an at_webproxy_root_creation function in existing web plugins file
|
||||
INFO_DICT["errors"] = (
|
||||
"WARNING: WEB_PLUGINS_MODULE is enabled but at_webproxy_root_creation() not found - "
|
||||
"copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf."
|
||||
)
|
||||
web_root = Website(web_root, logPath=settings.HTTP_LOG_FILE)
|
||||
web_root.is_portal = True
|
||||
proxy_service = internet.TCPServer(proxyport, web_root, interface=interface)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ It is set as the NOGOAHEAD protocol_flag option.
|
|||
http://www.faqs.org/rfcs/rfc858.html
|
||||
|
||||
"""
|
||||
SUPPRESS_GA = b"\x03"
|
||||
from twisted.python.compat import _bytesChr as bchr
|
||||
|
||||
SUPPRESS_GA = bchr(3) # b"\x03"
|
||||
|
||||
# default taken from telnet specification
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,21 @@ _RE_SCREENREADER_REGEX = re.compile(
|
|||
)
|
||||
_IDLE_COMMAND = str.encode(settings.IDLE_COMMAND + "\n")
|
||||
|
||||
# identify HTTP indata
|
||||
_HTTP_REGEX = re.compile(
|
||||
b"(GET|HEAD|POST|PUT|DELETE|TRACE|OPTIONS|CONNECT|PATCH) (.*? HTTP/[0-9]\.[0-9])", re.I
|
||||
)
|
||||
|
||||
_HTTP_WARNING = bytes(
|
||||
"""
|
||||
This is Evennia's Telnet port and cannot be used for regular HTTP traffic.
|
||||
Use a telnet client to connect here and point your browser to the server's
|
||||
dedicated web port instead.
|
||||
|
||||
""".strip(),
|
||||
"utf-8",
|
||||
)
|
||||
|
||||
|
||||
class TelnetServerFactory(protocol.ServerFactory):
|
||||
"This is only to name this better in logs"
|
||||
|
|
@ -60,13 +75,21 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
|
|||
self.protocol_key = "telnet"
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def dataReceived(self, data):
|
||||
"""
|
||||
Unused by default, but a good place to put debug printouts
|
||||
of incoming data.
|
||||
"""
|
||||
# print(f"telnet dataReceived: {data}")
|
||||
super().dataReceived(data)
|
||||
|
||||
def connectionMade(self):
|
||||
"""
|
||||
This is called when the connection is first established.
|
||||
|
||||
"""
|
||||
# important in order to work normally with standard telnet
|
||||
self.do(LINEMODE)
|
||||
self.do(LINEMODE).addErrback(self._wont_linemode)
|
||||
# initialize the session
|
||||
self.line_buffer = b""
|
||||
client_address = self.transport.client
|
||||
|
|
@ -111,6 +134,14 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
|
|||
self.nop_keep_alive = None
|
||||
self.toggle_nop_keepalive()
|
||||
|
||||
def _wont_linemode(self, *args):
|
||||
"""
|
||||
Client refuses do(linemode). This is common for MUD-specific
|
||||
clients, but we must ask for the sake of raw telnet. We ignore
|
||||
this error.
|
||||
"""
|
||||
pass
|
||||
|
||||
def _send_nop_keepalive(self):
|
||||
"""Send NOP keepalive unless flag is set"""
|
||||
if self.protocol_flags.get("NOPKEEPALIVE"):
|
||||
|
|
@ -178,6 +209,16 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
|
|||
or option == suppress_ga.SUPPRESS_GA
|
||||
)
|
||||
|
||||
def disableRemote(self, option):
|
||||
return (
|
||||
option == LINEMODE
|
||||
or option == ttype.TTYPE
|
||||
or option == naws.NAWS
|
||||
or option == MCCP
|
||||
or option == mssp.MSSP
|
||||
or option == suppress_ga.SUPPRESS_GA
|
||||
)
|
||||
|
||||
def enableLocal(self, option):
|
||||
"""
|
||||
Call to allow the activation of options for this protocol
|
||||
|
|
@ -204,13 +245,20 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
|
|||
option (char): The telnet option to disable locally.
|
||||
|
||||
"""
|
||||
if option == LINEMODE:
|
||||
return True
|
||||
if option == ECHO:
|
||||
return True
|
||||
if option == MCCP:
|
||||
self.mccp.no_mccp(option)
|
||||
return True
|
||||
else:
|
||||
return super().disableLocal(option)
|
||||
try:
|
||||
return super().disableLocal(option)
|
||||
except Exception:
|
||||
from evennia.utils import logger
|
||||
|
||||
logger.log_trace()
|
||||
|
||||
def connectionLost(self, reason):
|
||||
"""
|
||||
|
|
@ -246,6 +294,14 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
|
|||
data = [_IDLE_COMMAND]
|
||||
else:
|
||||
data = _RE_LINEBREAK.split(data)
|
||||
|
||||
if len(data) > 2 and _HTTP_REGEX.match(data[0]):
|
||||
# guard against HTTP request on the Telnet port; we
|
||||
# block and kill the connection.
|
||||
self.transport.write(_HTTP_WARNING)
|
||||
self.transport.loseConnection()
|
||||
return
|
||||
|
||||
if self.line_buffer and len(data) > 1:
|
||||
# buffer exists, it is terminated by the first line feed
|
||||
data[0] = self.line_buffer + data[0]
|
||||
|
|
|
|||
|
|
@ -28,22 +28,24 @@ header where applicable.
|
|||
import re
|
||||
import json
|
||||
from evennia.utils.utils import is_iter
|
||||
|
||||
# MSDP-relevant telnet cmd/opt-codes
|
||||
MSDP = b"\x45"
|
||||
MSDP_VAR = b"\x01" # ^A
|
||||
MSDP_VAL = b"\x02" # ^B
|
||||
MSDP_TABLE_OPEN = b"\x03" # ^C
|
||||
MSDP_TABLE_CLOSE = b"\x04" # ^D
|
||||
MSDP_ARRAY_OPEN = b"\x05" # ^E
|
||||
MSDP_ARRAY_CLOSE = b"\x06" # ^F
|
||||
|
||||
# GMCP
|
||||
GMCP = b"\xc9"
|
||||
from twisted.python.compat import _bytesChr as bchr
|
||||
|
||||
# General Telnet
|
||||
from twisted.conch.telnet import IAC, SB, SE
|
||||
|
||||
# MSDP-relevant telnet cmd/opt-codes
|
||||
MSDP = bchr(69)
|
||||
MSDP_VAR = bchr(1)
|
||||
MSDP_VAL = bchr(2)
|
||||
MSDP_TABLE_OPEN = bchr(3)
|
||||
MSDP_TABLE_CLOSE = bchr(4)
|
||||
|
||||
MSDP_ARRAY_OPEN = bchr(5)
|
||||
MSDP_ARRAY_CLOSE = bchr(6)
|
||||
|
||||
# GMCP
|
||||
GMCP = bchr(201)
|
||||
|
||||
|
||||
# pre-compiled regexes
|
||||
# returns 2-tuple
|
||||
|
|
@ -168,7 +170,7 @@ class TelnetOOB(object):
|
|||
|
||||
"""
|
||||
msdp_cmdname = "{msdp_var}{msdp_cmdname}{msdp_val}".format(
|
||||
msdp_var=MSDP_VAR, msdp_cmdname=cmdname, msdp_val=MSDP_VAL
|
||||
msdp_var=MSDP_VAR.decode(), msdp_cmdname=cmdname, msdp_val=MSDP_VAL.decode()
|
||||
)
|
||||
|
||||
if not (args or kwargs):
|
||||
|
|
@ -186,9 +188,9 @@ class TelnetOOB(object):
|
|||
"{msdp_array_open}"
|
||||
"{msdp_args}"
|
||||
"{msdp_array_close}".format(
|
||||
msdp_array_open=MSDP_ARRAY_OPEN,
|
||||
msdp_array_close=MSDP_ARRAY_CLOSE,
|
||||
msdp_args="".join("%s%s" % (MSDP_VAL, json.dumps(val)) for val in args),
|
||||
msdp_array_open=MSDP_ARRAY_OPEN.decode(),
|
||||
msdp_array_close=MSDP_ARRAY_CLOSE.decode(),
|
||||
msdp_args="".join("%s%s" % (MSDP_VAL.decode(), val) for val in args),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -199,10 +201,10 @@ class TelnetOOB(object):
|
|||
"{msdp_table_open}"
|
||||
"{msdp_kwargs}"
|
||||
"{msdp_table_close}".format(
|
||||
msdp_table_open=MSDP_TABLE_OPEN,
|
||||
msdp_table_close=MSDP_TABLE_CLOSE,
|
||||
msdp_table_open=MSDP_TABLE_OPEN.decode(),
|
||||
msdp_table_close=MSDP_TABLE_CLOSE.decode(),
|
||||
msdp_kwargs="".join(
|
||||
"%s%s%s%s" % (MSDP_VAR, key, MSDP_VAL, json.dumps(val))
|
||||
"%s%s%s%s" % (MSDP_VAR.decode(), key, MSDP_VAL.decode(), val)
|
||||
for key, val in kwargs.items()
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -100,11 +100,11 @@ def verify_or_create_SSL_key_and_cert(keyfile, certfile):
|
|||
keypair.generate_key(crypto.TYPE_RSA, _PRIVATE_KEY_LENGTH)
|
||||
|
||||
with open(_PRIVATE_KEY_FILE, "wt") as pfile:
|
||||
pfile.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, keypair))
|
||||
pfile.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, keypair).decode("utf-8"))
|
||||
print("Created SSL private key in '{}'.".format(_PRIVATE_KEY_FILE))
|
||||
|
||||
with open(_PUBLIC_KEY_FILE, "wt") as pfile:
|
||||
pfile.write(crypto.dump_publickey(crypto.FILETYPE_PEM, keypair))
|
||||
pfile.write(crypto.dump_publickey(crypto.FILETYPE_PEM, keypair).decode("utf-8"))
|
||||
print("Created SSL public key in '{}'.".format(_PUBLIC_KEY_FILE))
|
||||
|
||||
except Exception as err:
|
||||
|
|
@ -128,7 +128,7 @@ def verify_or_create_SSL_key_and_cert(keyfile, certfile):
|
|||
cert.sign(keypair, "sha1")
|
||||
|
||||
with open(_CERTIFICATE_FILE, "wt") as cfile:
|
||||
cfile.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert))
|
||||
cfile.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode("utf-8"))
|
||||
print("Created SSL certificate in '{}'.".format(_CERTIFICATE_FILE))
|
||||
|
||||
except Exception as err:
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ except ImportError:
|
|||
import sys
|
||||
import string
|
||||
import mock
|
||||
import pickle
|
||||
from mock import Mock, MagicMock
|
||||
from evennia.server.portal import irc
|
||||
|
||||
|
|
@ -50,11 +51,21 @@ class TestAMPServer(TwistedTestCase):
|
|||
self.proto.makeConnection(self.transport)
|
||||
|
||||
self.proto.data_to_server(MsgServer2Portal, 1, test=2)
|
||||
byte_out = (
|
||||
b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgServer2Portal\x00\x0b"
|
||||
b"packed_data\x00 x\xdak`\x99*\xc8\x00\x01\xde\x8c\xb5SzXJR"
|
||||
b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00V:\x07t\x00\x00"
|
||||
)
|
||||
|
||||
if pickle.HIGHEST_PROTOCOL == 5:
|
||||
# Python 3.8+
|
||||
byte_out = (
|
||||
b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgServer2Portal\x00\x0b"
|
||||
b"packed_data\x00 x\xdak`\x9d*\xc8\x00\x01\xde\x8c\xb5SzXJR"
|
||||
b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00VU\x07u\x00\x00"
|
||||
)
|
||||
elif pickle.HIGHEST_PROTOCOL == 4:
|
||||
# Python 3.7
|
||||
byte_out = (
|
||||
b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgServer2Portal\x00\x0b"
|
||||
b"packed_data\x00 x\xdak`\x99*\xc8\x00\x01\xde\x8c\xb5SzXJR"
|
||||
b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00V:\x07t\x00\x00"
|
||||
)
|
||||
self.transport.write.assert_called_with(byte_out)
|
||||
with mock.patch("evennia.server.portal.amp.amp.AMP.dataReceived") as mocked_amprecv:
|
||||
self.proto.dataReceived(byte_out)
|
||||
|
|
@ -64,11 +75,20 @@ class TestAMPServer(TwistedTestCase):
|
|||
self.proto.makeConnection(self.transport)
|
||||
|
||||
self.proto.data_to_server(MsgPortal2Server, 1, test=2)
|
||||
byte_out = (
|
||||
b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgPortal2Server\x00\x0b"
|
||||
b"packed_data\x00 x\xdak`\x99*\xc8\x00\x01\xde\x8c\xb5SzXJR"
|
||||
b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00V:\x07t\x00\x00"
|
||||
)
|
||||
if pickle.HIGHEST_PROTOCOL == 5:
|
||||
# Python 3.8+
|
||||
byte_out = (
|
||||
b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgPortal2Server\x00\x0b"
|
||||
b"packed_data\x00 x\xdak`\x9d*\xc8\x00\x01\xde\x8c\xb5SzXJR"
|
||||
b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00VU\x07u\x00\x00"
|
||||
)
|
||||
elif pickle.HIGHEST_PROTOCOL == 4:
|
||||
# Python 3.7
|
||||
byte_out = (
|
||||
b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgPortal2Server\x00\x0b"
|
||||
b"packed_data\x00 x\xdak`\x99*\xc8\x00\x01\xde\x8c\xb5SzXJR"
|
||||
b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00V:\x07t\x00\x00"
|
||||
)
|
||||
self.transport.write.assert_called_with(byte_out)
|
||||
with mock.patch("evennia.server.portal.amp.amp.AMP.dataReceived") as mocked_amprecv:
|
||||
self.proto.dataReceived(byte_out)
|
||||
|
|
@ -82,28 +102,28 @@ class TestAMPServer(TwistedTestCase):
|
|||
outstr = "test" * AMP_MAXLEN
|
||||
self.proto.data_to_server(MsgServer2Portal, 1, test=outstr)
|
||||
|
||||
if sys.version < "3.7":
|
||||
if pickle.HIGHEST_PROTOCOL == 5:
|
||||
# Python 3.8+
|
||||
self.transport.write.assert_called_with(
|
||||
b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgServer2Portal\x00\x0bpacked_data"
|
||||
b"\x00xx\xda\xed\xc6\xc1\t\x800\x10\x00\xc1\x13\xaf\x01\xeb\xb2\x01\x1bH"
|
||||
b'\x05\xe6+X\x80\xcf\xd8m@I\x1d\x99\x85\x81\xbd\xf3\xdd"c\xb4/W{'
|
||||
b"\xb2\x96\xb3\xb6\xa3\x7fk\x8c\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
|
||||
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
|
||||
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
|
||||
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00`\x0e?Pv\x02\x16\x00\r"
|
||||
b"packed_data.2\x00Zx\xda\xed\xc3\x01\r\x00\x00\x08\xc0\xa0\xb4&\xf0\xfdg\x10a"
|
||||
b"\xa3\xd9RUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU\xf5\xfb"
|
||||
b"\x03m\xe0\x06\x1d\x00\rpacked_data.3\x00Zx\xda\xed\xc3\x01\r\x00\x00\x08\xc0\xa0"
|
||||
b"\xb4&\xf0\xfdg\x10a\xa3fSUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU"
|
||||
b"UUUUU\xf5\xfb\x03n\x1c\x06\x1e\x00\rpacked_data.4\x00Zx\xda\xed\xc3\x01\t\x00"
|
||||
b"\x00\x0c\x03\xa0\xb4O\xb0\xf5gA\xae`\xda\x8b\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa"
|
||||
b"\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa"
|
||||
b"\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa"
|
||||
b"\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xdf\x0fnI"
|
||||
b"\x06,\x00\rpacked_data.5\x00\x14x\xdaK-.)I\xc5\x8e\xa7\x14\xb7M\xd1\x03\x00"
|
||||
b"\xe7s\x0e\x1c\x00\x00"
|
||||
b"\x00wx\xda\xed\xc6\xc1\t\x80 \x00@Q#=5Z\x0b\xb8\x80\x13\xe85h\x80\x8e\xbam`Dc\xf4><\xf8g"
|
||||
b"\x1a[\xf8\xda\x97\xa3_\xb1\x95\xdaz\xbe\xe7\x1a\xde\x03\x00\x00\x00\x00\x00\x00\x00"
|
||||
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
|
||||
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
|
||||
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xe0\x1f\x1eP\x1d\x02\r\x00\rpacked_data.2"
|
||||
b"\x00Zx\xda\xed\xc3\x01\r\x00\x00\x08\xc0\xa0\xb4&\xf0\xfdg\x10a\xa3"
|
||||
b"\xd9RUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU\xf5\xfb\x03m\xe0\x06"
|
||||
b"\x1d\x00\rpacked_data.3\x00Zx\xda\xed\xc3\x01\r\x00\x00\x08\xc0\xa0\xb4&\xf0\xfdg\x10a"
|
||||
b"\xa3fSUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU\xf5\xfb\x03n\x1c"
|
||||
b"\x06\x1e\x00\rpacked_data.4\x00Zx\xda\xed\xc3\x01\t\x00\x00\x0c\x03\xa0\xb4O\xb0\xf5gA"
|
||||
b"\xae`\xda\x8b\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa"
|
||||
b"\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa"
|
||||
b"\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa"
|
||||
b"\xaa\xaa\xaa\xdf\x0fnI\x06,\x00\rpacked_data.5\x00\x18x\xdaK-.)I\xc5\x8e\xa7\xb22@\xc0"
|
||||
b"\x94\xe2\xb6)z\x00Z\x1e\x0e\xb6\x00\x00"
|
||||
)
|
||||
else:
|
||||
elif pickle.HIGHEST_PROTOCOL == 4:
|
||||
# Python 3.7
|
||||
self.transport.write.assert_called_with(
|
||||
b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgServer2Portal\x00\x0bpacked_data"
|
||||
b"\x00wx\xda\xed\xc6\xc1\t\x80 \x00@Q#o\x8e\xd6\x02-\xe0\x04z\r\x1a\xa0\xa3m+$\xd2"
|
||||
|
|
|
|||
|
|
@ -10,11 +10,12 @@ etc. If the client does not support TTYPE, this will be ignored.
|
|||
All data will be stored on the protocol's protocol_flags dictionary,
|
||||
under the 'TTYPE' key.
|
||||
"""
|
||||
from twisted.python.compat import _bytesChr as bchr
|
||||
|
||||
# telnet option codes
|
||||
TTYPE = b"\x18"
|
||||
IS = b"\x00"
|
||||
SEND = b"\x01"
|
||||
TTYPE = bchr(24) # b"\x18"
|
||||
IS = bchr(0) # b"\x00"
|
||||
SEND = bchr(1) # b"\x01"
|
||||
|
||||
# terminal capabilities and their codes
|
||||
MTTS = [
|
||||
|
|
|
|||
|
|
@ -29,16 +29,26 @@ _RE_SCREENREADER_REGEX = re.compile(
|
|||
r"%s" % settings.SCREENREADER_REGEX_STRIP, re.DOTALL + re.MULTILINE
|
||||
)
|
||||
_CLIENT_SESSIONS = mod_import(settings.SESSION_ENGINE).SessionStore
|
||||
_UPSTREAM_IPS = settings.UPSTREAM_IPS
|
||||
|
||||
|
||||
# Status Code 1000: Normal Closure
|
||||
# called when the connection was closed through JavaScript
|
||||
CLOSE_NORMAL = WebSocketServerProtocol.CLOSE_STATUS_CODE_NORMAL
|
||||
|
||||
# Status Code 1001: Going Away
|
||||
# called when the browser is navigating away from the page
|
||||
GOING_AWAY = WebSocketServerProtocol.CLOSE_STATUS_CODE_GOING_AWAY
|
||||
|
||||
|
||||
class WebSocketClient(WebSocketServerProtocol, Session):
|
||||
"""
|
||||
Implements the server-side of the Websocket connection.
|
||||
"""
|
||||
|
||||
# nonce value, used to prevent the webclient from erasing the
|
||||
# webclient_authenticated_uid value of csession on disconnect
|
||||
nonce = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.protocol_key = "webclient/websocket"
|
||||
|
|
@ -75,14 +85,26 @@ class WebSocketClient(WebSocketServerProtocol, Session):
|
|||
"""
|
||||
client_address = self.transport.client
|
||||
client_address = client_address[0] if client_address else None
|
||||
|
||||
if client_address in _UPSTREAM_IPS and "x-forwarded-for" in self.http_headers:
|
||||
addresses = [x.strip() for x in self.http_headers["x-forwarded-for"].split(",")]
|
||||
addresses.reverse()
|
||||
|
||||
for addr in addresses:
|
||||
if addr not in _UPSTREAM_IPS:
|
||||
client_address = addr
|
||||
break
|
||||
|
||||
self.init_session("websocket", client_address, self.factory.sessionhandler)
|
||||
|
||||
csession = self.get_client_session() # this sets self.csessid
|
||||
csessid = self.csessid
|
||||
uid = csession and csession.get("webclient_authenticated_uid", None)
|
||||
nonce = csession and csession.get("webclient_authenticated_nonce", 0)
|
||||
if uid:
|
||||
# the client session is already logged in.
|
||||
self.uid = uid
|
||||
self.nonce = nonce
|
||||
self.logged_in = True
|
||||
|
||||
for old_session in self.sessionhandler.sessions_from_csessid(csessid):
|
||||
|
|
@ -111,12 +133,20 @@ class WebSocketClient(WebSocketServerProtocol, Session):
|
|||
csession = self.get_client_session()
|
||||
|
||||
if csession:
|
||||
csession["webclient_authenticated_uid"] = None
|
||||
csession.save()
|
||||
# if the nonce is different, webclient_authenticated_uid has been
|
||||
# set *before* this disconnect (disconnect called after a new client
|
||||
# connects, which occurs in some 'fast' browsers like Google Chrome
|
||||
# and Mobile Safari)
|
||||
if csession.get("webclient_authenticated_nonce", None) == self.nonce:
|
||||
csession["webclient_authenticated_uid"] = None
|
||||
csession["webclient_authenticated_nonce"] = 0
|
||||
csession.save()
|
||||
self.logged_in = False
|
||||
|
||||
self.sessionhandler.disconnect(self)
|
||||
# autobahn-python: 1000 for a normal close, 3000-4999 for app. specific,
|
||||
# autobahn-python:
|
||||
# 1000 for a normal close, 1001 if the browser window is closed,
|
||||
# 3000-4999 for app. specific,
|
||||
# in case anyone wants to expose this functionality later.
|
||||
#
|
||||
# sendClose() under autobahn/websocket/interfaces.py
|
||||
|
|
@ -134,7 +164,7 @@ class WebSocketClient(WebSocketServerProtocol, Session):
|
|||
reason (str or None): Close reason as sent by the WebSocket peer.
|
||||
|
||||
"""
|
||||
if code == CLOSE_NORMAL:
|
||||
if code == CLOSE_NORMAL or code == GOING_AWAY:
|
||||
self.disconnect(reason)
|
||||
else:
|
||||
self.websocket_close_code = code
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ from evennia.utils import logger
|
|||
from evennia.comms import channelhandler
|
||||
from evennia.server.sessionhandler import SESSIONS
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
_SA = object.__setattr__
|
||||
|
||||
|
|
@ -148,13 +148,17 @@ def _server_maintenance():
|
|||
# handle idle timeouts
|
||||
if _IDLE_TIMEOUT > 0:
|
||||
reason = _("idle timeout exceeded")
|
||||
to_disconnect = []
|
||||
for session in (
|
||||
sess for sess in SESSIONS.values() if (now - sess.cmd_last) > _IDLE_TIMEOUT
|
||||
):
|
||||
if not session.account or not session.account.access(
|
||||
session.account, "noidletimeout", default=False
|
||||
):
|
||||
SESSIONS.disconnect(session, reason=reason)
|
||||
to_disconnect.append(session)
|
||||
|
||||
for session in to_disconnect:
|
||||
SESSIONS.disconnect(session, reason=reason)
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
|
|
@ -416,7 +420,7 @@ class Evennia(object):
|
|||
yield [
|
||||
(s.pause(manual_pause=False), s.at_server_reload())
|
||||
for s in ScriptDB.get_all_cached_instances()
|
||||
if s.is_active or s.attributes.has("_manual_pause")
|
||||
if s.id and (s.is_active or s.attributes.has("_manual_pause"))
|
||||
]
|
||||
yield self.sessions.all_sessions_portal_sync()
|
||||
self.at_server_reload_stop()
|
||||
|
|
@ -612,7 +616,10 @@ application = service.Application("Evennia")
|
|||
if "--nodaemon" not in sys.argv:
|
||||
# custom logging, but only if we are not running in interactive mode
|
||||
logfile = logger.WeeklyLogFile(
|
||||
os.path.basename(settings.SERVER_LOG_FILE), os.path.dirname(settings.SERVER_LOG_FILE)
|
||||
os.path.basename(settings.SERVER_LOG_FILE),
|
||||
os.path.dirname(settings.SERVER_LOG_FILE),
|
||||
day_rotation=settings.SERVER_LOG_DAY_ROTATION,
|
||||
max_size=settings.SERVER_LOG_MAX_SIZE,
|
||||
)
|
||||
application.setComponent(ILogObserver, logger.ServerLogObserver(logfile).emit)
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ _ObjectDB = None
|
|||
_ANSI = None
|
||||
|
||||
# i18n
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
# Handlers for Session.db/ndb operation
|
||||
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ PSTATUS = chr(18) # ping server or portal status
|
|||
SRESET = chr(19) # server shutdown in reset mode
|
||||
|
||||
# i18n
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
_SERVERNAME = settings.SERVERNAME
|
||||
_MULTISESSION_MODE = settings.MULTISESSION_MODE
|
||||
|
|
|
|||
|
|
@ -70,14 +70,15 @@ class HTTPChannelWithXForwardedFor(http.HTTPChannel):
|
|||
Check to see if this is a reverse proxied connection.
|
||||
|
||||
"""
|
||||
CLIENT = 0
|
||||
http.HTTPChannel.allHeadersReceived(self)
|
||||
req = self.requests[-1]
|
||||
client_ip, port = self.transport.client
|
||||
proxy_chain = req.getHeader("X-FORWARDED-FOR")
|
||||
if proxy_chain and client_ip in _UPSTREAM_IPS:
|
||||
forwarded = proxy_chain.split(", ", 1)[CLIENT]
|
||||
self.transport.client = (forwarded, port)
|
||||
if self.requests:
|
||||
CLIENT = 0
|
||||
http.HTTPChannel.allHeadersReceived(self)
|
||||
req = self.requests[-1]
|
||||
client_ip, port = self.transport.client
|
||||
proxy_chain = req.getHeader("X-FORWARDED-FOR")
|
||||
if proxy_chain and client_ip in _UPSTREAM_IPS:
|
||||
forwarded = proxy_chain.split(", ", 1)[CLIENT]
|
||||
self.transport.client = (forwarded, port)
|
||||
|
||||
|
||||
# Monkey-patch Twisted to handle X-Forwarded-For.
|
||||
|
|
|
|||
|
|
@ -135,17 +135,19 @@ else:
|
|||
break
|
||||
os.chdir(os.pardir)
|
||||
|
||||
# Place to put log files
|
||||
# Place to put log files, how often to rotate the log and how big each log file
|
||||
# may become before rotating.
|
||||
LOG_DIR = os.path.join(GAME_DIR, "server", "logs")
|
||||
SERVER_LOG_FILE = os.path.join(LOG_DIR, "server.log")
|
||||
SERVER_LOG_DAY_ROTATION = 7
|
||||
SERVER_LOG_MAX_SIZE = 1000000
|
||||
PORTAL_LOG_FILE = os.path.join(LOG_DIR, "portal.log")
|
||||
PORTAL_LOG_DAY_ROTATION = 7
|
||||
PORTAL_LOG_MAX_SIZE = 1000000
|
||||
# The http log is usually only for debugging since it's very spammy
|
||||
HTTP_LOG_FILE = os.path.join(LOG_DIR, "http_requests.log")
|
||||
# if this is set to the empty string, lockwarnings will be turned off.
|
||||
LOCKWARNING_LOG_FILE = os.path.join(LOG_DIR, "lockwarnings.log")
|
||||
# Rotate log files when server and/or portal stops. This will keep log
|
||||
# file sizes down. Turn off to get ever growing log files and never
|
||||
# lose log info.
|
||||
CYCLE_LOGFILES = True
|
||||
# Number of lines to append to rotating channel logs when they rotate
|
||||
CHANNEL_LOG_NUM_TAIL_LINES = 20
|
||||
# Max size (in bytes) of channel log files before they rotate
|
||||
|
|
@ -243,7 +245,7 @@ IN_GAME_ERRORS = True
|
|||
# ENGINE - path to the the database backend. Possible choices are:
|
||||
# 'django.db.backends.sqlite3', (default)
|
||||
# 'django.db.backends.mysql',
|
||||
# 'django.db.backends.postgresql_psycopg2',
|
||||
# 'django.db.backends.postgresql',
|
||||
# 'django.db.backends.oracle' (untested).
|
||||
# NAME - database name, or path to the db file for sqlite3
|
||||
# USER - db admin (unused in sqlite3)
|
||||
|
|
@ -400,22 +402,23 @@ COLOR_NO_DEFAULT = False
|
|||
|
||||
|
||||
######################################################################
|
||||
# Default command sets
|
||||
# Default command sets and commands
|
||||
######################################################################
|
||||
# Note that with the exception of the unloggedin set (which is not
|
||||
# stored anywhere in the database), changing these paths will only affect
|
||||
# NEW created characters/objects, not those already in play. So if you plan to
|
||||
# change this, it's recommended you do it before having created a lot of objects
|
||||
# (or simply reset the database after the change for simplicity).
|
||||
|
||||
# Command set used on session before account has logged in
|
||||
CMDSET_UNLOGGEDIN = "commands.default_cmdsets.UnloggedinCmdSet"
|
||||
# (Note that changing these three following cmdset paths will only affect NEW
|
||||
# created characters/objects, not those already in play. So if you want to
|
||||
# change this and have it apply to every object, it's recommended you do it
|
||||
# before having created a lot of objects (or simply reset the database after
|
||||
# the change for simplicity)).
|
||||
# Command set used on the logged-in session
|
||||
CMDSET_SESSION = "commands.default_cmdsets.SessionCmdSet"
|
||||
# Default set for logged in account with characters (fallback)
|
||||
CMDSET_CHARACTER = "commands.default_cmdsets.CharacterCmdSet"
|
||||
# Command set for accounts without a character (ooc)
|
||||
CMDSET_ACCOUNT = "commands.default_cmdsets.AccountCmdSet"
|
||||
|
||||
# Location to search for cmdsets if full path not given
|
||||
CMDSET_PATHS = ["commands", "evennia", "evennia.contrib"]
|
||||
# Fallbacks for cmdset paths that fail to load. Note that if you change the path for your
|
||||
|
|
@ -445,10 +448,17 @@ COMMAND_DEFAULT_MSG_ALL_SESSIONS = False
|
|||
COMMAND_DEFAULT_HELP_CATEGORY = "general"
|
||||
# The default lockstring of a command.
|
||||
COMMAND_DEFAULT_LOCKS = ""
|
||||
# The Channel Handler will create a command to represent each channel,
|
||||
# creating it with the key of the channel, its aliases, locks etc. The
|
||||
# default class logs channel messages to a file and allows for /history.
|
||||
# This setting allows to override the command class used with your own.
|
||||
# The Channel Handler is responsible for managing all available channels. By
|
||||
# default it builds the current channels into a channel-cmdset that it feeds
|
||||
# to the cmdhandler. Overloading this can completely change how Channels
|
||||
# are identified and called.
|
||||
CHANNEL_HANDLER_CLASS = "evennia.comms.channelhandler.ChannelHandler"
|
||||
# The (default) Channel Handler will create a command to represent each
|
||||
# channel, creating it with the key of the channel, its aliases, locks etc. The
|
||||
# default class logs channel messages to a file and allows for /history. This
|
||||
# setting allows to override the command class used with your own.
|
||||
# If you implement CHANNEL_HANDLER_CLASS, you can change this directly and will
|
||||
# likely not need this setting.
|
||||
CHANNEL_COMMAND_CLASS = "evennia.comms.channelhandler.ChannelCommand"
|
||||
|
||||
######################################################################
|
||||
|
|
@ -640,6 +650,12 @@ CLIENT_DEFAULT_HEIGHT = 45
|
|||
# (excluding webclient with separate help popups). If continuous scroll
|
||||
# is preferred, change 'HELP_MORE' to False. EvMORE uses CLIENT_DEFAULT_HEIGHT
|
||||
HELP_MORE = True
|
||||
# Set rate limits per-IP on account creations and login attempts
|
||||
CREATION_THROTTLE_LIMIT = 2
|
||||
CREATION_THROTTLE_TIMEOUT = 10 * 60
|
||||
LOGIN_THROTTLE_LIMIT = 5
|
||||
LOGIN_THROTTLE_TIMEOUT = 5 * 60
|
||||
|
||||
|
||||
######################################################################
|
||||
# Guest accounts
|
||||
|
|
|
|||
|
|
@ -235,17 +235,23 @@ class AttributeHandler(object):
|
|||
# full cache was run on all attributes
|
||||
self._cache_complete = False
|
||||
|
||||
def _fullcache(self):
|
||||
"""Cache all attributes of this object"""
|
||||
def _query_all(self):
|
||||
"Fetch all Attributes on this object"
|
||||
query = {
|
||||
"%s__id" % self._model: self._objid,
|
||||
"attribute__db_model__iexact": self._model,
|
||||
"attribute__db_attrtype": self._attrtype,
|
||||
}
|
||||
attrs = [
|
||||
return [
|
||||
conn.attribute
|
||||
for conn in getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query)
|
||||
]
|
||||
|
||||
def _fullcache(self):
|
||||
"""Cache all attributes of this object"""
|
||||
if not _TYPECLASS_AGGRESSIVE_CACHE:
|
||||
return
|
||||
attrs = self._query_all()
|
||||
self._cache = dict(
|
||||
(
|
||||
"%s-%s"
|
||||
|
|
@ -298,7 +304,7 @@ class AttributeHandler(object):
|
|||
attr = None
|
||||
cachefound = False
|
||||
del self._cache[cachekey]
|
||||
if cachefound:
|
||||
if cachefound and _TYPECLASS_AGGRESSIVE_CACHE:
|
||||
if attr:
|
||||
return [attr] # return cached entity
|
||||
else:
|
||||
|
|
@ -316,13 +322,15 @@ class AttributeHandler(object):
|
|||
conn = getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query)
|
||||
if conn:
|
||||
attr = conn[0].attribute
|
||||
self._cache[cachekey] = attr
|
||||
if _TYPECLASS_AGGRESSIVE_CACHE:
|
||||
self._cache[cachekey] = attr
|
||||
return [attr] if attr.pk else []
|
||||
else:
|
||||
# There is no such attribute. We will explicitly save that
|
||||
# in our cache to avoid firing another query if we try to
|
||||
# retrieve that (non-existent) attribute again.
|
||||
self._cache[cachekey] = None
|
||||
if _TYPECLASS_AGGRESSIVE_CACHE:
|
||||
self._cache[cachekey] = None
|
||||
return []
|
||||
else:
|
||||
# only category given (even if it's None) - we can't
|
||||
|
|
@ -345,12 +353,13 @@ class AttributeHandler(object):
|
|||
**query
|
||||
)
|
||||
]
|
||||
for attr in attrs:
|
||||
if attr.pk:
|
||||
cachekey = "%s-%s" % (attr.db_key, category)
|
||||
self._cache[cachekey] = attr
|
||||
# mark category cache as up-to-date
|
||||
self._catcache[catkey] = True
|
||||
if _TYPECLASS_AGGRESSIVE_CACHE:
|
||||
for attr in attrs:
|
||||
if attr.pk:
|
||||
cachekey = "%s-%s" % (attr.db_key, category)
|
||||
self._cache[cachekey] = attr
|
||||
# mark category cache as up-to-date
|
||||
self._catcache[catkey] = True
|
||||
return attrs
|
||||
|
||||
def _setcache(self, key, category, attr_obj):
|
||||
|
|
@ -363,6 +372,8 @@ class AttributeHandler(object):
|
|||
attr_obj (Attribute): The newly saved attribute
|
||||
|
||||
"""
|
||||
if not _TYPECLASS_AGGRESSIVE_CACHE:
|
||||
return
|
||||
if not key: # don't allow an empty key in cache
|
||||
return
|
||||
cachekey = "%s-%s" % (key, category)
|
||||
|
|
@ -769,9 +780,13 @@ class AttributeHandler(object):
|
|||
their values!) in the handler.
|
||||
|
||||
"""
|
||||
if not self._cache_complete:
|
||||
self._fullcache()
|
||||
attrs = sorted([attr for attr in self._cache.values() if attr], key=lambda o: o.id)
|
||||
if _TYPECLASS_AGGRESSIVE_CACHE:
|
||||
if not self._cache_complete:
|
||||
self._fullcache()
|
||||
attrs = sorted([attr for attr in self._cache.values() if attr], key=lambda o: o.id)
|
||||
else:
|
||||
attrs = sorted([attr for attr in self._query_all() if attr], key=lambda o: o.id)
|
||||
|
||||
if accessing_obj:
|
||||
return [
|
||||
attr
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ all Attributes and TypedObjects).
|
|||
|
||||
"""
|
||||
import shlex
|
||||
from django.db.models import Q
|
||||
from django.db.models import F, Q, Count, ExpressionWrapper, FloatField
|
||||
from django.db.models.functions import Cast
|
||||
from evennia.utils import idmapper
|
||||
from evennia.utils.utils import make_iter, variable_from_module
|
||||
from evennia.typeclasses.attributes import Attribute
|
||||
|
|
@ -236,21 +237,29 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
|
|||
"""
|
||||
return self.get_tag(key=key, category=category, obj=obj, tagtype="alias")
|
||||
|
||||
def get_by_tag(self, key=None, category=None, tagtype=None):
|
||||
def get_by_tag(self, key=None, category=None, tagtype=None, **kwargs):
|
||||
"""
|
||||
Return objects having tags with a given key or category or combination of the two.
|
||||
Also accepts multiple tags/category/tagtype
|
||||
|
||||
Args:
|
||||
key (str or list, optional): Tag key or list of keys. Not case sensitive.
|
||||
category (str or list, optional): Tag category. Not case sensitive. If `key` is
|
||||
a list, a single category can either apply to all keys in that list or this
|
||||
must be a list matching the `key` list element by element. If no `key` is given,
|
||||
all objects with tags of this category are returned.
|
||||
category (str or list, optional): Tag category. Not case sensitive.
|
||||
If `key` is a list, a single category can either apply to all
|
||||
keys in that list or this must be a list matching the `key`
|
||||
list element by element. If no `key` is given, all objects with
|
||||
tags of this category are returned.
|
||||
tagtype (str, optional): 'type' of Tag, by default
|
||||
this is either `None` (a normal Tag), `alias` or
|
||||
`permission`. This always apply to all queried tags.
|
||||
|
||||
Kwargs:
|
||||
match (str): "all" (default) or "any"; determines whether the
|
||||
target object must be tagged with ALL of the provided
|
||||
tags/categories or ANY single one. ANY will perform a weighted
|
||||
sort, so objects with more tag matches will outrank those with
|
||||
fewer tag matches.
|
||||
|
||||
Returns:
|
||||
objects (list): Objects with matching tag.
|
||||
|
||||
|
|
@ -262,10 +271,18 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
|
|||
if not (key or category):
|
||||
return []
|
||||
|
||||
global _Tag
|
||||
if not _Tag:
|
||||
from evennia.typeclasses.models import Tag as _Tag
|
||||
|
||||
match = kwargs.get("match", "all").lower().strip()
|
||||
|
||||
keys = make_iter(key) if key else []
|
||||
categories = make_iter(category) if category else []
|
||||
n_keys = len(keys)
|
||||
n_categories = len(categories)
|
||||
unique_categories = sorted(set(categories))
|
||||
n_unique_categories = len(unique_categories)
|
||||
|
||||
dbmodel = self.model.__dbclass__.__name__.lower()
|
||||
query = (
|
||||
|
|
@ -286,14 +303,30 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
|
|||
"get_by_tag needs a single category or a list of categories "
|
||||
"the same length as the list of tags."
|
||||
)
|
||||
clauses = Q()
|
||||
for ikey, key in enumerate(keys):
|
||||
query = query.filter(
|
||||
db_tags__db_key__iexact=key, db_tags__db_category__iexact=categories[ikey]
|
||||
)
|
||||
# Keep each key and category together, grouped by AND
|
||||
clauses |= Q(db_key__iexact=key, db_category__iexact=categories[ikey])
|
||||
|
||||
else:
|
||||
# only one or more categories given
|
||||
for category in categories:
|
||||
query = query.filter(db_tags__db_category__iexact=category)
|
||||
# import evennia;evennia.set_trace()
|
||||
clauses = Q()
|
||||
for category in unique_categories:
|
||||
clauses |= Q(db_category__iexact=category)
|
||||
|
||||
tags = _Tag.objects.filter(clauses)
|
||||
query = query.filter(db_tags__in=tags).annotate(
|
||||
matches=Count("db_tags__pk", filter=Q(db_tags__in=tags), distinct=True)
|
||||
)
|
||||
|
||||
# Default ALL: Match all of the tags and optionally more
|
||||
if match == "all":
|
||||
n_req_tags = tags.count() if n_keys > 0 else n_unique_categories
|
||||
query = query.filter(matches__gte=n_req_tags)
|
||||
# ANY: Match any single tag, ordered by weight
|
||||
elif match == "any":
|
||||
query = query.order_by("-matches")
|
||||
|
||||
return query
|
||||
|
||||
|
|
@ -452,6 +485,34 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
|
|||
retval = retval.filter(id__lte=self.dbref(max_dbref, reqhash=False))
|
||||
return retval
|
||||
|
||||
def get_typeclass_totals(self, *args, **kwargs) -> object:
|
||||
"""
|
||||
Returns a queryset of typeclass composition statistics.
|
||||
|
||||
Returns:
|
||||
qs (Queryset): A queryset of dicts containing the typeclass (name),
|
||||
the count of objects with that typeclass and a float representing
|
||||
the percentage of objects associated with the typeclass.
|
||||
|
||||
"""
|
||||
return (
|
||||
self.values("db_typeclass_path")
|
||||
.distinct()
|
||||
.annotate(
|
||||
# Get count of how many objects for each typeclass exist
|
||||
count=Count("db_typeclass_path")
|
||||
)
|
||||
.annotate(
|
||||
# Rename db_typeclass_path field to something more human
|
||||
typeclass=F("db_typeclass_path"),
|
||||
# Calculate this class' percentage of total composition
|
||||
percent=ExpressionWrapper(
|
||||
((F("count") / float(self.count())) * 100.0), output_field=FloatField()
|
||||
),
|
||||
)
|
||||
.values("typeclass", "count", "percent")
|
||||
)
|
||||
|
||||
def object_totals(self):
|
||||
"""
|
||||
Get info about database statistics.
|
||||
|
|
@ -463,11 +524,8 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
|
|||
object having that typeclass set on themselves).
|
||||
|
||||
"""
|
||||
dbtotals = {}
|
||||
typeclass_paths = set(self.values_list("db_typeclass_path", flat=True))
|
||||
for typeclass_path in typeclass_paths:
|
||||
dbtotals[typeclass_path] = self.filter(db_typeclass_path=typeclass_path).count()
|
||||
return dbtotals
|
||||
stats = self.get_typeclass_totals().order_by("typeclass")
|
||||
return {x.get("typeclass"): x.get("count") for x in stats}
|
||||
|
||||
def typeclass_search(self, typeclass, include_children=False, include_parents=False):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ def _drop_table(db_cursor, table_name):
|
|||
db_cursor.execute("SET FOREIGN_KEY_CHECKS=0;")
|
||||
db_cursor.execute("DROP TABLE {table};".format(table=table_name))
|
||||
db_cursor.execute("SET FOREIGN_KEY_CHECKS=1;")
|
||||
elif _ENGINE == "postgresql_psycopg2":
|
||||
elif _ENGINE == "postgresql":
|
||||
db_cursor.execute("ALTER TABLE {table} DISABLE TRIGGER ALL;".format(table=table_name))
|
||||
db_cursor.execute("DROP TABLE {table};".format(table=table_name))
|
||||
db_cursor.execute("ALTER TABLE {table} ENABLE TRIGGER ALL;".format(table=table_name))
|
||||
|
|
|
|||
|
|
@ -458,7 +458,7 @@ class TypedObject(SharedMemoryModel):
|
|||
# Object manipulation methods
|
||||
#
|
||||
|
||||
def is_typeclass(self, typeclass, exact=True):
|
||||
def is_typeclass(self, typeclass, exact=False):
|
||||
"""
|
||||
Returns true if this object has this type OR has a typeclass
|
||||
which is an subclass of the given typeclass. This operates on
|
||||
|
|
|
|||
|
|
@ -124,17 +124,23 @@ class TagHandler(object):
|
|||
# full cache was run on all tags
|
||||
self._cache_complete = False
|
||||
|
||||
def _fullcache(self):
|
||||
"Cache all tags of this object"
|
||||
def _query_all(self):
|
||||
"Get all tags for this objects"
|
||||
query = {
|
||||
"%s__id" % self._model: self._objid,
|
||||
"tag__db_model": self._model,
|
||||
"tag__db_tagtype": self._tagtype,
|
||||
}
|
||||
tags = [
|
||||
return [
|
||||
conn.tag
|
||||
for conn in getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query)
|
||||
]
|
||||
|
||||
def _fullcache(self):
|
||||
"Cache all tags of this object"
|
||||
if not _TYPECLASS_AGGRESSIVE_CACHE:
|
||||
return
|
||||
tags = self._query_all()
|
||||
self._cache = dict(
|
||||
(
|
||||
"%s-%s"
|
||||
|
|
@ -193,7 +199,8 @@ class TagHandler(object):
|
|||
conn = getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query)
|
||||
if conn:
|
||||
tag = conn[0].tag
|
||||
self._cache[cachekey] = tag
|
||||
if _TYPECLASS_AGGRESSIVE_CACHE:
|
||||
self._cache[cachekey] = tag
|
||||
return [tag]
|
||||
else:
|
||||
# only category given (even if it's None) - we can't
|
||||
|
|
@ -216,11 +223,12 @@ class TagHandler(object):
|
|||
**query
|
||||
)
|
||||
]
|
||||
for tag in tags:
|
||||
cachekey = "%s-%s" % (tag.db_key, category)
|
||||
self._cache[cachekey] = tag
|
||||
# mark category cache as up-to-date
|
||||
self._catcache[catkey] = True
|
||||
if _TYPECLASS_AGGRESSIVE_CACHE:
|
||||
for tag in tags:
|
||||
cachekey = "%s-%s" % (tag.db_key, category)
|
||||
self._cache[cachekey] = tag
|
||||
# mark category cache as up-to-date
|
||||
self._catcache[catkey] = True
|
||||
return tags
|
||||
return []
|
||||
|
||||
|
|
@ -234,9 +242,11 @@ class TagHandler(object):
|
|||
tag_obj (tag): The newly saved tag
|
||||
|
||||
"""
|
||||
if not _TYPECLASS_AGGRESSIVE_CACHE:
|
||||
return
|
||||
if not key: # don't allow an empty key in cache
|
||||
return
|
||||
key, category = key.strip().lower(), category.strip().lower() if category else category
|
||||
key, category = (key.strip().lower(), category.strip().lower() if category else category)
|
||||
cachekey = "%s-%s" % (key, category)
|
||||
catkey = "-%s" % category
|
||||
self._cache[cachekey] = tag_obj
|
||||
|
|
@ -253,7 +263,7 @@ class TagHandler(object):
|
|||
category (str or None): A cleaned category name
|
||||
|
||||
"""
|
||||
key, category = key.strip().lower(), category.strip().lower() if category else category
|
||||
key, category = (key.strip().lower(), category.strip().lower() if category else category)
|
||||
catkey = "-%s" % category
|
||||
if key:
|
||||
cachekey = "%s-%s" % (key, category)
|
||||
|
|
@ -419,9 +429,13 @@ class TagHandler(object):
|
|||
`return_key_and_category` is set.
|
||||
|
||||
"""
|
||||
if not self._cache_complete:
|
||||
self._fullcache()
|
||||
tags = sorted(self._cache.values())
|
||||
if _TYPECLASS_AGGRESSIVE_CACHE:
|
||||
if not self._cache_complete:
|
||||
self._fullcache()
|
||||
tags = sorted(self._cache.values())
|
||||
else:
|
||||
tags = sorted(self._query_all())
|
||||
|
||||
if return_key_and_category:
|
||||
# return tuple (key, category)
|
||||
return [(to_str(tag.db_key), tag.db_category) for tag in tags]
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@
|
|||
Unit tests for typeclass base system
|
||||
|
||||
"""
|
||||
|
||||
from django.test import override_settings
|
||||
from evennia.utils.test_resources import EvenniaTest
|
||||
from mock import patch
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Manager tests
|
||||
|
|
@ -19,6 +20,19 @@ class TestAttributes(EvenniaTest):
|
|||
self.obj1.db.testattr = value
|
||||
self.assertEqual(self.obj1.db.testattr, value)
|
||||
|
||||
@override_settings(TYPECLASS_AGGRESSIVE_CACHE=False)
|
||||
@patch("evennia.typeclasses.attributes._TYPECLASS_AGGRESSIVE_CACHE", False)
|
||||
def test_attrhandler_nocache(self):
|
||||
key = "testattr"
|
||||
value = "test attr value "
|
||||
self.obj1.attributes.add(key, value)
|
||||
self.assertFalse(self.obj1.attributes._cache)
|
||||
|
||||
self.assertEqual(self.obj1.attributes.get(key), value)
|
||||
self.obj1.db.testattr = value
|
||||
self.assertEqual(self.obj1.db.testattr, value)
|
||||
self.assertFalse(self.obj1.attributes._cache)
|
||||
|
||||
def test_weird_text_save(self):
|
||||
"test 'weird' text type (different in py2 vs py3)"
|
||||
from django.utils.safestring import SafeText
|
||||
|
|
@ -62,6 +76,9 @@ class TestTypedObjectManager(EvenniaTest):
|
|||
self.obj2.tags.add("tag6", "category3")
|
||||
self.obj2.tags.add("tag7", "category1")
|
||||
self.obj2.tags.add("tag7", "category5")
|
||||
self.obj1.tags.add("tag8", "category6")
|
||||
self.obj2.tags.add("tag9", "category6")
|
||||
|
||||
self.assertEqual(self._manager("get_by_tag", "tag5", "category1"), [self.obj1, self.obj2])
|
||||
self.assertEqual(self._manager("get_by_tag", "tag6", "category1"), [])
|
||||
self.assertEqual(self._manager("get_by_tag", "tag6", "category3"), [self.obj1, self.obj2])
|
||||
|
|
@ -78,6 +95,8 @@ class TestTypedObjectManager(EvenniaTest):
|
|||
self._manager("get_by_tag", category=["category1", "category3"]), [self.obj1, self.obj2]
|
||||
)
|
||||
self.assertEqual(
|
||||
self._manager("get_by_tag", category=["category1", "category2"]), [self.obj2]
|
||||
self._manager("get_by_tag", category=["category1", "category2"]), [self.obj1, self.obj2]
|
||||
)
|
||||
self.assertEqual(self._manager("get_by_tag", category=["category5", "category4"]), [])
|
||||
self.assertEqual(self._manager("get_by_tag", category="category1"), [self.obj1, self.obj2])
|
||||
self.assertEqual(self._manager("get_by_tag", category="category6"), [self.obj1, self.obj2])
|
||||
|
|
|
|||
|
|
@ -674,6 +674,13 @@ class ANSIString(str, metaclass=ANSIMeta):
|
|||
|
||||
"""
|
||||
|
||||
# A compiled Regex for the format mini-language: https://docs.python.org/3/library/string.html#formatspec
|
||||
re_format = re.compile(
|
||||
r"(?i)(?P<just>(?P<fill>.)?(?P<align>\<|\>|\=|\^))?(?P<sign>\+|\-| )?(?P<alt>\#)?"
|
||||
r"(?P<zero>0)?(?P<width>\d+)?(?P<grouping>\_|\,)?(?:\.(?P<precision>\d+))?"
|
||||
r"(?P<type>b|c|d|e|E|f|F|g|G|n|o|s|x|X|%)?"
|
||||
)
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
"""
|
||||
When creating a new ANSIString, you may use a custom parser that has
|
||||
|
|
@ -733,6 +740,47 @@ class ANSIString(str, metaclass=ANSIMeta):
|
|||
def __str__(self):
|
||||
return self._raw_string
|
||||
|
||||
def __format__(self, format_spec):
|
||||
"""
|
||||
This magic method covers ANSIString's behavior within a str.format() or f-string.
|
||||
|
||||
Current features supported: fill, align, width.
|
||||
|
||||
Args:
|
||||
format_spec (str): The format specification passed by f-string or str.format(). This is a string such as
|
||||
"0<30" which would mean "left justify to 30, filling with zeros". The full specification can be found
|
||||
at https://docs.python.org/3/library/string.html#formatspec
|
||||
|
||||
Returns:
|
||||
ansi_str (str): The formatted ANSIString's .raw() form, for display.
|
||||
"""
|
||||
# This calls the compiled regex stored on ANSIString's class to analyze the format spec.
|
||||
# It returns a dictionary.
|
||||
format_data = self.re_format.match(format_spec).groupdict()
|
||||
clean = self.clean()
|
||||
base_output = ANSIString(self.raw())
|
||||
align = format_data.get("align", "<")
|
||||
fill = format_data.get("fill", " ")
|
||||
|
||||
# Need to coerce width into an integer. We can be certain that it's numeric thanks to regex.
|
||||
width = format_data.get("width", None)
|
||||
if width is None:
|
||||
width = len(clean)
|
||||
else:
|
||||
width = int(width)
|
||||
|
||||
if align == "<":
|
||||
base_output = self.ljust(width, fill)
|
||||
elif align == ">":
|
||||
base_output = self.rjust(width, fill)
|
||||
elif align == "^":
|
||||
base_output = self.center(width, fill)
|
||||
elif align == "=":
|
||||
pass
|
||||
|
||||
# Return the raw string with ANSI markup, ready to be displayed.
|
||||
return base_output.raw()
|
||||
|
||||
def __repr__(self):
|
||||
"""
|
||||
Let's make the repr the command that would actually be used to
|
||||
|
|
|
|||
|
|
@ -255,11 +255,11 @@ def create_script(
|
|||
if obj:
|
||||
kwarg["db_obj"] = dbid_to_obj(obj, _ObjectDB)
|
||||
if interval:
|
||||
kwarg["db_interval"] = interval
|
||||
kwarg["db_interval"] = max(0, interval)
|
||||
if start_delay:
|
||||
kwarg["db_start_delay"] = start_delay
|
||||
if repeats:
|
||||
kwarg["db_repeats"] = repeats
|
||||
kwarg["db_repeats"] = max(0, repeats)
|
||||
if persistent:
|
||||
kwarg["db_persistent"] = persistent
|
||||
if desc:
|
||||
|
|
@ -486,9 +486,8 @@ def create_account(
|
|||
|
||||
Args:
|
||||
key (str): The account's name. This should be unique.
|
||||
email (str): Email on valid addr@addr.domain form. This is
|
||||
technically required but if set to `None`, an email of
|
||||
`dummy@example.com` will be used as a placeholder.
|
||||
email (str or None): Email on valid addr@addr.domain form. If
|
||||
the empty string, will be set to None.
|
||||
password (str): Password in cleartext.
|
||||
|
||||
Kwargs:
|
||||
|
|
@ -532,7 +531,7 @@ def create_account(
|
|||
# correctly when each object is recovered).
|
||||
|
||||
if not email:
|
||||
email = "dummy@example.com"
|
||||
email = None
|
||||
if _AccountDB.objects.filter(username__iexact=key):
|
||||
raise ValueError("An Account with the name '%s' already exists." % key)
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ except ImportError:
|
|||
from pickle import dumps, loads
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.safestring import SafeString, SafeBytes
|
||||
from django.utils.safestring import SafeString
|
||||
from evennia.utils.utils import uses_database, is_iter, to_str, to_bytes
|
||||
from evennia.utils import logger
|
||||
|
||||
|
|
@ -549,7 +549,7 @@ def to_pickle(data):
|
|||
def process_item(item):
|
||||
"""Recursive processor and identification of data"""
|
||||
dtype = type(item)
|
||||
if dtype in (str, int, float, bool, bytes, SafeString, SafeBytes):
|
||||
if dtype in (str, int, float, bool, bytes, SafeString):
|
||||
return item
|
||||
elif dtype == tuple:
|
||||
return tuple(process_item(val) for val in item)
|
||||
|
|
@ -577,7 +577,7 @@ def to_pickle(data):
|
|||
except TypeError:
|
||||
return item
|
||||
except Exception:
|
||||
logger.log_error(f"The object {item} of type {type(item)} could not be stored.")
|
||||
logger.log_err(f"The object {item} of type {type(item)} could not be stored.")
|
||||
raise
|
||||
|
||||
return process_item(data)
|
||||
|
|
@ -609,7 +609,7 @@ def from_pickle(data, db_obj=None):
|
|||
def process_item(item):
|
||||
"""Recursive processor and identification of data"""
|
||||
dtype = type(item)
|
||||
if dtype in (str, int, float, bool, bytes, SafeString, SafeBytes):
|
||||
if dtype in (str, int, float, bool, bytes, SafeString):
|
||||
return item
|
||||
elif _IS_PACKED_DBOBJ(item):
|
||||
# this must be checked before tuple
|
||||
|
|
@ -638,7 +638,7 @@ def from_pickle(data, db_obj=None):
|
|||
def process_tree(item, parent):
|
||||
"""Recursive processor, building a parent-tree from iterable data"""
|
||||
dtype = type(item)
|
||||
if dtype in (str, int, float, bool, bytes, SafeString, SafeBytes):
|
||||
if dtype in (str, int, float, bool, bytes, SafeString):
|
||||
return item
|
||||
elif _IS_PACKED_DBOBJ(item):
|
||||
# this must be checked before tuple
|
||||
|
|
@ -716,7 +716,7 @@ def do_pickle(data):
|
|||
try:
|
||||
return dumps(data, protocol=PICKLE_PROTOCOL)
|
||||
except Exception:
|
||||
logger.log_error(f"Could not pickle data for storage: {data}")
|
||||
logger.log_err(f"Could not pickle data for storage: {data}")
|
||||
raise
|
||||
|
||||
|
||||
|
|
@ -725,7 +725,7 @@ def do_unpickle(data):
|
|||
try:
|
||||
return loads(to_bytes(data))
|
||||
except Exception:
|
||||
logger.log_error(f"Could not unpickle data from storage: {data}")
|
||||
logger.log_err(f"Could not unpickle data from storage: {data}")
|
||||
raise
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ _CMD_NOINPUT = cmdhandler.CMD_NOINPUT
|
|||
# Return messages
|
||||
|
||||
# i18n
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
_ERR_NOT_IMPLEMENTED = _(
|
||||
"Menu node '{nodename}' is either not implemented or " "caused an error. Make another choice."
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ caller.msg() construct every time the page is updated.
|
|||
from django.conf import settings
|
||||
from evennia import Command, CmdSet
|
||||
from evennia.commands import cmdhandler
|
||||
from evennia.utils.utils import justify
|
||||
from evennia.utils.utils import justify, make_iter
|
||||
|
||||
_CMD_NOMATCH = cmdhandler.CMD_NOMATCH
|
||||
_CMD_NOINPUT = cmdhandler.CMD_NOINPUT
|
||||
|
|
@ -39,6 +39,8 @@ _CMD_NOINPUT = cmdhandler.CMD_NOINPUT
|
|||
_SCREEN_WIDTH = settings.CLIENT_DEFAULT_WIDTH
|
||||
_SCREEN_HEIGHT = settings.CLIENT_DEFAULT_HEIGHT
|
||||
|
||||
_EVTABLE = None
|
||||
|
||||
# text
|
||||
|
||||
_DISPLAY = """{text}
|
||||
|
|
@ -126,26 +128,38 @@ class EvMore(object):
|
|||
text,
|
||||
always_page=False,
|
||||
session=None,
|
||||
justify=False,
|
||||
justify_kwargs=None,
|
||||
exit_on_lastpage=False,
|
||||
exit_cmd=None,
|
||||
**kwargs,
|
||||
):
|
||||
|
||||
"""
|
||||
Initialization of the text handler.
|
||||
|
||||
Args:
|
||||
caller (Object or Account): Entity reading the text.
|
||||
text (str): The text to put under paging.
|
||||
text (str, EvTable or iterator): The text or data to put under paging.
|
||||
- If a string, paginage normally. If this text contains
|
||||
one or more `\f` format symbol, automatic pagination and justification
|
||||
are force-disabled and page-breaks will only happen after each `\f`.
|
||||
- If `EvTable`, the EvTable will be paginated with the same
|
||||
setting on each page if it is too long. The table
|
||||
decorations will be considered in the size of the page.
|
||||
- Otherwise `text` is converted to an iterator, where each step is
|
||||
expected to be a line in the final display. Each line
|
||||
will be run through repr() (so one could pass a list of objects).
|
||||
always_page (bool, optional): If `False`, the
|
||||
pager will only kick in if `text` is too big
|
||||
to fit the screen.
|
||||
session (Session, optional): If given, this session will be used
|
||||
to determine the screen width and will receive all output.
|
||||
justify_kwargs (dict, bool or None, optional): If given, this should
|
||||
be valid keyword arguments to the utils.justify() function. If False,
|
||||
no justification will be done (especially important for handling
|
||||
fixed-width text content, like tables!).
|
||||
justify (bool, optional): If set, auto-justify long lines. This must be turned
|
||||
off for fixed-width or formatted output, like tables. It's force-disabled
|
||||
if `text` is an EvTable.
|
||||
justify_kwargs (dict, optional): Keywords for the justifiy function. Used only
|
||||
if `justify` is True. If this is not set, default arguments will be used.
|
||||
exit_on_lastpage (bool, optional): If reaching the last page without the
|
||||
page being completely filled, exit pager immediately. If unset,
|
||||
another move forward is required to exit. If set, the pager
|
||||
|
|
@ -154,18 +168,32 @@ class EvMore(object):
|
|||
the caller when the more page exits. Note that this will be using whatever
|
||||
cmdset the user had *before* the evmore pager was activated (so none of
|
||||
the evmore commands will be available when this is run).
|
||||
kwargs (any, optional): These will be passed on
|
||||
to the `caller.msg` method.
|
||||
kwargs (any, optional): These will be passed on to the `caller.msg` method.
|
||||
|
||||
Examples:
|
||||
super_long_text = " ... "
|
||||
EvMore(caller, super_long_text)
|
||||
|
||||
from django.core.paginator import Paginator
|
||||
query = ObjectDB.objects.all()
|
||||
pages = Paginator(query, 10) # 10 objs per page
|
||||
EvMore(caller, pages) # will repr() each object per line, 10 to a page
|
||||
|
||||
multi_page_table = [ [[..],[..]], ...]
|
||||
EvMore(caller, multi_page_table, use_evtable=True,
|
||||
evtable_args=("Header1", "Header2"),
|
||||
evtable_kwargs={"align": "r", "border": "tablecols"})
|
||||
|
||||
"""
|
||||
self._caller = caller
|
||||
self._kwargs = kwargs
|
||||
self._pages = []
|
||||
self._npages = []
|
||||
self._npos = []
|
||||
self._npages = 1
|
||||
self._npos = 0
|
||||
self.exit_on_lastpage = exit_on_lastpage
|
||||
self.exit_cmd = exit_cmd
|
||||
self._exit_msg = "Exited |wmore|n pager."
|
||||
|
||||
if not session:
|
||||
# if not supplied, use the first session to
|
||||
# determine screen size
|
||||
|
|
@ -179,16 +207,33 @@ class EvMore(object):
|
|||
height = max(4, session.protocol_flags.get("SCREENHEIGHT", {0: _SCREEN_HEIGHT})[0] - 4)
|
||||
width = session.protocol_flags.get("SCREENWIDTH", {0: _SCREEN_WIDTH})[0]
|
||||
|
||||
if hasattr(text, "table") and hasattr(text, "get"):
|
||||
# This is an EvTable.
|
||||
|
||||
table = text
|
||||
|
||||
if table.height:
|
||||
# enforced height of each paged table, plus space for evmore extras
|
||||
height = table.height - 4
|
||||
|
||||
# convert table to string
|
||||
text = str(text)
|
||||
justify_kwargs = None # enforce
|
||||
|
||||
if not isinstance(text, str):
|
||||
# not a string - pre-set pages of some form
|
||||
text = "\n".join(str(repr(element)) for element in make_iter(text))
|
||||
|
||||
if "\f" in text:
|
||||
# we use \f to indicate the user wants to enforce their line breaks
|
||||
# on their own. If so, we do no automatic line-breaking/justification
|
||||
# at all.
|
||||
self._pages = text.split("\f")
|
||||
self._npages = len(self._pages)
|
||||
self._npos = 0
|
||||
else:
|
||||
if justify_kwargs is False:
|
||||
# no justification. Simple division by line
|
||||
lines = text.split("\n")
|
||||
else:
|
||||
# we must break very long lines into multiple ones
|
||||
if justify:
|
||||
# we must break very long lines into multiple ones. Note that this
|
||||
# will also remove spurious whitespace.
|
||||
justify_kwargs = justify_kwargs or {}
|
||||
width = justify_kwargs.get("width", width)
|
||||
justify_kwargs["width"] = width
|
||||
|
|
@ -201,17 +246,20 @@ class EvMore(object):
|
|||
lines.extend(justify(line, **justify_kwargs).split("\n"))
|
||||
else:
|
||||
lines.append(line)
|
||||
else:
|
||||
# no justification. Simple division by line
|
||||
lines = text.split("\n")
|
||||
|
||||
# always limit number of chars to 10 000 per page
|
||||
height = min(10000 // max(1, width), height)
|
||||
|
||||
# figure out the pagination
|
||||
self._pages = ["\n".join(lines[i : i + height]) for i in range(0, len(lines), height)]
|
||||
self._npages = len(self._pages)
|
||||
self._npos = 0
|
||||
|
||||
if self._npages <= 1 and not always_page:
|
||||
# no need for paging; just pass-through.
|
||||
caller.msg(text=text, session=self._session, **kwargs)
|
||||
caller.msg(text=self._get_page(0), session=self._session, **kwargs)
|
||||
else:
|
||||
# go into paging mode
|
||||
# first pass on the msg kwargs
|
||||
|
|
@ -221,12 +269,15 @@ class EvMore(object):
|
|||
# goto top of the text
|
||||
self.page_top()
|
||||
|
||||
def _get_page(self, pos):
|
||||
return self._pages[pos]
|
||||
|
||||
def display(self, show_footer=True):
|
||||
"""
|
||||
Pretty-print the page.
|
||||
"""
|
||||
pos = self._pos
|
||||
text = self._pages[pos]
|
||||
pos = self._npos
|
||||
text = self._get_page(pos)
|
||||
if show_footer:
|
||||
page = _DISPLAY.format(text=text, pageno=pos + 1, pagemax=self._npages)
|
||||
else:
|
||||
|
|
@ -245,14 +296,14 @@ class EvMore(object):
|
|||
"""
|
||||
Display the top page
|
||||
"""
|
||||
self._pos = 0
|
||||
self._npos = 0
|
||||
self.display()
|
||||
|
||||
def page_end(self):
|
||||
"""
|
||||
Display the bottom page.
|
||||
"""
|
||||
self._pos = self._npages - 1
|
||||
self._npos = self._npages - 1
|
||||
self.display()
|
||||
|
||||
def page_next(self):
|
||||
|
|
@ -260,12 +311,12 @@ class EvMore(object):
|
|||
Scroll the text to the next page. Quit if already at the end
|
||||
of the page.
|
||||
"""
|
||||
if self._pos >= self._npages - 1:
|
||||
if self._npos >= self._npages - 1:
|
||||
# exit if we are already at the end
|
||||
self.page_quit()
|
||||
else:
|
||||
self._pos += 1
|
||||
if self.exit_on_lastpage and self._pos >= (self._npages - 1):
|
||||
self._npos += 1
|
||||
if self.exit_on_lastpage and self._npos >= (self._npages - 1):
|
||||
self.display(show_footer=False)
|
||||
self.page_quit(quiet=True)
|
||||
else:
|
||||
|
|
@ -275,7 +326,7 @@ class EvMore(object):
|
|||
"""
|
||||
Scroll the text back up, at the most to the top.
|
||||
"""
|
||||
self._pos = max(0, self._pos - 1)
|
||||
self._npos = max(0, self._npos - 1)
|
||||
self.display()
|
||||
|
||||
def page_quit(self, quiet=False):
|
||||
|
|
@ -290,30 +341,51 @@ class EvMore(object):
|
|||
self._caller.execute_cmd(self.exit_cmd, session=self._session)
|
||||
|
||||
|
||||
# helper function
|
||||
|
||||
|
||||
def msg(
|
||||
caller,
|
||||
text="",
|
||||
always_page=False,
|
||||
session=None,
|
||||
justify=False,
|
||||
justify_kwargs=None,
|
||||
exit_on_lastpage=True,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
More-supported version of msg, mimicking the normal msg method.
|
||||
EvMore-supported version of msg, mimicking the normal msg method.
|
||||
|
||||
Args:
|
||||
caller (Object or Account): Entity reading the text.
|
||||
text (str): The text to put under paging.
|
||||
text (str, EvTable or iterator): The text or data to put under paging.
|
||||
- If a string, paginage normally. If this text contains
|
||||
one or more `\f` format symbol, automatic pagination is disabled
|
||||
and page-breaks will only happen after each `\f`.
|
||||
- If `EvTable`, the EvTable will be paginated with the same
|
||||
setting on each page if it is too long. The table
|
||||
decorations will be considered in the size of the page.
|
||||
- Otherwise `text` is converted to an iterator, where each step is
|
||||
is expected to be a line in the final display, and each line
|
||||
will be run through repr().
|
||||
always_page (bool, optional): If `False`, the
|
||||
pager will only kick in if `text` is too big
|
||||
to fit the screen.
|
||||
session (Session, optional): If given, this session will be used
|
||||
to determine the screen width and will receive all output.
|
||||
justify (bool, optional): If set, justify long lines in output. Disable for
|
||||
fixed-format output, like tables.
|
||||
justify_kwargs (dict, bool or None, optional): If given, this should
|
||||
be valid keyword arguments to the utils.justify() function. If False,
|
||||
no justification will be done.
|
||||
exit_on_lastpage (bool, optional): Immediately exit pager when reaching the last page.
|
||||
use_evtable (bool, optional): If True, each page will be rendered as an
|
||||
EvTable. For this to work, `text` must be an iterable, where each element
|
||||
is the table (list of list) to render on that page.
|
||||
evtable_args (tuple, optional): The args to use for EvTable on each page.
|
||||
evtable_kwargs (dict, optional): The kwargs to use for EvTable on each
|
||||
page (except `table`, which is supplied by EvMore per-page).
|
||||
kwargs (any, optional): These will be passed on
|
||||
to the `caller.msg` method.
|
||||
|
||||
|
|
@ -323,6 +395,7 @@ def msg(
|
|||
text,
|
||||
always_page=always_page,
|
||||
session=session,
|
||||
justify=justify,
|
||||
justify_kwargs=justify_kwargs,
|
||||
exit_on_lastpage=exit_on_lastpage,
|
||||
**kwargs,
|
||||
|
|
|
|||
|
|
@ -1107,8 +1107,9 @@ class EvTable(object):
|
|||
Exception: If given erroneous input or width settings for the data.
|
||||
|
||||
Notes:
|
||||
Beyond those table-specific keywords, the non-overlapping keywords of `EcCell.__init__` are
|
||||
also available. These will be passed down to every cell in the table.
|
||||
Beyond those table-specific keywords, the non-overlapping keywords
|
||||
of `EcCell.__init__` are also available. These will be passed down
|
||||
to every cell in the table.
|
||||
|
||||
"""
|
||||
# at this point table is a 2D grid - a list of columns
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ log_typemsg(). This is for historical, back-compatible reasons.
|
|||
|
||||
import os
|
||||
import time
|
||||
import glob
|
||||
from datetime import datetime
|
||||
from traceback import format_exc
|
||||
from twisted.python import log, logfile
|
||||
|
|
@ -76,33 +77,79 @@ def timeformat(when=None):
|
|||
|
||||
class WeeklyLogFile(logfile.DailyLogFile):
|
||||
"""
|
||||
Log file that rotates once per week. Overrides key methods to change format
|
||||
Log file that rotates once per week by default. Overrides key methods to change format.
|
||||
|
||||
"""
|
||||
|
||||
day_rotation = 7
|
||||
def __init__(self, name, directory, defaultMode=None, day_rotation=7, max_size=1000000):
|
||||
"""
|
||||
Args:
|
||||
name (str): Name of log file.
|
||||
directory (str): Directory holding the file.
|
||||
defaultMode (str): Permissions used to create file. Defaults to
|
||||
current permissions of this file if it exists.
|
||||
day_rotation (int): How often to rotate the file.
|
||||
max_size (int): Max size of log file before rotation (regardless of
|
||||
time). Defaults to 1M.
|
||||
|
||||
"""
|
||||
self.day_rotation = day_rotation
|
||||
self.max_size = max_size
|
||||
self.size = 0
|
||||
logfile.DailyLogFile.__init__(self, name, directory, defaultMode=defaultMode)
|
||||
|
||||
def _openFile(self):
|
||||
logfile.DailyLogFile._openFile(self)
|
||||
self.size = self._file.tell()
|
||||
|
||||
def shouldRotate(self):
|
||||
"""Rotate when the date has changed since last write"""
|
||||
# all dates here are tuples (year, month, day)
|
||||
now = self.toDate()
|
||||
then = self.lastDate
|
||||
return now[0] > then[0] or now[1] > then[1] or now[2] > (then[2] + self.day_rotation)
|
||||
return (
|
||||
now[0] > then[0]
|
||||
or now[1] > then[1]
|
||||
or now[2] > (then[2] + self.day_rotation)
|
||||
or self.size >= self.max_size
|
||||
)
|
||||
|
||||
def suffix(self, tupledate):
|
||||
"""Return the suffix given a (year, month, day) tuple or unixtime.
|
||||
Format changed to have 03 for march instead of 3 etc (retaining unix file order)
|
||||
Format changed to have 03 for march instead of 3 etc (retaining unix
|
||||
file order)
|
||||
|
||||
If we get duplicate suffixes in location (due to hitting size limit),
|
||||
we append __1, __2 etc.
|
||||
|
||||
Examples:
|
||||
server.log.2020_01_29
|
||||
server.log.2020_01_29__1
|
||||
server.log.2020_01_29__2
|
||||
"""
|
||||
try:
|
||||
return "_".join(["{:02d}".format(part) for part in tupledate])
|
||||
except Exception:
|
||||
# try taking a float unixtime
|
||||
return "_".join(["{:02d}".format(part) for part in self.toDate(tupledate)])
|
||||
suffix = ""
|
||||
copy_suffix = 0
|
||||
while True:
|
||||
try:
|
||||
suffix = "_".join(["{:02d}".format(part) for part in tupledate])
|
||||
except Exception:
|
||||
# try taking a float unixtime
|
||||
suffix = "_".join(["{:02d}".format(part) for part in self.toDate(tupledate)])
|
||||
|
||||
suffix += f"__{copy_suffix}" if copy_suffix else ""
|
||||
|
||||
if os.path.exists(f"{self.path}.{suffix}"):
|
||||
# Append a higher copy_suffix to try to break the tie (starting from 2)
|
||||
copy_suffix += 1
|
||||
else:
|
||||
break
|
||||
return suffix
|
||||
|
||||
def write(self, data):
|
||||
"Write data to log file"
|
||||
logfile.BaseLogFile.write(self, data)
|
||||
self.lastDate = max(self.lastDate, self.toDate())
|
||||
self.size += len(data)
|
||||
|
||||
|
||||
class PortalLogObserver(log.FileLogObserver):
|
||||
|
|
|
|||
|
|
@ -24,8 +24,7 @@ class InMemorySaveHandler(object):
|
|||
|
||||
class OptionHandler(object):
|
||||
"""
|
||||
This is a generic Option handler. It is commonly used
|
||||
implements AttributeHandler. Retrieve options eithers as properties on
|
||||
This is a generic Option handler. Retrieve options either as properties on
|
||||
this handler or by using the .get method.
|
||||
|
||||
This is used for Account.options but it could be used by Scripts or Objects
|
||||
|
|
@ -54,7 +53,7 @@ class OptionHandler(object):
|
|||
It will be called as `savefunc(key, value, **save_kwargs)`. A common one
|
||||
to pass would be AttributeHandler.add.
|
||||
loadfunc (callable): A callable for all options to call when loading data into
|
||||
itself. It will be called as `loadfunc(key, default=default, **load_kwargs)`.
|
||||
itself. It will be called as `loadfunc(key, default=default, **load_kwargs)`.
|
||||
A common one to pass would be AttributeHandler.get.
|
||||
save_kwargs (any): Optional extra kwargs to pass into `savefunc` above.
|
||||
load_kwargs (any): Optional extra kwargs to pass into `loadfunc` above.
|
||||
|
|
@ -116,14 +115,16 @@ class OptionHandler(object):
|
|||
self.options[key] = loaded_option
|
||||
return loaded_option
|
||||
|
||||
def get(self, key, return_obj=False):
|
||||
def get(self, key, default=None, return_obj=False, raise_error=False):
|
||||
"""
|
||||
Retrieves an Option stored in the handler. Will load it if it doesn't exist.
|
||||
|
||||
Args:
|
||||
key (str): The option key to retrieve.
|
||||
default (any): What to return if the option is defined.
|
||||
return_obj (bool, optional): If True, returns the actual option
|
||||
object instead of its value.
|
||||
raise_error (bool, optional): Raise Exception if key is not found in options.
|
||||
Returns:
|
||||
option_value (any or Option): An option value the Option itself.
|
||||
Raises:
|
||||
|
|
@ -131,7 +132,10 @@ class OptionHandler(object):
|
|||
|
||||
"""
|
||||
if key not in self.options_dict:
|
||||
raise KeyError("Option not found!")
|
||||
if raise_error:
|
||||
raise KeyError("Option not found!")
|
||||
return default
|
||||
# get the options or load/recache it
|
||||
op_found = self.options.get(key) or self._load_option(key)
|
||||
return op_found if return_obj else op_found.value
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ from django.forms.fields import CharField
|
|||
from django.forms.widgets import Textarea
|
||||
|
||||
from pickle import loads, dumps
|
||||
from django.utils.encoding import force_text
|
||||
from django.utils.encoding import force_str
|
||||
|
||||
|
||||
DEFAULT_PROTOCOL = 4
|
||||
|
|
@ -133,8 +133,9 @@ class PickledWidget(Textarea):
|
|||
try:
|
||||
# necessary to convert it back after repr(), otherwise validation errors will mutate it
|
||||
value = literal_eval(repr_value)
|
||||
except ValueError:
|
||||
pass
|
||||
except (ValueError, SyntaxError):
|
||||
# we could not eval it, just show its prepresentation
|
||||
value = repr_value
|
||||
return super().render(name, value, attrs=attrs, renderer=renderer)
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
|
|
@ -209,10 +210,10 @@ class PickledObjectField(models.Field):
|
|||
"""
|
||||
Returns the default value for this field.
|
||||
|
||||
The default implementation on models.Field calls force_text
|
||||
The default implementation on models.Field calls force_str
|
||||
on the default, which means you can't set arbitrary Python
|
||||
objects as the default. To fix this, we just return the value
|
||||
without calling force_text on it. Note that if you set a
|
||||
without calling force_str on it. Note that if you set a
|
||||
callable as a default, the field will still call it. It will
|
||||
*not* try to pickle and encode it.
|
||||
|
||||
|
|
@ -266,13 +267,13 @@ class PickledObjectField(models.Field):
|
|||
|
||||
"""
|
||||
if value is not None and not isinstance(value, PickledObject):
|
||||
# We call force_text here explicitly, so that the encoded string
|
||||
# isn't rejected by the postgresql_psycopg2 backend. Alternatively,
|
||||
# We call force_str here explicitly, so that the encoded string
|
||||
# isn't rejected by the postgresql backend. Alternatively,
|
||||
# we could have just registered PickledObject with the psycopg
|
||||
# marshaller (telling it to store it like it would a string), but
|
||||
# since both of these methods result in the same value being stored,
|
||||
# doing things this way is much easier.
|
||||
value = force_text(dbsafe_encode(value, self.compress, self.protocol))
|
||||
value = force_str(dbsafe_encode(value, self.compress, self.protocol))
|
||||
return value
|
||||
|
||||
def value_to_string(self, obj):
|
||||
|
|
|
|||
|
|
@ -95,4 +95,4 @@ class TestGametime(TestCase):
|
|||
self.timescripts.append(script)
|
||||
self.assertIsInstance(script, gametime.TimeScript)
|
||||
self.assertAlmostEqual(script.interval, 12)
|
||||
self.assertEqual(script.repeats, -1)
|
||||
self.assertEqual(script.repeats, 0)
|
||||
|
|
|
|||
|
|
@ -98,6 +98,30 @@ class TestMLen(TestCase):
|
|||
self.assertEqual(utils.m_len({"hello": True, "Goodbye": False}), 2)
|
||||
|
||||
|
||||
class TestANSIString(TestCase):
|
||||
"""
|
||||
Verifies that ANSIString's string-API works as intended.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.example_raw = "|relectric |cboogaloo|n"
|
||||
self.example_ansi = ANSIString(self.example_raw)
|
||||
self.example_str = "electric boogaloo"
|
||||
self.example_output = "\x1b[1m\x1b[31melectric \x1b[1m\x1b[36mboogaloo\x1b[0m"
|
||||
|
||||
def test_length(self):
|
||||
self.assertEqual(len(self.example_ansi), 17)
|
||||
|
||||
def test_clean(self):
|
||||
self.assertEqual(self.example_ansi.clean(), self.example_str)
|
||||
|
||||
def test_raw(self):
|
||||
self.assertEqual(self.example_ansi.raw(), self.example_output)
|
||||
|
||||
def test_format(self):
|
||||
self.assertEqual(f"{self.example_ansi:0<20}", self.example_output + "000")
|
||||
|
||||
|
||||
class TestTimeformat(TestCase):
|
||||
"""
|
||||
Default function header from utils.py:
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ class TestValidatorFuncs(TestCase):
|
|||
self.assertTrue(
|
||||
isinstance(validatorfuncs.datetime(dt, from_tz=pytz.UTC), datetime.datetime)
|
||||
)
|
||||
account = mock.MagicMock()
|
||||
account.options.get = mock.MagicMock(return_value="America/Chicago")
|
||||
expected = datetime.datetime(1492, 10, 12, 6, 51, tzinfo=pytz.UTC)
|
||||
self.assertEqual(expected, validatorfuncs.datetime("Oct 12 1:00 1492", account=account))
|
||||
account.options.get.assert_called_with("timezone", "UTC")
|
||||
|
||||
def test_datetime_raises_ValueError(self):
|
||||
for dt in ["", "January 1, 2019", "1/1/2019", "Jan 1 2019"]:
|
||||
|
|
@ -121,6 +126,7 @@ class TestValidatorFuncs(TestCase):
|
|||
validatorfuncs.boolean(b)
|
||||
|
||||
def test_timezone_ok(self):
|
||||
|
||||
for tz in ["America/Chicago", "GMT", "UTC"]:
|
||||
self.assertEqual(tz, validatorfuncs.timezone(tz).zone)
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ from collections import defaultdict, OrderedDict
|
|||
from twisted.internet import threads, reactor
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from django.apps import apps
|
||||
from evennia.utils import logger
|
||||
|
||||
|
|
@ -1036,7 +1036,7 @@ def uses_database(name="sqlite3"):
|
|||
shortcut to having to use the full backend name.
|
||||
|
||||
Args:
|
||||
name (str): One of 'sqlite3', 'mysql', 'postgresql_psycopg2'
|
||||
name (str): One of 'sqlite3', 'mysql', 'postgresql'
|
||||
or 'oracle'.
|
||||
|
||||
Returns:
|
||||
|
|
|
|||
|
|
@ -40,24 +40,36 @@ def color(entry, option_key="Color", **kwargs):
|
|||
|
||||
def datetime(entry, option_key="Datetime", account=None, from_tz=None, **kwargs):
|
||||
"""
|
||||
Process a datetime string in standard forms while accounting for the inputter's timezone.
|
||||
Process a datetime string in standard forms while accounting for the inputer's timezone. Always
|
||||
returns a result in UTC.
|
||||
|
||||
Args:
|
||||
entry (str): A date string from a user.
|
||||
option_key (str): Name to display this datetime as.
|
||||
account (AccountDB): The Account performing this lookup. Unless from_tz is provided,
|
||||
account's timezone will be used (if found) for local time and convert the results
|
||||
to UTC.
|
||||
from_tz (pytz): An instance of pytz from the user. If not provided, defaults to whatever
|
||||
the Account uses. If neither one is provided, defaults to UTC.
|
||||
|
||||
account (AccountDB): The Account performing this lookup. Unless `from_tz` is provided,
|
||||
the account's timezone option will be used.
|
||||
from_tz (pytz.timezone): An instance of a pytz timezone object from the
|
||||
user. If not provided, tries to use the timezone option of the `account'.
|
||||
If neither one is provided, defaults to UTC.
|
||||
Returns:
|
||||
datetime in utc.
|
||||
datetime in UTC.
|
||||
Raises:
|
||||
ValueError: If encountering a malformed timezone, date string or other format error.
|
||||
|
||||
"""
|
||||
if not entry:
|
||||
raise ValueError(f"No {option_key} entered!")
|
||||
if not from_tz:
|
||||
from_tz = _pytz.UTC
|
||||
if account:
|
||||
acct_tz = account.options.get("timezone", "UTC")
|
||||
try:
|
||||
from_tz = _pytz.timezone(acct_tz)
|
||||
except Exception as err:
|
||||
raise ValueError(f"Timezone string '{acct_tz}' is not a valid timezone ({err})")
|
||||
else:
|
||||
from_tz = _pytz.UTC
|
||||
|
||||
utc = _pytz.UTC
|
||||
now = _dt.datetime.utcnow().replace(tzinfo=utc)
|
||||
cur_year = now.strftime("%Y")
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
# http://diveintopython.org/regular_expressions/street_addresses.html#re.matching.2.3
|
||||
#
|
||||
|
||||
from django.conf.urls import url, include
|
||||
from django.urls import path, include
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
# Setup the root url tree from /
|
||||
|
|
@ -14,9 +14,9 @@ from django.views.generic import RedirectView
|
|||
urlpatterns = [
|
||||
# Front page (note that we shouldn't specify namespace here since we will
|
||||
# not be able to load django-auth/admin stuff (will probably work in Django>1.9)
|
||||
url(r"^", include("evennia.web.website.urls")), # , namespace='website', app_name='website')),
|
||||
path("", include("evennia.web.website.urls")),
|
||||
# webclient
|
||||
url(r"^webclient/", include("evennia.web.webclient.urls", namespace="webclient")),
|
||||
path("webclient/", include("evennia.web.webclient.urls")),
|
||||
# favicon
|
||||
url(r"^favicon\.ico$", RedirectView.as_view(url="/media/images/favicon.ico", permanent=False)),
|
||||
path("favicon.ico", RedirectView.as_view(url="/media/images/favicon.ico", permanent=False)),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -61,3 +61,12 @@ class SharedLoginMiddleware(object):
|
|||
login(request, account)
|
||||
except AttributeError:
|
||||
logger.log_trace()
|
||||
|
||||
if csession.get("webclient_authenticated_uid", None):
|
||||
# set a nonce to prevent the webclient from erasing the webclient_authenticated_uid value
|
||||
csession["webclient_authenticated_nonce"] = (
|
||||
csession.get("webclient_authenticated_nonce", 0) + 1
|
||||
)
|
||||
# wrap around to prevent integer overflows
|
||||
if csession["webclient_authenticated_nonce"] > 32:
|
||||
csession["webclient_authenticated_nonce"] = 0
|
||||
|
|
|
|||
|
|
@ -88,6 +88,10 @@ div {margin:0px;}
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
/* Container surrounding entire client */
|
||||
#clientwrapper {
|
||||
height: 100%;
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
79
evennia/web/webclient/static/webclient/js/plugins/font.js
Normal file
79
evennia/web/webclient/static/webclient/js/plugins/font.js
Normal 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);
|
||||
|
|
@ -383,6 +383,14 @@ let goldenlayout = (function () {
|
|||
// Public
|
||||
//
|
||||
|
||||
//
|
||||
// helper accessor for other plugins to add new known-message types
|
||||
var addKnownType = function (newtype) {
|
||||
if( knownTypes.includes(newtype) == false ) {
|
||||
knownTypes.push(newtype);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
//
|
||||
|
|
@ -526,7 +534,7 @@ let goldenlayout = (function () {
|
|||
onKeydown: onKeydown,
|
||||
onText: onText,
|
||||
getGL: function () { return myLayout; },
|
||||
addKnownType: function (newtype) { knownTypes.push(newtype); },
|
||||
addKnownType: addKnownType,
|
||||
}
|
||||
}());
|
||||
window.plugin_handler.add("goldenlayout", goldenlayout);
|
||||
|
|
|
|||
68
evennia/web/webclient/static/webclient/js/plugins/iframe.js
Normal file
68
evennia/web/webclient/static/webclient/js/plugins/iframe.js
Normal 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);
|
||||
|
|
@ -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);
|
||||
184
evennia/web/webclient/static/webclient/js/plugins/options2.js
Normal file
184
evennia/web/webclient/static/webclient/js/plugins/options2.js
Normal 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'>⚙</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
Loading…
Add table
Add a link
Reference in a new issue