diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8cecd0de21..13c450939f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,126 @@
-# Evennia Changelog
+# Changelog
-# Sept 2017:
-Release of Evennia 0.7; upgrade to Django 1.11, change 'Player' to
+## Evennia 0.8 (2018)
+
+### Requirements
+
+- Up requirements to Django 1.11.x, Twisted 18 and pillow 5.2.0
+- Add `autobahn` dependency for Websocket support, removing very old embedded txWS library (from a
+ time before websocket specification was still not fixed).
+- Add `inflect` dependency for automatic pluralization of object names.
+
+### Server/Portal
+
+- Removed `evennia_runner`, completely refactor `evennia_launcher.py` (the 'evennia' program)
+ with different functionality).
+- Both Portal/Server are now stand-alone processes (easy to run as daemon)
+- Made Portal the AMP Server for starting/restarting the Server (the AMP client)
+- Dynamic logging now happens using `evennia -l` rather than by interactive mode.
+- Made AMP secure against erroneous HTTP requests on the wrong port (return error messages).
+- The `evennia istart` option will start/switch the Server in foreground (interactive) mode, where it logs
+ to terminal and can be stopped with Ctrl-C. Using `evennia reload`, or reloading in-game, will
+ return Server to normal daemon operation.
+- For validating passwords, use safe Django password-validation backend instead of custom Evennia one.
+- Alias `evennia restart` to mean the same as `evennia reload`.
+
+### Prototype changes
+
+- New OLC started from `olc` command for loading/saving/manipulating prototypes in a menu.
+- Moved evennia/utils/spawner.py into the new evennia/prototypes/ along with all new
+ functionality around prototypes.
+- A new form of prototype - database-stored prototypes, editable from in-game, was added. The old,
+ module-created prototypes remain as read-only prototypes.
+- All prototypes must have a key `prototype_key` identifying the prototype in listings. This is
+ checked to be server-unique. Prototypes created in a module will use the global variable name they
+ are assigned to if no `prototype_key` is given.
+- Prototype field `prototype` was renamed to `prototype_parent` to avoid mixing terms.
+- All prototypes must either have `typeclass` or `prototype_parent` defined. If using
+ `prototype_parent`, `typeclass` must be defined somewhere in the inheritance chain. This is a
+ change from Evennia 0.7 which allowed 'mixin' prototypes without `typeclass`/`prototype_key`. To
+ make a mixin now, give it a default typeclass, like `evennia.objects.objects.DefaultObject` and just
+ override in the child as needed.
+- Spawning an object using a prototype will automatically assign a new tag to it, named the same as
+ the `prototype_key` and with the category `from_prototype`.
+- The spawn command was extended to accept a full prototype on one line.
+- The spawn command got the /save switch to save the defined prototype and its key
+- The command spawn/menu will now start an OLC (OnLine Creation) menu to load/save/edit/spawn prototypes.
+
+### EvMenu
+
+- Added `EvMenu.helptext_formatter(helptext)` to allow custom formatting of per-node help.
+- Added `evennia.utils.evmenu.list_node` decorator for turning an EvMenu node into a multi-page listing.
+- A `goto` option callable returning None (rather than the name of the next node) will now rerun the
+ current node instead of failing.
+- Better error handling of in-node syntax errors.
+- Improve dedent of default text/helptext formatter. Right-strip whitespace.
+- Add `debug` option when creating menu - this turns off persistence and makes the `menudebug`
+ command available for examining the current menu state.
+
+
+### Webclient
+
+- Webclient now uses a plugin system to inject new components from the html file.
+- Split-windows - divide input field into any number of horizontal/vertical panes and
+ assign different types of server messages to them.
+- Lots of cleanup and bug fixes.
+- Hot buttons plugin (friarzen) (disabled by default).
+
+### Locks
+
+- New function `evennia.locks.lockhandler.check_lockstring`. This allows for checking an object
+ against an arbitrary lockstring without needing the lock to be stored on an object first.
+- New function `evennia.locks.lockhandler.validate_lockstring` allows for stand-alone validation
+ of a lockstring.
+- New function `evennia.locks.lockhandler.get_all_lockfuncs` gives a dict {"name": lockfunc} for
+ all available lock funcs. This is useful for dynamic listings.
+
+
+### Utils
+
+- Added new `columnize` function for easily splitting text into multiple columns. At this point it
+ is not working too well with ansi-colored text however.
+- Extend the `dedent` function with a new `baseline_index` kwarg. This allows to force all lines to
+ the indentation given by the given line regardless of if other lines were already a 0 indentation.
+ This removes a problem with the original `textwrap.dedent` which will only dedent to the least
+ indented part of a text.
+- Added `exit_cmd` to EvMore pager, to allow for calling a command (e.g. 'look') when leaving the pager.
+- `get_all_typeclasses` will return dict `{"path": typeclass, ...}` for all typeclasses available
+ in the system. This is used by the new `@typeclass/list` subcommand (useful for builders etc).
+- `evennia.utils.dbserialize.deserialize(obj)` is a new helper function to *completely* disconnect
+ a mutable recovered from an Attribute from the database. This will convert all nested `_Saver*`
+ classes to their plain-Python counterparts.
+
+### General
+
+- Start structuring the `CHANGELOG` to list features in more detail.
+- Docker image `evennia/evennia:develop` is now auto-built, tracking the develop branch.
+- Inflection and grouping of multiple objects in default room (an box, three boxes)
+- `evennia.set_trace()` is now a shortcut for launching pdb/pudb on a line in the Evennia event loop.
+- Removed the enforcing of `MAX_NR_CHARACTERS=1` for `MULTISESSION_MODE` `0` and `1` by default.
+- Add `evennia.utils.logger.log_sec` for logging security-related messages (marked SS in log).
+
+### Contribs
+
+- `Auditing` (Johnny): Log and filter server input/output for security purposes
+- `Build Menu` (vincent-lg): New @edit command to edit object properties in a menu.
+- `Field Fill` (Tim Ashley Jenkins): Wraps EvMenu for creating submittable forms.
+- `Health Bar` (Tim Ashley Jenkins): Easily create colorful bars/meters.
+- `Tree select` (Fluttersprite): Wrap EvMenu to create a common type of menu from a string.
+- `Turnbattle suite` (Tim Ashley Jenkins)- the old `turnbattle.py` was moved into its own
+ `turnbattle/` package and reworked with many different flavors of combat systems:
+ - `tb_basic` - The basic turnbattle system, with initiative/turn order attack/defense/damage.
+ - `tb_equip` - Adds weapon and armor, wielding, accuracy modifiers.
+ - `tb_items` - Extends `tb_equip` with item use with conditions/status effects.
+ - `tb_magic` - Extends `tb_equip` with spellcasting.
+ - `tb_range` - Adds system for abstract positioning and movement.
+- Updates and some cleanup of existing contribs.
+
+# Overviews
+
+## Sept 2017:
+Release of Evennia 0.7; upgrade to Django 1.11, change 'Player' to
'Account', rework the website template and a slew of other updates.
-Info on what changed and how to migrat is found here:
+Info on what changed and how to migrate is found here:
https://groups.google.com/forum/#!msg/evennia/0JYYNGY-NfE/cDFaIwmPBAAJ
## Feb 2017:
@@ -14,9 +131,9 @@ Lots of bugfixes and considerable uptick in contributors. Unittest coverage
and PEP8 adoption and refactoring.
## May 2016:
-Evennia 0.6 with completely reworked Out-of-band system, making
+Evennia 0.6 with completely reworked Out-of-band system, making
the message path completely flexible and built around input/outputfuncs.
-A completely new webclient, split into the evennia.js library and a
+A completely new webclient, split into the evennia.js library and a
gui library, making it easier to customize.
## Feb 2016:
@@ -33,15 +150,15 @@ library format with a stand-alone launcher, in preparation for making
an 'evennia' pypy package and using versioning. The version we will
merge with will likely be 0.5. There is also work with an expanded
testing structure and the use of threading for saves. We also now
-use Travis for automatic build checking.
+use Travis for automatic build checking.
## Sept 2014:
Updated to Django 1.7+ which means South dependency was dropped and
minimum Python version upped to 2.7. MULTISESSION_MODE=3 was added
-and the web customization system was overhauled using the latest
-functionality of django. Otherwise, mostly bug-fixes and
+and the web customization system was overhauled using the latest
+functionality of django. Otherwise, mostly bug-fixes and
implementation of various smaller feature requests as we got used
-to github. Many new users have appeared.
+to github. Many new users have appeared.
## Jan 2014:
Moved Evennia project from Google Code to github.com/evennia/evennia.
diff --git a/Dockerfile b/Dockerfile
index 3f55973d70..961d3ad8ed 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -7,7 +7,7 @@
# Usage:
# cd to a folder where you want your game data to be (or where it already is).
#
-# docker run -it -p 4000:4000 -p 4001:4001 -p 4005:4005 -v $PWD:/usr/src/game evennia/evennia
+# docker run -it --rm -p 4000:4000 -p 4001:4001 -p 4005:4005 -v $PWD:/usr/src/game evennia/evennia
#
# (If your OS does not support $PWD, replace it with the full path to your current
# folder).
@@ -15,6 +15,14 @@
# You will end up in a shell where the `evennia` command is available. From here you
# can install and run the game normally. Use Ctrl-D to exit the evennia docker container.
#
+# You can also start evennia directly by passing arguments to the folder:
+#
+# docker run -it --rm -p 4000:4000 -p 4001:4001 -p 4005:4005 -v $PWD:/usr/src/game evennia/evennia evennia start -l
+#
+# This will start Evennia running as the core process of the container. Note that you *must* use -l
+# or one of the foreground modes (like evennia ipstart) since otherwise the container will immediately
+# die since no foreground process keeps it up.
+#
# The evennia/evennia base image is found on DockerHub and can also be used
# as a base for creating your own custom containerized Evennia game. For more
# info, see https://github.com/evennia/evennia/wiki/Running%20Evennia%20in%20Docker .
@@ -34,7 +42,7 @@ COPY ./evennia/VERSION.txt /usr/src/evennia/evennia/
COPY ./bin /usr/src/evennia/bin/
# install dependencies
-RUN pip install -e /usr/src/evennia --trusted-host pypi.python.org
+RUN pip install --upgrade pip && pip install -e /usr/src/evennia --trusted-host pypi.python.org
RUN pip install cryptography pyasn1 service_identity
# add the project source; this should always be done after all
@@ -58,7 +66,7 @@ WORKDIR /usr/src/game
ENV PS1 "evennia|docker \w $ "
# startup a shell when we start the container
-ENTRYPOINT ["bash"]
+ENTRYPOINT ["/usr/src/evennia/bin/unix/evennia-docker-start.sh"]
# expose the telnet, webserver and websocket client ports
EXPOSE 4000 4001 4005
diff --git a/bin/unix/evennia-docker-start.sh b/bin/unix/evennia-docker-start.sh
new file mode 100755
index 0000000000..5c87052da9
--- /dev/null
+++ b/bin/unix/evennia-docker-start.sh
@@ -0,0 +1,18 @@
+#! /bin/sh
+
+# called by the Dockerfile to start the server in docker mode
+
+# remove leftover .pid files (such as from when dropping the container)
+rm /usr/src/game/server/*.pid >& /dev/null || true
+
+PS1="evennia|docker \w $ "
+
+cmd="$@"
+output="Docker starting with argument '$cmd' ..."
+if test -z $cmd; then
+ cmd="bash"
+ output="No argument given, starting shell ..."
+fi
+
+echo $output
+exec 3>&1; $cmd
diff --git a/bin/unix/evennia.service b/bin/unix/evennia.service
new file mode 100644
index 0000000000..a312bb8b4e
--- /dev/null
+++ b/bin/unix/evennia.service
@@ -0,0 +1,34 @@
+# Evennia systemd unit script
+#
+# Copy this to /usr/lib/systemd/system/ and Edit the paths to match your game.
+#
+# Then, register with systemd using:
+#
+# sudo systemctl daemon-reload
+# sudo systemctl enable evennia.service
+#
+
+[Unit]
+Description=Evennia Server
+
+[Service]
+Type=simple
+
+#
+# Change this to the user the game should run as.
+# Don't run this as root. Please, I beg you.
+#
+User=your-user
+
+#
+# The command to start Evennia as a Systemd service. NOTE: These must be absolute paths.
+# Replace /your/path/to with whatever is appropriate.
+#
+ExecStart=/your/path/to/pyenv/bin/python /your/path/to/evennia/bin/unix/evennia ipstart --gamedir /your/path/to/mygame
+
+# restart on all failures, wait 3 seconds before doing so.
+Restart=on-failure
+RestartSec=3
+
+ [Install]
+WantedBy=default.target
diff --git a/evennia/VERSION.txt b/evennia/VERSION.txt
index faef31a435..a3df0a6959 100644
--- a/evennia/VERSION.txt
+++ b/evennia/VERSION.txt
@@ -1 +1 @@
-0.7.0
+0.8.0
diff --git a/evennia/__init__.py b/evennia/__init__.py
index 6fdc4aaece..e04ab84dba 100644
--- a/evennia/__init__.py
+++ b/evennia/__init__.py
@@ -174,7 +174,7 @@ def _init():
from .utils import logger
from .utils import gametime
from .utils import ansi
- from .utils.spawner import spawn
+ from .prototypes.spawner import spawn
from . import contrib
from .utils.evmenu import EvMenu
from .utils.evtable import EvTable
@@ -319,3 +319,59 @@ def _init():
del object
del absolute_import
del print_function
+
+
+def set_trace(debugger="auto", term_size=(140, 40)):
+ """
+ Helper function for running a debugger inside the Evennia event loop.
+
+ Args:
+ debugger (str, optional): One of 'auto', 'pdb' or 'pudb'. Pdb is the standard debugger. Pudb
+ is an external package with a different, more 'graphical', ncurses-based UI. With
+ 'auto', will use pudb if possible, otherwise fall back to pdb. Pudb is available through
+ `pip install pudb`.
+ term_size (tuple, optional): Only used for Pudb and defines the size of the terminal
+ (width, height) in number of characters.
+
+ Notes:
+ To use:
+
+ 1) add this to a line to act as a breakpoint for entering the debugger:
+
+ from evennia import set_trace; set_trace()
+
+ 2) restart evennia in interactive mode
+
+ evennia istart
+
+ 3) debugger will appear in the interactive terminal when breakpoint is reached. Exit
+ with 'q', remove the break line and restart server when finished.
+
+ """
+ import sys
+ dbg = None
+ pudb_mode = False
+
+ if debugger in ('auto', 'pudb'):
+ try:
+ from pudb import debugger
+ dbg = debugger.Debugger(stdout=sys.__stdout__,
+ term_size=term_size)
+ pudb_mode = True
+ except ImportError:
+ if debugger == 'pudb':
+ raise
+ pass
+
+ if not dbg:
+ import pdb
+ dbg = pdb.Pdb(stdout=sys.__stdout__)
+ pudb_mode = False
+
+ if pudb_mode:
+ # Stopped at breakpoint. Press 'n' to continue into the code.
+ dbg.set_trace()
+ else:
+ # Start debugger, forcing it up one stack frame (otherwise `set_trace` will start debugger
+ # this point, not the actual code location)
+ dbg.set_trace(sys._getframe().f_back)
diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py
index b32c3a31cb..3eab69a3ce 100644
--- a/evennia/accounts/accounts.py
+++ b/evennia/accounts/accounts.py
@@ -13,6 +13,8 @@ instead for most things).
import time
from django.conf import settings
+from django.contrib.auth import password_validation
+from django.core.exceptions import ValidationError
from django.utils import timezone
from evennia.typeclasses.models import TypeclassBase
from evennia.accounts.manager import AccountManager
@@ -357,6 +359,65 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
puppet = property(__get_single_puppet)
# utility methods
+ @classmethod
+ def validate_password(cls, password, account=None):
+ """
+ Checks the given password against the list of Django validators enabled
+ in the server.conf file.
+
+ Args:
+ password (str): Password to validate
+
+ Kwargs:
+ account (DefaultAccount, optional): Account object to validate the
+ password for. Optional, but Django includes some validators to
+ do things like making sure users aren't setting passwords to the
+ same value as their username. If left blank, these user-specific
+ checks are skipped.
+
+ Returns:
+ valid (bool): Whether or not the password passed validation
+ error (ValidationError, None): Any validation error(s) raised. Multiple
+ errors can be nested within a single object.
+
+ """
+ valid = False
+ error = None
+
+ # Validation returns None on success; invert it and return a more sensible bool
+ try:
+ valid = not password_validation.validate_password(password, user=account)
+ except ValidationError as e:
+ error = e
+
+ return valid, error
+
+ def set_password(self, password, force=False):
+ """
+ Applies the given password to the account if it passes validation checks.
+ Can be overridden by using the 'force' flag.
+
+ Args:
+ password (str): Password to set
+
+ Kwargs:
+ force (bool): Sets password without running validation checks.
+
+ Raises:
+ ValidationError
+
+ Returns:
+ None (None): Does not return a value.
+
+ """
+ if not force:
+ # Run validation checks
+ valid, error = self.validate_password(password, account=self)
+ if error: raise error
+
+ super(DefaultAccount, self).set_password(password)
+ logger.log_info("Password succesfully changed for %s." % self)
+ self.at_password_change()
def delete(self, *args, **kwargs):
"""
@@ -633,10 +694,31 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
# this will only be set if the utils.create_account
# function was used to create the object.
cdict = self._createdict
+ updates = []
+ if not cdict.get("key"):
+ if not self.db_key:
+ self.db_key = "#%i" % self.dbid
+ updates.append("db_key")
+ elif self.key != cdict.get("key"):
+ updates.append("db_key")
+ self.db_key = cdict["key"]
+ if updates:
+ self.save(update_fields=updates)
+
if cdict.get("locks"):
self.locks.add(cdict["locks"])
if cdict.get("permissions"):
permissions = cdict["permissions"]
+ if cdict.get("tags"):
+ # this should be a list of tags, tuples (key, category) or (key, category, data)
+ self.tags.batch_add(*cdict["tags"])
+ if cdict.get("attributes"):
+ # this should be tuples (key, val, ...)
+ self.attributes.batch_add(*cdict["attributes"])
+ if cdict.get("nattributes"):
+ # this should be a dict of nattrname:value
+ for key, value in cdict["nattributes"]:
+ self.nattributes.add(key, value)
del self._createdict
self.permissions.batch_add(*permissions)
@@ -694,6 +776,17 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
"""
pass
+ def at_password_change(self, **kwargs):
+ """
+ Called after a successful password set/modify.
+
+ Args:
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ pass
+
def at_pre_login(self, **kwargs):
"""
Called every time the user logs in, just before the actual
@@ -773,7 +866,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
elif _MULTISESSION_MODE in (2, 3):
# In this mode we by default end up at a character selection
# screen. We execute look on the account.
- # we make sure to clean up the _playable_characers list in case
+ # we make sure to clean up the _playable_characters list in case
# any was deleted in the interim.
self.db._playable_characters = [char for char in self.db._playable_characters if char]
self.msg(self.at_look(target=self.db._playable_characters,
@@ -908,7 +1001,10 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
if target and not is_iter(target):
# single target - just show it
- return target.return_appearance(self)
+ if hasattr(target, "return_appearance"):
+ return target.return_appearance(self)
+ else:
+ return "{} has no in-game appearance.".format(target)
else:
# list of targets - make list to disconnect from db
characters = list(tar for tar in target if tar) if target else []
@@ -929,7 +1025,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
result.append("\n\n |whelp|n - more commands")
result.append("\n |wooc |n - talk on public channel")
- charmax = _MAX_NR_CHARACTERS if _MULTISESSION_MODE > 1 else 1
+ charmax = _MAX_NR_CHARACTERS
if is_su or len(characters) < charmax:
if not characters:
diff --git a/evennia/accounts/manager.py b/evennia/accounts/manager.py
index c612cf930d..5d9bda2ab9 100644
--- a/evennia/accounts/manager.py
+++ b/evennia/accounts/manager.py
@@ -35,7 +35,6 @@ class AccountDBManager(TypedObjectManager, UserManager):
get_account_from_uid
get_account_from_name
account_search (equivalent to evennia.search_account)
- #swap_character
"""
diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py
index 039a25601f..78ee87f37d 100644
--- a/evennia/accounts/tests.py
+++ b/evennia/accounts/tests.py
@@ -1,7 +1,8 @@
-from mock import Mock
+from mock import Mock, MagicMock
from random import randint
from unittest import TestCase
+from django.test import override_settings
from evennia.accounts.accounts import AccountSessionHandler
from evennia.accounts.accounts import DefaultAccount
from evennia.server.session import Session
@@ -14,9 +15,15 @@ class TestAccountSessionHandler(TestCase):
"Check AccountSessionHandler class"
def setUp(self):
- self.account = create.create_account("TestAccount%s" % randint(0, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount)
+ self.account = create.create_account(
+ "TestAccount%s" % randint(0, 999999), email="test@test.com",
+ password="testpassword", typeclass=DefaultAccount)
self.handler = AccountSessionHandler(self.account)
+ def tearDown(self):
+ if hasattr(self, 'account'):
+ self.account.delete()
+
def test_get(self):
"Check get method"
self.assertEqual(self.handler.get(), [])
@@ -24,24 +31,24 @@ class TestAccountSessionHandler(TestCase):
import evennia.server.sessionhandler
- s1 = Session()
+ s1 = MagicMock()
s1.logged_in = True
s1.uid = self.account.uid
evennia.server.sessionhandler.SESSIONS[s1.uid] = s1
- s2 = Session()
+ s2 = MagicMock()
s2.logged_in = True
s2.uid = self.account.uid + 1
evennia.server.sessionhandler.SESSIONS[s2.uid] = s2
- s3 = Session()
+ s3 = MagicMock()
s3.logged_in = False
s3.uid = self.account.uid + 2
evennia.server.sessionhandler.SESSIONS[s3.uid] = s3
- self.assertEqual(self.handler.get(), [s1])
- self.assertEqual(self.handler.get(self.account.uid), [s1])
- self.assertEqual(self.handler.get(self.account.uid + 1), [])
+ self.assertEqual([s.uid for s in self.handler.get()], [s1.uid])
+ self.assertEqual([s.uid for s in [self.handler.get(self.account.uid)]], [s1.uid])
+ self.assertEqual([s.uid for s in self.handler.get(self.account.uid + 1)], [])
def test_all(self):
"Check all method"
@@ -51,12 +58,44 @@ class TestAccountSessionHandler(TestCase):
"Check count method"
self.assertEqual(self.handler.count(), len(self.handler.get()))
+
class TestDefaultAccount(TestCase):
"Check DefaultAccount class"
def setUp(self):
- self.s1 = Session()
+ self.s1 = MagicMock()
+ self.s1.puppet = None
self.s1.sessid = 0
+ self.s1.data_outj
+
+ def tearDown(self):
+ if hasattr(self, "account"):
+ self.account.delete()
+
+ def test_password_validation(self):
+ "Check password validators deny bad passwords"
+
+ self.account = create.create_account("TestAccount%s" % randint(0, 9),
+ email="test@test.com", password="testpassword", typeclass=DefaultAccount)
+ for bad in ('', '123', 'password', 'TestAccount', '#', 'xyzzy'):
+ self.assertFalse(self.account.validate_password(bad, account=self.account)[0])
+
+ "Check validators allow sufficiently complex passwords"
+ for better in ('Mxyzptlk', "j0hn, i'M 0n1y d4nc1nG"):
+ self.assertTrue(self.account.validate_password(better, account=self.account)[0])
+
+ def test_password_change(self):
+ "Check password setting and validation is working as expected"
+ self.account = create.create_account("TestAccount%s" % randint(0, 9),
+ email="test@test.com", password="testpassword", typeclass=DefaultAccount)
+
+ from django.core.exceptions import ValidationError
+ # Try setting some bad passwords
+ for bad in ('', '#', 'TestAccount', 'password'):
+ self.assertRaises(ValidationError, self.account.set_password, bad)
+
+ # Try setting a better password (test for False; returns None on success)
+ self.assertFalse(self.account.set_password('Mxyzptlk'))
def test_puppet_object_no_object(self):
"Check puppet_object method called with no object param"
@@ -65,7 +104,7 @@ class TestDefaultAccount(TestCase):
DefaultAccount().puppet_object(self.s1, None)
self.fail("Expected error: 'Object not found'")
except RuntimeError as re:
- self.assertEqual("Object not found", re.message)
+ self.assertEqual("Object not found", str(re))
def test_puppet_object_no_session(self):
"Check puppet_object method called with no session param"
@@ -74,14 +113,16 @@ class TestDefaultAccount(TestCase):
DefaultAccount().puppet_object(None, Mock())
self.fail("Expected error: 'Session not found'")
except RuntimeError as re:
- self.assertEqual("Session not found", re.message)
+ self.assertEqual("Session not found", str(re))
def test_puppet_object_already_puppeting(self):
"Check puppet_object method called, already puppeting this"
import evennia.server.sessionhandler
- account = create.create_account("TestAccount%s" % randint(0, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount)
+ account = create.create_account(
+ "TestAccount%s" % randint(0, 999999), email="test@test.com",
+ password="testpassword", typeclass=DefaultAccount)
self.s1.uid = account.uid
evennia.server.sessionhandler.SESSIONS[self.s1.uid] = self.s1
@@ -103,10 +144,7 @@ class TestDefaultAccount(TestCase):
self.s1.uid = account.uid
evennia.server.sessionhandler.SESSIONS[self.s1.uid] = self.s1
- self.s1.puppet = None
- self.s1.logged_in = True
- self.s1.data_out = Mock(return_value=None)
-
+ self.s1.data_out = MagicMock()
obj = Mock()
obj.access = Mock(return_value=False)
@@ -115,6 +153,7 @@ class TestDefaultAccount(TestCase):
self.assertTrue(self.s1.data_out.call_args[1]['text'].startswith("You don't have permission to puppet"))
self.assertIsNone(obj.at_post_puppet.call_args)
+ @override_settings(MULTISESSION_MODE=0)
def test_puppet_object_joining_other_session(self):
"Check puppet_object method called, joining other session"
@@ -126,15 +165,16 @@ class TestDefaultAccount(TestCase):
self.s1.puppet = None
self.s1.logged_in = True
- self.s1.data_out = Mock(return_value=None)
+ self.s1.data_out = MagicMock()
obj = Mock()
obj.access = Mock(return_value=True)
obj.account = account
+ obj.sessions.all = MagicMock(return_value=[self.s1])
account.puppet_object(self.s1, obj)
# works because django.conf.settings.MULTISESSION_MODE is not in (1, 3)
- self.assertTrue(self.s1.data_out.call_args[1]['text'].endswith("from another of your sessions."))
+ self.assertTrue(self.s1.data_out.call_args[1]['text'].endswith("from another of your sessions.|n"))
self.assertTrue(obj.at_post_puppet.call_args[1] == {})
def test_puppet_object_already_puppeted(self):
@@ -143,6 +183,7 @@ class TestDefaultAccount(TestCase):
import evennia.server.sessionhandler
account = create.create_account("TestAccount%s" % randint(0, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount)
+ self.account = account
self.s1.uid = account.uid
evennia.server.sessionhandler.SESSIONS[self.s1.uid] = self.s1
diff --git a/evennia/commands/cmdsethandler.py b/evennia/commands/cmdsethandler.py
index 84eea1fdc5..14d195599c 100644
--- a/evennia/commands/cmdsethandler.py
+++ b/evennia/commands/cmdsethandler.py
@@ -586,11 +586,9 @@ class CmdSetHandler(object):
"""
if callable(cmdset) and hasattr(cmdset, 'path'):
# try it as a callable
- print "Try callable", cmdset
if must_be_default:
return self.cmdset_stack and (self.cmdset_stack[0].path == cmdset.path)
else:
- print [cset.path for cset in self.cmdset_stack], cmdset.path
return any([cset for cset in self.cmdset_stack
if cset.path == cmdset.path])
else:
diff --git a/evennia/commands/command.py b/evennia/commands/command.py
index 48a4b132da..17902b3602 100644
--- a/evennia/commands/command.py
+++ b/evennia/commands/command.py
@@ -297,7 +297,7 @@ class Command(with_metaclass(CommandMeta, object)):
Args:
srcobj (Object): Object trying to gain permission
access_type (str, optional): The lock type to check.
- default (bool, optional): The fallbacl result if no lock
+ default (bool, optional): The fallback result if no lock
of matching `access_type` is found on this Command.
"""
diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py
index 9a4d2d330c..b50f55a8e0 100644
--- a/evennia/commands/default/account.py
+++ b/evennia/commands/default/account.py
@@ -136,7 +136,7 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS):
key = self.lhs
desc = self.rhs
- charmax = _MAX_NR_CHARACTERS if _MULTISESSION_MODE > 1 else 1
+ charmax = _MAX_NR_CHARACTERS
if not account.is_superuser and \
(account.db._playable_characters and
@@ -168,7 +168,7 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS):
if desc:
new_character.db.desc = desc
elif not new_character.db.desc:
- new_character.db.desc = "This is an Account."
+ new_character.db.desc = "This is a character."
self.msg("Created new character %s. Use |w@ic %s|n to enter the game as this character."
% (new_character.key, new_character.key))
@@ -455,7 +455,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS):
Usage:
@option[/save] [name = value]
- Switch:
+ Switches:
save - Save the current option settings for future logins.
clear - Clear the saved options.
@@ -467,6 +467,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS):
"""
key = "@option"
aliases = "@options"
+ switch_options = ("save", "clear")
locks = "cmd:all()"
# this is used by the parent
@@ -626,10 +627,16 @@ class CmdPassword(COMMAND_DEFAULT_CLASS):
return
oldpass = self.lhslist[0] # Both of these are
newpass = self.rhslist[0] # already stripped by parse()
+
+ # Validate password
+ validated, error = account.validate_password(newpass)
+
if not account.check_password(oldpass):
self.msg("The specified old password isn't correct.")
- elif len(newpass) < 3:
- self.msg("Passwords must be at least three characters long.")
+ elif not validated:
+ errors = [e for suberror in error.messages for e in error.messages]
+ string = "\n".join(errors)
+ self.msg(string)
else:
account.set_password(newpass)
account.save()
@@ -650,6 +657,7 @@ class CmdQuit(COMMAND_DEFAULT_CLASS):
game. Use the /all switch to disconnect from all sessions.
"""
key = "@quit"
+ switch_options = ("all",)
locks = "cmd:all()"
# this is used by the parent
diff --git a/evennia/commands/default/admin.py b/evennia/commands/default/admin.py
index 8b694ffd8f..fc90277127 100644
--- a/evennia/commands/default/admin.py
+++ b/evennia/commands/default/admin.py
@@ -36,6 +36,7 @@ class CmdBoot(COMMAND_DEFAULT_CLASS):
"""
key = "@boot"
+ switch_options = ("quiet", "sid")
locks = "cmd:perm(boot) or perm(Admin)"
help_category = "Admin"
@@ -265,6 +266,7 @@ class CmdDelAccount(COMMAND_DEFAULT_CLASS):
"""
key = "@delaccount"
+ switch_options = ("delobj",)
locks = "cmd:perm(delaccount) or perm(Developer)"
help_category = "Admin"
@@ -329,9 +331,9 @@ class CmdEmit(COMMAND_DEFAULT_CLASS):
@pemit [, , ... =]
Switches:
- room : limit emits to rooms only (default)
- accounts : limit emits to accounts only
- contents : send to the contents of matched objects too
+ room - limit emits to rooms only (default)
+ accounts - limit emits to accounts only
+ contents - send to the contents of matched objects too
Emits a message to the selected objects or to
your immediate surroundings. If the object is a room,
@@ -341,6 +343,7 @@ class CmdEmit(COMMAND_DEFAULT_CLASS):
"""
key = "@emit"
aliases = ["@pemit", "@remit"]
+ switch_options = ("room", "accounts", "contents")
locks = "cmd:perm(emit) or perm(Builder)"
help_category = "Admin"
@@ -425,12 +428,23 @@ class CmdNewPassword(COMMAND_DEFAULT_CLASS):
account = caller.search_account(self.lhs)
if not account:
return
- account.set_password(self.rhs)
+
+ newpass = self.rhs
+
+ # Validate password
+ validated, error = account.validate_password(newpass)
+ if not validated:
+ errors = [e for suberror in error.messages for e in error.messages]
+ string = "\n".join(errors)
+ caller.msg(string)
+ return
+
+ account.set_password(newpass)
account.save()
- self.msg("%s - new password set to '%s'." % (account.name, self.rhs))
+ self.msg("%s - new password set to '%s'." % (account.name, newpass))
if account.character != caller:
account.msg("%s has changed your password to '%s'." % (caller.name,
- self.rhs))
+ newpass))
class CmdPerm(COMMAND_DEFAULT_CLASS):
@@ -442,14 +456,15 @@ class CmdPerm(COMMAND_DEFAULT_CLASS):
@perm[/switch] * [= [,,...]]
Switches:
- del : delete the given permission from or .
- account : set permission on an account (same as adding * to name)
+ del - delete the given permission from or .
+ account - set permission on an account (same as adding * to name)
This command sets/clears individual permission strings on an object
or account. If no permission is given, list all permissions on .
"""
key = "@perm"
aliases = "@setperm"
+ switch_options = ("del", "account")
locks = "cmd:perm(perm) or perm(Developer)"
help_category = "Admin"
diff --git a/evennia/commands/default/batchprocess.py b/evennia/commands/default/batchprocess.py
index f0b117a816..5970d35887 100644
--- a/evennia/commands/default/batchprocess.py
+++ b/evennia/commands/default/batchprocess.py
@@ -237,6 +237,7 @@ class CmdBatchCommands(_COMMAND_DEFAULT_CLASS):
"""
key = "@batchcommands"
aliases = ["@batchcommand", "@batchcmd"]
+ switch_options = ("interactive",)
locks = "cmd:perm(batchcommands) or perm(Developer)"
help_category = "Building"
@@ -347,6 +348,7 @@ class CmdBatchCode(_COMMAND_DEFAULT_CLASS):
"""
key = "@batchcode"
aliases = ["@batchcodes"]
+ switch_options = ("interactive", "debug")
locks = "cmd:superuser()"
help_category = "Building"
diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py
index 0afeea8fe5..f0ae108f00 100644
--- a/evennia/commands/default/building.py
+++ b/evennia/commands/default/building.py
@@ -10,9 +10,10 @@ from evennia.objects.models import ObjectDB
from evennia.locks.lockhandler import LockException
from evennia.commands.cmdhandler import get_and_merge_cmdsets
from evennia.utils import create, utils, search
-from evennia.utils.utils import inherits_from, class_from_module
+from evennia.utils.utils import inherits_from, class_from_module, get_all_typeclasses
from evennia.utils.eveditor import EvEditor
-from evennia.utils.spawner import spawn
+from evennia.utils.evmore import EvMore
+from evennia.prototypes import spawner, prototypes as protlib, menus as olc_menus
from evennia.utils.ansi import raw
COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
@@ -26,12 +27,8 @@ __all__ = ("ObjManipCommand", "CmdSetObjAlias", "CmdCopy",
"CmdLock", "CmdExamine", "CmdFind", "CmdTeleport",
"CmdScript", "CmdTag", "CmdSpawn")
-try:
- # used by @set
- from ast import literal_eval as _LITERAL_EVAL
-except ImportError:
- # literal_eval is not available before Python 2.6
- _LITERAL_EVAL = None
+# used by @set
+from ast import literal_eval as _LITERAL_EVAL
# used by @find
CHAR_TYPECLASS = settings.BASE_CHARACTER_TYPECLASS
@@ -106,9 +103,15 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS):
Usage:
@alias [= [alias[,alias,alias,...]]]
@alias =
+ @alias/category = [alias[,alias,...]:
+
+ Switches:
+ category - requires ending input with :category, to store the
+ given aliases with the given category.
Assigns aliases to an object so it can be referenced by more
- than one name. Assign empty to remove all aliases from object.
+ than one name. Assign empty to remove all aliases from object. If
+ assigning a category, all aliases given will be using this category.
Observe that this is not the same thing as personal aliases
created with the 'nick' command! Aliases set with @alias are
@@ -118,6 +121,7 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS):
key = "@alias"
aliases = "@setobjalias"
+ switch_options = ("category",)
locks = "cmd:perm(setobjalias) or perm(Builder)"
help_category = "Building"
@@ -138,9 +142,12 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS):
return
if self.rhs is None:
# no =, so we just list aliases on object.
- aliases = obj.aliases.all()
+ aliases = obj.aliases.all(return_key_and_category=True)
if aliases:
- caller.msg("Aliases for '%s': %s" % (obj.get_display_name(caller), ", ".join(aliases)))
+ caller.msg("Aliases for %s: %s" % (
+ obj.get_display_name(caller),
+ ", ".join("'%s'%s" % (alias, "" if category is None else "[category:'%s']" % category)
+ for (alias, category) in aliases)))
else:
caller.msg("No aliases exist for '%s'." % obj.get_display_name(caller))
return
@@ -159,17 +166,27 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS):
caller.msg("No aliases to clear.")
return
+ category = None
+ if "category" in self.switches:
+ if ":" in self.rhs:
+ rhs, category = self.rhs.rsplit(':', 1)
+ category = category.strip()
+ else:
+ caller.msg("If specifying the /category switch, the category must be given "
+ "as :category at the end.")
+ else:
+ rhs = self.rhs
+
# merge the old and new aliases (if any)
- old_aliases = obj.aliases.all()
- new_aliases = [alias.strip().lower() for alias in self.rhs.split(',')
- if alias.strip()]
+ old_aliases = obj.aliases.get(category=category, return_list=True)
+ new_aliases = [alias.strip().lower() for alias in rhs.split(',') if alias.strip()]
# make the aliases only appear once
old_aliases.extend(new_aliases)
aliases = list(set(old_aliases))
# save back to object.
- obj.aliases.add(aliases)
+ obj.aliases.add(aliases, category=category)
# we need to trigger this here, since this will force
# (default) Exits to rebuild their Exit commands with the new
@@ -177,7 +194,8 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS):
obj.at_cmdset_get(force_init=True)
# report all aliases on the object
- caller.msg("Alias(es) for '%s' set to %s." % (obj.get_display_name(caller), str(obj.aliases)))
+ caller.msg("Alias(es) for '%s' set to '%s'%s." % (obj.get_display_name(caller),
+ str(obj.aliases), " (category: '%s')" % category if category else ""))
class CmdCopy(ObjManipCommand):
@@ -198,6 +216,7 @@ class CmdCopy(ObjManipCommand):
"""
key = "@copy"
+ switch_options = ("reset",)
locks = "cmd:perm(copy) or perm(Builder)"
help_category = "Building"
@@ -279,6 +298,7 @@ class CmdCpAttr(ObjManipCommand):
If you don't supply a source object, yourself is used.
"""
key = "@cpattr"
+ switch_options = ("move",)
locks = "cmd:perm(cpattr) or perm(Builder)"
help_category = "Building"
@@ -420,6 +440,7 @@ class CmdMvAttr(ObjManipCommand):
object. If you don't supply a source object, yourself is used.
"""
key = "@mvattr"
+ switch_options = ("copy",)
locks = "cmd:perm(mvattr) or perm(Builder)"
help_category = "Building"
@@ -468,6 +489,7 @@ class CmdCreate(ObjManipCommand):
"""
key = "@create"
+ switch_options = ("drop",)
locks = "cmd:perm(create) or perm(Builder)"
help_category = "Building"
@@ -553,6 +575,7 @@ class CmdDesc(COMMAND_DEFAULT_CLASS):
"""
key = "@desc"
aliases = "@describe"
+ switch_options = ("edit",)
locks = "cmd:perm(desc) or perm(Builder)"
help_category = "Building"
@@ -614,11 +637,11 @@ class CmdDestroy(COMMAND_DEFAULT_CLASS):
Usage:
@destroy[/switches] [obj, obj2, obj3, [dbref-dbref], ...]
- switches:
+ Switches:
override - The @destroy command will usually avoid accidentally
destroying account objects. This switch overrides this safety.
force - destroy without confirmation.
- examples:
+ Examples:
@destroy house, roof, door, 44-78
@destroy 5-10, flower, 45
@destroy/force north
@@ -631,6 +654,7 @@ class CmdDestroy(COMMAND_DEFAULT_CLASS):
key = "@destroy"
aliases = ["@delete", "@del"]
+ switch_options = ("override", "force")
locks = "cmd:perm(destroy) or perm(Builder)"
help_category = "Building"
@@ -754,6 +778,7 @@ class CmdDig(ObjManipCommand):
would be 'north;no;n'.
"""
key = "@dig"
+ switch_options = ("teleport",)
locks = "cmd:perm(dig) or perm(Builder)"
help_category = "Building"
@@ -863,7 +888,7 @@ class CmdDig(ObjManipCommand):
new_back_exit.dbref,
alias_string)
caller.msg("%s%s%s" % (room_string, exit_to_string, exit_back_string))
- if new_room and ('teleport' in self.switches or "tel" in self.switches):
+ if new_room and 'teleport' in self.switches:
caller.move_to(new_room)
@@ -896,6 +921,7 @@ class CmdTunnel(COMMAND_DEFAULT_CLASS):
key = "@tunnel"
aliases = ["@tun"]
+ switch_options = ("oneway", "tel")
locks = "cmd: perm(tunnel) or perm(Builder)"
help_category = "Building"
@@ -1429,17 +1455,16 @@ def _convert_from_string(cmd, strobj):
# if nothing matches, return as-is
return obj
- if _LITERAL_EVAL:
- # Use literal_eval to parse python structure exactly.
- try:
- return _LITERAL_EVAL(strobj)
- except (SyntaxError, ValueError):
- # treat as string
- strobj = utils.to_str(strobj)
- string = "|RNote: name \"|r%s|R\" was converted to a string. " \
- "Make sure this is acceptable." % strobj
- cmd.caller.msg(string)
- return strobj
+ # Use literal_eval to parse python structure exactly.
+ try:
+ return _LITERAL_EVAL(strobj)
+ except (SyntaxError, ValueError):
+ # treat as string
+ strobj = utils.to_str(strobj)
+ string = "|RNote: name \"|r%s|R\" was converted to a string. " \
+ "Make sure this is acceptable." % strobj
+ cmd.caller.msg(string)
+ return strobj
else:
# fall back to old recursive solution (does not support
# nested lists/dicts)
@@ -1458,6 +1483,13 @@ class CmdSetAttribute(ObjManipCommand):
Switch:
edit: Open the line editor (string values only)
+ script: If we're trying to set an attribute on a script
+ channel: If we're trying to set an attribute on a channel
+ account: If we're trying to set an attribute on an account
+ room: Setting an attribute on a room (global search)
+ exit: Setting an attribute on an exit (global search)
+ char: Setting an attribute on a character (global search)
+ character: Alias for char, as above.
Sets attributes on objects. The second form clears
a previously set attribute while the last form
@@ -1558,6 +1590,38 @@ class CmdSetAttribute(ObjManipCommand):
# start the editor
EvEditor(self.caller, load, save, key="%s/%s" % (obj, attr))
+ def search_for_obj(self, objname):
+ """
+ Searches for an object matching objname. The object may be of different typeclasses.
+ Args:
+ objname: Name of the object we're looking for
+
+ Returns:
+ A typeclassed object, or None if nothing is found.
+ """
+ from evennia.utils.utils import variable_from_module
+ _AT_SEARCH_RESULT = variable_from_module(*settings.SEARCH_AT_RESULT.rsplit('.', 1))
+ caller = self.caller
+ if objname.startswith('*') or "account" in self.switches:
+ found_obj = caller.search_account(objname.lstrip('*'))
+ elif "script" in self.switches:
+ found_obj = _AT_SEARCH_RESULT(search.search_script(objname), caller)
+ elif "channel" in self.switches:
+ found_obj = _AT_SEARCH_RESULT(search.search_channel(objname), caller)
+ else:
+ global_search = True
+ if "char" in self.switches or "character" in self.switches:
+ typeclass = settings.BASE_CHARACTER_TYPECLASS
+ elif "room" in self.switches:
+ typeclass = settings.BASE_ROOM_TYPECLASS
+ elif "exit" in self.switches:
+ typeclass = settings.BASE_EXIT_TYPECLASS
+ else:
+ global_search = False
+ typeclass = None
+ found_obj = caller.search(objname, global_search=global_search, typeclass=typeclass)
+ return found_obj
+
def func(self):
"""Implement the set attribute - a limited form of @py."""
@@ -1571,10 +1635,7 @@ class CmdSetAttribute(ObjManipCommand):
objname = self.lhs_objattr[0]['name']
attrs = self.lhs_objattr[0]['attrs']
- if objname.startswith('*'):
- obj = caller.search_account(objname.lstrip('*'))
- else:
- obj = caller.search(objname)
+ obj = self.search_for_obj(objname)
if not obj:
return
@@ -1637,17 +1698,22 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS):
@typeclass[/switch] [= typeclass.path]
@type ''
@parent ''
+ @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.
Switch:
- show - display the current typeclass of object (default)
+ show, examine - display the current typeclass of object (default) or, if
+ given a typeclass path, show the docstring of that typeclass.
update - *only* re-run at_object_creation on this object
meaning locks or other properties set later may remain.
reset - clean out *all* the attributes and properties on the
object - basically making this a new clean object.
force - change to the typeclass also if the object
already has a typeclass of the same name.
+ list - show available typeclasses.
+
+
Example:
@type button = examples.red_button.RedButton
@@ -1671,6 +1737,7 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS):
key = "@typeclass"
aliases = ["@type", "@parent", "@swap", "@update"]
+ switch_options = ("show", "examine", "update", "reset", "force", "list")
locks = "cmd:perm(typeclass) or perm(Builder)"
help_category = "Building"
@@ -1679,10 +1746,56 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS):
caller = self.caller
+ if 'list' in self.switches:
+ tclasses = get_all_typeclasses()
+ contribs = [key for key in sorted(tclasses)
+ if key.startswith("evennia.contrib")] or [""]
+ core = [key for key in sorted(tclasses)
+ if key.startswith("evennia") and key not in contribs] or [""]
+ game = [key for key in sorted(tclasses)
+ if not key.startswith("evennia")] or [""]
+ string = ("|wCore typeclasses|n\n"
+ " {core}\n"
+ "|wLoaded Contrib typeclasses|n\n"
+ " {contrib}\n"
+ "|wGame-dir typeclasses|n\n"
+ " {game}").format(core="\n ".join(core),
+ contrib="\n ".join(contribs),
+ game="\n ".join(game))
+ EvMore(caller, string, exit_on_lastpage=True)
+ return
+
if not self.args:
caller.msg("Usage: %s [= typeclass]" % self.cmdstring)
return
+ if "show" in self.switches or "examine" in self.switches:
+ oquery = self.lhs
+ obj = caller.search(oquery, quiet=True)
+ if not obj:
+ # no object found to examine, see if it's a typeclass-path instead
+ tclasses = get_all_typeclasses()
+ matches = [(key, tclass)
+ for key, tclass in tclasses.items() if key.endswith(oquery)]
+ nmatches = len(matches)
+ if nmatches > 1:
+ caller.msg("Multiple typeclasses found matching {}:\n {}".format(
+ oquery, "\n ".join(tup[0] for tup in matches)))
+ elif not matches:
+ caller.msg("No object or typeclass path found to match '{}'".format(oquery))
+ else:
+ # one match found
+ caller.msg("Docstring for typeclass '{}':\n{}".format(
+ oquery, matches[0][1].__doc__))
+ else:
+ # do the search again to get the error handling in case of multi-match
+ obj = caller.search(oquery)
+ if not obj:
+ return
+ caller.msg("{}'s current typeclass is '{}.{}'".format(
+ obj.name, obj.__class__.__module__, obj.__class__.__name__))
+ return
+
# get object to swap on
obj = caller.search(self.lhs)
if not obj:
@@ -1695,7 +1808,7 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS):
new_typeclass = self.rhs or obj.path
- if "show" in self.switches:
+ if "show" in self.switches or "examine" in self.switches:
string = "%s's current typeclass is %s." % (obj.name, obj.__class__)
caller.msg(string)
return
@@ -2114,12 +2227,15 @@ class CmdExamine(ObjManipCommand):
else:
things.append(content)
if exits:
- string += "\n|wExits|n: %s" % ", ".join(["%s(%s)" % (exit.name, exit.dbref) for exit in exits])
+ string += "\n|wExits|n: %s" % ", ".join(
+ ["%s(%s)" % (exit.name, exit.dbref) for exit in exits])
if pobjs:
- string += "\n|wCharacters|n: %s" % ", ".join(["|c%s|n(%s)" % (pobj.name, pobj.dbref) for pobj in pobjs])
+ string += "\n|wCharacters|n: %s" % ", ".join(
+ ["|c%s|n(%s)" % (pobj.name, pobj.dbref) for pobj in pobjs])
if things:
- string += "\n|wContents|n: %s" % ", ".join(["%s(%s)" % (cont.name, cont.dbref) for cont in obj.contents
- if cont not in exits and cont not in pobjs])
+ string += "\n|wContents|n: %s" % ", ".join(
+ ["%s(%s)" % (cont.name, cont.dbref) for cont in obj.contents
+ if cont not in exits and cont not in pobjs])
separator = "-" * _DEFAULT_WIDTH
# output info
return '%s\n%s\n%s' % (separator, string.strip(), separator)
@@ -2202,12 +2318,15 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
Usage:
@find[/switches] [= dbrefmin[-dbrefmax]]
+ @locate - this is a shorthand for using the /loc switch.
Switches:
- room - only look for rooms (location=None)
- exit - only look for exits (destination!=None)
- char - only look for characters (BASE_CHARACTER_TYPECLASS)
- exact- only exact matches are returned.
+ room - only look for rooms (location=None)
+ exit - only look for exits (destination!=None)
+ char - only look for characters (BASE_CHARACTER_TYPECLASS)
+ exact - only exact matches are returned.
+ loc - display object location if exists and match has one result
+ startswith - search for names starting with the string, rather than containing
Searches the database for an object of a particular name or exact #dbref.
Use *accountname to search for an account. The switches allows for
@@ -2218,6 +2337,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
key = "@find"
aliases = "@search, @locate"
+ switch_options = ("room", "exit", "char", "exact", "loc", "startswith")
locks = "cmd:perm(find) or perm(Builder)"
help_category = "Building"
@@ -2230,6 +2350,9 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
caller.msg("Usage: @find [= low [-high]]")
return
+ if "locate" in self.cmdstring: # Use option /loc as a default for @locate command alias
+ switches.append('loc')
+
searchstring = self.lhs
low, high = 1, ObjectDB.objects.all().order_by("-id")[0].id
if self.rhs:
@@ -2251,7 +2374,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
restrictions = ""
if self.switches:
- restrictions = ", %s" % (",".join(self.switches))
+ restrictions = ", %s" % (", ".join(self.switches))
if is_dbref or is_account:
@@ -2279,6 +2402,8 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
else:
result = result[0]
string += "\n|g %s - %s|n" % (result.get_display_name(caller), result.path)
+ if "loc" in self.switches and not is_account and result.location:
+ string += " (|wlocation|n: |g{}|n)".format(result.location.get_display_name(caller))
else:
# Not an account/dbref search but a wider search; build a queryset.
# Searchs for key and aliases
@@ -2286,10 +2411,14 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
keyquery = Q(db_key__iexact=searchstring, id__gte=low, id__lte=high)
aliasquery = Q(db_tags__db_key__iexact=searchstring,
db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high)
- else:
+ elif "startswith" in switches:
keyquery = Q(db_key__istartswith=searchstring, id__gte=low, id__lte=high)
aliasquery = Q(db_tags__db_key__istartswith=searchstring,
db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high)
+ else:
+ keyquery = Q(db_key__icontains=searchstring, id__gte=low, id__lte=high)
+ aliasquery = Q(db_tags__db_key__icontains=searchstring,
+ db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high)
results = ObjectDB.objects.filter(keyquery | aliasquery).distinct()
nresults = results.count()
@@ -2314,6 +2443,8 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
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))
else:
string = "|wMatch|n(#%i-#%i%s):" % (low, high, restrictions)
string += "\n |RNo matches found for '%s'|n" % searchstring
@@ -2327,7 +2458,7 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS):
teleport object to another location
Usage:
- @tel/switch [ =]
+ @tel/switch [ to||=]
Examples:
@tel Limbo
@@ -2351,6 +2482,8 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS):
"""
key = "@tel"
aliases = "@teleport"
+ switch_options = ("quiet", "intoexit", "tonone", "loc")
+ rhs_split = ("=", " to ") # Prefer = delimiter, but allow " to " usage.
locks = "cmd:perm(teleport) or perm(Builder)"
help_category = "Building"
@@ -2458,6 +2591,7 @@ class CmdScript(COMMAND_DEFAULT_CLASS):
key = "@script"
aliases = "@addscript"
+ switch_options = ("start", "stop")
locks = "cmd:perm(script) or perm(Builder)"
help_category = "Building"
@@ -2557,6 +2691,7 @@ class CmdTag(COMMAND_DEFAULT_CLASS):
key = "@tag"
aliases = ["@tags"]
+ options = ("search", "del")
locks = "cmd:perm(tag) or perm(Builder)"
help_category = "Building"
arg_regex = r"(/\w+?(\s|$))|\s|$"
@@ -2654,100 +2789,313 @@ class CmdTag(COMMAND_DEFAULT_CLASS):
string = "No tags attached to %s." % obj
self.caller.msg(string)
-#
-# To use the prototypes with the @spawn function set
-# PROTOTYPE_MODULES = ["commands.prototypes"]
-# Reload the server and the prototypes should be available.
-#
-
class CmdSpawn(COMMAND_DEFAULT_CLASS):
"""
spawn objects from prototype
Usage:
- @spawn
- @spawn[/switch]
- @spawn[/switch] {prototype dictionary}
+ @spawn[/noloc]
+ @spawn[/noloc]
- Switch:
+ @spawn/search [prototype_keykey][;tag[,tag]]
+ @spawn/list [tag, tag, ...]
+ @spawn/show []
+ @spawn/update
+
+ @spawn/save
+ @spawn/edit []
+ @olc - equivalent to @spawn/edit
+
+ Switches:
noloc - allow location to be None if not specified explicitly. Otherwise,
location will default to caller's current location.
+ search - search prototype by name or tags.
+ list - list available prototypes, optionally limit by tags.
+ show, examine - inspect prototype by key. If not given, acts like list.
+ save - save a prototype to the database. It will be listable by /list.
+ delete - remove a prototype from database, if allowed to.
+ update - find existing objects with the same prototype_key and update
+ them with latest version of given prototype. If given with /save,
+ will auto-update all objects with the old version of the prototype
+ without asking first.
+ edit, olc - create/manipulate prototype in a menu interface.
Example:
@spawn GOBLIN
@spawn {"key":"goblin", "typeclass":"monster.Monster", "location":"#2"}
+ @spawn/save {"key": "grunt", prototype: "goblin"};;mobs;edit:all()
Dictionary keys:
- |wprototype |n - name of parent prototype to use. Can be a list for
- multiple inheritance (inherits left to right)
+ |wprototype_parent |n - name of parent prototype to use. Required if typeclass is
+ not set. Can be a path or a list for multiple inheritance (inherits
+ left to right). If set one of the parents must have a typeclass.
+ |wtypeclass |n - string. Required if prototype_parent is not set.
|wkey |n - string, the main object identifier
- |wtypeclass |n - string, if not set, will use settings.BASE_OBJECT_TYPECLASS
|wlocation |n - this should be a valid object or #dbref
|whome |n - valid object or #dbref
|wdestination|n - only valid for exits (object or dbref)
|wpermissions|n - string or list of permission strings
|wlocks |n - a lock-string
- |waliases |n - string or list of strings
+ |waliases |n - string or list of strings.
|wndb_|n - value of a nattribute (ndb_ is stripped)
+
+ |wprototype_key|n - name of this prototype. Unique. Used to store/retrieve from db
+ and update existing prototyped objects if desired.
+ |wprototype_desc|n - desc of this prototype. Used in listings
+ |wprototype_locks|n - locks of this prototype. Limits who may use prototype
+ |wprototype_tags|n - tags of this prototype. Used to find prototype
+
any other keywords are interpreted as Attributes and their values.
The available prototypes are defined globally in modules set in
settings.PROTOTYPE_MODULES. If @spawn is used without arguments it
displays a list of available prototypes.
+
"""
key = "@spawn"
+ aliases = ["olc"]
+ switch_options = ("noloc", "search", "list", "show", "save", "delete", "menu", "olc", "update")
locks = "cmd:perm(spawn) or perm(Builder)"
help_category = "Building"
def func(self):
"""Implements the spawner"""
- def _show_prototypes(prototypes):
- """Helper to show a list of available prototypes"""
- prots = ", ".join(sorted(prototypes.keys()))
- return "\nAvailable prototypes (case sensitive): %s" % (
- "\n" + utils.fill(prots) if prots else "None")
+ def _parse_prototype(inp, expect=dict):
+ err = None
+ try:
+ prototype = _LITERAL_EVAL(inp)
+ except (SyntaxError, ValueError) as err:
+ # treat as string
+ prototype = utils.to_str(inp)
+ finally:
+ if not isinstance(prototype, expect):
+ if err:
+ string = ("{}\n|RCritical Python syntax error in argument. Only primitive "
+ "Python structures are allowed. \nYou also need to use correct "
+ "Python syntax. Remember especially to put quotes around all "
+ "strings inside lists and dicts.|n For more advanced uses, embed "
+ "inline functions in the strings.".format(err))
+ else:
+ string = "Expected {}, got {}.".format(expect, type(prototype))
+ self.caller.msg(string)
+ return None
+ if expect == dict:
+ # an actual prototype. We need to make sure it's safe. Don't allow exec
+ if "exec" in prototype and not self.caller.check_permstring("Developer"):
+ self.caller.msg("Spawn aborted: You are not allowed to "
+ "use the 'exec' prototype key.")
+ return None
+ try:
+ # we homogenize first, to be more lenient
+ protlib.validate_prototype(protlib.homogenize_prototype(prototype))
+ except RuntimeError as err:
+ self.caller.msg(str(err))
+ return
+ return prototype
- prototypes = spawn(return_prototypes=True)
- if not self.args:
- string = "Usage: @spawn {key:value, key, value, ... }"
- self.caller.msg(string + _show_prototypes(prototypes))
- return
- try:
- # make use of _convert_from_string from the SetAttribute command
- prototype = _convert_from_string(self, self.args)
- except SyntaxError:
- # this means literal_eval tried to parse a faulty string
- string = "|RCritical Python syntax error in argument. "
- string += "Only primitive Python structures are allowed. "
- string += "\nYou also need to use correct Python syntax. "
- string += "Remember especially to put quotes around all "
- string += "strings inside lists and dicts.|n"
- self.caller.msg(string)
+ def _search_show_prototype(query, prototypes=None):
+ # prototype detail
+ if not prototypes:
+ prototypes = protlib.search_prototype(key=query)
+ if prototypes:
+ return "\n".join(protlib.prototype_to_str(prot) for prot in prototypes)
+ else:
+ return False
+
+ caller = self.caller
+
+ if self.cmdstring == "olc" or 'menu' in self.switches or 'olc' in self.switches:
+ # OLC menu mode
+ prototype = None
+ if self.lhs:
+ key = self.lhs
+ prototype = spawner.search_prototype(key=key, return_meta=True)
+ 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]
+ olc_menus.start_olc(caller, session=self.session, prototype=prototype)
return
- if isinstance(prototype, basestring):
- # A prototype key
- keystr = prototype
- prototype = prototypes.get(prototype, None)
+ if 'search' in self.switches:
+ # query for a key match
+ if not self.args:
+ self.switches.append("list")
+ else:
+ key, tags = self.args.strip(), None
+ if ';' in self.args:
+ key, tags = (part.strip().lower() for part in self.args.split(";", 1))
+ tags = [tag.strip() for tag in tags.split(",")] if tags else None
+ EvMore(caller, unicode(protlib.list_prototypes(caller, key=key, tags=tags)),
+ exit_on_lastpage=True)
+ return
+
+ if 'show' in self.switches or 'examine' in self.switches:
+ # the argument is a key in this case (may be a partial key)
+ if not self.args:
+ self.switches.append('list')
+ else:
+ matchstring = _search_show_prototype(self.args)
+ if matchstring:
+ caller.msg(matchstring)
+ else:
+ caller.msg("No prototype '{}' was found.".format(self.args))
+ return
+
+ if 'list' in self.switches:
+ # for list, all optional arguments are tags
+ # import pudb; pudb.set_trace()
+
+ EvMore(caller, unicode(protlib.list_prototypes(caller,
+ tags=self.lhslist)), exit_on_lastpage=True)
+ return
+
+ if 'save' in self.switches:
+ # store a prototype to the database store
+ if not self.args:
+ caller.msg(
+ "Usage: @spawn/save [;desc[;tag,tag[,...][;lockstring]]] = ")
+ return
+
+ # handle rhs:
+ prototype = _parse_prototype(self.lhs.strip())
if not prototype:
- string = "No prototype named '%s'." % keystr
- self.caller.msg(string + _show_prototypes(prototypes))
return
- elif isinstance(prototype, dict):
- # we got the prototype on the command line. We must make sure to not allow
- # the 'exec' key unless we are developers or higher.
- if "exec" in prototype and not self.caller.check_permstring("Developer"):
- self.caller.msg("Spawn aborted: You don't have access to use the 'exec' prototype key.")
+
+ # present prototype to save
+ new_matchstring = _search_show_prototype("", prototypes=[prototype])
+ string = "|yCreating new prototype:|n\n{}".format(new_matchstring)
+ question = "\nDo you want to continue saving? [Y]/N"
+
+ prototype_key = prototype.get("prototype_key")
+ if not prototype_key:
+ caller.msg("\n|yTo save a prototype it must have the 'prototype_key' set.")
return
- else:
- self.caller.msg("The prototype must be a prototype key or a Python dictionary.")
+
+ # check for existing prototype,
+ old_matchstring = _search_show_prototype(prototype_key)
+
+ if old_matchstring:
+ string += "\n|yExisting saved prototype found:|n\n{}".format(old_matchstring)
+ question = "\n|yDo you want to replace the existing prototype?|n [Y]/N"
+
+ answer = yield(string + question)
+ if answer.lower() in ["n", "no"]:
+ caller.msg("|rSave cancelled.|n")
+ return
+
+ # all seems ok. Try to save.
+ try:
+ prot = protlib.save_prototype(**prototype)
+ if not prot:
+ caller.msg("|rError saving:|R {}.|n".format(prototype_key))
+ return
+ except protlib.PermissionError as err:
+ caller.msg("|rError saving:|R {}|n".format(err))
+ return
+ caller.msg("|gSaved prototype:|n {}".format(prototype_key))
+
+ # check if we want to update existing objects
+ existing_objects = protlib.search_objects_with_prototype(prototype_key)
+ if existing_objects:
+ if 'update' not in self.switches:
+ n_existing = len(existing_objects)
+ slow = " (note that this may be slow)" if n_existing > 10 else ""
+ string = ("There are {} objects already created with an older version "
+ "of prototype {}. Should it be re-applied to them{}? [Y]/N".format(
+ n_existing, prototype_key, slow))
+ answer = yield(string)
+ if answer.lower() in ["n", "no"]:
+ caller.msg("|rNo update was done of existing objects. "
+ "Use @spawn/update to apply later as needed.|n")
+ return
+ n_updated = spawner.batch_update_objects_with_prototype(existing_objects, key)
+ caller.msg("{} objects were updated.".format(n_updated))
return
+ if not self.args:
+ ncount = len(protlib.search_prototype())
+ caller.msg("Usage: @spawn or {{key: value, ...}}"
+ "\n ({} existing prototypes. Use /list to inspect)".format(ncount))
+ return
+
+ if 'delete' in self.switches:
+ # remove db-based prototype
+ matchstring = _search_show_prototype(self.args)
+ if matchstring:
+ string = "|rDeleting prototype:|n\n{}".format(matchstring)
+ question = "\nDo you want to continue deleting? [Y]/N"
+ answer = yield(string + question)
+ if answer.lower() in ["n", "no"]:
+ caller.msg("|rDeletion cancelled.|n")
+ return
+ try:
+ success = protlib.delete_db_prototype(caller, self.args)
+ except protlib.PermissionError as err:
+ caller.msg("|rError deleting:|R {}|n".format(err))
+ caller.msg("Deletion {}.".format(
+ 'successful' if success else 'failed (does the prototype exist?)'))
+ return
+ else:
+ caller.msg("Could not find prototype '{}'".format(key))
+
+ if 'update' in self.switches:
+ # update existing prototypes
+ key = self.args.strip().lower()
+ existing_objects = protlib.search_objects_with_prototype(key)
+ if existing_objects:
+ n_existing = len(existing_objects)
+ slow = " (note that this may be slow)" if n_existing > 10 else ""
+ string = ("There are {} objects already created with an older version "
+ "of prototype {}. Should it be re-applied to them{}? [Y]/N".format(
+ n_existing, key, slow))
+ answer = yield(string)
+ if answer.lower() in ["n", "no"]:
+ caller.msg("|rUpdate cancelled.")
+ return
+ n_updated = spawner.batch_update_objects_with_prototype(existing_objects, key)
+ caller.msg("{} objects were updated.".format(n_updated))
+
+ # A direct creation of an object from a given prototype
+
+ prototype = _parse_prototype(
+ self.args, expect=dict if self.args.strip().startswith("{") else basestring)
+ if not prototype:
+ # this will only let through dicts or strings
+ return
+
+ key = ''
+ if isinstance(prototype, basestring):
+ # A prototype key we are looking to apply
+ key = prototype
+ prototypes = protlib.search_prototype(prototype)
+ nprots = len(prototypes)
+ if not prototypes:
+ caller.msg("No prototype named '%s'." % prototype)
+ return
+ elif nprots > 1:
+ caller.msg("Found {} prototypes matching '{}':\n {}".format(
+ nprots, prototype, ", ".join(prot.get('prototype_key', '')
+ for proto in prototypes)))
+ return
+ # we have a prototype, check access
+ prototype = prototypes[0]
+ if not caller.locks.check_lockstring(caller, prototype.get('prototype_locks', ''), access_type='spawn'):
+ caller.msg("You don't have access to use this prototype.")
+ return
+
if "noloc" not in self.switches and "location" not in prototype:
prototype["location"] = self.caller.location
- for obj in spawn(prototype):
- self.caller.msg("Spawned %s." % obj.get_display_name(self.caller))
+ # proceed to spawning
+ try:
+ for obj in spawner.spawn(prototype):
+ self.caller.msg("Spawned %s." % obj.get_display_name(self.caller))
+ except RuntimeError as err:
+ caller.msg(err)
diff --git a/evennia/commands/default/cmdset_account.py b/evennia/commands/default/cmdset_account.py
index 4e357a2ce0..d7b887c017 100644
--- a/evennia/commands/default/cmdset_account.py
+++ b/evennia/commands/default/cmdset_account.py
@@ -11,7 +11,7 @@ command method rather than caller.msg().
from evennia.commands.cmdset import CmdSet
from evennia.commands.default import help, comms, admin, system
-from evennia.commands.default import building, account
+from evennia.commands.default import building, account, general
class AccountCmdSet(CmdSet):
@@ -39,6 +39,9 @@ class AccountCmdSet(CmdSet):
self.add(account.CmdColorTest())
self.add(account.CmdQuell())
+ # nicks
+ self.add(general.CmdNick())
+
# testing
self.add(building.CmdExamine())
diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py
index 37a4934d16..04fcceba46 100644
--- a/evennia/commands/default/comms.py
+++ b/evennia/commands/default/comms.py
@@ -7,6 +7,8 @@ make sure to homogenize self.caller to always be the account object
for easy handling.
"""
+import hashlib
+import time
from past.builtins import cmp
from django.conf import settings
from evennia.comms.models import ChannelDB, Msg
@@ -377,7 +379,7 @@ class CmdCBoot(COMMAND_DEFAULT_CLASS):
Usage:
@cboot[/quiet] = [:reason]
- Switches:
+ Switch:
quiet - don't notify the channel
Kicks an account or object from a channel you control.
@@ -385,6 +387,7 @@ class CmdCBoot(COMMAND_DEFAULT_CLASS):
"""
key = "@cboot"
+ switch_options = ("quiet",)
locks = "cmd: not pperm(channel_banned)"
help_category = "Comms"
@@ -453,6 +456,7 @@ class CmdCemit(COMMAND_DEFAULT_CLASS):
key = "@cemit"
aliases = ["@cmsg"]
+ switch_options = ("sendername", "quiet")
locks = "cmd: not pperm(channel_banned) and pperm(Player)"
help_category = "Comms"
@@ -683,6 +687,7 @@ class CmdPage(COMMAND_DEFAULT_CLASS):
key = "page"
aliases = ['tell']
+ switch_options = ("last", "list")
locks = "cmd:not pperm(page_banned)"
help_category = "Comms"
@@ -850,6 +855,7 @@ class CmdIRC2Chan(COMMAND_DEFAULT_CLASS):
"""
key = "@irc2chan"
+ switch_options = ("delete", "remove", "disconnect", "list", "ssl")
locks = "cmd:serversetting(IRC_ENABLED) and pperm(Developer)"
help_category = "Comms"
@@ -914,8 +920,9 @@ class CmdIRC2Chan(COMMAND_DEFAULT_CLASS):
self.msg("Account '%s' already exists and is not a bot." % botname)
return
else:
+ password = hashlib.md5(str(time.time())).hexdigest()[:11]
try:
- bot = create.create_account(botname, None, None, typeclass=botclass)
+ bot = create.create_account(botname, None, password, typeclass=botclass)
except Exception as err:
self.msg("|rError, could not create the bot:|n '%s'." % err)
return
@@ -1016,6 +1023,7 @@ class CmdRSS2Chan(COMMAND_DEFAULT_CLASS):
"""
key = "@rss2chan"
+ switch_options = ("disconnect", "remove", "list")
locks = "cmd:serversetting(RSS_ENABLED) and pperm(Developer)"
help_category = "Comms"
diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py
index f9634ed675..85fb1b4dd4 100644
--- a/evennia/commands/default/general.py
+++ b/evennia/commands/default/general.py
@@ -71,7 +71,7 @@ class CmdLook(COMMAND_DEFAULT_CLASS):
target = caller.search(self.args)
if not target:
return
- self.msg(caller.at_look(target))
+ self.msg((caller.at_look(target), {'type': 'look'}), options=None)
class CmdNick(COMMAND_DEFAULT_CLASS):
@@ -88,8 +88,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
Switches:
inputline - replace on the inputline (default)
object - replace on object-lookup
- account - replace on account-lookup
-
+ account - replace on account-lookup
list - show all defined aliases (also "nicks" works)
delete - remove nick by index in /list
clearall - clear all nicks
@@ -118,7 +117,8 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
"""
key = "nick"
- aliases = ["nickname", "nicks", "alias"]
+ switch_options = ("inputline", "object", "account", "list", "delete", "clearall")
+ aliases = ["nickname", "nicks"]
locks = "cmd:all()"
def parse(self):
@@ -143,7 +143,6 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
return re.sub(r"(\$[0-9]+|\*|\?|\[.+?\])", r"|Y\1|n", string)
caller = self.caller
- account = self.caller.account or caller
switches = self.switches
nicktypes = [switch for switch in switches if switch in ("object", "account", "inputline")]
specified_nicktype = bool(nicktypes)
@@ -151,7 +150,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
nicklist = (utils.make_iter(caller.nicks.get(category="inputline", return_obj=True) or []) +
utils.make_iter(caller.nicks.get(category="object", return_obj=True) or []) +
- utils.make_iter(account.nicks.get(category="account", return_obj=True) or []))
+ utils.make_iter(caller.nicks.get(category="account", return_obj=True) or []))
if 'list' in switches or self.cmdstring in ("nicks", "@nicks"):
@@ -174,29 +173,77 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
if 'delete' in switches or 'del' in switches:
if not self.args or not self.lhs:
- caller.msg("usage nick/delete #num ('nicks' for list)")
+ caller.msg("usage nick/delete or <#num> ('nicks' for list)")
return
# see if a number was given
arg = self.args.lstrip("#")
+ oldnicks = []
if arg.isdigit():
# we are given a index in nicklist
delindex = int(arg)
if 0 < delindex <= len(nicklist):
- oldnick = nicklist[delindex - 1]
- _, _, old_nickstring, old_replstring = oldnick.value
+ oldnicks.append(nicklist[delindex - 1])
else:
caller.msg("Not a valid nick index. See 'nicks' for a list.")
return
- nicktype = oldnick.category
- nicktypestr = "%s-nick" % nicktype.capitalize()
+ else:
+ if not specified_nicktype:
+ nicktypes = ("object", "account", "inputline")
+ for nicktype in nicktypes:
+ oldnicks.append(caller.nicks.get(arg, category=nicktype, return_obj=True))
- if nicktype == "account":
- account.nicks.remove(old_nickstring, category=nicktype)
- else:
+ oldnicks = [oldnick for oldnick in oldnicks if oldnick]
+ if oldnicks:
+ for oldnick in oldnicks:
+ nicktype = oldnick.category
+ nicktypestr = "%s-nick" % nicktype.capitalize()
+ _, _, old_nickstring, old_replstring = oldnick.value
caller.nicks.remove(old_nickstring, category=nicktype)
- caller.msg("%s removed: '|w%s|n' -> |w%s|n." % (
- nicktypestr, old_nickstring, old_replstring))
- return
+ caller.msg("%s removed: '|w%s|n' -> |w%s|n." % (
+ nicktypestr, old_nickstring, old_replstring))
+ else:
+ caller.msg("No matching nicks to remove.")
+ return
+
+ if not self.rhs and self.lhs:
+ # check what a nick is set to
+ strings = []
+ if not specified_nicktype:
+ nicktypes = ("object", "account", "inputline")
+ for nicktype in nicktypes:
+ nicks = utils.make_iter(caller.nicks.get(category=nicktype, return_obj=True))
+ for nick in nicks:
+ _, _, nick, repl = nick.value
+ if nick.startswith(self.lhs):
+ strings.append("{}-nick: '{}' -> '{}'".format(
+ nicktype.capitalize(), nick, repl))
+ if strings:
+ caller.msg("\n".join(strings))
+ else:
+ caller.msg("No nicks found matching '{}'".format(self.lhs))
+ return
+
+ if not self.rhs and self.lhs:
+ # check what a nick is set to
+ strings = []
+ if not specified_nicktype:
+ nicktypes = ("object", "account", "inputline")
+ for nicktype in nicktypes:
+ if nicktype == "account":
+ obj = account
+ else:
+ obj = caller
+ nicks = utils.make_iter(obj.nicks.get(category=nicktype, return_obj=True))
+ for nick in nicks:
+ _, _, nick, repl = nick.value
+ if nick.startswith(self.lhs):
+ strings.append("{}-nick: '{}' -> '{}'".format(
+ nicktype.capitalize(), nick, repl))
+ if strings:
+ caller.msg("\n".join(strings))
+ else:
+ caller.msg("No nicks found matching '{}'".format(self.lhs))
+ return
if not self.rhs and self.lhs:
# check what a nick is set to
@@ -237,16 +284,11 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
errstring = ""
string = ""
for nicktype in nicktypes:
- if nicktype == "account":
- obj = account
- else:
- obj = caller
-
nicktypestr = "%s-nick" % nicktype.capitalize()
old_nickstring = None
old_replstring = None
- oldnick = obj.nicks.get(key=nickstring, category=nicktype, return_obj=True)
+ oldnick = caller.nicks.get(key=nickstring, category=nicktype, return_obj=True)
if oldnick:
_, _, old_nickstring, old_replstring = oldnick.value
if replstring:
@@ -261,7 +303,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
else:
string += "\n%s '|w%s|n' mapped to '|w%s|n'." % (nicktypestr, nickstring, replstring)
try:
- obj.nicks.add(nickstring, replstring, category=nicktype)
+ caller.nicks.add(nickstring, replstring, category=nicktype)
except NickTemplateInvalid:
caller.msg("You must use the same $-markers both in the nick and in the replacement.")
return
@@ -337,13 +379,17 @@ class CmdGet(COMMAND_DEFAULT_CLASS):
caller.msg("You can't get that.")
return
+ # calling at_before_get hook method
+ if not obj.at_before_get(caller):
+ return
+
obj.move_to(caller, quiet=True)
caller.msg("You pick up %s." % obj.name)
caller.location.msg_contents("%s picks up %s." %
(caller.name,
obj.name),
exclude=caller)
- # calling hook method
+ # calling at_get hook method
obj.at_get(caller)
@@ -378,6 +424,10 @@ class CmdDrop(COMMAND_DEFAULT_CLASS):
if not obj:
return
+ # Call the object script's at_before_drop() method.
+ if not obj.at_before_drop(caller):
+ return
+
obj.move_to(caller.location, quiet=True)
caller.msg("You drop %s." % (obj.name,))
caller.location.msg_contents("%s drops %s." %
@@ -392,12 +442,13 @@ class CmdGive(COMMAND_DEFAULT_CLASS):
give away something to someone
Usage:
- give =
+ give
Gives an items from your inventory to another character,
placing it in their inventory.
"""
key = "give"
+ rhs_split = ("=", " to ") # Prefer = delimiter, but allow " to " usage.
locks = "cmd:all()"
arg_regex = r"\s|$"
@@ -420,6 +471,11 @@ class CmdGive(COMMAND_DEFAULT_CLASS):
if not to_give.location == caller:
caller.msg("You are not holding %s." % to_give.key)
return
+
+ # calling at_before_give hook method
+ if not to_give.at_before_give(caller, target):
+ return
+
# give object
caller.msg("You give %s to %s." % (to_give.key, target.key))
to_give.move_to(target, quiet=True)
diff --git a/evennia/commands/default/help.py b/evennia/commands/default/help.py
index ebce460748..d2011629c1 100644
--- a/evennia/commands/default/help.py
+++ b/evennia/commands/default/help.py
@@ -317,6 +317,7 @@ class CmdSetHelp(COMMAND_DEFAULT_CLASS):
"""
key = "@sethelp"
+ switch_options = ("edit", "replace", "append", "extend", "delete")
locks = "cmd:perm(Helper)"
help_category = "Building"
diff --git a/evennia/commands/default/muxcommand.py b/evennia/commands/default/muxcommand.py
index 5d8d4b2890..349679f5bd 100644
--- a/evennia/commands/default/muxcommand.py
+++ b/evennia/commands/default/muxcommand.py
@@ -79,6 +79,13 @@ class MuxCommand(Command):
it here). The rest of the command is stored in self.args, which can
start with the switch indicator /.
+ Optional variables to aid in parsing, if set:
+ self.switch_options - (tuple of valid /switches expected by this
+ command (without the /))
+ self.rhs_split - Alternate string delimiter or tuple of strings
+ to separate left/right hand sides. tuple form
+ gives priority split to first string delimiter.
+
This parser breaks self.args into its constituents and stores them in the
following variables:
self.switches = [list of /switches (without the /)]
@@ -97,9 +104,18 @@ class MuxCommand(Command):
"""
raw = self.args
args = raw.strip()
+ # Without explicitly setting these attributes, they assume default values:
+ if not hasattr(self, "switch_options"):
+ self.switch_options = None
+ if not hasattr(self, "rhs_split"):
+ self.rhs_split = "="
+ if not hasattr(self, "account_caller"):
+ self.account_caller = False
# split out switches
- switches = []
+ switches, delimiters = [], self.rhs_split
+ if self.switch_options:
+ self.switch_options = [opt.lower() for opt in self.switch_options]
if args and len(args) > 1 and raw[0] == "/":
# we have a switch, or a set of switches. These end with a space.
switches = args[1:].split(None, 1)
@@ -109,16 +125,50 @@ class MuxCommand(Command):
else:
args = ""
switches = switches[0].split('/')
+ # If user-provides switches, parse them with parser switch options.
+ if switches and self.switch_options:
+ valid_switches, unused_switches, extra_switches = [], [], []
+ for element in switches:
+ option_check = [opt for opt in self.switch_options if opt == element]
+ if not option_check:
+ option_check = [opt for opt in self.switch_options if opt.startswith(element)]
+ match_count = len(option_check)
+ if match_count > 1:
+ extra_switches.extend(option_check) # Either the option provided is ambiguous,
+ elif match_count == 1:
+ valid_switches.extend(option_check) # or it is a valid option abbreviation,
+ elif match_count == 0:
+ unused_switches.append(element) # or an extraneous option to be ignored.
+ if extra_switches: # User provided switches
+ self.msg('|g%s|n: |wAmbiguous switch supplied: Did you mean /|C%s|w?' %
+ (self.cmdstring, ' |nor /|C'.join(extra_switches)))
+ if unused_switches:
+ plural = '' if len(unused_switches) == 1 else 'es'
+ self.msg('|g%s|n: |wExtra switch%s "/|C%s|w" ignored.' %
+ (self.cmdstring, plural, '|n, /|C'.join(unused_switches)))
+ switches = valid_switches # Only include valid_switches in command function call
arglist = [arg.strip() for arg in args.split()]
# check for arg1, arg2, ... = argA, argB, ... constructs
- lhs, rhs = args, None
- lhslist, rhslist = [arg.strip() for arg in args.split(',')], []
- if args and '=' in args:
- lhs, rhs = [arg.strip() for arg in args.split('=', 1)]
- lhslist = [arg.strip() for arg in lhs.split(',')]
- rhslist = [arg.strip() for arg in rhs.split(',')]
-
+ lhs, rhs = args.strip(), None
+ if lhs:
+ if delimiters and hasattr(delimiters, '__iter__'): # If delimiter is iterable,
+ best_split = delimiters[0] # (default to first delimiter)
+ for this_split in delimiters: # try each delimiter
+ if this_split in lhs: # to find first successful split
+ best_split = this_split # to be the best split.
+ break
+ else:
+ best_split = delimiters
+ # Parse to separate left into left/right sides using best_split delimiter string
+ if best_split in lhs:
+ lhs, rhs = lhs.split(best_split, 1)
+ # Trim user-injected whitespace
+ rhs = rhs.strip() if rhs is not None else None
+ lhs = lhs.strip()
+ # Further split left/right sides by comma delimiter
+ lhslist = [arg.strip() for arg in lhs.split(',')] if lhs is not None else ""
+ rhslist = [arg.strip() for arg in rhs.split(',')] if rhs is not None else ""
# save to object properties:
self.raw = raw
self.switches = switches
@@ -133,7 +183,7 @@ class MuxCommand(Command):
# sure that self.caller is always the account if possible. We also create
# a special property "character" for the puppeted object, if any. This
# is convenient for commands defined on the Account only.
- if hasattr(self, "account_caller") and self.account_caller:
+ if self.account_caller:
if utils.inherits_from(self.caller, "evennia.objects.objects.DefaultObject"):
# caller is an Object/Character
self.character = self.caller
@@ -169,6 +219,8 @@ class MuxCommand(Command):
string += "\nraw argument (self.raw): |w%s|n \n" % self.raw
string += "cmd args (self.args): |w%s|n\n" % self.args
string += "cmd switches (self.switches): |w%s|n\n" % self.switches
+ string += "cmd options (self.switch_options): |w%s|n\n" % self.switch_options
+ string += "cmd parse left/right using (self.rhs_split): |w%s|n\n" % self.rhs_split
string += "space-separated arg list (self.arglist): |w%s|n\n" % self.arglist
string += "lhs, left-hand side of '=' (self.lhs): |w%s|n\n" % self.lhs
string += "lhs, comma separated (self.lhslist): |w%s|n\n" % self.lhslist
@@ -193,18 +245,4 @@ class MuxAccountCommand(MuxCommand):
character is actually attached to this Account and Session.
"""
- def parse(self):
- """
- We run the parent parser as usual, then fix the result
- """
- super(MuxAccountCommand, self).parse()
-
- if utils.inherits_from(self.caller, "evennia.objects.objects.DefaultObject"):
- # caller is an Object/Character
- self.character = self.caller
- self.caller = self.caller.account
- elif utils.inherits_from(self.caller, "evennia.accounts.accounts.DefaultAccount"):
- # caller was already an Account
- self.character = self.caller.get_puppet(self.session)
- else:
- self.character = None
+ account_caller = True # Using MuxAccountCommand explicitly defaults the caller to an account
diff --git a/evennia/commands/default/system.py b/evennia/commands/default/system.py
index fe5efa337d..c454de9aff 100644
--- a/evennia/commands/default/system.py
+++ b/evennia/commands/default/system.py
@@ -58,7 +58,7 @@ class CmdReload(COMMAND_DEFAULT_CLASS):
if self.args:
reason = "(Reason: %s) " % self.args.rstrip(".")
SESSIONS.announce_all(" Server restart initiated %s..." % reason)
- SESSIONS.server.shutdown(mode='reload')
+ SESSIONS.portal_restart_server()
class CmdReset(COMMAND_DEFAULT_CLASS):
@@ -91,7 +91,7 @@ class CmdReset(COMMAND_DEFAULT_CLASS):
Reload the system.
"""
SESSIONS.announce_all(" Server resetting/restarting ...")
- SESSIONS.server.shutdown(mode='reset')
+ SESSIONS.portal_reset_server()
class CmdShutdown(COMMAND_DEFAULT_CLASS):
@@ -119,7 +119,6 @@ class CmdShutdown(COMMAND_DEFAULT_CLASS):
announcement += "%s\n" % self.args
logger.log_info('Server shutdown by %s.' % self.caller.name)
SESSIONS.announce_all(announcement)
- SESSIONS.server.shutdown(mode='shutdown')
SESSIONS.portal_shutdown()
@@ -246,6 +245,7 @@ class CmdPy(COMMAND_DEFAULT_CLASS):
"""
key = "@py"
aliases = ["!"]
+ switch_options = ("time", "edit")
locks = "cmd:perm(py) or perm(Developer)"
help_category = "System"
@@ -329,6 +329,7 @@ class CmdScripts(COMMAND_DEFAULT_CLASS):
"""
key = "@scripts"
aliases = ["@globalscript", "@listscripts"]
+ switch_options = ("start", "stop", "kill", "validate")
locks = "cmd:perm(listscripts) or perm(Admin)"
help_category = "System"
@@ -522,6 +523,7 @@ class CmdService(COMMAND_DEFAULT_CLASS):
key = "@service"
aliases = ["@services"]
+ switch_options = ("list", "start", "stop", "delete")
locks = "cmd:perm(service) or perm(Developer)"
help_category = "System"
@@ -673,7 +675,7 @@ class CmdServerLoad(COMMAND_DEFAULT_CLASS):
Usage:
@server[/mem]
- Switch:
+ Switches:
mem - return only a string of the current memory usage
flushmem - flush the idmapper cache
@@ -704,6 +706,7 @@ class CmdServerLoad(COMMAND_DEFAULT_CLASS):
"""
key = "@server"
aliases = ["@serverload", "@serverprocess"]
+ switch_options = ("mem", "flushmem")
locks = "cmd:perm(list) or perm(Developer)"
help_category = "System"
diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py
index cbae47866d..691e1e63cb 100644
--- a/evennia/commands/default/tests.py
+++ b/evennia/commands/default/tests.py
@@ -22,11 +22,13 @@ from mock import Mock, mock
from evennia.commands.default.cmdset_character import CharacterCmdSet
from evennia.utils.test_resources import EvenniaTest
from evennia.commands.default import help, general, system, admin, account, building, batchprocess, comms, unloggedin
+from evennia.commands.default.muxcommand import MuxCommand
from evennia.commands.command import Command, InterruptCommand
from evennia.utils import ansi, utils, gametime
from evennia.server.sessionhandler import SESSIONS
from evennia import search_object
from evennia import DefaultObject, DefaultCharacter
+from evennia.prototypes import prototypes as protlib
# set up signal here since we are not starting the server
@@ -44,7 +46,7 @@ class CommandTest(EvenniaTest):
Tests a command
"""
def call(self, cmdobj, args, msg=None, cmdset=None, noansi=True, caller=None,
- receiver=None, cmdstring=None, obj=None):
+ receiver=None, cmdstring=None, obj=None, inputs=None):
"""
Test a command by assigning all the needed
properties to cmdobj and running
@@ -73,42 +75,58 @@ class CommandTest(EvenniaTest):
cmdobj.obj = obj or (caller if caller else self.char1)
# test
old_msg = receiver.msg
- returned_msg = ""
+ inputs = inputs or []
+
try:
receiver.msg = Mock()
if cmdobj.at_pre_cmd():
return
cmdobj.parse()
ret = cmdobj.func()
+
+ # handle func's with yield in them (generators)
if isinstance(ret, types.GeneratorType):
- ret.next()
+ while True:
+ try:
+ inp = inputs.pop() if inputs else None
+ if inp:
+ try:
+ ret.send(inp)
+ except TypeError:
+ ret.next()
+ ret = ret.send(inp)
+ else:
+ ret.next()
+ except StopIteration:
+ break
+
cmdobj.at_post_cmd()
except StopIteration:
pass
except InterruptCommand:
pass
- finally:
- # clean out evtable sugar. We only operate on text-type
- stored_msg = [args[0] if args and args[0] else kwargs.get("text", utils.to_str(kwargs, force_string=True))
- for name, args, kwargs in receiver.msg.mock_calls]
- # Get the first element of a tuple if msg received a tuple instead of a string
- stored_msg = [smsg[0] if isinstance(smsg, tuple) else smsg for smsg in stored_msg]
- if msg is not None:
- # set our separator for returned messages based on parsing ansi or not
- msg_sep = "|" if noansi else "||"
- # Have to strip ansi for each returned message for the regex to handle it correctly
- returned_msg = msg_sep.join(_RE.sub("", ansi.parse_ansi(mess, strip_ansi=noansi))
- for mess in stored_msg).strip()
- if msg == "" and returned_msg or not returned_msg.startswith(msg.strip()):
- sep1 = "\n" + "=" * 30 + "Wanted message" + "=" * 34 + "\n"
- sep2 = "\n" + "=" * 30 + "Returned message" + "=" * 32 + "\n"
- sep3 = "\n" + "=" * 78
- retval = sep1 + msg.strip() + sep2 + returned_msg + sep3
- raise AssertionError(retval)
- else:
- returned_msg = "\n".join(str(msg) for msg in stored_msg)
- returned_msg = ansi.parse_ansi(returned_msg, strip_ansi=noansi).strip()
- receiver.msg = old_msg
+
+ # clean out evtable sugar. We only operate on text-type
+ stored_msg = [args[0] if args and args[0] else kwargs.get("text", utils.to_str(kwargs, force_string=True))
+ for name, args, kwargs in receiver.msg.mock_calls]
+ # Get the first element of a tuple if msg received a tuple instead of a string
+ stored_msg = [smsg[0] if isinstance(smsg, tuple) else smsg for smsg in stored_msg]
+ if msg is not None:
+ # set our separator for returned messages based on parsing ansi or not
+ msg_sep = "|" if noansi else "||"
+ # Have to strip ansi for each returned message for the regex to handle it correctly
+ returned_msg = msg_sep.join(_RE.sub("", ansi.parse_ansi(mess, strip_ansi=noansi))
+ for mess in stored_msg).strip()
+ if msg == "" and returned_msg or not returned_msg.startswith(msg.strip()):
+ sep1 = "\n" + "=" * 30 + "Wanted message" + "=" * 34 + "\n"
+ sep2 = "\n" + "=" * 30 + "Returned message" + "=" * 32 + "\n"
+ sep3 = "\n" + "=" * 78
+ retval = sep1 + msg.strip() + sep2 + returned_msg + sep3
+ raise AssertionError(retval)
+ else:
+ returned_msg = "\n".join(str(msg) for msg in stored_msg)
+ returned_msg = ansi.parse_ansi(returned_msg, strip_ansi=noansi).strip()
+ receiver.msg = old_msg
return returned_msg
@@ -131,18 +149,48 @@ class TestGeneral(CommandTest):
self.call(general.CmdPose(), "looks around", "Char looks around")
def test_nick(self):
- self.call(general.CmdNick(), "testalias = testaliasedstring1", "Inputline-nick 'testalias' mapped to 'testaliasedstring1'.")
- self.call(general.CmdNick(), "/account testalias = testaliasedstring2", "Account-nick 'testalias' mapped to 'testaliasedstring2'.")
- self.call(general.CmdNick(), "/object testalias = testaliasedstring3", "Object-nick 'testalias' mapped to 'testaliasedstring3'.")
+ self.call(general.CmdNick(), "testalias = testaliasedstring1",
+ "Inputline-nick 'testalias' mapped to 'testaliasedstring1'.")
+ self.call(general.CmdNick(), "/account testalias = testaliasedstring2",
+ "Account-nick 'testalias' mapped to 'testaliasedstring2'.")
+ self.call(general.CmdNick(), "/object testalias = testaliasedstring3",
+ "Object-nick 'testalias' mapped to 'testaliasedstring3'.")
self.assertEqual(u"testaliasedstring1", self.char1.nicks.get("testalias"))
- self.assertEqual(None, self.char1.nicks.get("testalias", category="account"))
- self.assertEqual(u"testaliasedstring2", self.char1.account.nicks.get("testalias", category="account"))
+ self.assertEqual(u"testaliasedstring2", self.char1.nicks.get("testalias", category="account"))
+ self.assertEqual(None, self.char1.account.nicks.get("testalias", category="account"))
self.assertEqual(u"testaliasedstring3", self.char1.nicks.get("testalias", category="object"))
def test_get_and_drop(self):
self.call(general.CmdGet(), "Obj", "You pick up Obj.")
self.call(general.CmdDrop(), "Obj", "You drop Obj.")
+ def test_give(self):
+ self.call(general.CmdGive(), "Obj to Char2", "You aren't carrying Obj.")
+ self.call(general.CmdGive(), "Obj = Char2", "You aren't carrying Obj.")
+ self.call(general.CmdGet(), "Obj", "You pick up Obj.")
+ self.call(general.CmdGive(), "Obj to Char2", "You give")
+ self.call(general.CmdGive(), "Obj = Char", "You give", caller=self.char2)
+
+ def test_mux_command(self):
+
+ class CmdTest(MuxCommand):
+ key = 'test'
+ switch_options = ('test', 'testswitch', 'testswitch2')
+
+ def func(self):
+ self.msg("Switches matched: {}".format(self.switches))
+
+ self.call(CmdTest(), "/test/testswitch/testswitch2", "Switches matched: ['test', 'testswitch', 'testswitch2']")
+ self.call(CmdTest(), "/test", "Switches matched: ['test']")
+ self.call(CmdTest(), "/test/testswitch", "Switches matched: ['test', 'testswitch']")
+ self.call(CmdTest(), "/testswitch/testswitch2", "Switches matched: ['testswitch', 'testswitch2']")
+ self.call(CmdTest(), "/testswitch", "Switches matched: ['testswitch']")
+ self.call(CmdTest(), "/testswitch2", "Switches matched: ['testswitch2']")
+ self.call(CmdTest(), "/t", "test: Ambiguous switch supplied: "
+ "Did you mean /test or /testswitch or /testswitch2?|Switches matched: []")
+ self.call(CmdTest(), "/tests", "test: Ambiguous switch supplied: "
+ "Did you mean /testswitch or /testswitch2?|Switches matched: []")
+
def test_say(self):
self.call(general.CmdSay(), "Testing", "You say, \"Testing\"")
@@ -230,7 +278,8 @@ class TestAccount(CommandTest):
self.call(account.CmdColorTest(), "ansi", "ANSI colors:", caller=self.account)
def test_char_create(self):
- self.call(account.CmdCharCreate(), "Test1=Test char", "Created new character Test1. Use @ic Test1 to enter the game", caller=self.account)
+ self.call(account.CmdCharCreate(), "Test1=Test char",
+ "Created new character Test1. Use @ic Test1 to enter the game", caller=self.account)
def test_quell(self):
self.call(account.CmdQuell(), "", "Quelling to current puppet's permissions (developer).", caller=self.account)
@@ -239,16 +288,19 @@ class TestAccount(CommandTest):
class TestBuilding(CommandTest):
def test_create(self):
name = settings.BASE_OBJECT_TYPECLASS.rsplit('.', 1)[1]
- self.call(building.CmdCreate(), "/drop TestObj1", "You create a new %s: TestObj1." % name)
+ self.call(building.CmdCreate(), "/d TestObj1", # /d switch is abbreviated form of /drop
+ "You create a new %s: TestObj1." % name)
def test_examine(self):
self.call(building.CmdExamine(), "Obj", "Name/key: Obj")
def test_set_obj_alias(self):
- self.call(building.CmdSetObjAlias(), "Obj = TestObj1b", "Alias(es) for 'Obj(#4)' set to testobj1b.")
+ self.call(building.CmdSetObjAlias(), "Obj =", "Cleared aliases from Obj(#4)")
+ self.call(building.CmdSetObjAlias(), "Obj = TestObj1b", "Alias(es) for 'Obj(#4)' set to 'testobj1b'.")
def test_copy(self):
- self.call(building.CmdCopy(), "Obj = TestObj2;TestObj2b, TestObj3;TestObj3b", "Copied Obj to 'TestObj3' (aliases: ['TestObj3b']")
+ self.call(building.CmdCopy(), "Obj = TestObj2;TestObj2b, TestObj3;TestObj3b",
+ "Copied Obj to 'TestObj3' (aliases: ['TestObj3b']")
def test_attribute_commands(self):
self.call(building.CmdSetAttribute(), "Obj/test1=\"value1\"", "Created attribute Obj/test1 = 'value1'")
@@ -291,19 +343,36 @@ class TestBuilding(CommandTest):
def test_typeclass(self):
self.call(building.CmdTypeclass(), "Obj = evennia.objects.objects.DefaultExit",
- "Obj changed typeclass from evennia.objects.objects.DefaultObject to evennia.objects.objects.DefaultExit.")
+ "Obj changed typeclass from evennia.objects.objects.DefaultObject "
+ "to evennia.objects.objects.DefaultExit.")
def test_lock(self):
self.call(building.CmdLock(), "Obj = test:perm(Developer)", "Added lock 'test:perm(Developer)' to Obj.")
def test_find(self):
- self.call(building.CmdFind(), "Room2", "One Match")
+ self.call(building.CmdFind(), "oom2", "One Match")
+ expect = "One Match(#1-#7, loc):\n " +\
+ "Char2(#7) - evennia.objects.objects.DefaultCharacter (location: Room(#1))"
+ self.call(building.CmdFind(), "Char2", expect, cmdstring="locate")
+ self.call(building.CmdFind(), "/ex Char2", # /ex is an ambiguous switch
+ "locate: Ambiguous switch supplied: Did you mean /exit or /exact?|" + expect,
+ cmdstring="locate")
+ self.call(building.CmdFind(), "Char2", expect, cmdstring="@locate")
+ self.call(building.CmdFind(), "/l Char2", expect, cmdstring="find") # /l switch is abbreviated form of /loc
+ self.call(building.CmdFind(), "Char2", "One Match", cmdstring="@find")
+ self.call(building.CmdFind(), "/startswith Room2", "One Match")
def test_script(self):
self.call(building.CmdScript(), "Obj = scripts.Script", "Script scripts.Script successfully added")
def test_teleport(self):
- self.call(building.CmdTeleport(), "Room2", "Room2(#2)\n|Teleported to Room2.")
+ self.call(building.CmdTeleport(), "/quiet Room2", "Room2(#2)\n|Teleported to Room2.")
+ self.call(building.CmdTeleport(), "/t", # /t switch is abbreviated form of /tonone
+ "Cannot teleport a puppeted object (Char, puppeted by TestAccount(account 1)) to a None-location.")
+ self.call(building.CmdTeleport(), "/l Room2", # /l switch is abbreviated form of /loc
+ "Destination has no location.")
+ self.call(building.CmdTeleport(), "/q me to Room2", # /q switch is abbreviated form of /quiet
+ "Char is already at Room2.")
def test_spawn(self):
def getObject(commandTest, objKeyStr):
@@ -311,6 +380,7 @@ class TestBuilding(CommandTest):
# check that it exists in the process.
query = search_object(objKeyStr)
commandTest.assertIsNotNone(query)
+ commandTest.assertTrue(bool(query))
obj = query[0]
commandTest.assertIsNotNone(obj)
return obj
@@ -319,17 +389,20 @@ class TestBuilding(CommandTest):
self.call(building.CmdSpawn(), " ", "Usage: @spawn")
# Tests "@spawn " without specifying location.
- self.call(building.CmdSpawn(), \
- "{'key':'goblin', 'typeclass':'evennia.DefaultCharacter'}", "Spawned goblin")
- goblin = getObject(self, "goblin")
- # Tests that the spawned object's type is a DefaultCharacter.
- self.assertIsInstance(goblin, DefaultCharacter)
+ self.call(building.CmdSpawn(),
+ "/save {'prototype_key': 'testprot', 'key':'Test Char', "
+ "'typeclass':'evennia.objects.objects.DefaultCharacter'}",
+ "Saved prototype: testprot", inputs=['y'])
+ self.call(building.CmdSpawn(), "/list", "Key ")
+
+ self.call(building.CmdSpawn(), 'testprot', "Spawned Test Char")
# Tests that the spawned object's location is the same as the caharacter's location, since
# we did not specify it.
- self.assertEqual(goblin.location, self.char1.location)
- goblin.delete()
+ testchar = getObject(self, "Test Char")
+ self.assertEqual(testchar.location, self.char1.location)
+ testchar.delete()
# Test "@spawn " with a location other than the character's.
spawnLoc = self.room2
@@ -338,39 +411,54 @@ class TestBuilding(CommandTest):
# char1's default location in the future...
spawnLoc = self.room1
- self.call(building.CmdSpawn(), \
- "{'prototype':'GOBLIN', 'key':'goblin', 'location':'%s'}" \
- % spawnLoc.dbref, "Spawned goblin")
+ self.call(building.CmdSpawn(),
+ "{'prototype_key':'GOBLIN', 'typeclass':'evennia.objects.objects.DefaultCharacter', "
+ "'key':'goblin', 'location':'%s'}" % spawnLoc.dbref, "Spawned goblin")
goblin = getObject(self, "goblin")
+ # Tests that the spawned object's type is a DefaultCharacter.
+ self.assertIsInstance(goblin, DefaultCharacter)
self.assertEqual(goblin.location, spawnLoc)
+
goblin.delete()
+ # create prototype
+ protlib.create_prototype(**{'key': 'Ball',
+ 'typeclass': 'evennia.objects.objects.DefaultCharacter',
+ 'prototype_key': 'testball'})
+
# Tests "@spawn "
- self.call(building.CmdSpawn(), "'BALL'", "Spawned Ball")
+ self.call(building.CmdSpawn(), "testball", "Spawned Ball")
+
ball = getObject(self, "Ball")
self.assertEqual(ball.location, self.char1.location)
self.assertIsInstance(ball, DefaultObject)
ball.delete()
- # Tests "@spawn/noloc ..." without specifying a location.
+ # Tests "@spawn/n ..." without specifying a location.
# Location should be "None".
- self.call(building.CmdSpawn(), "/noloc 'BALL'", "Spawned Ball")
+ self.call(building.CmdSpawn(), "/n 'BALL'", "Spawned Ball") # /n switch is abbreviated form of /noloc
ball = getObject(self, "Ball")
self.assertIsNone(ball.location)
ball.delete()
+ self.call(building.CmdSpawn(),
+ "/noloc {'prototype_parent':'TESTBALL', 'prototype_key': 'testball', 'location':'%s'}"
+ % spawnLoc.dbref, "Error: Prototype testball tries to parent itself.")
+
# Tests "@spawn/noloc ...", but DO specify a location.
# Location should be the specified location.
- self.call(building.CmdSpawn(), \
- "/noloc {'prototype':'BALL', 'location':'%s'}" \
+ self.call(building.CmdSpawn(),
+ "/noloc {'prototype_parent':'TESTBALL', 'key': 'Ball', 'prototype_key': 'foo', 'location':'%s'}"
% spawnLoc.dbref, "Spawned Ball")
ball = getObject(self, "Ball")
self.assertEqual(ball.location, spawnLoc)
ball.delete()
# test calling spawn with an invalid prototype.
- self.call(building.CmdSpawn(), \
- "'NO_EXIST'", "No prototype named 'NO_EXIST'")
+ self.call(building.CmdSpawn(), "'NO_EXIST'", "No prototype named 'NO_EXIST'")
+
+ # Test listing commands
+ self.call(building.CmdSpawn(), "/list", "Key ")
# @span/edit
self.call(
@@ -388,45 +476,57 @@ class TestComms(CommandTest):
def setUp(self):
super(CommandTest, self).setUp()
- self.call(comms.CmdChannelCreate(), "testchan;test=Test Channel", "Created channel testchan and connected to it.", receiver=self.account)
+ self.call(comms.CmdChannelCreate(), "testchan;test=Test Channel",
+ "Created channel testchan and connected to it.", receiver=self.account)
def test_toggle_com(self):
- self.call(comms.CmdAddCom(), "tc = testchan", "You are already connected to channel testchan. You can now", receiver=self.account)
+ self.call(comms.CmdAddCom(), "tc = testchan",
+ "You are already connected to channel testchan. You can now", receiver=self.account)
self.call(comms.CmdDelCom(), "tc", "Your alias 'tc' for channel testchan was cleared.", receiver=self.account)
def test_channels(self):
- self.call(comms.CmdChannels(), "", "Available channels (use comlist,addcom and delcom to manage", receiver=self.account)
+ self.call(comms.CmdChannels(), "",
+ "Available channels (use comlist,addcom and delcom to manage", receiver=self.account)
def test_all_com(self):
- self.call(comms.CmdAllCom(), "", "Available channels (use comlist,addcom and delcom to manage", receiver=self.account)
+ self.call(comms.CmdAllCom(), "",
+ "Available channels (use comlist,addcom and delcom to manage", receiver=self.account)
def test_clock(self):
- self.call(comms.CmdClock(), "testchan=send:all()", "Lock(s) applied. Current locks on testchan:", receiver=self.account)
+ self.call(comms.CmdClock(),
+ "testchan=send:all()", "Lock(s) applied. Current locks on testchan:", receiver=self.account)
def test_cdesc(self):
- self.call(comms.CmdCdesc(), "testchan = Test Channel", "Description of channel 'testchan' set to 'Test Channel'.", receiver=self.account)
+ self.call(comms.CmdCdesc(), "testchan = Test Channel",
+ "Description of channel 'testchan' set to 'Test Channel'.", receiver=self.account)
def test_cemit(self):
- self.call(comms.CmdCemit(), "testchan = Test Message", "[testchan] Test Message|Sent to channel testchan: Test Message", receiver=self.account)
+ self.call(comms.CmdCemit(), "testchan = Test Message",
+ "[testchan] Test Message|Sent to channel testchan: Test Message", receiver=self.account)
def test_cwho(self):
self.call(comms.CmdCWho(), "testchan", "Channel subscriptions\ntestchan:\n TestAccount", receiver=self.account)
def test_page(self):
- self.call(comms.CmdPage(), "TestAccount2 = Test", "TestAccount2 is offline. They will see your message if they list their pages later.|You paged TestAccount2 with: 'Test'.", receiver=self.account)
+ self.call(comms.CmdPage(), "TestAccount2 = Test",
+ "TestAccount2 is offline. They will see your message if they list their pages later."
+ "|You paged TestAccount2 with: 'Test'.", receiver=self.account)
def test_cboot(self):
# No one else connected to boot
self.call(comms.CmdCBoot(), "", "Usage: @cboot[/quiet] = [:reason]", receiver=self.account)
def test_cdestroy(self):
- self.call(comms.CmdCdestroy(), "testchan", "[testchan] TestAccount: testchan is being destroyed. Make sure to change your aliases.|Channel 'testchan' was destroyed.", receiver=self.account)
+ self.call(comms.CmdCdestroy(), "testchan",
+ "[testchan] TestAccount: testchan is being destroyed. Make sure to change your aliases."
+ "|Channel 'testchan' was destroyed.", receiver=self.account)
class TestBatchProcess(CommandTest):
def test_batch_commands(self):
# cannot test batchcode here, it must run inside the server process
- self.call(batchprocess.CmdBatchCommands(), "example_batch_cmds", "Running Batch-command processor - Automatic mode for example_batch_cmds")
+ self.call(batchprocess.CmdBatchCommands(), "example_batch_cmds",
+ "Running Batch-command processor - Automatic mode for example_batch_cmds")
# we make sure to delete the button again here to stop the running reactor
confirm = building.CmdDestroy.confirm
building.CmdDestroy.confirm = False
diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py
index 293e022a19..8ae700e2c1 100644
--- a/evennia/commands/default/unloggedin.py
+++ b/evennia/commands/default/unloggedin.py
@@ -4,13 +4,13 @@ Commands that are available from the connect screen.
import re
import time
import datetime
-from collections import defaultdict
from random import getrandbits
from django.conf import settings
from django.contrib.auth import authenticate
from evennia.accounts.models import AccountDB
from evennia.objects.models import ObjectDB
from evennia.server.models import ServerConfig
+from evennia.server.throttle import Throttle
from evennia.comms.models import ChannelDB
from evennia.server.sessionhandler import SESSIONS
@@ -26,57 +26,10 @@ __all__ = ("CmdUnconnectedConnect", "CmdUnconnectedCreate",
MULTISESSION_MODE = settings.MULTISESSION_MODE
CONNECTION_SCREEN_MODULE = settings.CONNECTION_SCREEN_MODULE
-# Helper function to throttle failed connection attempts.
-# This can easily be used to limit account creation too,
-# (just supply a different storage dictionary), but this
-# would also block dummyrunner, so it's not added as default.
-
-_LATEST_FAILED_LOGINS = defaultdict(list)
-
-
-def _throttle(session, maxlim=None, timeout=None, storage=_LATEST_FAILED_LOGINS):
- """
- This will check the session's address against the
- _LATEST_LOGINS dictionary to check they haven't
- spammed too many fails recently.
-
- Args:
- session (Session): Session failing
- maxlim (int): max number of attempts to allow
- timeout (int): number of timeout seconds after
- max number of tries has been reached.
-
- Returns:
- throttles (bool): True if throttling is active,
- False otherwise.
-
- Notes:
- If maxlim and/or timeout are set, the function will
- just do the comparison, not append a new datapoint.
-
- """
- address = session.address
- if isinstance(address, tuple):
- address = address[0]
- now = time.time()
- if maxlim and timeout:
- # checking mode
- latest_fails = storage[address]
- if latest_fails and len(latest_fails) >= maxlim:
- # too many fails recently
- if now - latest_fails[-1] < timeout:
- # too soon - timeout in play
- return True
- else:
- # timeout has passed. Reset faillist
- storage[address] = []
- return False
- else:
- return False
- else:
- # store the time of the latest fail
- storage[address].append(time.time())
- return False
+# Create throttles for too many connections, account-creations and login attempts
+CONNECTION_THROTTLE = Throttle(limit=5, timeout=1 * 60)
+CREATION_THROTTLE = Throttle(limit=2, timeout=10 * 60)
+LOGIN_THROTTLE = Throttle(limit=5, timeout=5 * 60)
def create_guest_account(session):
@@ -149,8 +102,11 @@ def create_normal_account(session, name, password):
account (Account): the account which was created from the name and password.
"""
# check for too many login errors too quick.
- if _throttle(session, maxlim=5, timeout=5 * 60):
- # timeout is 5 minutes.
+ address = session.address
+ if isinstance(address, tuple):
+ address = address[0]
+
+ if LOGIN_THROTTLE.check(address):
session.msg("|RYou made too many connection attempts. Try again in a few minutes.|n")
return None
@@ -161,7 +117,7 @@ def create_normal_account(session, name, password):
# No accountname or password match
session.msg("Incorrect login information given.")
# this just updates the throttle
- _throttle(session)
+ LOGIN_THROTTLE.update(address)
# calls account hook for a failed login if possible.
account = AccountDB.objects.get_account_from_name(name)
if account:
@@ -171,7 +127,6 @@ def create_normal_account(session, name, password):
# Check IP and/or name bans
bans = ServerConfig.objects.conf("server_bans")
if bans and (any(tup[0] == account.name.lower() for tup in bans) or
-
any(tup[2].match(session.address) for tup in bans if tup[2])):
# this is a banned IP or name!
string = "|rYou have been banned and cannot continue from here." \
@@ -211,7 +166,10 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS):
session = self.caller
# check for too many login errors too quick.
- if _throttle(session, maxlim=5, timeout=5 * 60, storage=_LATEST_FAILED_LOGINS):
+ address = session.address
+ if isinstance(address, tuple):
+ address = address[0]
+ if CONNECTION_THROTTLE.check(address):
# timeout is 5 minutes.
session.msg("|RYou made too many connection attempts. Try again in a few minutes.|n")
return
@@ -234,6 +192,7 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS):
session.msg("\n\r Usage (without <>): connect ")
return
+ CONNECTION_THROTTLE.update(address)
name, password = parts
account = create_normal_account(session, name, password)
if account:
@@ -263,6 +222,15 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
session = self.caller
args = self.args.strip()
+ # Rate-limit account creation.
+ address = session.address
+
+ if isinstance(address, tuple):
+ address = address[0]
+ if CREATION_THROTTLE.check(address):
+ session.msg("|RYou are creating too many accounts. Try again in a few minutes.|n")
+ return
+
# extract double quoted parts
parts = [part.strip() for part in re.split(r"\"", args) if part.strip()]
if len(parts) == 1:
@@ -294,10 +262,14 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
string = "\n\r That name is reserved. Please choose another Accountname."
session.msg(string)
return
- if not re.findall(r"^[\w. @+\-']+$", password) or not (3 < len(password)):
- string = "\n\r Password should be longer than 3 characers. Letters, spaces, digits and @/./+/-/_/' only." \
- "\nFor best security, make it longer than 8 characters. You can also use a phrase of" \
- "\nmany words if you enclose the password in double quotes."
+
+ # Validate password
+ Account = utils.class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
+ # Have to create a dummy Account object to check username similarity
+ valid, error = Account.validate_password(password, account=Account(username=accountname))
+ if error:
+ errors = [e for suberror in error.messages for e in error.messages]
+ string = "\n".join(errors)
session.msg(string)
return
@@ -322,6 +294,10 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
if MULTISESSION_MODE < 2:
default_home = ObjectDB.objects.get_id(settings.DEFAULT_HOME)
_create_character(session, new_account, typeclass, default_home, permissions)
+
+ # Update the throttle to indicate a new account was created from this IP
+ CREATION_THROTTLE.update(address)
+
# tell the caller everything went well.
string = "A new account '%s' was created. Welcome!"
if " " in accountname:
@@ -577,7 +553,7 @@ def _create_character(session, new_account, typeclass, home, permissions):
# If no description is set, set a default description
if not new_character.db.desc:
- new_character.db.desc = "This is an Account."
+ new_character.db.desc = "This is a character."
# We need to set this to have @ic auto-connect to this character
new_account.db._last_puppet = new_character
except Exception as e:
diff --git a/evennia/comms/comms.py b/evennia/comms/comms.py
index 32ea2731a1..a7d74ae0e4 100644
--- a/evennia/comms/comms.py
+++ b/evennia/comms/comms.py
@@ -26,6 +26,7 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)):
the hooks called by this method.
"""
+ self.basetype_setup()
self.at_channel_creation()
self.attributes.add("log_file", "channel_%s.log" % self.key)
if hasattr(self, "_createdict"):
@@ -46,11 +47,7 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)):
if cdict.get("desc"):
self.attributes.add("desc", cdict["desc"])
- def at_channel_creation(self):
- """
- Called once, when the channel is first created.
-
- """
+ def basetype_setup(self):
# delayed import of the channelhandler
global _CHANNEL_HANDLER
if not _CHANNEL_HANDLER:
@@ -58,6 +55,15 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)):
# register ourselves with the channelhandler.
_CHANNEL_HANDLER.add(self)
+ self.locks.add("send:all();listen:all();control:perm(Admin)")
+
+ def at_channel_creation(self):
+ """
+ Called once, when the channel is first created.
+
+ """
+ pass
+
# helper methods, for easy overloading
def has_connection(self, subscriber):
diff --git a/evennia/comms/migrations/0016_auto_20180925_1735.py b/evennia/comms/migrations/0016_auto_20180925_1735.py
new file mode 100644
index 0000000000..cd4588843f
--- /dev/null
+++ b/evennia/comms/migrations/0016_auto_20180925_1735.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.15 on 2018-09-25 17:35
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('comms', '0015_auto_20170706_2041'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='channeldb',
+ name='db_subscriptions',
+ ),
+ migrations.AlterField(
+ model_name='msg',
+ name='db_message',
+ field=models.TextField(verbose_name=b'message'),
+ ),
+ ]
diff --git a/evennia/comms/models.py b/evennia/comms/models.py
index c358cf352b..bff356ab9f 100644
--- a/evennia/comms/models.py
+++ b/evennia/comms/models.py
@@ -107,7 +107,7 @@ class Msg(SharedMemoryModel):
# it, or as a separate store for the mail subject line maybe.
db_header = models.TextField('header', null=True, blank=True)
# the message body itself
- db_message = models.TextField('messsage')
+ db_message = models.TextField('message')
# send date
db_date_created = models.DateTimeField('date sent', editable=False, auto_now_add=True, db_index=True)
# lock storage
diff --git a/evennia/contrib/README.md b/evennia/contrib/README.md
index ed3c048c32..fad1802237 100644
--- a/evennia/contrib/README.md
+++ b/evennia/contrib/README.md
@@ -16,10 +16,12 @@ things you want from here into your game folder and change them there.
## Contrib modules
* Barter system (Griatch 2012) - A safe and effective barter-system
- for any game. Allows safe trading of any godds (including coin)
+ for any game. Allows safe trading of any goods (including coin).
+* Building menu (vincent-lg 2018) - An @edit command for modifying
+ objects using a generated menu. Customizable for different games.
* CharGen (Griatch 2011) - A simple Character creator for OOC mode.
Meant as a starting point for a more fleshed-out system.
-* Clothing (BattleJenkins 2017) - A layered clothing system with
+* Clothing (FlutterSprite 2017) - A layered clothing system with
slots for different types of garments auto-showing in description.
* Color-markups (Griatch, 2017) - Alternative in-game color markups.
* Custom gametime (Griatch, vlgeoff 2017) - Implements Evennia's
@@ -29,11 +31,15 @@ things you want from here into your game folder and change them there.
that requires an email to login rather then just name+password.
* Extended Room (Griatch 2012) - An expanded Room typeclass with
multiple descriptions for time and season as well as details.
+* Field Fill (FlutterSprite 2018) - A simple system for creating an
+ EvMenu that presents a player with a highly customizable fillable
+ form
* GenderSub (Griatch 2015) - Simple example (only) of storing gender
on a character and access it in an emote with a custom marker.
+* Health Bar (Tim Ashley Jenkins 2017) - Tool to create colorful bars/meters.
* Mail (grungies1138 2016) - An in-game mail system for communication.
* Menu login (Griatch 2011) - A login system using menus asking
- for name/password rather than giving them as one command
+ for name/password rather than giving them as one command.
* Map Builder (CloudKeeper 2016) - Build a game area based on a 2D
"graphical" unicode map. Supports assymmetric exits.
* Menu Login (Vincent-lg 2016) - Alternate login system using EvMenu.
@@ -45,13 +51,18 @@ things you want from here into your game folder and change them there.
speaking unfamiliar languages. Also obfuscates whispers.
* RPSystem (Griatch 2015) - Full director-style emoting system
replacing names with sdescs/recogs. Supports wearing masks.
+* Security/Auditing (Johhny 2018) - Log server input/output for debug/security.
* Simple Door - Example of an exit that can be opened and closed.
* Slow exit (Griatch 2014) - Custom Exit class that takes different
time to pass depending on if you are walking/running etc.
* Talking NPC (Griatch 2011) - A talking NPC object that offers a
menu-driven conversation tree.
-* Turnbattle (BattleJenkins 2017) - A turn-based combat engine meant
- as a start to build from. Has attack/disengage and turn timeouts.
+* Tree Select (FlutterSprite 2017) - A simple system for creating a
+ branching EvMenu with selection options sourced from a single
+ multi-line string.
+* Turnbattle (Tim Ashley Jenkins 2017) - This is a framework for a turn-based
+ combat system with different levels of complexity, including versions with
+ equipment and magic as well as ranged combat.
* Wilderness (titeuf87 2017) - Make infinitely large wilderness areas
with dynamically created locations.
* UnixCommand (Vincent Le Geoff 2017) - Add commands with UNIX-style syntax.
@@ -59,9 +70,12 @@ things you want from here into your game folder and change them there.
## Contrib packages
* EGI_Client (gtaylor 2016) - Client for reporting game status
- to the Evennia game index (games.evennia.com)
+ to the Evennia game index (games.evennia.com).
* In-game Python (Vincent Le Goff 2017) - Allow trusted builders to script
objects and events using Python from in-game.
+* Turnbattle (FlutterSprite 2017) - A turn-based combat engine meant
+ as a start to build from. Has attack/disengage and turn timeouts,
+ and includes optional expansions for equipment and combat movement.
* Tutorial examples (Griatch 2011, 2015) - A folder of basic
example objects, commands and scripts.
* Tutorial world (Griatch 2011, 2015) - A folder containing the
diff --git a/evennia/contrib/building_menu.py b/evennia/contrib/building_menu.py
new file mode 100644
index 0000000000..596af136b3
--- /dev/null
+++ b/evennia/contrib/building_menu.py
@@ -0,0 +1,1147 @@
+"""
+Module containing the building menu system.
+
+Evennia contributor: vincent-lg 2018
+
+Building menus are in-game menus, not unlike `EvMenu` though using a
+different approach. Building menus have been specifically designed to edit
+information as a builder. Creating a building menu in a command allows
+builders quick-editing of a given object, like a room. If you follow the
+steps below to add the contrib, you will have access to an `@edit` command
+that will edit any default object offering to change its key and description.
+
+1. Import the `GenericBuildingCmd` class from this contrib in your `mygame/commands/default_cmdset.py` file:
+
+ ```python
+ from evennia.contrib.building_menu import GenericBuildingCmd
+ ```
+
+2. Below, add the command in the `CharacterCmdSet`:
+
+ ```python
+ # ... These lines should exist in the file
+ class CharacterCmdSet(default_cmds.CharacterCmdSet):
+ key = "DefaultCharacter"
+
+ def at_cmdset_creation(self):
+ super(CharacterCmdSet, self).at_cmdset_creation()
+ # ... add the line below
+ self.add(GenericBuildingCmd())
+ ```
+
+The `@edit` command will allow you to edit any object. You will need to
+specify the object name or ID as an argument. For instance: `@edit here`
+will edit the current room. However, building menus can perform much more
+than this very simple example, read on for more details.
+
+Building menus can be set to edit about anything. Here is an example of
+output you could obtain when editing the room:
+
+```
+ Editing the room: Limbo(#2)
+
+ [T]itle: the limbo room
+ [D]escription
+ This is the limbo room. You can easily change this default description,
+ either by using the |y@desc/edit|n command, or simply by entering this
+ menu (enter |yd|n).
+ [E]xits:
+ north to A parking(#4)
+ [Q]uit this menu
+```
+
+From there, you can open the title choice by pressing t. You can then
+change the room title by simply entering text, and go back to the
+main menu entering @ (all this is customizable). Press q to quit this menu.
+
+The first thing to do is to create a new module and place a class
+inheriting from `BuildingMenu` in it.
+
+```python
+from evennia.contrib.building_menu import BuildingMenu
+
+class RoomBuildingMenu(BuildingMenu):
+ # ...
+```
+
+Next, override the `init` method. You can add choices (like the title,
+description, and exits choices as seen above) by using the `add_choice`
+method.
+
+```
+class RoomBuildingMenu(BuildingMenu):
+ def init(self, room):
+ self.add_choice("title", "t", attr="key")
+```
+
+That will create the first choice, the title choice. If one opens your menu
+and enter t, she will be in the title choice. She can change the title
+(it will write in the room's `key` attribute) and then go back to the
+main menu using `@`.
+
+`add_choice` has a lot of arguments and offers a great deal of
+flexibility. The most useful ones is probably the usage of callbacks,
+as you can set almost any argument in `add_choice` to be a callback, a
+function that you have defined above in your module. This function will be
+called when the menu element is triggered.
+
+Notice that in order to edit a description, the best method to call isn't
+`add_choice`, but `add_choice_edit`. This is a convenient shortcut
+which is available to quickly open an `EvEditor` when entering this choice
+and going back to the menu when the editor closes.
+
+```
+class RoomBuildingMenu(BuildingMenu):
+ def init(self, room):
+ self.add_choice("title", "t", attr="key")
+ self.add_choice_edit("description", key="d", attr="db.desc")
+```
+
+When you wish to create a building menu, you just need to import your
+class, create it specifying your intended caller and object to edit,
+then call `open`:
+
+```python
+from import RoomBuildingMenu
+
+class CmdEdit(Command):
+
+ key = "redit"
+
+ def func(self):
+ menu = RoomBuildingMenu(self.caller, self.caller.location)
+ menu.open()
+```
+
+This is a very short introduction. For more details, see the online tutorial
+(https://github.com/evennia/evennia/wiki/Building-menus) or read the
+heavily-documented code below.
+
+"""
+
+from inspect import getargspec
+from textwrap import dedent
+
+from django.conf import settings
+from evennia import Command, CmdSet
+from evennia.commands import cmdhandler
+from evennia.utils.ansi import strip_ansi
+from evennia.utils.eveditor import EvEditor
+from evennia.utils.logger import log_err, log_trace
+from evennia.utils.utils import class_from_module
+
+
+# Constants
+_MAX_TEXT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
+_CMD_NOMATCH = cmdhandler.CMD_NOMATCH
+_CMD_NOINPUT = cmdhandler.CMD_NOINPUT
+
+
+# Private functions
+def _menu_loadfunc(caller):
+ obj, attr = caller.attributes.get("_building_menu_to_edit", [None, None])
+ if obj and attr:
+ for part in attr.split(".")[:-1]:
+ obj = getattr(obj, part)
+
+ return getattr(obj, attr.split(".")[-1]) if obj is not None else ""
+
+
+def _menu_savefunc(caller, buf):
+ obj, attr = caller.attributes.get("_building_menu_to_edit", [None, None])
+ if obj and attr:
+ for part in attr.split(".")[:-1]:
+ obj = getattr(obj, part)
+
+ setattr(obj, attr.split(".")[-1], buf)
+
+ caller.attributes.remove("_building_menu_to_edit")
+ return True
+
+
+def _menu_quitfunc(caller):
+ caller.cmdset.add(BuildingMenuCmdSet,
+ permanent=caller.ndb._building_menu and
+ caller.ndb._building_menu.persistent or False)
+ if caller.ndb._building_menu:
+ caller.ndb._building_menu.move(back=True)
+
+
+def _call_or_get(value, menu=None, choice=None, string=None, obj=None, caller=None):
+ """
+ Call the value, if appropriate, or just return it.
+
+ Args:
+ value (any): the value to obtain. It might be a callable (see note).
+
+ Kwargs:
+ menu (BuildingMenu, optional): the building menu to pass to value
+ if it is a callable.
+ choice (Choice, optional): the choice to pass to value if a callable.
+ string (str, optional): the raw string to pass to value if a callback.
+ obj (Object): the object to pass to value if a callable.
+ caller (Account or Object, optional): the caller to pass to value
+ if a callable.
+
+ Returns:
+ The value itself. If the argument is a function, call it with
+ specific arguments (see note).
+
+ Note:
+ If `value` is a function, call it with varying arguments. The
+ list of arguments will depend on the argument names in your callable.
+ - An argument named `menu` will contain the building menu or None.
+ - The `choice` argument will contain the choice or None.
+ - The `string` argument will contain the raw string or None.
+ - The `obj` argument will contain the object or None.
+ - The `caller` argument will contain the caller or None.
+ - Any other argument will contain the object (`obj`).
+ Thus, you could define callbacks like this:
+ def on_enter(menu, caller, obj):
+ def on_nomatch(string, choice, menu):
+ def on_leave(caller, room): # note that room will contain `obj`
+
+ """
+ if callable(value):
+ # Check the function arguments
+ kwargs = {}
+ spec = getargspec(value)
+ args = spec.args
+ if spec.keywords:
+ kwargs.update(dict(menu=menu, choice=choice, string=string, obj=obj, caller=caller))
+ else:
+ if "menu" in args:
+ kwargs["menu"] = menu
+ if "choice" in args:
+ kwargs["choice"] = choice
+ if "string" in args:
+ kwargs["string"] = string
+ if "obj" in args:
+ kwargs["obj"] = obj
+ if "caller" in args:
+ kwargs["caller"] = caller
+
+ # Fill missing arguments
+ for arg in args:
+ if arg not in kwargs:
+ kwargs[arg] = obj
+
+ # Call the function and return its return value
+ return value(**kwargs)
+
+ return value
+
+
+# Helper functions, to be used in menu choices
+
+def menu_setattr(menu, choice, obj, string):
+ """
+ Set the value at the specified attribute.
+
+ Args:
+ menu (BuildingMenu): the menu object.
+ choice (Chocie): the specific choice.
+ obj (Object): the object to modify.
+ string (str): the string with the new value.
+
+ Note:
+ This function is supposed to be used as a default to
+ `BuildingMenu.add_choice`, when an attribute name is specified
+ (in the `attr` argument) but no function `on_nomatch` is defined.
+
+ """
+ attr = getattr(choice, "attr", None) if choice else None
+ if choice is None or string is None or attr is None or menu is None:
+ log_err(dedent("""
+ The `menu_setattr` function was called to set the attribute {} of object {} to {},
+ but the choice {} of menu {} or another information is missing.
+ """.format(attr, obj, repr(string), choice, menu)).strip("\n")).strip()
+ return
+
+ for part in attr.split(".")[:-1]:
+ obj = getattr(obj, part)
+
+ setattr(obj, attr.split(".")[-1], string)
+ return True
+
+
+def menu_quit(caller, menu):
+ """
+ Quit the menu, closing the CmdSet.
+
+ Args:
+ caller (Account or Object): the caller.
+ menu (BuildingMenu): the building menu to close.
+
+ Note:
+ This callback is used by default when using the
+ `BuildingMenu.add_choice_quit` method. This method is called
+ automatically if the menu has no parent.
+
+ """
+ if caller is None or menu is None:
+ log_err("The function `menu_quit` was called with missing "
+ "arguments: caller={}, menu={}".format(caller, menu))
+
+ if caller.cmdset.has(BuildingMenuCmdSet):
+ menu.close()
+ caller.msg("Closing the building menu.")
+ else:
+ caller.msg("It looks like the building menu has already been closed.")
+
+
+def menu_edit(caller, choice, obj):
+ """
+ Open the EvEditor to edit a specified attribute.
+
+ Args:
+ caller (Account or Object): the caller.
+ choice (Choice): the choice object.
+ obj (Object): the object to edit.
+
+ """
+ attr = choice.attr
+ caller.db._building_menu_to_edit = (obj, attr)
+ caller.cmdset.remove(BuildingMenuCmdSet)
+ EvEditor(caller, loadfunc=_menu_loadfunc, savefunc=_menu_savefunc, quitfunc=_menu_quitfunc,
+ key="editor", persistent=True)
+
+
+# Building menu commands and CmdSet
+
+class CmdNoInput(Command):
+
+ """No input has been found."""
+
+ key = _CMD_NOINPUT
+ locks = "cmd:all()"
+
+ def __init__(self, **kwargs):
+ self.menu = kwargs.pop("building_menu", None)
+ super(Command, self).__init__(**kwargs)
+
+ def func(self):
+ """Display the menu or choice text."""
+ if self.menu:
+ self.menu.display()
+ else:
+ log_err("When CMDNOINPUT was called, the building menu couldn't be found")
+ self.caller.msg("|rThe building menu couldn't be found, remove the CmdSet.|n")
+ self.caller.cmdset.delete(BuildingMenuCmdSet)
+
+
+class CmdNoMatch(Command):
+
+ """No input has been found."""
+
+ key = _CMD_NOMATCH
+ locks = "cmd:all()"
+
+ def __init__(self, **kwargs):
+ self.menu = kwargs.pop("building_menu", None)
+ super(Command, self).__init__(**kwargs)
+
+ def func(self):
+ """Call the proper menu or redirect to nomatch."""
+ raw_string = self.args.rstrip()
+ if self.menu is None:
+ log_err("When CMDNOMATCH was called, the building menu couldn't be found")
+ self.caller.msg("|rThe building menu couldn't be found, remove the CmdSet.|n")
+ self.caller.cmdset.delete(BuildingMenuCmdSet)
+ return
+
+ choice = self.menu.current_choice
+ if raw_string in self.menu.keys_go_back:
+ if self.menu.keys:
+ self.menu.move(back=True)
+ elif self.menu.parents:
+ self.menu.open_parent_menu()
+ else:
+ self.menu.display()
+ elif choice:
+ if choice.nomatch(raw_string):
+ self.caller.msg(choice.format_text())
+ else:
+ for choice in self.menu.relevant_choices:
+ if choice.key.lower() == raw_string.lower() or any(raw_string.lower() == alias for alias in choice.aliases):
+ self.menu.move(choice.key)
+ return
+
+ self.msg("|rUnknown command: {}|n.".format(raw_string))
+
+
+class BuildingMenuCmdSet(CmdSet):
+
+ """Building menu CmdSet."""
+
+ key = "building_menu"
+ priority = 5
+
+ def at_cmdset_creation(self):
+ """Populates the cmdset with commands."""
+ caller = self.cmdsetobj
+
+ # The caller could recall the menu
+ menu = caller.ndb._building_menu
+ if menu is None:
+ menu = caller.db._building_menu
+ if menu:
+ menu = BuildingMenu.restore(caller)
+
+ cmds = [CmdNoInput, CmdNoMatch]
+ for cmd in cmds:
+ self.add(cmd(building_menu=menu))
+
+
+# Menu classes
+
+class Choice(object):
+
+ """A choice object, created by `add_choice`."""
+
+ def __init__(self, title, key=None, aliases=None, attr=None, text=None,
+ glance=None, on_enter=None, on_nomatch=None, on_leave=None,
+ menu=None, caller=None, obj=None):
+ """Constructor.
+
+ Args:
+ title (str): the choice's title.
+ key (str, optional): the key of the letters to type to access
+ the choice. If not set, try to guess it based on the title.
+ aliases (list of str, optional): the allowed aliases for this choice.
+ attr (str, optional): the name of the attribute of 'obj' to set.
+ text (str or callable, optional): a text to be displayed for this
+ choice. It can be a callable.
+ glance (str or callable, optional): an at-a-glance summary of the
+ sub-menu shown in the main menu. It can be set to
+ display the current value of the attribute in the
+ main menu itself.
+ menu (BuildingMenu, optional): the parent building menu.
+ on_enter (callable, optional): a callable to call when the
+ caller enters into the choice.
+ on_nomatch (callable, optional): a callable to call when no
+ match is entered in the choice.
+ on_leave (callable, optional): a callable to call when the caller
+ leaves the choice.
+ caller (Account or Object, optional): the caller.
+ obj (Object, optional): the object to edit.
+
+ """
+ self.title = title
+ self.key = key
+ self.aliases = aliases
+ self.attr = attr
+ self.text = text
+ self.glance = glance
+ self.on_enter = on_enter
+ self.on_nomatch = on_nomatch
+ self.on_leave = on_leave
+ self.menu = menu
+ self.caller = caller
+ self.obj = obj
+
+ def __repr__(self):
+ return "".format(self.title, self.key)
+
+ @property
+ def keys(self):
+ """Return a tuple of keys separated by `sep_keys`."""
+ return tuple(self.key.split(self.menu.sep_keys))
+
+ def format_text(self):
+ """Format the choice text and return it, or an empty string."""
+ text = ""
+ if self.text:
+ text = _call_or_get(self.text, menu=self.menu, choice=self, string="", caller=self.caller, obj=self.obj)
+ text = dedent(text.strip("\n"))
+ text = text.format(obj=self.obj, caller=self.caller)
+
+ return text
+
+ def enter(self, string):
+ """Called when the user opens the choice.
+
+ Args:
+ string (str): the entered string.
+
+ """
+ if self.on_enter:
+ _call_or_get(self.on_enter, menu=self.menu, choice=self, string=string,
+ caller=self.caller, obj=self.obj)
+
+ def nomatch(self, string):
+ """Called when the user entered something in the choice.
+
+ Args:
+ string (str): the entered string.
+
+ Returns:
+ to_display (bool): The return value of `nomatch` if set or
+ `True`. The rule is that if `no_match` returns `True`,
+ then the choice or menu is displayed.
+
+ """
+ if self.on_nomatch:
+ return _call_or_get(self.on_nomatch, menu=self.menu, choice=self,
+ string=string, caller=self.caller, obj=self.obj)
+
+ return True
+
+ def leave(self, string):
+ """Called when the user closes the choice.
+
+ Args:
+ string (str): the entered string.
+
+ """
+ if self.on_leave:
+ _call_or_get(self.on_leave, menu=self.menu, choice=self,
+ string=string, caller=self.caller, obj=self.obj)
+
+
+class BuildingMenu(object):
+
+ """
+ Class allowing to create and set building menus to edit specific objects.
+
+ A building menu is somewhat similar to `EvMenu`, but designed to edit
+ objects by builders, although it can be used for players in some contexts.
+ You could, for instance, create a building menu to edit a room with a
+ sub-menu for the room's key, another for the room's description,
+ another for the room's exits, and so on.
+
+ To add choices (simple sub-menus), you should call `add_choice` (see the
+ full documentation of this method). With most arguments, you can
+ specify either a plain string or a callback. This callback will be
+ called when the operation is to be performed.
+
+ Some methods are provided for frequent needs (see the `add_choice_*`
+ methods). Some helper functions are defined at the top of this
+ module in order to be used as arguments to `add_choice`
+ in frequent cases.
+
+ """
+
+ keys_go_back = ["@"] # The keys allowing to go back in the menu tree
+ sep_keys = "." # The key separator for menus with more than 2 levels
+ joker_key = "*" # The special key meaning "anything" in a choice key
+ min_shortcut = 1 # The minimum length of shorcuts when `key` is not set
+
+ def __init__(self, caller=None, obj=None, title="Building menu: {obj}",
+ keys=None, parents=None, persistent=False):
+ """Constructor, you shouldn't override. See `init` instead.
+
+ Args:
+ caller (Account or Object): the caller.
+ obj (Object): the object to be edited, like a room.
+ title (str, optional): the menu title.
+ keys (list of str, optional): the starting menu keys (None
+ to start from the first level).
+ parents (tuple, optional): information for parent menus,
+ automatically supplied.
+ persistent (bool, optional): should this building menu
+ survive a reload/restart?
+
+ Note:
+ If some of these options have to be changed, it is
+ preferable to do so in the `init` method and not to
+ override `__init__`. For instance:
+ class RoomBuildingMenu(BuildingMenu):
+ def init(self, room):
+ self.title = "Menu for room: {obj.key}(#{obj.id})"
+ # ...
+
+ """
+ self.caller = caller
+ self.obj = obj
+ self.title = title
+ self.keys = keys or []
+ self.parents = parents or ()
+ self.persistent = persistent
+ self.choices = []
+ self.cmds = {}
+ self.can_quit = False
+
+ if obj:
+ self.init(obj)
+ if not parents and not self.can_quit:
+ # Automatically add the menu to quit
+ self.add_choice_quit(key=None)
+ self._add_keys_choice()
+
+ @property
+ def current_choice(self):
+ """Return the current choice or None.
+
+ Returns:
+ choice (Choice): the current choice or None.
+
+ Note:
+ We use the menu keys to identify the current position of
+ the caller in the menu. The menu `keys` hold a list of
+ keys that should match a choice to be usable.
+
+ """
+ menu_keys = self.keys
+ if not menu_keys:
+ return None
+
+ for choice in self.choices:
+ choice_keys = choice.keys
+ if len(menu_keys) == len(choice_keys):
+ # Check all the intermediate keys
+ common = True
+ for menu_key, choice_key in zip(menu_keys, choice_keys):
+ if choice_key == self.joker_key:
+ continue
+
+ if not isinstance(menu_key, basestring) or menu_key != choice_key:
+ common = False
+ break
+
+ if common:
+ return choice
+
+ return None
+
+ @property
+ def relevant_choices(self):
+ """Only return the relevant choices according to the current meny key.
+
+ Returns:
+ relevant (list of Choice object): the relevant choices.
+
+ Note:
+ We use the menu keys to identify the current position of
+ the caller in the menu. The menu `keys` hold a list of
+ keys that should match a choice to be usable.
+
+ """
+ menu_keys = self.keys
+ relevant = []
+ for choice in self.choices:
+ choice_keys = choice.keys
+ if not menu_keys and len(choice_keys) == 1:
+ # First level choice with the menu key empty, that's relevant
+ relevant.append(choice)
+ elif len(menu_keys) == len(choice_keys) - 1:
+ # Check all the intermediate keys
+ common = True
+ for menu_key, choice_key in zip(menu_keys, choice_keys):
+ if choice_key == self.joker_key:
+ continue
+
+ if not isinstance(menu_key, basestring) or menu_key != choice_key:
+ common = False
+ break
+
+ if common:
+ relevant.append(choice)
+
+ return relevant
+
+ def _save(self):
+ """Save the menu in a attributes on the caller.
+
+ If `persistent` is set to `True`, also save in a persistent attribute.
+
+ """
+ self.caller.ndb._building_menu = self
+
+ if self.persistent:
+ self.caller.db._building_menu = {
+ "class": type(self).__module__ + "." + type(self).__name__,
+ "obj": self.obj,
+ "title": self.title,
+ "keys": self.keys,
+ "parents": self.parents,
+ "persistent": self.persistent,
+ }
+
+ def _add_keys_choice(self):
+ """Add the choices' keys if some choices don't have valid keys."""
+ # If choices have been added without keys, try to guess them
+ for choice in self.choices:
+ if not choice.key:
+ title = strip_ansi(choice.title.strip()).lower()
+ length = self.min_shortcut
+ while length <= len(title):
+ i = 0
+ while i < len(title) - length + 1:
+ guess = title[i:i + length]
+ if guess not in self.cmds:
+ choice.key = guess
+ break
+
+ i += 1
+
+ if choice.key:
+ break
+
+ length += 1
+
+ if choice.key:
+ self.cmds[choice.key] = choice
+ else:
+ raise ValueError("Cannot guess the key for {}".format(choice))
+
+ def init(self, obj):
+ """Create the sub-menu to edit the specified object.
+
+ Args:
+ obj (Object): the object to edit.
+
+ Note:
+ This method is probably to be overridden in your subclasses.
+ Use `add_choice` and its variants to create menu choices.
+
+ """
+ pass
+
+ def add_choice(self, title, key=None, aliases=None, attr=None, text=None, glance=None,
+ on_enter=None, on_nomatch=None, on_leave=None):
+ """
+ Add a choice, a valid sub-menu, in the current builder menu.
+
+ Args:
+ title (str): the choice's title.
+ key (str, optional): the key of the letters to type to access
+ the sub-neu. If not set, try to guess it based on the
+ choice title.
+ aliases (list of str, optional): the aliases for this choice.
+ attr (str, optional): the name of the attribute of 'obj' to set.
+ This is really useful if you want to edit an
+ attribute of the object (that's a frequent need). If
+ you don't want to do so, just use the `on_*` arguments.
+ text (str or callable, optional): a text to be displayed when
+ the menu is opened It can be a callable.
+ glance (str or callable, optional): an at-a-glance summary of the
+ sub-menu shown in the main menu. It can be set to
+ display the current value of the attribute in the
+ main menu itself.
+ on_enter (callable, optional): a callable to call when the
+ caller enters into this choice.
+ on_nomatch (callable, optional): a callable to call when
+ the caller enters something in this choice. If you
+ don't set this argument but you have specified
+ `attr`, then `obj`.`attr` will be set with the value
+ entered by the user.
+ on_leave (callable, optional): a callable to call when the
+ caller leaves the choice.
+
+ Returns:
+ choice (Choice): the newly-created choice.
+
+ Raises:
+ ValueError if the choice cannot be added.
+
+ Note:
+ Most arguments can be callables, like functions. This has the
+ advantage of allowing great flexibility. If you specify
+ a callable in most of the arguments, the callable should return
+ the value expected by the argument (a str more often than
+ not). For instance, you could set a function to be called
+ to get the menu text, which allows for some filtering:
+ def text_exits(menu):
+ return "Some text to display"
+ class RoomBuildingMenu(BuildingMenu):
+ def init(self):
+ self.add_choice("exits", key="x", text=text_exits)
+
+ The allowed arguments in a callable are specific to the
+ argument names (they are not sensitive to orders, not all
+ arguments have to be present). For more information, see
+ `_call_or_get`.
+
+ """
+ key = key or ""
+ key = key.lower()
+ aliases = aliases or []
+ aliases = [a.lower() for a in aliases]
+ if attr and on_nomatch is None:
+ on_nomatch = menu_setattr
+
+ if key and key in self.cmds:
+ raise ValueError("A conflict exists between {} and {}, both use "
+ "key or alias {}".format(self.cmds[key], title, repr(key)))
+
+ if attr:
+ if glance is None:
+ glance = "{obj." + attr + "}"
+ if text is None:
+ text = """
+ -------------------------------------------------------------------------------
+ {attr} for {{obj}}(#{{obj.id}})
+
+ You can change this value simply by entering it.
+ Use |y{back}|n to go back to the main menu.
+
+ Current value: |c{{{obj_attr}}}|n
+ """.format(attr=attr, obj_attr="obj." + attr,
+ back="|n or |y".join(self.keys_go_back))
+
+ choice = Choice(title, key=key, aliases=aliases, attr=attr, text=text, glance=glance,
+ on_enter=on_enter, on_nomatch=on_nomatch, on_leave=on_leave,
+ menu=self, caller=self.caller, obj=self.obj)
+ self.choices.append(choice)
+ if key:
+ self.cmds[key] = choice
+
+ for alias in aliases:
+ self.cmds[alias] = choice
+
+ return choice
+
+ def add_choice_edit(self, title="description", key="d", aliases=None, attr="db.desc",
+ glance="\n {obj.db.desc}", on_enter=None):
+ """
+ Add a simple choice to edit a given attribute in the EvEditor.
+
+ Args:
+ title (str, optional): the choice's title.
+ key (str, optional): the choice's key.
+ aliases (list of str, optional): the choice's aliases.
+ glance (str or callable, optional): the at-a-glance description.
+ on_enter (callable, optional): a different callable to edit
+ the attribute.
+
+ Returns:
+ choice (Choice): the newly-created choice.
+
+ Note:
+ This is just a shortcut method, calling `add_choice`.
+ If `on_enter` is not set, use `menu_edit` which opens
+ an EvEditor to edit the specified attribute.
+ When the caller closes the editor (with :q), the menu
+ will be re-opened.
+
+ """
+ on_enter = on_enter or menu_edit
+ return self.add_choice(title, key=key, aliases=aliases, attr=attr,
+ glance=glance, on_enter=on_enter, text="")
+
+ def add_choice_quit(self, title="quit the menu", key="q", aliases=None, on_enter=None):
+ """
+ Add a simple choice just to quit the building menu.
+
+ Args:
+ title (str, optional): the choice's title.
+ key (str, optional): the choice's key.
+ aliases (list of str, optional): the choice's aliases.
+ on_enter (callable, optional): a different callable
+ to quit the building menu.
+
+ Note:
+ This is just a shortcut method, calling `add_choice`.
+ If `on_enter` is not set, use `menu_quit` which simply
+ closes the menu and displays a message. It also
+ removes the CmdSet from the caller. If you supply
+ another callable instead, make sure to do the same.
+
+ """
+ on_enter = on_enter or menu_quit
+ self.can_quit = True
+ return self.add_choice(title, key=key, aliases=aliases, on_enter=on_enter)
+
+ def open(self):
+ """Open the building menu for the caller.
+
+ Note:
+ This method should be called once when the building menu
+ has been instanciated. From there, the building menu will
+ be re-created automatically when the server
+ reloads/restarts, assuming `persistent` is set to `True`.
+
+ """
+ caller = self.caller
+ self._save()
+
+ # Remove the same-key cmdset if exists
+ if caller.cmdset.has(BuildingMenuCmdSet):
+ caller.cmdset.remove(BuildingMenuCmdSet)
+
+ self.caller.cmdset.add(BuildingMenuCmdSet, permanent=self.persistent)
+ self.display()
+
+ def open_parent_menu(self):
+ """Open the parent menu, using `self.parents`.
+
+ Note:
+ You probably don't need to call this method directly,
+ since the caller can go back to the parent menu using the
+ `keys_go_back` automatically.
+
+ """
+ parents = list(self.parents)
+ if parents:
+ parent_class, parent_obj, parent_keys = parents[-1]
+ del parents[-1]
+
+ if self.caller.cmdset.has(BuildingMenuCmdSet):
+ self.caller.cmdset.remove(BuildingMenuCmdSet)
+
+ try:
+ menu_class = class_from_module(parent_class)
+ except Exception:
+ log_trace("BuildingMenu: attempting to load class {} failed".format(
+ repr(parent_class)))
+ return
+
+ # Create the parent menu
+ try:
+ building_menu = menu_class(self.caller, parent_obj,
+ keys=parent_keys, parents=tuple(parents))
+ except Exception:
+ log_trace("An error occurred while creating building menu {}".format(
+ repr(parent_class)))
+ return
+ else:
+ return building_menu.open()
+
+ def open_submenu(self, submenu_class, submenu_obj, parent_keys=None):
+ """
+ Open a sub-menu, closing the current menu and opening the new one.
+
+ Args:
+ submenu_class (str): the submenu class as a Python path.
+ submenu_obj (Object): the object to give to the submenu.
+ parent_keys (list of str, optional): the parent keys when
+ the submenu is closed.
+
+ Note:
+ When the user enters `@` in the submenu, she will go back to
+ the current menu, with the `parent_keys` set as its keys.
+ Therefore, you should set it on the keys of the choice that
+ should be opened when the user leaves the submenu.
+
+ Returns:
+ new_menu (BuildingMenu): the new building menu or None.
+
+ """
+ parent_keys = parent_keys or []
+ parents = list(self.parents)
+ parents.append((type(self).__module__ + "." + type(self).__name__, self.obj, parent_keys))
+ if self.caller.cmdset.has(BuildingMenuCmdSet):
+ self.caller.cmdset.remove(BuildingMenuCmdSet)
+
+ # Shift to the new menu
+ try:
+ menu_class = class_from_module(submenu_class)
+ except Exception:
+ log_trace("BuildingMenu: attempting to load class {} failed".format(repr(submenu_class)))
+ return
+
+ # Create the submenu
+ try:
+ building_menu = menu_class(self.caller, submenu_obj, parents=parents)
+ except Exception:
+ log_trace("An error occurred while creating building menu {}".format(repr(submenu_class)))
+ return
+ else:
+ return building_menu.open()
+
+ def move(self, key=None, back=False, quiet=False, string=""):
+ """
+ Move inside the menu.
+
+ Args:
+ key (any): the portion of the key to add to the current
+ menu keys. If you wish to go back in the menu
+ tree, don't provide a `key`, just set `back` to `True`.
+ back (bool, optional): go back in the menu (`False` by default).
+ quiet (bool, optional): should the menu or choice be
+ displayed afterward?
+ string (str, optional): the string sent by the caller to move.
+
+ Note:
+ This method will need to be called directly should you
+ use more than two levels in your menu. For instance,
+ in your room menu, if you want to have an "exits"
+ option, and then be able to enter "north" in this
+ choice to edit an exit. The specific exit choice
+ could be a different menu (with a different class), but
+ it could also be an additional level in your original menu.
+ If that's the case, you will need to use this method.
+
+ """
+ choice = self.current_choice
+ if choice:
+ choice.leave("")
+
+ if not back: # Move forward
+ if not key:
+ raise ValueError("you are asking to move forward, you should specify a key.")
+
+ self.keys.append(key)
+ else: # Move backward
+ if not self.keys:
+ raise ValueError("you already are at the top of the tree, you cannot move backward.")
+
+ del self.keys[-1]
+
+ self._save()
+ choice = self.current_choice
+ if choice:
+ choice.enter(string)
+
+ if not quiet:
+ self.display()
+
+ def close(self):
+ """Close the building menu, removing the CmdSet."""
+ if self.caller.cmdset.has(BuildingMenuCmdSet):
+ self.caller.cmdset.delete(BuildingMenuCmdSet)
+ if self.caller.attributes.has("_building_menu"):
+ self.caller.attributes.remove("_building_menu")
+ if self.caller.nattributes.has("_building_menu"):
+ self.caller.nattributes.remove("_building_menu")
+
+ # Display methods. Override for customization
+ def display_title(self):
+ """Return the menu title to be displayed."""
+ return _call_or_get(self.title, menu=self, obj=self.obj, caller=self.caller).format(obj=self.obj)
+
+ def display_choice(self, choice):
+ """Display the specified choice.
+
+ Args:
+ choice (Choice): the menu choice.
+
+ """
+ title = _call_or_get(choice.title, menu=self, choice=choice, obj=self.obj, caller=self.caller)
+ clear_title = title.lower()
+ pos = clear_title.find(choice.key.lower())
+ ret = " "
+ if pos >= 0:
+ ret += title[:pos] + "[|y" + choice.key.title() + "|n]" + title[pos + len(choice.key):]
+ else:
+ ret += "[|y" + choice.key.title() + "|n] " + title
+
+ if choice.glance:
+ glance = _call_or_get(choice.glance, menu=self, choice=choice,
+ caller=self.caller, string="", obj=self.obj)
+ glance = glance.format(obj=self.obj, caller=self.caller)
+
+ ret += ": " + glance
+
+ return ret
+
+ def display(self):
+ """Display the entire menu or a single choice, depending on the keys."""
+ choice = self.current_choice
+ if self.keys and choice:
+ text = choice.format_text()
+ else:
+ text = self.display_title() + "\n"
+ for choice in self.relevant_choices:
+ text += "\n" + self.display_choice(choice)
+
+ self.caller.msg(text)
+
+ @staticmethod
+ def restore(caller):
+ """Restore the building menu for the caller.
+
+ Args:
+ caller (Account or Object): the caller.
+
+ Note:
+ This method should be automatically called if a menu is
+ saved in the caller, but the object itself cannot be found.
+
+ """
+ menu = caller.db._building_menu
+ if menu:
+ class_name = menu.get("class")
+ if not class_name:
+ log_err("BuildingMenu: on caller {}, a persistent attribute holds building menu "
+ "data, but no class could be found to restore the menu".format(caller))
+ return
+
+ try:
+ menu_class = class_from_module(class_name)
+ except Exception:
+ log_trace("BuildingMenu: attempting to load class {} failed".format(repr(class_name)))
+ return
+
+ # Create the menu
+ obj = menu.get("obj")
+ keys = menu.get("keys")
+ title = menu.get("title", "")
+ parents = menu.get("parents")
+ persistent = menu.get("persistent", False)
+ try:
+ building_menu = menu_class(caller, obj, title=title, keys=keys,
+ parents=parents, persistent=persistent)
+ except Exception:
+ log_trace("An error occurred while creating building menu {}".format(repr(class_name)))
+ return
+
+ return building_menu
+
+
+# Generic building menu and command
+class GenericBuildingMenu(BuildingMenu):
+
+ """A generic building menu, allowing to edit any object.
+
+ This is more a demonstration menu. By default, it allows to edit the
+ object key and description. Nevertheless, it will be useful to demonstrate
+ how building menus are meant to be used.
+
+ """
+
+ def init(self, obj):
+ """Build the meny, adding the 'key' and 'description' choices.
+
+ Args:
+ obj (Object): any object to be edited, like a character or room.
+
+ Note:
+ The 'quit' choice will be automatically added, though you can
+ call `add_choice_quit` to add this choice with different options.
+
+ """
+ self.add_choice("key", key="k", attr="key", glance="{obj.key}", text="""
+ -------------------------------------------------------------------------------
+ Editing the key of {{obj.key}}(#{{obj.id}})
+
+ You can change the simply by entering it.
+ Use |y{back}|n to go back to the main menu.
+
+ Current key: |c{{obj.key}}|n
+ """.format(back="|n or |y".join(self.keys_go_back)))
+ self.add_choice_edit("description", key="d", attr="db.desc")
+
+
+class GenericBuildingCmd(Command):
+
+ """
+ Generic building command.
+
+ Syntax:
+ @edit [object]
+
+ Open a building menu to edit the specified object. This menu allows to
+ change the object's key and description.
+
+ Examples:
+ @edit here
+ @edit self
+ @edit #142
+
+ """
+
+ key = "@edit"
+
+ def func(self):
+ if not self.args.strip():
+ self.msg("You should provide an argument to this function: the object to edit.")
+ return
+
+ obj = self.caller.search(self.args.strip(), global_search=True)
+ if not obj:
+ return
+
+ menu = GenericBuildingMenu(self.caller, obj)
+ menu.open()
diff --git a/evennia/contrib/email_login.py b/evennia/contrib/email_login.py
index 01d08abab4..c98bf4aa88 100644
--- a/evennia/contrib/email_login.py
+++ b/evennia/contrib/email_login.py
@@ -197,7 +197,7 @@ class CmdUnconnectedCreate(MuxCommand):
session.msg(string)
return
if not re.findall(r"^[\w. @+\-']+$", password) or not (3 < len(password)):
- string = "\n\r Password should be longer than 3 characers. Letters, spaces, digits and @/./+/-/_/' only." \
+ string = "\n\r Password should be longer than 3 characters. Letters, spaces, digits and @/./+/-/_/' only." \
"\nFor best security, make it longer than 8 characters. You can also use a phrase of" \
"\nmany words if you enclose the password in double quotes."
session.msg(string)
diff --git a/evennia/contrib/extended_room.py b/evennia/contrib/extended_room.py
index 0530bd796c..6cacca980c 100644
--- a/evennia/contrib/extended_room.py
+++ b/evennia/contrib/extended_room.py
@@ -213,37 +213,39 @@ class ExtendedRoom(DefaultRoom):
return detail
return None
- def return_appearance(self, looker):
+ def return_appearance(self, looker, **kwargs):
"""
This is called when e.g. the look command wants to retrieve
the description of this object.
Args:
looker (Object): The object looking at us.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
Returns:
description (str): Our description.
"""
- update = False
+ # ensures that our description is current based on time/season
+ self.update_current_description()
+ # run the normal return_appearance method, now that desc is updated.
+ return super(ExtendedRoom, self).return_appearance(looker, **kwargs)
+ def update_current_description(self):
+ """
+ This will update the description of the room if the time or season
+ has changed since last checked.
+ """
+ update = False
# get current time and season
curr_season, curr_timeslot = self.get_time_and_season()
-
# compare with previously stored slots
last_season = self.ndb.last_season
last_timeslot = self.ndb.last_timeslot
-
if curr_season != last_season:
# season changed. Load new desc, or a fallback.
- if curr_season == 'spring':
- new_raw_desc = self.db.spring_desc
- elif curr_season == 'summer':
- new_raw_desc = self.db.summer_desc
- elif curr_season == 'autumn':
- new_raw_desc = self.db.autumn_desc
- else:
- new_raw_desc = self.db.winter_desc
+ new_raw_desc = self.attributes.get("%s_desc" % curr_season)
if new_raw_desc:
raw_desc = new_raw_desc
else:
@@ -252,19 +254,15 @@ class ExtendedRoom(DefaultRoom):
self.db.raw_desc = raw_desc
self.ndb.last_season = curr_season
update = True
-
if curr_timeslot != last_timeslot:
# timeslot changed. Set update flag.
self.ndb.last_timeslot = curr_timeslot
update = True
-
if update:
# if anything changed we have to re-parse
# the raw_desc for time markers
# and re-save the description again.
self.db.desc = self.replace_timeslots(self.db.raw_desc, curr_timeslot)
- # run the normal return_appearance method, now that desc is updated.
- return super(ExtendedRoom, self).return_appearance(looker)
# Custom Look command supporting Room details. Add this to
@@ -369,6 +367,7 @@ class CmdExtendedDesc(default_cmds.CmdDesc):
"""
aliases = ["describe", "detail"]
+ switch_options = () # Inherits from default_cmds.CmdDesc, but unused here
def reset_times(self, obj):
"""By deleteting the caches we force a re-load."""
diff --git a/evennia/contrib/fieldfill.py b/evennia/contrib/fieldfill.py
new file mode 100644
index 0000000000..b20f64cd66
--- /dev/null
+++ b/evennia/contrib/fieldfill.py
@@ -0,0 +1,667 @@
+"""
+Easy fillable form
+
+Contrib - Tim Ashley Jenkins 2018
+
+This module contains a function that calls an easily customizable EvMenu - this
+menu presents the player with a fillable form, with fields that can be filled
+out in any order. Each field's value can be verified, with the function
+allowing easy checks for text and integer input, minimum and maximum values /
+character lengths, or can even be verified by a custom function. Once the form
+is submitted, the form's data is submitted as a dictionary to any callable of
+your choice.
+
+The function that initializes the fillable form menu is fairly simple, and
+includes the caller, the template for the form, and the callback(caller, result) to which the form
+data will be sent to upon submission.
+
+ init_fill_field(formtemplate, caller, formcallback)
+
+Form templates are defined as a list of dictionaries - each dictionary
+represents a field in the form, and contains the data for the field's name and
+behavior. For example, this basic form template will allow a player to fill out
+a brief character profile:
+
+ PROFILE_TEMPLATE = [
+ {"fieldname":"Name", "fieldtype":"text"},
+ {"fieldname":"Age", "fieldtype":"number"},
+ {"fieldname":"History", "fieldtype":"text"},
+ ]
+
+This will present the player with an EvMenu showing this basic form:
+
+ Name:
+ Age:
+ History:
+
+While in this menu, the player can assign a new value to any field with the
+syntax = , like so:
+
+ > name = Ashley
+ Field 'Name' set to: Ashley
+
+Typing 'look' by itself will show the form and its current values.
+
+ > look
+
+ Name: Ashley
+ Age:
+ History:
+
+Number fields require an integer input, and will reject any text that can't
+be converted into an integer.
+
+ > age = youthful
+ Field 'Age' requires a number.
+ > age = 31
+ Field 'Age' set to: 31
+
+Form data is presented as an EvTable, so text of any length will wrap cleanly.
+
+ > history = EVERY MORNING I WAKE UP AND OPEN PALM SLAM[...]
+ Field 'History' set to: EVERY MORNING I WAKE UP AND[...]
+ > look
+
+ Name: Ashley
+ Age: 31
+ History: EVERY MORNING I WAKE UP AND OPEN PALM SLAM A VHS INTO THE SLOT.
+ IT'S CHRONICLES OF RIDDICK AND RIGHT THEN AND THERE I START DOING
+ THE MOVES ALONGSIDE WITH THE MAIN CHARACTER, RIDDICK. I DO EVERY
+ MOVE AND I DO EVERY MOVE HARD.
+
+When the player types 'submit' (or your specified submit command), the menu
+quits and the form's data is passed to your specified function as a dictionary,
+like so:
+
+ formdata = {"Name":"Ashley", "Age":31, "History":"EVERY MORNING I[...]"}
+
+You can do whatever you like with this data in your function - forms can be used
+to set data on a character, to help builders create objects, or for players to
+craft items or perform other complicated actions with many variables involved.
+
+The data that your form will accept can also be specified in your form template -
+let's say, for example, that you won't accept ages under 18 or over 100. You can
+do this by specifying "min" and "max" values in your field's dictionary:
+
+ PROFILE_TEMPLATE = [
+ {"fieldname":"Name", "fieldtype":"text"},
+ {"fieldname":"Age", "fieldtype":"number", "min":18, "max":100},
+ {"fieldname":"History", "fieldtype":"text"}
+ ]
+
+Now if the player tries to enter a value out of range, the form will not acept the
+given value.
+
+ > age = 10
+ Field 'Age' reqiures a minimum value of 18.
+ > age = 900
+ Field 'Age' has a maximum value of 100.
+
+Setting 'min' and 'max' for a text field will instead act as a minimum or
+maximum character length for the player's input.
+
+There are lots of ways to present the form to the player - fields can have default
+values or show a custom message in place of a blank value, and player input can be
+verified by a custom function, allowing for a great deal of flexibility. There
+is also an option for 'bool' fields, which accept only a True / False input and
+can be customized to represent the choice to the player however you like (E.G.
+Yes/No, On/Off, Enabled/Disabled, etc.)
+
+This module contains a simple example form that demonstrates all of the included
+functionality - a command that allows a player to compose a message to another
+online character and have it send after a custom delay. You can test it by
+importing this module in your game's default_cmdsets.py module and adding
+CmdTestMenu to your default character's command set.
+
+FIELD TEMPLATE KEYS:
+Required:
+ fieldname (str): Name of the field, as presented to the player.
+ fieldtype (str): Type of value required: 'text', 'number', or 'bool'.
+
+Optional:
+ max (int): Maximum character length (if text) or value (if number).
+ min (int): Minimum charater length (if text) or value (if number).
+ truestr (str): String for a 'True' value in a bool field.
+ (E.G. 'On', 'Enabled', 'Yes')
+ falsestr (str): String for a 'False' value in a bool field.
+ (E.G. 'Off', 'Disabled', 'No')
+ default (str): Initial value (blank if not given).
+ blankmsg (str): Message to show in place of value when field is blank.
+ cantclear (bool): Field can't be cleared if True.
+ required (bool): If True, form cannot be submitted while field is blank.
+ verifyfunc (callable): Name of a callable used to verify input - takes
+ (caller, value) as arguments. If the function returns True,
+ the player's input is considered valid - if it returns False,
+ the input is rejected. Any other value returned will act as
+ the field's new value, replacing the player's input. This
+ allows for values that aren't strings or integers (such as
+ object dbrefs). For boolean fields, return '0' or '1' to set
+ the field to False or True.
+"""
+
+from evennia.utils import evmenu, evtable, delay, list_to_string, logger
+from evennia import Command
+from evennia.server.sessionhandler import SESSIONS
+
+
+class FieldEvMenu(evmenu.EvMenu):
+ """
+ Custom EvMenu type with its own node formatter - removes extraneous lines
+ """
+
+ def node_formatter(self, nodetext, optionstext):
+ """
+ Formats the entirety of the node.
+
+ Args:
+ nodetext (str): The node text as returned by `self.nodetext_formatter`.
+ optionstext (str): The options display as returned by `self.options_formatter`.
+ caller (Object, Account or None, optional): The caller of the node.
+
+ Returns:
+ node (str): The formatted node to display.
+
+ """
+ # Only return node text, no options or separators
+ return nodetext
+
+
+def init_fill_field(formtemplate, caller, formcallback, pretext="", posttext="",
+ submitcmd="submit", borderstyle="cells", formhelptext=None,
+ persistent=False, initial_formdata=None):
+ """
+ Initializes a menu presenting a player with a fillable form - once the form
+ is submitted, the data will be passed as a dictionary to your chosen
+ function.
+
+ Args:
+ formtemplate (list of dicts): The template for the form's fields.
+ caller (obj): Player who will be filling out the form.
+ formcallback (callable): Function to pass the completed form's data to.
+
+ Options:
+ pretext (str): Text to put before the form in the menu.
+ posttext (str): Text to put after the form in the menu.
+ submitcmd (str): Command used to submit the form.
+ borderstyle (str): Form's EvTable border style.
+ formhelptext (str): Help text for the form menu (or default is provided).
+ persistent (bool): Whether to make the EvMenu persistent across reboots.
+ initial_formdata (dict): Initial data for the form - a blank form with
+ defaults specified in the template will be generated otherwise.
+ In the case of a form used to edit properties on an object or a
+ similar application, you may want to generate the initial form
+ data dynamically before calling init_fill_field.
+ """
+
+ # Initialize form data from the template if none provided
+ formdata = form_template_to_dict(formtemplate)
+ if initial_formdata:
+ formdata = initial_formdata
+
+ # Provide default help text if none given
+ if formhelptext is None:
+ formhelptext = (
+ "Available commands:|/"
+ "|w = :|n Set given field to new value, replacing the old value|/"
+ "|wclear :|n Clear the value in the given field, making it blank|/"
+ "|wlook|n: Show the form's current values|/"
+ "|whelp|n: Display this help screen|/"
+ "|wquit|n: Quit the form menu without submitting|/"
+ "|w%s|n: Submit this form and quit the menu" % submitcmd)
+
+ # Pass kwargs to store data needed in the menu
+ kwargs = {
+ "formdata": formdata,
+ "formtemplate": formtemplate,
+ "formcallback": formcallback,
+ "pretext": pretext,
+ "posttext": posttext,
+ "submitcmd": submitcmd,
+ "borderstyle": borderstyle,
+ "formhelptext": formhelptext
+ }
+
+ # Initialize menu of selections
+ FieldEvMenu(caller, "evennia.contrib.fieldfill", startnode="menunode_fieldfill",
+ auto_look=False, persistent=persistent, **kwargs)
+
+
+def menunode_fieldfill(caller, raw_string, **kwargs):
+ """
+ This is an EvMenu node, which calls itself over and over in order to
+ allow a player to enter values into a fillable form. When the form is
+ submitted, the form data is passed to a callback as a dictionary.
+ """
+
+ # Retrieve menu info - taken from ndb if not persistent or db if persistent
+ if not caller.db._menutree:
+ formdata = caller.ndb._menutree.formdata
+ formtemplate = caller.ndb._menutree.formtemplate
+ formcallback = caller.ndb._menutree.formcallback
+ pretext = caller.ndb._menutree.pretext
+ posttext = caller.ndb._menutree.posttext
+ submitcmd = caller.ndb._menutree.submitcmd
+ borderstyle = caller.ndb._menutree.borderstyle
+ formhelptext = caller.ndb._menutree.formhelptext
+ else:
+ formdata = caller.db._menutree.formdata
+ formtemplate = caller.db._menutree.formtemplate
+ formcallback = caller.db._menutree.formcallback
+ pretext = caller.db._menutree.pretext
+ posttext = caller.db._menutree.posttext
+ submitcmd = caller.db._menutree.submitcmd
+ borderstyle = caller.db._menutree.borderstyle
+ formhelptext = caller.db._menutree.formhelptext
+
+ # Syntax error
+ syntax_err = "Syntax: = |/Or: clear , help, look, quit|/'%s' to submit form" % submitcmd
+
+ # Display current form data
+ text = (display_formdata(formtemplate, formdata, pretext=pretext,
+ posttext=posttext, borderstyle=borderstyle), formhelptext)
+ options = ({"key": "_default",
+ "goto": "menunode_fieldfill"})
+
+ if raw_string:
+ # Test for given 'submit' command
+ if raw_string.lower().strip() == submitcmd:
+ # Test to see if any blank fields are required
+ blank_and_required = []
+ for field in formtemplate:
+ if "required" in field.keys():
+ # If field is required but current form data for field is blank
+ if field["required"] is True and formdata[field["fieldname"]] is None:
+ # Add to blank and required fields
+ blank_and_required.append(field["fieldname"])
+ if len(blank_and_required) > 0:
+ # List the required fields left empty to the player
+ caller.msg("The following blank fields require a value: %s" % list_to_string(blank_and_required))
+ text = (None, formhelptext)
+ return text, options
+
+ # If everything checks out, pass form data to the callback and end the menu!
+ try:
+ formcallback(caller, formdata)
+ except Exception:
+ logger.log_trace("Error in fillable form callback.")
+ return None, None
+
+ # Test for 'look' command
+ if raw_string.lower().strip() == "look" or raw_string.lower().strip() == "l":
+ return text, options
+
+ # Test for 'clear' command
+ cleartest = raw_string.lower().strip().split(" ", 1)
+ if cleartest[0].lower() == "clear":
+ text = (None, formhelptext)
+ if len(cleartest) < 2:
+ caller.msg(syntax_err)
+ return text, options
+ matched_field = None
+
+ for key in formdata.keys():
+ if cleartest[1].lower() in key.lower():
+ matched_field = key
+
+ if not matched_field:
+ caller.msg("Field '%s' does not exist!" % cleartest[1])
+ text = (None, formhelptext)
+ return text, options
+
+ # Test to see if field can be cleared
+ for field in formtemplate:
+ if field["fieldname"] == matched_field and "cantclear" in field.keys():
+ if field["cantclear"] is True:
+ caller.msg("Field '%s' can't be cleared!" % matched_field)
+ text = (None, formhelptext)
+ return text, options
+
+ # Clear the field
+ formdata.update({matched_field: None})
+ caller.ndb._menutree.formdata = formdata
+ caller.msg("Field '%s' cleared." % matched_field)
+ return text, options
+
+ if "=" not in raw_string:
+ text = (None, formhelptext)
+ caller.msg(syntax_err)
+ return text, options
+
+ # Extract field name and new field value
+ entry = raw_string.split("=", 1)
+ fieldname = entry[0].strip()
+ newvalue = entry[1].strip()
+
+ # Syntax error if field name is too short or blank
+ if len(fieldname) < 1:
+ caller.msg(syntax_err)
+ text = (None, formhelptext)
+ return text, options
+
+ # Attempt to match field name to field in form data
+ matched_field = None
+ for key in formdata.keys():
+ if fieldname.lower() in key.lower():
+ matched_field = key
+
+ # No matched field
+ if matched_field is None:
+ caller.msg("Field '%s' does not exist!" % fieldname)
+ text = (None, formhelptext)
+ return text, options
+
+ # Set new field value if match
+ # Get data from template
+ fieldtype = None
+ max_value = None
+ min_value = None
+ truestr = "True"
+ falsestr = "False"
+ verifyfunc = None
+ for field in formtemplate:
+ if field["fieldname"] == matched_field:
+ fieldtype = field["fieldtype"]
+ if "max" in field.keys():
+ max_value = field["max"]
+ if "min" in field.keys():
+ min_value = field["min"]
+ if "truestr" in field.keys():
+ truestr = field["truestr"]
+ if "falsestr" in field.keys():
+ falsestr = field["falsestr"]
+ if "verifyfunc" in field.keys():
+ verifyfunc = field["verifyfunc"]
+
+ # Field type text verification
+ if fieldtype == "text":
+ # Test for max/min
+ if max_value is not None:
+ if len(newvalue) > max_value:
+ caller.msg("Field '%s' has a maximum length of %i characters." % (matched_field, max_value))
+ text = (None, formhelptext)
+ return text, options
+ if min_value is not None:
+ if len(newvalue) < min_value:
+ caller.msg("Field '%s' reqiures a minimum length of %i characters." % (matched_field, min_value))
+ text = (None, formhelptext)
+ return text, options
+
+ # Field type number verification
+ if fieldtype == "number":
+ try:
+ newvalue = int(newvalue)
+ except:
+ caller.msg("Field '%s' requires a number." % matched_field)
+ text = (None, formhelptext)
+ return text, options
+ # Test for max/min
+ if max_value is not None:
+ if newvalue > max_value:
+ caller.msg("Field '%s' has a maximum value of %i." % (matched_field, max_value))
+ text = (None, formhelptext)
+ return text, options
+ if min_value is not None:
+ if newvalue < min_value:
+ caller.msg("Field '%s' reqiures a minimum value of %i." % (matched_field, min_value))
+ text = (None, formhelptext)
+ return text, options
+
+ # Field type bool verification
+ if fieldtype == "bool":
+ if newvalue.lower() != truestr.lower() and newvalue.lower() != falsestr.lower():
+ caller.msg("Please enter '%s' or '%s' for field '%s'." % (truestr, falsestr, matched_field))
+ text = (None, formhelptext)
+ return text, options
+ if newvalue.lower() == truestr.lower():
+ newvalue = True
+ elif newvalue.lower() == falsestr.lower():
+ newvalue = False
+
+ # Call verify function if present
+ if verifyfunc:
+ if verifyfunc(caller, newvalue) is False:
+ # No error message is given - should be provided by verifyfunc
+ text = (None, formhelptext)
+ return text, options
+ elif verifyfunc(caller, newvalue) is not True:
+ newvalue = verifyfunc(caller, newvalue)
+ # Set '0' or '1' to True or False if the field type is bool
+ if fieldtype == "bool":
+ if newvalue == 0:
+ newvalue = False
+ elif newvalue == 1:
+ newvalue = True
+
+ # If everything checks out, update form!!
+ formdata.update({matched_field: newvalue})
+ caller.ndb._menutree.formdata = formdata
+
+ # Account for truestr and falsestr when updating a boolean form
+ announced_newvalue = newvalue
+ if newvalue is True:
+ announced_newvalue = truestr
+ elif newvalue is False:
+ announced_newvalue = falsestr
+
+ # Announce the new value to the player
+ caller.msg("Field '%s' set to: %s" % (matched_field, str(announced_newvalue)))
+ text = (None, formhelptext)
+
+ return text, options
+
+
+def form_template_to_dict(formtemplate):
+ """
+ Initializes a dictionary of form data from the given list-of-dictionaries
+ form template, as formatted above.
+
+ Args:
+ formtemplate (list of dicts): Tempate for the form to be initialized.
+
+ Returns:
+ formdata (dict): Dictionary of initalized form data.
+ """
+ formdata = {}
+
+ for field in formtemplate:
+ # Value is blank by default
+ fieldvalue = None
+ if "default" in field:
+ # Add in default value if present
+ fieldvalue = field["default"]
+ formdata.update({field["fieldname"]: fieldvalue})
+
+ return formdata
+
+
+def display_formdata(formtemplate, formdata,
+ pretext="", posttext="", borderstyle="cells"):
+ """
+ Displays a form's current data as a table. Used in the form menu.
+
+ Args:
+ formtemplate (list of dicts): Template for the form
+ formdata (dict): Form's current data
+
+ Options:
+ pretext (str): Text to put before the form table.
+ posttext (str): Text to put after the form table.
+ borderstyle (str): EvTable's border style.
+ """
+
+ formtable = evtable.EvTable(border=borderstyle, valign="t", maxwidth=80)
+ field_name_width = 5
+
+ for field in formtemplate:
+ new_fieldname = None
+ new_fieldvalue = None
+ # Get field name
+ new_fieldname = "|w" + field["fieldname"] + ":|n"
+ if len(field["fieldname"]) + 5 > field_name_width:
+ field_name_width = len(field["fieldname"]) + 5
+ # Get field value
+ if formdata[field["fieldname"]] is not None:
+ new_fieldvalue = str(formdata[field["fieldname"]])
+ # Use blank message if field is blank and once is present
+ if new_fieldvalue is None and "blankmsg" in field:
+ new_fieldvalue = "|x" + str(field["blankmsg"]) + "|n"
+ elif new_fieldvalue is None:
+ new_fieldvalue = " "
+ # Replace True and False values with truestr and falsestr from template
+ if formdata[field["fieldname"]] is True and "truestr" in field:
+ new_fieldvalue = field["truestr"]
+ elif formdata[field["fieldname"]] is False and "falsestr" in field:
+ new_fieldvalue = field["falsestr"]
+ # Add name and value to table
+ formtable.add_row(new_fieldname, new_fieldvalue)
+
+ formtable.reformat_column(0, align="r", width=field_name_width)
+
+ return pretext + "|/" + str(formtable) + "|/" + posttext
+
+
+# EXAMPLE FUNCTIONS / COMMAND STARTS HERE
+
+
+def verify_online_player(caller, value):
+ """
+ Example 'verify function' that matches player input to an online character
+ or else rejects their input as invalid.
+
+ Args:
+ caller (obj): Player entering the form data.
+ value (str): String player entered into the form, to be verified.
+
+ Returns:
+ matched_character (obj or False): dbref to a currently logged in
+ character object - reference to the object will be stored in
+ the form instead of a string. Returns False if no match is
+ made.
+ """
+ # Get a list of sessions
+ session_list = SESSIONS.get_sessions()
+ char_list = []
+ matched_character = None
+
+ # Get a list of online characters
+ for session in session_list:
+ if not session.logged_in:
+ # Skip over logged out characters
+ continue
+ # Append to our list of online characters otherwise
+ char_list.append(session.get_puppet())
+
+ # Match player input to a character name
+ for character in char_list:
+ if value.lower() == character.key.lower():
+ matched_character = character
+
+ # If input didn't match to a character
+ if not matched_character:
+ # Send the player an error message unique to this function
+ caller.msg("No character matching '%s' is online." % value)
+ # Returning False indicates the new value is not valid
+ return False
+
+ # Returning anything besides True or False will replace the player's input with the returned
+ # value. In this case, the value becomes a reference to the character object. You can store data
+ # besides strings and integers in the 'formdata' dictionary this way!
+ return matched_character
+
+# Form template for the example 'delayed message' form
+SAMPLE_FORM = [
+ {"fieldname": "Character",
+ "fieldtype": "text",
+ "max": 30,
+ "blankmsg": "(Name of an online player)",
+ "required": True,
+ "verifyfunc": verify_online_player
+ },
+ {"fieldname": "Delay",
+ "fieldtype": "number",
+ "min": 3,
+ "max": 30,
+ "default": 10,
+ "cantclear": True
+ },
+ {"fieldname": "Message",
+ "fieldtype": "text",
+ "min": 3,
+ "max": 200,
+ "blankmsg": "(Message up to 200 characters)"
+ },
+ {"fieldname": "Anonymous",
+ "fieldtype": "bool",
+ "truestr": "Yes",
+ "falsestr": "No",
+ "default": False
+ }
+ ]
+
+
+class CmdTestMenu(Command):
+ """
+ This test command will initialize a menu that presents you with a form.
+ You can fill out the fields of this form in any order, and then type in
+ 'send' to send a message to another online player, which will reach them
+ after a delay you specify.
+
+ Usage:
+ =
+ clear
+ help
+ look
+ quit
+ send
+ """
+
+ key = "testmenu"
+
+ def func(self):
+ """
+ This performs the actual command.
+ """
+ pretext = "|cSend a delayed message to another player ---------------------------------------|n"
+ posttext = ("|c--------------------------------------------------------------------------------|n|/"
+ "Syntax: type |c = |n to change the values of the form. Given|/"
+ "player must be currently logged in, delay is given in seconds. When you are|/"
+ "finished, type '|csend|n' to send the message.|/")
+
+ init_fill_field(SAMPLE_FORM, self.caller, init_delayed_message,
+ pretext=pretext, posttext=posttext,
+ submitcmd="send", borderstyle="none")
+
+
+def sendmessage(obj, text):
+ """
+ Callback to send a message to a player.
+
+ Args:
+ obj (obj): Player to message.
+ text (str): Message.
+ """
+ obj.msg(text)
+
+
+def init_delayed_message(caller, formdata):
+ """
+ Initializes a delayed message, using data from the example form.
+
+ Args:
+ caller (obj): Character submitting the message.
+ formdata (dict): Data from submitted form.
+ """
+ # Retrieve data from the filled out form.
+ # We stored the character to message as an object ref using a verifyfunc
+ # So we don't have to do any more searching or matching here!
+ player_to_message = formdata["Character"]
+ message_delay = formdata["Delay"]
+ sender = str(caller)
+ if formdata["Anonymous"] is True:
+ sender = "anonymous"
+ message = ("Message from %s: " % sender) + str(formdata["Message"])
+
+ caller.msg("Message sent to %s!" % player_to_message)
+ # Make a deferred call to 'sendmessage' above.
+ delay(message_delay, sendmessage, player_to_message, message)
+ return
diff --git a/evennia/contrib/health_bar.py b/evennia/contrib/health_bar.py
new file mode 100644
index 0000000000..c3d4af1c52
--- /dev/null
+++ b/evennia/contrib/health_bar.py
@@ -0,0 +1,103 @@
+"""
+Health Bar
+
+Contrib - Tim Ashley Jenkins 2017
+
+The function provided in this module lets you easily display visual
+bars or meters - "health bar" is merely the most obvious use for this,
+though these bars are highly customizable and can be used for any sort
+of appropriate data besides player health.
+
+Today's players may be more used to seeing statistics like health,
+stamina, magic, and etc. displayed as bars rather than bare numerical
+values, so using this module to present this data this way may make it
+more accessible. Keep in mind, however, that players may also be using
+a screen reader to connect to your game, which will not be able to
+represent the colors of the bar in any way. By default, the values
+represented are rendered as text inside the bar which can be read by
+screen readers.
+
+The health bar will account for current values above the maximum or
+below 0, rendering them as a completely full or empty bar with the
+values displayed within.
+"""
+
+def display_meter(cur_value, max_value,
+ length=30, fill_color=["R", "Y", "G"],
+ empty_color="B", text_color="w",
+ align="left", pre_text="", post_text="",
+ show_values=True):
+ """
+ Represents a current and maximum value given as a "bar" rendered with
+ ANSI or xterm256 background colors.
+
+ Args:
+ cur_value (int): Current value to display
+ max_value (int): Maximum value to display
+
+ Options:
+ length (int): Length of meter returned, in characters
+ fill_color (list): List of color codes for the full portion
+ of the bar, sans any sort of prefix - both ANSI and xterm256
+ colors are usable. When the bar is empty, colors toward the
+ start of the list will be chosen - when the bar is full, colors
+ towards the end are picked. You can adjust the 'weights' of
+ the changing colors by adding multiple entries of the same
+ color - for example, if you only want the bar to change when
+ it's close to empty, you could supply ['R','Y','G','G','G']
+ empty_color (str): Color code for the empty portion of the bar.
+ text_color (str): Color code for text inside the bar.
+ align (str): "left", "right", or "center" - alignment of text in the bar
+ pre_text (str): Text to put before the numbers in the bar
+ post_text (str): Text to put after the numbers in the bar
+ show_values (bool): If true, shows the numerical values represented by
+ the bar. It's highly recommended you keep this on, especially if
+ there's no info given in pre_text or post_text, as players on screen
+ readers will be unable to read the graphical aspect of the bar.
+ """
+ # Start by building the base string.
+ num_text = ""
+ if show_values:
+ num_text = "%i / %i" % (cur_value, max_value)
+ bar_base_str = pre_text + num_text + post_text
+ # Cut down the length of the base string if needed
+ if len(bar_base_str) > length:
+ bar_base_str = bar_base_str[:length]
+ # Pad and align the bar base string
+ if align == "right":
+ bar_base_str = bar_base_str.rjust(length, " ")
+ elif align == "center":
+ bar_base_str = bar_base_str.center(length, " ")
+ else:
+ bar_base_str = bar_base_str.ljust(length, " ")
+
+ if max_value < 1: # Prevent divide by zero
+ max_value = 1
+ if cur_value < 0: # Prevent weirdly formatted 'negative bars'
+ cur_value = 0
+ if cur_value > max_value: # Display overfull bars correctly
+ cur_value = max_value
+
+ # Now it's time to determine where to put the color codes.
+ percent_full = float(cur_value) / float(max_value)
+ split_index = round(float(length) * percent_full)
+ # Determine point at which to split the bar
+ split_index = int(split_index)
+
+ # Separate the bar string into full and empty portions
+ full_portion = bar_base_str[:split_index]
+ empty_portion = bar_base_str[split_index:]
+
+ # Pick which fill color to use based on how full the bar is
+ fillcolor_index = (float(len(fill_color)) * percent_full)
+ fillcolor_index = int(round(fillcolor_index)) - 1
+ fillcolor_code = "|[" + fill_color[fillcolor_index]
+
+ # Make color codes for empty bar portion and text_color
+ emptycolor_code = "|[" + empty_color
+ textcolor_code = "|" + text_color
+
+ # Assemble the final bar
+ final_bar = fillcolor_code + textcolor_code + full_portion + "|n" + emptycolor_code + textcolor_code + empty_portion + "|n"
+
+ return final_bar
diff --git a/evennia/contrib/ingame_python/typeclasses.py b/evennia/contrib/ingame_python/typeclasses.py
index 33729bef66..3f52b982bf 100644
--- a/evennia/contrib/ingame_python/typeclasses.py
+++ b/evennia/contrib/ingame_python/typeclasses.py
@@ -430,7 +430,7 @@ class EventCharacter(DefaultCharacter):
# Browse all the room's other characters
for obj in location.contents:
- if obj is self or not inherits_from(obj, "objects.objects.DefaultCharacter"):
+ if obj is self or not inherits_from(obj, "evennia.objects.objects.DefaultCharacter"):
continue
allow = obj.callbacks.call("can_say", self, obj, message, parameters=message)
@@ -491,7 +491,7 @@ class EventCharacter(DefaultCharacter):
parameters=message)
# Call the other characters' "say" event
- presents = [obj for obj in location.contents if obj is not self and inherits_from(obj, "objects.objects.DefaultCharacter")]
+ presents = [obj for obj in location.contents if obj is not self and inherits_from(obj, "evennia.objects.objects.DefaultCharacter")]
for present in presents:
present.callbacks.call("say", self, present, message, parameters=message)
diff --git a/evennia/contrib/security/README.md b/evennia/contrib/security/README.md
new file mode 100644
index 0000000000..87908dd3c8
--- /dev/null
+++ b/evennia/contrib/security/README.md
@@ -0,0 +1,5 @@
+# Security
+
+This directory contains security-related contribs
+
+- Auditing (Johnny 2018) - Allow for optional security logging of user input/output.
diff --git a/evennia/contrib/security/__init__.py b/evennia/contrib/security/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/evennia/contrib/security/auditing/README.md b/evennia/contrib/security/auditing/README.md
new file mode 100644
index 0000000000..ab669f30c9
--- /dev/null
+++ b/evennia/contrib/security/auditing/README.md
@@ -0,0 +1,72 @@
+# Input/Output Auditing
+
+Contrib - Johnny 2017
+
+This is a tap that optionally intercepts all data sent to/from clients and the
+server and passes it to a callback of your choosing.
+
+It is intended for quality assurance, post-incident investigations and debugging
+but obviously can be abused. All data is recorded in cleartext. Please
+be ethical, and if you are unwilling to properly deal with the implications of
+recording user passwords or private communications, please do not enable
+this module.
+
+Some checks have been implemented to protect the privacy of users.
+
+
+Files included in this module:
+
+ outputs.py - Example callback methods. This module ships with examples of
+ callbacks that send data as JSON to a file in your game/server/logs
+ dir or to your native Linux syslog daemon. You can of course write
+ your own to do other things like post them to Kafka topics.
+
+ server.py - Extends the Evennia ServerSession object to pipe data to the
+ callback upon receipt.
+
+ tests.py - Unit tests that check to make sure commands with sensitive
+ arguments are having their PII scrubbed.
+
+
+Installation/Configuration:
+
+Deployment is completed by configuring a few settings in server.conf. This line
+is required:
+
+ SERVER_SESSION_CLASS = 'evennia.contrib.security.auditing.server.AuditedServerSession'
+
+This tells Evennia to use this ServerSession instead of its own. Below are the
+other possible options along with the default value that will be used if unset.
+
+ # Where to send logs? Define the path to a module containing your callback
+ # function. It should take a single dict argument as input
+ AUDIT_CALLBACK = 'evennia.contrib.security.auditing.outputs.to_file'
+
+ # Log user input? Be ethical about this; it will log all private and
+ # public communications between players and/or admins (default: False).
+ AUDIT_IN = False
+
+ # Log server output? This will result in logging of ALL system
+ # messages and ALL broadcasts to connected players, so on a busy game any
+ # broadcast to all users will yield a single event for every connected user!
+ AUDIT_OUT = False
+
+ # The default output is a dict. Do you want to allow key:value pairs with
+ # null/blank values? If you're just writing to disk, disabling this saves
+ # some disk space, but whether you *want* sparse values or not is more of a
+ # consideration if you're shipping logs to a NoSQL/schemaless database.
+ # (default: False)
+ AUDIT_ALLOW_SPARSE = False
+
+ # If you write custom commands that handle sensitive data like passwords,
+ # you must write a regular expression to remove that before writing to log.
+ # AUDIT_MASKS is a list of dictionaries that define the names of commands
+ # and the regexes needed to scrub them.
+ # The system already has defaults to filter out sensitive login/creation
+ # commands in the default command set. Your list of AUDIT_MASKS will be appended
+ # to those defaults.
+ #
+ # In the regex, the sensitive data itself must be captured in a named group with a
+ # label of 'secret' (see the Python docs on the `re` module for more info). For
+ # example: `{'authentication': r"^@auth\s+(?P[\w]+)"}`
+ AUDIT_MASKS = []
diff --git a/evennia/contrib/security/auditing/__init__.py b/evennia/contrib/security/auditing/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/evennia/contrib/security/auditing/outputs.py b/evennia/contrib/security/auditing/outputs.py
new file mode 100644
index 0000000000..b3f9d72c23
--- /dev/null
+++ b/evennia/contrib/security/auditing/outputs.py
@@ -0,0 +1,60 @@
+"""
+Auditable Server Sessions - Example Outputs
+Example methods demonstrating output destinations for logs generated by
+audited server sessions.
+
+This is designed to be a single source of events for developers to customize
+and add any additional enhancements before events are written out-- i.e. if you
+want to keep a running list of what IPs a user logs in from on account/character
+objects, or if you want to perform geoip or ASN lookups on IPs before committing,
+or tag certain events with the results of a reputational lookup, this should be
+the easiest place to do it. Write a method and invoke it via
+`settings.AUDIT_CALLBACK` to have log data objects passed to it.
+
+Evennia contribution - Johnny 2017
+"""
+from evennia.utils.logger import log_file
+import json
+import syslog
+
+
+def to_file(data):
+ """
+ Writes dictionaries of data generated by an AuditedServerSession to files
+ in JSON format, bucketed by date.
+
+ Uses Evennia's native logger and writes to the default
+ log directory (~/yourgame/server/logs/ or settings.LOG_DIR)
+
+ Args:
+ data (dict): Parsed session transmission data.
+
+ """
+ # Bucket logs by day and remove objects before serialization
+ bucket = data.pop('objects')['time'].strftime('%Y-%m-%d')
+
+ # Write it
+ log_file(json.dumps(data), filename="audit_%s.log" % bucket)
+
+
+def to_syslog(data):
+ """
+ Writes dictionaries of data generated by an AuditedServerSession to syslog.
+
+ Takes advantage of your system's native logger and writes to wherever
+ you have it configured, which is independent of Evennia.
+ Linux systems tend to write to /var/log/syslog.
+
+ If you're running rsyslog, you can configure it to dump and/or forward logs
+ to disk and/or an external data warehouse (recommended-- if your server is
+ compromised or taken down, losing your logs along with it is no help!).
+
+ Args:
+ data (dict): Parsed session transmission data.
+
+ """
+ # Remove objects before serialization
+ data.pop('objects')
+
+ # Write it out
+ syslog.syslog(json.dumps(data))
diff --git a/evennia/contrib/security/auditing/server.py b/evennia/contrib/security/auditing/server.py
new file mode 100644
index 0000000000..07bf24ccc5
--- /dev/null
+++ b/evennia/contrib/security/auditing/server.py
@@ -0,0 +1,241 @@
+"""
+Auditable Server Sessions:
+Extension of the stock ServerSession that yields objects representing
+user inputs and system outputs.
+
+Evennia contribution - Johnny 2017
+"""
+import os
+import re
+import socket
+
+from django.utils import timezone
+from django.conf import settings as ev_settings
+from evennia.utils import utils, logger, mod_import, get_evennia_version
+from evennia.server.serversession import ServerSession
+
+# Attributes governing auditing of commands and where to send log objects
+AUDIT_CALLBACK = getattr(ev_settings, 'AUDIT_CALLBACK',
+ 'evennia.contrib.security.auditing.outputs.to_file')
+AUDIT_IN = getattr(ev_settings, 'AUDIT_IN', False)
+AUDIT_OUT = getattr(ev_settings, 'AUDIT_OUT', False)
+AUDIT_ALLOW_SPARSE = getattr(ev_settings, 'AUDIT_ALLOW_SPARSE', False)
+AUDIT_MASKS = [
+ {'connect': r"^[@\s]*[connect]{5,8}\s+(\".+?\"|[^\s]+)\s+(?P.+)"},
+ {'connect': r"^[@\s]*[connect]{5,8}\s+(?P[\w]+)"},
+ {'create': r"^[^@]?[create]{5,6}\s+(\w+|\".+?\")\s+(?P[\w]+)"},
+ {'create': r"^[^@]?[create]{5,6}\s+(?P[\w]+)"},
+ {'userpassword': r"^[@\s]*[userpassword]{11,14}\s+(\w+|\".+?\")\s+=*\s*(?P[\w]+)"},
+ {'userpassword': r"^.*new password set to '(?P[^']+)'\."},
+ {'userpassword': r"^.* has changed your password to '(?P[^']+)'\."},
+ {'password': r"^[@\s]*[password]{6,9}\s+(?P.*)"},
+] + getattr(ev_settings, 'AUDIT_MASKS', [])
+
+
+if AUDIT_CALLBACK:
+ try:
+ AUDIT_CALLBACK = getattr(
+ mod_import('.'.join(AUDIT_CALLBACK.split('.')[:-1])), AUDIT_CALLBACK.split('.')[-1])
+ logger.log_sec("Auditing module online.")
+ logger.log_sec("Audit record User input: {}, output: {}.\n"
+ "Audit sparse recording: {}, Log callback: {}".format(
+ AUDIT_IN, AUDIT_OUT, AUDIT_ALLOW_SPARSE, AUDIT_CALLBACK))
+ except Exception as e:
+ logger.log_err("Failed to activate Auditing module. %s" % e)
+
+
+class AuditedServerSession(ServerSession):
+ """
+ This particular implementation parses all server inputs and/or outputs and
+ passes a dict containing the parsed metadata to a callback method of your
+ creation. This is useful for recording player activity where necessary for
+ security auditing, usage analysis or post-incident forensic discovery.
+
+ *** WARNING ***
+ All strings are recorded and stored in plaintext. This includes those strings
+ which might contain sensitive data (create, connect, @password). These commands
+ have their arguments masked by default, but you must mask or mask any
+ custom commands of your own that handle sensitive information.
+
+ See README.md for installation/configuration instructions.
+ """
+ def audit(self, **kwargs):
+ """
+ Extracts messages and system data from a Session object upon message
+ send or receive.
+
+ Kwargs:
+ src (str): Source of data; 'client' or 'server'. Indicates direction.
+ text (str or list): Client sends messages to server in the form of
+ lists. Server sends messages to client as string.
+
+ Returns:
+ log (dict): Dictionary object containing parsed system and user data
+ related to this message.
+
+ """
+ # Get time at start of processing
+ time_obj = timezone.now()
+ time_str = str(time_obj)
+
+ session = self
+ src = kwargs.pop('src', '?')
+ bytecount = 0
+
+ # Do not log empty lines
+ if not kwargs:
+ return {}
+
+ # Get current session's IP address
+ client_ip = session.address
+
+ # Capture Account name and dbref together
+ account = session.get_account()
+ account_token = ''
+ if account:
+ account_token = '%s%s' % (account.key, account.dbref)
+
+ # Capture Character name and dbref together
+ char = session.get_puppet()
+ char_token = ''
+ if char:
+ char_token = '%s%s' % (char.key, char.dbref)
+
+ # Capture Room name and dbref together
+ room = None
+ room_token = ''
+ if char:
+ room = char.location
+ room_token = '%s%s' % (room.key, room.dbref)
+
+ # Try to compile an input/output string
+ def drill(obj, bucket):
+ if isinstance(obj, dict):
+ return bucket
+ elif utils.is_iter(obj):
+ for sub_obj in obj:
+ bucket.extend(drill(sub_obj, []))
+ else:
+ bucket.append(obj)
+ return bucket
+
+ text = kwargs.pop('text', '')
+ if utils.is_iter(text):
+ text = '|'.join(drill(text, []))
+
+ # Mask any PII in message, where possible
+ bytecount = len(text.encode('utf-8'))
+ text = self.mask(text)
+
+ # Compile the IP, Account, Character, Room, and the message.
+ log = {
+ 'time': time_str,
+ 'hostname': socket.getfqdn(),
+ 'application': '%s' % ev_settings.SERVERNAME,
+ 'version': get_evennia_version(),
+ 'pid': os.getpid(),
+ 'direction': 'SND' if src == 'server' else 'RCV',
+ 'protocol': self.protocol_key,
+ 'ip': client_ip,
+ 'session': 'session#%s' % self.sessid,
+ 'account': account_token,
+ 'character': char_token,
+ 'room': room_token,
+ 'text': text.strip(),
+ 'bytes': bytecount,
+ 'data': kwargs,
+ 'objects': {
+ 'time': time_obj,
+ 'session': self,
+ 'account': account,
+ 'character': char,
+ 'room': room,
+ }
+ }
+
+ # Remove any keys with blank values
+ if AUDIT_ALLOW_SPARSE is False:
+ log['data'] = {k: v for k, v in log['data'].iteritems() if v}
+ log['objects'] = {k: v for k, v in log['objects'].iteritems() if v}
+ log = {k: v for k, v in log.iteritems() if v}
+
+ return log
+
+ def mask(self, msg):
+ """
+ Masks potentially sensitive user information within messages before
+ writing to log. Recording cleartext password attempts is bad policy.
+
+ Args:
+ msg (str): Raw text string sent from client <-> server
+
+ Returns:
+ msg (str): Text string with sensitive information masked out.
+
+ """
+ # Check to see if the command is embedded within server output
+ _msg = msg
+ is_embedded = False
+ match = re.match(".*Command.*'(.+)'.*is not available.*", msg, flags=re.IGNORECASE)
+ if match:
+ msg = match.group(1).replace('\\', '')
+ submsg = msg
+ is_embedded = True
+
+ for mask in AUDIT_MASKS:
+ for command, regex in mask.iteritems():
+ try:
+ match = re.match(regex, msg, flags=re.IGNORECASE)
+ except Exception as e:
+ logger.log_err(regex)
+ logger.log_err(e)
+ continue
+
+ if match:
+ term = match.group('secret')
+ masked = re.sub(term, '*' * len(term.zfill(8)), msg)
+
+ if is_embedded:
+ msg = re.sub(submsg, '%s ' % (masked, command), _msg, flags=re.IGNORECASE)
+ else:
+ msg = masked
+
+ return msg
+
+ return _msg
+
+ def data_out(self, **kwargs):
+ """
+ Generic hook for sending data out through the protocol.
+
+ Kwargs:
+ kwargs (any): Other data to the protocol.
+
+ """
+ if AUDIT_CALLBACK and AUDIT_OUT:
+ try:
+ log = self.audit(src='server', **kwargs)
+ if log:
+ AUDIT_CALLBACK(log)
+ except Exception as e:
+ logger.log_err(e)
+
+ super(AuditedServerSession, self).data_out(**kwargs)
+
+ def data_in(self, **kwargs):
+ """
+ Hook for protocols to send incoming data to the engine.
+
+ Kwargs:
+ kwargs (any): Other data from the protocol.
+
+ """
+ if AUDIT_CALLBACK and AUDIT_IN:
+ try:
+ log = self.audit(src='client', **kwargs)
+ if log:
+ AUDIT_CALLBACK(log)
+ except Exception as e:
+ logger.log_err(e)
+
+ super(AuditedServerSession, self).data_in(**kwargs)
diff --git a/evennia/contrib/security/auditing/tests.py b/evennia/contrib/security/auditing/tests.py
new file mode 100644
index 0000000000..4a522925eb
--- /dev/null
+++ b/evennia/contrib/security/auditing/tests.py
@@ -0,0 +1,95 @@
+"""
+Module containing the test cases for the Audit system.
+"""
+
+from django.conf import settings
+from evennia.utils.test_resources import EvenniaTest
+import re
+
+# Configure session auditing settings
+settings.AUDIT_CALLBACK = "evennia.security.contrib.auditing.outputs.to_syslog"
+settings.AUDIT_IN = True
+settings.AUDIT_OUT = True
+settings.AUDIT_ALLOW_SPARSE = True
+
+# Configure settings to use custom session
+settings.SERVER_SESSION_CLASS = "evennia.contrib.security.auditing.server.AuditedServerSession"
+
+
+class AuditingTest(EvenniaTest):
+
+ def test_mask(self):
+ """
+ Make sure the 'mask' function is properly masking potentially sensitive
+ information from strings.
+ """
+ safe_cmds = (
+ '/say hello to my little friend',
+ '@ccreate channel = for channeling',
+ '@create/drop some stuff',
+ '@create rock',
+ '@create a pretty shirt : evennia.contrib.clothing.Clothing',
+ '@charcreate johnnyefhiwuhefwhef',
+ 'Command "@logout" is not available. Maybe you meant "@color" or "@cboot"?',
+ '/me says, "what is the password?"',
+ 'say the password is plugh',
+ # Unfortunately given the syntax, there is no way to discern the
+ # latter of these as sensitive
+ '@create pretty sunset'
+ '@create johnny password123',
+ '{"text": "Command \'do stuff\' is not available. Type \"help\" for help."}'
+ )
+
+ for cmd in safe_cmds:
+ self.assertEqual(self.session.mask(cmd), cmd)
+
+ unsafe_cmds = (
+ ("something - new password set to 'asdfghjk'.", "something - new password set to '********'."),
+ ("someone has changed your password to 'something'.", "someone has changed your password to '*********'."),
+ ('connect johnny password123', 'connect johnny ***********'),
+ ('concnct johnny password123', 'concnct johnny ***********'),
+ ('concnct johnnypassword123', 'concnct *****************'),
+ ('connect "johnny five" "password 123"', 'connect "johnny five" **************'),
+ ('connect johnny "password 123"', 'connect johnny **************'),
+ ('create johnny password123', 'create johnny ***********'),
+ ('@password password1234 = password2345', '@password ***************************'),
+ ('@password password1234 password2345', '@password *************************'),
+ ('@passwd password1234 = password2345', '@passwd ***************************'),
+ ('@userpassword johnny = password234', '@userpassword johnny = ***********'),
+ ('craete johnnypassword123', 'craete *****************'),
+ ("Command 'conncect teddy teddy' is not available. Maybe you meant \"@encode\"?", 'Command \'conncect ******** ********\' is not available. Maybe you meant "@encode"?'),
+ ("{'text': u'Command \\'conncect jsis dfiidf\\' is not available. Type \"help\" for help.'}", "{'text': u'Command \\'conncect jsis ********\\' is not available. Type \"help\" for help.'}")
+ )
+
+ for index, (unsafe, safe) in enumerate(unsafe_cmds):
+ self.assertEqual(re.sub(' ', '', self.session.mask(unsafe)).strip(), safe)
+
+ # Make sure scrubbing is not being abused to evade monitoring
+ secrets = [
+ 'say password password password; ive got a secret that i cant explain',
+ 'whisper johnny = password\n let\'s lynch the landlord',
+ 'say connect johnny password1234|the secret life of arabia',
+ "@password eval(\"__import__('os').system('clear')\", {'__builtins__':{}})"
+ ]
+ for secret in secrets:
+ self.assertEqual(self.session.mask(secret), secret)
+
+ def test_audit(self):
+ """
+ Make sure the 'audit' function is returning a dictionary based on values
+ parsed from the Session object.
+ """
+ log = self.session.audit(src='client', text=[['hello']])
+ obj = {k:v for k,v in log.iteritems() if k in ('direction', 'protocol', 'application', 'text')}
+ self.assertEqual(obj, {
+ 'direction': 'RCV',
+ 'protocol': 'telnet',
+ 'application': 'Evennia',
+ 'text': 'hello'
+ })
+
+ # Make sure OOB data is being recorded
+ log = self.session.audit(src='client', text="connect johnny password123", prompt="hp=20|st=10|ma=15", pane=2)
+ self.assertEqual(log['text'], 'connect johnny ***********')
+ self.assertEqual(log['data']['prompt'], 'hp=20|st=10|ma=15')
+ self.assertEqual(log['data']['pane'], 2)
diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py
index 2a337d2065..9695ce63df 100644
--- a/evennia/contrib/tests.py
+++ b/evennia/contrib/tests.py
@@ -671,6 +671,15 @@ class TestGenderSub(CommandTest):
txt = "Test |p gender"
self.assertEqual(gendersub._RE_GENDER_PRONOUN.sub(char._get_pronoun, txt), "Test their gender")
+# test health bar contrib
+
+from evennia.contrib import health_bar
+
+class TestHealthBar(EvenniaTest):
+ def test_healthbar(self):
+ expected_bar_str = "|[G|w |n|[B|w test24 / 200test |n"
+ self.assertTrue(health_bar.display_meter(24, 200, length=40, pre_text="test", post_text="test", align="center") == expected_bar_str)
+
# test mail contrib
@@ -789,7 +798,7 @@ from evennia.contrib import talking_npc
class TestTalkingNPC(CommandTest):
def test_talkingnpc(self):
npc = create_object(talking_npc.TalkingNPC, key="npctalker", location=self.room1)
- self.call(talking_npc.CmdTalk(), "", "(You walk up and talk to Char.)|")
+ self.call(talking_npc.CmdTalk(), "", "(You walk up and talk to Char.)")
npc.delete()
@@ -944,101 +953,637 @@ class TestTutorialWorldRooms(CommandTest):
# test turnbattle
-from evennia.contrib import turnbattle
+from evennia.contrib.turnbattle import tb_basic, tb_equip, tb_range, tb_items, tb_magic
from evennia.objects.objects import DefaultRoom
-class TestTurnBattleCmd(CommandTest):
+class TestTurnBattleBasicCmd(CommandTest):
- # Test combat commands
+ # Test basic combat commands
def test_turnbattlecmd(self):
- self.call(turnbattle.CmdFight(), "", "You can't start a fight if you've been defeated!")
- self.call(turnbattle.CmdAttack(), "", "You can only do that in combat. (see: help fight)")
- self.call(turnbattle.CmdPass(), "", "You can only do that in combat. (see: help fight)")
- self.call(turnbattle.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
- self.call(turnbattle.CmdRest(), "", "Char rests to recover HP.")
+ self.call(tb_basic.CmdFight(), "", "You can't start a fight if you've been defeated!")
+ self.call(tb_basic.CmdAttack(), "", "You can only do that in combat. (see: help fight)")
+ self.call(tb_basic.CmdPass(), "", "You can only do that in combat. (see: help fight)")
+ self.call(tb_basic.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
+ self.call(tb_basic.CmdRest(), "", "Char rests to recover HP.")
-class TestTurnBattleFunc(EvenniaTest):
+class TestTurnBattleEquipCmd(CommandTest):
+
+ def setUp(self):
+ super(TestTurnBattleEquipCmd, self).setUp()
+ self.testweapon = create_object(tb_equip.TBEWeapon, key="test weapon")
+ self.testarmor = create_object(tb_equip.TBEArmor, key="test armor")
+ self.testweapon.move_to(self.char1)
+ self.testarmor.move_to(self.char1)
+
+ # Test equipment commands
+ def test_turnbattleequipcmd(self):
+ # Start with equip module specific commands.
+ self.call(tb_equip.CmdWield(), "weapon", "Char wields test weapon.")
+ self.call(tb_equip.CmdUnwield(), "", "Char lowers test weapon.")
+ self.call(tb_equip.CmdDon(), "armor", "Char dons test armor.")
+ self.call(tb_equip.CmdDoff(), "", "Char removes test armor.")
+ # Also test the commands that are the same in the basic module
+ self.call(tb_equip.CmdFight(), "", "You can't start a fight if you've been defeated!")
+ self.call(tb_equip.CmdAttack(), "", "You can only do that in combat. (see: help fight)")
+ self.call(tb_equip.CmdPass(), "", "You can only do that in combat. (see: help fight)")
+ self.call(tb_equip.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
+ self.call(tb_equip.CmdRest(), "", "Char rests to recover HP.")
+
+
+class TestTurnBattleRangeCmd(CommandTest):
+ # Test range commands
+ def test_turnbattlerangecmd(self):
+ # Start with range module specific commands.
+ self.call(tb_range.CmdShoot(), "", "You can only do that in combat. (see: help fight)")
+ self.call(tb_range.CmdApproach(), "", "You can only do that in combat. (see: help fight)")
+ self.call(tb_range.CmdWithdraw(), "", "You can only do that in combat. (see: help fight)")
+ self.call(tb_range.CmdStatus(), "", "HP Remaining: 100 / 100")
+ # Also test the commands that are the same in the basic module
+ self.call(tb_range.CmdFight(), "", "There's nobody here to fight!")
+ self.call(tb_range.CmdAttack(), "", "You can only do that in combat. (see: help fight)")
+ self.call(tb_range.CmdPass(), "", "You can only do that in combat. (see: help fight)")
+ self.call(tb_range.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
+ self.call(tb_range.CmdRest(), "", "Char rests to recover HP.")
+
+
+class TestTurnBattleItemsCmd(CommandTest):
+
+ def setUp(self):
+ super(TestTurnBattleItemsCmd, self).setUp()
+ self.testitem = create_object(key="test item")
+ self.testitem.move_to(self.char1)
+
+ # Test item commands
+ def test_turnbattleitemcmd(self):
+ self.call(tb_items.CmdUse(), "item", "'Test item' is not a usable item.")
+ # Also test the commands that are the same in the basic module
+ self.call(tb_items.CmdFight(), "", "You can't start a fight if you've been defeated!")
+ self.call(tb_items.CmdAttack(), "", "You can only do that in combat. (see: help fight)")
+ self.call(tb_items.CmdPass(), "", "You can only do that in combat. (see: help fight)")
+ self.call(tb_items.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
+ self.call(tb_items.CmdRest(), "", "Char rests to recover HP.")
+
+
+class TestTurnBattleMagicCmd(CommandTest):
+
+ # Test magic commands
+ def test_turnbattlemagiccmd(self):
+ self.call(tb_magic.CmdStatus(), "", "You have 100 / 100 HP and 20 / 20 MP.")
+ self.call(tb_magic.CmdLearnSpell(), "test spell", "There is no spell with that name.")
+ self.call(tb_magic.CmdCast(), "", "Usage: cast = , ")
+ # Also test the commands that are the same in the basic module
+ self.call(tb_magic.CmdFight(), "", "There's nobody here to fight!")
+ self.call(tb_magic.CmdAttack(), "", "You can only do that in combat. (see: help fight)")
+ self.call(tb_magic.CmdPass(), "", "You can only do that in combat. (see: help fight)")
+ self.call(tb_magic.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
+ self.call(tb_magic.CmdRest(), "", "Char rests to recover HP and MP.")
+
+
+class TestTurnBattleBasicFunc(EvenniaTest):
+
+ def setUp(self):
+ super(TestTurnBattleBasicFunc, self).setUp()
+ self.testroom = create_object(DefaultRoom, key="Test Room")
+ self.attacker = create_object(tb_basic.TBBasicCharacter, key="Attacker", location=self.testroom)
+ self.defender = create_object(tb_basic.TBBasicCharacter, key="Defender", location=self.testroom)
+ self.joiner = create_object(tb_basic.TBBasicCharacter, key="Joiner", location=None)
+
+ def tearDown(self):
+ super(TestTurnBattleBasicFunc, self).tearDown()
+ self.attacker.delete()
+ self.defender.delete()
+ self.joiner.delete()
+ self.testroom.delete()
+ self.turnhandler.stop()
# Test combat functions
- def test_turnbattlefunc(self):
- attacker = create_object(turnbattle.BattleCharacter, key="Attacker")
- defender = create_object(turnbattle.BattleCharacter, key="Defender")
- testroom = create_object(DefaultRoom, key="Test Room")
- attacker.location = testroom
- defender.loaction = testroom
+ def test_tbbasicfunc(self):
# Initiative roll
- initiative = turnbattle.roll_init(attacker)
+ initiative = tb_basic.roll_init(self.attacker)
self.assertTrue(initiative >= 0 and initiative <= 1000)
# Attack roll
- attack_roll = turnbattle.get_attack(attacker, defender)
+ attack_roll = tb_basic.get_attack(self.attacker, self.defender)
self.assertTrue(attack_roll >= 0 and attack_roll <= 100)
# Defense roll
- defense_roll = turnbattle.get_defense(attacker, defender)
+ defense_roll = tb_basic.get_defense(self.attacker, self.defender)
self.assertTrue(defense_roll == 50)
# Damage roll
- damage_roll = turnbattle.get_damage(attacker, defender)
+ damage_roll = tb_basic.get_damage(self.attacker, self.defender)
self.assertTrue(damage_roll >= 15 and damage_roll <= 25)
# Apply damage
- defender.db.hp = 10
- turnbattle.apply_damage(defender, 3)
- self.assertTrue(defender.db.hp == 7)
+ self.defender.db.hp = 10
+ tb_basic.apply_damage(self.defender, 3)
+ self.assertTrue(self.defender.db.hp == 7)
# Resolve attack
- defender.db.hp = 40
- turnbattle.resolve_attack(attacker, defender, attack_value=20, defense_value=10)
- self.assertTrue(defender.db.hp < 40)
+ self.defender.db.hp = 40
+ tb_basic.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10)
+ self.assertTrue(self.defender.db.hp < 40)
# Combat cleanup
- attacker.db.Combat_attribute = True
- turnbattle.combat_cleanup(attacker)
- self.assertFalse(attacker.db.combat_attribute)
+ self.attacker.db.Combat_attribute = True
+ tb_basic.combat_cleanup(self.attacker)
+ self.assertFalse(self.attacker.db.combat_attribute)
# Is in combat
- self.assertFalse(turnbattle.is_in_combat(attacker))
+ self.assertFalse(tb_basic.is_in_combat(self.attacker))
# Set up turn handler script for further tests
- attacker.location.scripts.add(turnbattle.TurnHandler)
- turnhandler = attacker.db.combat_TurnHandler
- self.assertTrue(attacker.db.combat_TurnHandler)
+ self.attacker.location.scripts.add(tb_basic.TBBasicTurnHandler)
+ self.turnhandler = self.attacker.db.combat_TurnHandler
+ self.assertTrue(self.attacker.db.combat_TurnHandler)
+ # Set the turn handler's interval very high to keep it from repeating during tests.
+ self.turnhandler.interval = 10000
# Force turn order
- turnhandler.db.fighters = [attacker, defender]
- turnhandler.db.turn = 0
+ self.turnhandler.db.fighters = [self.attacker, self.defender]
+ self.turnhandler.db.turn = 0
# Test is turn
- self.assertTrue(turnbattle.is_turn(attacker))
+ self.assertTrue(tb_basic.is_turn(self.attacker))
# Spend actions
- attacker.db.Combat_ActionsLeft = 1
- turnbattle.spend_action(attacker, 1, action_name="Test")
- self.assertTrue(attacker.db.Combat_ActionsLeft == 0)
- self.assertTrue(attacker.db.Combat_LastAction == "Test")
+ self.attacker.db.Combat_ActionsLeft = 1
+ tb_basic.spend_action(self.attacker, 1, action_name="Test")
+ self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
+ self.assertTrue(self.attacker.db.Combat_LastAction == "Test")
# Initialize for combat
- attacker.db.Combat_ActionsLeft = 983
- turnhandler.initialize_for_combat(attacker)
- self.assertTrue(attacker.db.Combat_ActionsLeft == 0)
- self.assertTrue(attacker.db.Combat_LastAction == "null")
+ self.attacker.db.Combat_ActionsLeft = 983
+ self.turnhandler.initialize_for_combat(self.attacker)
+ self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
+ self.assertTrue(self.attacker.db.Combat_LastAction == "null")
# Start turn
- defender.db.Combat_ActionsLeft = 0
- turnhandler.start_turn(defender)
- self.assertTrue(defender.db.Combat_ActionsLeft == 1)
+ self.defender.db.Combat_ActionsLeft = 0
+ self.turnhandler.start_turn(self.defender)
+ self.assertTrue(self.defender.db.Combat_ActionsLeft == 1)
# Next turn
- turnhandler.db.fighters = [attacker, defender]
- turnhandler.db.turn = 0
- turnhandler.next_turn()
- self.assertTrue(turnhandler.db.turn == 1)
+ self.turnhandler.db.fighters = [self.attacker, self.defender]
+ self.turnhandler.db.turn = 0
+ self.turnhandler.next_turn()
+ self.assertTrue(self.turnhandler.db.turn == 1)
# Turn end check
- turnhandler.db.fighters = [attacker, defender]
- turnhandler.db.turn = 0
- attacker.db.Combat_ActionsLeft = 0
- turnhandler.turn_end_check(attacker)
- self.assertTrue(turnhandler.db.turn == 1)
+ self.turnhandler.db.fighters = [self.attacker, self.defender]
+ self.turnhandler.db.turn = 0
+ self.attacker.db.Combat_ActionsLeft = 0
+ self.turnhandler.turn_end_check(self.attacker)
+ self.assertTrue(self.turnhandler.db.turn == 1)
# Join fight
- joiner = create_object(turnbattle.BattleCharacter, key="Joiner")
- turnhandler.db.fighters = [attacker, defender]
- turnhandler.db.turn = 0
- turnhandler.join_fight(joiner)
- self.assertTrue(turnhandler.db.turn == 1)
- self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender])
- # Remove the script at the end
- turnhandler.stop()
+ self.joiner.location = self.testroom
+ self.turnhandler.db.fighters = [self.attacker, self.defender]
+ self.turnhandler.db.turn = 0
+ self.turnhandler.join_fight(self.joiner)
+ self.assertTrue(self.turnhandler.db.turn == 1)
+ self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender])
+class TestTurnBattleEquipFunc(EvenniaTest):
+
+ def setUp(self):
+ super(TestTurnBattleEquipFunc, self).setUp()
+ self.testroom = create_object(DefaultRoom, key="Test Room")
+ self.attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker", location=self.testroom)
+ self.defender = create_object(tb_equip.TBEquipCharacter, key="Defender", location=self.testroom)
+ self.joiner = create_object(tb_equip.TBEquipCharacter, key="Joiner", location=None)
+
+ def tearDown(self):
+ super(TestTurnBattleEquipFunc, self).tearDown()
+ self.attacker.delete()
+ self.defender.delete()
+ self.joiner.delete()
+ self.testroom.delete()
+ self.turnhandler.stop()
+
+ # Test the combat functions in tb_equip too. They work mostly the same.
+ def test_tbequipfunc(self):
+ # Initiative roll
+ initiative = tb_equip.roll_init(self.attacker)
+ self.assertTrue(initiative >= 0 and initiative <= 1000)
+ # Attack roll
+ attack_roll = tb_equip.get_attack(self.attacker, self.defender)
+ self.assertTrue(attack_roll >= -50 and attack_roll <= 150)
+ # Defense roll
+ defense_roll = tb_equip.get_defense(self.attacker, self.defender)
+ self.assertTrue(defense_roll == 50)
+ # Damage roll
+ damage_roll = tb_equip.get_damage(self.attacker, self.defender)
+ self.assertTrue(damage_roll >= 0 and damage_roll <= 50)
+ # Apply damage
+ self.defender.db.hp = 10
+ tb_equip.apply_damage(self.defender, 3)
+ self.assertTrue(self.defender.db.hp == 7)
+ # Resolve attack
+ self.defender.db.hp = 40
+ tb_equip.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10)
+ self.assertTrue(self.defender.db.hp < 40)
+ # Combat cleanup
+ self.attacker.db.Combat_attribute = True
+ tb_equip.combat_cleanup(self.attacker)
+ self.assertFalse(self.attacker.db.combat_attribute)
+ # Is in combat
+ self.assertFalse(tb_equip.is_in_combat(self.attacker))
+ # Set up turn handler script for further tests
+ self.attacker.location.scripts.add(tb_equip.TBEquipTurnHandler)
+ self.turnhandler = self.attacker.db.combat_TurnHandler
+ self.assertTrue(self.attacker.db.combat_TurnHandler)
+ # Set the turn handler's interval very high to keep it from repeating during tests.
+ self.turnhandler.interval = 10000
+ # Force turn order
+ self.turnhandler.db.fighters = [self.attacker, self.defender]
+ self.turnhandler.db.turn = 0
+ # Test is turn
+ self.assertTrue(tb_equip.is_turn(self.attacker))
+ # Spend actions
+ self.attacker.db.Combat_ActionsLeft = 1
+ tb_equip.spend_action(self.attacker, 1, action_name="Test")
+ self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
+ self.assertTrue(self.attacker.db.Combat_LastAction == "Test")
+ # Initialize for combat
+ self.attacker.db.Combat_ActionsLeft = 983
+ self.turnhandler.initialize_for_combat(self.attacker)
+ self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
+ self.assertTrue(self.attacker.db.Combat_LastAction == "null")
+ # Start turn
+ self.defender.db.Combat_ActionsLeft = 0
+ self.turnhandler.start_turn(self.defender)
+ self.assertTrue(self.defender.db.Combat_ActionsLeft == 1)
+ # Next turn
+ self.turnhandler.db.fighters = [self.attacker, self.defender]
+ self.turnhandler.db.turn = 0
+ self.turnhandler.next_turn()
+ self.assertTrue(self.turnhandler.db.turn == 1)
+ # Turn end check
+ self.turnhandler.db.fighters = [self.attacker, self.defender]
+ self.turnhandler.db.turn = 0
+ self.attacker.db.Combat_ActionsLeft = 0
+ self.turnhandler.turn_end_check(self.attacker)
+ self.assertTrue(self.turnhandler.db.turn == 1)
+ # Join fight
+ self.turnhandler.db.fighters = [self.attacker, self.defender]
+ self.turnhandler.db.turn = 0
+ self.turnhandler.join_fight(self.joiner)
+ self.assertTrue(self.turnhandler.db.turn == 1)
+ self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender])
+
+
+class TestTurnBattleRangeFunc(EvenniaTest):
+
+ def setUp(self):
+ super(TestTurnBattleRangeFunc, self).setUp()
+ self.testroom = create_object(DefaultRoom, key="Test Room")
+ self.attacker = create_object(tb_range.TBRangeCharacter, key="Attacker", location=self.testroom)
+ self.defender = create_object(tb_range.TBRangeCharacter, key="Defender", location=self.testroom)
+ self.joiner = create_object(tb_range.TBRangeCharacter, key="Joiner", location=self.testroom)
+
+ def tearDown(self):
+ super(TestTurnBattleRangeFunc, self).tearDown()
+ self.attacker.delete()
+ self.defender.delete()
+ self.joiner.delete()
+ self.testroom.delete()
+ self.turnhandler.stop()
+
+ # Test combat functions in tb_range too.
+ def test_tbrangefunc(self):
+ # Initiative roll
+ initiative = tb_range.roll_init(self.attacker)
+ self.assertTrue(initiative >= 0 and initiative <= 1000)
+ # Attack roll
+ attack_roll = tb_range.get_attack(self.attacker, self.defender, "test")
+ self.assertTrue(attack_roll >= 0 and attack_roll <= 100)
+ # Defense roll
+ defense_roll = tb_range.get_defense(self.attacker, self.defender, "test")
+ self.assertTrue(defense_roll == 50)
+ # Damage roll
+ damage_roll = tb_range.get_damage(self.attacker, self.defender)
+ self.assertTrue(damage_roll >= 15 and damage_roll <= 25)
+ # Apply damage
+ self.defender.db.hp = 10
+ tb_range.apply_damage(self.defender, 3)
+ self.assertTrue(self.defender.db.hp == 7)
+ # Resolve attack
+ self.defender.db.hp = 40
+ tb_range.resolve_attack(self.attacker, self.defender, "test", attack_value=20, defense_value=10)
+ self.assertTrue(self.defender.db.hp < 40)
+ # Combat cleanup
+ self.attacker.db.Combat_attribute = True
+ tb_range.combat_cleanup(self.attacker)
+ self.assertFalse(self.attacker.db.combat_attribute)
+ # Is in combat
+ self.assertFalse(tb_range.is_in_combat(self.attacker))
+ # Set up turn handler script for further tests
+ self.attacker.location.scripts.add(tb_range.TBRangeTurnHandler)
+ self.turnhandler = self.attacker.db.combat_TurnHandler
+ self.assertTrue(self.attacker.db.combat_TurnHandler)
+ # Set the turn handler's interval very high to keep it from repeating during tests.
+ self.turnhandler.interval = 10000
+ # Force turn order
+ self.turnhandler.db.fighters = [self.attacker, self.defender]
+ self.turnhandler.db.turn = 0
+ # Test is turn
+ self.assertTrue(tb_range.is_turn(self.attacker))
+ # Spend actions
+ self.attacker.db.Combat_ActionsLeft = 1
+ tb_range.spend_action(self.attacker, 1, action_name="Test")
+ self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
+ self.assertTrue(self.attacker.db.Combat_LastAction == "Test")
+ # Initialize for combat
+ self.attacker.db.Combat_ActionsLeft = 983
+ self.turnhandler.initialize_for_combat(self.attacker)
+ self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
+ self.assertTrue(self.attacker.db.Combat_LastAction == "null")
+ # Set up ranges again, since initialize_for_combat clears them
+ self.attacker.db.combat_range = {}
+ self.attacker.db.combat_range[self.attacker] = 0
+ self.attacker.db.combat_range[self.defender] = 1
+ self.defender.db.combat_range = {}
+ self.defender.db.combat_range[self.defender] = 0
+ self.defender.db.combat_range[self.attacker] = 1
+ # Start turn
+ self.defender.db.Combat_ActionsLeft = 0
+ self.turnhandler.start_turn(self.defender)
+ self.assertTrue(self.defender.db.Combat_ActionsLeft == 2)
+ # Next turn
+ self.turnhandler.db.fighters = [self.attacker, self.defender]
+ self.turnhandler.db.turn = 0
+ self.turnhandler.next_turn()
+ self.assertTrue(self.turnhandler.db.turn == 1)
+ # Turn end check
+ self.turnhandler.db.fighters = [self.attacker, self.defender]
+ self.turnhandler.db.turn = 0
+ self.attacker.db.Combat_ActionsLeft = 0
+ self.turnhandler.turn_end_check(self.attacker)
+ self.assertTrue(self.turnhandler.db.turn == 1)
+ # Join fight
+ self.turnhandler.db.fighters = [self.attacker, self.defender]
+ self.turnhandler.db.turn = 0
+ self.turnhandler.join_fight(self.joiner)
+ self.assertTrue(self.turnhandler.db.turn == 1)
+ self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender])
+ # Now, test for approach/withdraw functions
+ self.assertTrue(tb_range.get_range(self.attacker, self.defender) == 1)
+ # Approach
+ tb_range.approach(self.attacker, self.defender)
+ self.assertTrue(tb_range.get_range(self.attacker, self.defender) == 0)
+ # Withdraw
+ tb_range.withdraw(self.attacker, self.defender)
+ self.assertTrue(tb_range.get_range(self.attacker, self.defender) == 1)
+
+
+class TestTurnBattleItemsFunc(EvenniaTest):
+
+ @patch("evennia.contrib.turnbattle.tb_items.tickerhandler", new=MagicMock())
+ def setUp(self):
+ super(TestTurnBattleItemsFunc, self).setUp()
+ self.testroom = create_object(DefaultRoom, key="Test Room")
+ self.attacker = create_object(tb_items.TBItemsCharacter, key="Attacker", location=self.testroom)
+ self.defender = create_object(tb_items.TBItemsCharacter, key="Defender", location=self.testroom)
+ self.joiner = create_object(tb_items.TBItemsCharacter, key="Joiner", location=self.testroom)
+ self.user = create_object(tb_items.TBItemsCharacter, key="User", location=self.testroom)
+ self.test_healpotion = create_object(key="healing potion")
+ self.test_healpotion.db.item_func = "heal"
+ self.test_healpotion.db.item_uses = 3
+
+ def tearDown(self):
+ super(TestTurnBattleItemsFunc, self).tearDown()
+ self.attacker.delete()
+ self.defender.delete()
+ self.joiner.delete()
+ self.user.delete()
+ self.testroom.delete()
+ self.turnhandler.stop()
+
+ # Test functions in tb_items.
+ def test_tbitemsfunc(self):
+ # Initiative roll
+ initiative = tb_items.roll_init(self.attacker)
+ self.assertTrue(initiative >= 0 and initiative <= 1000)
+ # Attack roll
+ attack_roll = tb_items.get_attack(self.attacker, self.defender)
+ self.assertTrue(attack_roll >= 0 and attack_roll <= 100)
+ # Defense roll
+ defense_roll = tb_items.get_defense(self.attacker, self.defender)
+ self.assertTrue(defense_roll == 50)
+ # Damage roll
+ damage_roll = tb_items.get_damage(self.attacker, self.defender)
+ self.assertTrue(damage_roll >= 15 and damage_roll <= 25)
+ # Apply damage
+ self.defender.db.hp = 10
+ tb_items.apply_damage(self.defender, 3)
+ self.assertTrue(self.defender.db.hp == 7)
+ # Resolve attack
+ self.defender.db.hp = 40
+ tb_items.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10)
+ self.assertTrue(self.defender.db.hp < 40)
+ # Combat cleanup
+ self.attacker.db.Combat_attribute = True
+ tb_items.combat_cleanup(self.attacker)
+ self.assertFalse(self.attacker.db.combat_attribute)
+ # Is in combat
+ self.assertFalse(tb_items.is_in_combat(self.attacker))
+ # Set up turn handler script for further tests
+ self.attacker.location.scripts.add(tb_items.TBItemsTurnHandler)
+ self.turnhandler = self.attacker.db.combat_TurnHandler
+ self.assertTrue(self.attacker.db.combat_TurnHandler)
+ # Set the turn handler's interval very high to keep it from repeating during tests.
+ self.turnhandler.interval = 10000
+ # Force turn order
+ self.turnhandler.db.fighters = [self.attacker, self.defender]
+ self.turnhandler.db.turn = 0
+ # Test is turn
+ self.assertTrue(tb_items.is_turn(self.attacker))
+ # Spend actions
+ self.attacker.db.Combat_ActionsLeft = 1
+ tb_items.spend_action(self.attacker, 1, action_name="Test")
+ self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
+ self.assertTrue(self.attacker.db.Combat_LastAction == "Test")
+ # Initialize for combat
+ self.attacker.db.Combat_ActionsLeft = 983
+ self.turnhandler.initialize_for_combat(self.attacker)
+ self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
+ self.assertTrue(self.attacker.db.Combat_LastAction == "null")
+ # Start turn
+ self.defender.db.Combat_ActionsLeft = 0
+ self.turnhandler.start_turn(self.defender)
+ self.assertTrue(self.defender.db.Combat_ActionsLeft == 1)
+ # Next turn
+ self.turnhandler.db.fighters = [self.attacker, self.defender]
+ self.turnhandler.db.turn = 0
+ self.turnhandler.next_turn()
+ self.assertTrue(self.turnhandler.db.turn == 1)
+ # Turn end check
+ self.turnhandler.db.fighters = [self.attacker, self.defender]
+ self.turnhandler.db.turn = 0
+ self.attacker.db.Combat_ActionsLeft = 0
+ self.turnhandler.turn_end_check(self.attacker)
+ self.assertTrue(self.turnhandler.db.turn == 1)
+ # Join fight
+ self.turnhandler.db.fighters = [self.attacker, self.defender]
+ self.turnhandler.db.turn = 0
+ self.turnhandler.join_fight(self.joiner)
+ self.assertTrue(self.turnhandler.db.turn == 1)
+ self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender])
+ # Now time to test item stuff.
+ # Spend item use
+ tb_items.spend_item_use(self.test_healpotion, self.user)
+ self.assertTrue(self.test_healpotion.db.item_uses == 2)
+ # Use item
+ self.user.db.hp = 2
+ tb_items.use_item(self.user, self.test_healpotion, self.user)
+ self.assertTrue(self.user.db.hp > 2)
+ # Add contition
+ tb_items.add_condition(self.user, self.user, "Test", 5)
+ self.assertTrue(self.user.db.conditions == {"Test":[5, self.user]})
+ # Condition tickdown
+ tb_items.condition_tickdown(self.user, self.user)
+ self.assertTrue(self.user.db.conditions == {"Test":[4, self.user]})
+ # Test item functions now!
+ # Item heal
+ self.user.db.hp = 2
+ tb_items.itemfunc_heal(self.test_healpotion, self.user, self.user)
+ # Item add condition
+ self.user.db.conditions = {}
+ tb_items.itemfunc_add_condition(self.test_healpotion, self.user, self.user)
+ self.assertTrue(self.user.db.conditions == {"Regeneration":[5, self.user]})
+ # Item cure condition
+ self.user.db.conditions = {"Poisoned":[5, self.user]}
+ tb_items.itemfunc_cure_condition(self.test_healpotion, self.user, self.user)
+ self.assertTrue(self.user.db.conditions == {})
+
+
+class TestTurnBattleMagicFunc(EvenniaTest):
+
+ def setUp(self):
+ super(TestTurnBattleMagicFunc, self).setUp()
+ self.testroom = create_object(DefaultRoom, key="Test Room")
+ self.attacker = create_object(tb_magic.TBMagicCharacter, key="Attacker", location=self.testroom)
+ self.defender = create_object(tb_magic.TBMagicCharacter, key="Defender", location=self.testroom)
+ self.joiner = create_object(tb_magic.TBMagicCharacter, key="Joiner", location=self.testroom)
+
+ def tearDown(self):
+ super(TestTurnBattleMagicFunc, self).tearDown()
+ self.attacker.delete()
+ self.defender.delete()
+ self.joiner.delete()
+ self.testroom.delete()
+ self.turnhandler.stop()
+
+ # Test combat functions in tb_magic.
+ def test_tbbasicfunc(self):
+ # Initiative roll
+ initiative = tb_magic.roll_init(self.attacker)
+ self.assertTrue(initiative >= 0 and initiative <= 1000)
+ # Attack roll
+ attack_roll = tb_magic.get_attack(self.attacker, self.defender)
+ self.assertTrue(attack_roll >= 0 and attack_roll <= 100)
+ # Defense roll
+ defense_roll = tb_magic.get_defense(self.attacker, self.defender)
+ self.assertTrue(defense_roll == 50)
+ # Damage roll
+ damage_roll = tb_magic.get_damage(self.attacker, self.defender)
+ self.assertTrue(damage_roll >= 15 and damage_roll <= 25)
+ # Apply damage
+ self.defender.db.hp = 10
+ tb_magic.apply_damage(self.defender, 3)
+ self.assertTrue(self.defender.db.hp == 7)
+ # Resolve attack
+ self.defender.db.hp = 40
+ tb_magic.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10)
+ self.assertTrue(self.defender.db.hp < 40)
+ # Combat cleanup
+ self.attacker.db.Combat_attribute = True
+ tb_magic.combat_cleanup(self.attacker)
+ self.assertFalse(self.attacker.db.combat_attribute)
+ # Is in combat
+ self.assertFalse(tb_magic.is_in_combat(self.attacker))
+ # Set up turn handler script for further tests
+ self.attacker.location.scripts.add(tb_magic.TBMagicTurnHandler)
+ self.turnhandler = self.attacker.db.combat_TurnHandler
+ self.assertTrue(self.attacker.db.combat_TurnHandler)
+ # Set the turn handler's interval very high to keep it from repeating during tests.
+ self.turnhandler.interval = 10000
+ # Force turn order
+ self.turnhandler.db.fighters = [self.attacker, self.defender]
+ self.turnhandler.db.turn = 0
+ # Test is turn
+ self.assertTrue(tb_magic.is_turn(self.attacker))
+ # Spend actions
+ self.attacker.db.Combat_ActionsLeft = 1
+ tb_magic.spend_action(self.attacker, 1, action_name="Test")
+ self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
+ self.assertTrue(self.attacker.db.Combat_LastAction == "Test")
+ # Initialize for combat
+ self.attacker.db.Combat_ActionsLeft = 983
+ self.turnhandler.initialize_for_combat(self.attacker)
+ self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0)
+ self.assertTrue(self.attacker.db.Combat_LastAction == "null")
+ # Start turn
+ self.defender.db.Combat_ActionsLeft = 0
+ self.turnhandler.start_turn(self.defender)
+ self.assertTrue(self.defender.db.Combat_ActionsLeft == 1)
+ # Next turn
+ self.turnhandler.db.fighters = [self.attacker, self.defender]
+ self.turnhandler.db.turn = 0
+ self.turnhandler.next_turn()
+ self.assertTrue(self.turnhandler.db.turn == 1)
+ # Turn end check
+ self.turnhandler.db.fighters = [self.attacker, self.defender]
+ self.turnhandler.db.turn = 0
+ self.attacker.db.Combat_ActionsLeft = 0
+ self.turnhandler.turn_end_check(self.attacker)
+ self.assertTrue(self.turnhandler.db.turn == 1)
+ # Join fight
+ self.turnhandler.db.fighters = [self.attacker, self.defender]
+ self.turnhandler.db.turn = 0
+ self.turnhandler.join_fight(self.joiner)
+ self.assertTrue(self.turnhandler.db.turn == 1)
+ self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender])
+
+
+# Test tree select
+
+from evennia.contrib import tree_select
+
+TREE_MENU_TESTSTR = """Foo
+Bar
+-Baz
+--Baz 1
+--Baz 2
+-Qux"""
+
+
+class TestTreeSelectFunc(EvenniaTest):
+
+ def test_tree_functions(self):
+ # Dash counter
+ self.assertTrue(tree_select.dashcount("--test") == 2)
+ # Is category
+ self.assertTrue(tree_select.is_category(TREE_MENU_TESTSTR, 1) == True)
+ # Parse options
+ self.assertTrue(tree_select.parse_opts(TREE_MENU_TESTSTR, category_index=2) == [(3, "Baz 1"), (4, "Baz 2")])
+ # Index to selection
+ self.assertTrue(tree_select.index_to_selection(TREE_MENU_TESTSTR, 2) == "Baz")
+ # Go up one category
+ self.assertTrue(tree_select.go_up_one_category(TREE_MENU_TESTSTR, 4) == 2)
+ # Option list to menu options
+ test_optlist = tree_select.parse_opts(TREE_MENU_TESTSTR, category_index=2)
+ optlist_to_menu_expected_result = [{'goto': ['menunode_treeselect', {'newindex': 3}], 'key': 'Baz 1'},
+ {'goto': ['menunode_treeselect', {'newindex': 4}], 'key': 'Baz 2'},
+ {'goto': ['menunode_treeselect', {'newindex': 1}], 'key': ['<< Go Back', 'go back', 'back'], 'desc': 'Return to the previous menu.'}]
+ self.assertTrue(tree_select.optlist_to_menuoptions(TREE_MENU_TESTSTR, test_optlist, 2, True, True) == optlist_to_menu_expected_result)
+
+# Test field fill
+
+from evennia.contrib import fieldfill
+
+FIELD_TEST_TEMPLATE = [
+{"fieldname":"TextTest", "fieldtype":"text"},
+{"fieldname":"NumberTest", "fieldtype":"number", "blankmsg":"Number here!"},
+{"fieldname":"DefaultText", "fieldtype":"text", "default":"Test"},
+{"fieldname":"DefaultNum", "fieldtype":"number", "default":3}
+]
+
+FIELD_TEST_DATA = {"TextTest":None, "NumberTest":None, "DefaultText":"Test", "DefaultNum":3}
+
+class TestFieldFillFunc(EvenniaTest):
+
+ def test_field_functions(self):
+ self.assertTrue(fieldfill.form_template_to_dict(FIELD_TEST_TEMPLATE) == FIELD_TEST_DATA)
+
# Test of the unixcommand module
from evennia.contrib.unixcommand import UnixCommand
@@ -1170,3 +1715,149 @@ class TestRandomStringGenerator(EvenniaTest):
# We can't generate one more
with self.assertRaises(random_string_generator.ExhaustedGenerator):
SIMPLE_GENERATOR.get()
+
+
+# Tests for the building_menu contrib
+from evennia.contrib.building_menu import BuildingMenu, CmdNoInput, CmdNoMatch
+
+class Submenu(BuildingMenu):
+ def init(self, exit):
+ self.add_choice("title", key="t", attr="key")
+
+class TestBuildingMenu(CommandTest):
+
+ def setUp(self):
+ super(TestBuildingMenu, self).setUp()
+ self.menu = BuildingMenu(caller=self.char1, obj=self.room1, title="test")
+ self.menu.add_choice("title", key="t", attr="key")
+
+ def test_quit(self):
+ """Try to quit the building menu."""
+ self.assertFalse(self.char1.cmdset.has("building_menu"))
+ self.menu.open()
+ self.assertTrue(self.char1.cmdset.has("building_menu"))
+ self.call(CmdNoMatch(building_menu=self.menu), "q")
+ # char1 tries to quit the editor
+ self.assertFalse(self.char1.cmdset.has("building_menu"))
+
+ def test_setattr(self):
+ """Test the simple setattr provided by building menus."""
+ key = self.room1.key
+ self.menu.open()
+ self.call(CmdNoMatch(building_menu=self.menu), "t")
+ self.assertIsNotNone(self.menu.current_choice)
+ self.call(CmdNoMatch(building_menu=self.menu), "some new title")
+ self.call(CmdNoMatch(building_menu=self.menu), "@")
+ self.assertIsNone(self.menu.current_choice)
+ self.assertEqual(self.room1.key, "some new title")
+ self.call(CmdNoMatch(building_menu=self.menu), "q")
+
+ def test_add_choice_without_key(self):
+ """Try to add choices without keys."""
+ choices = []
+ for i in range(20):
+ choices.append(self.menu.add_choice("choice", attr="test"))
+ self.menu._add_keys_choice()
+ keys = ["c", "h", "o", "i", "e", "ch", "ho", "oi", "ic", "ce", "cho", "hoi", "oic", "ice", "choi", "hoic", "oice", "choic", "hoice", "choice"]
+ for i in range(20):
+ self.assertEqual(choices[i].key, keys[i])
+
+ # Adding another key of the same title would break, no more available shortcut
+ self.menu.add_choice("choice", attr="test")
+ with self.assertRaises(ValueError):
+ self.menu._add_keys_choice()
+
+ def test_callbacks(self):
+ """Test callbacks in menus."""
+ self.room1.key = "room1"
+ def on_enter(caller, menu):
+ caller.msg("on_enter:{}".format(menu.title))
+ def on_nomatch(caller, string, choice):
+ caller.msg("on_nomatch:{},{}".format(string, choice.key))
+ def on_leave(caller, obj):
+ caller.msg("on_leave:{}".format(obj.key))
+ self.menu.add_choice("test", key="e", on_enter=on_enter, on_nomatch=on_nomatch, on_leave=on_leave)
+ self.call(CmdNoMatch(building_menu=self.menu), "e", "on_enter:test")
+ self.call(CmdNoMatch(building_menu=self.menu), "ok", "on_nomatch:ok,e")
+ self.call(CmdNoMatch(building_menu=self.menu), "@", "on_leave:room1")
+ self.call(CmdNoMatch(building_menu=self.menu), "q")
+
+ def test_multi_level(self):
+ """Test multi-level choices."""
+ # Creaste three succeeding menu (t2 is contained in t1, t3 is contained in t2)
+ def on_nomatch_t1(caller, menu):
+ menu.move("whatever") # this will be valid since after t1 is a joker
+
+ def on_nomatch_t2(caller, menu):
+ menu.move("t3") # this time the key matters
+
+ t1 = self.menu.add_choice("what", key="t1", on_nomatch=on_nomatch_t1)
+ t2 = self.menu.add_choice("and", key="t1.*", on_nomatch=on_nomatch_t2)
+ t3 = self.menu.add_choice("why", key="t1.*.t3")
+ self.menu.open()
+
+ # Move into t1
+ self.assertIn(t1, self.menu.relevant_choices)
+ self.assertNotIn(t2, self.menu.relevant_choices)
+ self.assertNotIn(t3, self.menu.relevant_choices)
+ self.assertIsNone(self.menu.current_choice)
+ self.call(CmdNoMatch(building_menu=self.menu), "t1")
+ self.assertEqual(self.menu.current_choice, t1)
+ self.assertNotIn(t1, self.menu.relevant_choices)
+ self.assertIn(t2, self.menu.relevant_choices)
+ self.assertNotIn(t3, self.menu.relevant_choices)
+
+ # Move into t2
+ self.call(CmdNoMatch(building_menu=self.menu), "t2")
+ self.assertEqual(self.menu.current_choice, t2)
+ self.assertNotIn(t1, self.menu.relevant_choices)
+ self.assertNotIn(t2, self.menu.relevant_choices)
+ self.assertIn(t3, self.menu.relevant_choices)
+
+ # Move into t3
+ self.call(CmdNoMatch(building_menu=self.menu), "t3")
+ self.assertEqual(self.menu.current_choice, t3)
+ self.assertNotIn(t1, self.menu.relevant_choices)
+ self.assertNotIn(t2, self.menu.relevant_choices)
+ self.assertNotIn(t3, self.menu.relevant_choices)
+
+ # Move back to t2
+ self.call(CmdNoMatch(building_menu=self.menu), "@")
+ self.assertEqual(self.menu.current_choice, t2)
+ self.assertNotIn(t1, self.menu.relevant_choices)
+ self.assertNotIn(t2, self.menu.relevant_choices)
+ self.assertIn(t3, self.menu.relevant_choices)
+
+ # Move back into t1
+ self.call(CmdNoMatch(building_menu=self.menu), "@")
+ self.assertEqual(self.menu.current_choice, t1)
+ self.assertNotIn(t1, self.menu.relevant_choices)
+ self.assertIn(t2, self.menu.relevant_choices)
+ self.assertNotIn(t3, self.menu.relevant_choices)
+
+ # Moves back to the main menu
+ self.call(CmdNoMatch(building_menu=self.menu), "@")
+ self.assertIn(t1, self.menu.relevant_choices)
+ self.assertNotIn(t2, self.menu.relevant_choices)
+ self.assertNotIn(t3, self.menu.relevant_choices)
+ self.assertIsNone(self.menu.current_choice)
+ self.call(CmdNoMatch(building_menu=self.menu), "q")
+
+ def test_submenu(self):
+ """Test to add sub-menus."""
+ def open_exit(menu):
+ menu.open_submenu("evennia.contrib.tests.Submenu", self.exit)
+ return False
+
+ self.menu.add_choice("exit", key="x", on_enter=open_exit)
+ self.menu.open()
+ self.call(CmdNoMatch(building_menu=self.menu), "x")
+ self.menu = self.char1.ndb._building_menu
+ self.call(CmdNoMatch(building_menu=self.menu), "t")
+ self.call(CmdNoMatch(building_menu=self.menu), "in")
+ self.call(CmdNoMatch(building_menu=self.menu), "@")
+ self.call(CmdNoMatch(building_menu=self.menu), "@")
+ self.menu = self.char1.ndb._building_menu
+ self.assertEqual(self.char1.ndb._building_menu.obj, self.room1)
+ self.call(CmdNoMatch(building_menu=self.menu), "q")
+ self.assertEqual(self.exit.key, "in")
diff --git a/evennia/contrib/tree_select.py b/evennia/contrib/tree_select.py
new file mode 100644
index 0000000000..d2854fc83f
--- /dev/null
+++ b/evennia/contrib/tree_select.py
@@ -0,0 +1,535 @@
+"""
+Easy menu selection tree
+
+Contrib - Tim Ashley Jenkins 2017
+
+This module allows you to create and initialize an entire branching EvMenu
+instance with nothing but a multi-line string passed to one function.
+
+EvMenu is incredibly powerful and flexible, but using it for simple menus
+can often be fairly cumbersome - a simple menu that can branch into five
+categories would require six nodes, each with options represented as a list
+of dictionaries.
+
+This module provides a function, init_tree_selection, which acts as a frontend
+for EvMenu, dynamically sourcing the options from a multi-line string you provide.
+For example, if you define a string as such:
+
+ TEST_MENU = '''Foo
+ Bar
+ Baz
+ Qux'''
+
+And then use TEST_MENU as the 'treestr' source when you call init_tree_selection
+on a player:
+
+ init_tree_selection(TEST_MENU, caller, callback)
+
+The player will be presented with an EvMenu, like so:
+
+ ___________________________
+
+ Make your selection:
+ ___________________________
+
+ Foo
+ Bar
+ Baz
+ Qux
+
+Making a selection will pass the selection's key to the specified callback as a
+string along with the caller, as well as the index of the selection (the line number
+on the source string) along with the source string for the tree itself.
+
+In addition to specifying selections on the menu, you can also specify categories.
+Categories are indicated by putting options below it preceded with a '-' character.
+If a selection is a category, then choosing it will bring up a new menu node, prompting
+the player to select between those options, or to go back to the previous menu. In
+addition, categories are marked by default with a '[+]' at the end of their key. Both
+this marker and the option to go back can be disabled.
+
+Categories can be nested in other categories as well - just go another '-' deeper. You
+can do this as many times as you like. There's no hard limit to the number of
+categories you can go down.
+
+For example, let's add some more options to our menu, turning 'Bar' into a category.
+
+ TEST_MENU = '''Foo
+ Bar
+ -You've got to know
+ --When to hold em
+ --When to fold em
+ --When to walk away
+ Baz
+ Qux'''
+
+Now when we call the menu, we can see that 'Bar' has become a category instead of a
+selectable option.
+
+ _______________________________
+
+ Make your selection:
+ _______________________________
+
+ Foo
+ Bar [+]
+ Baz
+ Qux
+
+Note the [+] next to 'Bar'. If we select 'Bar', it'll show us the option listed under it.
+
+ ________________________________________________________________
+
+ Bar
+ ________________________________________________________________
+
+ You've got to know [+]
+ << Go Back: Return to the previous menu.
+
+Just the one option, which is a category itself, and the option to go back, which will
+take us back to the previous menu. Let's select 'You've got to know'.
+
+ ________________________________________________________________
+
+ You've got to know
+ ________________________________________________________________
+
+ When to hold em
+ When to fold em
+ When to walk away
+ << Go Back: Return to the previous menu.
+
+Now we see the three options listed under it, too. We can select one of them or use 'Go
+Back' to return to the 'Bar' menu we were just at before. It's very simple to make a
+branching tree of selections!
+
+One last thing - you can set the descriptions for the various options simply by adding a
+':' character followed by the description to the option's line. For example, let's add a
+description to 'Baz' in our menu:
+
+ TEST_MENU = '''Foo
+ Bar
+ -You've got to know
+ --When to hold em
+ --When to fold em
+ --When to walk away
+ Baz: Look at this one: the best option.
+ Qux'''
+
+Now we see that the Baz option has a description attached that's separate from its key:
+
+ _______________________________________________________________
+
+ Make your selection:
+ _______________________________________________________________
+
+ Foo
+ Bar [+]
+ Baz: Look at this one: the best option.
+ Qux
+
+Once the player makes a selection - let's say, 'Foo' - the menu will terminate and call
+your specified callback with the selection, like so:
+
+ callback(caller, TEST_MENU, 0, "Foo")
+
+The index of the selection is given along with a string containing the selection's key.
+That way, if you have two selections in the menu with the same key, you can still
+differentiate between them.
+
+And that's all there is to it! For simple branching-tree selections, using this system is
+much easier than manually creating EvMenu nodes. It also makes generating menus with dynamic
+options much easier - since the source of the menu tree is just a string, you could easily
+generate that string procedurally before passing it to the init_tree_selection function.
+For example, if a player casts a spell or does an attack without specifying a target, instead
+of giving them an error, you could present them with a list of valid targets to select by
+generating a multi-line string of targets and passing it to init_tree_selection, with the
+callable performing the maneuver once a selection is made.
+
+This selection system only works for simple branching trees - doing anything really complicated
+like jumping between categories or prompting for arbitrary input would still require a full
+EvMenu implementation. For simple selections, however, I'm sure you will find using this function
+to be much easier!
+
+Included in this module is a sample menu and function which will let a player change the color
+of their name - feel free to mess with it to get a feel for how this system works by importing
+this module in your game's default_cmdsets.py module and adding CmdNameColor to your default
+character's command set.
+"""
+
+from evennia.utils import evmenu
+from evennia.utils.logger import log_trace
+from evennia import Command
+
+def init_tree_selection(treestr, caller, callback,
+ index=None, mark_category=True, go_back=True,
+ cmd_on_exit="look",
+ start_text="Make your selection:"):
+ """
+ Prompts a player to select an option from a menu tree given as a multi-line string.
+
+ Args:
+ treestr (str): Multi-lne string representing menu options
+ caller (obj): Player to initialize the menu for
+ callback (callable): Function to run when a selection is made. Must take 4 args:
+ caller (obj): Caller given above
+ treestr (str): Menu tree string given above
+ index (int): Index of final selection
+ selection (str): Key of final selection
+
+ Options:
+ index (int or None): Index to start the menu at, or None for top level
+ mark_category (bool): If True, marks categories with a [+] symbol in the menu
+ go_back (bool): If True, present an option to go back to previous categories
+ start_text (str): Text to display at the top level of the menu
+ cmd_on_exit(str): Command to enter when the menu exits - 'look' by default
+
+
+ Notes:
+ This function will initialize an instance of EvMenu with options generated
+ dynamically from the source string, and passes the menu user's selection to
+ a function of your choosing. The EvMenu is made of a single, repeating node,
+ which will call itself over and over at different levels of the menu tree as
+ categories are selected.
+
+ Once a non-category selection is made, the user's selection will be passed to
+ the given callable, both as a string and as an index number. The index is given
+ to ensure every selection has a unique identifier, so that selections with the
+ same key in different categories can be distinguished between.
+
+ The menus called by this function are not persistent and cannot perform
+ complicated tasks like prompt for arbitrary input or jump multiple category
+ levels at once - you'll have to use EvMenu itself if you want to take full
+ advantage of its features.
+ """
+
+ # Pass kwargs to store data needed in the menu
+ kwargs = {
+ "index":index,
+ "mark_category":mark_category,
+ "go_back":go_back,
+ "treestr":treestr,
+ "callback":callback,
+ "start_text":start_text
+ }
+
+ # Initialize menu of selections
+ evmenu.EvMenu(caller, "evennia.contrib.tree_select", startnode="menunode_treeselect",
+ startnode_input=None, cmd_on_exit=cmd_on_exit, **kwargs)
+
+def dashcount(entry):
+ """
+ Counts the number of dashes at the beginning of a string. This
+ is needed to determine the depth of options in categories.
+
+ Args:
+ entry (str): String to count the dashes at the start of
+
+ Returns:
+ dashes (int): Number of dashes at the start
+ """
+ dashes = 0
+ for char in entry:
+ if char == "-":
+ dashes += 1
+ else:
+ return dashes
+ return dashes
+
+def is_category(treestr, index):
+ """
+ Determines whether an option in a tree string is a category by
+ whether or not there are additional options below it.
+
+ Args:
+ treestr (str): Multi-line string representing menu options
+ index (int): Which line of the string to test
+
+ Returns:
+ is_category (bool): Whether the option is a category
+ """
+ opt_list = treestr.split('\n')
+ # Not a category if it's the last one in the list
+ if index == len(opt_list) - 1:
+ return False
+ # Not a category if next option is not one level deeper
+ return not bool(dashcount(opt_list[index+1]) != dashcount(opt_list[index]) + 1)
+
+def parse_opts(treestr, category_index=None):
+ """
+ Parses a tree string and given index into a list of options. If
+ category_index is none, returns all the options at the top level of
+ the menu. If category_index corresponds to a category, returns a list
+ of options under that category. If category_index corresponds to
+ an option that is not a category, it's a selection and returns True.
+
+ Args:
+ treestr (str): Multi-line string representing menu options
+ category_index (int): Index of category or None for top level
+
+ Returns:
+ kept_opts (list or True): Either a list of options in the selected
+ category or True if a selection was made
+ """
+ dash_depth = 0
+ opt_list = treestr.split('\n')
+ kept_opts = []
+
+ # If a category index is given
+ if category_index != None:
+ # If given index is not a category, it's a selection - return True.
+ if not is_category(treestr, category_index):
+ return True
+ # Otherwise, change the dash depth to match the new category.
+ dash_depth = dashcount(opt_list[category_index]) + 1
+ # Delete everything before the category index
+ opt_list = opt_list [category_index+1:]
+
+ # Keep every option (referenced by index) at the appropriate depth
+ cur_index = 0
+ for option in opt_list:
+ if dashcount(option) == dash_depth:
+ if category_index == None:
+ kept_opts.append((cur_index, option[dash_depth:]))
+ else:
+ kept_opts.append((cur_index + category_index + 1, option[dash_depth:]))
+ # Exits the loop if leaving a category
+ if dashcount(option) < dash_depth:
+ return kept_opts
+ cur_index += 1
+ return kept_opts
+
+def index_to_selection(treestr, index, desc=False):
+ """
+ Given a menu tree string and an index, returns the corresponding selection's
+ name as a string. If 'desc' is set to True, will return the selection's
+ description as a string instead.
+
+ Args:
+ treestr (str): Multi-line string representing menu options
+ index (int): Index to convert to selection key or description
+
+ Options:
+ desc (bool): If true, returns description instead of key
+
+ Returns:
+ selection (str): Selection key or description if 'desc' is set
+ """
+ opt_list = treestr.split('\n')
+ # Fetch the given line
+ selection = opt_list[index]
+ # Strip out the dashes at the start
+ selection = selection[dashcount(selection):]
+ # Separate out description, if any
+ if ":" in selection:
+ # Split string into key and description
+ selection = selection.split(':', 1)
+ selection[1] = selection[1].strip(" ")
+ else:
+ # If no description given, set description to None
+ selection = [selection, None]
+ if not desc:
+ return selection[0]
+ else:
+ return selection[1]
+
+def go_up_one_category(treestr, index):
+ """
+ Given a menu tree string and an index, returns the category that the given option
+ belongs to. Used for the 'go back' option.
+
+ Args:
+ treestr (str): Multi-line string representing menu options
+ index (int): Index to determine the parent category of
+
+ Returns:
+ parent_category (int): Index of parent category
+ """
+ opt_list = treestr.split('\n')
+ # Get the number of dashes deep the given index is
+ dash_level = dashcount(opt_list[index])
+ # Delete everything after the current index
+ opt_list = opt_list[:index+1]
+
+
+ # If there's no dash, return 'None' to return to base menu
+ if dash_level == 0:
+ return None
+ current_index = index
+ # Go up through each option until we find one that's one category above
+ for selection in reversed(opt_list):
+ if dashcount(selection) == dash_level - 1:
+ return current_index
+ current_index -= 1
+
+def optlist_to_menuoptions(treestr, optlist, index, mark_category, go_back):
+ """
+ Takes a list of options processed by parse_opts and turns it into
+ a list/dictionary of menu options for use in menunode_treeselect.
+
+ Args:
+ treestr (str): Multi-line string representing menu options
+ optlist (list): List of options to convert to EvMenu's option format
+ index (int): Index of current category
+ mark_category (bool): Whether or not to mark categories with [+]
+ go_back (bool): Whether or not to add an option to go back in the menu
+
+ Returns:
+ menuoptions (list of dicts): List of menu options formatted for use
+ in EvMenu, each passing a different "newindex" kwarg that changes
+ the menu level or makes a selection
+ """
+
+ menuoptions = []
+ cur_index = 0
+ for option in optlist:
+ index_to_add = optlist[cur_index][0]
+ menuitem = {}
+ keystr = index_to_selection(treestr, index_to_add)
+ if mark_category and is_category(treestr, index_to_add):
+ # Add the [+] to the key if marking categories, and the key by itself as an alias
+ menuitem["key"] = [keystr + " [+]", keystr]
+ else:
+ menuitem["key"] = keystr
+ # Get the option's description
+ desc = index_to_selection(treestr, index_to_add, desc=True)
+ if desc:
+ menuitem["desc"] = desc
+ # Passing 'newindex' as a kwarg to the node is how we move through the menu!
+ menuitem["goto"] = ["menunode_treeselect", {"newindex":index_to_add}]
+ menuoptions.append(menuitem)
+ cur_index += 1
+ # Add option to go back, if needed
+ if index != None and go_back == True:
+ gobackitem = {"key":["<< Go Back", "go back", "back"],
+ "desc":"Return to the previous menu.",
+ "goto":["menunode_treeselect", {"newindex":go_up_one_category(treestr, index)}]}
+ menuoptions.append(gobackitem)
+ return menuoptions
+
+def menunode_treeselect(caller, raw_string, **kwargs):
+ """
+ This is the repeating menu node that handles the tree selection.
+ """
+
+ # If 'newindex' is in the kwargs, change the stored index.
+ if "newindex" in kwargs:
+ caller.ndb._menutree.index = kwargs["newindex"]
+
+ # Retrieve menu info
+ index = caller.ndb._menutree.index
+ mark_category = caller.ndb._menutree.mark_category
+ go_back = caller.ndb._menutree.go_back
+ treestr = caller.ndb._menutree.treestr
+ callback = caller.ndb._menutree.callback
+ start_text = caller.ndb._menutree.start_text
+
+ # List of options if index is 'None' or category, or 'True' if a selection
+ optlist = parse_opts(treestr, category_index=index)
+
+ # If given index returns optlist as 'True', it's a selection. Pass to callback and end the menu.
+ if optlist == True:
+ selection = index_to_selection(treestr, index)
+ try:
+ callback(caller, treestr, index, selection)
+ except Exception:
+ log_trace("Error in tree selection callback.")
+
+ # Returning None, None ends the menu.
+ return None, None
+
+ # Otherwise, convert optlist to a list of menu options.
+ else:
+ options = optlist_to_menuoptions(treestr, optlist, index, mark_category, go_back)
+ if index == None:
+ # Use start_text for the menu text on the top level
+ text = start_text
+ else:
+ # Use the category name and description (if any) as the menu text
+ if index_to_selection(treestr, index, desc=True) != None:
+ text = "|w" + index_to_selection(treestr, index) + "|n: " + index_to_selection(treestr, index, desc=True)
+ else:
+ text = "|w" + index_to_selection(treestr, index) + "|n"
+ return text, options
+
+# The rest of this module is for the example menu and command! It'll change the color of your name.
+
+"""
+Here's an example string that you can initialize a menu from. Note the dashes at
+the beginning of each line - that's how menu option depth and hierarchy is determined.
+"""
+
+NAMECOLOR_MENU = """Set name color: Choose a color for your name!
+-Red shades: Various shades of |511red|n
+--Red: |511Set your name to Red|n
+--Pink: |533Set your name to Pink|n
+--Maroon: |301Set your name to Maroon|n
+-Orange shades: Various shades of |531orange|n
+--Orange: |531Set your name to Orange|n
+--Brown: |321Set your name to Brown|n
+--Sienna: |420Set your name to Sienna|n
+-Yellow shades: Various shades of |551yellow|n
+--Yellow: |551Set your name to Yellow|n
+--Gold: |540Set your name to Gold|n
+--Dandelion: |553Set your name to Dandelion|n
+-Green shades: Various shades of |141green|n
+--Green: |141Set your name to Green|n
+--Lime: |350Set your name to Lime|n
+--Forest: |032Set your name to Forest|n
+-Blue shades: Various shades of |115blue|n
+--Blue: |115Set your name to Blue|n
+--Cyan: |155Set your name to Cyan|n
+--Navy: |113Set your name to Navy|n
+-Purple shades: Various shades of |415purple|n
+--Purple: |415Set your name to Purple|n
+--Lavender: |535Set your name to Lavender|n
+--Fuchsia: |503Set your name to Fuchsia|n
+Remove name color: Remove your name color, if any"""
+
+class CmdNameColor(Command):
+ """
+ Set or remove a special color on your name. Just an example for the
+ easy menu selection tree contrib.
+ """
+
+ key = "namecolor"
+
+ def func(self):
+ # This is all you have to do to initialize a menu!
+ init_tree_selection(NAMECOLOR_MENU, self.caller,
+ change_name_color,
+ start_text="Name color options:")
+
+def change_name_color(caller, treestr, index, selection):
+ """
+ Changes a player's name color.
+
+ Args:
+ caller (obj): Character whose name to color.
+ treestr (str): String for the color change menu - unused
+ index (int): Index of menu selection - unused
+ selection (str): Selection made from the name color menu - used
+ to determine the color the player chose.
+ """
+
+ # Store the caller's uncolored name
+ if not caller.db.uncolored_name:
+ caller.db.uncolored_name = caller.key
+
+ # Dictionary matching color selection names to color codes
+ colordict = { "Red":"|511", "Pink":"|533", "Maroon":"|301",
+ "Orange":"|531", "Brown":"|321", "Sienna":"|420",
+ "Yellow":"|551", "Gold":"|540", "Dandelion":"|553",
+ "Green":"|141", "Lime":"|350", "Forest":"|032",
+ "Blue":"|115", "Cyan":"|155", "Navy":"|113",
+ "Purple":"|415", "Lavender":"|535", "Fuchsia":"|503"}
+
+ # I know this probably isn't the best way to do this. It's just an example!
+ if selection == "Remove name color": # Player chose to remove their name color
+ caller.key = caller.db.uncolored_name
+ caller.msg("Name color removed.")
+ elif selection in colordict:
+ newcolor = colordict[selection] # Retrieve color code based on menu selection
+ caller.key = newcolor + caller.db.uncolored_name + "|n" # Add color code to caller's name
+ caller.msg(newcolor + ("Name color changed to %s!" % selection) + "|n")
+
diff --git a/evennia/contrib/turnbattle/README.md b/evennia/contrib/turnbattle/README.md
new file mode 100644
index 0000000000..fd2563bceb
--- /dev/null
+++ b/evennia/contrib/turnbattle/README.md
@@ -0,0 +1,55 @@
+# Turn based battle system framework
+
+Contrib - Tim Ashley Jenkins 2017
+
+This is a framework for a simple turn-based combat system, similar
+to those used in D&D-style tabletop role playing games. It allows
+any character to start a fight in a room, at which point initiative
+is rolled and a turn order is established. Each participant in combat
+has a limited time to decide their action for that turn (30 seconds by
+default), and combat progresses through the turn order, looping through
+the participants until the fight ends.
+
+This folder contains multiple examples of how such a system can be
+implemented and customized:
+
+ tb_basic.py - The simplest system, which implements initiative and turn
+ order, attack rolls against defense values, and damage to hit
+ points. Only very basic game mechanics are included.
+
+ tb_equip.py - Adds weapons and armor to the basic implementation of
+ the battle system, including commands for wielding weapons and
+ donning armor, and modifiers to accuracy and damage based on
+ currently used equipment.
+
+ tb_items.py - Adds usable items and conditions/status effects, and gives
+ a lot of examples for each. Items can perform nearly any sort of
+ function, including healing, adding or curing conditions, or
+ being used to attack. Conditions affect a fighter's attributes
+ and options in combat and persist outside of fights, counting
+ down per turn in combat and in real time outside combat.
+
+ tb_magic.py - Adds a spellcasting system, allowing characters to cast
+ spells with a variety of effects by spending MP. Spells are
+ linked to functions, and as such can perform any sort of action
+ the developer can imagine - spells for attacking, healing and
+ conjuring objects are included as examples.
+
+ tb_range.py - Adds a system for abstract positioning and movement, which
+ tracks the distance between different characters and objects in
+ combat, as well as differentiates between melee and ranged
+ attacks.
+
+This system is meant as a basic framework to start from, and is modeled
+after the combat systems of popular tabletop role playing games rather than
+the real-time battle systems that many MMOs and some MUDs use. As such, it
+may be better suited to role-playing or more story-oriented games, or games
+meant to closely emulate the experience of playing a tabletop RPG.
+
+Each of these modules contains the full functionality of the battle system
+with different customizations added in - the instructions to install each
+one is contained in the module itself. It's recommended that you install
+and test tb_basic first, so you can better understand how the other
+modules expand on it and get a better idea of how you can customize the
+system to your liking and integrate the subsystems presented here into
+your own combat system.
diff --git a/evennia/contrib/turnbattle/__init__.py b/evennia/contrib/turnbattle/__init__.py
new file mode 100644
index 0000000000..8b13789179
--- /dev/null
+++ b/evennia/contrib/turnbattle/__init__.py
@@ -0,0 +1 @@
+
diff --git a/evennia/contrib/turnbattle.py b/evennia/contrib/turnbattle/tb_basic.py
similarity index 88%
rename from evennia/contrib/turnbattle.py
rename to evennia/contrib/turnbattle/tb_basic.py
index 692656bc3a..88e5c176c8 100644
--- a/evennia/contrib/turnbattle.py
+++ b/evennia/contrib/turnbattle/tb_basic.py
@@ -16,26 +16,26 @@ is easily extensible and can be used as the foundation for implementing
the rules from your turn-based tabletop game of choice or making your
own battle system.
-To install and test, import this module's BattleCharacter object into
+To install and test, import this module's TBBasicCharacter object into
your game's character.py module:
- from evennia.contrib.turnbattle import BattleCharacter
+ from evennia.contrib.turnbattle.tb_basic import TBBasicCharacter
-And change your game's character typeclass to inherit from BattleCharacter
+And change your game's character typeclass to inherit from TBBasicCharacter
instead of the default:
- class Character(BattleCharacter):
+ class Character(TBBasicCharacter):
Next, import this module into your default_cmdsets.py module:
- from evennia.contrib import turnbattle
+ from evennia.contrib.turnbattle import tb_basic
And add the battle command set to your default command set:
#
# any commands you add below will overload the default ones.
#
- self.add(turnbattle.BattleCmdSet())
+ self.add(tb_basic.BattleCmdSet())
This module is meant to be heavily expanded on, so you may want to copy it
to your game's 'world' folder and modify it there rather than importing it
@@ -48,10 +48,18 @@ from evennia.commands.default.help import CmdHelp
"""
----------------------------------------------------------------------------
-COMBAT FUNCTIONS START HERE
+OPTIONS
----------------------------------------------------------------------------
"""
+TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds
+ACTIONS_PER_TURN = 1 # Number of actions allowed per turn
+
+"""
+----------------------------------------------------------------------------
+COMBAT FUNCTIONS START HERE
+----------------------------------------------------------------------------
+"""
def roll_init(character):
"""
@@ -167,6 +175,20 @@ def apply_damage(defender, damage):
if defender.db.hp <= 0:
defender.db.hp = 0
+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
+ into a dying state or something similar) then this is the place to
+ do it.
+ """
+ defeated.location.msg_contents("%s has been defeated!" % defeated)
def resolve_attack(attacker, defender, attack_value=None, defense_value=None):
"""
@@ -195,10 +217,9 @@ def resolve_attack(attacker, defender, attack_value=None, defense_value=None):
# Announce damage dealt and apply damage.
attacker.location.msg_contents("%s hits %s for %i damage!" % (attacker, defender, damage_value))
apply_damage(defender, damage_value)
- # If defender HP is reduced to 0 or less, announce defeat.
+ # If defender HP is reduced to 0 or less, call at_defeat.
if defender.db.hp <= 0:
- attacker.location.msg_contents("%s has been defeated!" % defender)
-
+ at_defeat(defender)
def combat_cleanup(character):
"""
@@ -226,9 +247,7 @@ def is_in_combat(character):
Returns:
(bool): True if in combat or False if not in combat
"""
- if character.db.Combat_TurnHandler:
- return True
- return False
+ return bool(character.db.combat_turnhandler)
def is_turn(character):
@@ -241,11 +260,9 @@ def is_turn(character):
Returns:
(bool): True if it is their turn or False otherwise
"""
- turnhandler = character.db.Combat_TurnHandler
+ turnhandler = character.db.combat_turnhandler
currentchar = turnhandler.db.fighters[turnhandler.db.turn]
- if character == currentchar:
- return True
- return False
+ return bool(character == currentchar)
def spend_action(character, actions, action_name=None):
@@ -261,14 +278,14 @@ def spend_action(character, actions, action_name=None):
combat to provided string
"""
if action_name:
- character.db.Combat_LastAction = action_name
+ character.db.combat_lastaction = action_name
if actions == 'all': # If spending all actions
- character.db.Combat_ActionsLeft = 0 # Set actions to 0
+ character.db.combat_actionsleft = 0 # Set actions to 0
else:
- character.db.Combat_ActionsLeft -= actions # Use up actions.
- if character.db.Combat_ActionsLeft < 0:
- character.db.Combat_ActionsLeft = 0 # Can't have fewer than 0 actions
- character.db.Combat_TurnHandler.turn_end_check(character) # Signal potential end of turn.
+ character.db.combat_actionsleft -= actions # Use up actions.
+ if character.db.combat_actionsleft < 0:
+ character.db.combat_actionsleft = 0 # Can't have fewer than 0 actions
+ character.db.combat_turnhandler.turn_end_check(character) # Signal potential end of turn.
"""
@@ -278,7 +295,7 @@ CHARACTER TYPECLASS
"""
-class BattleCharacter(DefaultCharacter):
+class TBBasicCharacter(DefaultCharacter):
"""
A character able to participate in turn-based combat. Has attributes for current
and maximum HP, and access to combat commands.
@@ -324,7 +341,182 @@ class BattleCharacter(DefaultCharacter):
return False
return True
+"""
+----------------------------------------------------------------------------
+SCRIPTS START HERE
+----------------------------------------------------------------------------
+"""
+
+class TBBasicTurnHandler(DefaultScript):
+ """
+ This is the script that handles the progression of combat through turns.
+ On creation (when a fight is started) it adds all combat-ready characters
+ to its roster and then sorts them into a turn order. There can only be one
+ fight going on in a single room at a time, so the script is assigned to a
+ room as its object.
+
+ Fights persist until only one participant is left with any HP or all
+ remaining participants choose to end the combat with the 'disengage' command.
+ """
+
+ def at_script_creation(self):
+ """
+ Called once, when the script is created.
+ """
+ self.key = "Combat Turn Handler"
+ self.interval = 5 # Once every 5 seconds
+ self.persistent = True
+ self.db.fighters = []
+
+ # Add all fighters in the room with at least 1 HP to the combat."
+ for thing in self.obj.contents:
+ if thing.db.hp:
+ self.db.fighters.append(thing)
+
+ # Initialize each fighter for combat
+ for fighter in self.db.fighters:
+ self.initialize_for_combat(fighter)
+
+ # Add a reference to this script to the room
+ self.obj.db.combat_turnhandler = self
+
+ # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order.
+ # The initiative roll is determined by the roll_init function and can be customized easily.
+ ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True)
+ self.db.fighters = ordered_by_roll
+
+ # Announce the turn order.
+ self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters))
+
+ # Start first fighter's turn.
+ self.start_turn(self.db.fighters[0])
+
+ # Set up the current turn and turn timeout delay.
+ self.db.turn = 0
+ self.db.timer = TURN_TIMEOUT # Set timer to turn timeout specified in options
+
+ def at_stop(self):
+ """
+ Called at script termination.
+ """
+ for fighter in self.db.fighters:
+ combat_cleanup(fighter) # Clean up the combat attributes for every fighter.
+ self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location
+
+ def at_repeat(self):
+ """
+ Called once every self.interval seconds.
+ """
+ currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order.
+ self.db.timer -= self.interval # Count down the timer.
+
+ if self.db.timer <= 0:
+ # Force current character to disengage if timer runs out.
+ self.obj.msg_contents("%s's turn timed out!" % currentchar)
+ spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions.
+ return
+ elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left
+ # Warn the current character if they're about to time out.
+ currentchar.msg("WARNING: About to time out!")
+ self.db.timeout_warning_given = True
+
+ def initialize_for_combat(self, character):
+ """
+ Prepares a character for combat when starting or entering a fight.
+
+ Args:
+ character (obj): Character to initialize for combat.
+ """
+ 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
+ character.db.combat_turnhandler = 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):
+ """
+ Readies a character for the start of their turn by replenishing their
+ available actions and notifying them that their turn has come up.
+
+ Args:
+ character (obj): Character to be readied.
+
+ Notes:
+ Here, you only get one action per turn, but you might want to allow more than
+ one per turn, or even grant a number of actions based on a character's
+ attributes. You can even add multiple different kinds of actions, I.E. actions
+ separated for movement, by adding "character.db.combat_movesleft = 3" or
+ something similar.
+ """
+ character.db.combat_actionsleft = ACTIONS_PER_TURN # Replenish actions
+ # Prompt the character for their turn and give some information.
+ character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp)
+
+ def next_turn(self):
+ """
+ Advances to the next character in the turn order.
+ """
+
+ # Check to see if every character disengaged as their last action. If so, end combat.
+ disengage_check = True
+ for fighter in self.db.fighters:
+ if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage
+ disengage_check = False
+ if disengage_check: # All characters have disengaged
+ self.obj.msg_contents("All fighters have disengaged! Combat is over!")
+ self.stop() # Stop this script and end combat.
+ return
+
+ # Check to see if only one character is left standing. If so, end combat.
+ defeated_characters = 0
+ for fighter in self.db.fighters:
+ if fighter.db.HP == 0:
+ defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated)
+ if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated
+ for fighter in self.db.fighters:
+ if fighter.db.HP != 0:
+ LastStanding = fighter # Pick the one fighter left with HP remaining
+ self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding)
+ self.stop() # Stop this script and end combat.
+ return
+
+ # Cycle to the next turn.
+ currentchar = self.db.fighters[self.db.turn]
+ self.db.turn += 1 # Go to the next in the turn order.
+ if self.db.turn > len(self.db.fighters) - 1:
+ self.db.turn = 0 # Go back to the first in the turn order once you reach the end.
+ newchar = self.db.fighters[self.db.turn] # Note the new character
+ self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer.
+ self.db.timeout_warning_given = False # Reset the timeout warning.
+ self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar))
+ self.start_turn(newchar) # Start the new character's turn.
+
+ def turn_end_check(self, character):
+ """
+ Tests to see if a character's turn is over, and cycles to the next turn if it is.
+
+ Args:
+ character (obj): Character to test for end of turn
+ """
+ if not character.db.combat_actionsleft: # Character has no actions remaining
+ self.next_turn()
+ return
+
+ def join_fight(self, character):
+ """
+ Adds a new character to a fight already in progress.
+
+ Args:
+ character (obj): Character to be added to the fight.
+ """
+ # Inserts the fighter to the turn order, right behind whoever's turn it currently is.
+ self.db.fighters.insert(self.db.turn, character)
+ # Tick the turn counter forward one to compensate.
+ self.db.turn += 1
+ # Initialize the character like you do at the start.
+ self.initialize_for_combat(character)
+
+
"""
----------------------------------------------------------------------------
COMMANDS START HERE
@@ -365,13 +557,13 @@ class CmdFight(Command):
if len(fighters) <= 1: # If you're the only able fighter in the room
self.caller.msg("There's nobody here to fight!")
return
- if here.db.Combat_TurnHandler: # If there's already a fight going on...
+ if here.db.combat_turnhandler: # If there's already a fight going on...
here.msg_contents("%s joins the fight!" % self.caller)
- here.db.Combat_TurnHandler.join_fight(self.caller) # Join the fight!
+ here.db.combat_turnhandler.join_fight(self.caller) # Join the fight!
return
here.msg_contents("%s starts a fight!" % self.caller)
# Add a turn handler script to the room, which starts combat.
- here.scripts.add("contrib.turnbattle.TurnHandler")
+ here.scripts.add("contrib.turnbattle.tb_basic.TBBasicTurnHandler")
# Remember you'll have to change the path to the script if you copy this code to your own modules!
@@ -559,177 +751,4 @@ class BattleCmdSet(default_cmds.CharacterCmdSet):
self.add(CmdRest())
self.add(CmdPass())
self.add(CmdDisengage())
- self.add(CmdCombatHelp())
-
-
-"""
-----------------------------------------------------------------------------
-SCRIPTS START HERE
-----------------------------------------------------------------------------
-"""
-
-
-class TurnHandler(DefaultScript):
- """
- This is the script that handles the progression of combat through turns.
- On creation (when a fight is started) it adds all combat-ready characters
- to its roster and then sorts them into a turn order. There can only be one
- fight going on in a single room at a time, so the script is assigned to a
- room as its object.
-
- Fights persist until only one participant is left with any HP or all
- remaining participants choose to end the combat with the 'disengage' command.
- """
-
- def at_script_creation(self):
- """
- Called once, when the script is created.
- """
- self.key = "Combat Turn Handler"
- self.interval = 5 # Once every 5 seconds
- self.persistent = True
- self.db.fighters = []
-
- # Add all fighters in the room with at least 1 HP to the combat."
- for object in self.obj.contents:
- if object.db.hp:
- self.db.fighters.append(object)
-
- # Initialize each fighter for combat
- for fighter in self.db.fighters:
- self.initialize_for_combat(fighter)
-
- # Add a reference to this script to the room
- self.obj.db.Combat_TurnHandler = self
-
- # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order.
- # The initiative roll is determined by the roll_init function and can be customized easily.
- ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True)
- self.db.fighters = ordered_by_roll
-
- # Announce the turn order.
- self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters))
-
- # Set up the current turn and turn timeout delay.
- self.db.turn = 0
- self.db.timer = 30 # 30 seconds
-
- def at_stop(self):
- """
- Called at script termination.
- """
- for fighter in self.db.fighters:
- combat_cleanup(fighter) # Clean up the combat attributes for every fighter.
- self.obj.db.Combat_TurnHandler = None # Remove reference to turn handler in location
-
- def at_repeat(self):
- """
- Called once every self.interval seconds.
- """
- currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order.
- self.db.timer -= self.interval # Count down the timer.
-
- if self.db.timer <= 0:
- # Force current character to disengage if timer runs out.
- self.obj.msg_contents("%s's turn timed out!" % currentchar)
- spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions.
- return
- elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left
- # Warn the current character if they're about to time out.
- currentchar.msg("WARNING: About to time out!")
- self.db.timeout_warning_given = True
-
- def initialize_for_combat(self, character):
- """
- Prepares a character for combat when starting or entering a fight.
-
- Args:
- character (obj): Character to initialize for combat.
- """
- 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
- character.db.Combat_TurnHandler = 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):
- """
- Readies a character for the start of their turn by replenishing their
- available actions and notifying them that their turn has come up.
-
- Args:
- character (obj): Character to be readied.
-
- Notes:
- Here, you only get one action per turn, but you might want to allow more than
- one per turn, or even grant a number of actions based on a character's
- attributes. You can even add multiple different kinds of actions, I.E. actions
- separated for movement, by adding "character.db.Combat_MovesLeft = 3" or
- something similar.
- """
- character.db.Combat_ActionsLeft = 1 # 1 action per turn.
- # Prompt the character for their turn and give some information.
- character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp)
-
- def next_turn(self):
- """
- Advances to the next character in the turn order.
- """
-
- # Check to see if every character disengaged as their last action. If so, end combat.
- disengage_check = True
- for fighter in self.db.fighters:
- if fighter.db.Combat_LastAction != "disengage": # If a character has done anything but disengage
- disengage_check = False
- if disengage_check: # All characters have disengaged
- self.obj.msg_contents("All fighters have disengaged! Combat is over!")
- self.stop() # Stop this script and end combat.
- return
-
- # Check to see if only one character is left standing. If so, end combat.
- defeated_characters = 0
- for fighter in self.db.fighters:
- if fighter.db.HP == 0:
- defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated)
- if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated
- for fighter in self.db.fighters:
- if fighter.db.HP != 0:
- LastStanding = fighter # Pick the one fighter left with HP remaining
- self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding)
- self.stop() # Stop this script and end combat.
- return
-
- # Cycle to the next turn.
- currentchar = self.db.fighters[self.db.turn]
- self.db.turn += 1 # Go to the next in the turn order.
- if self.db.turn > len(self.db.fighters) - 1:
- self.db.turn = 0 # Go back to the first in the turn order once you reach the end.
- newchar = self.db.fighters[self.db.turn] # Note the new character
- self.db.timer = 30 + self.time_until_next_repeat() # Reset the timer.
- self.db.timeout_warning_given = False # Reset the timeout warning.
- self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar))
- self.start_turn(newchar) # Start the new character's turn.
-
- def turn_end_check(self, character):
- """
- Tests to see if a character's turn is over, and cycles to the next turn if it is.
-
- Args:
- character (obj): Character to test for end of turn
- """
- if not character.db.Combat_ActionsLeft: # Character has no actions remaining
- self.next_turn()
- return
-
- def join_fight(self, character):
- """
- Adds a new character to a fight already in progress.
-
- Args:
- character (obj): Character to be added to the fight.
- """
- # Inserts the fighter to the turn order, right behind whoever's turn it currently is.
- self.db.fighters.insert(self.db.turn, character)
- # Tick the turn counter forward one to compensate.
- self.db.turn += 1
- # Initialize the character like you do at the start.
- self.initialize_for_combat(character)
+ self.add(CmdCombatHelp())
\ No newline at end of file
diff --git a/evennia/contrib/turnbattle/tb_equip.py b/evennia/contrib/turnbattle/tb_equip.py
new file mode 100644
index 0000000000..b6b7ae6035
--- /dev/null
+++ b/evennia/contrib/turnbattle/tb_equip.py
@@ -0,0 +1,1086 @@
+"""
+Simple turn-based combat system with equipment
+
+Contrib - Tim Ashley Jenkins 2017
+
+This is a version of the 'turnbattle' contrib with a basic system for
+weapons and armor implemented. Weapons can have unique damage ranges
+and accuracy modifiers, while armor can reduce incoming damage and
+change one's chance of getting hit. The 'wield' command is used to
+equip weapons and the 'don' command is used to equip armor.
+
+Some prototypes are included at the end of this module - feel free to
+copy them into your game's prototypes.py module in your 'world' folder
+and create them with the @spawn command. (See the tutorial for using
+the @spawn command for details.)
+
+For the example equipment given, heavier weapons deal more damage
+but are less accurate, while light weapons are more accurate but
+deal less damage. Similarly, heavy armor reduces incoming damage by
+a lot but increases your chance of getting hit, while light armor is
+easier to dodge in but reduces incoming damage less. Light weapons are
+more effective against lightly armored opponents and heavy weapons are
+more damaging against heavily armored foes, but heavy weapons and armor
+are slightly better than light weapons and armor overall.
+
+This is a fairly bare implementation of equipment that is meant to be
+expanded to fit your game - weapon and armor slots, damage types and
+damage bonuses, etc. should be fairly simple to implement according to
+the rules of your preferred system or the needs of your own game.
+
+To install and test, import this module's TBEquipCharacter object into
+your game's character.py module:
+
+ from evennia.contrib.turnbattle.tb_equip import TBEquipCharacter
+
+And change your game's character typeclass to inherit from TBEquipCharacter
+instead of the default:
+
+ class Character(TBEquipCharacter):
+
+Next, import this module into your default_cmdsets.py module:
+
+ from evennia.contrib.turnbattle import tb_equip
+
+And add the battle command set to your default command set:
+
+ #
+ # any commands you add below will overload the default ones.
+ #
+ self.add(tb_equip.BattleCmdSet())
+
+This module is meant to be heavily expanded on, so you may want to copy it
+to your game's 'world' folder and modify it there rather than importing it
+in your game and using it as-is.
+"""
+
+from random import randint
+from evennia import DefaultCharacter, Command, default_cmds, DefaultScript, DefaultObject
+from evennia.commands.default.help import CmdHelp
+
+"""
+----------------------------------------------------------------------------
+OPTIONS
+----------------------------------------------------------------------------
+"""
+
+TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds
+ACTIONS_PER_TURN = 1 # Number of actions allowed per turn
+
+"""
+----------------------------------------------------------------------------
+COMBAT FUNCTIONS START HERE
+----------------------------------------------------------------------------
+"""
+
+def roll_init(character):
+ """
+ Rolls a number between 1-1000 to determine initiative.
+
+ Args:
+ character (obj): The character to determine initiative for
+
+ Returns:
+ initiative (int): The character's place in initiative - higher
+ numbers go first.
+
+ Notes:
+ By default, does not reference the character and simply returns
+ a random integer from 1 to 1000.
+
+ Since the character is passed to this function, you can easily reference
+ a character's stats to determine an initiative roll - for example, if your
+ character has a 'dexterity' attribute, you can use it to give that character
+ an advantage in turn order, like so:
+
+ return (randint(1,20)) + character.db.dexterity
+
+ This way, characters with a higher dexterity will go first more often.
+ """
+ return randint(1, 1000)
+
+
+def get_attack(attacker, defender):
+ """
+ Returns a value for an attack roll.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+
+ Returns:
+ attack_value (int): Attack roll value, compared against a defense value
+ to determine whether an attack hits or misses.
+
+ Notes:
+ In this example, a weapon's accuracy bonus is factored into the attack
+ roll. Lighter weapons are more accurate but less damaging, and heavier
+ weapons are less accurate but deal more damage. Of course, you can
+ change this paradigm completely in your own game.
+ """
+ # Start with a roll from 1 to 100.
+ attack_value = randint(1, 100)
+ accuracy_bonus = 0
+ # If armed, add weapon's accuracy bonus.
+ if attacker.db.wielded_weapon:
+ weapon = attacker.db.wielded_weapon
+ accuracy_bonus += weapon.db.accuracy_bonus
+ # If unarmed, use character's unarmed accuracy bonus.
+ else:
+ accuracy_bonus += attacker.db.unarmed_accuracy
+ # Add the accuracy bonus to the attack roll.
+ attack_value += accuracy_bonus
+ return attack_value
+
+
+def get_defense(attacker, defender):
+ """
+ Returns a value for defense, which an attack roll must equal or exceed in order
+ for an attack to hit.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+
+ Returns:
+ defense_value (int): Defense value, compared against an attack roll
+ to determine whether an attack hits or misses.
+
+ Notes:
+ Characters are given a default defense value of 50 which can be
+ modified up or down by armor. In this example, wearing armor actually
+ makes you a little easier to hit, but reduces incoming damage.
+ """
+ # Start with a defense value of 50 for a 50/50 chance to hit.
+ defense_value = 50
+ # Modify this value based on defender's armor.
+ if defender.db.worn_armor:
+ armor = defender.db.worn_armor
+ defense_value += armor.db.defense_modifier
+ return defense_value
+
+
+def get_damage(attacker, defender):
+ """
+ Returns a value for damage to be deducted from the defender's HP after abilities
+ successful hit.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being damaged
+
+ Returns:
+ damage_value (int): Damage value, which is to be deducted from the defending
+ character's HP.
+
+ Notes:
+ Damage is determined by the attacker's wielded weapon, or the attacker's
+ unarmed damage range if no weapon is wielded. Incoming damage is reduced
+ by the defender's armor.
+ """
+ damage_value = 0
+ # Generate a damage value from wielded weapon if armed
+ if attacker.db.wielded_weapon:
+ weapon = attacker.db.wielded_weapon
+ # Roll between minimum and maximum damage
+ damage_value = randint(weapon.db.damage_range[0], weapon.db.damage_range[1])
+ # Use attacker's unarmed damage otherwise
+ else:
+ damage_value = randint(attacker.db.unarmed_damage_range[0], attacker.db.unarmed_damage_range[1])
+ # If defender is armored, reduce incoming damage
+ if defender.db.worn_armor:
+ armor = defender.db.worn_armor
+ damage_value -= armor.db.damage_reduction
+ # Make sure minimum damage is 0
+ if damage_value < 0:
+ damage_value = 0
+ return damage_value
+
+
+def apply_damage(defender, damage):
+ """
+ Applies damage to a target, reducing their HP by the damage amount to a
+ minimum of 0.
+
+ Args:
+ defender (obj): Character taking damage
+ damage (int): Amount of damage being taken
+ """
+ defender.db.hp -= damage # Reduce defender's HP by the damage dealt.
+ # If this reduces it to 0 or less, set HP to 0.
+ if defender.db.hp <= 0:
+ defender.db.hp = 0
+
+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
+ into a dying state or something similar) then this is the place to
+ do it.
+ """
+ defeated.location.msg_contents("%s has been defeated!" % defeated)
+
+def resolve_attack(attacker, defender, attack_value=None, defense_value=None):
+ """
+ Resolves an attack and outputs the result.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+
+ Notes:
+ Even though the attack and defense values are calculated
+ extremely simply, they are separated out into their own functions
+ so that they are easier to expand upon.
+ """
+ # Get the attacker's weapon type to reference in combat messages.
+ attackers_weapon = "attack"
+ if attacker.db.wielded_weapon:
+ weapon = attacker.db.wielded_weapon
+ attackers_weapon = weapon.db.weapon_type_name
+ # Get an attack roll from the attacker.
+ if not attack_value:
+ attack_value = get_attack(attacker, defender)
+ # Get a defense value from the defender.
+ if not defense_value:
+ defense_value = get_defense(attacker, defender)
+ # If the attack value is lower than the defense value, miss. Otherwise, hit.
+ if attack_value < defense_value:
+ attacker.location.msg_contents("%s's %s misses %s!" % (attacker, attackers_weapon, defender))
+ else:
+ damage_value = get_damage(attacker, defender) # Calculate damage value.
+ # Announce damage dealt and apply damage.
+ if damage_value > 0:
+ attacker.location.msg_contents("%s's %s strikes %s for %i damage!" % (attacker, attackers_weapon, defender, damage_value))
+ else:
+ attacker.location.msg_contents("%s's %s bounces harmlessly off %s!" % (attacker, attackers_weapon, defender))
+ apply_damage(defender, damage_value)
+ # If defender HP is reduced to 0 or less, call at_defeat.
+ if defender.db.hp <= 0:
+ at_defeat(defender)
+
+
+def combat_cleanup(character):
+ """
+ Cleans up all the temporary combat-related attributes on a character.
+
+ Args:
+ character (obj): Character to have their combat attributes removed
+
+ Notes:
+ Any attribute whose key begins with 'combat_' is temporary and no
+ longer needed once a fight ends.
+ """
+ for attr in character.attributes.all():
+ if attr.key[:7] == "combat_": # If the attribute name starts with 'combat_'...
+ character.attributes.remove(key=attr.key) # ...then delete it!
+
+
+def is_in_combat(character):
+ """
+ Returns true if the given character is in combat.
+
+ Args:
+ character (obj): Character to determine if is in combat or not
+
+ Returns:
+ (bool): True if in combat or False if not in combat
+ """
+ return bool(character.db.combat_turnhandler)
+
+
+def is_turn(character):
+ """
+ Returns true if it's currently the given character's turn in combat.
+
+ Args:
+ character (obj): Character to determine if it is their turn or not
+
+ Returns:
+ (bool): True if it is their turn or False otherwise
+ """
+ turnhandler = character.db.combat_turnhandler
+ currentchar = turnhandler.db.fighters[turnhandler.db.turn]
+ return bool(character == currentchar)
+
+
+def spend_action(character, actions, action_name=None):
+ """
+ Spends a character's available combat actions and checks for end of turn.
+
+ Args:
+ character (obj): Character spending the action
+ actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions
+
+ Kwargs:
+ action_name (str or None): If a string is given, sets character's last action in
+ combat to provided string
+ """
+ if action_name:
+ character.db.combat_lastaction = action_name
+ if actions == 'all': # If spending all actions
+ character.db.combat_actionsleft = 0 # Set actions to 0
+ else:
+ character.db.combat_actionsleft -= actions # Use up actions.
+ if character.db.combat_actionsleft < 0:
+ character.db.combat_actionsleft = 0 # Can't have fewer than 0 actions
+ character.db.combat_turnhandler.turn_end_check(character) # Signal potential end of turn.
+
+"""
+----------------------------------------------------------------------------
+SCRIPTS START HERE
+----------------------------------------------------------------------------
+"""
+
+
+class TBEquipTurnHandler(DefaultScript):
+ """
+ This is the script that handles the progression of combat through turns.
+ On creation (when a fight is started) it adds all combat-ready characters
+ to its roster and then sorts them into a turn order. There can only be one
+ fight going on in a single room at a time, so the script is assigned to a
+ room as its object.
+
+ Fights persist until only one participant is left with any HP or all
+ remaining participants choose to end the combat with the 'disengage' command.
+ """
+
+ def at_script_creation(self):
+ """
+ Called once, when the script is created.
+ """
+ self.key = "Combat Turn Handler"
+ self.interval = 5 # Once every 5 seconds
+ self.persistent = True
+ self.db.fighters = []
+
+ # Add all fighters in the room with at least 1 HP to the combat."
+ for thing in self.obj.contents:
+ if thing.db.hp:
+ self.db.fighters.append(thing)
+
+ # Initialize each fighter for combat
+ for fighter in self.db.fighters:
+ self.initialize_for_combat(fighter)
+
+ # Add a reference to this script to the room
+ self.obj.db.combat_turnhandler = self
+
+ # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order.
+ # The initiative roll is determined by the roll_init function and can be customized easily.
+ ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True)
+ self.db.fighters = ordered_by_roll
+
+ # Announce the turn order.
+ self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters))
+
+ # Start first fighter's turn.
+ self.start_turn(self.db.fighters[0])
+
+ # Set up the current turn and turn timeout delay.
+ self.db.turn = 0
+ self.db.timer = TURN_TIMEOUT # Set timer to turn timeout specified in options
+
+ def at_stop(self):
+ """
+ Called at script termination.
+ """
+ for fighter in self.db.fighters:
+ combat_cleanup(fighter) # Clean up the combat attributes for every fighter.
+ self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location
+
+ def at_repeat(self):
+ """
+ Called once every self.interval seconds.
+ """
+ currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order.
+ self.db.timer -= self.interval # Count down the timer.
+
+ if self.db.timer <= 0:
+ # Force current character to disengage if timer runs out.
+ self.obj.msg_contents("%s's turn timed out!" % currentchar)
+ spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions.
+ return
+ elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left
+ # Warn the current character if they're about to time out.
+ currentchar.msg("WARNING: About to time out!")
+ self.db.timeout_warning_given = True
+
+ def initialize_for_combat(self, character):
+ """
+ Prepares a character for combat when starting or entering a fight.
+
+ Args:
+ character (obj): Character to initialize for combat.
+ """
+ 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
+ character.db.combat_turnhandler = 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):
+ """
+ Readies a character for the start of their turn by replenishing their
+ available actions and notifying them that their turn has come up.
+
+ Args:
+ character (obj): Character to be readied.
+
+ Notes:
+ Here, you only get one action per turn, but you might want to allow more than
+ one per turn, or even grant a number of actions based on a character's
+ attributes. You can even add multiple different kinds of actions, I.E. actions
+ separated for movement, by adding "character.db.combat_movesleft = 3" or
+ something similar.
+ """
+ character.db.combat_actionsleft = ACTIONS_PER_TURN # Replenish actions
+ # Prompt the character for their turn and give some information.
+ character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp)
+
+ def next_turn(self):
+ """
+ Advances to the next character in the turn order.
+ """
+
+ # Check to see if every character disengaged as their last action. If so, end combat.
+ disengage_check = True
+ for fighter in self.db.fighters:
+ if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage
+ disengage_check = False
+ if disengage_check: # All characters have disengaged
+ self.obj.msg_contents("All fighters have disengaged! Combat is over!")
+ self.stop() # Stop this script and end combat.
+ return
+
+ # Check to see if only one character is left standing. If so, end combat.
+ defeated_characters = 0
+ for fighter in self.db.fighters:
+ if fighter.db.HP == 0:
+ defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated)
+ if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated
+ for fighter in self.db.fighters:
+ if fighter.db.HP != 0:
+ LastStanding = fighter # Pick the one fighter left with HP remaining
+ self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding)
+ self.stop() # Stop this script and end combat.
+ return
+
+ # Cycle to the next turn.
+ currentchar = self.db.fighters[self.db.turn]
+ self.db.turn += 1 # Go to the next in the turn order.
+ if self.db.turn > len(self.db.fighters) - 1:
+ self.db.turn = 0 # Go back to the first in the turn order once you reach the end.
+ newchar = self.db.fighters[self.db.turn] # Note the new character
+ self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer.
+ self.db.timeout_warning_given = False # Reset the timeout warning.
+ self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar))
+ self.start_turn(newchar) # Start the new character's turn.
+
+ def turn_end_check(self, character):
+ """
+ Tests to see if a character's turn is over, and cycles to the next turn if it is.
+
+ Args:
+ character (obj): Character to test for end of turn
+ """
+ if not character.db.combat_actionsleft: # Character has no actions remaining
+ self.next_turn()
+ return
+
+ def join_fight(self, character):
+ """
+ Adds a new character to a fight already in progress.
+
+ Args:
+ character (obj): Character to be added to the fight.
+ """
+ # Inserts the fighter to the turn order, right behind whoever's turn it currently is.
+ self.db.fighters.insert(self.db.turn, character)
+ # Tick the turn counter forward one to compensate.
+ self.db.turn += 1
+ # Initialize the character like you do at the start.
+ self.initialize_for_combat(character)
+
+"""
+----------------------------------------------------------------------------
+TYPECLASSES START HERE
+----------------------------------------------------------------------------
+"""
+
+class TBEWeapon(DefaultObject):
+ """
+ A weapon which can be wielded in combat with the 'wield' command.
+ """
+ def at_object_creation(self):
+ """
+ Called once, when this object is first created. This is the
+ normal hook to overload for most object types.
+ """
+ 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"
+ def at_drop(self, dropper):
+ """
+ Stop being wielded if dropped.
+ """
+ if dropper.db.wielded_weapon == self:
+ dropper.db.wielded_weapon = None
+ dropper.location.msg_contents("%s stops wielding %s." % (dropper, self))
+ def at_give(self, giver, getter):
+ """
+ Stop being wielded if given.
+ """
+ if giver.db.wielded_weapon == self:
+ giver.db.wielded_weapon = None
+ giver.location.msg_contents("%s stops wielding %s." % (giver, self))
+
+class TBEArmor(DefaultObject):
+ """
+ A set of armor which can be worn with the 'don' command.
+ """
+ def at_object_creation(self):
+ """
+ Called once, when this object is first created. This is the
+ normal hook to overload for most object types.
+ """
+ self.db.damage_reduction = 4 # Amount of incoming damage reduced by armor
+ self.db.defense_modifier = -4 # Amount to modify defense value (pos = harder to hit, neg = easier)
+ def at_before_drop(self, dropper):
+ """
+ Can't drop in combat.
+ """
+ if is_in_combat(dropper):
+ dropper.msg("You can't doff armor in a fight!")
+ return False
+ return True
+ def at_drop(self, dropper):
+ """
+ Stop being wielded if dropped.
+ """
+ if dropper.db.worn_armor == self:
+ dropper.db.worn_armor = None
+ dropper.location.msg_contents("%s removes %s." % (dropper, self))
+ def at_before_give(self, giver, getter):
+ """
+ Can't give away in combat.
+ """
+ if is_in_combat(giver):
+ dropper.msg("You can't doff armor in a fight!")
+ return False
+ return True
+ def at_give(self, giver, getter):
+ """
+ Stop being wielded if given.
+ """
+ if giver.db.worn_armor == self:
+ giver.db.worn_armor = None
+ giver.location.msg_contents("%s removes %s." % (giver, self))
+
+class TBEquipCharacter(DefaultCharacter):
+ """
+ A character able to participate in turn-based combat. Has attributes for current
+ and maximum HP, and access to combat commands.
+ """
+
+ def at_object_creation(self):
+ """
+ Called once, when this object is first created. This is the
+ normal hook to overload for most object types.
+ """
+ self.db.max_hp = 100 # Set maximum HP to 100
+ self.db.hp = self.db.max_hp # Set current HP to maximum
+ self.db.wielded_weapon = None # Currently used weapon
+ self.db.worn_armor = None # Currently worn armor
+ self.db.unarmed_damage_range = (5, 15) # Minimum and maximum unarmed damage
+ self.db.unarmed_accuracy = 30 # Accuracy bonus for unarmed attacks
+
+ """
+ Adds attributes for a character's current and maximum HP.
+ We're just going to set this value at '100' by default.
+
+ You may want to expand this to include various 'stats' that
+ can be changed at creation and factor into combat calculations.
+ """
+
+ def at_before_move(self, destination):
+ """
+ Called just before starting to move this object to
+ destination.
+
+ Args:
+ destination (Object): The object we are moving to
+
+ Returns:
+ shouldmove (bool): If we should move or not.
+
+ Notes:
+ If this method returns False/None, the move is cancelled
+ before it is even started.
+
+ """
+ # Keep the character from moving if at 0 HP or in combat.
+ if is_in_combat(self):
+ self.msg("You can't exit a room while in combat!")
+ return False # Returning false keeps the character from moving.
+ if self.db.HP <= 0:
+ self.msg("You can't move, you've been defeated!")
+ return False
+ return True
+
+
+"""
+----------------------------------------------------------------------------
+COMMANDS START HERE
+----------------------------------------------------------------------------
+"""
+
+
+class CmdFight(Command):
+ """
+ Starts a fight with everyone in the same room as you.
+
+ Usage:
+ fight
+
+ When you start a fight, everyone in the room who is able to
+ fight is added to combat, and a turn order is randomly rolled.
+ When it's your turn, you can attack other characters.
+ """
+ key = "fight"
+ help_category = "combat"
+
+ def func(self):
+ """
+ This performs the actual command.
+ """
+ here = self.caller.location
+ fighters = []
+
+ if not self.caller.db.hp: # If you don't have any hp
+ self.caller.msg("You can't start a fight if you've been defeated!")
+ return
+ if is_in_combat(self.caller): # Already in a fight
+ self.caller.msg("You're already in a fight!")
+ return
+ for thing in here.contents: # Test everything in the room to add it to the fight.
+ if thing.db.HP: # If the object has HP...
+ fighters.append(thing) # ...then add it to the fight.
+ if len(fighters) <= 1: # If you're the only able fighter in the room
+ self.caller.msg("There's nobody here to fight!")
+ return
+ if here.db.combat_turnhandler: # If there's already a fight going on...
+ here.msg_contents("%s joins the fight!" % self.caller)
+ here.db.combat_turnhandler.join_fight(self.caller) # Join the fight!
+ return
+ here.msg_contents("%s starts a fight!" % self.caller)
+ # Add a turn handler script to the room, which starts combat.
+ here.scripts.add("contrib.turnbattle.tb_equip.TBEquipTurnHandler")
+ # Remember you'll have to change the path to the script if you copy this code to your own modules!
+
+
+class CmdAttack(Command):
+ """
+ Attacks another character.
+
+ Usage:
+ attack
+
+ When in a fight, you may attack another character. The attack has
+ a chance to hit, and if successful, will deal damage.
+ """
+
+ key = "attack"
+ help_category = "combat"
+
+ def func(self):
+ "This performs the actual command."
+ "Set the attacker to the caller and the defender to the target."
+
+ if not is_in_combat(self.caller): # If not in combat, can't attack.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ if not is_turn(self.caller): # If it's not your turn, can't attack.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ if not self.caller.db.hp: # Can't attack if you have no HP.
+ self.caller.msg("You can't attack, you've been defeated.")
+ return
+
+ attacker = self.caller
+ defender = self.caller.search(self.args)
+
+ if not defender: # No valid target given.
+ return
+
+ if not defender.db.hp: # Target object has no HP left or to begin with
+ self.caller.msg("You can't fight that!")
+ return
+
+ if attacker == defender: # Target and attacker are the same
+ self.caller.msg("You can't attack yourself!")
+ return
+
+ "If everything checks out, call the attack resolving function."
+ resolve_attack(attacker, defender)
+ spend_action(self.caller, 1, action_name="attack") # Use up one action.
+
+
+class CmdPass(Command):
+ """
+ Passes on your turn.
+
+ Usage:
+ pass
+
+ When in a fight, you can use this command to end your turn early, even
+ if there are still any actions you can take.
+ """
+
+ key = "pass"
+ aliases = ["wait", "hold"]
+ help_category = "combat"
+
+ def func(self):
+ """
+ This performs the actual command.
+ """
+ if not is_in_combat(self.caller): # Can only pass a turn in combat.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ if not is_turn(self.caller): # Can only pass if it's your turn.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ self.caller.location.msg_contents("%s takes no further action, passing the turn." % self.caller)
+ spend_action(self.caller, 'all', action_name="pass") # Spend all remaining actions.
+
+
+class CmdDisengage(Command):
+ """
+ Passes your turn and attempts to end combat.
+
+ Usage:
+ disengage
+
+ Ends your turn early and signals that you're trying to end
+ the fight. If all participants in a fight disengage, the
+ fight ends.
+ """
+
+ key = "disengage"
+ aliases = ["spare"]
+ help_category = "combat"
+
+ def func(self):
+ """
+ This performs the actual command.
+ """
+ if not is_in_combat(self.caller): # If you're not in combat
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ if not is_turn(self.caller): # If it's not your turn
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ self.caller.location.msg_contents("%s disengages, ready to stop fighting." % self.caller)
+ spend_action(self.caller, 'all', action_name="disengage") # Spend all remaining actions.
+ """
+ The action_name kwarg sets the character's last action to "disengage", which is checked by
+ the turn handler script to see if all fighters have disengaged.
+ """
+
+
+class CmdRest(Command):
+ """
+ Recovers damage.
+
+ Usage:
+ rest
+
+ Resting recovers your HP to its maximum, but you can only
+ rest if you're not in a fight.
+ """
+
+ key = "rest"
+ help_category = "combat"
+
+ def func(self):
+ "This performs the actual command."
+
+ if is_in_combat(self.caller): # If you're in combat
+ self.caller.msg("You can't rest while you're in combat.")
+ return
+
+ self.caller.db.hp = self.caller.db.max_hp # Set current HP to maximum
+ self.caller.location.msg_contents("%s rests to recover HP." % self.caller)
+ """
+ You'll probably want to replace this with your own system for recovering HP.
+ """
+
+
+class CmdCombatHelp(CmdHelp):
+ """
+ View help or a list of topics
+
+ Usage:
+ help
+ help list
+ help all
+
+ This will search for help on commands and other
+ topics related to the game.
+ """
+ # Just like the default help command, but will give quick
+ # tips on combat when used in a fight with no arguments.
+
+ def func(self):
+ if is_in_combat(self.caller) and not self.args: # In combat and entered 'help' alone
+ self.caller.msg("Available combat commands:|/" +
+ "|wAttack:|n Attack a target, attempting to deal damage.|/" +
+ "|wPass:|n Pass your turn without further action.|/" +
+ "|wDisengage:|n End your turn and attempt to end combat.|/")
+ else:
+ super(CmdCombatHelp, self).func() # Call the default help command
+
+class CmdWield(Command):
+ """
+ Wield a weapon you are carrying
+
+ Usage:
+ wield
+
+ 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
+ combat will spend your action for your turn. Use the
+ "unwield" command to stop wielding any weapon you are
+ currently wielding.
+ """
+
+ key = "wield"
+ help_category = "combat"
+
+ def func(self):
+ """
+ This performs the actual command.
+ """
+ # If in combat, check to see if it's your turn.
+ if is_in_combat(self.caller):
+ if not is_turn(self.caller):
+ self.caller.msg("You can only do that on your turn.")
+ return
+ if not self.args:
+ self.caller.msg("Usage: wield ")
+ return
+ 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"):
+ self.caller.msg("That's not a weapon!")
+ # Remember to update the path to the weapon typeclass if you move this module!
+ return
+
+ if not self.caller.db.wielded_weapon:
+ self.caller.db.wielded_weapon = weapon
+ self.caller.location.msg_contents("%s wields %s." % (self.caller, weapon))
+ else:
+ old_weapon = self.caller.db.wielded_weapon
+ self.caller.db.wielded_weapon = weapon
+ self.caller.location.msg_contents("%s lowers %s and wields %s." % (self.caller, old_weapon, weapon))
+ # Spend an action if in combat.
+ if is_in_combat(self.caller):
+ spend_action(self.caller, 1, action_name="wield") # Use up one action.
+
+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.
+ """
+
+ key = "unwield"
+ help_category = "combat"
+
+ def func(self):
+ """
+ This performs the actual command.
+ """
+ # If in combat, check to see if it's your turn.
+ if is_in_combat(self.caller):
+ if not is_turn(self.caller):
+ self.caller.msg("You can only do that on your turn.")
+ return
+ if not self.caller.db.wielded_weapon:
+ self.caller.msg("You aren't wielding a weapon!")
+ else:
+ old_weapon = self.caller.db.wielded_weapon
+ self.caller.db.wielded_weapon = None
+ self.caller.location.msg_contents("%s lowers %s." % (self.caller, old_weapon))
+
+class CmdDon(Command):
+ """
+ Don armor that you are carrying
+
+ Usage:
+ don
+
+ Select armor to wear in combat. You can't use this
+ command in the middle of a fight. Use the "doff"
+ command to remove any armor you are wearing.
+ """
+
+ key = "don"
+ help_category = "combat"
+
+ def func(self):
+ """
+ This performs the actual command.
+ """
+ # Can't do this in combat
+ if is_in_combat(self.caller):
+ self.caller.msg("You can't don armor in a fight!")
+ return
+ if not self.args:
+ self.caller.msg("Usage: don ")
+ return
+ 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"):
+ self.caller.msg("That's not armor!")
+ # Remember to update the path to the armor typeclass if you move this module!
+ return
+
+ if not self.caller.db.worn_armor:
+ self.caller.db.worn_armor = armor
+ self.caller.location.msg_contents("%s dons %s." % (self.caller, armor))
+ else:
+ old_armor = self.caller.db.worn_armor
+ self.caller.db.worn_armor = armor
+ self.caller.location.msg_contents("%s removes %s and dons %s." % (self.caller, old_armor, armor))
+
+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.
+ """
+
+ key = "doff"
+ help_category = "combat"
+
+ def func(self):
+ """
+ This performs the actual command.
+ """
+ # Can't do this in combat
+ if is_in_combat(self.caller):
+ self.caller.msg("You can't doff armor in a fight!")
+ return
+ if not self.caller.db.worn_armor:
+ self.caller.msg("You aren't wearing any armor!")
+ else:
+ old_armor = self.caller.db.worn_armor
+ self.caller.db.worn_armor = None
+ self.caller.location.msg_contents("%s removes %s." % (self.caller, old_armor))
+
+
+
+class BattleCmdSet(default_cmds.CharacterCmdSet):
+ """
+ This command set includes all the commmands used in the battle system.
+ """
+ key = "DefaultCharacter"
+
+ def at_cmdset_creation(self):
+ """
+ Populates the cmdset
+ """
+ self.add(CmdFight())
+ self.add(CmdAttack())
+ self.add(CmdRest())
+ self.add(CmdPass())
+ self.add(CmdDisengage())
+ self.add(CmdCombatHelp())
+ self.add(CmdWield())
+ self.add(CmdUnwield())
+ self.add(CmdDon())
+ self.add(CmdDoff())
+
+"""
+----------------------------------------------------------------------------
+PROTOTYPES START HERE
+----------------------------------------------------------------------------
+"""
+
+BASEWEAPON = {
+ "typeclass": "evennia.contrib.turnbattle.tb_equip.TBEWeapon",
+}
+
+BASEARMOR = {
+ "typeclass": "evennia.contrib.turnbattle.tb_equip.TBEArmor",
+}
+
+DAGGER = {
+ "prototype" : "BASEWEAPON",
+ "damage_range" : (10, 20),
+ "accuracy_bonus" : 30,
+ "key": "a thin steel dagger",
+ "weapon_type_name" : "dagger"
+}
+
+BROADSWORD = {
+ "prototype" : "BASEWEAPON",
+ "damage_range" : (15, 30),
+ "accuracy_bonus" : 15,
+ "key": "an iron broadsword",
+ "weapon_type_name" : "broadsword"
+}
+
+GREATSWORD = {
+ "prototype" : "BASEWEAPON",
+ "damage_range" : (20, 40),
+ "accuracy_bonus" : 0,
+ "key": "a rune-etched greatsword",
+ "weapon_type_name" : "greatsword"
+}
+
+LEATHERARMOR = {
+ "prototype" : "BASEARMOR",
+ "damage_reduction" : 2,
+ "defense_modifier" : -2,
+ "key": "a suit of leather armor"
+}
+
+SCALEMAIL = {
+ "prototype" : "BASEARMOR",
+ "damage_reduction" : 4,
+ "defense_modifier" : -4,
+ "key": "a suit of scale mail"
+}
+
+PLATEMAIL = {
+ "prototype" : "BASEARMOR",
+ "damage_reduction" : 6,
+ "defense_modifier" : -6,
+ "key": "a suit of plate mail"
+}
diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py
new file mode 100644
index 0000000000..cfb511b4ad
--- /dev/null
+++ b/evennia/contrib/turnbattle/tb_items.py
@@ -0,0 +1,1397 @@
+"""
+Simple turn-based combat system with items and status effects
+
+Contrib - Tim Ashley Jenkins 2017
+
+This is a version of the 'turnbattle' combat system that includes
+conditions and usable items, which can instill these conditions, cure
+them, or do just about anything else.
+
+Conditions are stored on characters as a dictionary, where the key
+is the name of the condition and the value is a list of two items:
+an integer representing the number of turns left until the condition
+runs out, and the character upon whose turn the condition timer is
+ticked down. Unlike most combat-related attributes, conditions aren't
+wiped once combat ends - if out of combat, they tick down in real time
+instead.
+
+This module includes a number of example conditions:
+
+ Regeneration: Character recovers HP every turn
+ Poisoned: Character loses HP every turn
+ Accuracy Up: +25 to character's attack rolls
+ Accuracy Down: -25 to character's attack rolls
+ Damage Up: +5 to character's damage
+ Damage Down: -5 to character's damage
+ Defense Up: +15 to character's defense
+ Defense Down: -15 to character's defense
+ Haste: +1 action per turn
+ Paralyzed: No actions per turn
+ Frightened: Character can't use the 'attack' command
+
+Since conditions can have a wide variety of effects, their code is
+scattered throughout the other functions wherever they may apply.
+
+Items aren't given any sort of special typeclass - instead, whether or
+not an object counts as an item is determined by its attributes. To make
+an object into an item, it must have the attribute 'item_func', with
+the value given as a callable - this is the function that will be called
+when an item is used. Other properties of the item, such as how many
+uses it has, whether it's destroyed when its uses are depleted, and such
+can be specified on the item as well, but they are optional.
+
+To install and test, import this module's TBItemsCharacter object into
+your game's character.py module:
+
+ from evennia.contrib.turnbattle.tb_items import TBItemsCharacter
+
+And change your game's character typeclass to inherit from TBItemsCharacter
+instead of the default:
+
+ class Character(TBItemsCharacter):
+
+Next, import this module into your default_cmdsets.py module:
+
+ from evennia.contrib.turnbattle import tb_items
+
+And add the battle command set to your default command set:
+
+ #
+ # any commands you add below will overload the default ones.
+ #
+ self.add(tb_items.BattleCmdSet())
+
+This module is meant to be heavily expanded on, so you may want to copy it
+to your game's 'world' folder and modify it there rather than importing it
+in your game and using it as-is.
+"""
+
+from random import randint
+from evennia import DefaultCharacter, Command, default_cmds, DefaultScript
+from evennia.commands.default.muxcommand import MuxCommand
+from evennia.commands.default.help import CmdHelp
+from evennia.prototypes.spawner import spawn
+from evennia import TICKER_HANDLER as tickerhandler
+
+"""
+----------------------------------------------------------------------------
+OPTIONS
+----------------------------------------------------------------------------
+"""
+
+TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds
+ACTIONS_PER_TURN = 1 # Number of actions allowed per turn
+NONCOMBAT_TURN_TIME = 30 # Time per turn count out of combat
+
+# Condition options start here.
+# If you need to make changes to how your conditions work later,
+# it's best to put the easily tweakable values all in one place!
+
+REGEN_RATE = (4, 8) # Min and max HP regen for Regeneration
+POISON_RATE = (4, 8) # Min and max damage for Poisoned
+ACC_UP_MOD = 25 # Accuracy Up attack roll bonus
+ACC_DOWN_MOD = -25 # Accuracy Down attack roll penalty
+DMG_UP_MOD = 5 # Damage Up damage roll bonus
+DMG_DOWN_MOD = -5 # Damage Down damage roll penalty
+DEF_UP_MOD = 15 # Defense Up defense bonus
+DEF_DOWN_MOD = -15 # Defense Down defense penalty
+
+"""
+----------------------------------------------------------------------------
+COMBAT FUNCTIONS START HERE
+----------------------------------------------------------------------------
+"""
+
+def roll_init(character):
+ """
+ Rolls a number between 1-1000 to determine initiative.
+
+ Args:
+ character (obj): The character to determine initiative for
+
+ Returns:
+ initiative (int): The character's place in initiative - higher
+ numbers go first.
+
+ Notes:
+ By default, does not reference the character and simply returns
+ a random integer from 1 to 1000.
+
+ Since the character is passed to this function, you can easily reference
+ a character's stats to determine an initiative roll - for example, if your
+ character has a 'dexterity' attribute, you can use it to give that character
+ an advantage in turn order, like so:
+
+ return (randint(1,20)) + character.db.dexterity
+
+ This way, characters with a higher dexterity will go first more often.
+ """
+ return randint(1, 1000)
+
+
+def get_attack(attacker, defender):
+ """
+ Returns a value for an attack roll.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+
+ Returns:
+ attack_value (int): Attack roll value, compared against a defense value
+ to determine whether an attack hits or misses.
+
+ Notes:
+ This is where conditions affecting attack rolls are applied, as well.
+ Accuracy Up and Accuracy Down are also accounted for in itemfunc_attack(),
+ so that attack items' accuracy is affected as well.
+ """
+ # For this example, just return a random integer up to 100.
+ attack_value = randint(1, 100)
+ # Add to the roll if the attacker has the "Accuracy Up" condition.
+ if "Accuracy Up" in attacker.db.conditions:
+ attack_value += ACC_UP_MOD
+ # Subtract from the roll if the attack has the "Accuracy Down" condition.
+ if "Accuracy Down" in attacker.db.conditions:
+ attack_value += ACC_DOWN_MOD
+ return attack_value
+
+
+def get_defense(attacker, defender):
+ """
+ Returns a value for defense, which an attack roll must equal or exceed in order
+ for an attack to hit.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+
+ Returns:
+ defense_value (int): Defense value, compared against an attack roll
+ to determine whether an attack hits or misses.
+
+ Notes:
+ This is where conditions affecting defense are accounted for.
+ """
+ # For this example, just return 50, for about a 50/50 chance of hit.
+ defense_value = 50
+ # Add to defense if the defender has the "Defense Up" condition.
+ if "Defense Up" in defender.db.conditions:
+ defense_value += DEF_UP_MOD
+ # Subtract from defense if the defender has the "Defense Down" condition.
+ if "Defense Down" in defender.db.conditions:
+ defense_value += DEF_DOWN_MOD
+ return defense_value
+
+
+def get_damage(attacker, defender):
+ """
+ Returns a value for damage to be deducted from the defender's HP after abilities
+ successful hit.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being damaged
+
+ Returns:
+ damage_value (int): Damage value, which is to be deducted from the defending
+ character's HP.
+
+ Notes:
+ This is where conditions affecting damage are accounted for. Since attack items
+ roll their own damage in itemfunc_attack(), their damage is unaffected by any
+ conditions.
+ """
+ # For this example, just generate a number between 15 and 25.
+ damage_value = randint(15, 25)
+ # Add to damage roll if attacker has the "Damage Up" condition.
+ if "Damage Up" in attacker.db.conditions:
+ damage_value += DMG_UP_MOD
+ # Subtract from the roll if the attacker has the "Damage Down" condition.
+ if "Damage Down" in attacker.db.conditions:
+ damage_value += DMG_DOWN_MOD
+ return damage_value
+
+
+def apply_damage(defender, damage):
+ """
+ Applies damage to a target, reducing their HP by the damage amount to a
+ minimum of 0.
+
+ Args:
+ defender (obj): Character taking damage
+ damage (int): Amount of damage being taken
+ """
+ defender.db.hp -= damage # Reduce defender's HP by the damage dealt.
+ # If this reduces it to 0 or less, set HP to 0.
+ if defender.db.hp <= 0:
+ defender.db.hp = 0
+
+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
+ into a dying state or something similar) then this is the place to
+ do it.
+ """
+ defeated.location.msg_contents("%s has been defeated!" % defeated)
+
+def resolve_attack(attacker, defender, attack_value=None, defense_value=None,
+ damage_value=None, inflict_condition=[]):
+ """
+ Resolves an attack and outputs the result.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+
+ Options:
+ attack_value (int): Override for attack roll
+ defense_value (int): Override for defense value
+ damage_value (int): Override for damage value
+ inflict_condition (list): Conditions to inflict upon hit, a
+ list of tuples formated as (condition(str), duration(int))
+
+ Notes:
+ This function is called by normal attacks as well as attacks
+ made with items.
+ """
+ # Get an attack roll from the attacker.
+ if not attack_value:
+ attack_value = get_attack(attacker, defender)
+ # Get a defense value from the defender.
+ if not defense_value:
+ defense_value = get_defense(attacker, defender)
+ # If the attack value is lower than the defense value, miss. Otherwise, hit.
+ if attack_value < defense_value:
+ attacker.location.msg_contents("%s's attack misses %s!" % (attacker, defender))
+ else:
+ if not damage_value:
+ damage_value = get_damage(attacker, defender) # Calculate damage value.
+ # Announce damage dealt and apply damage.
+ attacker.location.msg_contents("%s hits %s for %i damage!" % (attacker, defender, damage_value))
+ apply_damage(defender, damage_value)
+ # Inflict conditions on hit, if any specified
+ for condition in inflict_condition:
+ add_condition(defender, attacker, condition[0], condition[1])
+ # If defender HP is reduced to 0 or less, call at_defeat.
+ if defender.db.hp <= 0:
+ at_defeat(defender)
+
+def combat_cleanup(character):
+ """
+ Cleans up all the temporary combat-related attributes on a character.
+
+ Args:
+ character (obj): Character to have their combat attributes removed
+
+ Notes:
+ Any attribute whose key begins with 'combat_' is temporary and no
+ longer needed once a fight ends.
+ """
+ for attr in character.attributes.all():
+ if attr.key[:7] == "combat_": # If the attribute name starts with 'combat_'...
+ character.attributes.remove(key=attr.key) # ...then delete it!
+
+
+def is_in_combat(character):
+ """
+ Returns true if the given character is in combat.
+
+ Args:
+ character (obj): Character to determine if is in combat or not
+
+ Returns:
+ (bool): True if in combat or False if not in combat
+ """
+ return bool(character.db.combat_turnhandler)
+
+
+def is_turn(character):
+ """
+ Returns true if it's currently the given character's turn in combat.
+
+ Args:
+ character (obj): Character to determine if it is their turn or not
+
+ Returns:
+ (bool): True if it is their turn or False otherwise
+ """
+ turnhandler = character.db.combat_turnhandler
+ currentchar = turnhandler.db.fighters[turnhandler.db.turn]
+ return bool(character == currentchar)
+
+
+def spend_action(character, actions, action_name=None):
+ """
+ Spends a character's available combat actions and checks for end of turn.
+
+ Args:
+ character (obj): Character spending the action
+ actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions
+
+ Kwargs:
+ action_name (str or None): If a string is given, sets character's last action in
+ combat to provided string
+ """
+ if action_name:
+ character.db.combat_lastaction = action_name
+ if actions == 'all': # If spending all actions
+ character.db.combat_actionsleft = 0 # Set actions to 0
+ else:
+ character.db.combat_actionsleft -= actions # Use up actions.
+ if character.db.combat_actionsleft < 0:
+ character.db.combat_actionsleft = 0 # Can't have fewer than 0 actions
+ character.db.combat_turnhandler.turn_end_check(character) # Signal potential end of turn.
+
+def spend_item_use(item, user):
+ """
+ Spends one use on an item with limited uses.
+
+ Args:
+ item (obj): Item being used
+ user (obj): Character using the item
+
+ Notes:
+ If item.db.item_consumable is 'True', the item is destroyed if it
+ runs out of uses - if it's a string instead of 'True', it will also
+ spawn a new object as residue, using the value of item.db.item_consumable
+ as the name of the prototype to spawn.
+ """
+ item.db.item_uses -= 1 # Spend one use
+
+ if item.db.item_uses > 0: # Has uses remaining
+ # Inform the player
+ user.msg("%s has %i uses remaining." % (item.key.capitalize(), item.db.item_uses))
+
+ else: # All uses spent
+
+ if not item.db.item_consumable: # Item isn't consumable
+ # Just inform the player that the uses are gone
+ user.msg("%s has no uses remaining." % item.key.capitalize())
+
+ else: # If item is consumable
+ if item.db.item_consumable == True: # If the value is 'True', just destroy the item
+ user.msg("%s has been consumed." % item.key.capitalize())
+ item.delete() # Delete the spent item
+
+ else: # If a string, use value of item_consumable to spawn an object in its place
+ residue = spawn({"prototype":item.db.item_consumable})[0] # Spawn the residue
+ residue.location = item.location # Move the residue to the same place as the item
+ user.msg("After using %s, you are left with %s." % (item, residue))
+ item.delete() # Delete the spent item
+
+def use_item(user, item, target):
+ """
+ Performs the action of using an item.
+
+ Args:
+ user (obj): Character using the item
+ item (obj): Item being used
+ target (obj): Target of the item use
+ """
+ # If item is self only and no target given, set target to self.
+ if item.db.item_selfonly and target == None:
+ target = user
+
+ # If item is self only, abort use if used on others.
+ if item.db.item_selfonly and user != target:
+ user.msg("%s can only be used on yourself." % item)
+ return
+
+ # Set kwargs to pass to item_func
+ kwargs = {}
+ if item.db.item_kwargs:
+ kwargs = item.db.item_kwargs
+
+ # Match item_func string to function
+ try:
+ item_func = ITEMFUNCS[item.db.item_func]
+ except KeyError: # If item_func string doesn't match to a function in ITEMFUNCS
+ user.msg("ERROR: %s not defined in ITEMFUNCS" % item.db.item_func)
+ return
+
+ # Call the item function - abort if it returns False, indicating an error.
+ # This performs the actual action of using the item.
+ # Regardless of what the function returns (if anything), it's still executed.
+ if item_func(item, user, target, **kwargs) == False:
+ return
+
+ # If we haven't returned yet, we assume the item was used successfully.
+ # Spend one use if item has limited uses
+ if item.db.item_uses:
+ spend_item_use(item, user)
+
+ # Spend an action if in combat
+ if is_in_combat(user):
+ spend_action(user, 1, action_name="item")
+
+def condition_tickdown(character, turnchar):
+ """
+ Ticks down the duration of conditions on a character at the start of a given character's turn.
+
+ Args:
+ character (obj): Character to tick down the conditions of
+ turnchar (obj): Character whose turn it currently is
+
+ Notes:
+ In combat, this is called on every fighter at the start of every character's turn. Out of
+ combat, it's instead called when a character's at_update() hook is called, which is every
+ 30 seconds by default.
+ """
+
+ for key in character.db.conditions:
+ # The first value is the remaining turns - the second value is whose turn to count down on.
+ condition_duration = character.db.conditions[key][0]
+ condition_turnchar = character.db.conditions[key][1]
+ # If the duration is 'True', then the condition doesn't tick down - it lasts indefinitely.
+ if not condition_duration is True:
+ # Count down if the given turn character matches the condition's turn character.
+ if condition_turnchar == turnchar:
+ character.db.conditions[key][0] -= 1
+ if character.db.conditions[key][0] <= 0:
+ # If the duration is brought down to 0, remove the condition and inform everyone.
+ character.location.msg_contents("%s no longer has the '%s' condition." % (str(character), str(key)))
+ del character.db.conditions[key]
+
+def add_condition(character, turnchar, condition, duration):
+ """
+ Adds a condition to a fighter.
+
+ Args:
+ character (obj): Character to give the condition to
+ turnchar (obj): Character whose turn to tick down the condition on in combat
+ condition (str): Name of the condition
+ duration (int or True): Number of turns the condition lasts, or True for indefinite
+ """
+ # The first value is the remaining turns - the second value is whose turn to count down on.
+ character.db.conditions.update({condition:[duration, turnchar]})
+ # Tell everyone!
+ character.location.msg_contents("%s gains the '%s' condition." % (character, condition))
+
+"""
+----------------------------------------------------------------------------
+CHARACTER TYPECLASS
+----------------------------------------------------------------------------
+"""
+
+
+class TBItemsCharacter(DefaultCharacter):
+ """
+ A character able to participate in turn-based combat. Has attributes for current
+ and maximum HP, and access to combat commands.
+ """
+
+ def at_object_creation(self):
+ """
+ Called once, when this object is first created. This is the
+ normal hook to overload for most object types.
+ """
+ self.db.max_hp = 100 # Set maximum HP to 100
+ self.db.hp = self.db.max_hp # Set current HP to maximum
+ self.db.conditions = {} # Set empty dict for conditions
+ # Subscribe character to the ticker handler
+ tickerhandler.add(NONCOMBAT_TURN_TIME, self.at_update, idstring="update")
+ """
+ Adds attributes for a character's current and maximum HP.
+ We're just going to set this value at '100' by default.
+
+ An empty dictionary is created to store conditions later,
+ and the character is subscribed to the Ticker Handler, which
+ will call at_update() on the character, with the interval
+ specified by NONCOMBAT_TURN_TIME above. This is used to tick
+ down conditions out of combat.
+
+ You may want to expand this to include various 'stats' that
+ can be changed at creation and factor into combat calculations.
+ """
+
+ def at_before_move(self, destination):
+ """
+ Called just before starting to move this object to
+ destination.
+
+ Args:
+ destination (Object): The object we are moving to
+
+ Returns:
+ shouldmove (bool): If we should move or not.
+
+ Notes:
+ If this method returns False/None, the move is cancelled
+ before it is even started.
+
+ """
+ # Keep the character from moving if at 0 HP or in combat.
+ if is_in_combat(self):
+ self.msg("You can't exit a room while in combat!")
+ return False # Returning false keeps the character from moving.
+ if self.db.HP <= 0:
+ self.msg("You can't move, you've been defeated!")
+ return False
+ return True
+
+ def at_turn_start(self):
+ """
+ Hook called at the beginning of this character's turn in combat.
+ """
+ # Prompt the character for their turn and give some information.
+ self.msg("|wIt's your turn! You have %i HP remaining.|n" % self.db.hp)
+
+ # Apply conditions that fire at the start of each turn.
+ self.apply_turn_conditions()
+
+ def apply_turn_conditions(self):
+ """
+ Applies the effect of conditions that occur at the start of each
+ turn in combat, or every 30 seconds out of combat.
+ """
+ # Regeneration: restores 4 to 8 HP at the start of character's turn
+ if "Regeneration" in self.db.conditions:
+ to_heal = randint(REGEN_RATE[0], REGEN_RAGE[1]) # Restore HP
+ if self.db.hp + to_heal > self.db.max_hp:
+ to_heal = self.db.max_hp - self.db.hp # Cap healing to max HP
+ self.db.hp += to_heal
+ self.location.msg_contents("%s regains %i HP from Regeneration." % (self, to_heal))
+
+ # Poisoned: does 4 to 8 damage at the start of character's turn
+ if "Poisoned" in self.db.conditions:
+ to_hurt = randint(POISON_RATE[0], POISON_RATE[1]) # Deal damage
+ apply_damage(self, to_hurt)
+ self.location.msg_contents("%s takes %i damage from being Poisoned." % (self, to_hurt))
+ if self.db.hp <= 0:
+ # Call at_defeat if poison defeats the character
+ at_defeat(self)
+
+ # Haste: Gain an extra action in combat.
+ if is_in_combat(self) and "Haste" in self.db.conditions:
+ self.db.combat_actionsleft += 1
+ self.msg("You gain an extra action this turn from Haste!")
+
+ # Paralyzed: Have no actions in combat.
+ if is_in_combat(self) and "Paralyzed" in self.db.conditions:
+ self.db.combat_actionsleft = 0
+ self.location.msg_contents("%s is Paralyzed, and can't act this turn!" % self)
+ self.db.combat_turnhandler.turn_end_check(self)
+
+ def at_update(self):
+ """
+ Fires every 30 seconds.
+ """
+ if not is_in_combat(self): # Not in combat
+ # Change all conditions to update on character's turn.
+ for key in self.db.conditions:
+ self.db.conditions[key][1] = self
+ # Apply conditions that fire every turn
+ self.apply_turn_conditions()
+ # Tick down condition durations
+ condition_tickdown(self, self)
+
+class TBItemsCharacterTest(TBItemsCharacter):
+ """
+ Just like the TBItemsCharacter, but doesn't subscribe to the TickerHandler.
+ This makes it easier to run unit tests on.
+ """
+ def at_object_creation(self):
+ self.db.max_hp = 100 # Set maximum HP to 100
+ self.db.hp = self.db.max_hp # Set current HP to maximum
+ self.db.conditions = {} # Set empty dict for conditions
+
+
+"""
+----------------------------------------------------------------------------
+SCRIPTS START HERE
+----------------------------------------------------------------------------
+"""
+
+
+class TBItemsTurnHandler(DefaultScript):
+ """
+ This is the script that handles the progression of combat through turns.
+ On creation (when a fight is started) it adds all combat-ready characters
+ to its roster and then sorts them into a turn order. There can only be one
+ fight going on in a single room at a time, so the script is assigned to a
+ room as its object.
+
+ Fights persist until only one participant is left with any HP or all
+ remaining participants choose to end the combat with the 'disengage' command.
+ """
+
+ def at_script_creation(self):
+ """
+ Called once, when the script is created.
+ """
+ self.key = "Combat Turn Handler"
+ self.interval = 5 # Once every 5 seconds
+ self.persistent = True
+ self.db.fighters = []
+
+ # Add all fighters in the room with at least 1 HP to the combat."
+ for thing in self.obj.contents:
+ if thing.db.hp:
+ self.db.fighters.append(thing)
+
+ # Initialize each fighter for combat
+ for fighter in self.db.fighters:
+ self.initialize_for_combat(fighter)
+
+ # Add a reference to this script to the room
+ self.obj.db.combat_turnhandler = self
+
+ # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order.
+ # The initiative roll is determined by the roll_init function and can be customized easily.
+ ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True)
+ self.db.fighters = ordered_by_roll
+
+ # Announce the turn order.
+ self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters))
+
+ # Start first fighter's turn.
+ self.start_turn(self.db.fighters[0])
+
+ # Set up the current turn and turn timeout delay.
+ self.db.turn = 0
+ self.db.timer = TURN_TIMEOUT # Set timer to turn timeout specified in options
+
+ def at_stop(self):
+ """
+ Called at script termination.
+ """
+ for fighter in self.db.fighters:
+ combat_cleanup(fighter) # Clean up the combat attributes for every fighter.
+ self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location
+
+ def at_repeat(self):
+ """
+ Called once every self.interval seconds.
+ """
+ currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order.
+ self.db.timer -= self.interval # Count down the timer.
+
+ if self.db.timer <= 0:
+ # Force current character to disengage if timer runs out.
+ self.obj.msg_contents("%s's turn timed out!" % currentchar)
+ spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions.
+ return
+ elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left
+ # Warn the current character if they're about to time out.
+ currentchar.msg("WARNING: About to time out!")
+ self.db.timeout_warning_given = True
+
+ def initialize_for_combat(self, character):
+ """
+ Prepares a character for combat when starting or entering a fight.
+
+ Args:
+ character (obj): Character to initialize for combat.
+ """
+ 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
+ character.db.combat_turnhandler = 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):
+ """
+ Readies a character for the start of their turn by replenishing their
+ available actions and notifying them that their turn has come up.
+
+ Args:
+ character (obj): Character to be readied.
+
+ Notes:
+ Here, you only get one action per turn, but you might want to allow more than
+ one per turn, or even grant a number of actions based on a character's
+ attributes. You can even add multiple different kinds of actions, I.E. actions
+ separated for movement, by adding "character.db.combat_movesleft = 3" or
+ something similar.
+ """
+ character.db.combat_actionsleft = ACTIONS_PER_TURN # Replenish actions
+ # Call character's at_turn_start() hook.
+ character.at_turn_start()
+
+ def next_turn(self):
+ """
+ Advances to the next character in the turn order.
+ """
+
+ # Check to see if every character disengaged as their last action. If so, end combat.
+ disengage_check = True
+ for fighter in self.db.fighters:
+ if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage
+ disengage_check = False
+ if disengage_check: # All characters have disengaged
+ self.obj.msg_contents("All fighters have disengaged! Combat is over!")
+ self.stop() # Stop this script and end combat.
+ return
+
+ # Check to see if only one character is left standing. If so, end combat.
+ defeated_characters = 0
+ for fighter in self.db.fighters:
+ if fighter.db.HP == 0:
+ defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated)
+ if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated
+ for fighter in self.db.fighters:
+ if fighter.db.HP != 0:
+ LastStanding = fighter # Pick the one fighter left with HP remaining
+ self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding)
+ self.stop() # Stop this script and end combat.
+ return
+
+ # Cycle to the next turn.
+ currentchar = self.db.fighters[self.db.turn]
+ self.db.turn += 1 # Go to the next in the turn order.
+ if self.db.turn > len(self.db.fighters) - 1:
+ self.db.turn = 0 # Go back to the first in the turn order once you reach the end.
+
+ newchar = self.db.fighters[self.db.turn] # Note the new character
+
+ self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer.
+ self.db.timeout_warning_given = False # Reset the timeout warning.
+ self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar))
+ self.start_turn(newchar) # Start the new character's turn.
+
+ # Count down condition timers.
+ for fighter in self.db.fighters:
+ condition_tickdown(fighter, newchar)
+
+ def turn_end_check(self, character):
+ """
+ Tests to see if a character's turn is over, and cycles to the next turn if it is.
+
+ Args:
+ character (obj): Character to test for end of turn
+ """
+ if not character.db.combat_actionsleft: # Character has no actions remaining
+ self.next_turn()
+ return
+
+ def join_fight(self, character):
+ """
+ Adds a new character to a fight already in progress.
+
+ Args:
+ character (obj): Character to be added to the fight.
+ """
+ # Inserts the fighter to the turn order, right behind whoever's turn it currently is.
+ self.db.fighters.insert(self.db.turn, character)
+ # Tick the turn counter forward one to compensate.
+ self.db.turn += 1
+ # Initialize the character like you do at the start.
+ self.initialize_for_combat(character)
+
+
+"""
+----------------------------------------------------------------------------
+COMMANDS START HERE
+----------------------------------------------------------------------------
+"""
+
+
+class CmdFight(Command):
+ """
+ Starts a fight with everyone in the same room as you.
+
+ Usage:
+ fight
+
+ When you start a fight, everyone in the room who is able to
+ fight is added to combat, and a turn order is randomly rolled.
+ When it's your turn, you can attack other characters.
+ """
+ key = "fight"
+ help_category = "combat"
+
+ def func(self):
+ """
+ This performs the actual command.
+ """
+ here = self.caller.location
+ fighters = []
+
+ if not self.caller.db.hp: # If you don't have any hp
+ self.caller.msg("You can't start a fight if you've been defeated!")
+ return
+ if is_in_combat(self.caller): # Already in a fight
+ self.caller.msg("You're already in a fight!")
+ return
+ for thing in here.contents: # Test everything in the room to add it to the fight.
+ if thing.db.HP: # If the object has HP...
+ fighters.append(thing) # ...then add it to the fight.
+ if len(fighters) <= 1: # If you're the only able fighter in the room
+ self.caller.msg("There's nobody here to fight!")
+ return
+ if here.db.combat_turnhandler: # If there's already a fight going on...
+ here.msg_contents("%s joins the fight!" % self.caller)
+ here.db.combat_turnhandler.join_fight(self.caller) # Join the fight!
+ return
+ here.msg_contents("%s starts a fight!" % self.caller)
+ # Add a turn handler script to the room, which starts combat.
+ here.scripts.add("contrib.turnbattle.tb_items.TBItemsTurnHandler")
+ # Remember you'll have to change the path to the script if you copy this code to your own modules!
+
+
+class CmdAttack(Command):
+ """
+ Attacks another character.
+
+ Usage:
+ attack
+
+ When in a fight, you may attack another character. The attack has
+ a chance to hit, and if successful, will deal damage.
+ """
+
+ key = "attack"
+ help_category = "combat"
+
+ def func(self):
+ "This performs the actual command."
+ "Set the attacker to the caller and the defender to the target."
+
+ if not is_in_combat(self.caller): # If not in combat, can't attack.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ if not is_turn(self.caller): # If it's not your turn, can't attack.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ if not self.caller.db.hp: # Can't attack if you have no HP.
+ self.caller.msg("You can't attack, you've been defeated.")
+ return
+
+ if "Frightened" in self.caller.db.conditions: # Can't attack if frightened
+ self.caller.msg("You're too frightened to attack!")
+ return
+
+ attacker = self.caller
+ defender = self.caller.search(self.args)
+
+ if not defender: # No valid target given.
+ return
+
+ if not defender.db.hp: # Target object has no HP left or to begin with
+ self.caller.msg("You can't fight that!")
+ return
+
+ if attacker == defender: # Target and attacker are the same
+ self.caller.msg("You can't attack yourself!")
+ return
+
+ "If everything checks out, call the attack resolving function."
+ resolve_attack(attacker, defender)
+ spend_action(self.caller, 1, action_name="attack") # Use up one action.
+
+
+class CmdPass(Command):
+ """
+ Passes on your turn.
+
+ Usage:
+ pass
+
+ When in a fight, you can use this command to end your turn early, even
+ if there are still any actions you can take.
+ """
+
+ key = "pass"
+ aliases = ["wait", "hold"]
+ help_category = "combat"
+
+ def func(self):
+ """
+ This performs the actual command.
+ """
+ if not is_in_combat(self.caller): # Can only pass a turn in combat.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ if not is_turn(self.caller): # Can only pass if it's your turn.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ self.caller.location.msg_contents("%s takes no further action, passing the turn." % self.caller)
+ spend_action(self.caller, 'all', action_name="pass") # Spend all remaining actions.
+
+
+class CmdDisengage(Command):
+ """
+ Passes your turn and attempts to end combat.
+
+ Usage:
+ disengage
+
+ Ends your turn early and signals that you're trying to end
+ the fight. If all participants in a fight disengage, the
+ fight ends.
+ """
+
+ key = "disengage"
+ aliases = ["spare"]
+ help_category = "combat"
+
+ def func(self):
+ """
+ This performs the actual command.
+ """
+ if not is_in_combat(self.caller): # If you're not in combat
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ if not is_turn(self.caller): # If it's not your turn
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ self.caller.location.msg_contents("%s disengages, ready to stop fighting." % self.caller)
+ spend_action(self.caller, 'all', action_name="disengage") # Spend all remaining actions.
+ """
+ The action_name kwarg sets the character's last action to "disengage", which is checked by
+ the turn handler script to see if all fighters have disengaged.
+ """
+
+
+class CmdRest(Command):
+ """
+ Recovers damage.
+
+ Usage:
+ rest
+
+ Resting recovers your HP to its maximum, but you can only
+ rest if you're not in a fight.
+ """
+
+ key = "rest"
+ help_category = "combat"
+
+ def func(self):
+ "This performs the actual command."
+
+ if is_in_combat(self.caller): # If you're in combat
+ self.caller.msg("You can't rest while you're in combat.")
+ return
+
+ self.caller.db.hp = self.caller.db.max_hp # Set current HP to maximum
+ self.caller.location.msg_contents("%s rests to recover HP." % self.caller)
+ """
+ You'll probably want to replace this with your own system for recovering HP.
+ """
+
+
+class CmdCombatHelp(CmdHelp):
+ """
+ View help or a list of topics
+
+ Usage:
+ help
+ help list
+ help all
+
+ This will search for help on commands and other
+ topics related to the game.
+ """
+ # Just like the default help command, but will give quick
+ # tips on combat when used in a fight with no arguments.
+
+ def func(self):
+ if is_in_combat(self.caller) and not self.args: # In combat and entered 'help' alone
+ self.caller.msg("Available combat commands:|/" +
+ "|wAttack:|n Attack a target, attempting to deal damage.|/" +
+ "|wPass:|n Pass your turn without further action.|/" +
+ "|wDisengage:|n End your turn and attempt to end combat.|/" +
+ "|wUse:|n Use an item you're carrying.")
+ else:
+ super(CmdCombatHelp, self).func() # Call the default help command
+
+
+class CmdUse(MuxCommand):
+ """
+ Use an item.
+
+ Usage:
+ use - [= target]
+
+ An item can have various function - looking at the item may
+ provide information as to its effects. Some items can be used
+ to attack others, and as such can only be used in combat.
+ """
+
+ key = "use"
+ help_category = "combat"
+
+ def func(self):
+ """
+ This performs the actual command.
+ """
+ # Search for item
+ item = self.caller.search(self.lhs, candidates=self.caller.contents)
+ if not item:
+ return
+
+ # Search for target, if any is given
+ target = None
+ if self.rhs:
+ target = self.caller.search(self.rhs)
+ if not target:
+ return
+
+ # If in combat, can only use items on your turn
+ if is_in_combat(self.caller):
+ if not is_turn(self.caller):
+ self.caller.msg("You can only use items on your turn.")
+ return
+
+ if not item.db.item_func: # Object has no item_func, not usable
+ self.caller.msg("'%s' is not a usable item." % item.key.capitalize())
+ return
+
+ if item.attributes.has("item_uses"): # Item has limited uses
+ if item.db.item_uses <= 0: # Limited uses are spent
+ self.caller.msg("'%s' has no uses remaining." % item.key.capitalize())
+ return
+
+ # If everything checks out, call the use_item function
+ use_item(self.caller, item, target)
+
+
+class BattleCmdSet(default_cmds.CharacterCmdSet):
+ """
+ This command set includes all the commmands used in the battle system.
+ """
+ key = "DefaultCharacter"
+
+ def at_cmdset_creation(self):
+ """
+ Populates the cmdset
+ """
+ self.add(CmdFight())
+ self.add(CmdAttack())
+ self.add(CmdRest())
+ self.add(CmdPass())
+ self.add(CmdDisengage())
+ self.add(CmdCombatHelp())
+ self.add(CmdUse())
+
+"""
+----------------------------------------------------------------------------
+ITEM FUNCTIONS START HERE
+----------------------------------------------------------------------------
+
+These functions carry out the action of using an item - every item should
+contain a db entry "item_func", with its value being a string that is
+matched to one of these functions in the ITEMFUNCS dictionary below.
+
+Every item function must take the following arguments:
+ item (obj): The item being used
+ user (obj): The character using the item
+ target (obj): The target of the item use
+
+Item functions must also accept **kwargs - these keyword arguments can be
+used to define how different items that use the same function can have
+different effects (for example, different attack items doing different
+amounts of damage).
+
+Each function below contains a description of what kwargs the function will
+take and the effect they have on the result.
+"""
+
+def itemfunc_heal(item, user, target, **kwargs):
+ """
+ Item function that heals HP.
+
+ kwargs:
+ min_healing(int): Minimum amount of HP recovered
+ max_healing(int): Maximum amount of HP recovered
+ """
+ if not target:
+ target = user # Target user if none specified
+
+ if not target.attributes.has("max_hp"): # Has no HP to speak of
+ user.msg("You can't use %s on that." % item)
+ return False # Returning false aborts the item use
+
+ if target.db.hp >= target.db.max_hp:
+ user.msg("%s is already at full health." % target)
+ return False
+
+ min_healing = 20
+ max_healing = 40
+
+ # Retrieve healing range from kwargs, if present
+ if "healing_range" in kwargs:
+ min_healing = kwargs["healing_range"][0]
+ max_healing = kwargs["healing_range"][1]
+
+ to_heal = randint(min_healing, max_healing) # Restore 20 to 40 hp
+ if target.db.hp + to_heal > target.db.max_hp:
+ to_heal = target.db.max_hp - target.db.hp # Cap healing to max HP
+ target.db.hp += to_heal
+
+ user.location.msg_contents("%s uses %s! %s regains %i HP!" % (user, item, target, to_heal))
+
+def itemfunc_add_condition(item, user, target, **kwargs):
+ """
+ Item function that gives the target one or more conditions.
+
+ kwargs:
+ conditions (list): Conditions added by the item
+ formatted as a list of tuples: (condition (str), duration (int or True))
+
+ Notes:
+ Should mostly be used for beneficial conditions - use itemfunc_attack
+ for an item that can give an enemy a harmful condition.
+ """
+ conditions = [("Regeneration", 5)]
+
+ if not target:
+ target = user # Target user if none specified
+
+ if not target.attributes.has("max_hp"): # Is not a fighter
+ user.msg("You can't use %s on that." % item)
+ return False # Returning false aborts the item use
+
+ # Retrieve condition / duration from kwargs, if present
+ if "conditions" in kwargs:
+ conditions = kwargs["conditions"]
+
+ user.location.msg_contents("%s uses %s!" % (user, item))
+
+ # Add conditions to the target
+ for condition in conditions:
+ add_condition(target, user, condition[0], condition[1])
+
+def itemfunc_cure_condition(item, user, target, **kwargs):
+ """
+ Item function that'll remove given conditions from a target.
+
+ kwargs:
+ to_cure(list): List of conditions (str) that the item cures when used
+ """
+ to_cure = ["Poisoned"]
+
+ if not target:
+ target = user # Target user if none specified
+
+ if not target.attributes.has("max_hp"): # Is not a fighter
+ user.msg("You can't use %s on that." % item)
+ return False # Returning false aborts the item use
+
+ # Retrieve condition(s) to cure from kwargs, if present
+ if "to_cure" in kwargs:
+ to_cure = kwargs["to_cure"]
+
+ item_msg = "%s uses %s! " % (user, item)
+
+ for key in target.db.conditions:
+ if key in to_cure:
+ # If condition specified in to_cure, remove it.
+ item_msg += "%s no longer has the '%s' condition. " % (str(target), str(key))
+ del target.db.conditions[key]
+
+ user.location.msg_contents(item_msg)
+
+def itemfunc_attack(item, user, target, **kwargs):
+ """
+ Item function that attacks a target.
+
+ kwargs:
+ min_damage(int): Minimum damage dealt by the attack
+ max_damage(int): Maximum damage dealth by the attack
+ accuracy(int): Bonus / penalty to attack accuracy roll
+ inflict_condition(list): List of conditions inflicted on hit,
+ formatted as a (str, int) tuple containing condition name
+ and duration.
+
+ Notes:
+ Calls resolve_attack at the end.
+ """
+ if not is_in_combat(user):
+ user.msg("You can only use that in combat.")
+ return False # Returning false aborts the item use
+
+ if not target:
+ user.msg("You have to specify a target to use %s! (use
- =
)" % item)
+ return False
+
+ if target == user:
+ user.msg("You can't attack yourself!")
+ return False
+
+ if not target.db.hp: # Has no HP
+ user.msg("You can't use %s on that." % item)
+ return False
+
+ min_damage = 20
+ max_damage = 40
+ accuracy = 0
+ inflict_condition = []
+
+ # Retrieve values from kwargs, if present
+ if "damage_range" in kwargs:
+ min_damage = kwargs["damage_range"][0]
+ max_damage = kwargs["damage_range"][1]
+ if "accuracy" in kwargs:
+ accuracy = kwargs["accuracy"]
+ if "inflict_condition" in kwargs:
+ inflict_condition = kwargs["inflict_condition"]
+
+ # Roll attack and damage
+ attack_value = randint(1, 100) + accuracy
+ damage_value = randint(min_damage, max_damage)
+
+ # Account for "Accuracy Up" and "Accuracy Down" conditions
+ if "Accuracy Up" in user.db.conditions:
+ attack_value += 25
+ if "Accuracy Down" in user.db.conditions:
+ attack_value -= 25
+
+ user.location.msg_contents("%s attacks %s with %s!" % (user, target, item))
+ resolve_attack(user, target, attack_value=attack_value,
+ damage_value=damage_value, inflict_condition=inflict_condition)
+
+# Match strings to item functions here. We can't store callables on
+# prototypes, so we store a string instead, matching that string to
+# a callable in this dictionary.
+ITEMFUNCS = {
+ "heal":itemfunc_heal,
+ "attack":itemfunc_attack,
+ "add_condition":itemfunc_add_condition,
+ "cure_condition":itemfunc_cure_condition
+}
+
+"""
+----------------------------------------------------------------------------
+PROTOTYPES START HERE
+----------------------------------------------------------------------------
+
+You can paste these prototypes into your game's prototypes.py module in your
+/world/ folder, and use the spawner to create them - they serve as examples
+of items you can make and a handy way to demonstrate the system for
+conditions as well.
+
+Items don't have any particular typeclass - any object with a db entry
+"item_func" that references one of the functions given above can be used as
+an item with the 'use' command.
+
+Only "item_func" is required, but item behavior can be further modified by
+specifying any of the following:
+
+ item_uses (int): If defined, item has a limited number of uses
+
+ item_selfonly (bool): If True, user can only use the item on themself
+
+ item_consumable(True or str): If True, item is destroyed when it runs
+ out of uses. If a string is given, the item will spawn a new
+ object as it's destroyed, with the string specifying what prototype
+ to spawn.
+
+ item_kwargs (dict): Keyword arguments to pass to the function defined in
+ item_func. Unique to each function, and can be used to make multiple
+ items using the same function work differently.
+"""
+
+MEDKIT = {
+ "key" : "a medical kit",
+ "aliases" : ["medkit"],
+ "desc" : "A standard medical kit. It can be used a few times to heal wounds.",
+ "item_func" : "heal",
+ "item_uses" : 3,
+ "item_consumable" : True,
+ "item_kwargs" : {"healing_range":(15, 25)}
+}
+
+GLASS_BOTTLE = {
+ "key" : "a glass bottle",
+ "desc" : "An empty glass bottle."
+}
+
+HEALTH_POTION = {
+ "key" : "a health potion",
+ "desc" : "A glass bottle full of a mystical potion that heals wounds when used.",
+ "item_func" : "heal",
+ "item_uses" : 1,
+ "item_consumable" : "GLASS_BOTTLE",
+ "item_kwargs" : {"healing_range":(35, 50)}
+}
+
+REGEN_POTION = {
+ "key" : "a regeneration potion",
+ "desc" : "A glass bottle full of a mystical potion that regenerates wounds over time.",
+ "item_func" : "add_condition",
+ "item_uses" : 1,
+ "item_consumable" : "GLASS_BOTTLE",
+ "item_kwargs" : {"conditions":[("Regeneration", 10)]}
+}
+
+HASTE_POTION = {
+ "key" : "a haste potion",
+ "desc" : "A glass bottle full of a mystical potion that hastens its user.",
+ "item_func" : "add_condition",
+ "item_uses" : 1,
+ "item_consumable" : "GLASS_BOTTLE",
+ "item_kwargs" : {"conditions":[("Haste", 10)]}
+}
+
+BOMB = {
+ "key" : "a rotund bomb",
+ "desc" : "A large black sphere with a fuse at the end. Can be used on enemies in combat.",
+ "item_func" : "attack",
+ "item_uses" : 1,
+ "item_consumable" : True,
+ "item_kwargs" : {"damage_range":(25, 40), "accuracy":25}
+}
+
+POISON_DART = {
+ "key" : "a poison dart",
+ "desc" : "A thin dart coated in deadly poison. Can be used on enemies in combat",
+ "item_func" : "attack",
+ "item_uses" : 1,
+ "item_consumable" : True,
+ "item_kwargs" : {"damage_range":(5, 10), "accuracy":25, "inflict_condition":[("Poisoned", 10)]}
+}
+
+TASER = {
+ "key" : "a taser",
+ "desc" : "A device that can be used to paralyze enemies in combat.",
+ "item_func" : "attack",
+ "item_kwargs" : {"damage_range":(10, 20), "accuracy":0, "inflict_condition":[("Paralyzed", 1)]}
+}
+
+GHOST_GUN = {
+ "key" : "a ghost gun",
+ "desc" : "A gun that fires scary ghosts at people. Anyone hit by a ghost becomes frightened.",
+ "item_func" : "attack",
+ "item_uses" : 6,
+ "item_kwargs" : {"damage_range":(5, 10), "accuracy":15, "inflict_condition":[("Frightened", 1)]}
+}
+
+ANTIDOTE_POTION = {
+ "key" : "an antidote potion",
+ "desc" : "A glass bottle full of a mystical potion that cures poison when used.",
+ "item_func" : "cure_condition",
+ "item_uses" : 1,
+ "item_consumable" : "GLASS_BOTTLE",
+ "item_kwargs" : {"to_cure":["Poisoned"]}
+}
+
+AMULET_OF_MIGHT = {
+ "key" : "The Amulet of Might",
+ "desc" : "The one who holds this amulet can call upon its power to gain great strength.",
+ "item_func" : "add_condition",
+ "item_selfonly" : True,
+ "item_kwargs" : {"conditions":[("Damage Up", 3), ("Accuracy Up", 3), ("Defense Up", 3)]}
+}
+
+AMULET_OF_WEAKNESS = {
+ "key" : "The Amulet of Weakness",
+ "desc" : "The one who holds this amulet can call upon its power to gain great weakness. It's not a terribly useful artifact.",
+ "item_func" : "add_condition",
+ "item_selfonly" : True,
+ "item_kwargs" : {"conditions":[("Damage Down", 3), ("Accuracy Down", 3), ("Defense Down", 3)]}
+}
diff --git a/evennia/contrib/turnbattle/tb_magic.py b/evennia/contrib/turnbattle/tb_magic.py
new file mode 100644
index 0000000000..7bf87d70be
--- /dev/null
+++ b/evennia/contrib/turnbattle/tb_magic.py
@@ -0,0 +1,1290 @@
+"""
+Simple turn-based combat system with spell casting
+
+Contrib - Tim Ashley Jenkins 2017
+
+This is a version of the 'turnbattle' contrib that includes a basic,
+expandable framework for a 'magic system', whereby players can spend
+a limited resource (MP) to achieve a wide variety of effects, both in
+and out of combat. This does not have to strictly be a system for
+magic - it can easily be re-flavored to any other sort of resource
+based mechanic, like psionic powers, special moves and stamina, and
+so forth.
+
+In this system, spells are learned by name with the 'learnspell'
+command, and then used with the 'cast' command. Spells can be cast in or
+out of combat - some spells can only be cast in combat, some can only be
+cast outside of combat, and some can be cast any time. However, if you
+are in combat, you can only cast a spell on your turn, and doing so will
+typically use an action (as specified in the spell's funciton).
+
+Spells are defined at the end of the module in a database that's a
+dictionary of dictionaries - each spell is matched by name to a function,
+along with various parameters that restrict when the spell can be used and
+what the spell can be cast on. Included is a small variety of spells that
+damage opponents and heal HP, as well as one that creates an object.
+
+Because a spell can call any function, a spell can be made to do just
+about anything at all. The SPELLS dictionary at the bottom of the module
+even allows kwargs to be passed to the spell function, so that the same
+function can be re-used for multiple similar spells.
+
+Spells in this system work on a very basic resource: MP, which is spent
+when casting spells and restored by resting. It shouldn't be too difficult
+to modify this system to use spell slots, some physical fuel or resource,
+or whatever else your game requires.
+
+To install and test, import this module's TBMagicCharacter object into
+your game's character.py module:
+
+ from evennia.contrib.turnbattle.tb_magic import TBMagicCharacter
+
+And change your game's character typeclass to inherit from TBMagicCharacter
+instead of the default:
+
+ class Character(TBMagicCharacter):
+
+Next, import this module into your default_cmdsets.py module:
+
+ from evennia.contrib.turnbattle import tb_magic
+
+And add the battle command set to your default command set:
+
+ #
+ # any commands you add below will overload the default ones.
+ #
+ self.add(tb_magic.BattleCmdSet())
+
+This module is meant to be heavily expanded on, so you may want to copy it
+to your game's 'world' folder and modify it there rather than importing it
+in your game and using it as-is.
+"""
+
+from random import randint
+from evennia import DefaultCharacter, Command, default_cmds, DefaultScript, create_object
+from evennia.commands.default.muxcommand import MuxCommand
+from evennia.commands.default.help import CmdHelp
+
+"""
+----------------------------------------------------------------------------
+OPTIONS
+----------------------------------------------------------------------------
+"""
+
+TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds
+ACTIONS_PER_TURN = 1 # Number of actions allowed per turn
+
+"""
+----------------------------------------------------------------------------
+COMBAT FUNCTIONS START HERE
+----------------------------------------------------------------------------
+"""
+
+def roll_init(character):
+ """
+ Rolls a number between 1-1000 to determine initiative.
+
+ Args:
+ character (obj): The character to determine initiative for
+
+ Returns:
+ initiative (int): The character's place in initiative - higher
+ numbers go first.
+
+ Notes:
+ By default, does not reference the character and simply returns
+ a random integer from 1 to 1000.
+
+ Since the character is passed to this function, you can easily reference
+ a character's stats to determine an initiative roll - for example, if your
+ character has a 'dexterity' attribute, you can use it to give that character
+ an advantage in turn order, like so:
+
+ return (randint(1,20)) + character.db.dexterity
+
+ This way, characters with a higher dexterity will go first more often.
+ """
+ return randint(1, 1000)
+
+
+def get_attack(attacker, defender):
+ """
+ Returns a value for an attack roll.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+
+ Returns:
+ attack_value (int): Attack roll value, compared against a defense value
+ to determine whether an attack hits or misses.
+
+ Notes:
+ By default, returns a random integer from 1 to 100 without using any
+ properties from either the attacker or defender.
+
+ This can easily be expanded to return a value based on characters stats,
+ equipment, and abilities. This is why the attacker and defender are passed
+ to this function, even though nothing from either one are used in this example.
+ """
+ # For this example, just return a random integer up to 100.
+ attack_value = randint(1, 100)
+ return attack_value
+
+
+def get_defense(attacker, defender):
+ """
+ Returns a value for defense, which an attack roll must equal or exceed in order
+ for an attack to hit.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+
+ Returns:
+ defense_value (int): Defense value, compared against an attack roll
+ to determine whether an attack hits or misses.
+
+ Notes:
+ By default, returns 50, not taking any properties of the defender or
+ attacker into account.
+
+ As above, this can be expanded upon based on character stats and equipment.
+ """
+ # For this example, just return 50, for about a 50/50 chance of hit.
+ defense_value = 50
+ return defense_value
+
+
+def get_damage(attacker, defender):
+ """
+ Returns a value for damage to be deducted from the defender's HP after abilities
+ successful hit.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being damaged
+
+ Returns:
+ damage_value (int): Damage value, which is to be deducted from the defending
+ character's HP.
+
+ Notes:
+ By default, returns a random integer from 15 to 25 without using any
+ properties from either the attacker or defender.
+
+ Again, this can be expanded upon.
+ """
+ # For this example, just generate a number between 15 and 25.
+ damage_value = randint(15, 25)
+ return damage_value
+
+
+def apply_damage(defender, damage):
+ """
+ Applies damage to a target, reducing their HP by the damage amount to a
+ minimum of 0.
+
+ Args:
+ defender (obj): Character taking damage
+ damage (int): Amount of damage being taken
+ """
+ defender.db.hp -= damage # Reduce defender's HP by the damage dealt.
+ # If this reduces it to 0 or less, set HP to 0.
+ if defender.db.hp <= 0:
+ defender.db.hp = 0
+
+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
+ into a dying state or something similar) then this is the place to
+ do it.
+ """
+ defeated.location.msg_contents("%s has been defeated!" % defeated)
+
+def resolve_attack(attacker, defender, attack_value=None, defense_value=None):
+ """
+ Resolves an attack and outputs the result.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+
+ Notes:
+ Even though the attack and defense values are calculated
+ extremely simply, they are separated out into their own functions
+ so that they are easier to expand upon.
+ """
+ # Get an attack roll from the attacker.
+ if not attack_value:
+ attack_value = get_attack(attacker, defender)
+ # Get a defense value from the defender.
+ if not defense_value:
+ defense_value = get_defense(attacker, defender)
+ # If the attack value is lower than the defense value, miss. Otherwise, hit.
+ if attack_value < defense_value:
+ attacker.location.msg_contents("%s's attack misses %s!" % (attacker, defender))
+ else:
+ damage_value = get_damage(attacker, defender) # Calculate damage value.
+ # Announce damage dealt and apply damage.
+ attacker.location.msg_contents("%s hits %s for %i damage!" % (attacker, defender, damage_value))
+ apply_damage(defender, damage_value)
+ # If defender HP is reduced to 0 or less, call at_defeat.
+ if defender.db.hp <= 0:
+ at_defeat(defender)
+
+def combat_cleanup(character):
+ """
+ Cleans up all the temporary combat-related attributes on a character.
+
+ Args:
+ character (obj): Character to have their combat attributes removed
+
+ Notes:
+ Any attribute whose key begins with 'combat_' is temporary and no
+ longer needed once a fight ends.
+ """
+ for attr in character.attributes.all():
+ if attr.key[:7] == "combat_": # If the attribute name starts with 'combat_'...
+ character.attributes.remove(key=attr.key) # ...then delete it!
+
+
+def is_in_combat(character):
+ """
+ Returns true if the given character is in combat.
+
+ Args:
+ character (obj): Character to determine if is in combat or not
+
+ Returns:
+ (bool): True if in combat or False if not in combat
+ """
+ return bool(character.db.combat_turnhandler)
+
+
+def is_turn(character):
+ """
+ Returns true if it's currently the given character's turn in combat.
+
+ Args:
+ character (obj): Character to determine if it is their turn or not
+
+ Returns:
+ (bool): True if it is their turn or False otherwise
+ """
+ turnhandler = character.db.combat_turnhandler
+ currentchar = turnhandler.db.fighters[turnhandler.db.turn]
+ return bool(character == currentchar)
+
+
+def spend_action(character, actions, action_name=None):
+ """
+ Spends a character's available combat actions and checks for end of turn.
+
+ Args:
+ character (obj): Character spending the action
+ actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions
+
+ Kwargs:
+ action_name (str or None): If a string is given, sets character's last action in
+ combat to provided string
+ """
+ if not is_in_combat(character):
+ return
+ if action_name:
+ character.db.combat_lastaction = action_name
+ if actions == 'all': # If spending all actions
+ character.db.combat_actionsleft = 0 # Set actions to 0
+ else:
+ character.db.combat_actionsleft -= actions # Use up actions.
+ if character.db.combat_actionsleft < 0:
+ character.db.combat_actionsleft = 0 # Can't have fewer than 0 actions
+ character.db.combat_turnhandler.turn_end_check(character) # Signal potential end of turn.
+
+
+"""
+----------------------------------------------------------------------------
+CHARACTER TYPECLASS
+----------------------------------------------------------------------------
+"""
+
+
+class TBMagicCharacter(DefaultCharacter):
+ """
+ A character able to participate in turn-based combat. Has attributes for current
+ and maximum HP, and access to combat commands.
+ """
+
+ def at_object_creation(self):
+ """
+ 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.
+
+ You may want to expand this to include various 'stats' that
+ can be changed at creation and factor into combat calculations.
+ """
+ self.db.max_hp = 100 # Set maximum HP to 100
+ self.db.hp = self.db.max_hp # Set current HP to maximum
+ self.db.spells_known = [] # Set empty spells known list
+ self.db.max_mp = 20 # Set maximum MP to 20
+ self.db.mp = self.db.max_mp # Set current MP to maximum
+
+
+ def at_before_move(self, destination):
+ """
+ Called just before starting to move this object to
+ destination.
+
+ Args:
+ destination (Object): The object we are moving to
+
+ Returns:
+ shouldmove (bool): If we should move or not.
+
+ Notes:
+ If this method returns False/None, the move is cancelled
+ before it is even started.
+
+ """
+ # Keep the character from moving if at 0 HP or in combat.
+ if is_in_combat(self):
+ self.msg("You can't exit a room while in combat!")
+ return False # Returning false keeps the character from moving.
+ if self.db.HP <= 0:
+ self.msg("You can't move, you've been defeated!")
+ return False
+ return True
+
+"""
+----------------------------------------------------------------------------
+SCRIPTS START HERE
+----------------------------------------------------------------------------
+"""
+
+
+class TBMagicTurnHandler(DefaultScript):
+ """
+ This is the script that handles the progression of combat through turns.
+ On creation (when a fight is started) it adds all combat-ready characters
+ to its roster and then sorts them into a turn order. There can only be one
+ fight going on in a single room at a time, so the script is assigned to a
+ room as its object.
+
+ Fights persist until only one participant is left with any HP or all
+ remaining participants choose to end the combat with the 'disengage' command.
+ """
+
+ def at_script_creation(self):
+ """
+ Called once, when the script is created.
+ """
+ self.key = "Combat Turn Handler"
+ self.interval = 5 # Once every 5 seconds
+ self.persistent = True
+ self.db.fighters = []
+
+ # Add all fighters in the room with at least 1 HP to the combat."
+ for thing in self.obj.contents:
+ if thing.db.hp:
+ self.db.fighters.append(thing)
+
+ # Initialize each fighter for combat
+ for fighter in self.db.fighters:
+ self.initialize_for_combat(fighter)
+
+ # Add a reference to this script to the room
+ self.obj.db.combat_turnhandler = self
+
+ # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order.
+ # The initiative roll is determined by the roll_init function and can be customized easily.
+ ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True)
+ self.db.fighters = ordered_by_roll
+
+ # Announce the turn order.
+ self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters))
+
+ # Start first fighter's turn.
+ self.start_turn(self.db.fighters[0])
+
+ # Set up the current turn and turn timeout delay.
+ self.db.turn = 0
+ self.db.timer = TURN_TIMEOUT # Set timer to turn timeout specified in options
+
+ def at_stop(self):
+ """
+ Called at script termination.
+ """
+ for fighter in self.db.fighters:
+ combat_cleanup(fighter) # Clean up the combat attributes for every fighter.
+ self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location
+
+ def at_repeat(self):
+ """
+ Called once every self.interval seconds.
+ """
+ currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order.
+ self.db.timer -= self.interval # Count down the timer.
+
+ if self.db.timer <= 0:
+ # Force current character to disengage if timer runs out.
+ self.obj.msg_contents("%s's turn timed out!" % currentchar)
+ spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions.
+ return
+ elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left
+ # Warn the current character if they're about to time out.
+ currentchar.msg("WARNING: About to time out!")
+ self.db.timeout_warning_given = True
+
+ def initialize_for_combat(self, character):
+ """
+ Prepares a character for combat when starting or entering a fight.
+
+ Args:
+ character (obj): Character to initialize for combat.
+ """
+ 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
+ character.db.combat_turnhandler = 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):
+ """
+ Readies a character for the start of their turn by replenishing their
+ available actions and notifying them that their turn has come up.
+
+ Args:
+ character (obj): Character to be readied.
+
+ Notes:
+ Here, you only get one action per turn, but you might want to allow more than
+ one per turn, or even grant a number of actions based on a character's
+ attributes. You can even add multiple different kinds of actions, I.E. actions
+ separated for movement, by adding "character.db.combat_movesleft = 3" or
+ something similar.
+ """
+ character.db.combat_actionsleft = ACTIONS_PER_TURN # Replenish actions
+ # Prompt the character for their turn and give some information.
+ character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp)
+
+ def next_turn(self):
+ """
+ Advances to the next character in the turn order.
+ """
+
+ # Check to see if every character disengaged as their last action. If so, end combat.
+ disengage_check = True
+ for fighter in self.db.fighters:
+ if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage
+ disengage_check = False
+ if disengage_check: # All characters have disengaged
+ self.obj.msg_contents("All fighters have disengaged! Combat is over!")
+ self.stop() # Stop this script and end combat.
+ return
+
+ # Check to see if only one character is left standing. If so, end combat.
+ defeated_characters = 0
+ for fighter in self.db.fighters:
+ if fighter.db.HP == 0:
+ defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated)
+ if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated
+ for fighter in self.db.fighters:
+ if fighter.db.HP != 0:
+ LastStanding = fighter # Pick the one fighter left with HP remaining
+ self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding)
+ self.stop() # Stop this script and end combat.
+ return
+
+ # Cycle to the next turn.
+ currentchar = self.db.fighters[self.db.turn]
+ self.db.turn += 1 # Go to the next in the turn order.
+ if self.db.turn > len(self.db.fighters) - 1:
+ self.db.turn = 0 # Go back to the first in the turn order once you reach the end.
+ newchar = self.db.fighters[self.db.turn] # Note the new character
+ self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer.
+ self.db.timeout_warning_given = False # Reset the timeout warning.
+ self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar))
+ self.start_turn(newchar) # Start the new character's turn.
+
+ def turn_end_check(self, character):
+ """
+ Tests to see if a character's turn is over, and cycles to the next turn if it is.
+
+ Args:
+ character (obj): Character to test for end of turn
+ """
+ if not character.db.combat_actionsleft: # Character has no actions remaining
+ self.next_turn()
+ return
+
+ def join_fight(self, character):
+ """
+ Adds a new character to a fight already in progress.
+
+ Args:
+ character (obj): Character to be added to the fight.
+ """
+ # Inserts the fighter to the turn order, right behind whoever's turn it currently is.
+ self.db.fighters.insert(self.db.turn, character)
+ # Tick the turn counter forward one to compensate.
+ self.db.turn += 1
+ # Initialize the character like you do at the start.
+ self.initialize_for_combat(character)
+
+
+"""
+----------------------------------------------------------------------------
+COMMANDS START HERE
+----------------------------------------------------------------------------
+"""
+
+
+class CmdFight(Command):
+ """
+ Starts a fight with everyone in the same room as you.
+
+ Usage:
+ fight
+
+ When you start a fight, everyone in the room who is able to
+ fight is added to combat, and a turn order is randomly rolled.
+ When it's your turn, you can attack other characters.
+ """
+ key = "fight"
+ help_category = "combat"
+
+ def func(self):
+ """
+ This performs the actual command.
+ """
+ here = self.caller.location
+ fighters = []
+
+ if not self.caller.db.hp: # If you don't have any hp
+ self.caller.msg("You can't start a fight if you've been defeated!")
+ return
+ if is_in_combat(self.caller): # Already in a fight
+ self.caller.msg("You're already in a fight!")
+ return
+ for thing in here.contents: # Test everything in the room to add it to the fight.
+ if thing.db.HP: # If the object has HP...
+ fighters.append(thing) # ...then add it to the fight.
+ if len(fighters) <= 1: # If you're the only able fighter in the room
+ self.caller.msg("There's nobody here to fight!")
+ return
+ if here.db.combat_turnhandler: # If there's already a fight going on...
+ here.msg_contents("%s joins the fight!" % self.caller)
+ here.db.combat_turnhandler.join_fight(self.caller) # Join the fight!
+ return
+ here.msg_contents("%s starts a fight!" % self.caller)
+ # Add a turn handler script to the room, which starts combat.
+ here.scripts.add("contrib.turnbattle.tb_magic.TBMagicTurnHandler")
+ # Remember you'll have to change the path to the script if you copy this code to your own modules!
+
+
+class CmdAttack(Command):
+ """
+ Attacks another character.
+
+ Usage:
+ attack
+
+ When in a fight, you may attack another character. The attack has
+ a chance to hit, and if successful, will deal damage.
+ """
+
+ key = "attack"
+ help_category = "combat"
+
+ def func(self):
+ "This performs the actual command."
+ "Set the attacker to the caller and the defender to the target."
+
+ if not is_in_combat(self.caller): # If not in combat, can't attack.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ if not is_turn(self.caller): # If it's not your turn, can't attack.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ if not self.caller.db.hp: # Can't attack if you have no HP.
+ self.caller.msg("You can't attack, you've been defeated.")
+ return
+
+ attacker = self.caller
+ defender = self.caller.search(self.args)
+
+ if not defender: # No valid target given.
+ return
+
+ if not defender.db.hp: # Target object has no HP left or to begin with
+ self.caller.msg("You can't fight that!")
+ return
+
+ if attacker == defender: # Target and attacker are the same
+ self.caller.msg("You can't attack yourself!")
+ return
+
+ "If everything checks out, call the attack resolving function."
+ resolve_attack(attacker, defender)
+ spend_action(self.caller, 1, action_name="attack") # Use up one action.
+
+
+class CmdPass(Command):
+ """
+ Passes on your turn.
+
+ Usage:
+ pass
+
+ When in a fight, you can use this command to end your turn early, even
+ if there are still any actions you can take.
+ """
+
+ key = "pass"
+ aliases = ["wait", "hold"]
+ help_category = "combat"
+
+ def func(self):
+ """
+ This performs the actual command.
+ """
+ if not is_in_combat(self.caller): # Can only pass a turn in combat.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ if not is_turn(self.caller): # Can only pass if it's your turn.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ self.caller.location.msg_contents("%s takes no further action, passing the turn." % self.caller)
+ spend_action(self.caller, 'all', action_name="pass") # Spend all remaining actions.
+
+
+class CmdDisengage(Command):
+ """
+ Passes your turn and attempts to end combat.
+
+ Usage:
+ disengage
+
+ Ends your turn early and signals that you're trying to end
+ the fight. If all participants in a fight disengage, the
+ fight ends.
+ """
+
+ key = "disengage"
+ aliases = ["spare"]
+ help_category = "combat"
+
+ def func(self):
+ """
+ This performs the actual command.
+ """
+ if not is_in_combat(self.caller): # If you're not in combat
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ if not is_turn(self.caller): # If it's not your turn
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ self.caller.location.msg_contents("%s disengages, ready to stop fighting." % self.caller)
+ spend_action(self.caller, 'all', action_name="disengage") # Spend all remaining actions.
+ """
+ The action_name kwarg sets the character's last action to "disengage", which is checked by
+ the turn handler script to see if all fighters have disengaged.
+ """
+
+class CmdLearnSpell(Command):
+ """
+ Learn a magic spell.
+
+ Usage:
+ learnspell
+
+ 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.
+ """
+
+ key = "learnspell"
+ help_category = "magic"
+
+ def func(self):
+ """
+ This performs the actual command.
+ """
+ spell_list = sorted(SPELLS.keys())
+ args = self.args.lower()
+ args = args.strip(" ")
+ caller = self.caller
+ spell_to_learn = []
+
+ if not args or len(args) < 3: # No spell given
+ caller.msg("Usage: learnspell ")
+ return
+
+ for spell in spell_list: # Match inputs to spells
+ if args in spell.lower():
+ spell_to_learn.append(spell)
+
+ if spell_to_learn == []: # No spells matched
+ caller.msg("There is no spell with that name.")
+ return
+ if len(spell_to_learn) > 1: # More than one match
+ matched_spells = ', '.join(spell_to_learn)
+ caller.msg("Which spell do you mean: %s?" % matched_spells)
+ return
+
+ if len(spell_to_learn) == 1: # If one match, extract the string
+ spell_to_learn = spell_to_learn[0]
+
+ if spell_to_learn not in self.caller.db.spells_known: # If the spell isn't known...
+ caller.db.spells_known.append(spell_to_learn) # ...then add the spell to the character
+ caller.msg("You learn the spell '%s'!" % spell_to_learn)
+ return
+ if spell_to_learn in self.caller.db.spells_known: # Already has the spell specified
+ caller.msg("You already know the spell '%s'!" % spell_to_learn)
+ """
+ You will almost definitely want to replace this with your own system
+ for learning spells, perhaps tied to character advancement or finding
+ items in the game world that spells can be learned from.
+ """
+
+class CmdCast(MuxCommand):
+ """
+ Cast a magic spell that you know, provided you have the MP
+ to spend on its casting.
+
+ Usage:
+ cast [= , , 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.
+ """
+
+ key = "cast"
+ help_category = "magic"
+
+ 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
+ command - all the command does is verify that the player's input
+ is valid for the spell being cast and then call the spell's
+ function.
+ """
+ caller = self.caller
+
+ if not self.lhs or len(self.lhs) < 3: # No spell name given
+ caller.msg("Usage: cast = , , ...")
+ if not caller.db.spells_known:
+ caller.msg("You don't know any spells.")
+ return
+ else:
+ caller.db.spells_known = sorted(caller.db.spells_known)
+ spells_known_msg = "You know the following spells:|/" + "|/".join(caller.db.spells_known)
+ caller.msg(spells_known_msg) # List the spells the player knows
+ return
+
+ spellname = self.lhs.lower()
+ spell_to_cast = []
+ spell_targets = []
+
+ if not self.rhs:
+ spell_targets = []
+ elif self.rhs.lower() in ['me', 'self', 'myself']:
+ spell_targets = [caller]
+ elif len(self.rhs) > 2:
+ spell_targets = self.rhslist
+
+ for spell in caller.db.spells_known: # Match inputs to spells
+ if self.lhs in spell.lower():
+ spell_to_cast.append(spell)
+
+ if spell_to_cast == []: # No spells matched
+ caller.msg("You don't know a spell of that name.")
+ return
+ if len(spell_to_cast) > 1: # More than one match
+ matched_spells = ', '.join(spell_to_cast)
+ caller.msg("Which spell do you mean: %s?" % matched_spells)
+ return
+
+ if len(spell_to_cast) == 1: # If one match, extract the string
+ spell_to_cast = spell_to_cast[0]
+
+ if spell_to_cast not in SPELLS: # Spell isn't defined
+ caller.msg("ERROR: Spell %s is undefined" % spell_to_cast)
+ return
+
+ # Time to extract some info from the chosen spell!
+ spelldata = SPELLS[spell_to_cast]
+
+ # Add in some default data if optional parameters aren't specified
+ if "combat_spell" not in spelldata:
+ spelldata.update({"combat_spell":True})
+ if "noncombat_spell" not in spelldata:
+ spelldata.update({"noncombat_spell":True})
+ if "max_targets" not in spelldata:
+ spelldata.update({"max_targets":1})
+
+ # Store any superfluous options as kwargs to pass to the spell function
+ kwargs = {}
+ spelldata_opts = ["spellfunc", "target", "cost", "combat_spell", "noncombat_spell", "max_targets"]
+ for key in spelldata:
+ if key not in spelldata_opts:
+ kwargs.update({key:spelldata[key]})
+
+ # If caster doesn't have enough MP to cover the spell's cost, give error and return
+ if spelldata["cost"] > caller.db.mp:
+ caller.msg("You don't have enough MP to cast '%s'." % spell_to_cast)
+ return
+
+ # If in combat and the spell isn't a combat spell, give error message and return
+ if spelldata["combat_spell"] == False and is_in_combat(caller):
+ caller.msg("You can't use the spell '%s' in combat." % spell_to_cast)
+ return
+
+ # If not in combat and the spell isn't a non-combat spell, error ms and return.
+ if spelldata["noncombat_spell"] == False and is_in_combat(caller) == False:
+ caller.msg("You can't use the spell '%s' outside of combat." % spell_to_cast)
+ return
+
+ # If spell takes no targets and one is given, give error message and return
+ if len(spell_targets) > 0 and spelldata["target"] == "none":
+ caller.msg("The spell '%s' isn't cast on a target." % spell_to_cast)
+ return
+
+ # If no target is given and spell requires a target, give error message
+ if spelldata["target"] not in ["self", "none"]:
+ if len(spell_targets) == 0:
+ caller.msg("The spell '%s' requires a target." % spell_to_cast)
+ return
+
+ # If more targets given than maximum, give error message
+ if len(spell_targets) > spelldata["max_targets"]:
+ targplural = "target"
+ if spelldata["max_targets"] > 1:
+ targplural = "targets"
+ caller.msg("The spell '%s' can only be cast on %i %s." % (spell_to_cast, spelldata["max_targets"], targplural))
+ return
+
+ # Set up our candidates for targets
+ target_candidates = []
+
+ # If spell targets 'any' or 'other', any object in caster's inventory or location
+ # can be targeted by the spell.
+ if spelldata["target"] in ["any", "other"]:
+ target_candidates = caller.location.contents + caller.contents
+
+ # If spell targets 'anyobj', only non-character objects can be targeted.
+ if spelldata["target"] == "anyobj":
+ prefilter_candidates = caller.location.contents + caller.contents
+ for thing in prefilter_candidates:
+ if not thing.attributes.has("max_hp"): # Has no max HP, isn't a fighter
+ target_candidates.append(thing)
+
+ # If spell targets 'anychar' or 'otherchar', only characters can be targeted.
+ if spelldata["target"] in ["anychar", "otherchar"]:
+ prefilter_candidates = caller.location.contents
+ for thing in prefilter_candidates:
+ if thing.attributes.has("max_hp"): # Has max HP, is a fighter
+ target_candidates.append(thing)
+
+ # Now, match each entry in spell_targets to an object in the search candidates
+ matched_targets = []
+ for target in spell_targets:
+ match = caller.search(target, candidates=target_candidates)
+ matched_targets.append(match)
+ spell_targets = matched_targets
+
+ # If no target is given and the spell's target is 'self', set target to self
+ if len(spell_targets) == 0 and spelldata["target"] == "self":
+ spell_targets = [caller]
+
+ # Give error message if trying to cast an "other" target spell on yourself
+ if spelldata["target"] in ["other", "otherchar"]:
+ if caller in spell_targets:
+ caller.msg("You can't cast '%s' on yourself." % spell_to_cast)
+ return
+
+ # Return if "None" in target list, indicating failed match
+ if None in spell_targets:
+ # No need to give an error message, as 'search' gives one by default.
+ return
+
+ # Give error message if repeats in target list
+ if len(spell_targets) != len(set(spell_targets)):
+ caller.msg("You can't specify the same target more than once!")
+ return
+
+ # Finally, we can cast the spell itself. Note that MP is not deducted here!
+ try:
+ spelldata["spellfunc"](caller, spell_to_cast, spell_targets, spelldata["cost"], **kwargs)
+ except Exception:
+ log_trace("Error in callback for spell: %s." % spell_to_cast)
+
+
+class CmdRest(Command):
+ """
+ Recovers damage and restores MP.
+
+ Usage:
+ rest
+
+ Resting recovers your HP and MP to their maximum, but you can
+ only rest if you're not in a fight.
+ """
+
+ key = "rest"
+ help_category = "combat"
+
+ def func(self):
+ "This performs the actual command."
+
+ if is_in_combat(self.caller): # If you're in combat
+ self.caller.msg("You can't rest while you're in combat.")
+ return
+
+ self.caller.db.hp = self.caller.db.max_hp # Set current HP to maximum
+ self.caller.db.mp = self.caller.db.max_mp # Set current MP to maximum
+ self.caller.location.msg_contents("%s rests to recover HP and MP." % self.caller)
+ # You'll probably want to replace this with your own system for recovering HP and MP.
+
+class CmdStatus(Command):
+ """
+ Gives combat information.
+
+ Usage:
+ status
+
+ Shows your current and maximum HP and your distance from
+ other targets in combat.
+ """
+
+ key = "status"
+ help_category = "combat"
+
+ def func(self):
+ "This performs the actual command."
+ char = self.caller
+
+ if not char.db.max_hp: # Character not initialized, IE in unit tests
+ char.db.hp = 100
+ char.db.max_hp = 100
+ char.db.spells_known = []
+ char.db.max_mp = 20
+ char.db.mp = char.db.max_mp
+
+ char.msg("You have %i / %i HP and %i / %i MP." % (char.db.hp, char.db.max_hp, char.db.mp, char.db.max_mp))
+
+class CmdCombatHelp(CmdHelp):
+ """
+ View help or a list of topics
+
+ Usage:
+ help
+ help list
+ help all
+
+ This will search for help on commands and other
+ topics related to the game.
+ """
+ # Just like the default help command, but will give quick
+ # tips on combat when used in a fight with no arguments.
+
+ def func(self):
+ if is_in_combat(self.caller) and not self.args: # In combat and entered 'help' alone
+ self.caller.msg("Available combat commands:|/" +
+ "|wAttack:|n Attack a target, attempting to deal damage.|/" +
+ "|wPass:|n Pass your turn without further action.|/" +
+ "|wDisengage:|n End your turn and attempt to end combat.|/")
+ else:
+ super(CmdCombatHelp, self).func() # Call the default help command
+
+
+class BattleCmdSet(default_cmds.CharacterCmdSet):
+ """
+ This command set includes all the commmands used in the battle system.
+ """
+ key = "DefaultCharacter"
+
+ def at_cmdset_creation(self):
+ """
+ Populates the cmdset
+ """
+ self.add(CmdFight())
+ self.add(CmdAttack())
+ self.add(CmdRest())
+ self.add(CmdPass())
+ self.add(CmdDisengage())
+ self.add(CmdCombatHelp())
+ self.add(CmdLearnSpell())
+ self.add(CmdCast())
+ self.add(CmdStatus())
+
+"""
+----------------------------------------------------------------------------
+SPELL FUNCTIONS START HERE
+----------------------------------------------------------------------------
+
+These are the functions that are called by the 'cast' command to perform the
+effects of various spells. Which spells execute which functions and what
+parameters are passed to them are specified at the bottom of the module, in
+the 'SPELLS' dictionary.
+
+All of these functions take the same arguments:
+ caster (obj): Character casting the spell
+ spell_name (str): Name of the spell being cast
+ targets (list): List of objects targeted by the spell
+ cost (int): MP cost of casting the spell
+
+These functions also all accept **kwargs, and how these are used is specified
+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.
+ """
+ spell_msg = "%s casts %s!" % (caster, spell_name)
+
+ min_healing = 20
+ max_healing = 40
+
+ # Retrieve healing range from kwargs, if present
+ if "healing_range" in kwargs:
+ min_healing = kwargs["healing_range"][0]
+ max_healing = kwargs["healing_range"][1]
+
+ for character in targets:
+ to_heal = randint(min_healing, max_healing) # Restore 20 to 40 hp
+ if character.db.hp + to_heal > character.db.max_hp:
+ to_heal = character.db.max_hp - character.db.hp # Cap healing to max HP
+ character.db.hp += to_heal
+ spell_msg += " %s regains %i HP!" % (character, to_heal)
+
+ caster.db.mp -= cost # Deduct MP cost
+
+ caster.location.msg_contents(spell_msg) # Message the room with spell results
+
+ if is_in_combat(caster): # Spend action if in combat
+ spend_action(caster, 1, action_name="cast")
+
+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.
+ damage_range (tuple): Minimum and maximum damage dealt by the
+ spell. (10, 20) by default.
+ accuracy (int): Modifier to the spell's attack roll, determining
+ an increased or decreased chance to hit. 0 by default.
+ attack_count (int): How many individual attacks are made as part
+ of the spell. If the number of attacks exceeds the number of
+ targets, the first target specified will be attacked more
+ than once. Just 1 by default - if the attack_count is less
+ than the number targets given, each target will only be
+ attacked once.
+ """
+ spell_msg = "%s casts %s!" % (caster, spell_name)
+
+ atkname_single = "The spell"
+ atkname_plural = "spells"
+ min_damage = 10
+ max_damage = 20
+ accuracy = 0
+ attack_count = 1
+
+ # Retrieve some variables from kwargs, if present
+ if "attack_name" in kwargs:
+ atkname_single = kwargs["attack_name"][0]
+ atkname_plural = kwargs["attack_name"][1]
+ if "damage_range" in kwargs:
+ min_damage = kwargs["damage_range"][0]
+ max_damage = kwargs["damage_range"][1]
+ if "accuracy" in kwargs:
+ accuracy = kwargs["accuracy"]
+ if "attack_count" in kwargs:
+ attack_count = kwargs["attack_count"]
+
+ to_attack = []
+ # If there are more attacks than targets given, attack first target multiple times
+ if len(targets) < attack_count:
+ to_attack = to_attack + targets
+ extra_attacks = attack_count - len(targets)
+ for n in range(extra_attacks):
+ to_attack.insert(0, targets[0])
+ else:
+ to_attack = to_attack + targets
+
+
+ # Set up dictionaries to track number of hits and total damage
+ total_hits = {}
+ total_damage = {}
+ for fighter in targets:
+ total_hits.update({fighter:0})
+ total_damage.update({fighter:0})
+
+ # Resolve attack for each target
+ for fighter in to_attack:
+ attack_value = randint(1, 100) + accuracy # Spell attack roll
+ defense_value = get_defense(caster, fighter)
+ if attack_value >= defense_value:
+ spell_dmg = randint(min_damage, max_damage) # Get spell damage
+ total_hits[fighter] += 1
+ total_damage[fighter] += spell_dmg
+
+ for fighter in targets:
+ # Construct combat message
+ if total_hits[fighter] == 0:
+ spell_msg += " The spell misses %s!" % fighter
+ elif total_hits[fighter] > 0:
+ attack_count_str = atkname_single + " hits"
+ if total_hits[fighter] > 1:
+ attack_count_str = "%i %s hit" % (total_hits[fighter], atkname_plural)
+ spell_msg += " %s %s for %i damage!" % (attack_count_str, fighter, total_damage[fighter])
+
+ caster.db.mp -= cost # Deduct MP cost
+
+ caster.location.msg_contents(spell_msg) # Message the room with spell results
+
+ for fighter in targets:
+ # Apply damage
+ apply_damage(fighter, total_damage[fighter])
+ # If fighter HP is reduced to 0 or less, call at_defeat.
+ if fighter.db.hp <= 0:
+ at_defeat(fighter)
+
+ if is_in_combat(caster): # Spend action if in combat
+ spend_action(caster, 1, action_name="cast")
+
+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.
+ """
+
+ obj_key = "a nondescript object"
+ obj_desc = "A perfectly generic object."
+ obj_typeclass = "evennia.objects.objects.DefaultObject"
+
+ # Retrieve some variables from kwargs, if present
+ if "obj_key" in kwargs:
+ obj_key = kwargs["obj_key"]
+ if "obj_desc" in kwargs:
+ obj_desc = kwargs["obj_desc"]
+ if "obj_typeclass" in kwargs:
+ obj_typeclass = kwargs["obj_typeclass"]
+
+ conjured_obj = create_object(obj_typeclass, key=obj_key, location=caster.location) # Create object
+ conjured_obj.db.desc = obj_desc # Add object desc
+
+ caster.db.mp -= cost # Deduct MP cost
+
+ # Message the room to announce the creation of the object
+ caster.location.msg_contents("%s casts %s, and %s appears!" % (caster, spell_name, conjured_obj))
+
+"""
+----------------------------------------------------------------------------
+SPELL DEFINITIONS START HERE
+----------------------------------------------------------------------------
+In this section, each spell is matched to a function, and given parameters
+that determine its MP cost, valid type and number of targets, and what
+function casting the spell executes.
+
+This data is given as a dictionary of dictionaries - the key of each entry
+is the spell's name, and the value is a dictionary of various options and
+parameters, some of which are required and others which are optional.
+
+Required values for spells:
+
+ 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
+ "any" - Any object
+ "anyobj" - Any object that isn't a character
+ "anychar" - Any character
+ "other" - Any object excluding the caster
+ "otherchar" - Any character excluding the caster
+ 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.
+ 1 by default - unused if target is "none" or "self"
+
+Any other values specified besides the above will be passed as kwargs to 'spellfunc'.
+You can use kwargs to effectively re-use the same function for different but similar
+spells - for example, 'magic missile' and 'flame shot' use the same function, but
+behave differently, as they have different damage ranges, accuracy, amount of attacks
+made as part of the spell, and so forth. If you make your spell functions flexible
+enough, you can make a wide variety of spells just by adding more entries to this
+dictionary.
+"""
+
+SPELLS = {
+"magic missile":{"spellfunc":spell_attack, "target":"otherchar", "cost":3, "noncombat_spell":False, "max_targets":3,
+ "attack_name":("A bolt", "bolts"), "damage_range":(4, 7), "accuracy":999, "attack_count":3},
+
+"flame shot":{"spellfunc":spell_attack, "target":"otherchar", "cost":3, "noncombat_spell":False,
+ "attack_name":("A jet of flame", "jets of flame"), "damage_range":(25, 35)},
+
+"cure wounds":{"spellfunc":spell_healing, "target":"anychar", "cost":5},
+
+"mass cure wounds":{"spellfunc":spell_healing, "target":"anychar", "cost":10, "max_targets": 5},
+
+"full heal":{"spellfunc":spell_healing, "target":"anychar", "cost":12, "healing_range":(100, 100)},
+
+"cactus conjuration":{"spellfunc":spell_conjure, "target":"none", "cost":2, "combat_spell":False,
+ "obj_key":"a cactus", "obj_desc":"An ordinary green cactus with little spines."}
+}
+
diff --git a/evennia/contrib/turnbattle/tb_range.py b/evennia/contrib/turnbattle/tb_range.py
new file mode 100644
index 0000000000..5596573a2d
--- /dev/null
+++ b/evennia/contrib/turnbattle/tb_range.py
@@ -0,0 +1,1383 @@
+"""
+Simple turn-based combat system with range and movement
+
+Contrib - Tim Ashley Jenkins 2017
+
+This is a version of the 'turnbattle' contrib that includes a system
+for abstract movement and positioning in combat, including distinction
+between melee and ranged attacks. In this system, a fighter or object's
+exact position is not recorded - only their relative distance to other
+actors in combat.
+
+In this example, the distance between two objects in combat is expressed
+as an integer value: 0 for "engaged" objects that are right next to each
+other, 1 for "reach" which is for objects that are near each other but
+not directly adjacent, and 2 for "range" for objects that are far apart.
+
+When combat starts, all fighters are at reach with each other and other
+objects, and at range from any exits. On a fighter's turn, they can use
+the "approach" command to move closer to an object, or the "withdraw"
+command to move further away from an object, either of which takes an
+action in combat. In this example, fighters are given two actions per
+turn, allowing them to move and attack in the same round, or to attack
+twice or move twice.
+
+When you move toward an object, you will also move toward anything else
+that's close to your target - the same goes for moving away from a target,
+which will also move you away from anything close to your target. Moving
+toward one target may also move you away from anything you're already
+close to, but withdrawing from a target will never inadvertently bring
+you closer to anything else.
+
+In this example, there are two attack commands. 'Attack' can only hit
+targets that are 'engaged' (range 0) with you. 'Shoot' can hit any target
+on the field, but cannot be used if you are engaged with any other fighters.
+In addition, strikes made with the 'attack' command are more accurate than
+'shoot' attacks. This is only to provide an example of how melee and ranged
+attacks can be made to work differently - you can, of course, modify this
+to fit your rules system.
+
+When in combat, the ranges of objects are also accounted for - you can't
+pick up an object unless you're engaged with it, and can't give an object
+to another fighter without being engaged with them either. Dropped objects
+are automatically assigned a range of 'engaged' with the fighter who dropped
+them. Additionally, giving or getting an object will take an action in combat.
+Dropping an object does not take an action, but can only be done on your turn.
+
+When combat ends, all range values are erased and all restrictions on getting
+or getting objects are lifted - distances are no longer tracked and objects in
+the same room can be considered to be in the same space, as is the default
+behavior of Evennia and most MUDs.
+
+This system allows for strategies in combat involving movement and
+positioning to be implemented in your battle system without the use of
+a 'grid' of coordinates, which can be difficult and clunky to navigate
+in text and disadvantageous to players who use screen readers. This loose,
+narrative method of tracking position is based around how the matter is
+handled in tabletop RPGs played without a grid - typically, a character's
+exact position in a room isn't important, only their relative distance to
+other actors.
+
+You may wish to expand this system with a method of distinguishing allies
+from enemies (to prevent allied characters from blocking your ranged attacks)
+as well as some method by which melee-focused characters can prevent enemies
+from withdrawing or punish them from doing so, such as by granting "attacks of
+opportunity" or something similar. If you wish, you can also expand the breadth
+of values allowed for range - rather than just 0, 1, and 2, you can allow ranges
+to go up to much higher values, and give attacks and movements more varying
+values for distance for a more granular system. You may also want to implement
+a system for fleeing or changing rooms in combat by approaching exits, which
+are objects placed in the range field like any other.
+
+To install and test, import this module's TBRangeCharacter object into
+your game's character.py module:
+
+ from evennia.contrib.turnbattle.tb_range import TBRangeCharacter
+
+And change your game's character typeclass to inherit from TBRangeCharacter
+instead of the default:
+
+ class Character(TBRangeCharacter):
+
+Do the same thing in your game's objects.py module for TBRangeObject:
+
+ from evennia.contrib.turnbattle.tb_range import TBRangeObject
+ class Object(TBRangeObject):
+
+Next, import this module into your default_cmdsets.py module:
+
+ from evennia.contrib.turnbattle import tb_range
+
+And add the battle command set to your default command set:
+
+ #
+ # any commands you add below will overload the default ones.
+ #
+ self.add(tb_range.BattleCmdSet())
+
+This module is meant to be heavily expanded on, so you may want to copy it
+to your game's 'world' folder and modify it there rather than importing it
+in your game and using it as-is.
+"""
+
+from random import randint
+from evennia import DefaultCharacter, DefaultObject, Command, default_cmds, DefaultScript
+from evennia.commands.default.help import CmdHelp
+
+"""
+----------------------------------------------------------------------------
+OPTIONS
+----------------------------------------------------------------------------
+"""
+
+TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds
+ACTIONS_PER_TURN = 2 # Number of actions allowed per turn
+
+"""
+----------------------------------------------------------------------------
+COMBAT FUNCTIONS START HERE
+----------------------------------------------------------------------------
+"""
+
+def roll_init(character):
+ """
+ Rolls a number between 1-1000 to determine initiative.
+
+ Args:
+ character (obj): The character to determine initiative for
+
+ Returns:
+ initiative (int): The character's place in initiative - higher
+ numbers go first.
+
+ Notes:
+ By default, does not reference the character and simply returns
+ a random integer from 1 to 1000.
+
+ Since the character is passed to this function, you can easily reference
+ a character's stats to determine an initiative roll - for example, if your
+ character has a 'dexterity' attribute, you can use it to give that character
+ an advantage in turn order, like so:
+
+ return (randint(1,20)) + character.db.dexterity
+
+ This way, characters with a higher dexterity will go first more often.
+ """
+ return randint(1, 1000)
+
+
+def get_attack(attacker, defender, attack_type):
+ """
+ Returns a value for an attack roll.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+ attack_type (str): Type of attack ('melee' or 'ranged')
+
+ Returns:
+ attack_value (int): Attack roll value, compared against a defense value
+ to determine whether an attack hits or misses.
+
+ Notes:
+ By default, generates a random integer from 1 to 100 without using any
+ properties from either the attacker or defender, and modifies the result
+ based on whether it's for a melee or ranged attack.
+
+ This can easily be expanded to return a value based on characters stats,
+ equipment, and abilities. This is why the attacker and defender are passed
+ to this function, even though nothing from either one are used in this example.
+ """
+ # For this example, just return a random integer up to 100.
+ attack_value = randint(1, 100)
+ # Make melee attacks more accurate, ranged attacks less accurate
+ if attack_type == "melee":
+ attack_value += 15
+ if attack_type == "ranged":
+ attack_value -= 15
+ return attack_value
+
+
+def get_defense(attacker, defender, attack_type):
+ """
+ Returns a value for defense, which an attack roll must equal or exceed in order
+ for an attack to hit.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+ attack_type (str): Type of attack ('melee' or 'ranged')
+
+ Returns:
+ defense_value (int): Defense value, compared against an attack roll
+ to determine whether an attack hits or misses.
+
+ Notes:
+ By default, returns 50, not taking any properties of the defender or
+ attacker into account.
+
+ As above, this can be expanded upon based on character stats and equipment.
+ """
+ # For this example, just return 50, for about a 50/50 chance of hit.
+ defense_value = 50
+ return defense_value
+
+
+def get_damage(attacker, defender):
+ """
+ Returns a value for damage to be deducted from the defender's HP after abilities
+ successful hit.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being damaged
+
+ Returns:
+ damage_value (int): Damage value, which is to be deducted from the defending
+ character's HP.
+
+ Notes:
+ By default, returns a random integer from 15 to 25 without using any
+ properties from either the attacker or defender.
+
+ Again, this can be expanded upon.
+ """
+ # For this example, just generate a number between 15 and 25.
+ damage_value = randint(15, 25)
+ return damage_value
+
+
+def apply_damage(defender, damage):
+ """
+ Applies damage to a target, reducing their HP by the damage amount to a
+ minimum of 0.
+
+ Args:
+ defender (obj): Character taking damage
+ damage (int): Amount of damage being taken
+ """
+ defender.db.hp -= damage # Reduce defender's HP by the damage dealt.
+ # If this reduces it to 0 or less, set HP to 0.
+ if defender.db.hp <= 0:
+ defender.db.hp = 0
+
+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
+ into a dying state or something similar) then this is the place to
+ do it.
+ """
+ defeated.location.msg_contents("%s has been defeated!" % defeated)
+
+def resolve_attack(attacker, defender, attack_type, attack_value=None, defense_value=None):
+ """
+ Resolves an attack and outputs the result.
+
+ Args:
+ attacker (obj): Character doing the attacking
+ defender (obj): Character being attacked
+ attack_type (str): Type of attack (melee or ranged)
+
+ Notes:
+ Even though the attack and defense values are calculated
+ extremely simply, they are separated out into their own functions
+ so that they are easier to expand upon.
+ """
+ # Get an attack roll from the attacker.
+ if not attack_value:
+ attack_value = get_attack(attacker, defender, attack_type)
+ # Get a defense value from the defender.
+ if not defense_value:
+ defense_value = get_defense(attacker, defender, attack_type)
+ # If the attack value is lower than the defense value, miss. Otherwise, hit.
+ if attack_value < defense_value:
+ attacker.location.msg_contents("%s's %s attack misses %s!" % (attacker, attack_type, defender))
+ else:
+ damage_value = get_damage(attacker, defender) # Calculate damage value.
+ # Announce damage dealt and apply damage.
+ attacker.location.msg_contents("%s hits %s with a %s attack for %i damage!" % (attacker, defender, attack_type, damage_value))
+ apply_damage(defender, damage_value)
+ # If defender HP is reduced to 0 or less, call at_defeat.
+ if defender.db.hp <= 0:
+ at_defeat(defender)
+
+def get_range(obj1, obj2):
+ """
+ Gets the combat range between two objects.
+
+ Args:
+ obj1 (obj): First object
+ obj2 (obj): Second object
+
+ Returns:
+ range (int or None): Distance between two objects or None if not applicable
+ """
+ # Return None if not applicable.
+ if not obj1.db.combat_range:
+ return None
+ if not obj2.db.combat_range:
+ return None
+ if obj1 not in obj2.db.combat_range:
+ return None
+ if obj2 not in obj1.db.combat_range:
+ return None
+ # Return the range between the two objects.
+ return obj1.db.combat_range[obj2]
+
+def distance_inc(mover, target):
+ """
+ Function that increases distance in range field between mover and target.
+
+ Args:
+ mover (obj): The object moving
+ target (obj): The object to be moved away from
+ """
+ mover.db.combat_range[target] += 1
+ target.db.combat_range[mover] = mover.db.combat_range[target]
+ # Set a cap of 2:
+ if get_range(mover, target) > 2:
+ target.db.combat_range[mover] = 2
+ mover.db.combat_range[target] = 2
+
+def approach(mover, target):
+ """
+ Manages a character's whole approach, including changes in ranges to other characters.
+
+ Args:
+ mover (obj): The object moving
+ target (obj): The object to be moved toward
+
+ Notes:
+ The mover will also automatically move toward any objects that are closer to the
+ target than the mover is. The mover will also move away from anything they started
+ out close to.
+ """
+ def distance_dec(mover, target):
+ """
+ Helper function that decreases distance in range field between mover and target.
+
+ Args:
+ mover (obj): The object moving
+ target (obj): The object to be moved toward
+ """
+ mover.db.combat_range[target] -= 1
+ target.db.combat_range[mover] = mover.db.combat_range[target]
+ # If this brings mover to range 0 (Engaged):
+ if get_range(mover, target) <= 0:
+ # Reset range to each other to 0 and copy target's ranges to mover.
+ target.db.combat_range[mover] = 0
+ mover.db.combat_range = target.db.combat_range
+ # Assure everything else has the same distance from the mover and target, now that they're together
+ for thing in mover.location.contents:
+ if thing != mover and thing != target:
+ thing.db.combat_range[mover] = thing.db.combat_range[target]
+
+ contents = mover.location.contents
+
+ for thing in contents:
+ if thing != mover and thing != target:
+ # Move closer to each object closer to the target than you.
+ if get_range(mover, thing) > get_range(target, thing):
+ distance_dec(mover, thing)
+ # Move further from each object that's further from you than from the target.
+ if get_range(mover, thing) < get_range(target, thing):
+ distance_inc(mover, thing)
+ # Lastly, move closer to your target.
+ distance_dec(mover, target)
+
+def withdraw(mover, target):
+ """
+ Manages a character's whole withdrawal, including changes in ranges to other characters.
+
+ Args:
+ mover (obj): The object moving
+ target (obj): The object to be moved away from
+
+ Notes:
+ The mover will also automatically move away from objects that are close to the target
+ of their withdrawl. The mover will never inadvertently move toward anything else while
+ withdrawing - they can be considered to be moving to open space.
+ """
+
+ contents = mover.location.contents
+
+ for thing in contents:
+ if thing != mover and thing != target:
+ # Move away from each object closer to the target than you, if it's also closer to you than you are to the target.
+ if get_range(mover, thing) >= get_range(target, thing) and get_range(mover, thing) < get_range(mover, target):
+ distance_inc(mover, thing)
+ # Move away from anything your target is engaged with
+ if get_range(target, thing) == 0:
+ distance_inc(mover, thing)
+ # Move away from anything you're engaged with.
+ if get_range(mover, thing) == 0:
+ distance_inc(mover, thing)
+ # Then, move away from your target.
+ distance_inc(mover, target)
+
+def combat_cleanup(character):
+ """
+ Cleans up all the temporary combat-related attributes on a character.
+
+ Args:
+ character (obj): Character to have their combat attributes removed
+
+ Notes:
+ Any attribute whose key begins with 'combat_' is temporary and no
+ longer needed once a fight ends.
+ """
+ for attr in character.attributes.all():
+ if attr.key[:7] == "combat_": # If the attribute name starts with 'combat_'...
+ character.attributes.remove(key=attr.key) # ...then delete it!
+
+
+def is_in_combat(character):
+ """
+ Returns true if the given character is in combat.
+
+ Args:
+ character (obj): Character to determine if is in combat or not
+
+ Returns:
+ (bool): True if in combat or False if not in combat
+ """
+ return bool(character.db.combat_turnhandler)
+
+
+def is_turn(character):
+ """
+ Returns true if it's currently the given character's turn in combat.
+
+ Args:
+ character (obj): Character to determine if it is their turn or not
+
+ Returns:
+ (bool): True if it is their turn or False otherwise
+ """
+ turnhandler = character.db.combat_turnhandler
+ currentchar = turnhandler.db.fighters[turnhandler.db.turn]
+ return bool(character == currentchar)
+
+
+def spend_action(character, actions, action_name=None):
+ """
+ Spends a character's available combat actions and checks for end of turn.
+
+ Args:
+ character (obj): Character spending the action
+ actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions
+
+ Kwargs:
+ action_name (str or None): If a string is given, sets character's last action in
+ combat to provided string
+ """
+ if action_name:
+ character.db.combat_lastaction = action_name
+ if actions == 'all': # If spending all actions
+ character.db.combat_actionsleft = 0 # Set actions to 0
+ else:
+ character.db.combat_actionsleft -= actions # Use up actions.
+ if character.db.combat_actionsleft < 0:
+ character.db.combat_actionsleft = 0 # Can't have fewer than 0 actions
+ character.db.combat_turnhandler.turn_end_check(character) # Signal potential end of turn.
+
+def combat_status_message(fighter):
+ """
+ Sends a message to a player with their current HP and
+ distances to other fighters and objects. Called at turn
+ start and by the 'status' command.
+ """
+ if not fighter.db.max_hp:
+ fighter.db.hp = 100
+ fighter.db.max_hp = 100
+
+ status_msg = ("HP Remaining: %i / %i" % (fighter.db.hp, fighter.db.max_hp))
+
+ if not is_in_combat(fighter):
+ fighter.msg(status_msg)
+ return
+
+ engaged_obj = []
+ reach_obj = []
+ range_obj = []
+
+ for thing in fighter.db.combat_range:
+ if thing != fighter:
+ if fighter.db.combat_range[thing] == 0:
+ engaged_obj.append(thing)
+ if fighter.db.combat_range[thing] == 1:
+ reach_obj.append(thing)
+ if fighter.db.combat_range[thing] > 1:
+ range_obj.append(thing)
+
+ if engaged_obj:
+ status_msg += "|/Engaged targets: %s" % ", ".join(obj.key for obj in engaged_obj)
+ if reach_obj:
+ status_msg += "|/Reach targets: %s" % ", ".join(obj.key for obj in reach_obj)
+ if range_obj:
+ status_msg += "|/Ranged targets: %s" % ", ".join(obj.key for obj in range_obj)
+
+ fighter.msg(status_msg)
+
+"""
+----------------------------------------------------------------------------
+SCRIPTS START HERE
+----------------------------------------------------------------------------
+"""
+
+
+class TBRangeTurnHandler(DefaultScript):
+ """
+ This is the script that handles the progression of combat through turns.
+ On creation (when a fight is started) it adds all combat-ready characters
+ to its roster and then sorts them into a turn order. There can only be one
+ fight going on in a single room at a time, so the script is assigned to a
+ room as its object.
+
+ Fights persist until only one participant is left with any HP or all
+ remaining participants choose to end the combat with the 'disengage' command.
+ """
+
+ def at_script_creation(self):
+ """
+ Called once, when the script is created.
+ """
+ self.key = "Combat Turn Handler"
+ self.interval = 5 # Once every 5 seconds
+ self.persistent = True
+ self.db.fighters = []
+
+ # Add all fighters in the room with at least 1 HP to the combat."
+ for thing in self.obj.contents:
+ if thing.db.hp:
+ self.db.fighters.append(thing)
+
+ # Initialize each fighter for combat
+ for fighter in self.db.fighters:
+ self.initialize_for_combat(fighter)
+
+ # Add a reference to this script to the room
+ self.obj.db.combat_turnhandler = self
+
+ # Initialize range field for all objects in the room
+ for thing in self.obj.contents:
+ self.init_range(thing)
+
+ # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order.
+ # The initiative roll is determined by the roll_init function and can be customized easily.
+ ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True)
+ self.db.fighters = ordered_by_roll
+
+ # Announce the turn order.
+ self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters))
+
+ # Start first fighter's turn.
+ self.start_turn(self.db.fighters[0])
+
+ # Set up the current turn and turn timeout delay.
+ self.db.turn = 0
+ self.db.timer = TURN_TIMEOUT # Set timer to turn timeout specified in options
+
+ def at_stop(self):
+ """
+ Called at script termination.
+ """
+ for thing in self.obj.contents:
+ combat_cleanup(thing) # Clean up the combat attributes for every object in the room.
+ self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location
+
+ def at_repeat(self):
+ """
+ Called once every self.interval seconds.
+ """
+ currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order.
+ self.db.timer -= self.interval # Count down the timer.
+
+ if self.db.timer <= 0:
+ # Force current character to disengage if timer runs out.
+ self.obj.msg_contents("%s's turn timed out!" % currentchar)
+ spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions.
+ return
+ elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left
+ # Warn the current character if they're about to time out.
+ currentchar.msg("WARNING: About to time out!")
+ self.db.timeout_warning_given = True
+
+ def init_range(self, to_init):
+ """
+ Initializes range values for an object at the start of a fight.
+
+ Args:
+ to_init (object): Object to initialize range field for.
+ """
+ rangedict = {}
+ # Get a list of objects in the room.
+ objectlist = self.obj.contents
+ for thing in objectlist:
+ # Object always at distance 0 from itself
+ if thing == to_init:
+ rangedict.update({thing:0})
+ else:
+ if thing.destination or to_init.destination:
+ # Start exits at range 2 to put them at the 'edges'
+ rangedict.update({thing:2})
+ else:
+ # Start objects at range 1 from other objects
+ rangedict.update({thing:1})
+ to_init.db.combat_range = rangedict
+
+ def join_rangefield(self, to_init, anchor_obj=None, add_distance=0):
+ """
+ Adds a new object to the range field of a fight in progress.
+
+ Args:
+ to_init (object): Object to initialize range field for.
+
+ Kwargs:
+ anchor_obj (object): Object to copy range values from, or None for a random object.
+ add_distance (int): Distance to put between to_init object and anchor object.
+
+ """
+ # Get a list of room's contents without to_init object.
+ contents = self.obj.contents
+ contents.remove(to_init)
+ # If no anchor object given, pick one in the room at random.
+ if not anchor_obj:
+ anchor_obj = contents[randint(0, (len(contents)-1))]
+ # Copy the range values from the anchor object.
+ to_init.db.combat_range = anchor_obj.db.combat_range
+ # Add the new object to everyone else's ranges.
+ for thing in contents:
+ new_objects_range = thing.db.combat_range[anchor_obj]
+ thing.db.combat_range.update({to_init:new_objects_range})
+ # Set the new object's range to itself to 0.
+ to_init.db.combat_range.update({to_init:0})
+ # Add additional distance from anchor object, if any.
+ for n in range(add_distance):
+ withdraw(to_init, anchor_obj)
+
+ def initialize_for_combat(self, character):
+ """
+ Prepares a character for combat when starting or entering a fight.
+
+ Args:
+ character (obj): Character to initialize for combat.
+ """
+ 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
+ character.db.combat_turnhandler = 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):
+ """
+ Readies a character for the start of their turn by replenishing their
+ available actions and notifying them that their turn has come up.
+
+ Args:
+ character (obj): Character to be readied.
+
+ Notes:
+ In this example, characters are given two actions per turn. This allows
+ characters to both move and attack in the same turn (or, alternately,
+ move twice or attack twice).
+ """
+ character.db.combat_actionsleft = ACTIONS_PER_TURN # Replenish actions
+ # Prompt the character for their turn and give some information.
+ character.msg("|wIt's your turn!|n")
+ combat_status_message(character)
+
+ def next_turn(self):
+ """
+ Advances to the next character in the turn order.
+ """
+
+ # Check to see if every character disengaged as their last action. If so, end combat.
+ disengage_check = True
+ for fighter in self.db.fighters:
+ if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage
+ disengage_check = False
+ if disengage_check: # All characters have disengaged
+ self.obj.msg_contents("All fighters have disengaged! Combat is over!")
+ self.stop() # Stop this script and end combat.
+ return
+
+ # Check to see if only one character is left standing. If so, end combat.
+ defeated_characters = 0
+ for fighter in self.db.fighters:
+ if fighter.db.HP == 0:
+ defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated)
+ if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated
+ for fighter in self.db.fighters:
+ if fighter.db.HP != 0:
+ LastStanding = fighter # Pick the one fighter left with HP remaining
+ self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding)
+ self.stop() # Stop this script and end combat.
+ return
+
+ # Cycle to the next turn.
+ currentchar = self.db.fighters[self.db.turn]
+ self.db.turn += 1 # Go to the next in the turn order.
+ if self.db.turn > len(self.db.fighters) - 1:
+ self.db.turn = 0 # Go back to the first in the turn order once you reach the end.
+ newchar = self.db.fighters[self.db.turn] # Note the new character
+ self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer.
+ self.db.timeout_warning_given = False # Reset the timeout warning.
+ self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar))
+ self.start_turn(newchar) # Start the new character's turn.
+
+ def turn_end_check(self, character):
+ """
+ Tests to see if a character's turn is over, and cycles to the next turn if it is.
+
+ Args:
+ character (obj): Character to test for end of turn
+ """
+ if not character.db.combat_actionsleft: # Character has no actions remaining
+ self.next_turn()
+ return
+
+ def join_fight(self, character):
+ """
+ Adds a new character to a fight already in progress.
+
+ Args:
+ character (obj): Character to be added to the fight.
+ """
+ # Inserts the fighter to the turn order, right behind whoever's turn it currently is.
+ self.db.fighters.insert(self.db.turn, character)
+ # Tick the turn counter forward one to compensate.
+ self.db.turn += 1
+ # Initialize the character like you do at the start.
+ self.initialize_for_combat(character)
+ # Add the character to the rangefield, at range from everyone, if they're not on it already.
+ if not character.db.combat_range:
+ self.join_rangefield(character, add_distance=2)
+
+
+"""
+----------------------------------------------------------------------------
+TYPECLASSES START HERE
+----------------------------------------------------------------------------
+"""
+
+
+class TBRangeCharacter(DefaultCharacter):
+ """
+ A character able to participate in turn-based combat. Has attributes for current
+ and maximum HP, and access to combat commands.
+ """
+
+ def at_object_creation(self):
+ """
+ Called once, when this object is first created. This is the
+ normal hook to overload for most object types.
+ """
+ self.db.max_hp = 100 # Set maximum HP to 100
+ self.db.hp = self.db.max_hp # Set current HP to maximum
+ """
+ Adds attributes for a character's current and maximum HP.
+ We're just going to set this value at '100' by default.
+
+ You may want to expand this to include various 'stats' that
+ can be changed at creation and factor into combat calculations.
+ """
+
+ def at_before_move(self, destination):
+ """
+ Called just before starting to move this object to
+ destination.
+
+ Args:
+ destination (Object): The object we are moving to
+
+ Returns:
+ shouldmove (bool): If we should move or not.
+
+ Notes:
+ If this method returns False/None, the move is cancelled
+ before it is even started.
+
+ """
+ # Keep the character from moving if at 0 HP or in combat.
+ if is_in_combat(self):
+ self.msg("You can't exit a room while in combat!")
+ return False # Returning false keeps the character from moving.
+ if self.db.HP <= 0:
+ self.msg("You can't move, you've been defeated!")
+ return False
+ return True
+
+class TBRangeObject(DefaultObject):
+ """
+ An object that is assigned range values in combat. Getting, giving, and dropping
+ the object has restrictions in combat - you must be next to an object to get it,
+ must be next to your target to give them something, and can only interact with
+ objects on your own turn.
+ """
+ def at_before_drop(self, dropper):
+ """
+ Called by the default `drop` command before this object has been
+ dropped.
+
+ Args:
+ dropper (Object): The object which will drop this object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ shoulddrop (bool): If the object should be dropped or not.
+
+ Notes:
+ If this method returns False/None, the dropping is cancelled
+ before it is even started.
+
+ """
+ # Can't drop something if in combat and it's not your turn
+ if is_in_combat(dropper) and not is_turn(dropper):
+ dropper.msg("You can only drop things on your turn!")
+ return False
+ return True
+
+ def at_drop(self, dropper):
+ """
+ Called by the default `drop` command when this object has been
+ dropped.
+
+ Args:
+ dropper (Object): The object which just dropped this object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Notes:
+ This hook cannot stop the drop from happening. Use
+ permissions or the at_before_drop() hook for that.
+
+ """
+ # If dropper is currently in combat
+ if dropper.location.db.combat_turnhandler:
+ # Object joins the range field
+ self.db.combat_range = {}
+ dropper.location.db.combat_turnhandler.join_rangefield(self, anchor_obj=dropper)
+
+ def at_before_get(self, getter):
+ """
+ Called by the default `get` command before this object has been
+ picked up.
+
+ Args:
+ getter (Object): The object about to get this object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ shouldget (bool): If the object should be gotten or not.
+
+ Notes:
+ If this method returns False/None, the getting is cancelled
+ before it is even started.
+ """
+ # Restrictions for getting in combat
+ if is_in_combat(getter):
+ if not is_turn(getter): # Not your turn
+ getter.msg("You can only get things on your turn!")
+ return False
+ if get_range(self, getter) > 0: # Too far away
+ getter.msg("You aren't close enough to get that! (see: help approach)")
+ return False
+ return True
+
+ def at_get(self, getter):
+ """
+ Called by the default `get` command when this object has been
+ picked up.
+
+ Args:
+ getter (Object): The object getting this object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Notes:
+ This hook cannot stop the pickup from happening. Use
+ permissions or the at_before_get() hook for that.
+
+ """
+ # If gotten, erase range values
+ if self.db.combat_range:
+ del self.db.combat_range
+ # Remove this object from everyone's range fields
+ for thing in getter.location.contents:
+ if thing.db.combat_range:
+ if self in thing.db.combat_range:
+ thing.db.combat_range.pop(self, None)
+ # If in combat, getter spends an action
+ if is_in_combat(getter):
+ spend_action(getter, 1, action_name="get") # Use up one action.
+
+ def at_before_give(self, giver, getter):
+ """
+ Called by the default `give` command before this object has been
+ given.
+
+ Args:
+ giver (Object): The object about to give this object.
+ getter (Object): The object about to get this object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ shouldgive (bool): If the object should be given or not.
+
+ Notes:
+ If this method returns False/None, the giving is cancelled
+ before it is even started.
+
+ """
+ # Restrictions for giving in combat
+ if is_in_combat(giver):
+ if not is_turn(giver): # Not your turn
+ giver.msg("You can only give things on your turn!")
+ return False
+ if get_range(giver, getter) > 0: # Too far away from target
+ giver.msg("You aren't close enough to give things to %s! (see: help approach)" % getter)
+ return False
+ return True
+
+ def at_give(self, giver, getter):
+ """
+ Called by the default `give` command when this object has been
+ given.
+
+ Args:
+ giver (Object): The object giving this object.
+ getter (Object): The object getting this object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Notes:
+ This hook cannot stop the give from happening. Use
+ permissions or the at_before_give() hook for that.
+
+ """
+ # Spend an action if in combat
+ if is_in_combat(giver):
+ spend_action(giver, 1, action_name="give") # Use up one action.
+
+"""
+----------------------------------------------------------------------------
+COMMANDS START HERE
+----------------------------------------------------------------------------
+"""
+
+
+class CmdFight(Command):
+ """
+ Starts a fight with everyone in the same room as you.
+
+ Usage:
+ fight
+
+ When you start a fight, everyone in the room who is able to
+ fight is added to combat, and a turn order is randomly rolled.
+ When it's your turn, you can attack other characters.
+ """
+ key = "fight"
+ help_category = "combat"
+
+ def func(self):
+ """
+ This performs the actual command.
+ """
+ here = self.caller.location
+ fighters = []
+
+ if not self.caller.db.hp: # If you don't have any hp
+ self.caller.msg("You can't start a fight if you've been defeated!")
+ return
+ if is_in_combat(self.caller): # Already in a fight
+ self.caller.msg("You're already in a fight!")
+ return
+ for thing in here.contents: # Test everything in the room to add it to the fight.
+ if thing.db.HP: # If the object has HP...
+ fighters.append(thing) # ...then add it to the fight.
+ if len(fighters) <= 1: # If you're the only able fighter in the room
+ self.caller.msg("There's nobody here to fight!")
+ return
+ if here.db.combat_turnhandler: # If there's already a fight going on...
+ here.msg_contents("%s joins the fight!" % self.caller)
+ here.db.combat_turnhandler.join_fight(self.caller) # Join the fight!
+ return
+ here.msg_contents("%s starts a fight!" % self.caller)
+ # Add a turn handler script to the room, which starts combat.
+ here.scripts.add("contrib.turnbattle.tb_range.TBRangeTurnHandler")
+ # Remember you'll have to change the path to the script if you copy this code to your own modules!
+
+
+class CmdAttack(Command):
+ """
+ Attacks another character in melee.
+
+ Usage:
+ attack
+
+ When in a fight, you may attack another character. The attack has
+ a chance to hit, and if successful, will deal damage. You can only
+ attack engaged targets - that is, targets that are right next to
+ you. Use the 'approach' command to get closer to a target.
+ """
+
+ key = "attack"
+ help_category = "combat"
+
+ def func(self):
+ "This performs the actual command."
+ "Set the attacker to the caller and the defender to the target."
+
+ if not is_in_combat(self.caller): # If not in combat, can't attack.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ if not is_turn(self.caller): # If it's not your turn, can't attack.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ if not self.caller.db.hp: # Can't attack if you have no HP.
+ self.caller.msg("You can't attack, you've been defeated.")
+ return
+
+ attacker = self.caller
+ defender = self.caller.search(self.args)
+
+ if not defender: # No valid target given.
+ return
+
+ if not defender.db.hp: # Target object has no HP left or to begin with
+ self.caller.msg("You can't fight that!")
+ return
+
+ if attacker == defender: # Target and attacker are the same
+ self.caller.msg("You can't attack yourself!")
+ return
+
+ if not get_range(attacker, defender) == 0: # Target isn't in melee
+ self.caller.msg("%s is too far away to attack - you need to get closer! (see: help approach)" % defender)
+ return
+
+ "If everything checks out, call the attack resolving function."
+ resolve_attack(attacker, defender, "melee")
+ spend_action(self.caller, 1, action_name="attack") # Use up one action.
+
+class CmdShoot(Command):
+ """
+ Attacks another character from range.
+
+ Usage:
+ shoot
+
+ When in a fight, you may shoot another character. The attack has
+ a chance to hit, and if successful, will deal damage. You can attack
+ any target in combat by shooting, but can't shoot if there are any
+ targets engaged with you. Use the 'withdraw' command to retreat from
+ nearby enemies.
+ """
+
+ key = "shoot"
+ help_category = "combat"
+
+ def func(self):
+ "This performs the actual command."
+ "Set the attacker to the caller and the defender to the target."
+
+ if not is_in_combat(self.caller): # If not in combat, can't attack.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ if not is_turn(self.caller): # If it's not your turn, can't attack.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ if not self.caller.db.hp: # Can't attack if you have no HP.
+ self.caller.msg("You can't attack, you've been defeated.")
+ return
+
+ attacker = self.caller
+ defender = self.caller.search(self.args)
+
+ if not defender: # No valid target given.
+ return
+
+ if not defender.db.hp: # Target object has no HP left or to begin with
+ self.caller.msg("You can't fight that!")
+ return
+
+ if attacker == defender: # Target and attacker are the same
+ self.caller.msg("You can't attack yourself!")
+ return
+
+ # Test to see if there are any nearby enemy targets.
+ in_melee = []
+ for target in attacker.db.combat_range:
+ # Object is engaged and has HP
+ if get_range(attacker, defender) == 0 and target.db.hp and target != self.caller:
+ in_melee.append(target) # Add to list of targets in melee
+
+ if len(in_melee) > 0:
+ self.caller.msg("You can't shoot because there are fighters engaged with you (%s) - you need to retreat! (see: help withdraw)" % ", ".join(obj.key for obj in in_melee))
+ return
+
+ "If everything checks out, call the attack resolving function."
+ resolve_attack(attacker, defender, "ranged")
+ spend_action(self.caller, 1, action_name="attack") # Use up one action.
+
+class CmdApproach(Command):
+ """
+ Approaches an object.
+
+ Usage:
+ approach
+
+ Move one space toward a character or object. You can only attack
+ characters you are 0 spaces away from.
+ """
+
+ key = "approach"
+ help_category = "combat"
+
+ def func(self):
+ "This performs the actual command."
+
+ if not is_in_combat(self.caller): # If not in combat, can't approach.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ if not is_turn(self.caller): # If it's not your turn, can't approach.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ if not self.caller.db.hp: # Can't approach if you have no HP.
+ self.caller.msg("You can't move, you've been defeated.")
+ return
+
+ mover = self.caller
+ target = self.caller.search(self.args)
+
+ if not target: # No valid target given.
+ return
+
+ if not target.db.combat_range: # Target object is not on the range field
+ self.caller.msg("You can't move toward that!")
+ return
+
+ if mover == target: # Target and mover are the same
+ self.caller.msg("You can't move toward yourself!")
+ return
+
+ if get_range(mover, target) <= 0: # Already engaged with target
+ self.caller.msg("You're already next to that target!")
+ return
+
+ # If everything checks out, call the approach resolving function.
+ approach(mover, target)
+ mover.location.msg_contents("%s moves toward %s." % (mover, target))
+ spend_action(self.caller, 1, action_name="move") # Use up one action.
+
+class CmdWithdraw(Command):
+ """
+ Moves away from an object.
+
+ Usage:
+ withdraw
+
+ Move one space away from a character or object.
+ """
+
+ key = "withdraw"
+ help_category = "combat"
+
+ def func(self):
+ "This performs the actual command."
+
+ if not is_in_combat(self.caller): # If not in combat, can't withdraw.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ if not is_turn(self.caller): # If it's not your turn, can't withdraw.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ if not self.caller.db.hp: # Can't withdraw if you have no HP.
+ self.caller.msg("You can't move, you've been defeated.")
+ return
+
+ mover = self.caller
+ target = self.caller.search(self.args)
+
+ if not target: # No valid target given.
+ return
+
+ if not target.db.combat_range: # Target object is not on the range field
+ self.caller.msg("You can't move away from that!")
+ return
+
+ if mover == target: # Target and mover are the same
+ self.caller.msg("You can't move away from yourself!")
+ return
+
+ if mover.db.combat_range[target] >= 3: # Already at maximum distance
+ self.caller.msg("You're as far as you can get from that target!")
+ return
+
+ # If everything checks out, call the approach resolving function.
+ withdraw(mover, target)
+ mover.location.msg_contents("%s moves away from %s." % (mover, target))
+ spend_action(self.caller, 1, action_name="move") # Use up one action.
+
+
+class CmdPass(Command):
+ """
+ Passes on your turn.
+
+ Usage:
+ pass
+
+ When in a fight, you can use this command to end your turn early, even
+ if there are still any actions you can take.
+ """
+
+ key = "pass"
+ aliases = ["wait", "hold"]
+ help_category = "combat"
+
+ def func(self):
+ """
+ This performs the actual command.
+ """
+ if not is_in_combat(self.caller): # Can only pass a turn in combat.
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ if not is_turn(self.caller): # Can only pass if it's your turn.
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ self.caller.location.msg_contents("%s takes no further action, passing the turn." % self.caller)
+ spend_action(self.caller, 'all', action_name="pass") # Spend all remaining actions.
+
+
+class CmdDisengage(Command):
+ """
+ Passes your turn and attempts to end combat.
+
+ Usage:
+ disengage
+
+ Ends your turn early and signals that you're trying to end
+ the fight. If all participants in a fight disengage, the
+ fight ends.
+ """
+
+ key = "disengage"
+ aliases = ["spare"]
+ help_category = "combat"
+
+ def func(self):
+ """
+ This performs the actual command.
+ """
+ if not is_in_combat(self.caller): # If you're not in combat
+ self.caller.msg("You can only do that in combat. (see: help fight)")
+ return
+
+ if not is_turn(self.caller): # If it's not your turn
+ self.caller.msg("You can only do that on your turn.")
+ return
+
+ self.caller.location.msg_contents("%s disengages, ready to stop fighting." % self.caller)
+ spend_action(self.caller, 'all', action_name="disengage") # Spend all remaining actions.
+ """
+ The action_name kwarg sets the character's last action to "disengage", which is checked by
+ the turn handler script to see if all fighters have disengaged.
+ """
+
+
+class CmdRest(Command):
+ """
+ Recovers damage.
+
+ Usage:
+ rest
+
+ Resting recovers your HP to its maximum, but you can only
+ rest if you're not in a fight.
+ """
+
+ key = "rest"
+ help_category = "combat"
+
+ def func(self):
+ "This performs the actual command."
+
+ if is_in_combat(self.caller): # If you're in combat
+ self.caller.msg("You can't rest while you're in combat.")
+ return
+
+ self.caller.db.hp = self.caller.db.max_hp # Set current HP to maximum
+ self.caller.location.msg_contents("%s rests to recover HP." % self.caller)
+ """
+ You'll probably want to replace this with your own system for recovering HP.
+ """
+
+class CmdStatus(Command):
+ """
+ Gives combat information.
+
+ Usage:
+ status
+
+ Shows your current and maximum HP and your distance from
+ other targets in combat.
+ """
+
+ key = "status"
+ help_category = "combat"
+
+ def func(self):
+ "This performs the actual command."
+ combat_status_message(self.caller)
+
+
+class CmdCombatHelp(CmdHelp):
+ """
+ View help or a list of topics
+
+ Usage:
+ help
+ help list
+ help all
+
+ This will search for help on commands and other
+ topics related to the game.
+ """
+ # Just like the default help command, but will give quick
+ # tips on combat when used in a fight with no arguments.
+
+ def func(self):
+ if is_in_combat(self.caller) and not self.args: # In combat and entered 'help' alone
+ self.caller.msg("Available combat commands:|/" +
+ "|wAttack:|n Attack an engaged target, attempting to deal damage.|/" +
+ "|wShoot:|n Attack from a distance, if not engaged with other fighters.|/" +
+ "|wApproach:|n Move one step cloer to a target.|/" +
+ "|wWithdraw:|n Move one step away from a target.|/" +
+ "|wPass:|n Pass your turn without further action.|/" +
+ "|wStatus:|n View current HP and ranges to other targets.|/" +
+ "|wDisengage:|n End your turn and attempt to end combat.|/")
+ else:
+ super(CmdCombatHelp, self).func() # Call the default help command
+
+
+class BattleCmdSet(default_cmds.CharacterCmdSet):
+ """
+ This command set includes all the commmands used in the battle system.
+ """
+ key = "DefaultCharacter"
+
+ def at_cmdset_creation(self):
+ """
+ Populates the cmdset
+ """
+ self.add(CmdFight())
+ self.add(CmdAttack())
+ self.add(CmdShoot())
+ self.add(CmdRest())
+ self.add(CmdPass())
+ self.add(CmdDisengage())
+ self.add(CmdApproach())
+ self.add(CmdWithdraw())
+ self.add(CmdStatus())
+ self.add(CmdCombatHelp())
\ No newline at end of file
diff --git a/evennia/contrib/tutorial_world/build.ev b/evennia/contrib/tutorial_world/build.ev
index 682171f66c..1184865004 100644
--- a/evennia/contrib/tutorial_world/build.ev
+++ b/evennia/contrib/tutorial_world/build.ev
@@ -749,9 +749,9 @@ hole
to the ground together with the stone archway that once help it up.
#
# We lock the bridge exit for the mob, so it don't wander out on the bridge. Only
-# traversing objects controlled by a player (i.e. Characters) may cross the bridge.
+# traversing objects controlled by an account (i.e. Characters) may cross the bridge.
#
-@lock bridge = traverse:has_player()
+@lock bridge = traverse:has_account()
#------------------------------------------------------------
#
@@ -997,7 +997,7 @@ mobon ghost
The stairs are worn by the age-old passage of feet.
#
# Lock the antechamber so the ghost cannot get in there.
-@lock stairs down = traverse:has_player()
+@lock stairs down = traverse:has_account()
#
# Go down
#
diff --git a/evennia/contrib/tutorial_world/objects.py b/evennia/contrib/tutorial_world/objects.py
index 0561f98d03..331b6b1a21 100644
--- a/evennia/contrib/tutorial_world/objects.py
+++ b/evennia/contrib/tutorial_world/objects.py
@@ -24,7 +24,7 @@ import random
from evennia import DefaultObject, DefaultExit, Command, CmdSet
from evennia.utils import search, delay
-from evennia.utils.spawner import spawn
+from evennia.prototypes.spawner import spawn
# -------------------------------------------------------------
#
@@ -475,14 +475,14 @@ class CmdShiftRoot(Command):
root_pos["blue"] -= 1
self.caller.msg("The root with blue flowers gets in the way and is pushed to the left.")
else:
- self.caller.msg("You cannot move the root in that direction.")
+ self.caller.msg("The root hangs straight down - you can only move it left or right.")
elif color == "blue":
if direction == "left":
root_pos[color] = max(-1, root_pos[color] - 1)
self.caller.msg("You shift the root with small blue flowers to the left.")
if root_pos[color] != 0 and root_pos[color] == root_pos["red"]:
root_pos["red"] += 1
- self.caller.msg("The reddish root is to big to fit as well, so that one falls away to the left.")
+ self.caller.msg("The reddish root is too big to fit as well, so that one falls away to the left.")
elif direction == "right":
root_pos[color] = min(1, root_pos[color] + 1)
self.caller.msg("You shove the root adorned with small blue flowers to the right.")
@@ -490,7 +490,7 @@ class CmdShiftRoot(Command):
root_pos["red"] -= 1
self.caller.msg("The thick reddish root gets in the way and is pushed back to the left.")
else:
- self.caller.msg("You cannot move the root in that direction.")
+ self.caller.msg("The root hangs straight down - you can only move it left or right.")
# now the horizontal roots (yellow/green). They can be moved up/down
elif color == "yellow":
@@ -507,7 +507,7 @@ class CmdShiftRoot(Command):
root_pos["green"] -= 1
self.caller.msg("The weedy green root is shifted upwards to make room.")
else:
- self.caller.msg("You cannot move the root in that direction.")
+ self.caller.msg("The root hangs across the wall - you can only move it up or down.")
elif color == "green":
if direction == "up":
root_pos[color] = max(-1, root_pos[color] - 1)
@@ -522,7 +522,7 @@ class CmdShiftRoot(Command):
root_pos["yellow"] -= 1
self.caller.msg("The root with yellow flowers gets in the way and is pushed upwards.")
else:
- self.caller.msg("You cannot move the root in that direction.")
+ self.caller.msg("The root hangs across the wall - you can only move it up or down.")
# we have moved the root. Store new position
self.obj.db.root_pos = root_pos
@@ -905,19 +905,19 @@ WEAPON_PROTOTYPES = {
"magic": False,
"desc": "A generic blade."},
"knife": {
- "prototype": "weapon",
+ "prototype_parent": "weapon",
"aliases": "sword",
"key": "Kitchen knife",
"desc": "A rusty kitchen knife. Better than nothing.",
"damage": 3},
"dagger": {
- "prototype": "knife",
+ "prototype_parent": "knife",
"key": "Rusty dagger",
"aliases": ["knife", "dagger"],
"desc": "A double-edged dagger with a nicked edge and a wooden handle.",
"hit": 0.25},
"sword": {
- "prototype": "weapon",
+ "prototype_parent": "weapon",
"key": "Rusty sword",
"aliases": ["sword"],
"desc": "A rusty shortsword. It has a leather-wrapped handle covered i food grease.",
@@ -925,28 +925,28 @@ WEAPON_PROTOTYPES = {
"damage": 5,
"parry": 0.5},
"club": {
- "prototype": "weapon",
+ "prototype_parent": "weapon",
"key": "Club",
"desc": "A heavy wooden club, little more than a heavy branch.",
"hit": 0.4,
"damage": 6,
"parry": 0.2},
"axe": {
- "prototype": "weapon",
+ "prototype_parent": "weapon",
"key": "Axe",
"desc": "A woodcutter's axe with a keen edge.",
"hit": 0.4,
"damage": 6,
"parry": 0.2},
"ornate longsword": {
- "prototype": "sword",
+ "prototype_parent": "sword",
"key": "Ornate longsword",
"desc": "A fine longsword with some swirling patterns on the handle.",
"hit": 0.5,
"magic": True,
"damage": 5},
"warhammer": {
- "prototype": "club",
+ "prototype_parent": "club",
"key": "Silver Warhammer",
"aliases": ["hammer", "warhammer", "war"],
"desc": "A heavy war hammer with silver ornaments. This huge weapon causes massive damage - if you can hit.",
@@ -954,21 +954,21 @@ WEAPON_PROTOTYPES = {
"magic": True,
"damage": 8},
"rune axe": {
- "prototype": "axe",
+ "prototype_parent": "axe",
"key": "Runeaxe",
"aliases": ["axe"],
"hit": 0.4,
"magic": True,
"damage": 6},
"thruning": {
- "prototype": "ornate longsword",
+ "prototype_parent": "ornate longsword",
"key": "Broadsword named Thruning",
"desc": "This heavy bladed weapon is marked with the name 'Thruning'. It is very powerful in skilled hands.",
"hit": 0.6,
"parry": 0.6,
"damage": 7},
"slayer waraxe": {
- "prototype": "rune axe",
+ "prototype_parent": "rune axe",
"key": "Slayer waraxe",
"aliases": ["waraxe", "war", "slayer"],
"desc": "A huge double-bladed axe marked with the runes for 'Slayer'."
@@ -976,7 +976,7 @@ WEAPON_PROTOTYPES = {
"hit": 0.7,
"damage": 8},
"ghostblade": {
- "prototype": "ornate longsword",
+ "prototype_parent": "ornate longsword",
"key": "The Ghostblade",
"aliases": ["blade", "ghost"],
"desc": "This massive sword is large as you are tall, yet seems to weigh almost nothing."
@@ -985,7 +985,7 @@ WEAPON_PROTOTYPES = {
"parry": 0.8,
"damage": 10},
"hawkblade": {
- "prototype": "ghostblade",
+ "prototype_parent": "ghostblade",
"key": "The Hawkblade",
"aliases": ["hawk", "blade"],
"desc": "The weapon of a long-dead heroine and a more civilized age,"
diff --git a/evennia/contrib/tutorial_world/rooms.py b/evennia/contrib/tutorial_world/rooms.py
index 15bddefb01..58e13a1356 100644
--- a/evennia/contrib/tutorial_world/rooms.py
+++ b/evennia/contrib/tutorial_world/rooms.py
@@ -747,9 +747,16 @@ class CmdLookDark(Command):
"""
caller = self.caller
- if random.random() < 0.8:
+ # count how many searches we've done
+ nr_searches = caller.ndb.dark_searches
+ if nr_searches is None:
+ nr_searches = 0
+ caller.ndb.dark_searches = nr_searches
+
+ if nr_searches < 4 and random.random() < 0.90:
# we don't find anything
caller.msg(random.choice(DARK_MESSAGES))
+ caller.ndb.dark_searches += 1
else:
# we could have found something!
if any(obj for obj in caller.contents if utils.inherits_from(obj, LightSource)):
@@ -791,7 +798,8 @@ class CmdDarkNoMatch(Command):
def func(self):
"""Implements the command."""
- self.caller.msg("Until you find some light, there's not much you can do. Try feeling around.")
+ self.caller.msg("Until you find some light, there's not much you can do. "
+ "Try feeling around, maybe you'll find something helpful!")
class DarkCmdSet(CmdSet):
@@ -814,7 +822,9 @@ class DarkCmdSet(CmdSet):
self.add(CmdLookDark())
self.add(CmdDarkHelp())
self.add(CmdDarkNoMatch())
- self.add(default_cmds.CmdSay)
+ self.add(default_cmds.CmdSay())
+ self.add(default_cmds.CmdQuit())
+ self.add(default_cmds.CmdHome())
class DarkRoom(TutorialRoom):
diff --git a/evennia/game_template/server/conf/settings.py b/evennia/game_template/server/conf/settings.py
index 7fe163b833..a8f9776805 100644
--- a/evennia/game_template/server/conf/settings.py
+++ b/evennia/game_template/server/conf/settings.py
@@ -34,27 +34,6 @@ from evennia.settings_default import *
# This is the name of your game. Make it catchy!
SERVERNAME = {servername}
-# Server ports. If enabled and marked as "visible", the port
-# should be visible to the outside world on a production server.
-# Note that there are many more options available beyond these.
-
-# Telnet ports. Visible.
-TELNET_ENABLED = True
-TELNET_PORTS = [4000]
-# (proxy, internal). Only proxy should be visible.
-WEBSERVER_ENABLED = True
-WEBSERVER_PORTS = [(4001, 4002)]
-# Telnet+SSL ports, for supporting clients. Visible.
-SSL_ENABLED = False
-SSL_PORTS = [4003]
-# SSH client ports. Requires crypto lib. Visible.
-SSH_ENABLED = False
-SSH_PORTS = [4004]
-# Websocket-client port. Visible.
-WEBSOCKET_CLIENT_ENABLED = True
-WEBSOCKET_CLIENT_PORT = 4005
-# Internal Server-Portal port. Not visible.
-AMP_PORT = 4006
######################################################################
# Settings given in secret_settings.py override those in this file.
@@ -62,4 +41,4 @@ AMP_PORT = 4006
try:
from server.conf.secret_settings import *
except ImportError:
- print "secret_settings.py file not found or failed to import."
+ print("secret_settings.py file not found or failed to import.")
diff --git a/evennia/game_template/typeclasses/accounts.py b/evennia/game_template/typeclasses/accounts.py
index bbab3d4f22..99d861bf0b 100644
--- a/evennia/game_template/typeclasses/accounts.py
+++ b/evennia/game_template/typeclasses/accounts.py
@@ -65,7 +65,6 @@ class Account(DefaultAccount):
* Helper methods
msg(text=None, **kwargs)
- swap_character(new_character, delete_old_character=False)
execute_cmd(raw_string, session=None)
search(ostring, global_search=False, attribute_name=None, use_nicks=False, location=None, ignore_errors=False, account=False)
is_typeclass(typeclass, exact=False)
diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py
index b8801f9655..d7170c54d1 100644
--- a/evennia/locks/lockhandler.py
+++ b/evennia/locks/lockhandler.py
@@ -114,6 +114,9 @@ from django.utils.translation import ugettext as _
__all__ = ("LockHandler", "LockException")
WARNING_LOG = settings.LOCKWARNING_LOG_FILE
+_LOCK_HANDLER = None
+
+
#
# Exception class. This will be raised
@@ -287,7 +290,7 @@ class LockHandler(object):
"""
self.lock_bypass = hasattr(obj, "is_superuser") and obj.is_superuser
- def add(self, lockstring):
+ def add(self, lockstring, validate_only=False):
"""
Add a new lockstring to handler.
@@ -296,10 +299,12 @@ class LockHandler(object):
`":"`. Multiple access types
should be separated by semicolon (`;`). Alternatively,
a list with lockstrings.
-
+ validate_only (bool, optional): If True, validate the lockstring but
+ don't actually store it.
Returns:
success (bool): The outcome of the addition, `False` on
- error.
+ error. If `validate_only` is True, this will be a tuple
+ (bool, error), for pass/fail and a string error.
"""
if isinstance(lockstring, basestring):
@@ -308,21 +313,41 @@ class LockHandler(object):
lockdefs = [lockdef for locks in lockstring for lockdef in locks.split(";")]
lockstring = ";".join(lockdefs)
+ err = ""
# sanity checks
for lockdef in lockdefs:
if ':' not in lockdef:
- self._log_error(_("Lock: '%s' contains no colon (:).") % lockdef)
- return False
+ err = _("Lock: '{lockdef}' contains no colon (:).").format(lockdef=lockdef)
+ if validate_only:
+ return False, err
+ else:
+ self._log_error(err)
+ return False
access_type, rhs = [part.strip() for part in lockdef.split(':', 1)]
if not access_type:
- self._log_error(_("Lock: '%s' has no access_type (left-side of colon is empty).") % lockdef)
- return False
+ err = _("Lock: '{lockdef}' has no access_type "
+ "(left-side of colon is empty).").format(lockdef=lockdef)
+ if validate_only:
+ return False, err
+ else:
+ self._log_error(err)
+ return False
if rhs.count('(') != rhs.count(')'):
- self._log_error(_("Lock: '%s' has mismatched parentheses.") % lockdef)
- return False
+ err = _("Lock: '{lockdef}' has mismatched parentheses.").format(lockdef=lockdef)
+ if validate_only:
+ return False, err
+ else:
+ self._log_error(err)
+ return False
if not _RE_FUNCS.findall(rhs):
- self._log_error(_("Lock: '%s' has no valid lock functions.") % lockdef)
- return False
+ err = _("Lock: '{lockdef}' has no valid lock functions.").format(lockdef=lockdef)
+ if validate_only:
+ return False, err
+ else:
+ self._log_error(err)
+ return False
+ if validate_only:
+ return True, None
# get the lock string
storage_lockstring = self.obj.lock_storage
if storage_lockstring:
@@ -334,6 +359,18 @@ class LockHandler(object):
self._save_locks()
return True
+ def validate(self, lockstring):
+ """
+ Validate lockstring syntactically, without saving it.
+
+ Args:
+ lockstring (str): Lockstring to validate.
+ Returns:
+ valid (bool): If validation passed or not.
+
+ """
+ return self.add(lockstring, validate_only=True)
+
def replace(self, lockstring):
"""
Replaces the lockstring entirely.
@@ -421,6 +458,28 @@ class LockHandler(object):
self._cache_locks(self.obj.lock_storage)
self.cache_lock_bypass(self.obj)
+ def append(self, access_type, lockstring, op='or'):
+ """
+ Append a lock definition to access_type if it doesn't already exist.
+
+ Args:
+ access_type (str): Access type.
+ lockstring (str): A valid lockstring, without the operator to
+ link it to an eventual existing lockstring.
+ op (str): An operator 'and', 'or', 'and not', 'or not' used
+ for appending the lockstring to an existing access-type.
+ Note:
+ The most common use of this method is for use in commands where
+ the user can specify their own lockstrings. This method allows
+ the system to auto-add things like Admin-override access.
+
+ """
+ old_lockstring = self.get(access_type)
+ if not lockstring.strip().lower() in old_lockstring.lower():
+ lockstring = "{old} {op} {new}".format(
+ old=old_lockstring, op=op, new=lockstring.strip())
+ self.add(lockstring)
+
def check(self, accessing_obj, access_type, default=False, no_superuser_bypass=False):
"""
Checks a lock of the correct type by passing execution off to
@@ -459,9 +518,13 @@ class LockHandler(object):
return True
except AttributeError:
# happens before session is initiated.
- if not no_superuser_bypass and ((hasattr(accessing_obj, 'is_superuser') and accessing_obj.is_superuser) or
- (hasattr(accessing_obj, 'account') and hasattr(accessing_obj.account, 'is_superuser') and accessing_obj.account.is_superuser) or
- (hasattr(accessing_obj, 'get_account') and (not accessing_obj.get_account() or accessing_obj.get_account().is_superuser))):
+ if not no_superuser_bypass and (
+ (hasattr(accessing_obj, 'is_superuser') and accessing_obj.is_superuser) or
+ (hasattr(accessing_obj, 'account') and
+ hasattr(accessing_obj.account, 'is_superuser') and
+ accessing_obj.account.is_superuser) or
+ (hasattr(accessing_obj, 'get_account') and
+ (not accessing_obj.get_account() or accessing_obj.get_account().is_superuser))):
return True
# no superuser or bypass -> normal lock operation
@@ -469,7 +532,8 @@ class LockHandler(object):
# we have a lock, test it.
evalstring, func_tup, raw_string = self.locks[access_type]
# execute all lock funcs in the correct order, producing a tuple of True/False results.
- true_false = tuple(bool(tup[0](accessing_obj, self.obj, *tup[1], **tup[2])) for tup in func_tup)
+ true_false = tuple(bool(
+ tup[0](accessing_obj, self.obj, *tup[1], **tup[2])) for tup in func_tup)
# the True/False tuple goes into evalstring, which combines them
# with AND/OR/NOT in order to get the final result.
return eval(evalstring % true_false)
@@ -520,9 +584,13 @@ class LockHandler(object):
if accessing_obj.locks.lock_bypass and not no_superuser_bypass:
return True
except AttributeError:
- if no_superuser_bypass and ((hasattr(accessing_obj, 'is_superuser') and accessing_obj.is_superuser) or
- (hasattr(accessing_obj, 'account') and hasattr(accessing_obj.account, 'is_superuser') and accessing_obj.account.is_superuser) or
- (hasattr(accessing_obj, 'get_account') and (not accessing_obj.get_account() or accessing_obj.get_account().is_superuser))):
+ if no_superuser_bypass and (
+ (hasattr(accessing_obj, 'is_superuser') and accessing_obj.is_superuser) or
+ (hasattr(accessing_obj, 'account') and
+ hasattr(accessing_obj.account, 'is_superuser') and
+ accessing_obj.account.is_superuser) or
+ (hasattr(accessing_obj, 'get_account') and
+ (not accessing_obj.get_account() or accessing_obj.get_account().is_superuser))):
return True
if ":" not in lockstring:
lockstring = "%s:%s" % ("_dummy", lockstring)
@@ -538,7 +606,81 @@ class LockHandler(object):
else:
# if no access types was given and multiple locks were
# embedded in the lockstring we assume all must be true
- return all(self._eval_access_type(accessing_obj, locks, access_type) for access_type in locks)
+ return all(self._eval_access_type(
+ accessing_obj, locks, access_type) for access_type in locks)
+
+
+# convenience access function
+
+# dummy to be able to call check_lockstring from the outside
+
+class _ObjDummy:
+ lock_storage = ''
+
+
+def check_lockstring(self, accessing_obj, lockstring, no_superuser_bypass=False,
+ default=False, access_type=None):
+ """
+ Do a direct check against a lockstring ('atype:func()..'),
+ without any intermediary storage on the accessed object.
+
+ Args:
+ accessing_obj (object or None): The object seeking access.
+ Importantly, this can be left unset if the lock functions
+ don't access it, no updating or storage of locks are made
+ against this object in this method.
+ lockstring (str): Lock string to check, on the form
+ `"access_type:lock_definition"` where the `access_type`
+ part can potentially be set to a dummy value to just check
+ a lock condition.
+ no_superuser_bypass (bool, optional): Force superusers to heed lock.
+ default (bool, optional): Fallback result to use if `access_type` is set
+ but no such `access_type` is found in the given `lockstring`.
+ access_type (str, bool): If set, only this access_type will be looked up
+ among the locks defined by `lockstring`.
+
+ Return:
+ access (bool): If check is passed or not.
+
+ """
+ global _LOCKHANDLER
+ if not _LOCKHANDLER:
+ _LOCKHANDLER = LockHandler(_ObjDummy())
+ return _LOCK_HANDLER.check_lockstring(
+ accessing_obj, lockstring, no_superuser_bypass=no_superuser_bypass,
+ default=default, access_type=access_type)
+
+
+def validate_lockstring(lockstring):
+ """
+ Validate so lockstring is on a valid form.
+
+ Args:
+ lockstring (str): Lockstring to validate.
+
+ Returns:
+ is_valid (bool): If the lockstring is valid or not.
+ error (str or None): A string describing the error, or None
+ if no error was found.
+
+ """
+ global _LOCK_HANDLER
+ if not _LOCK_HANDLER:
+ _LOCK_HANDLER = LockHandler(_ObjDummy())
+ return _LOCK_HANDLER.validate(lockstring)
+
+
+def get_all_lockfuncs():
+ """
+ Get a dict of available lock funcs.
+
+ Returns:
+ lockfuncs (dict): Mapping {lockfuncname:func}.
+
+ """
+ if not _LOCKFUNCS:
+ _cache_lockfuncs()
+ return _LOCKFUNCS
def _test():
diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py
index 8cdf546706..27d8147999 100644
--- a/evennia/objects/objects.py
+++ b/evennia/objects/objects.py
@@ -6,8 +6,10 @@ entities.
"""
import time
+import inflect
from builtins import object
from future.utils import with_metaclass
+from collections import defaultdict
from django.conf import settings
@@ -21,10 +23,13 @@ from evennia.commands.cmdsethandler import CmdSetHandler
from evennia.commands import cmdhandler
from evennia.utils import search
from evennia.utils import logger
+from evennia.utils import ansi
from evennia.utils.utils import (variable_from_module, lazy_property,
- make_iter, to_unicode, is_iter, to_str)
+ make_iter, to_unicode, is_iter, list_to_string,
+ to_str)
from django.utils.translation import ugettext as _
+_INFLECT = inflect.engine()
_MULTISESSION_MODE = settings.MULTISESSION_MODE
_ScriptDB = None
@@ -289,9 +294,40 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
return "{}(#{})".format(self.name, self.id)
return self.name
+ def get_numbered_name(self, count, looker, **kwargs):
+ """
+ Return the numbered (singular, plural) forms of this object's key. This is by default called
+ by return_appearance and is used for grouping multiple same-named of this object. Note that
+ this will be called on *every* member of a group even though the plural name will be only
+ shown once. Also the singular display version, such as 'an apple', 'a tree' is determined
+ from this method.
+
+ Args:
+ count (int): Number of objects of this type
+ looker (Object): Onlooker. Not used by default.
+ Kwargs:
+ key (str): Optional key to pluralize, if given, use this instead of the object's key.
+ Returns:
+ singular (str): The singular form to display.
+ plural (str): The determined plural form of the key, including the count.
+ """
+ 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)
+ 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
+ self.aliases.clear(category="plural_key")
+ self.aliases.add(plural, category="plural_key")
+ # save the singular form as an alias here too so we can display "an egg" and also
+ # look at 'an egg'.
+ self.aliases.add(singular, category="plural_key")
+ return singular, plural
+
def search(self, searchdata,
global_search=False,
- use_nicks=True, # should this default to off?
+ use_nicks=True,
typeclass=None,
location=None,
attribute_name=None,
@@ -969,14 +1005,14 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
cdict["location"].at_object_receive(self, None)
self.at_after_move(None)
if cdict.get("tags"):
- # this should be a list of tags
+ # this should be a list of tags, tuples (key, category) or (key, category, data)
self.tags.batch_add(*cdict["tags"])
if cdict.get("attributes"):
- # this should be a dict of attrname:value
+ # this should be tuples (key, val, ...)
self.attributes.batch_add(*cdict["attributes"])
if cdict.get("nattributes"):
# this should be a dict of nattrname:value
- for key, value in cdict["nattributes"].items():
+ for key, value in cdict["nattributes"]:
self.nattributes.add(key, value)
del self._createdict
@@ -1450,7 +1486,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
# get and identify all objects
visible = (con for con in self.contents if con != looker and
con.access(looker, "view"))
- exits, users, things = [], [], []
+ exits, users, things = [], [], defaultdict(list)
for con in visible:
key = con.get_display_name(looker)
if con.destination:
@@ -1458,16 +1494,28 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
elif con.has_account:
users.append("|c%s|n" % key)
else:
- things.append(key)
+ # things can be pluralized
+ things[key].append(con)
# get description, build string
string = "|c%s|n\n" % self.get_display_name(looker)
desc = self.db.desc
if desc:
string += "%s" % desc
if exits:
- string += "\n|wExits:|n " + ", ".join(exits)
+ string += "\n|wExits:|n " + list_to_string(exits)
if users or things:
- string += "\n|wYou see:|n " + ", ".join(users + things)
+ # handle pluralization of things (never pluralize users)
+ thing_strings = []
+ for key, itemlist in sorted(things.iteritems()):
+ nitem = len(itemlist)
+ if nitem == 1:
+ key, _ = itemlist[0].get_numbered_name(nitem, looker, key=key)
+ else:
+ key = [item.get_numbered_name(nitem, looker, key=key)[1] for item in itemlist][0]
+ thing_strings.append(key)
+
+ string += "\n|wYou see:|n " + list_to_string(users + thing_strings)
+
return string
def at_look(self, target, **kwargs):
@@ -1514,6 +1562,25 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
"""
pass
+ def at_before_get(self, getter, **kwargs):
+ """
+ Called by the default `get` command before this object has been
+ picked up.
+
+ Args:
+ getter (Object): The object about to get this object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ shouldget (bool): If the object should be gotten or not.
+
+ Notes:
+ If this method returns False/None, the getting is cancelled
+ before it is even started.
+ """
+ return True
+
def at_get(self, getter, **kwargs):
"""
Called by the default `get` command when this object has been
@@ -1526,11 +1593,32 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
Notes:
This hook cannot stop the pickup from happening. Use
- permissions for that.
+ permissions or the at_before_get() hook for that.
"""
pass
+ def at_before_give(self, giver, getter, **kwargs):
+ """
+ Called by the default `give` command before this object has been
+ given.
+
+ Args:
+ giver (Object): The object about to give this object.
+ getter (Object): The object about to get this object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ shouldgive (bool): If the object should be given or not.
+
+ Notes:
+ If this method returns False/None, the giving is cancelled
+ before it is even started.
+
+ """
+ return True
+
def at_give(self, giver, getter, **kwargs):
"""
Called by the default `give` command when this object has been
@@ -1544,11 +1632,31 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
Notes:
This hook cannot stop the give from happening. Use
- permissions for that.
+ permissions or the at_before_give() hook for that.
"""
pass
+ def at_before_drop(self, dropper, **kwargs):
+ """
+ Called by the default `drop` command before this object has been
+ dropped.
+
+ Args:
+ dropper (Object): The object which will drop this object.
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ Returns:
+ shoulddrop (bool): If the object should be dropped or not.
+
+ Notes:
+ If this method returns False/None, the dropping is cancelled
+ before it is even started.
+
+ """
+ return True
+
def at_drop(self, dropper, **kwargs):
"""
Called by the default `drop` command when this object has been
@@ -1561,7 +1669,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
Notes:
This hook cannot stop the drop from happening. Use
- permissions from that.
+ permissions or the at_before_drop() hook for that.
"""
pass
@@ -1641,12 +1749,13 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
# whisper mode
msg_type = 'whisper'
msg_self = '{self} whisper to {all_receivers}, "{speech}"' if msg_self is True else msg_self
+ msg_receivers = '{object} whispers: "{speech}"'
msg_receivers = msg_receivers or '{object} whispers: "{speech}"'
msg_location = None
else:
msg_self = '{self} say, "{speech}"' if msg_self is True else msg_self
- msg_receivers = None
msg_location = msg_location or '{object} says, "{speech}"'
+ msg_receivers = msg_receivers or message
custom_mapping = kwargs.get('mapping', {})
receivers = make_iter(receivers) if receivers else None
@@ -1691,9 +1800,14 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
"receiver": None,
"speech": message}
location_mapping.update(custom_mapping)
+ exclude = []
+ if msg_self:
+ exclude.append(self)
+ if receivers:
+ exclude.extend(receivers)
self.location.msg_contents(text=(msg_location, {"type": msg_type}),
from_obj=self,
- exclude=(self, ) if msg_self else None,
+ exclude=exclude,
mapping=location_mapping)
@@ -1764,7 +1878,7 @@ class DefaultCharacter(DefaultObject):
"""
self.msg("\nYou become |c%s|n.\n" % self.name)
- self.msg(self.at_look(self.location))
+ self.msg((self.at_look(self.location), {'type':'look'}), options = None)
def message(obj, from_obj):
obj.msg("%s has entered the game." % self.get_display_name(obj), from_obj=from_obj)
diff --git a/evennia/prototypes/README.md b/evennia/prototypes/README.md
new file mode 100644
index 0000000000..0f4139aa3e
--- /dev/null
+++ b/evennia/prototypes/README.md
@@ -0,0 +1,145 @@
+# Prototypes
+
+A 'Prototype' is a normal Python dictionary describing unique features of individual instance of a
+Typeclass. The prototype is used to 'spawn' a new instance with custom features detailed by said
+prototype. This allows for creating variations without having to create a large number of actual
+Typeclasses. It is a good way to allow Builders more freedom of creation without giving them full
+Python access to create Typeclasses.
+
+For example, if a Typeclass 'Cat' describes all the coded differences between a Cat and
+other types of animals, then prototypes could be used to quickly create unique individual cats with
+different Attributes/properties (like different colors, stats, names etc) without having to make a new
+Typeclass for each. Prototypes have inheritance and can be scripted when they are applied to create
+a new instance of a typeclass - a common example would be to randomize stats and name.
+
+The prototype is a normal dictionary with specific keys. Almost all values can be callables
+triggered when the prototype is used to spawn a new instance. Below is an example:
+
+```
+{
+# meta-keys - these are used only when listing prototypes in-game. Only prototype_key is mandatory,
+# but it must be globally unique.
+
+ "prototype_key": "base_goblin",
+ "prototype_desc": "A basic goblin",
+ "prototype_locks": "edit:all();spawn:all()",
+ "prototype_tags": "mobs",
+
+# fixed-meaning keys, modifying the spawned instance. 'typeclass' may be
+# replaced by 'parent', referring to the prototype_key of an existing prototype
+# to inherit from.
+
+ "typeclass": "types.objects.Monster",
+ "key": "goblin grunt",
+ "tags": ["mob", "evil", ('greenskin','mob')] # tags as well as tags with category etc
+ "attrs": [("weapon", "sword")] # this allows to set Attributes with categories etc
+
+# non-fixed keys are interpreted as Attributes and their
+
+ "health": lambda: randint(20,30),
+ "resists": ["cold", "poison"],
+ "attacks": ["fists"],
+ "weaknesses": ["fire", "light"]
+ }
+
+```
+## Using prototypes
+
+Prototypes are generally used as inputs to the `spawn` command:
+
+ @spawn prototype_key
+
+This will spawn a new instance of the prototype in the caller's current location unless the
+`location` key of the prototype was set (see below). The caller must pass the prototype's 'spawn'
+lock to be able to use it.
+
+ @spawn/list [prototype_key]
+
+will show all available prototypes along with meta info, or look at a specific prototype in detail.
+
+
+## Creating prototypes
+
+The `spawn` command can also be used to directly create/update prototypes from in-game.
+
+ spawn/save {"prototype_key: "goblin", ... }
+
+but it is probably more convenient to use the menu-driven prototype wizard:
+
+ spawn/menu goblin
+
+In code:
+
+```python
+
+from evennia import prototypes
+
+goblin = {"prototype_key": "goblin:, ... }
+
+prototype = prototypes.save_prototype(caller, **goblin)
+
+```
+
+Prototypes will normally be stored in the database (internally this is done using a Script, holding
+the meta-info and the prototype). One can also define prototypes outside of the game by assigning
+the prototype dictionary to a global variable in a module defined by `settings.PROTOTYPE_MODULES`:
+
+```python
+# in e.g. mygame/world/prototypes.py
+
+GOBLIN = {
+ "prototype_key": "goblin",
+ ...
+ }
+
+```
+
+Such prototypes cannot be modified from inside the game no matter what `edit` lock they are given
+(we refer to them as 'readonly') but can be a fast and efficient way to give builders a starting
+library of prototypes to inherit from.
+
+## Valid Prototype keys
+
+Every prototype key also accepts a callable (taking no arguments) for producing its value or a
+string with an $protfunc definition. That callable/protfunc must then return a value on a form the
+prototype key expects.
+
+ - `prototype_key` (str): name of this prototype. This is used when storing prototypes and should
+ be unique. This should always be defined but for prototypes defined in modules, the
+ variable holding the prototype dict will become the prototype_key if it's not explicitly
+ given.
+ - `prototype_desc` (str, optional): describes prototype in listings
+ - `prototype_locks` (str, optional): locks for restricting access to this prototype. Locktypes
+ supported are 'edit' and 'use'.
+ - `prototype_tags` (list, optional): List of tags or tuples (tag, category) used to group prototype
+ in listings
+
+ - `parent` (str or tuple, optional): name (`prototype_key`) of eventual parent prototype, or a
+ list of parents for multiple left-to-right inheritance.
+ - `prototype`: Deprecated. Same meaning as 'parent'.
+ - `typeclass` (str, optional): if not set, will use typeclass of parent prototype or use
+ `settings.BASE_OBJECT_TYPECLASS`
+ - `key` (str, optional): the name of the spawned object. If not given this will set to a
+ random hash
+ - `location` (obj, optional): location of the object - a valid object or #dbref
+ - `home` (obj or str, optional): valid object or #dbref
+ - `destination` (obj or str, optional): only valid for exits (object or #dbref)
+
+ - `permissions` (str or list, optional): which permissions for spawned object to have
+ - `locks` (str, optional): lock-string for the spawned object
+ - `aliases` (str or list, optional): Aliases for the spawned object.
+ - `exec` (str, optional): this is a string of python code to execute or a list of such
+ codes. This can be used e.g. to trigger custom handlers on the object. The execution
+ namespace contains 'evennia' for the library and 'obj'. All default spawn commands limit
+ this functionality to Developer/superusers. Usually it's better to use callables or
+ prototypefuncs instead of this.
+ - `tags` (str, tuple or list, optional): string or list of strings or tuples
+ `(tagstr, category)`. Plain strings will be result in tags with no category (default tags).
+ - `attrs` (tuple or list, optional): tuple or list of tuples of Attributes to add. This
+ form allows more complex Attributes to be set. Tuples at least specify `(key, value)`
+ but can also specify up to `(key, value, category, lockstring)`. If you want to specify a
+ lockstring but not a category, set the category to `None`.
+ - `ndb_` (any): value of a nattribute (`ndb_` is stripped). This is usually not useful to
+ put in a prototype unless the NAttribute is used immediately upon spawning.
+ - `other` (any): any other name is interpreted as the key of an Attribute with
+ its value. Such Attributes have no categories.
diff --git a/evennia/prototypes/__init__.py b/evennia/prototypes/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py
new file mode 100644
index 0000000000..9605ea1a8f
--- /dev/null
+++ b/evennia/prototypes/menus.py
@@ -0,0 +1,2462 @@
+"""
+
+OLC Prototype menu nodes
+
+"""
+
+import json
+import re
+from random import choice
+from django.db.models import Q
+from django.conf import settings
+from evennia.objects.models import ObjectDB
+from evennia.utils.evmenu import EvMenu, list_node
+from evennia.utils import evmore
+from evennia.utils.ansi import strip_ansi
+from evennia.utils import utils
+from evennia.locks.lockhandler import get_all_lockfuncs
+from evennia.prototypes import prototypes as protlib
+from evennia.prototypes import spawner
+
+# ------------------------------------------------------------
+#
+# OLC Prototype design menu
+#
+# ------------------------------------------------------------
+
+_MENU_CROP_WIDTH = 15
+_MENU_ATTR_LITERAL_EVAL_ERROR = (
+ "|rCritical Python syntax error in your value. Only primitive Python structures are allowed.\n"
+ "You also need to use correct Python syntax. Remember especially to put quotes around all "
+ "strings inside lists and dicts.|n")
+
+
+# Helper functions
+
+
+def _get_menu_prototype(caller):
+ """Return currently active menu prototype."""
+ prototype = None
+ if hasattr(caller.ndb._menutree, "olc_prototype"):
+ prototype = caller.ndb._menutree.olc_prototype
+ if not prototype:
+ caller.ndb._menutree.olc_prototype = prototype = {}
+ caller.ndb._menutree.olc_new = True
+ return prototype
+
+
+def _get_flat_menu_prototype(caller, refresh=False, validate=False):
+ """Return prototype where parent values are included"""
+ flat_prototype = None
+ if not refresh and hasattr(caller.ndb._menutree, "olc_flat_prototype"):
+ flat_prototype = caller.ndb._menutree.olc_flat_prototype
+ if not flat_prototype:
+ prot = _get_menu_prototype(caller)
+ caller.ndb._menutree.olc_flat_prototype = \
+ flat_prototype = spawner.flatten_prototype(prot, validate=validate)
+ return flat_prototype
+
+
+def _get_unchanged_inherited(caller, protname):
+ """Return prototype values inherited from parent(s), which are not replaced in child"""
+ prototype = _get_menu_prototype(caller)
+ if protname in prototype:
+ return protname[protname], False
+ else:
+ flattened = _get_flat_menu_prototype(caller)
+ if protname in flattened:
+ return protname[protname], True
+ return None, False
+
+
+def _set_menu_prototype(caller, prototype):
+ """Set the prototype with existing one"""
+ caller.ndb._menutree.olc_prototype = prototype
+ caller.ndb._menutree.olc_new = False
+ return prototype
+
+
+def _is_new_prototype(caller):
+ """Check if prototype is marked as new or was loaded from a saved one."""
+ return hasattr(caller.ndb._menutree, "olc_new")
+
+
+def _format_option_value(prop, required=False, prototype=None, cropper=None):
+ """
+ Format wizard option values.
+
+ Args:
+ prop (str): Name or value to format.
+ required (bool, optional): The option is required.
+ prototype (dict, optional): If given, `prop` will be considered a key in this prototype.
+ cropper (callable, optional): A function to crop the value to a certain width.
+
+ Returns:
+ value (str): The formatted value.
+ """
+ if prototype is not None:
+ prop = prototype.get(prop, '')
+
+ out = prop
+ if callable(prop):
+ if hasattr(prop, '__name__'):
+ out = "<{}>".format(prop.__name__)
+ else:
+ out = repr(prop)
+ if utils.is_iter(prop):
+ out = ", ".join(str(pr) for pr in prop)
+ if not out and required:
+ out = "|rrequired"
+ if out:
+ return " ({}|n)".format(cropper(out) if cropper else utils.crop(out, _MENU_CROP_WIDTH))
+ return ""
+
+
+def _set_prototype_value(caller, field, value, parse=True):
+ """Set prototype's field in a safe way."""
+ prototype = _get_menu_prototype(caller)
+ prototype[field] = value
+ caller.ndb._menutree.olc_prototype = prototype
+ return prototype
+
+
+def _set_property(caller, raw_string, **kwargs):
+ """
+ Add or update a property. To be called by the 'goto' option variable.
+
+ Args:
+ caller (Object, Account): The user of the wizard.
+ raw_string (str): Input from user on given node - the new value to set.
+
+ Kwargs:
+ test_parse (bool): If set (default True), parse raw_string for protfuncs and obj-refs and
+ try to run result through literal_eval. The parser will be run in 'testing' mode and any
+ parsing errors will shown to the user. Note that this is just for testing, the original
+ given string will be what is inserted.
+ prop (str): Property name to edit with `raw_string`.
+ processor (callable): Converts `raw_string` to a form suitable for saving.
+ next_node (str): Where to redirect to after this has run.
+
+ Returns:
+ next_node (str): Next node to go to.
+
+ """
+ prop = kwargs.get("prop", "prototype_key")
+ processor = kwargs.get("processor", None)
+ next_node = kwargs.get("next_node", None)
+
+ if callable(processor):
+ try:
+ value = processor(raw_string)
+ except Exception as err:
+ caller.msg("Could not set {prop} to {value} ({err})".format(
+ prop=prop.replace("_", "-").capitalize(), value=raw_string, err=str(err)))
+ # this means we'll re-run the current node.
+ return None
+ else:
+ value = raw_string
+
+ if not value:
+ return next_node
+
+ prototype = _set_prototype_value(caller, prop, value)
+ caller.ndb._menutree.olc_prototype = prototype
+
+ try:
+ # TODO simple way to get rid of the u'' markers in list reprs, remove this when on py3.
+ repr_value = json.dumps(value)
+ except Exception:
+ repr_value = value
+
+ out = [" Set {prop} to {value} ({typ}).".format(prop=prop, value=repr_value, typ=type(value))]
+
+ if kwargs.get("test_parse", True):
+ out.append(" Simulating prototype-func parsing ...")
+ err, parsed_value = protlib.protfunc_parser(value, testing=True)
+ if err:
+ out.append(" |yPython `literal_eval` warning: {}|n".format(err))
+ if parsed_value != value:
+ out.append(" |g(Example-)value when parsed ({}):|n {}".format(
+ type(parsed_value), parsed_value))
+ else:
+ out.append(" |gNo change when parsed.")
+
+ caller.msg("\n".join(out))
+
+ return next_node
+
+
+def _wizard_options(curr_node, prev_node, next_node, color="|W", search=False):
+ """Creates default navigation options available in the wizard."""
+ options = []
+ if prev_node:
+ options.append({"key": ("|wB|Wack", "b"),
+ "desc": "{color}({node})|n".format(
+ color=color, node=prev_node.replace("_", "-")),
+ "goto": "node_{}".format(prev_node)})
+ if next_node:
+ options.append({"key": ("|wF|Worward", "f"),
+ "desc": "{color}({node})|n".format(
+ color=color, node=next_node.replace("_", "-")),
+ "goto": "node_{}".format(next_node)})
+
+ options.append({"key": ("|wI|Wndex", "i"),
+ "goto": "node_index"})
+
+ if curr_node:
+ options.append({"key": ("|wV|Walidate prototype", "validate", "v"),
+ "goto": ("node_validate_prototype", {"back": curr_node})})
+ if search:
+ options.append({"key": ("|wSE|Warch objects", "search object", "search", "se"),
+ "goto": ("node_search_object", {"back": curr_node})})
+
+ return options
+
+
+def _set_actioninfo(caller, string):
+ caller.ndb._menutree.actioninfo = string
+
+
+def _path_cropper(pythonpath):
+ "Crop path to only the last component"
+ return pythonpath.split('.')[-1]
+
+
+def _validate_prototype(prototype):
+ """Run validation on prototype"""
+
+ txt = protlib.prototype_to_str(prototype)
+ errors = "\n\n|g No validation errors found.|n (but errors could still happen at spawn-time)"
+ err = False
+ try:
+ # validate, don't spawn
+ spawner.spawn(prototype, only_validate=True)
+ except RuntimeError as err:
+ errors = "\n\n|r{}|n".format(err)
+ err = True
+ except RuntimeWarning as err:
+ errors = "\n\n|y{}|n".format(err)
+ err = True
+
+ text = (txt + errors)
+ return err, text
+
+
+def _format_protfuncs():
+ out = []
+ sorted_funcs = [(key, func) for key, func in
+ sorted(protlib.PROT_FUNCS.items(), key=lambda tup: tup[0])]
+ for protfunc_name, protfunc in sorted_funcs:
+ out.append("- |c${name}|n - |W{docs}".format(
+ name=protfunc_name,
+ docs=utils.justify(protfunc.__doc__.strip(), align='l', indent=10).strip()))
+ return "\n ".join(out)
+
+
+def _format_lockfuncs():
+ out = []
+ sorted_funcs = [(key, func) for key, func in
+ sorted(get_all_lockfuncs().items(), key=lambda tup: tup[0])]
+ for lockfunc_name, lockfunc in sorted_funcs:
+ doc = (lockfunc.__doc__ or "").strip()
+ out.append("- |c${name}|n - |W{docs}".format(
+ name=lockfunc_name,
+ docs=utils.justify(doc, align='l', indent=10).strip()))
+ return "\n".join(out)
+
+
+def _format_list_actions(*args, **kwargs):
+ """Create footer text for nodes with extra list actions
+
+ Args:
+ actions (str): Available actions. The first letter of the action name will be assumed
+ to be a shortcut.
+ Kwargs:
+ prefix (str): Default prefix to use.
+ Returns:
+ string (str): Formatted footer for adding to the node text.
+
+ """
+ actions = []
+ prefix = kwargs.get('prefix', "|WSelect with |w|W. Other actions:|n ")
+ for action in args:
+ actions.append("|w{}|n|W{} |w|n".format(action[0], action[1:]))
+ return prefix + " |W|||n ".join(actions)
+
+
+def _get_current_value(caller, keyname, comparer=None, formatter=str, only_inherit=False):
+ """
+ Return current value, marking if value comes from parent or set in this prototype.
+
+ Args:
+ keyname (str): Name of prototoype key to get current value of.
+ comparer (callable, optional): This will be called as comparer(prototype_value,
+ flattened_value) and is expected to return the value to show as the current
+ or inherited one. If not given, a straight comparison is used and what is returned
+ depends on the only_inherit setting.
+ formatter (callable, optional)): This will be called with the result of comparer.
+ only_inherit (bool, optional): If a current value should only be shown if all
+ the values are inherited from the prototype parent (otherwise, show an empty string).
+ Returns:
+ current (str): The current value.
+
+ """
+ def _default_comparer(protval, flatval):
+ if only_inherit:
+ return "" if protval else flatval
+ else:
+ return protval if protval else flatval
+
+ if not callable(comparer):
+ comparer = _default_comparer
+
+ prot = _get_menu_prototype(caller)
+ flat_prot = _get_flat_menu_prototype(caller)
+
+ out = ""
+ if keyname in prot:
+ if keyname in flat_prot:
+ out = formatter(comparer(prot[keyname], flat_prot[keyname]))
+ if only_inherit:
+ if str(out).strip():
+ return "|WCurrent|n {} |W(|binherited|W):|n {}".format(keyname, out)
+ return ""
+ else:
+ if out:
+ return "|WCurrent|n {}|W:|n {}".format(keyname, out)
+ return "|W[No {} set]|n".format(keyname)
+ elif only_inherit:
+ return ""
+ else:
+ out = formatter(prot[keyname])
+ return "|WCurrent|n {}|W:|n {}".format(keyname, out)
+ elif keyname in flat_prot:
+ out = formatter(flat_prot[keyname])
+ if out:
+ return "|WCurrent|n {} |W(|n|binherited|W):|n {}".format(keyname, out)
+ else:
+ return ""
+ elif only_inherit:
+ return ""
+ else:
+ return "|W[No {} set]|n".format(keyname)
+
+
+def _default_parse(raw_inp, choices, *args):
+ """
+ Helper to parse default input to a node decorated with the node_list decorator on
+ the form l1, l 2, look 1, etc. Spaces are ignored, as is case.
+
+ Args:
+ raw_inp (str): Input from the user.
+ choices (list): List of available options on the node listing (list of strings).
+ args (tuples): The available actions, each specifed as a tuple (name, alias, ...)
+ Returns:
+ choice (str): A choice among the choices, or None if no match was found.
+ action (str): The action operating on the choice, or None.
+
+ """
+ raw_inp = raw_inp.lower().strip()
+ mapping = {t.lower(): tup[0] for tup in args for t in tup}
+ match = re.match(r"(%s)\s*?(\d+)$" % "|".join(mapping.keys()), raw_inp)
+ if match:
+ action = mapping.get(match.group(1), None)
+ num = int(match.group(2)) - 1
+ num = num if 0 <= num < len(choices) else None
+ if action is not None and num is not None:
+ return choices[num], action
+ return None, None
+
+
+# Menu nodes ------------------------------
+
+# helper nodes
+
+# validate prototype (available as option from all nodes)
+
+def node_validate_prototype(caller, raw_string, **kwargs):
+ """General node to view and validate a protototype"""
+ prototype = _get_flat_menu_prototype(caller, refresh=True, validate=False)
+ prev_node = kwargs.get("back", "index")
+
+ _, text = _validate_prototype(prototype)
+
+ helptext = """
+ The validator checks if the prototype's various values are on the expected form. It also tests
+ any $protfuncs.
+
+ """
+
+ text = (text, helptext)
+
+ options = _wizard_options(None, prev_node, None)
+ options.append({"key": "_default",
+ "goto": "node_" + prev_node})
+
+ return text, options
+
+
+# node examine_entity
+
+def node_examine_entity(caller, raw_string, **kwargs):
+ """
+ General node to view a text and then return to previous node. Kwargs should contain "text" for
+ the text to show and 'back" pointing to the node to return to.
+ """
+ text = kwargs.get("text", "Nothing was found here.")
+ helptext = "Use |wback|n to return to the previous node."
+ prev_node = kwargs.get('back', 'index')
+
+ text = (text, helptext)
+
+ options = _wizard_options(None, prev_node, None)
+ options.append({"key": "_default",
+ "goto": "node_" + prev_node})
+
+ return text, options
+
+
+# node object_search
+
+def _search_object(caller):
+ "update search term based on query stored on menu; store match too"
+ try:
+ searchstring = caller.ndb._menutree.olc_search_object_term.strip()
+ caller.ndb._menutree.olc_search_object_matches = []
+ except AttributeError:
+ return []
+
+ if not searchstring:
+ caller.msg("Must specify a search criterion.")
+ return []
+
+ is_dbref = utils.dbref(searchstring)
+ is_account = searchstring.startswith("*")
+
+ if is_dbref or is_account:
+
+ if is_dbref:
+ # a dbref search
+ results = caller.search(searchstring, global_search=True, quiet=True)
+ else:
+ # an account search
+ searchstring = searchstring.lstrip("*")
+ results = caller.search_account(searchstring, quiet=True)
+ else:
+ keyquery = Q(db_key__istartswith=searchstring)
+ aliasquery = Q(db_tags__db_key__istartswith=searchstring,
+ db_tags__db_tagtype__iexact="alias")
+ results = ObjectDB.objects.filter(keyquery | aliasquery).distinct()
+
+ caller.msg("Searching for '{}' ...".format(searchstring))
+ caller.ndb._menutree.olc_search_object_matches = results
+ return ["{}(#{})".format(obj.key, obj.id) for obj in results]
+
+
+def _object_search_select(caller, obj_entry, **kwargs):
+ choices = kwargs['available_choices']
+ num = choices.index(obj_entry)
+ matches = caller.ndb._menutree.olc_search_object_matches
+ obj = matches[num]
+
+ if not obj.access(caller, 'examine'):
+ caller.msg("|rYou don't have 'examine' access on this object.|n")
+ del caller.ndb._menutree.olc_search_object_term
+ return "node_search_object"
+
+ prot = spawner.prototype_from_object(obj)
+ txt = protlib.prototype_to_str(prot)
+ return "node_examine_entity", {"text": txt, "back": "search_object"}
+
+
+def _object_search_actions(caller, raw_inp, **kwargs):
+ "All this does is to queue a search query"
+ choices = kwargs['available_choices']
+ obj_entry, action = _default_parse(
+ raw_inp, choices, ("examine", "e"), ("create prototype from object", "create", "c"))
+
+ raw_inp = raw_inp.strip()
+
+ if obj_entry:
+
+ num = choices.index(obj_entry)
+ matches = caller.ndb._menutree.olc_search_object_matches
+ obj = matches[num]
+ prot = spawner.prototype_from_object(obj)
+
+ if action == "examine":
+
+ if not obj.access(caller, 'examine'):
+ caller.msg("\n|rYou don't have 'examine' access on this object.|n")
+ del caller.ndb._menutree.olc_search_object_term
+ return "node_search_object"
+
+ txt = protlib.prototype_to_str(prot)
+ return "node_examine_entity", {"text": txt, "back": "search_object"}
+ else:
+ # load prototype
+
+ if not obj.access(caller, 'control'):
+ caller.msg("|rYou don't have access to do this with this object.|n")
+ del caller.ndb._menutree.olc_search_object_term
+ return "node_search_object"
+
+ _set_menu_prototype(caller, prot)
+ caller.msg("Created prototype from object.")
+ return "node_index"
+ elif raw_inp:
+ caller.ndb._menutree.olc_search_object_term = raw_inp
+ return "node_search_object", kwargs
+ else:
+ # empty input - exit back to previous node
+ prev_node = "node_" + kwargs.get("back", "index")
+ return prev_node
+
+
+@list_node(_search_object, _object_search_select)
+def node_search_object(caller, raw_inp, **kwargs):
+ """
+ Node for searching for an existing object.
+ """
+ try:
+ matches = caller.ndb._menutree.olc_search_object_matches
+ except AttributeError:
+ matches = []
+ nmatches = len(matches)
+ prev_node = kwargs.get("back", "index")
+
+ if matches:
+ text = """
+ Found {num} match{post}.
+
+ (|RWarning: creating a prototype will |roverwrite|r |Rthe current prototype!)|n""".format(
+ num=nmatches, post="es" if nmatches > 1 else "")
+ _set_actioninfo(caller, _format_list_actions(
+ "examine", "create prototype from object", prefix="Actions: "))
+ else:
+ text = "Enter search criterion."
+
+ helptext = """
+ You can search objects by specifying partial key, alias or its exact #dbref. Use *query to
+ search for an Account instead.
+
+ Once having found any matches you can choose to examine it or use |ccreate prototype from
+ object|n. If doing the latter, a prototype will be calculated from the selected object and
+ loaded as the new 'current' prototype. This is useful for having a base to build from but be
+ careful you are not throwing away any existing, unsaved, prototype work!
+ """
+
+ text = (text, helptext)
+
+ options = _wizard_options(None, prev_node, None)
+ options.append({"key": "_default",
+ "goto": (_object_search_actions, {"back": prev_node})})
+
+ return text, options
+
+# main index (start page) node
+
+
+def node_index(caller):
+ prototype = _get_menu_prototype(caller)
+
+ text = """
+ |c --- Prototype wizard --- |n
+
+ A |cprototype|n is a 'template' for |wspawning|n an in-game entity. A field of the prototype
+ can either be hard-coded, left empty or scripted using |w$protfuncs|n - for example to
+ randomize the value every time a new entity is spawned. The fields whose names start with
+ 'Prototype-' are not fields on the object itself but are used for prototype-inheritance, or
+ when saving and loading.
+
+ Select prototype field to edit. If you are unsure, start from [|w1|n]. Enter [|wh|n]elp at
+ any menu node for more info.
+
+ """
+ helptxt = """
+ |c- prototypes |n
+
+ A prototype is really just a Python dictionary. When spawning, this dictionary is essentially
+ passed into `|wevennia.utils.create.create_object(**prototype)|n` to create a new object. By
+ using different prototypes you can customize instances of objects without having to do code
+ changes to their typeclass (something which requires code access). The classical example is
+ to spawn goblins with different names, looks, equipment and skill, each based on the same
+ `Goblin` typeclass.
+
+ At any time you can [|wV|n]alidate that the prototype works correctly and use it to
+ [|wSP|n]awn a new entity. You can also [|wSA|n]ve|n your work, [|wLO|n]oad an existing
+ prototype to [|wSE|n]arch for existing objects to use as a base. Use [|wL|n]ook to re-show a
+ menu node. [|wQ|n]uit will always exit the menu and [|wH|n]elp will show context-sensitive
+ help.
+
+
+ |c- $protfuncs |n
+
+ Prototype-functions (protfuncs) allow for limited scripting within a prototype. These are
+ entered as a string $funcname(arg, arg, ...) and are evaluated |wat the time of spawning|n
+ only. They can also be nested for combined effects.
+
+ {pfuncs}
+ """.format(pfuncs=_format_protfuncs())
+
+ text = (text, helptxt)
+
+ options = []
+ options.append(
+ {"desc": "|WPrototype-Key|n|n{}".format(
+ _format_option_value("Key", "prototype_key" not in prototype, prototype, None)),
+ "goto": "node_prototype_key"})
+ for key in ('Prototype_Parent', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks',
+ 'Permissions', 'Location', 'Home', 'Destination'):
+ required = False
+ cropper = None
+ if key in ("Prototype_Parent", "Typeclass"):
+ required = ("prototype_parent" not in prototype) and ("typeclass" not in prototype)
+ if key == 'Typeclass':
+ cropper = _path_cropper
+ options.append(
+ {"desc": "|w{}|n{}".format(
+ key.replace("_", "-"),
+ _format_option_value(key, required, prototype, cropper=cropper)),
+ "goto": "node_{}".format(key.lower())})
+ required = False
+ for key in ('Desc', 'Tags', 'Locks'):
+ options.append(
+ {"desc": "|WPrototype-{}|n|n{}".format(
+ key, _format_option_value(key, required, prototype, None)),
+ "goto": "node_prototype_{}".format(key.lower())})
+
+ options.extend((
+ {"key": ("|wV|Walidate prototype", "validate", "v"),
+ "goto": "node_validate_prototype"},
+ {"key": ("|wSA|Wve prototype", "save", "sa"),
+ "goto": "node_prototype_save"},
+ {"key": ("|wSP|Wawn prototype", "spawn", "sp"),
+ "goto": "node_prototype_spawn"},
+ {"key": ("|wLO|Wad prototype", "load", "lo"),
+ "goto": "node_prototype_load"},
+ {"key": ("|wSE|Warch objects|n", "search", "se"),
+ "goto": "node_search_object"}))
+
+ return text, options
+
+
+# prototype_key node
+
+
+def _check_prototype_key(caller, key):
+ old_prototype = protlib.search_prototype(key)
+ olc_new = _is_new_prototype(caller)
+ key = key.strip().lower()
+ if old_prototype:
+ old_prototype = old_prototype[0]
+ # we are starting a new prototype that matches an existing
+ if not caller.locks.check_lockstring(
+ caller, old_prototype['prototype_locks'], access_type='edit'):
+ # return to the node_prototype_key to try another key
+ caller.msg("Prototype '{key}' already exists and you don't "
+ "have permission to edit it.".format(key=key))
+ return "node_prototype_key"
+ elif olc_new:
+ # we are selecting an existing prototype to edit. Reset to index.
+ del caller.ndb._menutree.olc_new
+ caller.ndb._menutree.olc_prototype = old_prototype
+ caller.msg("Prototype already exists. Reloading.")
+ return "node_index"
+
+ return _set_property(caller, key, prop='prototype_key')
+
+
+def node_prototype_key(caller):
+
+ text = """
+ The |cPrototype-Key|n uniquely identifies the prototype and is |wmandatory|n. It is used to
+ find and use the prototype to spawn new entities. It is not case sensitive.
+
+ {current}""".format(current=_get_current_value(caller, "prototype_key"))
+
+ helptext = """
+ The prototype-key is not itself used when spawnng the new object, but is only used for
+ managing, storing and loading the prototype. It must be globally unique, so existing keys
+ will be checked before a new key is accepted. If an existing key is picked, the existing
+ prototype will be loaded.
+ """
+
+ options = _wizard_options("prototype_key", "index", "prototype_parent")
+ options.append({"key": "_default",
+ "goto": _check_prototype_key})
+
+ text = (text, helptext)
+ return text, options
+
+
+# prototype_parents node
+
+
+def _all_prototype_parents(caller):
+ """Return prototype_key of all available prototypes for listing in menu"""
+ return [prototype["prototype_key"]
+ for prototype in protlib.search_prototype() if "prototype_key" in prototype]
+
+
+def _prototype_parent_actions(caller, raw_inp, **kwargs):
+ """Parse the default Convert prototype to a string representation for closer inspection"""
+ choices = kwargs.get("available_choices", [])
+ prototype_parent, action = _default_parse(
+ raw_inp, choices, ("examine", "e", "l"), ("add", "a"), ("remove", "r", 'delete', 'd'))
+
+ if prototype_parent:
+ # a selection of parent was made
+ prototype_parent = protlib.search_prototype(key=prototype_parent)[0]
+ prototype_parent_key = prototype_parent['prototype_key']
+
+ # which action to apply on the selection
+ if action == 'examine':
+ # examine the prototype
+ txt = protlib.prototype_to_str(prototype_parent)
+ kwargs['text'] = txt
+ kwargs['back'] = 'prototype_parent'
+ return "node_examine_entity", kwargs
+ elif action == 'add':
+ # add/append parent
+ prot = _get_menu_prototype(caller)
+ current_prot_parent = prot.get('prototype_parent', None)
+ if current_prot_parent:
+ current_prot_parent = utils.make_iter(current_prot_parent)
+ if prototype_parent_key in current_prot_parent:
+ caller.msg("Prototype_parent {} is already used.".format(prototype_parent_key))
+ return "node_prototype_parent"
+ else:
+ current_prot_parent.append(prototype_parent_key)
+ caller.msg("Add prototype parent for multi-inheritance.")
+ else:
+ current_prot_parent = prototype_parent_key
+ try:
+ if prototype_parent:
+ spawner.flatten_prototype(prototype_parent, validate=True)
+ else:
+ raise RuntimeError("Not found.")
+ except RuntimeError as err:
+ caller.msg("Selected prototype-parent {} "
+ "caused Error(s):\n|r{}|n".format(prototype_parent, err))
+ return "node_prototype_parent"
+ _set_prototype_value(caller, "prototype_parent", current_prot_parent)
+ _get_flat_menu_prototype(caller, refresh=True)
+ elif action == "remove":
+ # remove prototype parent
+ prot = _get_menu_prototype(caller)
+ current_prot_parent = prot.get('prototype_parent', None)
+ if current_prot_parent:
+ current_prot_parent = utils.make_iter(current_prot_parent)
+ try:
+ current_prot_parent.remove(prototype_parent_key)
+ _set_prototype_value(caller, 'prototype_parent', current_prot_parent)
+ _get_flat_menu_prototype(caller, refresh=True)
+ caller.msg("Removed prototype parent {}.".format(prototype_parent_key))
+ except ValueError:
+ caller.msg("|rPrototype-parent {} could not be removed.".format(
+ prototype_parent_key))
+ return 'node_prototype_parent'
+
+
+def _prototype_parent_select(caller, new_parent):
+
+ ret = None
+ prototype_parent = protlib.search_prototype(new_parent)
+ try:
+ if prototype_parent:
+ spawner.flatten_prototype(prototype_parent[0], validate=True)
+ else:
+ raise RuntimeError("Not found.")
+ except RuntimeError as err:
+ caller.msg("Selected prototype-parent {} "
+ "caused Error(s):\n|r{}|n".format(new_parent, err))
+ else:
+ ret = _set_property(caller, new_parent,
+ prop="prototype_parent",
+ processor=str, next_node="node_prototype_parent")
+ _get_flat_menu_prototype(caller, refresh=True)
+ caller.msg("Selected prototype parent |c{}|n.".format(new_parent))
+ return ret
+
+
+@list_node(_all_prototype_parents, _prototype_parent_select)
+def node_prototype_parent(caller):
+ prototype = _get_menu_prototype(caller)
+
+ prot_parent_keys = prototype.get('prototype_parent')
+
+ text = """
+ The |cPrototype Parent|n allows you to |winherit|n prototype values from another named
+ prototype (given as that prototype's |wprototype_key|n). If not changing these values in
+ the current prototype, the parent's value will be used. Pick the available prototypes below.
+
+ Note that somewhere in the prototype's parentage, a |ctypeclass|n must be specified. If no
+ parent is given, this prototype must define the typeclass (next menu node).
+
+ {current}
+ """
+ helptext = """
+ Prototypes can inherit from one another. Changes in the child replace any values set in a
+ parent. The |wtypeclass|n key must exist |wsomewhere|n in the parent chain for the
+ prototype to be valid.
+ """
+
+ _set_actioninfo(caller, _format_list_actions("examine", "add", "remove"))
+
+ ptexts = []
+ if prot_parent_keys:
+ for pkey in utils.make_iter(prot_parent_keys):
+ prot_parent = protlib.search_prototype(pkey)
+ if prot_parent:
+ prot_parent = prot_parent[0]
+ ptexts.append("|c -- {pkey} -- |n\n{prot}".format(
+ pkey=pkey,
+ prot=protlib.prototype_to_str(prot_parent)))
+ else:
+ ptexts.append("Prototype parent |r{pkey} was not found.".format(pkey=pkey))
+
+ if not ptexts:
+ ptexts.append("[No prototype_parent set]")
+
+ text = text.format(current="\n\n".join(ptexts))
+
+ text = (text, helptext)
+
+ options = _wizard_options("prototype_parent", "prototype_key", "typeclass", color="|W")
+ options.append({"key": "_default",
+ "goto": _prototype_parent_actions})
+
+ return text, options
+
+
+# typeclasses node
+
+def _all_typeclasses(caller):
+ """Get name of available typeclasses."""
+ return list(name for name in
+ sorted(utils.get_all_typeclasses("evennia.objects.models.ObjectDB").keys())
+ if name != "evennia.objects.models.ObjectDB")
+
+
+def _typeclass_actions(caller, raw_inp, **kwargs):
+ """Parse actions for typeclass listing"""
+
+ choices = kwargs.get("available_choices", [])
+ typeclass_path, action = _default_parse(
+ raw_inp, choices, ("examine", "e", "l"), ("remove", "r", "delete", "d"))
+
+ if typeclass_path:
+ if action == 'examine':
+ typeclass = utils.get_all_typeclasses().get(typeclass_path)
+ if typeclass:
+ docstr = []
+ for line in typeclass.__doc__.split("\n"):
+ if line.strip():
+ docstr.append(line)
+ elif docstr:
+ break
+ docstr = '\n'.join(docstr) if docstr else ""
+ txt = "Typeclass |c{typeclass_path}|n; " \
+ "First paragraph of docstring:\n\n{docstring}".format(
+ typeclass_path=typeclass_path, docstring=docstr)
+ else:
+ txt = "This is typeclass |y{}|n.".format(typeclass)
+ return "node_examine_entity", {"text": txt, "back": "typeclass"}
+ elif action == 'remove':
+ prototype = _get_menu_prototype(caller)
+ old_typeclass = prototype.pop('typeclass', None)
+ if old_typeclass:
+ _set_menu_prototype(caller, prototype)
+ caller.msg("Cleared typeclass {}.".format(old_typeclass))
+ else:
+ caller.msg("No typeclass to remove.")
+ return "node_typeclass"
+
+
+def _typeclass_select(caller, typeclass):
+ """Select typeclass from list and add it to prototype. Return next node to go to."""
+ ret = _set_property(caller, typeclass, prop='typeclass', processor=str)
+ caller.msg("Selected typeclass |c{}|n.".format(typeclass))
+ return ret
+
+
+@list_node(_all_typeclasses, _typeclass_select)
+def node_typeclass(caller):
+ text = """
+ The |cTypeclass|n defines what 'type' of object this is - the actual working code to use.
+
+ All spawned objects must have a typeclass. If not given here, the typeclass must be set in
+ one of the prototype's |cparents|n.
+
+ {current}
+ """.format(current=_get_current_value(caller, "typeclass"),
+ actions="|WSelect with |w|W. Other actions: "
+ "|we|Wxamine |w|W, |wr|Wemove selection")
+
+ helptext = """
+ A |nTypeclass|n is specified by the actual python-path to the class definition in the
+ Evennia code structure.
+
+ Which |cAttributes|n, |cLocks|n and other properties have special
+ effects or expects certain values depend greatly on the code in play.
+ """
+
+ text = (text, helptext)
+
+ options = _wizard_options("typeclass", "prototype_parent", "key", color="|W")
+ options.append({"key": "_default",
+ "goto": _typeclass_actions})
+ return text, options
+
+
+# key node
+
+
+def node_key(caller):
+ text = """
+ The |cKey|n is the given name of the object to spawn. This will retain the given case.
+
+ {current}
+ """.format(current=_get_current_value(caller, "key"))
+
+ helptext = """
+ The key should often not be identical for every spawned object. Using a randomising
+ $protfunc can be used, for example |c$choice(Alan, Tom, John)|n will give one of the three
+ names every time an object of this prototype is spawned.
+
+ |c$protfuncs|n
+ {pfuncs}
+ """.format(pfuncs=_format_protfuncs())
+
+ text = (text, helptext)
+
+ options = _wizard_options("key", "typeclass", "aliases")
+ options.append({"key": "_default",
+ "goto": (_set_property,
+ dict(prop="key",
+ processor=lambda s: s.strip()))})
+ return text, options
+
+
+# aliases node
+
+
+def _all_aliases(caller):
+ "Get aliases in prototype"
+ prototype = _get_menu_prototype(caller)
+ return prototype.get("aliases", [])
+
+
+def _aliases_select(caller, alias):
+ "Add numbers as aliases"
+ aliases = _all_aliases(caller)
+ try:
+ ind = str(aliases.index(alias) + 1)
+ if ind not in aliases:
+ aliases.append(ind)
+ _set_prototype_value(caller, "aliases", aliases)
+ caller.msg("Added alias '{}'.".format(ind))
+ except (IndexError, ValueError) as err:
+ caller.msg("Error: {}".format(err))
+
+ return "node_aliases"
+
+
+def _aliases_actions(caller, raw_inp, **kwargs):
+ """Parse actions for aliases listing"""
+ choices = kwargs.get("available_choices", [])
+ alias, action = _default_parse(
+ raw_inp, choices, ("remove", "r", "delete", "d"))
+
+ aliases = _all_aliases(caller)
+ if alias and action == 'remove':
+ try:
+ aliases.remove(alias)
+ _set_prototype_value(caller, "aliases", aliases)
+ caller.msg("Removed alias '{}'.".format(alias))
+ except ValueError:
+ caller.msg("No matching alias found to remove.")
+ else:
+ # if not a valid remove, add as a new alias
+ alias = raw_inp.lower().strip()
+ if alias and alias not in aliases:
+ aliases.append(alias)
+ _set_prototype_value(caller, "aliases", aliases)
+ caller.msg("Added alias '{}'.".format(alias))
+ else:
+ caller.msg("Alias '{}' was already set.".format(alias))
+ return "node_aliases"
+
+
+@list_node(_all_aliases, _aliases_select)
+def node_aliases(caller):
+
+ text = """
+ |cAliases|n are alternative ways to address an object, next to its |cKey|n. Aliases are not
+ case sensitive.
+
+ {current}
+ """.format(current=_get_current_value(
+ caller, 'aliases',
+ comparer=lambda propval, flatval: [al for al in flatval if al not in propval],
+ formatter=lambda lst: "\n" + ", ".join(lst), only_inherit=True))
+ _set_actioninfo(caller,
+ _format_list_actions(
+ "remove",
+ prefix="|w|W to add new alias. Other action: "))
+
+ helptext = """
+ Aliases are fixed alternative identifiers and are stored with the new object.
+
+ |c$protfuncs|n
+
+ {pfuncs}
+ """.format(pfuncs=_format_protfuncs())
+
+ text = (text, helptext)
+
+ options = _wizard_options("aliases", "key", "attrs")
+ options.append({"key": "_default",
+ "goto": _aliases_actions})
+ return text, options
+
+
+# attributes node
+
+
+def _caller_attrs(caller):
+ prototype = _get_menu_prototype(caller)
+ attrs = ["{}={}".format(tup[0], utils.crop(utils.to_str(tup[1], force_string=True), width=10))
+ for tup in prototype.get("attrs", [])]
+ return attrs
+
+
+def _get_tup_by_attrname(caller, attrname):
+ prototype = _get_menu_prototype(caller)
+ attrs = prototype.get("attrs", [])
+ try:
+ inp = [tup[0] for tup in attrs].index(attrname)
+ return attrs[inp]
+ except ValueError:
+ return None
+
+
+def _display_attribute(attr_tuple):
+ """Pretty-print attribute tuple"""
+ attrkey, value, category, locks = attr_tuple
+ value = protlib.protfunc_parser(value)
+ typ = type(value)
+ out = ("{attrkey} |c=|n {value} |W({typ}{category}{locks})|n".format(
+ attrkey=attrkey,
+ value=value,
+ typ=typ,
+ category=", category={}".format(category) if category else '',
+ locks=", locks={}".format(";".join(locks)) if any(locks) else ''))
+
+ return out
+
+
+def _add_attr(caller, attr_string, **kwargs):
+ """
+ Add new attribute, parsing input.
+
+ Args:
+ caller (Object): Caller of menu.
+ attr_string (str): Input from user
+ attr is entered on these forms
+ attr = value
+ attr;category = value
+ attr;category;lockstring = value
+ Kwargs:
+ delete (str): If this is set, attr_string is
+ considered the name of the attribute to delete and
+ no further parsing happens.
+ Returns:
+ result (str): Result string of action.
+ """
+ attrname = ''
+ value = ''
+ category = None
+ locks = ''
+
+ if 'delete' in kwargs:
+ attrname = attr_string.lower().strip()
+ elif '=' in attr_string:
+ attrname, value = (part.strip() for part in attr_string.split('=', 1))
+ attrname = attrname.lower()
+ nameparts = attrname.split(";", 2)
+ nparts = len(nameparts)
+ if nparts == 2:
+ attrname, category = nameparts
+ elif nparts > 2:
+ attrname, category, locks = nameparts
+ attr_tuple = (attrname, value, category, str(locks))
+
+ if attrname:
+ prot = _get_menu_prototype(caller)
+ attrs = prot.get('attrs', [])
+
+ if 'delete' in kwargs:
+ try:
+ ind = [tup[0] for tup in attrs].index(attrname)
+ del attrs[ind]
+ _set_prototype_value(caller, "attrs", attrs)
+ return "Removed Attribute '{}'".format(attrname)
+ except IndexError:
+ return "Attribute to delete not found."
+
+ try:
+ # replace existing attribute with the same name in the prototype
+ ind = [tup[0] for tup in attrs].index(attrname)
+ attrs[ind] = attr_tuple
+ text = "Edited Attribute '{}'.".format(attrname)
+ except ValueError:
+ attrs.append(attr_tuple)
+ text = "Added Attribute " + _display_attribute(attr_tuple)
+
+ _set_prototype_value(caller, "attrs", attrs)
+ else:
+ text = "Attribute must be given as 'attrname[;category;locks] = '."
+
+ return text
+
+
+def _attr_select(caller, attrstr):
+ attrname, _ = attrstr.split("=", 1)
+ attrname = attrname.strip()
+
+ attr_tup = _get_tup_by_attrname(caller, attrname)
+ if attr_tup:
+ return "node_examine_entity", \
+ {"text": _display_attribute(attr_tup), "back": "attrs"}
+ else:
+ caller.msg("Attribute not found.")
+ return "node_attrs"
+
+
+def _attrs_actions(caller, raw_inp, **kwargs):
+ """Parse actions for attribute listing"""
+ choices = kwargs.get("available_choices", [])
+ attrstr, action = _default_parse(
+ raw_inp, choices, ('examine', 'e'), ('remove', 'r', 'delete', 'd'))
+ if attrstr is None:
+ attrstr = raw_inp
+ try:
+ attrname, _ = attrstr.split("=", 1)
+ except ValueError:
+ caller.msg("|rNeed to enter the attribute on the form attrname=value.|n")
+ return "node_attrs"
+
+ attrname = attrname.strip()
+ attr_tup = _get_tup_by_attrname(caller, attrname)
+
+ if action and attr_tup:
+ if action == 'examine':
+ return "node_examine_entity", \
+ {"text": _display_attribute(attr_tup), "back": "attrs"}
+ elif action == 'remove':
+ res = _add_attr(caller, attrname, delete=True)
+ caller.msg(res)
+ else:
+ res = _add_attr(caller, raw_inp)
+ caller.msg(res)
+ return "node_attrs"
+
+
+@list_node(_caller_attrs, _attr_select)
+def node_attrs(caller):
+
+ def _currentcmp(propval, flatval):
+ "match by key + category"
+ cmp1 = [(tup[0].lower(), tup[2].lower() if tup[2] else None) for tup in propval]
+ return [tup for tup in flatval if (tup[0].lower(), tup[2].lower()
+ if tup[2] else None) not in cmp1]
+
+ text = """
+ |cAttributes|n are custom properties of the object. Enter attributes on one of these forms:
+
+ attrname=value
+ attrname;category=value
+ attrname;category;lockstring=value
+
+ To give an attribute without a category but with a lockstring, leave that spot empty
+ (attrname;;lockstring=value). Attribute values can have embedded $protfuncs.
+
+ {current}
+ """.format(
+ current=_get_current_value(
+ caller, "attrs",
+ comparer=_currentcmp,
+ formatter=lambda lst: "\n" + "\n".join(_display_attribute(tup) for tup in lst),
+ only_inherit=True))
+ _set_actioninfo(caller, _format_list_actions("examine", "remove", prefix="Actions: "))
+
+ helptext = """
+ Most commonly, Attributes don't need any categories or locks. If using locks, the lock-types
+ 'attredit' and 'attrread' are used to limit editing and viewing of the Attribute. Putting
+ the lock-type `attrcreate` in the |clocks|n prototype key can be used to restrict builders
+ from adding new Attributes.
+
+ |c$protfuncs
+
+ {pfuncs}
+ """.format(pfuncs=_format_protfuncs())
+
+ text = (text, helptext)
+
+ options = _wizard_options("attrs", "aliases", "tags")
+ options.append({"key": "_default",
+ "goto": _attrs_actions})
+ return text, options
+
+
+# tags node
+
+
+def _caller_tags(caller):
+ prototype = _get_menu_prototype(caller)
+ tags = [tup[0] for tup in prototype.get("tags", [])]
+ return tags
+
+
+def _get_tup_by_tagname(caller, tagname):
+ prototype = _get_menu_prototype(caller)
+ tags = prototype.get("tags", [])
+ try:
+ inp = [tup[0] for tup in tags].index(tagname)
+ return tags[inp]
+ except ValueError:
+ return None
+
+
+def _display_tag(tag_tuple):
+ """Pretty-print tag tuple"""
+ tagkey, category, data = tag_tuple
+ out = ("Tag: '{tagkey}' (category: {category}{dat})".format(
+ tagkey=tagkey, category=category, dat=", data: {}".format(data) if data else ""))
+ return out
+
+
+def _add_tag(caller, tag_string, **kwargs):
+ """
+ Add tags to the system, parsing input
+
+ Args:
+ caller (Object): Caller of menu.
+ tag_string (str): Input from user on one of these forms
+ tagname
+ tagname;category
+ tagname;category;data
+
+ Kwargs:
+ delete (str): If this is set, tag_string is considered
+ the name of the tag to delete.
+
+ Returns:
+ result (str): Result string of action.
+
+ """
+ tag = tag_string.strip().lower()
+ category = None
+ data = ""
+
+ if 'delete' in kwargs:
+ tag = tag_string.lower().strip()
+ else:
+ nameparts = tag.split(";", 2)
+ ntuple = len(nameparts)
+ if ntuple == 2:
+ tag, category = nameparts
+ elif ntuple > 2:
+ tag, category, data = nameparts[:3]
+
+ tag_tuple = (tag.lower(), category.lower() if category else None, data)
+
+ if tag:
+ prot = _get_menu_prototype(caller)
+ tags = prot.get('tags', [])
+
+ old_tag = _get_tup_by_tagname(caller, tag)
+
+ if 'delete' in kwargs:
+
+ if old_tag:
+ tags.pop(tags.index(old_tag))
+ text = "Removed Tag '{}'.".format(tag)
+ else:
+ text = "Found no Tag to remove."
+ elif not old_tag:
+ # a fresh, new tag
+ tags.append(tag_tuple)
+ text = "Added Tag '{}'".format(tag)
+ else:
+ # old tag exists; editing a tag means replacing old with new
+ ind = tags.index(old_tag)
+ tags[ind] = tag_tuple
+ text = "Edited Tag '{}'".format(tag)
+
+ _set_prototype_value(caller, "tags", tags)
+ else:
+ text = "Tag must be given as 'tag[;category;data]'."
+
+ return text
+
+
+def _tag_select(caller, tagname):
+ tag_tup = _get_tup_by_tagname(caller, tagname)
+ if tag_tup:
+ return "node_examine_entity", \
+ {"text": _display_tag(tag_tup), "back": "attrs"}
+ else:
+ caller.msg("Tag not found.")
+ return "node_attrs"
+
+
+def _tags_actions(caller, raw_inp, **kwargs):
+ """Parse actions for tags listing"""
+ choices = kwargs.get("available_choices", [])
+ tagname, action = _default_parse(
+ raw_inp, choices, ('examine', 'e'), ('remove', 'r', 'delete', 'd'))
+
+ if tagname is None:
+ tagname = raw_inp.lower().strip()
+
+ tag_tup = _get_tup_by_tagname(caller, tagname)
+
+ if tag_tup:
+ if action == 'examine':
+ return "node_examine_entity", \
+ {"text": _display_tag(tag_tup), 'back': 'tags'}
+ elif action == 'remove':
+ res = _add_tag(caller, tagname, delete=True)
+ caller.msg(res)
+ else:
+ res = _add_tag(caller, raw_inp)
+ caller.msg(res)
+ return "node_tags"
+
+
+@list_node(_caller_tags, _tag_select)
+def node_tags(caller):
+
+ def _currentcmp(propval, flatval):
+ "match by key + category"
+ cmp1 = [(tup[0].lower(), tup[1].lower() if tup[2] else None) for tup in propval]
+ return [tup for tup in flatval if (tup[0].lower(), tup[1].lower()
+ if tup[1] else None) not in cmp1]
+
+ text = """
+ |cTags|n are used to group objects so they can quickly be found later. Enter tags on one of
+ the following forms:
+ tagname
+ tagname;category
+ tagname;category;data
+
+ {current}
+ """.format(
+ current=_get_current_value(
+ caller, 'tags',
+ comparer=_currentcmp,
+ formatter=lambda lst: "\n" + "\n".join(_display_tag(tup) for tup in lst),
+ only_inherit=True))
+ _set_actioninfo(caller, _format_list_actions("examine", "remove", prefix="Actions: "))
+
+ helptext = """
+ Tags are shared between all objects with that tag. So the 'data' field (which is not
+ commonly used) can only hold eventual info about the Tag itself, not about the individual
+ object on which it sits.
+
+ All objects created with this prototype will automatically get assigned a tag named the same
+ as the |cprototype_key|n and with a category "{tag_category}". This allows the spawner to
+ optionally update previously spawned objects when their prototype changes.
+ """.format(tag_category=protlib._PROTOTYPE_TAG_CATEGORY)
+
+ text = (text, helptext)
+ options = _wizard_options("tags", "attrs", "locks")
+ options.append({"key": "_default",
+ "goto": _tags_actions})
+ return text, options
+
+
+# locks node
+
+def _caller_locks(caller):
+ locks = _get_menu_prototype(caller).get("locks", "")
+ return [lck for lck in locks.split(";") if lck]
+
+
+def _locks_display(caller, lock):
+ return lock
+
+
+def _lock_select(caller, lockstr):
+ return "node_examine_entity", {"text": _locks_display(caller, lockstr), "back": "locks"}
+
+
+def _lock_add(caller, lock, **kwargs):
+ locks = _caller_locks(caller)
+
+ try:
+ locktype, lockdef = lock.split(":", 1)
+ except ValueError:
+ return "Lockstring lacks ':'."
+
+ locktype = locktype.strip().lower()
+
+ if 'delete' in kwargs:
+ try:
+ ind = locks.index(lock)
+ locks.pop(ind)
+ _set_prototype_value(caller, "locks", ";".join(locks), parse=False)
+ ret = "Lock {} deleted.".format(lock)
+ except ValueError:
+ ret = "No lock found to delete."
+ return ret
+ try:
+ locktypes = [lck.split(":", 1)[0].strip().lower() for lck in locks]
+ ind = locktypes.index(locktype)
+ locks[ind] = lock
+ ret = "Lock with locktype '{}' updated.".format(locktype)
+ except ValueError:
+ locks.append(lock)
+ ret = "Added lock '{}'.".format(lock)
+ _set_prototype_value(caller, "locks", ";".join(locks))
+ return ret
+
+
+def _locks_actions(caller, raw_inp, **kwargs):
+ choices = kwargs.get("available_choices", [])
+ lock, action = _default_parse(
+ raw_inp, choices, ("examine", "e"), ("remove", "r", "delete", "d"))
+
+ if lock:
+ if action == 'examine':
+ return "node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"}
+ elif action == 'remove':
+ ret = _lock_add(caller, lock, delete=True)
+ caller.msg(ret)
+ else:
+ ret = _lock_add(caller, raw_inp)
+ caller.msg(ret)
+
+ return "node_locks"
+
+
+@list_node(_caller_locks, _lock_select)
+def node_locks(caller):
+
+ def _currentcmp(propval, flatval):
+ "match by locktype"
+ cmp1 = [lck.split(":", 1)[0] for lck in propval.split(';')]
+ return ";".join(lstr for lstr in flatval.split(';') if lstr.split(':', 1)[0] not in cmp1)
+
+ text = """
+ The |cLock string|n defines limitations for accessing various properties of the object once
+ it's spawned. The string should be on one of the following forms:
+
+ locktype:[NOT] lockfunc(args)
+ locktype: [NOT] lockfunc(args) [AND|OR|NOT] lockfunc(args) [AND|OR|NOT] ...
+
+ {current}{action}
+ """.format(
+ current=_get_current_value(
+ caller, 'locks',
+ comparer=_currentcmp,
+ formatter=lambda lockstr: "\n".join(_locks_display(caller, lstr)
+ for lstr in lockstr.split(';')),
+ only_inherit=True),
+ action=_format_list_actions("examine", "remove", prefix="Actions: "))
+
+ helptext = """
+ Here is an example of two lock strings:
+
+ edit:false()
+ call:tag(Foo) OR perm(Builder)
+
+ Above locks limit two things, 'edit' and 'call'. Which lock types are actually checked
+ depend on the typeclass of the object being spawned. Here 'edit' is never allowed by anyone
+ while 'call' is allowed to all accessors with a |ctag|n 'Foo' OR which has the
+ |cPermission|n 'Builder'.
+
+ |cAvailable lockfuncs:|n
+
+ {lfuncs}
+ """.format(lfuncs=_format_lockfuncs())
+
+ text = (text, helptext)
+
+ options = _wizard_options("locks", "tags", "permissions")
+ options.append({"key": "_default",
+ "goto": _locks_actions})
+
+ return text, options
+
+
+# permissions node
+
+def _caller_permissions(caller):
+ prototype = _get_menu_prototype(caller)
+ perms = prototype.get("permissions", [])
+ return perms
+
+
+def _display_perm(caller, permission, only_hierarchy=False):
+ hierarchy = settings.PERMISSION_HIERARCHY
+ perm_low = permission.lower()
+ txt = ''
+ if perm_low in [prm.lower() for prm in hierarchy]:
+ txt = "Permission (in hieararchy): {}".format(
+ ", ".join(
+ ["|w[{}]|n".format(prm)
+ if prm.lower() == perm_low else "|W{}|n".format(prm)
+ for prm in hierarchy]))
+ elif not only_hierarchy:
+ txt = "Permission: '{}'".format(permission)
+ return txt
+
+
+def _permission_select(caller, permission, **kwargs):
+ return "node_examine_entity", {"text": _display_perm(caller, permission), "back": "permissions"}
+
+
+def _add_perm(caller, perm, **kwargs):
+ if perm:
+ perm_low = perm.lower()
+ perms = _caller_permissions(caller)
+ perms_low = [prm.lower() for prm in perms]
+ if 'delete' in kwargs:
+ try:
+ ind = perms_low.index(perm_low)
+ del perms[ind]
+ text = "Removed Permission '{}'.".format(perm)
+ except ValueError:
+ text = "Found no Permission to remove."
+ else:
+ if perm_low in perms_low:
+ text = "Permission already set."
+ else:
+ perms.append(perm)
+ _set_prototype_value(caller, "permissions", perms)
+ text = "Added Permission '{}'".format(perm)
+ return text
+
+
+def _permissions_actions(caller, raw_inp, **kwargs):
+ """Parse actions for permission listing"""
+ choices = kwargs.get("available_choices", [])
+ perm, action = _default_parse(
+ raw_inp, choices, ('examine', 'e'), ('remove', 'r', 'delete', 'd'))
+
+ if perm:
+ if action == 'examine':
+ return "node_examine_entity", \
+ {"text": _display_perm(caller, perm), "back": "permissions"}
+ elif action == 'remove':
+ res = _add_perm(caller, perm, delete=True)
+ caller.msg(res)
+ else:
+ res = _add_perm(caller, raw_inp.strip())
+ caller.msg(res)
+ return "node_permissions"
+
+
+@list_node(_caller_permissions, _permission_select)
+def node_permissions(caller):
+
+ def _currentcmp(pval, fval):
+ cmp1 = [perm.lower() for perm in pval]
+ return [perm for perm in fval if perm.lower() not in cmp1]
+
+ text = """
+ |cPermissions|n are simple strings used to grant access to this object. A permission is used
+ when a |clock|n is checked that contains the |wperm|n or |wpperm|n lock functions. Certain
+ permissions belong in the |cpermission hierarchy|n together with the |Wperm()|n lock
+ function.
+
+ {current}
+ """.format(
+ current=_get_current_value(
+ caller, 'permissions',
+ comparer=_currentcmp,
+ formatter=lambda lst: "\n" + "\n".join(prm for prm in lst), only_inherit=True))
+ _set_actioninfo(caller, _format_list_actions("examine", "remove", prefix="Actions: "))
+
+ helptext = """
+ Any string can act as a permission as long as a lock is set to look for it. Depending on the
+ lock, having a permission could even be negative (i.e. the lock is only passed if you
+ |wdon't|n have the 'permission'). The most common permissions are the hierarchical
+ permissions:
+
+ {permissions}.
+
+ For example, a |clock|n string like "edit:perm(Builder)" will grant access to accessors
+ having the |cpermission|n "Builder" or higher.
+ """.format(permissions=", ".join(settings.PERMISSION_HIERARCHY))
+
+ text = (text, helptext)
+
+ options = _wizard_options("permissions", "locks", "location")
+ options.append({"key": "_default",
+ "goto": _permissions_actions})
+
+ return text, options
+
+
+# location node
+
+
+def node_location(caller):
+
+ text = """
+ The |cLocation|n of this object in the world. If not given, the object will spawn in the
+ inventory of |c{caller}|n by default.
+
+ {current}
+ """.format(caller=caller.key, current=_get_current_value(caller, "location"))
+
+ helptext = """
+ You get the most control by not specifying the location - you can then teleport the spawned
+ objects as needed later. Setting the location may be useful for quickly populating a given
+ location. One could also consider randomizing the location using a $protfunc.
+
+ |c$protfuncs|n
+ {pfuncs}
+ """.format(pfuncs=_format_protfuncs())
+
+ text = (text, helptext)
+
+ options = _wizard_options("location", "permissions", "home", search=True)
+ options.append({"key": "_default",
+ "goto": (_set_property,
+ dict(prop="location",
+ processor=lambda s: s.strip()))})
+ return text, options
+
+
+# home node
+
+
+def node_home(caller):
+
+ text = """
+ The |cHome|n location of an object is often only used as a backup - this is where the object
+ will be moved to if its location is deleted. The home location can also be used as an actual
+ home for characters to quickly move back to.
+
+ If unset, the global home default (|w{default}|n) will be used.
+
+ {current}
+ """.format(default=settings.DEFAULT_HOME,
+ current=_get_current_value(caller, "home"))
+ helptext = """
+ The home can be given as a #dbref but can also be specified using the protfunc
+ '$obj(name)'. Use |wSE|nearch to find objects in the database.
+
+ The home location is commonly not used except as a backup; using the global default is often
+ enough.
+
+ |c$protfuncs|n
+ {pfuncs}
+ """.format(pfuncs=_format_protfuncs())
+
+ text = (text, helptext)
+
+ options = _wizard_options("home", "location", "destination", search=True)
+ options.append({"key": "_default",
+ "goto": (_set_property,
+ dict(prop="home",
+ processor=lambda s: s.strip()))})
+ return text, options
+
+
+# destination node
+
+
+def node_destination(caller):
+
+ text = """
+ The object's |cDestination|n is generally only used by Exit-like objects to designate where
+ the exit 'leads to'. It's usually unset for all other types of objects.
+
+ {current}
+ """.format(current=_get_current_value(caller, "destination"))
+
+ helptext = """
+ The destination can be given as a #dbref but can also be specified using the protfunc
+ '$obj(name)'. Use |wSEearch to find objects in the database.
+
+ |c$protfuncs|n
+ {pfuncs}
+ """.format(pfuncs=_format_protfuncs())
+
+ text = (text, helptext)
+
+ options = _wizard_options("destination", "home", "prototype_desc", search=True)
+ options.append({"key": "_default",
+ "goto": (_set_property,
+ dict(prop="destination",
+ processor=lambda s: s.strip()))})
+ return text, options
+
+
+# prototype_desc node
+
+
+def node_prototype_desc(caller):
+
+ text = """
+ The |cPrototype-Description|n briefly describes the prototype when it's viewed in listings.
+
+ {current}
+ """.format(current=_get_current_value(caller, "prototype_desc"))
+
+ helptext = """
+ Giving a brief description helps you and others to locate the prototype for use later.
+ """
+
+ text = (text, helptext)
+
+ options = _wizard_options("prototype_desc", "prototype_key", "prototype_tags")
+ options.append({"key": "_default",
+ "goto": (_set_property,
+ dict(prop='prototype_desc',
+ processor=lambda s: s.strip(),
+ next_node="node_prototype_desc"))})
+
+ return text, options
+
+
+# prototype_tags node
+
+
+def _caller_prototype_tags(caller):
+ prototype = _get_menu_prototype(caller)
+ tags = prototype.get("prototype_tags", [])
+ return tags
+
+
+def _add_prototype_tag(caller, tag_string, **kwargs):
+ """
+ Add prototype_tags to the system. We only support straight tags, no
+ categories (category is assigned automatically).
+
+ Args:
+ caller (Object): Caller of menu.
+ tag_string (str): Input from user - only tagname
+
+ Kwargs:
+ delete (str): If this is set, tag_string is considered
+ the name of the tag to delete.
+
+ Returns:
+ result (str): Result string of action.
+
+ """
+ tag = tag_string.strip().lower()
+
+ if tag:
+ prot = _get_menu_prototype(caller)
+ tags = prot.get('prototype_tags', [])
+ exists = tag in tags
+
+ if 'delete' in kwargs:
+ if exists:
+ tags.pop(tags.index(tag))
+ text = "Removed Prototype-Tag '{}'.".format(tag)
+ else:
+ text = "Found no Prototype-Tag to remove."
+ elif not exists:
+ # a fresh, new tag
+ tags.append(tag)
+ text = "Added Prototype-Tag '{}'.".format(tag)
+ else:
+ text = "Prototype-Tag already added."
+
+ _set_prototype_value(caller, "prototype_tags", tags)
+ else:
+ text = "No Prototype-Tag specified."
+
+ return text
+
+
+def _prototype_tag_select(caller, tagname):
+ caller.msg("Prototype-Tag: {}".format(tagname))
+ return "node_prototype_tags"
+
+
+def _prototype_tags_actions(caller, raw_inp, **kwargs):
+ """Parse actions for tags listing"""
+ choices = kwargs.get("available_choices", [])
+ tagname, action = _default_parse(
+ raw_inp, choices, ('remove', 'r', 'delete', 'd'))
+
+ if tagname:
+ if action == 'remove':
+ res = _add_prototype_tag(caller, tagname, delete=True)
+ caller.msg(res)
+ else:
+ res = _add_prototype_tag(caller, raw_inp.lower().strip())
+ caller.msg(res)
+ return "node_prototype_tags"
+
+
+@list_node(_caller_prototype_tags, _prototype_tag_select)
+def node_prototype_tags(caller):
+
+ text = """
+ |cPrototype-Tags|n can be used to classify and find prototypes in listings Tag names are not
+ case-sensitive and can have not have a custom category.
+
+ {current}
+ """.format(
+ current=_get_current_value(
+ caller, 'prototype_tags',
+ formatter=lambda lst: ", ".join(tg for tg in lst), only_inherit=True))
+ _set_actioninfo(caller, _format_list_actions(
+ "remove", prefix="|w|n|W to add Tag. Other Action:|n "))
+ helptext = """
+ Using prototype-tags is a good way to organize and group large numbers of prototypes by
+ genre, type etc. Under the hood, prototypes' tags will all be stored with the category
+ '{tagmetacategory}'.
+ """.format(tagmetacategory=protlib._PROTOTYPE_TAG_META_CATEGORY)
+
+ text = (text, helptext)
+
+ options = _wizard_options("prototype_tags", "prototype_desc", "prototype_locks")
+ options.append({"key": "_default",
+ "goto": _prototype_tags_actions})
+
+ return text, options
+
+
+# prototype_locks node
+
+
+def _caller_prototype_locks(caller):
+ locks = _get_menu_prototype(caller).get("prototype_locks", "")
+ return [lck for lck in locks.split(";") if lck]
+
+
+def _prototype_lock_select(caller, lockstr):
+ return "node_examine_entity", {"text": _locks_display(caller, lockstr), "back": "prototype_locks"}
+
+
+def _prototype_lock_add(caller, lock, **kwargs):
+ locks = _caller_prototype_locks(caller)
+
+ try:
+ locktype, lockdef = lock.split(":", 1)
+ except ValueError:
+ return "Lockstring lacks ':'."
+
+ locktype = locktype.strip().lower()
+
+ if 'delete' in kwargs:
+ try:
+ ind = locks.index(lock)
+ locks.pop(ind)
+ _set_prototype_value(caller, "prototype_locks", ";".join(locks), parse=False)
+ ret = "Prototype-lock {} deleted.".format(lock)
+ except ValueError:
+ ret = "No Prototype-lock found to delete."
+ return ret
+ try:
+ locktypes = [lck.split(":", 1)[0].strip().lower() for lck in locks]
+ ind = locktypes.index(locktype)
+ locks[ind] = lock
+ ret = "Prototype-lock with locktype '{}' updated.".format(locktype)
+ except ValueError:
+ locks.append(lock)
+ ret = "Added Prototype-lock '{}'.".format(lock)
+ _set_prototype_value(caller, "prototype_locks", ";".join(locks))
+ return ret
+
+
+def _prototype_locks_actions(caller, raw_inp, **kwargs):
+ choices = kwargs.get("available_choices", [])
+ lock, action = _default_parse(
+ raw_inp, choices, ("examine", "e"), ("remove", "r", "delete", "d"))
+
+ if lock:
+ if action == 'examine':
+ 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)
+ else:
+ ret = _prototype_lock_add(caller, raw_inp.strip())
+ caller.msg(ret)
+
+ return "node_prototype_locks"
+
+
+@list_node(_caller_prototype_locks, _prototype_lock_select)
+def node_prototype_locks(caller):
+
+ text = """
+ |cPrototype-Locks|n are used to limit access to this prototype when someone else is trying
+ to access it. By default any prototype can be edited only by the creator and by Admins while
+ they can be used by anyone with access to the spawn command. There are two valid lock types
+ the prototype access tools look for:
+
+ - 'edit': Who can edit the prototype.
+ - 'spawn': Who can spawn new objects with this prototype.
+
+ If unsure, keep the open defaults.
+
+ {current}
+ """.format(
+ current=_get_current_value(
+ caller, 'prototype_locks',
+ formatter=lambda lstring: "\n".join(_locks_display(caller, lstr)
+ for lstr in lstring.split(';')),
+ only_inherit=True))
+ _set_actioninfo(caller, _format_list_actions('examine', "remove", prefix="Actions: "))
+
+ helptext = """
+ Prototype locks can be used to vary access for different tiers of builders. It also allows
+ developers to produce 'base prototypes' only meant for builders to inherit and expand on
+ rather than tweak in-place.
+ """
+
+ text = (text, helptext)
+
+ options = _wizard_options("prototype_locks", "prototype_tags", "index")
+ options.append({"key": "_default",
+ "goto": _prototype_locks_actions})
+
+ return text, options
+
+
+# update existing objects node
+
+
+def _apply_diff(caller, **kwargs):
+ """update existing objects"""
+ prototype = kwargs['prototype']
+ objects = kwargs['objects']
+ back_node = kwargs['back_node']
+ diff = kwargs.get('diff', None)
+ num_changed = spawner.batch_update_objects_with_prototype(prototype, diff=diff, objects=objects)
+ caller.msg("|g{num} objects were updated successfully.|n".format(num=num_changed))
+ return back_node
+
+
+def _keep_diff(caller, **kwargs):
+ """Change to KEEP setting for a given section of a diff"""
+ # from evennia import set_trace;set_trace(term_size=(182, 50))
+ path = kwargs['path']
+ diff = kwargs['diff']
+ tmp = diff
+ for key in path[:-1]:
+ tmp = tmp[key]
+ tmp[path[-1]] = tuple(list(tmp[path[-1]][:-1]) + ["KEEP"])
+
+
+def _format_diff_text_and_options(diff, **kwargs):
+ """
+ Reformat the diff in a way suitable for the olc menu.
+
+ Args:
+ diff (dict): A diff as produced by `prototype_diff`.
+
+ Kwargs:
+ any (any): Forwarded into the generated options as arguments to the callable.
+
+ Returns:
+ texts (list): List of texts.
+ options (list): List of options dict.
+
+ """
+ valid_instructions = ('KEEP', 'REMOVE', 'ADD', 'UPDATE')
+
+ def _visualize(obj, rootname, get_name=False):
+ if utils.is_iter(obj):
+ if get_name:
+ return obj[0] if obj[0] else ""
+ if rootname == "attrs":
+ return "{} |W=|n {} |W(category:|n {}|W, locks:|n {}|W)|n".format(*obj)
+ elif rootname == "tags":
+ return "{} |W(category:|n {}|W)|n".format(obj[0], obj[1])
+ return "{}".format(obj)
+
+ def _parse_diffpart(diffpart, optnum, *args):
+ typ = type(diffpart)
+ texts = []
+ options = []
+ if typ == tuple and len(diffpart) == 3 and diffpart[2] in valid_instructions:
+ rootname = args[0]
+ old, new, instruction = diffpart
+ if instruction == 'KEEP':
+ texts.append(" |gKEEP|W:|n {old}".format(old=_visualize(old, rootname)))
+ else:
+ vold = _visualize(old, rootname)
+ vnew = _visualize(new, rootname)
+ vsep = "" if len(vold) < 78 else "\n"
+ vinst = "|rREMOVE|n" if instruction == 'REMOVE' else "|y{}|n".format(instruction)
+ texts.append(" |c[{num}] {inst}|W:|n {old} |W->|n{sep} {new}".format(
+ inst=vinst, num=optnum, old=vold, sep=vsep, new=vnew))
+ options.append({"key": str(optnum),
+ "desc": "|gKEEP|n ({}) {}".format(
+ rootname, _visualize(old, args[-1], get_name=True)),
+ "goto": (_keep_diff, dict((("path", args),
+ ("diff", diff)), **kwargs))})
+ optnum += 1
+ else:
+ for key, subdiffpart in diffpart.items():
+ text, option, optnum = _parse_diffpart(
+ subdiffpart, optnum, *(args + (key, )))
+ texts.extend(text)
+ options.extend(option)
+ return texts, options, optnum
+
+ texts = []
+ options = []
+ # we use this to allow for skipping full KEEP instructions
+ optnum = 1
+
+ for root_key in sorted(diff):
+ diffpart = diff[root_key]
+ text, option, optnum = _parse_diffpart(diffpart, optnum, root_key)
+
+ heading = "- |w{}:|n ".format(root_key)
+ if root_key in ("attrs", "tags", "permissions"):
+ texts.append(heading)
+ elif text:
+ text = [heading + text[0]] + text[1:]
+ else:
+ text = [heading]
+
+ texts.extend(text)
+ options.extend(option)
+
+ return texts, options
+
+
+def node_apply_diff(caller, **kwargs):
+ """Offer options for updating objects"""
+
+ def _keep_option(keyname, prototype, base_obj, obj_prototype, diff, objects, back_node):
+ """helper returning an option dict"""
+ options = {"desc": "Keep {} as-is".format(keyname),
+ "goto": (_keep_diff,
+ {"key": keyname, "prototype": prototype,
+ "base_obj": base_obj, "obj_prototype": obj_prototype,
+ "diff": diff, "objects": objects, "back_node": back_node})}
+ return options
+
+ prototype = kwargs.get("prototype", None)
+ update_objects = kwargs.get("objects", None)
+ back_node = kwargs.get("back_node", "node_index")
+ obj_prototype = kwargs.get("obj_prototype", None)
+ base_obj = kwargs.get("base_obj", None)
+ diff = kwargs.get("diff", None)
+ custom_location = kwargs.get("custom_location", None)
+
+ if not update_objects:
+ text = "There are no existing objects to update."
+ options = {"key": "_default",
+ "goto": back_node}
+ return text, options
+
+ if not diff:
+ # use one random object as a reference to calculate a diff
+ base_obj = choice(update_objects)
+
+ diff, obj_prototype = spawner.prototype_diff_from_object(prototype, base_obj)
+
+ helptext = """
+ This will go through all existing objects and apply the changes you accept.
+
+ Be careful with this operation! The upgrade mechanism will try to automatically estimate
+ what changes need to be applied. But the estimate is |wonly based on the analysis of one
+ randomly selected object|n among all objects spawned by this prototype. If that object
+ happens to be unusual in some way the estimate will be off and may lead to unexpected
+ results for other objects. Always test your objects carefully after an upgrade and consider
+ being conservative (switch to KEEP) for things you are unsure of. For complex upgrades it
+ may be better to get help from an administrator with access to the `@py` command for doing
+ this manually.
+
+ Note that the `location` will never be auto-adjusted because it's so rare to want to
+ homogenize the location of all object instances."""
+
+ if not custom_location:
+ diff.pop("location", None)
+
+ txt, options = _format_diff_text_and_options(diff, objects=update_objects, base_obj=base_obj)
+
+ if options:
+ text = ["Suggested changes to {} objects. ".format(len(update_objects)),
+ "Showing random example obj to change: {name} ({dbref}))\n".format(
+ name=base_obj.key, dbref=base_obj.dbref)] + txt
+ options.extend(
+ [{"key": ("|wu|Wpdate {} objects".format(len(update_objects)), "update", "u"),
+ "desc": "Update {} objects".format(len(update_objects)),
+ "goto": (_apply_diff, {"prototype": prototype, "objects": update_objects,
+ "back_node": back_node, "diff": diff, "base_obj": base_obj})},
+ {"key": ("|wr|Weset changes", "reset", "r"),
+ "goto": ("node_apply_diff", {"prototype": prototype, "back_node": back_node,
+ "objects": update_objects})}])
+ else:
+ text = ["Analyzed a random sample object (out of {}) - "
+ "found no changes to apply.".format(len(update_objects))]
+
+ options.extend(_wizard_options("update_objects", back_node[5:], None))
+ options.append({"key": "_default",
+ "goto": back_node})
+
+ text = "\n".join(text)
+ text = (text, helptext)
+
+ return text, options
+
+
+# prototype save node
+
+
+def node_prototype_save(caller, **kwargs):
+ """Save prototype to disk """
+ # these are only set if we selected 'yes' to save on a previous pass
+ prototype = kwargs.get("prototype", None)
+ # set to True/False if answered, None if first pass
+ accept_save = kwargs.get("accept_save", None)
+
+ if accept_save and prototype:
+ # we already validated and accepted the save, so this node acts as a goto callback and
+ # should now only return the next node
+ prototype_key = prototype.get("prototype_key")
+ protlib.save_prototype(**prototype)
+
+ spawned_objects = protlib.search_objects_with_prototype(prototype_key)
+ nspawned = spawned_objects.count()
+
+ text = ["|gPrototype saved.|n"]
+
+ if nspawned:
+ text.append("\nDo you want to update {} object(s) "
+ "already using this prototype?".format(nspawned))
+ options = (
+ {"key": ("|wY|Wes|n", "yes", "y"),
+ "desc": "Go to updating screen",
+ "goto": ("node_apply_diff",
+ {"accept_update": True, "objects": spawned_objects,
+ "prototype": prototype, "back_node": "node_prototype_save"})},
+ {"key": ("[|wN|Wo|n]", "n"),
+ "desc": "Return to index",
+ "goto": "node_index"},
+ {"key": "_default",
+ "goto": "node_index"})
+ else:
+ text.append("(press Return to continue)")
+ options = {"key": "_default",
+ "goto": "node_index"}
+
+ text = "\n".join(text)
+
+ helptext = """
+ Updating objects means that the spawner will find all objects previously created by this
+ prototype. You will be presented with a list of the changes the system will try to apply to
+ each of these objects and you can choose to customize that change if needed. If you have
+ done a lot of manual changes to your objects after spawning, you might want to update those
+ objects manually instead.
+ """
+
+ text = (text, helptext)
+
+ return text, options
+
+ # not validated yet
+ prototype = _get_menu_prototype(caller)
+ error, text = _validate_prototype(prototype)
+
+ text = [text]
+
+ if error:
+ # abort save
+ text.append(
+ "\n|yValidation errors were found. They need to be corrected before this prototype "
+ "can be saved (or used to spawn).|n")
+ options = _wizard_options("prototype_save", "index", None)
+ options.append({"key": "_default",
+ "goto": "node_index"})
+ return "\n".join(text), options
+
+ prototype_key = prototype['prototype_key']
+ if protlib.search_prototype(prototype_key):
+ text.append("\nDo you want to save/overwrite the existing prototype '{name}'?".format(
+ name=prototype_key))
+ else:
+ text.append("\nDo you want to save the prototype as '{name}'?".format(name=prototype_key))
+
+ text = "\n".join(text)
+
+ helptext = """
+ Saving the prototype makes it available for use later. It can also be used to inherit from,
+ by name. Depending on |cprototype-locks|n it also makes the prototype usable and/or
+ editable by others. Consider setting good |cPrototype-tags|n and to give a useful, brief
+ |cPrototype-desc|n to make the prototype easy to find later.
+
+ """
+
+ text = (text, helptext)
+
+ options = (
+ {"key": ("[|wY|Wes|n]", "yes", "y"),
+ "desc": "Save prototype",
+ "goto": ("node_prototype_save",
+ {"accept_save": True, "prototype": prototype})},
+ {"key": ("|wN|Wo|n", "n"),
+ "desc": "Abort and return to Index",
+ "goto": "node_index"},
+ {"key": "_default",
+ "goto": ("node_prototype_save",
+ {"accept_save": True, "prototype": prototype})})
+
+ return text, options
+
+
+# spawning node
+
+
+def _spawn(caller, **kwargs):
+ """Spawn prototype"""
+ prototype = kwargs["prototype"].copy()
+ new_location = kwargs.get('location', None)
+ if new_location:
+ prototype['location'] = new_location
+ if not prototype.get('location'):
+ prototype['location'] = caller
+
+ obj = spawner.spawn(prototype)
+ if obj:
+ obj = obj[0]
+ text = "|gNew instance|n {key} ({dbref}) |gspawned at location |n{loc}|n|g.|n".format(
+ key=obj.key, dbref=obj.dbref, loc=prototype['location'])
+ else:
+ text = "|rError: Spawner did not return a new instance.|n"
+ return "node_examine_entity", {"text": text, "back": "prototype_spawn"}
+
+
+def node_prototype_spawn(caller, **kwargs):
+ """Submenu for spawning the prototype"""
+
+ prototype = _get_menu_prototype(caller)
+
+ already_validated = kwargs.get("already_validated", False)
+
+ if already_validated:
+ error, text = None, []
+ else:
+ error, text = _validate_prototype(prototype)
+ text = [text]
+
+ if error:
+ text.append("\n|rPrototype validation failed. Correct the errors before spawning.|n")
+ options = _wizard_options("prototype_spawn", "index", None)
+ return "\n".join(text), options
+
+ text = "\n".join(text)
+
+ helptext = """
+ Spawning is the act of instantiating a prototype into an actual object. As a new object is
+ spawned, every $protfunc in the prototype is called anew. Since this is a common thing to
+ do, you may also temporarily change the |clocation|n of this prototype to bypass whatever
+ value is set in the prototype.
+
+ """
+ text = (text, helptext)
+
+ # show spawn submenu options
+ options = []
+ prototype_key = prototype['prototype_key']
+ location = prototype.get('location', None)
+
+ if location:
+ options.append(
+ {"desc": "Spawn in prototype's defined location ({loc})".format(loc=location),
+ "goto": (_spawn,
+ dict(prototype=prototype, location=location, custom_location=True))})
+ caller_loc = caller.location
+ if location != caller_loc:
+ options.append(
+ {"desc": "Spawn in {caller}'s location ({loc})".format(
+ caller=caller, loc=caller_loc),
+ "goto": (_spawn,
+ dict(prototype=prototype, location=caller_loc))})
+ if location != caller_loc != caller:
+ options.append(
+ {"desc": "Spawn in {caller}'s inventory".format(caller=caller),
+ "goto": (_spawn,
+ dict(prototype=prototype, location=caller))})
+
+ spawned_objects = protlib.search_objects_with_prototype(prototype_key)
+ nspawned = spawned_objects.count()
+ if spawned_objects:
+ options.append(
+ {"desc": "Update {num} existing objects with this prototype".format(num=nspawned),
+ "goto": ("node_apply_diff",
+ {"objects": list(spawned_objects),
+ "prototype": prototype,
+ "back_node": "node_prototype_spawn"})})
+ options.extend(_wizard_options("prototype_spawn", "index", None))
+ options.append({"key": "_default",
+ "goto": "node_index"})
+
+ return text, options
+
+
+# prototype load node
+
+
+def _prototype_load_select(caller, prototype_key):
+ matches = protlib.search_prototype(key=prototype_key)
+ if matches:
+ prototype = matches[0]
+ _set_menu_prototype(caller, prototype)
+ return "node_examine_entity", \
+ {"text": "|gLoaded prototype {}.|n".format(prototype['prototype_key']),
+ "back": "index"}
+ else:
+ caller.msg("|rFailed to load prototype '{}'.".format(prototype_key))
+ return None
+
+
+def _prototype_load_actions(caller, raw_inp, **kwargs):
+ """Parse the default Convert prototype to a string representation for closer inspection"""
+ choices = kwargs.get("available_choices", [])
+ prototype, action = _default_parse(
+ raw_inp, choices, ("examine", "e", "l"), ("delete", "del", "d"))
+
+ if prototype:
+
+ # which action to apply on the selection
+ if action == 'examine':
+ # examine the prototype
+ prototype = protlib.search_prototype(key=prototype)[0]
+ txt = protlib.prototype_to_str(prototype)
+ return "node_examine_entity", {"text": txt, "back": 'prototype_load'}
+ elif action == 'delete':
+ # delete prototype from disk
+ try:
+ protlib.delete_prototype(prototype, caller=caller)
+ except protlib.PermissionError as err:
+ txt = "|rDeletion error:|n {}".format(err)
+ else:
+ txt = "|gPrototype {} was deleted.|n".format(prototype)
+ return "node_examine_entity", {"text": txt, "back": "prototype_load"}
+
+ return 'node_prototype_load'
+
+
+@list_node(_all_prototype_parents, _prototype_load_select)
+def node_prototype_load(caller, **kwargs):
+ """Load prototype"""
+
+ text = """
+ Select a prototype to load. This will replace any prototype currently being edited!
+ """
+ _set_actioninfo(caller, _format_list_actions("examine", "delete"))
+
+ helptext = """
+ Loading a prototype will load it and return you to the main index. It can be a good idea
+ to examine the prototype before loading it.
+ """
+
+ text = (text, helptext)
+
+ options = _wizard_options("prototype_load", "index", None)
+ options.append({"key": "_default",
+ "goto": _prototype_load_actions})
+
+ return text, options
+
+
+# EvMenu definition, formatting and access functions
+
+
+class OLCMenu(EvMenu):
+ """
+ A custom EvMenu with a different formatting for the options.
+
+ """
+ def nodetext_formatter(self, nodetext):
+ """
+ Format the node text itself.
+
+ """
+ return super(OLCMenu, self).nodetext_formatter(nodetext)
+
+ def options_formatter(self, optionlist):
+ """
+ Split the options into two blocks - olc options and normal options
+
+ """
+ olc_keys = ("index", "forward", "back", "previous", "next", "validate prototype",
+ "save prototype", "load prototype", "spawn prototype", "search objects")
+ actioninfo = self.actioninfo + "\n" if hasattr(self, 'actioninfo') else ''
+ self.actioninfo = '' # important, or this could bleed over to other nodes
+ olc_options = []
+ other_options = []
+ for key, desc in optionlist:
+ raw_key = strip_ansi(key).lower()
+ if raw_key in olc_keys:
+ desc = " {}".format(desc) if desc else ""
+ olc_options.append("|lc{}|lt{}|le{}".format(raw_key, key, desc))
+ else:
+ other_options.append((key, desc))
+
+ olc_options = actioninfo + \
+ " |W|||n ".join(olc_options) + " |W|||n " + "|wQ|Wuit" if olc_options else ""
+ other_options = super(OLCMenu, self).options_formatter(other_options)
+ sep = "\n\n" if olc_options and other_options else ""
+
+ return "{}{}{}".format(olc_options, sep, other_options)
+
+ def helptext_formatter(self, helptext):
+ """
+ Show help text
+ """
+ return "|c --- Help ---|n\n" + utils.dedent(helptext)
+
+ def display_helptext(self):
+ evmore.msg(self.caller, self.helptext, session=self._session, exit_cmd='look')
+
+
+def start_olc(caller, session=None, prototype=None):
+ """
+ Start menu-driven olc system for prototypes.
+
+ Args:
+ caller (Object or Account): The entity starting the menu.
+ session (Session, optional): The individual session to get data.
+ prototype (dict, optional): Given when editing an existing
+ prototype rather than creating a new one.
+
+ """
+ menudata = {"node_index": node_index,
+ "node_validate_prototype": node_validate_prototype,
+ "node_examine_entity": node_examine_entity,
+ "node_search_object": node_search_object,
+ "node_prototype_key": node_prototype_key,
+ "node_prototype_parent": node_prototype_parent,
+ "node_typeclass": node_typeclass,
+ "node_key": node_key,
+ "node_aliases": node_aliases,
+ "node_attrs": node_attrs,
+ "node_tags": node_tags,
+ "node_locks": node_locks,
+ "node_permissions": node_permissions,
+ "node_location": node_location,
+ "node_home": node_home,
+ "node_destination": node_destination,
+ "node_apply_diff": node_apply_diff,
+ "node_prototype_desc": node_prototype_desc,
+ "node_prototype_tags": node_prototype_tags,
+ "node_prototype_locks": node_prototype_locks,
+ "node_prototype_load": node_prototype_load,
+ "node_prototype_save": node_prototype_save,
+ "node_prototype_spawn": node_prototype_spawn
+ }
+ OLCMenu(caller, menudata, startnode='node_index', session=session,
+ olc_prototype=prototype, debug=True)
diff --git a/evennia/prototypes/protfuncs.py b/evennia/prototypes/protfuncs.py
new file mode 100644
index 0000000000..a13aa7e532
--- /dev/null
+++ b/evennia/prototypes/protfuncs.py
@@ -0,0 +1,327 @@
+"""
+Protfuncs are function-strings embedded in a prototype and allows for a builder to create a
+prototype with custom logics without having access to Python. The Protfunc is parsed using the
+inlinefunc parser but is fired at the moment the spawning happens, using the creating object's
+session as input.
+
+In the prototype dict, the protfunc is specified as a string inside the prototype, e.g.:
+
+ { ...
+
+ "key": "$funcname(arg1, arg2, ...)"
+
+ ... }
+
+and multiple functions can be nested (no keyword args are supported). The result will be used as the
+value for that prototype key for that individual spawn.
+
+Available protfuncs are callables in one of the modules of `settings.PROT_FUNC_MODULES`. They
+are specified as functions
+
+ def funcname (*args, **kwargs)
+
+where *args are the arguments given in the prototype, and **kwargs are inserted by Evennia:
+
+ - session (Session): The Session of the entity spawning using this prototype.
+ - prototype (dict): The dict this protfunc is a part of.
+ - current_key (str): The active key this value belongs to in the prototype.
+ - testing (bool): This is set if this function is called as part of the prototype validation; if
+ set, the protfunc should take care not to perform any persistent actions, such as operate on
+ objects or add things to the database.
+
+Any traceback raised by this function will be handled at the time of spawning and abort the spawn
+before any object is created/updated. It must otherwise return the value to store for the specified
+prototype key (this value must be possible to serialize in an Attribute).
+
+"""
+
+from ast import literal_eval
+from random import randint as base_randint, random as base_random, choice as base_choice
+
+from evennia.utils import search
+from evennia.utils.utils import justify as base_justify, is_iter, to_str
+
+_PROTLIB = None
+
+
+# default protfuncs
+
+def random(*args, **kwargs):
+ """
+ Usage: $random()
+ Returns a random value in the interval [0, 1)
+
+ """
+ return base_random()
+
+
+def randint(*args, **kwargs):
+ """
+ Usage: $randint(start, end)
+ Returns random integer in interval [start, end]
+
+ """
+ if len(args) != 2:
+ raise TypeError("$randint needs two arguments - start and end.")
+ start, end = int(args[0]), int(args[1])
+ return base_randint(start, end)
+
+
+def left_justify(*args, **kwargs):
+ """
+ Usage: $left_justify()
+ Returns left-justified.
+
+ """
+ if args:
+ return base_justify(args[0], align='l')
+ return ""
+
+
+def right_justify(*args, **kwargs):
+ """
+ Usage: $right_justify()
+ Returns right-justified across screen width.
+
+ """
+ if args:
+ return base_justify(args[0], align='r')
+ return ""
+
+
+def center_justify(*args, **kwargs):
+
+ """
+ Usage: $center_justify()
+ Returns centered in screen width.
+
+ """
+ if args:
+ return base_justify(args[0], align='c')
+ return ""
+
+
+def choice(*args, **kwargs):
+ """
+ Usage: $choice(val, val, val, ...)
+ Returns one of the values randomly
+ """
+ if args:
+ return base_choice(args)
+ return ""
+
+
+def full_justify(*args, **kwargs):
+
+ """
+ Usage: $full_justify()
+ Returns filling up screen width by adding extra space.
+
+ """
+ if args:
+ return base_justify(args[0], align='f')
+ return ""
+
+
+def protkey(*args, **kwargs):
+ """
+ Usage: $protkey()
+ Returns the value of another key in this prototoype. Will raise an error if
+ the key is not found in this prototype.
+
+ """
+ if args:
+ prototype = kwargs['prototype']
+ return prototype[args[0].strip()]
+
+
+def add(*args, **kwargs):
+ """
+ Usage: $add(val1, val2)
+ Returns the result of val1 + val2. Values must be
+ valid simple Python structures possible to add,
+ such as numbers, lists etc.
+
+ """
+ if len(args) > 1:
+ val1, val2 = args[0], args[1]
+ # try to convert to python structures, otherwise, keep as strings
+ try:
+ val1 = literal_eval(val1.strip())
+ except Exception:
+ pass
+ try:
+ val2 = literal_eval(val2.strip())
+ except Exception:
+ pass
+ return val1 + val2
+ raise ValueError("$add requires two arguments.")
+
+
+def sub(*args, **kwargs):
+ """
+ Usage: $del(val1, val2)
+ Returns the value of val1 - val2. Values must be
+ valid simple Python structures possible to
+ subtract.
+
+ """
+ if len(args) > 1:
+ val1, val2 = args[0], args[1]
+ # try to convert to python structures, otherwise, keep as strings
+ try:
+ val1 = literal_eval(val1.strip())
+ except Exception:
+ pass
+ try:
+ val2 = literal_eval(val2.strip())
+ except Exception:
+ pass
+ return val1 - val2
+ raise ValueError("$sub requires two arguments.")
+
+
+def mult(*args, **kwargs):
+ """
+ Usage: $mul(val1, val2)
+ Returns the value of val1 * val2. The values must be
+ valid simple Python structures possible to
+ multiply, like strings and/or numbers.
+
+ """
+ if len(args) > 1:
+ val1, val2 = args[0], args[1]
+ # try to convert to python structures, otherwise, keep as strings
+ try:
+ val1 = literal_eval(val1.strip())
+ except Exception:
+ pass
+ try:
+ val2 = literal_eval(val2.strip())
+ except Exception:
+ pass
+ return val1 * val2
+ raise ValueError("$mul requires two arguments.")
+
+
+def div(*args, **kwargs):
+ """
+ Usage: $div(val1, val2)
+ Returns the value of val1 / val2. Values must be numbers and
+ the result is always a float.
+
+ """
+ if len(args) > 1:
+ val1, val2 = args[0], args[1]
+ # try to convert to python structures, otherwise, keep as strings
+ try:
+ val1 = literal_eval(val1.strip())
+ except Exception:
+ pass
+ try:
+ val2 = literal_eval(val2.strip())
+ except Exception:
+ pass
+ return val1 / float(val2)
+ raise ValueError("$mult requires two arguments.")
+
+
+def toint(*args, **kwargs):
+ """
+ Usage: $toint()
+ Returns as an integer.
+ """
+ if args:
+ val = args[0]
+ try:
+ return int(literal_eval(val.strip()))
+ except ValueError:
+ return val
+ raise ValueError("$toint requires one argument.")
+
+
+def eval(*args, **kwargs):
+ """
+ Usage $eval()
+ Returns evaluation of a simple Python expression. The string may *only* consist of the following
+ Python literal structures: strings, numbers, tuples, lists, dicts, booleans,
+ and None. The strings can also contain #dbrefs. Escape embedded protfuncs as $$protfunc(..)
+ - those will then be evaluated *after* $eval.
+
+ """
+ global _PROTLIB
+ if not _PROTLIB:
+ from evennia.prototypes import prototypes as _PROTLIB
+
+ string = ",".join(args)
+ struct = literal_eval(string)
+
+ if isinstance(struct, basestring):
+ # we must shield the string, otherwise it will be merged as a string and future
+ # literal_evas will pick up e.g. '2' as something that should be converted to a number
+ struct = '"{}"'.format(struct)
+
+ # convert any #dbrefs to objects (also in nested structures)
+ struct = _PROTLIB.value_to_obj_or_any(struct)
+
+ return struct
+
+
+def _obj_search(*args, **kwargs):
+ "Helper function to search for an object"
+
+ query = "".join(args)
+ session = kwargs.get("session", None)
+ return_list = kwargs.pop("return_list", False)
+ account = None
+
+ if session:
+ account = session.account
+
+ targets = search.search_object(query)
+
+ if return_list:
+ retlist = []
+ if account:
+ for target in targets:
+ if target.access(account, target, 'control'):
+ retlist.append(target)
+ else:
+ retlist = targets
+ return retlist
+ else:
+ # single-match
+ if not targets:
+ raise ValueError("$obj: Query '{}' gave no matches.".format(query))
+ if len(targets) > 1:
+ raise ValueError("$obj: Query '{query}' gave {nmatches} matches. Limit your "
+ "query or use $objlist instead.".format(
+ query=query, nmatches=len(targets)))
+ target = targets[0]
+ if account:
+ if not target.access(account, target, 'control'):
+ raise ValueError("$obj: Obj {target}(#{dbref} cannot be added - "
+ "Account {account} does not have 'control' access.".format(
+ target=target.key, dbref=target.id, account=account))
+ return target
+
+
+def obj(*args, **kwargs):
+ """
+ Usage $obj()
+ Returns one Object searched globally by key, alias or #dbref. Error if more than one.
+
+ """
+ obj = _obj_search(return_list=False, *args, **kwargs)
+ if obj:
+ return "#{}".format(obj.id)
+ return "".join(args)
+
+
+def objlist(*args, **kwargs):
+ """
+ Usage $objlist()
+ Returns list with one or more Objects searched globally by key, alias or #dbref.
+
+ """
+ return ["#{}".format(obj.id) for obj in _obj_search(return_list=True, *args, **kwargs)]
diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py
new file mode 100644
index 0000000000..fc8edb55ab
--- /dev/null
+++ b/evennia/prototypes/prototypes.py
@@ -0,0 +1,769 @@
+"""
+
+Handling storage of prototypes, both database-based ones (DBPrototypes) and those defined in modules
+(Read-only prototypes). Also contains utility functions, formatters and manager functions.
+
+"""
+
+import re
+import hashlib
+import time
+from ast import literal_eval
+from django.conf import settings
+from evennia.scripts.scripts import DefaultScript
+from evennia.objects.models import ObjectDB
+from evennia.utils.create import create_script
+from evennia.utils.utils import (
+ all_from_module, make_iter, is_iter, dbid_to_obj, callables_from_module,
+ get_all_typeclasses, to_str, dbref, justify, class_from_module)
+from evennia.locks.lockhandler import validate_lockstring, check_lockstring
+from evennia.utils import logger
+from evennia.utils import inlinefuncs, dbserialize
+from evennia.utils.evtable import EvTable
+
+
+_MODULE_PROTOTYPE_MODULES = {}
+_MODULE_PROTOTYPES = {}
+_PROTOTYPE_META_NAMES = (
+ "prototype_key", "prototype_desc", "prototype_tags", "prototype_locks", "prototype_parent")
+_PROTOTYPE_RESERVED_KEYS = _PROTOTYPE_META_NAMES + (
+ "key", "aliases", "typeclass", "location", "home", "destination",
+ "permissions", "locks", "exec", "tags", "attrs")
+_PROTOTYPE_TAG_CATEGORY = "from_prototype"
+_PROTOTYPE_TAG_META_CATEGORY = "db_prototype"
+PROT_FUNCS = {}
+
+_RE_DBREF = re.compile(r"(? attrs
+ attrs.append((key, val, None, ''))
+ if attrs:
+ homogenized['attrs'] = attrs
+ if homogenized_tags:
+ homogenized['tags'] = homogenized_tags
+
+ # add required missing parts that had defaults before
+
+ if "prototype_key" not in prototype:
+ # assign a random hash as key
+ homogenized["prototype_key"] = "prototype-{}".format(
+ hashlib.md5(str(time.time())).hexdigest()[:7])
+
+ if "typeclass" not in prototype and "prototype_parent" not in prototype:
+ homogenized["typeclass"] = settings.BASE_OBJECT_TYPECLASS
+
+ return homogenized
+
+
+# module-based prototypes
+
+for mod in settings.PROTOTYPE_MODULES:
+ # to remove a default prototype, override it with an empty dict.
+ # internally we store as (key, desc, locks, tags, prototype_dict)
+ prots = []
+ for variable_name, prot in all_from_module(mod).items():
+ if isinstance(prot, dict):
+ if "prototype_key" not in prot:
+ prot['prototype_key'] = variable_name.lower()
+ prots.append((prot['prototype_key'], homogenize_prototype(prot)))
+ # assign module path to each prototype_key for easy reference
+ _MODULE_PROTOTYPE_MODULES.update({prototype_key.lower(): mod for prototype_key, _ in prots})
+ # make sure the prototype contains all meta info
+ for prototype_key, prot in prots:
+ actual_prot_key = prot.get('prototype_key', prototype_key).lower()
+ prot.update({
+ "prototype_key": actual_prot_key,
+ "prototype_desc": prot['prototype_desc'] if 'prototype_desc' in prot else mod,
+ "prototype_locks": (prot['prototype_locks']
+ if 'prototype_locks' in prot else "use:all();edit:false()"),
+ "prototype_tags": list(set(make_iter(prot.get('prototype_tags', [])) + ["module"]))})
+ _MODULE_PROTOTYPES[actual_prot_key] = prot
+
+
+# Db-based prototypes
+
+
+class DbPrototype(DefaultScript):
+ """
+ This stores a single prototype, in an Attribute `prototype`.
+ """
+ def at_script_creation(self):
+ self.key = "empty prototype" # prototype_key
+ self.desc = "A prototype" # prototype_desc (.tags are used for prototype_tags)
+ self.db.prototype = {} # actual prototype
+
+ @property
+ def prototype(self):
+ "Make sure to decouple from db!"
+ return dbserialize.deserialize(self.attributes.get('prototype', {}))
+
+ @prototype.setter
+ def prototype(self, prototype):
+ self.attributes.add('prototype', prototype)
+
+
+# Prototype manager functions
+
+
+def save_prototype(**kwargs):
+ """
+ Create/Store a prototype persistently.
+
+ Kwargs:
+ prototype_key (str): This is required for any storage.
+ All other kwargs are considered part of the new prototype dict.
+
+ Returns:
+ prototype (dict or None): The prototype stored using the given kwargs, None if deleting.
+
+ Raises:
+ prototypes.ValidationError: If prototype does not validate.
+
+ Note:
+ No edit/spawn locks will be checked here - if this function is called the caller
+ is expected to have valid permissions.
+
+ """
+
+ kwargs = homogenize_prototype(kwargs)
+
+ def _to_batchtuple(inp, *args):
+ "build tuple suitable for batch-creation"
+ if is_iter(inp):
+ # already a tuple/list, use as-is
+ return inp
+ return (inp, ) + args
+
+ prototype_key = kwargs.get("prototype_key")
+ if not prototype_key:
+ raise ValidationError("Prototype requires a prototype_key")
+
+ prototype_key = str(prototype_key).lower()
+
+ # we can't edit a prototype defined in a module
+ if prototype_key in _MODULE_PROTOTYPES:
+ mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key, "N/A")
+ raise PermissionError("{} is a read-only prototype "
+ "(defined as code in {}).".format(prototype_key, mod))
+
+ # make sure meta properties are included with defaults
+ stored_prototype = DbPrototype.objects.filter(db_key=prototype_key)
+ prototype = stored_prototype[0].prototype if stored_prototype else {}
+
+ kwargs['prototype_desc'] = kwargs.get("prototype_desc", prototype.get("prototype_desc", ""))
+ prototype_locks = kwargs.get(
+ "prototype_locks", prototype.get('prototype_locks', "spawn:all();edit:perm(Admin)"))
+ is_valid, err = validate_lockstring(prototype_locks)
+ if not is_valid:
+ raise ValidationError("Lock error: {}".format(err))
+ kwargs['prototype_locks'] = prototype_locks
+
+ prototype_tags = [
+ _to_batchtuple(tag, _PROTOTYPE_TAG_META_CATEGORY)
+ for tag in make_iter(kwargs.get("prototype_tags",
+ prototype.get('prototype_tags', [])))]
+ kwargs["prototype_tags"] = prototype_tags
+
+ prototype.update(kwargs)
+
+ if stored_prototype:
+ # edit existing prototype
+ stored_prototype = stored_prototype[0]
+ stored_prototype.desc = prototype['prototype_desc']
+ if prototype_tags:
+ stored_prototype.tags.clear(category=_PROTOTYPE_TAG_CATEGORY)
+ stored_prototype.tags.batch_add(*prototype['prototype_tags'])
+ stored_prototype.locks.add(prototype['prototype_locks'])
+ stored_prototype.attributes.add('prototype', prototype)
+ else:
+ # create a new prototype
+ stored_prototype = create_script(
+ DbPrototype, key=prototype_key, desc=prototype['prototype_desc'], persistent=True,
+ locks=prototype_locks, tags=prototype['prototype_tags'],
+ attributes=[("prototype", prototype)])
+ return stored_prototype.prototype
+
+create_prototype = save_prototype # alias
+
+
+def delete_prototype(prototype_key, caller=None):
+ """
+ Delete a stored prototype
+
+ Args:
+ key (str): The persistent prototype to delete.
+ caller (Account or Object, optionsl): Caller aiming to delete a prototype.
+ Note that no locks will be checked if`caller` is not passed.
+ Returns:
+ success (bool): If deletion worked or not.
+ Raises:
+ PermissionError: If 'edit' lock was not passed or deletion failed for some other reason.
+
+ """
+ if prototype_key in _MODULE_PROTOTYPES:
+ mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key.lower(), "N/A")
+ raise PermissionError("{} is a read-only prototype "
+ "(defined as code in {}).".format(prototype_key, mod))
+
+ stored_prototype = DbPrototype.objects.filter(db_key__iexact=prototype_key)
+
+ if not stored_prototype:
+ raise PermissionError("Prototype {} was not found.".format(prototype_key))
+
+ stored_prototype = stored_prototype[0]
+ if caller:
+ if not stored_prototype.access(caller, 'edit'):
+ raise PermissionError("{} does not have permission to "
+ "delete prototype {}.".format(caller, prototype_key))
+ stored_prototype.delete()
+ return True
+
+
+def search_prototype(key=None, tags=None):
+ """
+ Find prototypes based on key and/or tags, or all prototypes.
+
+ Kwargs:
+ key (str): An exact or partial key to query for.
+ tags (str or list): Tag key or keys to query for. These
+ will always be applied with the 'db_protototype'
+ tag category.
+
+ Return:
+ matches (list): All found prototype dicts. If no keys
+ or tags are given, all available prototypes will be returned.
+
+ Note:
+ The available prototypes is a combination of those supplied in
+ PROTOTYPE_MODULES and those stored in the database. Note that if
+ tags are given and the prototype has no tags defined, it will not
+ be found as a match.
+
+ """
+ # search module prototypes
+
+ mod_matches = {}
+ if tags:
+ # use tags to limit selection
+ tagset = set(tags)
+ mod_matches = {prototype_key: prototype
+ for prototype_key, prototype in _MODULE_PROTOTYPES.items()
+ if tagset.intersection(prototype.get("prototype_tags", []))}
+ else:
+ mod_matches = _MODULE_PROTOTYPES
+ if key:
+ if key in mod_matches:
+ # exact match
+ module_prototypes = [mod_matches[key]]
+ else:
+ # fuzzy matching
+ module_prototypes = [prototype for prototype_key, prototype in mod_matches.items()
+ if key in prototype_key]
+ else:
+ module_prototypes = [match for match in mod_matches.values()]
+
+ # search db-stored prototypes
+
+ if tags:
+ # exact match on tag(s)
+ tags = make_iter(tags)
+ tag_categories = ["db_prototype" for _ in tags]
+ db_matches = DbPrototype.objects.get_by_tag(tags, tag_categories)
+ else:
+ db_matches = DbPrototype.objects.all()
+ if key:
+ # exact or partial match on key
+ db_matches = db_matches.filter(db_key=key) or db_matches.filter(db_key__icontains=key)
+ # return prototype
+ db_prototypes = [dbprot.prototype for dbprot in db_matches]
+
+ matches = db_prototypes + module_prototypes
+ nmatches = len(matches)
+ if nmatches > 1 and key:
+ key = key.lower()
+ # avoid duplicates if an exact match exist between the two types
+ filter_matches = [mta for mta in matches
+ if mta.get('prototype_key') and mta['prototype_key'] == key]
+ if filter_matches and len(filter_matches) < nmatches:
+ matches = filter_matches
+
+ return matches
+
+
+def search_objects_with_prototype(prototype_key):
+ """
+ Retrieve all object instances created by a given prototype.
+
+ Args:
+ prototype_key (str): The exact (and unique) prototype identifier to query for.
+
+ Returns:
+ matches (Queryset): All matching objects spawned from this prototype.
+
+ """
+ return ObjectDB.objects.get_by_tag(key=prototype_key, category=_PROTOTYPE_TAG_CATEGORY)
+
+
+def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True):
+ """
+ Collate a list of found prototypes based on search criteria and access.
+
+ Args:
+ caller (Account or Object): The object requesting the list.
+ key (str, optional): Exact or partial prototype key to query for.
+ tags (str or list, optional): Tag key or keys to query for.
+ show_non_use (bool, optional): Show also prototypes the caller may not use.
+ show_non_edit (bool, optional): Show also prototypes the caller may not edit.
+ Returns:
+ table (EvTable or None): An EvTable representation of the prototypes. None
+ if no prototypes were found.
+
+ """
+ # this allows us to pass lists of empty strings
+ tags = [tag for tag in make_iter(tags) if tag]
+
+ # get prototypes for readonly and db-based prototypes
+ prototypes = search_prototype(key, tags)
+
+ # get use-permissions of readonly attributes (edit is always False)
+ display_tuples = []
+ for prototype in sorted(prototypes, key=lambda d: d.get('prototype_key', '')):
+ lock_use = caller.locks.check_lockstring(
+ caller, prototype.get('prototype_locks', ''), access_type='spawn')
+ if not show_non_use and not lock_use:
+ continue
+ if prototype.get('prototype_key', '') in _MODULE_PROTOTYPES:
+ lock_edit = False
+ else:
+ lock_edit = caller.locks.check_lockstring(
+ caller, prototype.get('prototype_locks', ''), access_type='edit')
+ if not show_non_edit and not lock_edit:
+ continue
+ ptags = []
+ for ptag in prototype.get('prototype_tags', []):
+ if is_iter(ptag):
+ if len(ptag) > 1:
+ ptags.append("{} (category: {}".format(ptag[0], ptag[1]))
+ else:
+ ptags.append(ptag[0])
+ else:
+ ptags.append(str(ptag))
+
+ display_tuples.append(
+ (prototype.get('prototype_key', ''),
+ prototype.get('prototype_desc', ''),
+ "{}/{}".format('Y' if lock_use else 'N', 'Y' if lock_edit else 'N'),
+ ",".join(ptags)))
+
+ if not display_tuples:
+ return ""
+
+ table = []
+ width = 78
+ for i in range(len(display_tuples[0])):
+ table.append([str(display_tuple[i]) for display_tuple in display_tuples])
+ table = EvTable("Key", "Desc", "Spawn/Edit", "Tags", table=table, crop=True, width=width)
+ table.reformat_column(0, width=22)
+ table.reformat_column(1, width=29)
+ table.reformat_column(2, width=11, align='c')
+ table.reformat_column(3, width=16)
+ return table
+
+
+def validate_prototype(prototype, protkey=None, protparents=None,
+ is_prototype_base=True, strict=True, _flags=None):
+ """
+ Run validation on a prototype, checking for inifinite regress.
+
+ Args:
+ prototype (dict): Prototype to validate.
+ protkey (str, optional): The name of the prototype definition. If not given, the prototype
+ dict needs to have the `prototype_key` field set.
+ protpartents (dict, optional): The available prototype parent library. If
+ note given this will be determined from settings/database.
+ is_prototype_base (bool, optional): We are trying to create a new object *based on this
+ object*. This means we can't allow 'mixin'-style prototypes without typeclass/parent
+ etc.
+ strict (bool, optional): If unset, don't require needed keys, only check against infinite
+ recursion etc.
+ _flags (dict, optional): Internal work dict that should not be set externally.
+ Raises:
+ RuntimeError: If prototype has invalid structure.
+ RuntimeWarning: If prototype has issues that would make it unsuitable to build an object
+ with (it may still be useful as a mix-in prototype).
+
+ """
+ assert isinstance(prototype, dict)
+
+ if _flags is None:
+ _flags = {"visited": [], "depth": 0, "typeclass": False, "errors": [], "warnings": []}
+
+ if not protparents:
+ protparents = {prototype.get('prototype_key', "").lower(): prototype
+ for prototype in search_prototype()}
+
+ protkey = protkey and protkey.lower() or prototype.get('prototype_key', None)
+
+ if strict and not bool(protkey):
+ _flags['errors'].append("Prototype lacks a `prototype_key`.")
+ protkey = "[UNSET]"
+
+ typeclass = prototype.get('typeclass')
+ prototype_parent = prototype.get('prototype_parent', [])
+
+ if strict and not (typeclass or prototype_parent):
+ if is_prototype_base:
+ _flags['errors'].append("Prototype {} requires `typeclass` "
+ "or 'prototype_parent'.".format(protkey))
+ else:
+ _flags['warnings'].append("Prototype {} can only be used as a mixin since it lacks "
+ "a typeclass or a prototype_parent.".format(protkey))
+
+ if strict and typeclass:
+ try:
+ class_from_module(typeclass)
+ except ImportError as err:
+ _flags['errors'].append(
+ "{}: Prototype {} is based on typeclass {}, which could not be imported!".format(
+ err, protkey, typeclass))
+
+ # recursively traverese prototype_parent chain
+
+ for protstring in make_iter(prototype_parent):
+ protstring = protstring.lower()
+ if protkey is not None and protstring == protkey:
+ _flags['errors'].append("Prototype {} tries to parent itself.".format(protkey))
+ protparent = protparents.get(protstring)
+ if not protparent:
+ _flags['errors'].append("Prototype {}'s prototype_parent '{}' was not found.".format(
+ (protkey, protstring)))
+ if id(prototype) in _flags['visited']:
+ _flags['errors'].append(
+ "{} has infinite nesting of prototypes.".format(protkey or prototype))
+
+ if _flags['errors']:
+ raise RuntimeError("Error: " + "\nError: ".join(_flags['errors']))
+ _flags['visited'].append(id(prototype))
+ _flags['depth'] += 1
+ validate_prototype(protparent, protstring, protparents,
+ is_prototype_base=is_prototype_base, _flags=_flags)
+ _flags['visited'].pop()
+ _flags['depth'] -= 1
+
+ if typeclass and not _flags['typeclass']:
+ _flags['typeclass'] = typeclass
+
+ # if we get back to the current level without a typeclass it's an error.
+ if strict and is_prototype_base and _flags['depth'] <= 0 and not _flags['typeclass']:
+ _flags['errors'].append("Prototype {} has no `typeclass` defined anywhere in its parent\n "
+ "chain. Add `typeclass`, or a `prototype_parent` pointing to a "
+ "prototype with a typeclass.".format(protkey))
+
+ if _flags['depth'] <= 0:
+ if _flags['errors']:
+ raise RuntimeError("Error: " + "\nError: ".join(_flags['errors']))
+ if _flags['warnings']:
+ raise RuntimeWarning("Warning: " + "\nWarning: ".join(_flags['warnings']))
+
+ # make sure prototype_locks are set to defaults
+ prototype_locks = [lstring.split(":", 1)
+ for lstring in prototype.get("prototype_locks", "").split(';')
+ if ":" in lstring]
+ locktypes = [tup[0].strip() for tup in prototype_locks]
+ if "spawn" not in locktypes:
+ prototype_locks.append(("spawn", "all()"))
+ if "edit" not in locktypes:
+ prototype_locks.append(("edit", "all()"))
+ prototype_locks = ";".join(":".join(tup) for tup in prototype_locks)
+ prototype['prototype_locks'] = prototype_locks
+
+
+# Protfunc parsing (in-prototype functions)
+
+for mod in settings.PROT_FUNC_MODULES:
+ try:
+ callables = callables_from_module(mod)
+ PROT_FUNCS.update(callables)
+ except ImportError:
+ logger.log_trace()
+ raise
+
+
+def protfunc_parser(value, available_functions=None, testing=False, stacktrace=False, **kwargs):
+ """
+ Parse a prototype value string for a protfunc and process it.
+
+ Available protfuncs are specified as callables in one of the modules of
+ `settings.PROTFUNC_MODULES`, or specified on the command line.
+
+ Args:
+ value (any): The value to test for a parseable protfunc. Only strings will be parsed for
+ protfuncs, all other types are returned as-is.
+ available_functions (dict, optional): Mapping of name:protfunction to use for this parsing.
+ If not set, use default sources.
+ testing (bool, optional): Passed to protfunc. If in a testing mode, some protfuncs may
+ behave differently.
+ stacktrace (bool, optional): If set, print the stack parsing process of the protfunc-parser.
+
+ Kwargs:
+ session (Session): Passed to protfunc. Session of the entity spawning the prototype.
+ protototype (dict): Passed to protfunc. The dict this protfunc is a part of.
+ current_key(str): Passed to protfunc. The key in the prototype that will hold this value.
+ any (any): Passed on to the protfunc.
+
+ Returns:
+ testresult (tuple): If `testing` is set, returns a tuple (error, result) where error is
+ either None or a string detailing the error from protfunc_parser or seen when trying to
+ run `literal_eval` on the parsed string.
+ any (any): A structure to replace the string on the prototype level. If this is a
+ callable or a (callable, (args,)) structure, it will be executed as if one had supplied
+ it to the prototype directly. This structure is also passed through literal_eval so one
+ can get actual Python primitives out of it (not just strings). It will also identify
+ eventual object #dbrefs in the output from the protfunc.
+
+ """
+ if not isinstance(value, basestring):
+ try:
+ value = value.dbref
+ except AttributeError:
+ pass
+ value = to_str(value, force_string=True)
+
+ available_functions = PROT_FUNCS if available_functions is None else available_functions
+
+ # insert $obj(#dbref) for #dbref
+ value = _RE_DBREF.sub("$obj(\\1)", value)
+
+ result = inlinefuncs.parse_inlinefunc(
+ value, available_funcs=available_functions,
+ stacktrace=stacktrace, testing=testing, **kwargs)
+
+ err = None
+ try:
+ result = literal_eval(result)
+ except ValueError:
+ pass
+ except Exception as err:
+ err = str(err)
+ if testing:
+ return err, result
+ return result
+
+
+# Various prototype utilities
+
+def format_available_protfuncs():
+ """
+ Get all protfuncs in a pretty-formatted form.
+
+ Args:
+ clr (str, optional): What coloration tag to use.
+ """
+ out = []
+ for protfunc_name, protfunc in PROT_FUNCS.items():
+ out.append("- |c${name}|n - |W{docs}".format(
+ name=protfunc_name, docs=protfunc.__doc__.strip().replace("\n", "")))
+ return justify("\n".join(out), indent=8)
+
+
+def prototype_to_str(prototype):
+ """
+ Format a prototype to a nice string representation.
+
+ Args:
+ prototype (dict): The prototype.
+ """
+
+ prototype = homogenize_prototype(prototype)
+
+ header = """
+|cprototype-key:|n {prototype_key}, |c-tags:|n {prototype_tags}, |c-locks:|n {prototype_locks}|n
+|c-desc|n: {prototype_desc}
+|cprototype-parent:|n {prototype_parent}
+ \n""".format(
+ prototype_key=prototype.get('prototype_key', '|r[UNSET](required)|n'),
+ prototype_tags=prototype.get('prototype_tags', '|wNone|n'),
+ prototype_locks=prototype.get('prototype_locks', '|wNone|n'),
+ prototype_desc=prototype.get('prototype_desc', '|wNone|n'),
+ prototype_parent=prototype.get('prototype_parent', '|wNone|n'))
+
+ key = prototype.get('key', '')
+ if key:
+ key = "|ckey:|n {key}".format(key=key)
+ aliases = prototype.get("aliases", '')
+ if aliases:
+ aliases = "|caliases:|n {aliases}".format(
+ aliases=", ".join(aliases))
+ attrs = prototype.get("attrs", '')
+ if attrs:
+ out = []
+ for (attrkey, value, category, locks) in attrs:
+ locks = ", ".join(lock for lock in locks if lock)
+ category = "|ccategory:|n {}".format(category) if category else ''
+ cat_locks = ""
+ if category or locks:
+ cat_locks = " (|ccategory:|n {category}, ".format(
+ category=category if category else "|wNone|n")
+ out.append(
+ "{attrkey}{cat_locks} |c=|n {value}".format(
+ attrkey=attrkey,
+ cat_locks=cat_locks,
+ locks=locks if locks else "|wNone|n",
+ value=value))
+ attrs = "|cattrs:|n\n {attrs}".format(attrs="\n ".join(out))
+ tags = prototype.get('tags', '')
+ if tags:
+ out = []
+ for (tagkey, category, data) in tags:
+ out.append("{tagkey} (category: {category}{dat})".format(
+ tagkey=tagkey, category=category, dat=", data: {}".format(data) if data else ""))
+ tags = "|ctags:|n\n {tags}".format(tags=", ".join(out))
+ locks = prototype.get('locks', '')
+ if locks:
+ locks = "|clocks:|n\n {locks}".format(locks=locks)
+ permissions = prototype.get("permissions", '')
+ if permissions:
+ permissions = "|cpermissions:|n {perms}".format(perms=", ".join(permissions))
+ location = prototype.get("location", '')
+ if location:
+ location = "|clocation:|n {location}".format(location=location)
+ home = prototype.get("home", '')
+ if home:
+ home = "|chome:|n {home}".format(home=home)
+ destination = prototype.get("destination", '')
+ if destination:
+ destination = "|cdestination:|n {destination}".format(destination=destination)
+
+ body = "\n".join(part for part in (key, aliases, attrs, tags, locks, permissions,
+ location, home, destination) if part)
+
+ return header.lstrip() + body.strip()
+
+
+def check_permission(prototype_key, action, default=True):
+ """
+ Helper function to check access to actions on given prototype.
+
+ Args:
+ prototype_key (str): The prototype to affect.
+ action (str): One of "spawn" or "edit".
+ default (str): If action is unknown or prototype has no locks
+
+ Returns:
+ passes (bool): If permission for action is granted or not.
+
+ """
+ if action == 'edit':
+ if prototype_key in _MODULE_PROTOTYPES:
+ mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key, "N/A")
+ logger.log_err("{} is a read-only prototype "
+ "(defined as code in {}).".format(prototype_key, mod))
+ return False
+
+ prototype = search_prototype(key=prototype_key)
+ if not prototype:
+ logger.log_err("Prototype {} not found.".format(prototype_key))
+ return False
+
+ lockstring = prototype.get("prototype_locks")
+
+ if lockstring:
+ return check_lockstring(None, lockstring, default=default, access_type=action)
+ return default
+
+
+def init_spawn_value(value, validator=None):
+ """
+ Analyze the prototype value and produce a value useful at the point of spawning.
+
+ Args:
+ value (any): This can be:
+ callable - will be called as callable()
+ (callable, (args,)) - will be called as callable(*args)
+ other - will be assigned depending on the variable type
+ validator (callable, optional): If given, this will be called with the value to
+ check and guarantee the outcome is of a given type.
+
+ Returns:
+ any (any): The (potentially pre-processed value to use for this prototype key)
+
+ """
+ value = protfunc_parser(value)
+ validator = validator if validator else lambda o: o
+ if callable(value):
+ return validator(value())
+ elif value and is_iter(value) and callable(value[0]):
+ # a structure (callable, (args, ))
+ args = value[1:]
+ return validator(value[0](*make_iter(args)))
+ else:
+ return validator(value)
+
+
+def value_to_obj_or_any(value):
+ "Convert value(s) to Object if possible, otherwise keep original value"
+ stype = type(value)
+ if is_iter(value):
+ if stype == dict:
+ return {value_to_obj_or_any(key):
+ value_to_obj_or_any(val) for key, val in value.items()}
+ else:
+ return stype([value_to_obj_or_any(val) for val in value])
+ obj = dbid_to_obj(value, ObjectDB)
+ return obj if obj is not None else value
+
+
+def value_to_obj(value, force=True):
+ "Always convert value(s) to Object, or None"
+ stype = type(value)
+ if is_iter(value):
+ if stype == dict:
+ return {value_to_obj_or_any(key): value_to_obj_or_any(val) for key, val in value.iter()}
+ else:
+ return stype([value_to_obj_or_any(val) for val in value])
+ return dbid_to_obj(value, ObjectDB)
diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py
new file mode 100644
index 0000000000..d1c099fb57
--- /dev/null
+++ b/evennia/prototypes/spawner.py
@@ -0,0 +1,758 @@
+"""
+Spawner
+
+The spawner takes input files containing object definitions in
+dictionary forms. These use a prototype architecture to define
+unique objects without having to make a Typeclass for each.
+
+There main function is `spawn(*prototype)`, where the `prototype`
+is a dictionary like this:
+
+```python
+from evennia.prototypes import prototypes
+
+prot = {
+ "prototype_key": "goblin",
+ "typeclass": "types.objects.Monster",
+ "key": "goblin grunt",
+ "health": lambda: randint(20,30),
+ "resists": ["cold", "poison"],
+ "attacks": ["fists"],
+ "weaknesses": ["fire", "light"]
+ "tags": ["mob", "evil", ('greenskin','mob')]
+ "attrs": [("weapon", "sword")]
+}
+
+prot = prototypes.create_prototype(**prot)
+
+```
+
+Possible keywords are:
+ prototype_key (str): name of this prototype. This is used when storing prototypes and should
+ be unique. This should always be defined but for prototypes defined in modules, the
+ variable holding the prototype dict will become the prototype_key if it's not explicitly
+ given.
+ prototype_desc (str, optional): describes prototype in listings
+ prototype_locks (str, optional): locks for restricting access to this prototype. Locktypes
+ supported are 'edit' and 'use'.
+ prototype_tags(list, optional): List of tags or tuples (tag, category) used to group prototype
+ in listings
+ prototype_parent (str, tuple or callable, optional): name (prototype_key) of eventual parent prototype, or
+ a list of parents, for multiple left-to-right inheritance.
+ prototype: Deprecated. Same meaning as 'parent'.
+
+ typeclass (str or callable, optional): if not set, will use typeclass of parent prototype or use
+ `settings.BASE_OBJECT_TYPECLASS`
+ key (str or callable, optional): the name of the spawned object. If not given this will set to a
+ random hash
+ location (obj, str or callable, optional): location of the object - a valid object or #dbref
+ home (obj, str or callable, optional): valid object or #dbref
+ destination (obj, str or callable, optional): only valid for exits (object or #dbref)
+
+ permissions (str, list or callable, optional): which permissions for spawned object to have
+ locks (str or callable, optional): lock-string for the spawned object
+ aliases (str, list or callable, optional): Aliases for the spawned object
+ exec (str or callable, optional): this is a string of python code to execute or a list of such
+ codes. This can be used e.g. to trigger custom handlers on the object. The execution
+ namespace contains 'evennia' for the library and 'obj'. All default spawn commands limit
+ this functionality to Developer/superusers. Usually it's better to use callables or
+ prototypefuncs instead of this.
+ tags (str, tuple, list or callable, optional): string or list of strings or tuples
+ `(tagstr, category)`. Plain strings will be result in tags with no category (default tags).
+ attrs (tuple, list or callable, optional): tuple or list of tuples of Attributes to add. This
+ form allows more complex Attributes to be set. Tuples at least specify `(key, value)`
+ but can also specify up to `(key, value, category, lockstring)`. If you want to specify a
+ lockstring but not a category, set the category to `None`.
+ ndb_ (any): value of a nattribute (ndb_ is stripped) - this is of limited use.
+ other (any): any other name is interpreted as the key of an Attribute with
+ its value. Such Attributes have no categories.
+
+Each value can also be a callable that takes no arguments. It should
+return the value to enter into the field and will be called every time
+the prototype is used to spawn an object. Note, if you want to store
+a callable in an Attribute, embed it in a tuple to the `args` keyword.
+
+By specifying the "prototype_parent" key, the prototype becomes a child of
+the given prototype, inheritng all prototype slots it does not explicitly
+define itself, while overloading those that it does specify.
+
+```python
+import random
+
+
+{
+ "prototype_key": "goblin_wizard",
+ "prototype_parent": GOBLIN,
+ "key": "goblin wizard",
+ "spells": ["fire ball", "lighting bolt"]
+ }
+
+GOBLIN_ARCHER = {
+ "prototype_parent": GOBLIN,
+ "key": "goblin archer",
+ "attack_skill": (random, (5, 10))"
+ "attacks": ["short bow"]
+}
+```
+
+One can also have multiple prototypes. These are inherited from the
+left, with the ones further to the right taking precedence.
+
+```python
+ARCHWIZARD = {
+ "attack": ["archwizard staff", "eye of doom"]
+
+GOBLIN_ARCHWIZARD = {
+ "key" : "goblin archwizard"
+ "prototype_parent": (GOBLIN_WIZARD, ARCHWIZARD),
+}
+```
+
+The *goblin archwizard* will have some different attacks, but will
+otherwise have the same spells as a *goblin wizard* who in turn shares
+many traits with a normal *goblin*.
+
+
+Storage mechanism:
+
+This sets up a central storage for prototypes. The idea is to make these
+available in a repository for buildiers to use. Each prototype is stored
+in a Script so that it can be tagged for quick sorting/finding and locked for limiting
+access.
+
+This system also takes into consideration prototypes defined and stored in modules.
+Such prototypes are considered 'read-only' to the system and can only be modified
+in code. To replace a default prototype, add the same-name prototype in a
+custom module read later in the settings.PROTOTYPE_MODULES list. To remove a default
+prototype, override its name with an empty dict.
+
+
+"""
+from __future__ import print_function
+
+import copy
+import hashlib
+import time
+
+from django.conf import settings
+
+import evennia
+from evennia.objects.models import ObjectDB
+from evennia.utils.utils import make_iter, is_iter
+from evennia.prototypes import prototypes as protlib
+from evennia.prototypes.prototypes import (
+ value_to_obj, value_to_obj_or_any, init_spawn_value, _PROTOTYPE_TAG_CATEGORY)
+
+
+_CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination")
+_PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks")
+_PROTOTYPE_ROOT_NAMES = ('typeclass', 'key', 'aliases', 'attrs', 'tags', 'locks', 'permissions',
+ 'location', 'home', 'destination')
+_NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES
+
+
+# Helper
+
+def _get_prototype(inprot, protparents, uninherited=None, _workprot=None):
+ """
+ Recursively traverse a prototype dictionary, including multiple
+ inheritance. Use validate_prototype before this, we don't check
+ for infinite recursion here.
+
+ Args:
+ inprot (dict): Prototype dict (the individual prototype, with no inheritance included).
+ protparents (dict): Available protparents, keyed by prototype_key.
+ uninherited (dict): Parts of prototype to not inherit.
+ _workprot (dict, optional): Work dict for the recursive algorithm.
+
+ """
+ _workprot = {} if _workprot is None else _workprot
+ if "prototype_parent" in inprot:
+ # move backwards through the inheritance
+ for prototype in make_iter(inprot["prototype_parent"]):
+ # Build the prot dictionary in reverse order, overloading
+ new_prot = _get_prototype(protparents.get(prototype.lower(), {}),
+ protparents, _workprot=_workprot)
+ _workprot.update(new_prot)
+ # the inprot represents a higher level (a child prot), which should override parents
+ _workprot.update(inprot)
+ if uninherited:
+ # put back the parts that should not be inherited
+ _workprot.update(uninherited)
+ _workprot.pop("prototype_parent", None) # we don't need this for spawning
+ return _workprot
+
+
+def flatten_prototype(prototype, validate=False):
+ """
+ Produce a 'flattened' prototype, where all prototype parents in the inheritance tree have been
+ merged into a final prototype.
+
+ Args:
+ prototype (dict): Prototype to flatten. Its `prototype_parent` field will be parsed.
+ validate (bool, optional): Validate for valid keys etc.
+
+ Returns:
+ flattened (dict): The final, flattened prototype.
+
+ """
+
+ if prototype:
+ prototype = protlib.homogenize_prototype(prototype)
+ protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()}
+ protlib.validate_prototype(prototype, None, protparents,
+ is_prototype_base=validate, strict=validate)
+ return _get_prototype(prototype, protparents,
+ uninherited={"prototype_key": prototype.get("prototype_key")})
+ return {}
+
+
+# obj-related prototype functions
+
+def prototype_from_object(obj):
+ """
+ Guess a minimal prototype from an existing object.
+
+ Args:
+ obj (Object): An object to analyze.
+
+ Returns:
+ prototype (dict): A prototype estimating the current state of the object.
+
+ """
+ # first, check if this object already has a prototype
+
+ prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True)
+ if prot:
+ prot = protlib.search_prototype(prot[0])
+
+ if not prot or len(prot) > 1:
+ # no unambiguous prototype found - build new prototype
+ prot = {}
+ prot['prototype_key'] = "From-Object-{}-{}".format(
+ obj.key, hashlib.md5(str(time.time())).hexdigest()[:7])
+ prot['prototype_desc'] = "Built from {}".format(str(obj))
+ prot['prototype_locks'] = "spawn:all();edit:all()"
+ prot['prototype_tags'] = []
+ else:
+ prot = prot[0]
+
+ prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6]
+ prot['typeclass'] = obj.db_typeclass_path
+
+ location = obj.db_location
+ if location:
+ prot['location'] = location.dbref
+ home = obj.db_home
+ if home:
+ prot['home'] = home.dbref
+ destination = obj.db_destination
+ if destination:
+ prot['destination'] = destination.dbref
+ locks = obj.locks.all()
+ if locks:
+ prot['locks'] = ";".join(locks)
+ perms = obj.permissions.get(return_list=True)
+ if perms:
+ prot['permissions'] = make_iter(perms)
+ aliases = obj.aliases.get(return_list=True)
+ if aliases:
+ prot['aliases'] = aliases
+ tags = [(tag.db_key, tag.db_category, tag.db_data)
+ for tag in obj.tags.all(return_objs=True)]
+ if tags:
+ prot['tags'] = tags
+ attrs = [(attr.key, attr.value, attr.category, ';'.join(attr.locks.all()))
+ for attr in obj.attributes.all()]
+ if attrs:
+ prot['attrs'] = attrs
+
+ return prot
+
+
+def prototype_diff(prototype1, prototype2, maxdepth=2):
+ """
+ A 'detailed' diff specifies differences down to individual sub-sectiions
+ of the prototype, like individual attributes, permissions etc. It is used
+ by the menu to allow a user to customize what should be kept.
+
+ Args:
+ prototype1 (dict): Original prototype.
+ prototype2 (dict): Comparison prototype.
+ maxdepth (int, optional): The maximum depth into the diff we go before treating the elements
+ of iterables as individual entities to compare. This is important since a single
+ attr/tag (for example) are represented by a tuple.
+
+ Returns:
+ diff (dict): A structure detailing how to convert prototype1 to prototype2. All
+ nested structures are dicts with keys matching either the prototype's matching
+ key or the first element in the tuple describing the prototype value (so for
+ a tag tuple `(tagname, category)` the second-level key in the diff would be tagname).
+ The the bottom level of the diff consist of tuples `(old, new, instruction)`, where
+ instruction can be one of "REMOVE", "ADD", "UPDATE" or "KEEP".
+
+ """
+ def _recursive_diff(old, new, depth=0):
+
+ old_type = type(old)
+ new_type = type(new)
+
+ if old_type != new_type:
+ if old and not new:
+ if depth < maxdepth and old_type == dict:
+ return {key: (part, None, "REMOVE") for key, part in old.items()}
+ elif depth < maxdepth and is_iter(old):
+ return {part[0] if is_iter(part) else part:
+ (part, None, "REMOVE") for part in old}
+ return (old, new, "REMOVE")
+ elif not old and new:
+ if depth < maxdepth and new_type == dict:
+ return {key: (None, part, "ADD") for key, part in new.items()}
+ elif depth < maxdepth and is_iter(new):
+ return {part[0] if is_iter(part) else part: (None, part, "ADD") for part in new}
+ return (old, new, "ADD")
+ else:
+ # this condition should not occur in a standard diff
+ return (old, new, "UPDATE")
+ elif depth < maxdepth and new_type == dict:
+ all_keys = set(old.keys() + new.keys())
+ return {key: _recursive_diff(old.get(key), new.get(key), depth=depth + 1)
+ for key in all_keys}
+ elif depth < maxdepth and is_iter(new):
+ old_map = {part[0] if is_iter(part) else part: part for part in old}
+ new_map = {part[0] if is_iter(part) else part: part for part in new}
+ all_keys = set(old_map.keys() + new_map.keys())
+ return {key: _recursive_diff(old_map.get(key), new_map.get(key), depth=depth + 1)
+ for key in all_keys}
+ elif old != new:
+ return (old, new, "UPDATE")
+ else:
+ return (old, new, "KEEP")
+
+ diff = _recursive_diff(prototype1, prototype2)
+
+ return diff
+
+
+def flatten_diff(diff):
+ """
+ For spawning, a 'detailed' diff is not necessary, rather we just want instructions on how to
+ handle each root key.
+
+ Args:
+ diff (dict): Diff produced by `prototype_diff` and
+ possibly modified by the user. Note that also a pre-flattened diff will come out
+ unchanged by this function.
+
+ Returns:
+ flattened_diff (dict): A flat structure detailing how to operate on each
+ root component of the prototype.
+
+ Notes:
+ The flattened diff has the following possible instructions:
+ UPDATE, REPLACE, REMOVE
+ Many of the detailed diff's values can hold nested structures with their own
+ individual instructions. A detailed diff can have the following instructions:
+ REMOVE, ADD, UPDATE, KEEP
+ Here's how they are translated:
+ - All REMOVE -> REMOVE
+ - All ADD|UPDATE -> UPDATE
+ - All KEEP -> KEEP
+ - Mix KEEP, UPDATE, ADD -> UPDATE
+ - Mix REMOVE, KEEP, UPDATE, ADD -> REPLACE
+ """
+
+ valid_instructions = ('KEEP', 'REMOVE', 'ADD', 'UPDATE')
+
+ def _get_all_nested_diff_instructions(diffpart):
+ "Started for each root key, returns all instructions nested under it"
+ out = []
+ typ = type(diffpart)
+ if typ == tuple and len(diffpart) == 3 and diffpart[2] in valid_instructions:
+ out = [diffpart[2]]
+ elif typ == dict:
+ # all other are dicts
+ for val in diffpart.values():
+ out.extend(_get_all_nested_diff_instructions(val))
+ else:
+ raise RuntimeError("Diff contains non-dicts that are not on the "
+ "form (old, new, inst): {}".format(diffpart))
+ return out
+
+ flat_diff = {}
+
+ # flatten diff based on rules
+ for rootkey, diffpart in diff.items():
+ insts = _get_all_nested_diff_instructions(diffpart)
+ if all(inst == "KEEP" for inst in insts):
+ rootinst = "KEEP"
+ elif all(inst in ("ADD", "UPDATE") for inst in insts):
+ rootinst = "UPDATE"
+ elif all(inst == "REMOVE" for inst in insts):
+ rootinst = "REMOVE"
+ elif "REMOVE" in insts:
+ rootinst = "REPLACE"
+ else:
+ rootinst = "UPDATE"
+
+ flat_diff[rootkey] = rootinst
+
+ return flat_diff
+
+
+def prototype_diff_from_object(prototype, obj):
+ """
+ Get a simple diff for a prototype compared to an object which may or may not already have a
+ prototype (or has one but changed locally). For more complex migratations a manual diff may be
+ needed.
+
+ Args:
+ prototype (dict): New prototype.
+ obj (Object): Object to compare prototype against.
+
+ Returns:
+ diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...}
+ obj_prototype (dict): The prototype calculated for the given object. The diff is how to
+ convert this prototype into the new prototype.
+
+ Notes:
+ The `diff` is on the following form:
+
+ {"key": (old, new, "KEEP|REPLACE|UPDATE|REMOVE"),
+ "attrs": {"attrkey": (old, new, "KEEP|REPLACE|UPDATE|REMOVE"),
+ "attrkey": (old, new, "KEEP|REPLACE|UPDATE|REMOVE"), ...},
+ "aliases": {"aliasname": (old, new, "KEEP...", ...},
+ ... }
+
+ """
+ obj_prototype = prototype_from_object(obj)
+ diff = prototype_diff(obj_prototype, protlib.homogenize_prototype(prototype))
+ return diff, obj_prototype
+
+
+def batch_update_objects_with_prototype(prototype, diff=None, objects=None):
+ """
+ Update existing objects with the latest version of the prototype.
+
+ Args:
+ prototype (str or dict): Either the `prototype_key` to use or the
+ prototype dict itself.
+ diff (dict, optional): This a diff structure that describes how to update the protototype.
+ If not given this will be constructed from the first object found.
+ objects (list, optional): List of objects to update. If not given, query for these
+ objects using the prototype's `prototype_key`.
+ Returns:
+ changed (int): The number of objects that had changes applied to them.
+
+ """
+ prototype = protlib.homogenize_prototype(prototype)
+
+ if isinstance(prototype, basestring):
+ new_prototype = protlib.search_prototype(prototype)
+ else:
+ new_prototype = prototype
+
+ prototype_key = new_prototype['prototype_key']
+
+ if not objects:
+ objects = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY)
+
+ if not objects:
+ return 0
+
+ if not diff:
+ diff, _ = prototype_diff_from_object(new_prototype, objects[0])
+
+ # make sure the diff is flattened
+ diff = flatten_diff(diff)
+ changed = 0
+ for obj in objects:
+ do_save = False
+
+ old_prot_key = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True)
+ old_prot_key = old_prot_key[0] if old_prot_key else None
+ if prototype_key != old_prot_key:
+ obj.tags.clear(category=_PROTOTYPE_TAG_CATEGORY)
+ obj.tags.add(prototype_key, category=_PROTOTYPE_TAG_CATEGORY)
+
+ for key, directive in diff.items():
+ if directive in ('UPDATE', 'REPLACE'):
+
+ if key in _PROTOTYPE_META_NAMES:
+ # prototype meta keys are not stored on-object
+ continue
+
+ val = new_prototype[key]
+ do_save = True
+
+ if key == 'key':
+ obj.db_key = init_spawn_value(val, str)
+ elif key == 'typeclass':
+ obj.db_typeclass_path = init_spawn_value(val, str)
+ elif key == 'location':
+ obj.db_location = init_spawn_value(val, value_to_obj)
+ elif key == 'home':
+ obj.db_home = init_spawn_value(val, value_to_obj)
+ elif key == 'destination':
+ obj.db_destination = init_spawn_value(val, value_to_obj)
+ elif key == 'locks':
+ if directive == 'REPLACE':
+ obj.locks.clear()
+ obj.locks.add(init_spawn_value(val, str))
+ elif key == 'permissions':
+ if directive == 'REPLACE':
+ obj.permissions.clear()
+ obj.permissions.batch_add(*(init_spawn_value(perm, str) for perm in val))
+ elif key == 'aliases':
+ if directive == 'REPLACE':
+ obj.aliases.clear()
+ obj.aliases.batch_add(*(init_spawn_value(alias, str) for alias in val))
+ elif key == 'tags':
+ if directive == 'REPLACE':
+ obj.tags.clear()
+ obj.tags.batch_add(*(
+ (init_spawn_value(ttag, str), tcategory, tdata)
+ for ttag, tcategory, tdata in val))
+ elif key == 'attrs':
+ if directive == 'REPLACE':
+ obj.attributes.clear()
+ obj.attributes.batch_add(*(
+ (init_spawn_value(akey, str),
+ init_spawn_value(aval, value_to_obj),
+ acategory,
+ alocks)
+ for akey, aval, acategory, alocks in val))
+ elif key == 'exec':
+ # we don't auto-rerun exec statements, it would be huge security risk!
+ pass
+ else:
+ obj.attributes.add(key, init_spawn_value(val, value_to_obj))
+ elif directive == 'REMOVE':
+ do_save = True
+ if key == 'key':
+ obj.db_key = ''
+ elif key == 'typeclass':
+ # fall back to default
+ obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS
+ elif key == 'location':
+ obj.db_location = None
+ elif key == 'home':
+ obj.db_home = None
+ elif key == 'destination':
+ obj.db_destination = None
+ elif key == 'locks':
+ obj.locks.clear()
+ elif key == 'permissions':
+ obj.permissions.clear()
+ elif key == 'aliases':
+ obj.aliases.clear()
+ elif key == 'tags':
+ obj.tags.clear()
+ elif key == 'attrs':
+ obj.attributes.clear()
+ elif key == 'exec':
+ # we don't auto-rerun exec statements, it would be huge security risk!
+ pass
+ else:
+ obj.attributes.remove(key)
+ if do_save:
+ changed += 1
+ obj.save()
+
+ return changed
+
+
+def batch_create_object(*objparams):
+ """
+ This is a cut-down version of the create_object() function,
+ optimized for speed. It does NOT check and convert various input
+ so make sure the spawned Typeclass works before using this!
+
+ Args:
+ objsparams (tuple): Each paremter tuple will create one object instance using the parameters
+ within.
+ The parameters should be given in the following order:
+ - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`.
+ - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`.
+ - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`.
+ - `aliases` (list): A list of alias strings for
+ adding with `new_object.aliases.batch_add(*aliases)`.
+ - `nattributes` (list): list of tuples `(key, value)` to be loop-added to
+ add with `new_obj.nattributes.add(*tuple)`.
+ - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for
+ adding with `new_obj.attributes.batch_add(*attributes)`.
+ - `tags` (list): list of tuples `(key, category)` for adding
+ with `new_obj.tags.batch_add(*tags)`.
+ - `execs` (list): Code strings to execute together with the creation
+ of each object. They will be executed with `evennia` and `obj`
+ (the newly created object) available in the namespace. Execution
+ will happend after all other properties have been assigned and
+ is intended for calling custom handlers etc.
+
+ Returns:
+ objects (list): A list of created objects
+
+ Notes:
+ The `exec` list will execute arbitrary python code so don't allow this to be available to
+ unprivileged users!
+
+ """
+
+ # bulk create all objects in one go
+
+ # unfortunately this doesn't work since bulk_create doesn't creates pks;
+ # the result would be duplicate objects at the next stage, so we comment
+ # it out for now:
+ # dbobjs = _ObjectDB.objects.bulk_create(dbobjs)
+
+ dbobjs = [ObjectDB(**objparam[0]) for objparam in objparams]
+ objs = []
+ for iobj, obj in enumerate(dbobjs):
+ # call all setup hooks on each object
+ objparam = objparams[iobj]
+ # setup
+ obj._createdict = {"permissions": make_iter(objparam[1]),
+ "locks": objparam[2],
+ "aliases": make_iter(objparam[3]),
+ "nattributes": objparam[4],
+ "attributes": objparam[5],
+ "tags": make_iter(objparam[6])}
+ # this triggers all hooks
+ obj.save()
+ # run eventual extra code
+ for code in objparam[7]:
+ if code:
+ exec(code, {}, {"evennia": evennia, "obj": obj})
+ objs.append(obj)
+ return objs
+
+
+# Spawner mechanism
+
+def spawn(*prototypes, **kwargs):
+ """
+ Spawn a number of prototyped objects.
+
+ Args:
+ prototypes (dict): Each argument should be a prototype
+ dictionary.
+ Kwargs:
+ prototype_modules (str or list): A python-path to a prototype
+ module, or a list of such paths. These will be used to build
+ the global protparents dictionary accessible by the input
+ prototypes. If not given, it will instead look for modules
+ defined by settings.PROTOTYPE_MODULES.
+ prototype_parents (dict): A dictionary holding a custom
+ prototype-parent dictionary. Will overload same-named
+ prototypes from prototype_modules.
+ return_parents (bool): Only return a dict of the
+ prototype-parents (no object creation happens)
+ only_validate (bool): Only run validation of prototype/parents
+ (no object creation) and return the create-kwargs.
+
+ Returns:
+ object (Object, dict or list): Spawned object(s). If `only_validate` is given, return
+ a list of the creation kwargs to build the object(s) without actually creating it. If
+ `return_parents` is set, instead return dict of prototype parents.
+
+ """
+ # get available protparents
+ protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()}
+
+ if not kwargs.get("only_validate"):
+ # homogenization to be more lenient about prototype format when entering the prototype manually
+ prototypes = [protlib.homogenize_prototype(prot) for prot in prototypes]
+
+ # overload module's protparents with specifically given protparents
+ # we allow prototype_key to be the key of the protparent dict, to allow for module-level
+ # prototype imports. We need to insert prototype_key in this case
+ for key, protparent in kwargs.get("prototype_parents", {}).items():
+ key = str(key).lower()
+ protparent['prototype_key'] = str(protparent.get("prototype_key", key)).lower()
+ protparents[key] = protparent
+
+ if "return_parents" in kwargs:
+ # only return the parents
+ return copy.deepcopy(protparents)
+
+ objsparams = []
+ for prototype in prototypes:
+
+ protlib.validate_prototype(prototype, None, protparents, is_prototype_base=True)
+ prot = _get_prototype(prototype, protparents,
+ uninherited={"prototype_key": prototype.get("prototype_key")})
+ if not prot:
+ continue
+
+ # extract the keyword args we need to create the object itself. If we get a callable,
+ # call that to get the value (don't catch errors)
+ create_kwargs = {}
+ # we must always add a key, so if not given we use a shortened md5 hash. There is a (small)
+ # chance this is not unique but it should usually not be a problem.
+ val = prot.pop("key", "Spawned-{}".format(
+ hashlib.md5(str(time.time())).hexdigest()[:6]))
+ create_kwargs["db_key"] = init_spawn_value(val, str)
+
+ val = prot.pop("location", None)
+ create_kwargs["db_location"] = init_spawn_value(val, value_to_obj)
+
+ val = prot.pop("home", settings.DEFAULT_HOME)
+ create_kwargs["db_home"] = init_spawn_value(val, value_to_obj)
+
+ val = prot.pop("destination", None)
+ create_kwargs["db_destination"] = init_spawn_value(val, value_to_obj)
+
+ val = prot.pop("typeclass", settings.BASE_OBJECT_TYPECLASS)
+ create_kwargs["db_typeclass_path"] = init_spawn_value(val, str)
+
+ # extract calls to handlers
+ val = prot.pop("permissions", [])
+ permission_string = init_spawn_value(val, make_iter)
+ val = prot.pop("locks", "")
+ lock_string = init_spawn_value(val, str)
+ val = prot.pop("aliases", [])
+ alias_string = init_spawn_value(val, make_iter)
+
+ val = prot.pop("tags", [])
+ tags = []
+ for (tag, category, data) in val:
+ tags.append((init_spawn_value(tag, str), category, data))
+
+ prototype_key = prototype.get('prototype_key', None)
+ if prototype_key:
+ # we make sure to add a tag identifying which prototype created this object
+ tags.append((prototype_key, _PROTOTYPE_TAG_CATEGORY))
+
+ val = prot.pop("exec", "")
+ execs = init_spawn_value(val, make_iter)
+
+ # extract ndb assignments
+ nattributes = dict((key.split("_", 1)[1], init_spawn_value(val, value_to_obj))
+ for key, val in prot.items() if key.startswith("ndb_"))
+
+ # the rest are attribute tuples (attrname, value, category, locks)
+ val = make_iter(prot.pop("attrs", []))
+ attributes = []
+ for (attrname, value, category, locks) in val:
+ attributes.append((attrname, init_spawn_value(value), category, locks))
+
+ simple_attributes = []
+ for key, value in ((key, value) for key, value in prot.items()
+ if not (key.startswith("ndb_"))):
+ # we don't support categories, nor locks for simple attributes
+ if key in _PROTOTYPE_META_NAMES:
+ continue
+ else:
+ simple_attributes.append(
+ (key, init_spawn_value(value, value_to_obj_or_any), None, None))
+
+ attributes = attributes + simple_attributes
+ attributes = [tup for tup in attributes if not tup[0] in _NON_CREATE_KWARGS]
+
+ # pack for call into _batch_create_object
+ objsparams.append((create_kwargs, permission_string, lock_string,
+ alias_string, nattributes, attributes, tags, execs))
+
+ if kwargs.get("only_validate"):
+ return objsparams
+ return batch_create_object(*objsparams)
diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py
new file mode 100644
index 0000000000..411bd45c27
--- /dev/null
+++ b/evennia/prototypes/tests.py
@@ -0,0 +1,681 @@
+"""
+Unit tests for the prototypes and spawner
+
+"""
+
+from random import randint
+import mock
+from anything import Something
+from django.test.utils import override_settings
+from evennia.utils.test_resources import EvenniaTest
+from evennia.utils.tests.test_evmenu import TestEvMenu
+from evennia.prototypes import spawner, prototypes as protlib
+from evennia.prototypes import menus as olc_menus
+
+from evennia.prototypes.prototypes import _PROTOTYPE_TAG_META_CATEGORY
+
+_PROTPARENTS = {
+ "NOBODY": {},
+ "GOBLIN": {
+ "prototype_key": "GOBLIN",
+ "typeclass": "evennia.objects.objects.DefaultObject",
+ "key": "goblin grunt",
+ "health": lambda: randint(1, 1),
+ "resists": ["cold", "poison"],
+ "attacks": ["fists"],
+ "weaknesses": ["fire", "light"]
+ },
+ "GOBLIN_WIZARD": {
+ "prototype_parent": "GOBLIN",
+ "key": "goblin wizard",
+ "spells": ["fire ball", "lighting bolt"]
+ },
+ "GOBLIN_ARCHER": {
+ "prototype_parent": "GOBLIN",
+ "key": "goblin archer",
+ "attacks": ["short bow"]
+ },
+ "ARCHWIZARD": {
+ "prototype_parent": "GOBLIN",
+ "attacks": ["archwizard staff"],
+ },
+ "GOBLIN_ARCHWIZARD": {
+ "key": "goblin archwizard",
+ "prototype_parent": ("GOBLIN_WIZARD", "ARCHWIZARD")
+ }
+}
+
+
+class TestSpawner(EvenniaTest):
+
+ def setUp(self):
+ super(TestSpawner, self).setUp()
+ self.prot1 = {"prototype_key": "testprototype",
+ "typeclass": "evennia.objects.objects.DefaultObject"}
+
+ def test_spawn(self):
+ obj1 = spawner.spawn(self.prot1)
+ # check spawned objects have the right tag
+ self.assertEqual(list(protlib.search_objects_with_prototype("testprototype")), obj1)
+ self.assertEqual([o.key for o in spawner.spawn(
+ _PROTPARENTS["GOBLIN"], _PROTPARENTS["GOBLIN_ARCHWIZARD"],
+ prototype_parents=_PROTPARENTS)], ['goblin grunt', 'goblin archwizard'])
+
+
+class TestUtils(EvenniaTest):
+
+ def test_prototype_from_object(self):
+ self.maxDiff = None
+ self.obj1.attributes.add("test", "testval")
+ self.obj1.tags.add('foo')
+ new_prot = spawner.prototype_from_object(self.obj1)
+ self.assertEqual(
+ {'attrs': [('test', 'testval', None, '')],
+ 'home': Something,
+ 'key': 'Obj',
+ 'location': Something,
+ 'locks': ";".join([
+ 'call:true()',
+ 'control:perm(Developer)',
+ 'delete:perm(Admin)',
+ 'edit:perm(Admin)',
+ 'examine:perm(Builder)',
+ 'get:all()',
+ 'puppet:pperm(Developer)',
+ 'tell:perm(Admin)',
+ 'view:all()']),
+ 'prototype_desc': 'Built from Obj',
+ 'prototype_key': Something,
+ 'prototype_locks': 'spawn:all();edit:all()',
+ 'prototype_tags': [],
+ 'tags': [(u'foo', None, None)],
+ 'typeclass': 'evennia.objects.objects.DefaultObject'}, new_prot)
+
+ def test_update_objects_from_prototypes(self):
+
+ self.maxDiff = None
+ self.obj1.attributes.add('oldtest', 'to_keep')
+
+ old_prot = spawner.prototype_from_object(self.obj1)
+
+ # modify object away from prototype
+ self.obj1.attributes.add('test', 'testval')
+ self.obj1.attributes.add('desc', 'changed desc')
+ self.obj1.aliases.add('foo')
+ self.obj1.tags.add('footag', 'foocategory')
+
+ # modify prototype
+ old_prot['new'] = 'new_val'
+ old_prot['test'] = 'testval_changed'
+ old_prot['permissions'] = ['Builder']
+ # this will not update, since we don't update the prototype on-disk
+ old_prot['prototype_desc'] = 'New version of prototype'
+ old_prot['attrs'] += (("fooattr", "fooattrval", None, ''),)
+
+ # diff obj/prototype
+ old_prot_copy = old_prot.copy()
+
+ pdiff, obj_prototype = spawner.prototype_diff_from_object(old_prot, self.obj1)
+
+ self.assertEqual(old_prot_copy, old_prot)
+
+ self.assertEqual(obj_prototype,
+ {'aliases': ['foo'],
+ 'attrs': [('oldtest', 'to_keep', None, ''),
+ ('test', 'testval', None, ''),
+ ('desc', 'changed desc', None, '')],
+ 'key': 'Obj',
+ 'home': '#1',
+ 'location': '#1',
+ 'locks': 'call:true();control:perm(Developer);delete:perm(Admin);'
+ 'edit:perm(Admin);examine:perm(Builder);get:all();'
+ 'puppet:pperm(Developer);tell:perm(Admin);view:all()',
+ 'prototype_desc': 'Built from Obj',
+ 'prototype_key': Something,
+ 'prototype_locks': 'spawn:all();edit:all()',
+ 'prototype_tags': [],
+ 'tags': [(u'footag', u'foocategory', None)],
+ 'typeclass': 'evennia.objects.objects.DefaultObject'})
+
+ self.assertEqual(old_prot,
+ {'attrs': [('oldtest', 'to_keep', None, ''),
+ ('fooattr', 'fooattrval', None, '')],
+ 'home': '#1',
+ 'key': 'Obj',
+ 'location': '#1',
+ 'locks': 'call:true();control:perm(Developer);delete:perm(Admin);'
+ 'edit:perm(Admin);examine:perm(Builder);get:all();'
+ 'puppet:pperm(Developer);tell:perm(Admin);view:all()',
+ 'new': 'new_val',
+ 'permissions': ['Builder'],
+ 'prototype_desc': 'New version of prototype',
+ 'prototype_key': Something,
+ 'prototype_locks': 'spawn:all();edit:all()',
+ 'prototype_tags': [],
+ 'test': 'testval_changed',
+ 'typeclass': 'evennia.objects.objects.DefaultObject'})
+
+ self.assertEqual(
+ pdiff,
+ {'home': ('#1', '#1', 'KEEP'),
+ 'prototype_locks': ('spawn:all();edit:all()',
+ 'spawn:all();edit:all()', 'KEEP'),
+ 'prototype_key': (Something, Something, 'UPDATE'),
+ 'location': ('#1', '#1', 'KEEP'),
+ 'locks': ('call:true();control:perm(Developer);delete:perm(Admin);'
+ 'edit:perm(Admin);examine:perm(Builder);get:all();'
+ 'puppet:pperm(Developer);tell:perm(Admin);view:all()',
+ 'call:true();control:perm(Developer);delete:perm(Admin);'
+ 'edit:perm(Admin);examine:perm(Builder);get:all();'
+ 'puppet:pperm(Developer);tell:perm(Admin);view:all()', 'KEEP'),
+ 'prototype_tags': {},
+ 'attrs': {'oldtest': (('oldtest', 'to_keep', None, ''),
+ ('oldtest', 'to_keep', None, ''), 'KEEP'),
+ 'test': (('test', 'testval', None, ''),
+ None, 'REMOVE'),
+ 'desc': (('desc', 'changed desc', None, ''),
+ None, 'REMOVE'),
+ 'fooattr': (None, ('fooattr', 'fooattrval', None, ''), 'ADD'),
+ 'test': (('test', 'testval', None, ''),
+ ('test', 'testval_changed', None, ''), 'UPDATE'),
+ 'new': (None, ('new', 'new_val', None, ''), 'ADD')},
+ 'key': ('Obj', 'Obj', 'KEEP'),
+ 'typeclass': ('evennia.objects.objects.DefaultObject',
+ 'evennia.objects.objects.DefaultObject', 'KEEP'),
+ 'aliases': {'foo': ('foo', None, 'REMOVE')},
+ 'tags': {u'footag': ((u'footag', u'foocategory', None), None, 'REMOVE')},
+ 'prototype_desc': ('Built from Obj',
+ 'New version of prototype', 'UPDATE'),
+ 'permissions': {"Builder": (None, 'Builder', 'ADD')}
+ })
+
+ self.assertEqual(
+ spawner.flatten_diff(pdiff),
+ {'aliases': 'REMOVE',
+ 'attrs': 'REPLACE',
+ 'home': 'KEEP',
+ 'key': 'KEEP',
+ 'location': 'KEEP',
+ 'locks': 'KEEP',
+ 'permissions': 'UPDATE',
+ 'prototype_desc': 'UPDATE',
+ 'prototype_key': 'UPDATE',
+ 'prototype_locks': 'KEEP',
+ 'prototype_tags': 'KEEP',
+ 'tags': 'REMOVE',
+ 'typeclass': 'KEEP'}
+ )
+
+ # apply diff
+ count = spawner.batch_update_objects_with_prototype(
+ old_prot, diff=pdiff, objects=[self.obj1])
+ self.assertEqual(count, 1)
+
+ new_prot = spawner.prototype_from_object(self.obj1)
+ self.assertEqual({'attrs': [('oldtest', 'to_keep', None, ''),
+ ('fooattr', 'fooattrval', None, ''),
+ ('new', 'new_val', None, ''),
+ ('test', 'testval_changed', None, '')],
+ 'home': Something,
+ 'key': 'Obj',
+ 'location': Something,
+ 'locks': ";".join([
+ 'call:true()',
+ 'control:perm(Developer)',
+ 'delete:perm(Admin)',
+ 'edit:perm(Admin)',
+ 'examine:perm(Builder)',
+ 'get:all()',
+ 'puppet:pperm(Developer)',
+ 'tell:perm(Admin)',
+ 'view:all()']),
+ 'permissions': ['builder'],
+ 'prototype_desc': 'Built from Obj',
+ 'prototype_key': Something,
+ 'prototype_locks': 'spawn:all();edit:all()',
+ 'prototype_tags': [],
+ 'typeclass': 'evennia.objects.objects.DefaultObject'},
+ new_prot)
+
+
+class TestProtLib(EvenniaTest):
+
+ def setUp(self):
+ super(TestProtLib, self).setUp()
+ self.obj1.attributes.add("testattr", "testval")
+ self.prot = spawner.prototype_from_object(self.obj1)
+
+ def test_prototype_to_str(self):
+ prstr = protlib.prototype_to_str(self.prot)
+ self.assertTrue(prstr.startswith("|cprototype-key:|n"))
+
+ def test_check_permission(self):
+ pass
+
+
+@override_settings(PROT_FUNC_MODULES=['evennia.prototypes.protfuncs'], CLIENT_DEFAULT_WIDTH=20)
+class TestProtFuncs(EvenniaTest):
+
+ def setUp(self):
+ super(TestProtFuncs, self).setUp()
+ self.prot = {"prototype_key": "test_prototype",
+ "prototype_desc": "testing prot",
+ "key": "ExampleObj"}
+
+ @mock.patch("evennia.prototypes.protfuncs.base_random", new=mock.MagicMock(return_value=0.5))
+ @mock.patch("evennia.prototypes.protfuncs.base_randint", new=mock.MagicMock(return_value=5))
+ def test_protfuncs(self):
+ self.assertEqual(protlib.protfunc_parser("$random()"), 0.5)
+ self.assertEqual(protlib.protfunc_parser("$randint(1, 10)"), 5)
+ self.assertEqual(protlib.protfunc_parser("$left_justify( foo )"), "foo ")
+ self.assertEqual(protlib.protfunc_parser("$right_justify( foo )"), " foo")
+ self.assertEqual(protlib.protfunc_parser("$center_justify(foo )"), " foo ")
+ self.assertEqual(protlib.protfunc_parser(
+ "$full_justify(foo bar moo too)"), 'foo bar moo too')
+ self.assertEqual(
+ protlib.protfunc_parser("$right_justify( foo )", testing=True),
+ ('unexpected indent (, line 1)', ' foo'))
+
+ test_prot = {"key1": "value1",
+ "key2": 2}
+
+ self.assertEqual(protlib.protfunc_parser(
+ "$protkey(key1)", testing=True, prototype=test_prot), (None, "value1"))
+ self.assertEqual(protlib.protfunc_parser(
+ "$protkey(key2)", testing=True, prototype=test_prot), (None, 2))
+
+ self.assertEqual(protlib.protfunc_parser("$add(1, 2)"), 3)
+ self.assertEqual(protlib.protfunc_parser("$add(10, 25)"), 35)
+ self.assertEqual(protlib.protfunc_parser(
+ "$add('''[1,2,3]''', '''[4,5,6]''')"), [1, 2, 3, 4, 5, 6])
+ self.assertEqual(protlib.protfunc_parser("$add(foo, bar)"), "foo bar")
+
+ self.assertEqual(protlib.protfunc_parser("$sub(5, 2)"), 3)
+ self.assertRaises(TypeError, protlib.protfunc_parser, "$sub(5, test)")
+
+ self.assertEqual(protlib.protfunc_parser("$mult(5, 2)"), 10)
+ self.assertEqual(protlib.protfunc_parser("$mult( 5 , 10)"), 50)
+ self.assertEqual(protlib.protfunc_parser("$mult('foo',3)"), "foofoofoo")
+ self.assertEqual(protlib.protfunc_parser("$mult(foo,3)"), "foofoofoo")
+ self.assertRaises(TypeError, protlib.protfunc_parser, "$mult(foo, foo)")
+
+ self.assertEqual(protlib.protfunc_parser("$toint(5.3)"), 5)
+
+ self.assertEqual(protlib.protfunc_parser("$div(5, 2)"), 2.5)
+ self.assertEqual(protlib.protfunc_parser("$toint($div(5, 2))"), 2)
+ self.assertEqual(protlib.protfunc_parser("$sub($add(5, 3), $add(10, 2))"), -4)
+
+ self.assertEqual(protlib.protfunc_parser("$eval('2')"), '2')
+
+ self.assertEqual(protlib.protfunc_parser(
+ "$eval(['test', 1, '2', 3.5, \"foo\"])"), ['test', 1, '2', 3.5, 'foo'])
+ self.assertEqual(protlib.protfunc_parser(
+ "$eval({'test': '1', 2:3, 3: $toint(3.5)})"), {'test': '1', 2: 3, 3: 3})
+
+ self.assertEqual(protlib.protfunc_parser("$obj(#1)", session=self.session), '#1')
+ self.assertEqual(protlib.protfunc_parser("#1", session=self.session), '#1')
+ self.assertEqual(protlib.protfunc_parser("$obj(Char)", session=self.session), '#6')
+ self.assertEqual(protlib.protfunc_parser("$obj(Char)", session=self.session), '#6')
+ self.assertEqual(protlib.protfunc_parser("$objlist(#1)", session=self.session), ['#1'])
+
+ self.assertEqual(protlib.value_to_obj(
+ protlib.protfunc_parser("#6", session=self.session)), self.char1)
+ self.assertEqual(protlib.value_to_obj_or_any(
+ protlib.protfunc_parser("#6", session=self.session)), self.char1)
+ self.assertEqual(protlib.value_to_obj_or_any(
+ protlib.protfunc_parser("[1,2,3,'#6',5]", session=self.session)),
+ [1, 2, 3, self.char1, 5])
+
+
+class TestPrototypeStorage(EvenniaTest):
+
+ def setUp(self):
+ super(TestPrototypeStorage, self).setUp()
+ self.maxDiff = None
+
+ self.prot1 = spawner.prototype_from_object(self.obj1)
+ self.prot1['prototype_key'] = 'testprototype1'
+ self.prot1['prototype_desc'] = 'testdesc1'
+ self.prot1['prototype_tags'] = [('foo1', _PROTOTYPE_TAG_META_CATEGORY)]
+
+ self.prot2 = self.prot1.copy()
+ self.prot2['prototype_key'] = 'testprototype2'
+ self.prot2['prototype_desc'] = 'testdesc2'
+ self.prot2['prototype_tags'] = [('foo1', _PROTOTYPE_TAG_META_CATEGORY)]
+
+ self.prot3 = self.prot2.copy()
+ self.prot3['prototype_key'] = 'testprototype3'
+ self.prot3['prototype_desc'] = 'testdesc3'
+ self.prot3['prototype_tags'] = [('foo1', _PROTOTYPE_TAG_META_CATEGORY)]
+
+ def test_prototype_storage(self):
+
+ # from evennia import set_trace;set_trace(term_size=(180, 50))
+ prot1 = protlib.create_prototype(**self.prot1)
+
+ self.assertTrue(bool(prot1))
+ self.assertEqual(prot1, self.prot1)
+
+ self.assertEqual(prot1['prototype_desc'], "testdesc1")
+
+ self.assertEqual(prot1['prototype_tags'], [("foo1", _PROTOTYPE_TAG_META_CATEGORY)])
+ self.assertEqual(
+ protlib.DbPrototype.objects.get_by_tag(
+ "foo1", _PROTOTYPE_TAG_META_CATEGORY)[0].db.prototype, prot1)
+
+ prot2 = protlib.create_prototype(**self.prot2)
+ self.assertEqual(
+ [pobj.db.prototype
+ for pobj in protlib.DbPrototype.objects.get_by_tag(
+ "foo1", _PROTOTYPE_TAG_META_CATEGORY)],
+ [prot1, prot2])
+
+ # add to existing prototype
+ prot1b = protlib.create_prototype(
+ prototype_key='testprototype1', foo='bar', prototype_tags=['foo2'])
+
+ self.assertEqual(
+ [pobj.db.prototype
+ for pobj in protlib.DbPrototype.objects.get_by_tag(
+ "foo2", _PROTOTYPE_TAG_META_CATEGORY)],
+ [prot1b])
+
+ self.assertEqual(list(protlib.search_prototype("testprototype2")), [prot2])
+ self.assertNotEqual(list(protlib.search_prototype("testprototype1")), [prot1])
+ self.assertEqual(list(protlib.search_prototype("testprototype1")), [prot1b])
+
+ prot3 = protlib.create_prototype(**self.prot3)
+
+ # partial match
+ with mock.patch("evennia.prototypes.prototypes._MODULE_PROTOTYPES", {}):
+ self.assertEqual(list(protlib.search_prototype("prot")), [prot1b, prot2, prot3])
+ self.assertEqual(list(protlib.search_prototype(tags="foo1")), [prot1b, prot2, prot3])
+
+ self.assertTrue(str(unicode(protlib.list_prototypes(self.char1))))
+
+
+class _MockMenu(object):
+ pass
+
+
+class TestMenuModule(EvenniaTest):
+
+ def setUp(self):
+ super(TestMenuModule, self).setUp()
+
+ # set up fake store
+ self.caller = self.char1
+ menutree = _MockMenu()
+ self.caller.ndb._menutree = menutree
+
+ self.test_prot = {"prototype_key": "test_prot",
+ "typeclass": "evennia.objects.objects.DefaultObject",
+ "prototype_locks": "edit:all();spawn:all()"}
+
+ def test_helpers(self):
+
+ caller = self.caller
+
+ # general helpers
+
+ self.assertEqual(olc_menus._get_menu_prototype(caller), {})
+ self.assertEqual(olc_menus._is_new_prototype(caller), True)
+
+ self.assertEqual(olc_menus._set_menu_prototype(caller, {}), {})
+
+ self.assertEqual(
+ olc_menus._set_prototype_value(caller, "key", "TestKey"), {"key": "TestKey"})
+ self.assertEqual(olc_menus._get_menu_prototype(caller), {"key": "TestKey"})
+
+ self.assertEqual(olc_menus._format_option_value(
+ "key", required=True, prototype=olc_menus._get_menu_prototype(caller)), " (TestKey|n)")
+ self.assertEqual(olc_menus._format_option_value(
+ [1, 2, 3, "foo"], required=True), ' (1, 2, 3, foo|n)')
+
+ self.assertEqual(olc_menus._set_property(
+ caller, "ChangedKey", prop="key", processor=str, next_node="foo"), "foo")
+ self.assertEqual(olc_menus._get_menu_prototype(caller), {"key": "ChangedKey"})
+
+ self.assertEqual(olc_menus._wizard_options(
+ "ThisNode", "PrevNode", "NextNode"),
+ [{'goto': 'node_PrevNode', 'key': ('|wB|Wack', 'b'), 'desc': '|W(PrevNode)|n'},
+ {'goto': 'node_NextNode', 'key': ('|wF|Worward', 'f'), 'desc': '|W(NextNode)|n'},
+ {'goto': 'node_index', 'key': ('|wI|Wndex', 'i')},
+ {'goto': ('node_validate_prototype', {'back': 'ThisNode'}),
+ 'key': ('|wV|Walidate prototype', 'validate', 'v')}])
+
+ self.assertEqual(olc_menus._validate_prototype(self.test_prot), (False, Something))
+ self.assertEqual(olc_menus._validate_prototype(
+ {"prototype_key": "testthing", "key": "mytest"}),
+ (True, Something))
+
+ choices = ["test1", "test2", "test3", "test4"]
+ actions = (("examine", "e", "l"), ("add", "a"), ("foo", "f"))
+ self.assertEqual(olc_menus._default_parse("l4", choices, *actions), ('test4', 'examine'))
+ self.assertEqual(olc_menus._default_parse("add 2", choices, *actions), ('test2', 'add'))
+ self.assertEqual(olc_menus._default_parse("foo3", choices, *actions), ('test3', 'foo'))
+ self.assertEqual(olc_menus._default_parse("f3", choices, *actions), ('test3', 'foo'))
+ self.assertEqual(olc_menus._default_parse("f5", choices, *actions), (None, None))
+
+ def test_node_helpers(self):
+
+ caller = self.caller
+
+ with mock.patch("evennia.prototypes.menus.protlib.search_prototype",
+ new=mock.MagicMock(return_value=[self.test_prot])):
+ # prototype_key helpers
+ self.assertEqual(olc_menus._check_prototype_key(caller, "test_prot"), None)
+ caller.ndb._menutree.olc_new = True
+ self.assertEqual(olc_menus._check_prototype_key(caller, "test_prot"), "node_index")
+
+ # prototype_parent helpers
+ self.assertEqual(olc_menus._all_prototype_parents(caller), ['test_prot'])
+ # self.assertEqual(olc_menus._prototype_parent_parse(
+ # caller, 'test_prot'),
+ # "|cprototype key:|n test_prot, |ctags:|n None, |clocks:|n edit:all();spawn:all() "
+ # "\n|cdesc:|n None \n|cprototype:|n "
+ # "{\n 'typeclass': 'evennia.objects.objects.DefaultObject', \n}")
+
+ with mock.patch("evennia.prototypes.menus.protlib.search_prototype",
+ new=mock.MagicMock(return_value=[_PROTPARENTS['GOBLIN']])):
+ self.assertEqual(olc_menus._prototype_parent_select(caller, "goblin"), "node_prototype_parent")
+
+ self.assertEqual(olc_menus._get_menu_prototype(caller),
+ {'prototype_key': 'test_prot',
+ 'prototype_locks': 'edit:all();spawn:all()',
+ 'prototype_parent': 'goblin',
+ 'typeclass': 'evennia.objects.objects.DefaultObject'})
+
+ # typeclass helpers
+ with mock.patch("evennia.utils.utils.get_all_typeclasses",
+ new=mock.MagicMock(return_value={"foo": None, "bar": None})):
+ self.assertEqual(olc_menus._all_typeclasses(caller), ["bar", "foo"])
+
+ self.assertEqual(olc_menus._typeclass_select(
+ caller, "evennia.objects.objects.DefaultObject"), None)
+ # prototype_parent should be popped off here
+ self.assertEqual(olc_menus._get_menu_prototype(caller),
+ {'prototype_key': 'test_prot',
+ 'prototype_locks': 'edit:all();spawn:all()',
+ 'prototype_parent': 'goblin',
+ 'typeclass': 'evennia.objects.objects.DefaultObject'})
+
+ # attr helpers
+ self.assertEqual(olc_menus._caller_attrs(caller), [])
+ self.assertEqual(olc_menus._add_attr(caller, "test1=foo1"), Something)
+ self.assertEqual(olc_menus._add_attr(caller, "test2;cat1=foo2"), Something)
+ self.assertEqual(olc_menus._add_attr(caller, "test3;cat2;edit:false()=foo3"), Something)
+ self.assertEqual(olc_menus._add_attr(caller, "test4;cat3;set:true();edit:false()=foo4"), Something)
+ self.assertEqual(olc_menus._add_attr(caller, "test5;cat4;set:true();edit:false()=123"), Something)
+ self.assertEqual(olc_menus._add_attr(caller, "test1=foo1_changed"), Something)
+ self.assertEqual(olc_menus._get_menu_prototype(caller)['attrs'],
+ [("test1", "foo1_changed", None, ''),
+ ("test2", "foo2", "cat1", ''),
+ ("test3", "foo3", "cat2", "edit:false()"),
+ ("test4", "foo4", "cat3", "set:true();edit:false()"),
+ ("test5", '123', "cat4", "set:true();edit:false()")])
+
+ # tag helpers
+ self.assertEqual(olc_menus._caller_tags(caller), [])
+ self.assertEqual(olc_menus._add_tag(caller, "foo1"), Something)
+ self.assertEqual(olc_menus._add_tag(caller, "foo2;cat1"), Something)
+ self.assertEqual(olc_menus._add_tag(caller, "foo3;cat2;dat1"), Something)
+ self.assertEqual(olc_menus._caller_tags(caller), ['foo1', 'foo2', 'foo3'])
+ self.assertEqual(olc_menus._get_menu_prototype(caller)['tags'],
+ [('foo1', None, ""),
+ ('foo2', 'cat1', ""),
+ ('foo3', 'cat2', "dat1")])
+ self.assertEqual(olc_menus._add_tag(caller, "foo1", delete=True), "Removed Tag 'foo1'.")
+ self.assertEqual(olc_menus._get_menu_prototype(caller)['tags'],
+ [('foo2', 'cat1', ""),
+ ('foo3', 'cat2', "dat1")])
+
+ self.assertEqual(olc_menus._display_tag(olc_menus._get_menu_prototype(caller)['tags'][0]), Something)
+ self.assertEqual(olc_menus._caller_tags(caller), ["foo2", "foo3"])
+
+ protlib.save_prototype(**self.test_prot)
+
+ # locks helpers
+ self.assertEqual(olc_menus._lock_add(caller, "foo:false()"), "Added lock 'foo:false()'.")
+ self.assertEqual(olc_menus._lock_add(caller, "foo2:false()"), "Added lock 'foo2:false()'.")
+ self.assertEqual(olc_menus._lock_add(caller, "foo2:true()"), "Lock with locktype 'foo2' updated.")
+ self.assertEqual(olc_menus._get_menu_prototype(caller)["locks"], "foo:false();foo2:true()")
+
+ # perm helpers
+ self.assertEqual(olc_menus._add_perm(caller, "foo"), "Added Permission 'foo'")
+ self.assertEqual(olc_menus._add_perm(caller, "foo2"), "Added Permission 'foo2'")
+ self.assertEqual(olc_menus._get_menu_prototype(caller)["permissions"], ["foo", "foo2"])
+
+ # prototype_tags helpers
+ self.assertEqual(olc_menus._add_prototype_tag(caller, "foo"), "Added Prototype-Tag 'foo'.")
+ self.assertEqual(olc_menus._add_prototype_tag(caller, "foo2"), "Added Prototype-Tag 'foo2'.")
+ self.assertEqual(olc_menus._get_menu_prototype(caller)["prototype_tags"], ["foo", "foo2"])
+
+ # spawn helpers
+ with mock.patch("evennia.prototypes.menus.protlib.search_prototype",
+ new=mock.MagicMock(return_value=[_PROTPARENTS['GOBLIN']])):
+ self.assertEqual(olc_menus._spawn(caller, prototype=self.test_prot), Something)
+ obj = caller.contents[0]
+
+ self.assertEqual(obj.typeclass_path, "evennia.objects.objects.DefaultObject")
+ self.assertEqual(obj.tags.get(category=spawner._PROTOTYPE_TAG_CATEGORY), self.test_prot['prototype_key'])
+
+ # update helpers
+ self.assertEqual(olc_menus._apply_diff(
+ caller, prototype=self.test_prot, back_node="foo", objects=[obj]), 'foo') # no changes to apply
+ self.test_prot['key'] = "updated key" # change prototype
+ self.assertEqual(olc_menus._apply_diff(
+ caller, prototype=self.test_prot, objects=[obj], back_node='foo'), 'foo') # apply change to the one obj
+
+ # load helpers
+ self.assertEqual(olc_menus._prototype_load_select(caller, self.test_prot['prototype_key']),
+ ('node_examine_entity', {'text': '|gLoaded prototype test_prot.|n', 'back': 'index'}) )
+
+ # diff helpers
+ obj_diff = {
+ 'attrs': {
+ u'desc': ((u'desc', u'This is User #1.', None, ''),
+ (u'desc', u'This is User #1.', None, ''),
+ 'KEEP'),
+ u'foo': (None,
+ (u'foo', u'bar', None, ''),
+ 'ADD'),
+ u'prelogout_location': ((u'prelogout_location', "#2", None, ''),
+ (u'prelogout_location', "#2", None, ''),
+ 'KEEP')},
+ 'home': ('#2', '#2', 'KEEP'),
+ 'key': (u'TestChar', u'TestChar', 'KEEP'),
+ 'locks': ('boot:false();call:false();control:perm(Developer);delete:false();'
+ 'edit:false();examine:perm(Developer);get:false();msg:all();'
+ 'puppet:false();tell:perm(Admin);view:all()',
+ 'boot:false();call:false();control:perm(Developer);delete:false();'
+ 'edit:false();examine:perm(Developer);get:false();msg:all();'
+ 'puppet:false();tell:perm(Admin);view:all()',
+ 'KEEP'),
+ 'permissions': {'developer': ('developer', 'developer', 'KEEP')},
+ 'prototype_desc': ('Testobject build', None, 'REMOVE'),
+ 'prototype_key': ('TestDiffKey', 'TestDiffKey', 'KEEP'),
+ 'prototype_locks': ('spawn:all();edit:all()', 'spawn:all();edit:all()', 'KEEP'),
+ 'prototype_tags': {},
+ 'tags': {'foo': (None, ('foo', None, ''), 'ADD')},
+ 'typeclass': (u'typeclasses.characters.Character',
+ u'typeclasses.characters.Character', 'KEEP')}
+
+ texts, options = olc_menus._format_diff_text_and_options(obj_diff)
+ self.assertEqual(
+ "\n".join(texts),
+ '- |wattrs:|n \n'
+ ' |c[1] |yADD|n|W:|n None |W->|n foo |W=|n bar |W(category:|n None|W, locks:|n |W)|n\n'
+ ' |gKEEP|W:|n prelogout_location |W=|n #2 |W(category:|n None|W, locks:|n |W)|n\n'
+ ' |gKEEP|W:|n desc |W=|n This is User #1. |W(category:|n None|W, locks:|n |W)|n\n'
+ '- |whome:|n |gKEEP|W:|n #2\n'
+ '- |wkey:|n |gKEEP|W:|n TestChar\n'
+ '- |wlocks:|n |gKEEP|W:|n boot:false();call:false();control:perm(Developer);delete:false();edit:false();examine:perm(Developer);get:false();msg:all();puppet:false();tell:perm(Admin);view:all()\n'
+ '- |wpermissions:|n \n'
+ ' |gKEEP|W:|n developer\n'
+ '- |wprototype_desc:|n |c[2] |rREMOVE|n|W:|n Testobject build |W->|n None\n'
+ '- |wprototype_key:|n |gKEEP|W:|n TestDiffKey\n'
+ '- |wprototype_locks:|n |gKEEP|W:|n spawn:all();edit:all()\n'
+ '- |wprototype_tags:|n \n'
+ '- |wtags:|n \n'
+ ' |c[3] |yADD|n|W:|n None |W->|n foo |W(category:|n None|W)|n\n'
+ '- |wtypeclass:|n |gKEEP|W:|n typeclasses.characters.Character')
+ self.assertEqual(
+ options,
+ [{'goto': (Something, Something),
+ 'key': '1',
+ 'desc': '|gKEEP|n (attrs) None'},
+ {'goto': (Something, Something),
+ 'key': '2',
+ 'desc': '|gKEEP|n (prototype_desc) Testobject build'},
+ {'goto': (Something, Something),
+ 'key': '3',
+ 'desc': '|gKEEP|n (tags) None'}])
+
+
+@mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock(
+ return_value=[{"prototype_key": "TestPrototype",
+ "typeclass": "TypeClassTest", "key": "TestObj"}]))
+@mock.patch("evennia.utils.utils.get_all_typeclasses", new=mock.MagicMock(
+ return_value={"TypeclassTest": None}))
+class TestOLCMenu(TestEvMenu):
+
+ maxDiff = None
+ menutree = "evennia.prototypes.menus"
+ startnode = "node_index"
+
+ # debug_output = True
+ expect_all_nodes = True
+
+ expected_node_texts = {
+ "node_index": "|c --- Prototype wizard --- |n"
+ }
+
+ expected_tree = ['node_index', ['node_prototype_key', ['node_index', 'node_index', 'node_index',
+ 'node_validate_prototype', ['node_index', 'node_index', 'node_index'], 'node_index'],
+ 'node_prototype_parent', ['node_prototype_parent', 'node_prototype_key',
+ 'node_prototype_parent', 'node_index', 'node_validate_prototype', 'node_index'],
+ 'node_typeclass', ['node_typeclass', 'node_prototype_parent', 'node_typeclass',
+ 'node_index', 'node_validate_prototype', 'node_index'], 'node_key', ['node_typeclass',
+ 'node_key', 'node_index', 'node_validate_prototype', 'node_index'], 'node_aliases',
+ ['node_key', 'node_aliases', 'node_index', 'node_validate_prototype', 'node_index'],
+ 'node_attrs', ['node_aliases', 'node_attrs', 'node_index', 'node_validate_prototype',
+ 'node_index'], 'node_tags', ['node_attrs', 'node_tags', 'node_index',
+ 'node_validate_prototype', 'node_index'], 'node_locks', ['node_tags',
+ 'node_locks', 'node_index', 'node_validate_prototype', 'node_index'],
+ 'node_permissions', ['node_locks', 'node_permissions', 'node_index',
+ 'node_validate_prototype', 'node_index'], 'node_location',
+ ['node_permissions', 'node_location', 'node_index', 'node_validate_prototype',
+ 'node_index', 'node_index'], 'node_home', ['node_location', 'node_home',
+ 'node_index', 'node_validate_prototype', 'node_index', 'node_index'],
+ 'node_destination', ['node_home', 'node_destination', 'node_index',
+ 'node_validate_prototype', 'node_index', 'node_index'],
+ 'node_prototype_desc', ['node_prototype_key', 'node_prototype_parent',
+ 'node_index', 'node_validate_prototype', 'node_index'],
+ 'node_prototype_tags', ['node_prototype_desc', 'node_prototype_tags',
+ 'node_index', 'node_validate_prototype', 'node_index'],
+ 'node_prototype_locks', ['node_prototype_tags', 'node_prototype_locks',
+ 'node_index', 'node_validate_prototype', 'node_index'],
+ 'node_validate_prototype', 'node_index', 'node_prototype_spawn',
+ ['node_index', 'node_index', 'node_validate_prototype'], 'node_index',
+ 'node_search_object', ['node_index', 'node_index', 'node_index']]]
diff --git a/evennia/scripts/manager.py b/evennia/scripts/manager.py
index 17a11d20b3..1b795b2c3e 100644
--- a/evennia/scripts/manager.py
+++ b/evennia/scripts/manager.py
@@ -214,7 +214,7 @@ class ScriptDBManager(TypedObjectManager):
VALIDATE_ITERATION -= 1
return nr_started, nr_stopped
- def search_script(self, ostring, obj=None, only_timed=False):
+ def search_script(self, ostring, obj=None, only_timed=False, typeclass=None):
"""
Search for a particular script.
@@ -224,6 +224,7 @@ class ScriptDBManager(TypedObjectManager):
this object
only_timed (bool): Limit search only to scripts that run
on a timer.
+ typeclass (class or str): Typeclass or path to typeclass.
"""
@@ -237,10 +238,17 @@ class ScriptDBManager(TypedObjectManager):
(only_timed and dbref_match.interval)):
return [dbref_match]
+ if typeclass:
+ if callable(typeclass):
+ typeclass = u"%s.%s" % (typeclass.__module__, typeclass.__name__)
+ else:
+ typeclass = u"%s" % typeclass
+
# not a dbref; normal search
obj_restriction = obj and Q(db_obj=obj) or Q()
- timed_restriction = only_timed and Q(interval__gt=0) or Q()
- scripts = self.filter(timed_restriction & obj_restriction & Q(db_key__iexact=ostring))
+ timed_restriction = only_timed and Q(db_interval__gt=0) or Q()
+ typeclass_restriction = typeclass and Q(db_typeclass_path=typeclass) or Q()
+ scripts = self.filter(timed_restriction & obj_restriction & typeclass_restriction & Q(db_key__iexact=ostring))
return scripts
# back-compatibility alias
script_search = search_script
diff --git a/evennia/scripts/migrations/0001_initial.py b/evennia/scripts/migrations/0001_initial.py
index 751bd2928e..363d0b28bc 100644
--- a/evennia/scripts/migrations/0001_initial.py
+++ b/evennia/scripts/migrations/0001_initial.py
@@ -29,8 +29,8 @@ class Migration(migrations.Migration):
('db_persistent', models.BooleanField(default=False, verbose_name=b'survive server reboot')),
('db_is_active', models.BooleanField(default=False, verbose_name=b'script active')),
('db_attributes', models.ManyToManyField(help_text=b'attributes on this object. An attribute can hold any pickle-able python object (see docs for special cases).', to='typeclasses.Attribute', null=True)),
- ('db_obj', models.ForeignKey(blank=True, to='objects.ObjectDB', help_text=b'the object to store this script on, if not a global script.', null=True, verbose_name=b'scripted object')),
- ('db_account', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, help_text=b'the account to store this script on (should not be set if obj is set)', null=True, verbose_name=b'scripted account')),
+ ('db_obj', models.ForeignKey(blank=True, to='objects.ObjectDB', on_delete=models.CASCADE, help_text=b'the object to store this script on, if not a global script.', null=True, verbose_name=b'scripted object')),
+ ('db_account', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE, help_text=b'the account to store this script on (should not be set if obj is set)', null=True, verbose_name=b'scripted account')),
('db_tags', models.ManyToManyField(help_text=b'tags on this object. Tags are simple string markers to identify, group and alias objects.', to='typeclasses.Tag', null=True)),
],
options={
diff --git a/evennia/scripts/models.py b/evennia/scripts/models.py
index d470349964..bb40b9382e 100644
--- a/evennia/scripts/models.py
+++ b/evennia/scripts/models.py
@@ -83,9 +83,11 @@ class ScriptDB(TypedObject):
# optional description.
db_desc = models.CharField('desc', max_length=255, blank=True)
# A reference to the database object affected by this Script, if any.
- db_obj = models.ForeignKey("objects.ObjectDB", null=True, blank=True, verbose_name='scripted object',
+ db_obj = models.ForeignKey("objects.ObjectDB", null=True, blank=True, on_delete=models.CASCADE,
+ verbose_name='scripted object',
help_text='the object to store this script on, if not a global script.')
- db_account = models.ForeignKey("accounts.AccountDB", null=True, blank=True, verbose_name="scripted account",
+ db_account = models.ForeignKey("accounts.AccountDB", null=True, blank=True,
+ on_delete=models.CASCADE, verbose_name="scripted account",
help_text='the account to store this script on (should not be set if db_obj is set)')
# how often to run Script (secs). -1 means there is no timer
diff --git a/evennia/scripts/scripts.py b/evennia/scripts/scripts.py
index 2b4bea3569..8bff161cf5 100644
--- a/evennia/scripts/scripts.py
+++ b/evennia/scripts/scripts.py
@@ -152,15 +152,6 @@ class ScriptBase(with_metaclass(TypeclassBase, ScriptDB)):
"""
objects = ScriptManager()
-
-class DefaultScript(ScriptBase):
- """
- This is the base TypeClass for all Scripts. Scripts describe
- events, timers and states in game, they can have a time component
- or describe a state that changes under certain conditions.
-
- """
-
def __eq__(self, other):
"""
Compares two Scripts. Compares dbids.
@@ -250,7 +241,96 @@ class DefaultScript(ScriptBase):
logger.log_trace()
return None
- # Public methods
+ def at_script_creation(self):
+ """
+ Should be overridden in child.
+
+ """
+ pass
+
+ def at_first_save(self, **kwargs):
+ """
+ This is called after very first time this object is saved.
+ Generally, you don't need to overload this, but only the hooks
+ called by this method.
+
+ Args:
+ **kwargs (dict): Arbitrary, optional arguments for users
+ overriding the call (unused by default).
+
+ """
+ self.at_script_creation()
+
+ if hasattr(self, "_createdict"):
+ # this will only be set if the utils.create_script
+ # function was used to create the object. We want
+ # the create call's kwargs to override the values
+ # set by hooks.
+ cdict = self._createdict
+ updates = []
+ if not cdict.get("key"):
+ if not self.db_key:
+ self.db_key = "#%i" % self.dbid
+ updates.append("db_key")
+ elif self.db_key != cdict["key"]:
+ self.db_key = cdict["key"]
+ updates.append("db_key")
+ if cdict.get("interval") and self.interval != cdict["interval"]:
+ self.db_interval = 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"]
+ updates.append("db_repeats")
+ if cdict.get("persistent") and self.persistent != cdict["persistent"]:
+ self.db_persistent = cdict["persistent"]
+ updates.append("db_persistent")
+ if cdict.get("desc") and self.desc != cdict["desc"]:
+ self.db_desc = cdict["desc"]
+ updates.append("db_desc")
+ if updates:
+ self.save(update_fields=updates)
+
+ if cdict.get("permissions"):
+ self.permissions.batch_add(*cdict["permissions"])
+ if cdict.get("locks"):
+ self.locks.add(cdict["locks"])
+ if cdict.get("tags"):
+ # this should be a list of tags, tuples (key, category) or (key, category, data)
+ self.tags.batch_add(*cdict["tags"])
+ if cdict.get("attributes"):
+ # this should be tuples (key, val, ...)
+ self.attributes.batch_add(*cdict["attributes"])
+ if cdict.get("nattributes"):
+ # this should be a dict of nattrname:value
+ for key, value in cdict["nattributes"]:
+ self.nattributes.add(key, value)
+
+ if not cdict.get("autostart"):
+ # don't auto-start the script
+ return
+
+ # auto-start script (default)
+ self.start()
+
+
+class DefaultScript(ScriptBase):
+ """
+ This is the base TypeClass for all Scripts. Scripts describe
+ events, timers and states in game, they can have a time component
+ or describe a state that changes under certain conditions.
+
+ """
+
+ def at_script_creation(self):
+ """
+ Only called once, when script is first created.
+
+ """
+ pass
+
def time_until_next_repeat(self):
"""
@@ -514,61 +594,6 @@ class DefaultScript(ScriptBase):
if task:
task.force_repeat()
- def at_first_save(self, **kwargs):
- """
- This is called after very first time this object is saved.
- Generally, you don't need to overload this, but only the hooks
- called by this method.
-
- Args:
- **kwargs (dict): Arbitrary, optional arguments for users
- overriding the call (unused by default).
-
- """
- self.at_script_creation()
-
- if hasattr(self, "_createdict"):
- # this will only be set if the utils.create_script
- # function was used to create the object. We want
- # the create call's kwargs to override the values
- # set by hooks.
- cdict = self._createdict
- updates = []
- if not cdict.get("key"):
- if not self.db_key:
- self.db_key = "#%i" % self.dbid
- updates.append("db_key")
- elif self.db_key != cdict["key"]:
- self.db_key = cdict["key"]
- updates.append("db_key")
- if cdict.get("interval") and self.interval != cdict["interval"]:
- self.db_interval = 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"]
- updates.append("db_repeats")
- if cdict.get("persistent") and self.persistent != cdict["persistent"]:
- self.db_persistent = cdict["persistent"]
- updates.append("db_persistent")
- if updates:
- self.save(update_fields=updates)
- if not cdict.get("autostart"):
- # don't auto-start the script
- return
-
- # auto-start script (default)
- self.start()
-
- def at_script_creation(self):
- """
- Only called once, by the create function.
-
- """
- pass
-
def is_valid(self):
"""
Is called to check if the script is valid to run at this time.
diff --git a/evennia/server/amp.py b/evennia/server/amp.py
deleted file mode 100644
index 9694abd034..0000000000
--- a/evennia/server/amp.py
+++ /dev/null
@@ -1,670 +0,0 @@
-"""
-Contains the protocols, commands, and client factory needed for the Server
-and Portal to communicate with each other, letting Portal work as a proxy.
-Both sides use this same protocol.
-
-The separation works like this:
-
-Portal - (AMP client) handles protocols. It contains a list of connected
- sessions in a dictionary for identifying the respective account
- connected. If it loses the AMP connection it will automatically
- try to reconnect.
-
-Server - (AMP server) Handles all mud operations. The server holds its own list
- of sessions tied to account objects. This is synced against the portal
- at startup and when a session connects/disconnects
-
-"""
-from __future__ import print_function
-
-# imports needed on both server and portal side
-import os
-import time
-from collections import defaultdict, namedtuple
-from itertools import count
-from cStringIO import StringIO
-try:
- import cPickle as pickle
-except ImportError:
- import pickle
-from twisted.protocols import amp
-from twisted.internet import protocol
-from twisted.internet.defer import Deferred
-from evennia.utils import logger
-from evennia.utils.utils import to_str, variable_from_module
-import zlib # Used in Compressed class
-
-DUMMYSESSION = namedtuple('DummySession', ['sessid'])(0)
-
-# communication bits
-# (chr(9) and chr(10) are \t and \n, so skipping them)
-
-PCONN = chr(1) # portal session connect
-PDISCONN = chr(2) # portal session disconnect
-PSYNC = chr(3) # portal session sync
-SLOGIN = chr(4) # server session login
-SDISCONN = chr(5) # server session disconnect
-SDISCONNALL = chr(6) # server session disconnect all
-SSHUTD = chr(7) # server shutdown
-SSYNC = chr(8) # server session sync
-SCONN = chr(11) # server creating new connection (for irc bots and etc)
-PCONNSYNC = chr(12) # portal post-syncing a session
-PDISCONNALL = chr(13) # portal session disconnect all
-AMP_MAXLEN = amp.MAX_VALUE_LENGTH # max allowed data length in AMP protocol (cannot be changed)
-
-BATCH_RATE = 250 # max commands/sec before switching to batch-sending
-BATCH_TIMEOUT = 0.5 # how often to poll to empty batch queue, in seconds
-
-# buffers
-_SENDBATCH = defaultdict(list)
-_MSGBUFFER = defaultdict(list)
-
-
-def get_restart_mode(restart_file):
- """
- Parse the server/portal restart status
-
- Args:
- restart_file (str): Path to restart.dat file.
-
- Returns:
- restart_mode (bool): If the file indicates the server is in
- restart mode or not.
-
- """
- if os.path.exists(restart_file):
- flag = open(restart_file, 'r').read()
- return flag == "True"
- return False
-
-
-class AmpServerFactory(protocol.ServerFactory):
- """
- This factory creates the Server as a new AMPProtocol instance for accepting
- connections from the Portal.
- """
- noisy = False
-
- def __init__(self, server):
- """
- Initialize the factory.
-
- Args:
- server (Server): The Evennia server service instance.
- protocol (Protocol): The protocol the factory creates
- instances of.
-
- """
- self.server = server
- self.protocol = AMPProtocol
-
- def buildProtocol(self, addr):
- """
- Start a new connection, and store it on the service object.
-
- Args:
- addr (str): Connection address. Not used.
-
- Returns:
- protocol (Protocol): The created protocol.
-
- """
- self.server.amp_protocol = AMPProtocol()
- self.server.amp_protocol.factory = self
- return self.server.amp_protocol
-
-
-class AmpClientFactory(protocol.ReconnectingClientFactory):
- """
- This factory creates an instance of the Portal, an AMPProtocol
- instances to use to connect
-
- """
- # Initial reconnect delay in seconds.
- initialDelay = 1
- factor = 1.5
- maxDelay = 1
- noisy = False
-
- def __init__(self, portal):
- """
- Initializes the client factory.
-
- Args:
- portal (Portal): Portal instance.
-
- """
- self.portal = portal
- self.protocol = AMPProtocol
-
- def startedConnecting(self, connector):
- """
- Called when starting to try to connect to the MUD server.
-
- Args:
- connector (Connector): Twisted Connector instance representing
- this connection.
-
- """
- pass
-
- def buildProtocol(self, addr):
- """
- Creates an AMPProtocol instance when connecting to the server.
-
- Args:
- addr (str): Connection address. Not used.
-
- """
- self.resetDelay()
- self.portal.amp_protocol = AMPProtocol()
- self.portal.amp_protocol.factory = self
- return self.portal.amp_protocol
-
- def clientConnectionLost(self, connector, reason):
- """
- Called when the AMP connection to the MUD server is lost.
-
- Args:
- connector (Connector): Twisted Connector instance representing
- this connection.
- reason (str): Eventual text describing why connection was lost.
-
- """
- if hasattr(self, "server_restart_mode"):
- self.portal.sessions.announce_all(" Server restarting ...")
- self.maxDelay = 2
- else:
- # Don't translate this; avoid loading django on portal side.
- self.maxDelay = 10
- self.portal.sessions.announce_all(" ... Portal lost connection to Server.")
- protocol.ReconnectingClientFactory.clientConnectionLost(self, connector, reason)
-
- def clientConnectionFailed(self, connector, reason):
- """
- Called when an AMP connection attempt to the MUD server fails.
-
- Args:
- connector (Connector): Twisted Connector instance representing
- this connection.
- reason (str): Eventual text describing why connection failed.
-
- """
- if hasattr(self, "server_restart_mode"):
- self.maxDelay = 2
- else:
- self.maxDelay = 10
- self.portal.sessions.announce_all(" ...")
- protocol.ReconnectingClientFactory.clientConnectionFailed(self, connector, reason)
-
-
-# AMP Communication Command types
-
-class Compressed(amp.String):
- """
- This is a customn AMP command Argument that both handles too-long
- sends as well as uses zlib for compression across the wire. The
- batch-grouping of too-long sends is borrowed from the "mediumbox"
- recipy at twisted-hacks's ~glyph/+junk/amphacks/mediumbox.
-
- """
-
- def fromBox(self, name, strings, objects, proto):
- """
- Converts from box representation to python. We
- group very long data into batches.
- """
- value = StringIO()
- value.write(strings.get(name))
- for counter in count(2):
- # count from 2 upwards
- chunk = strings.get("%s.%d" % (name, counter))
- if chunk is None:
- break
- value.write(chunk)
- objects[name] = value.getvalue()
-
- def toBox(self, name, strings, objects, proto):
- """
- Convert from data to box. We handled too-long
- batched data and put it together here.
- """
- value = StringIO(objects[name])
- strings[name] = value.read(AMP_MAXLEN)
- for counter in count(2):
- chunk = value.read(AMP_MAXLEN)
- if not chunk:
- break
- strings["%s.%d" % (name, counter)] = chunk
-
- def toString(self, inObject):
- """
- Convert to send on the wire, with compression.
- """
- return zlib.compress(inObject, 9)
-
- def fromString(self, inString):
- """
- Convert (decompress) from the wire to Python.
- """
- return zlib.decompress(inString)
-
-
-class MsgPortal2Server(amp.Command):
- """
- Message Portal -> Server
-
- """
- key = "MsgPortal2Server"
- arguments = [('packed_data', Compressed())]
- errors = {Exception: 'EXCEPTION'}
- response = []
-
-
-class MsgServer2Portal(amp.Command):
- """
- Message Server -> Portal
-
- """
- key = "MsgServer2Portal"
- arguments = [('packed_data', Compressed())]
- errors = {Exception: 'EXCEPTION'}
- response = []
-
-
-class AdminPortal2Server(amp.Command):
- """
- Administration Portal -> Server
-
- Sent when the portal needs to perform admin operations on the
- server, such as when a new session connects or resyncs
-
- """
- key = "AdminPortal2Server"
- arguments = [('packed_data', Compressed())]
- errors = {Exception: 'EXCEPTION'}
- response = []
-
-
-class AdminServer2Portal(amp.Command):
- """
- Administration Server -> Portal
-
- Sent when the server needs to perform admin operations on the
- portal.
-
- """
- key = "AdminServer2Portal"
- arguments = [('packed_data', Compressed())]
- errors = {Exception: 'EXCEPTION'}
- response = []
-
-
-class FunctionCall(amp.Command):
- """
- Bidirectional Server <-> Portal
-
- Sent when either process needs to call an arbitrary function in
- the other. This does not use the batch-send functionality.
-
- """
- key = "FunctionCall"
- arguments = [('module', amp.String()),
- ('function', amp.String()),
- ('args', amp.String()),
- ('kwargs', amp.String())]
- errors = {Exception: 'EXCEPTION'}
- response = [('result', amp.String())]
-
-
-# Helper functions for pickling.
-
-def dumps(data):
- return to_str(pickle.dumps(to_str(data), pickle.HIGHEST_PROTOCOL))
-
-
-def loads(data):
- return pickle.loads(to_str(data))
-
-
-# -------------------------------------------------------------
-# Core AMP protocol for communication Server <-> Portal
-# -------------------------------------------------------------
-
-class AMPProtocol(amp.AMP):
- """
- This is the protocol that the MUD server and the proxy server
- communicate to each other with. AMP is a bi-directional protocol,
- so both the proxy and the MUD use the same commands and protocol.
-
- AMP specifies responder methods here and connect them to
- amp.Command subclasses that specify the datatypes of the
- input/output of these methods.
-
- """
-
- # helper methods
-
- def __init__(self, *args, **kwargs):
- """
- Initialize protocol with some things that need to be in place
- already before connecting both on portal and server.
-
- """
- self.send_batch_counter = 0
- self.send_reset_time = time.time()
- self.send_mode = True
- self.send_task = None
-
- def connectionMade(self):
- """
- This is called when an AMP connection is (re-)established
- between server and portal. AMP calls it on both sides, so we
- need to make sure to only trigger resync from the portal side.
-
- """
- # this makes for a factor x10 faster sends across the wire
- self.transport.setTcpNoDelay(True)
-
- if hasattr(self.factory, "portal"):
- # only the portal has the 'portal' property, so we know we are
- # on the portal side and can initialize the connection.
- sessdata = self.factory.portal.sessions.get_all_sync_data()
- self.send_AdminPortal2Server(DUMMYSESSION,
- PSYNC,
- sessiondata=sessdata)
- self.factory.portal.sessions.at_server_connection()
- if hasattr(self.factory, "server_restart_mode"):
- del self.factory.server_restart_mode
-
- def connectionLost(self, reason):
- """
- We swallow connection errors here. The reason is that during a
- normal reload/shutdown there will almost always be cases where
- either the portal or server shuts down before a message has
- returned its (empty) return, triggering a connectionLost error
- that is irrelevant. If a true connection error happens, the
- portal will continuously try to reconnect, showing the problem
- that way.
- """
- pass
-
- # Error handling
-
- def errback(self, e, info):
- """
- Error callback.
- Handles errors to avoid dropping connections on server tracebacks.
-
- Args:
- e (Failure): Deferred error instance.
- info (str): Error string.
-
- """
- e.trap(Exception)
- logger.log_err("AMP Error for %(info)s: %(e)s" % {'info': info,
- 'e': e.getErrorMessage()})
-
- def send_data(self, command, sessid, **kwargs):
- """
- Send data across the wire.
-
- Args:
- command (AMP Command): A protocol send command.
- sessid (int): A unique Session id.
-
- Returns:
- deferred (deferred or None): A deferred with an errback.
-
- Notes:
- Data will be sent across the wire pickled as a tuple
- (sessid, kwargs).
-
- """
- return self.callRemote(command,
- packed_data=dumps((sessid, kwargs))
- ).addErrback(self.errback, command.key)
-
- # Message definition + helper methods to call/create each message type
-
- # Portal -> Server Msg
-
- @MsgPortal2Server.responder
- def server_receive_msgportal2server(self, packed_data):
- """
- Receives message arriving to server. This method is executed
- on the Server.
-
- Args:
- packed_data (str): Data to receive (a pickled tuple (sessid,kwargs))
-
- """
- sessid, kwargs = loads(packed_data)
- session = self.factory.server.sessions.get(sessid, None)
- if session:
- self.factory.server.sessions.data_in(session, **kwargs)
- return {}
-
- def send_MsgPortal2Server(self, session, **kwargs):
- """
- Access method called by the Portal and executed on the Portal.
-
- Args:
- session (session): Session
- kwargs (any, optional): Optional data.
-
- Returns:
- deferred (Deferred): Asynchronous return.
-
- """
- return self.send_data(MsgPortal2Server, session.sessid, **kwargs)
-
- # Server -> Portal message
-
- @MsgServer2Portal.responder
- def portal_receive_server2portal(self, packed_data):
- """
- Receives message arriving to Portal from Server.
- This method is executed on the Portal.
-
- Args:
- packed_data (str): Pickled data (sessid, kwargs) coming over the wire.
- """
- sessid, kwargs = loads(packed_data)
- session = self.factory.portal.sessions.get(sessid, None)
- if session:
- self.factory.portal.sessions.data_out(session, **kwargs)
- return {}
-
- def send_MsgServer2Portal(self, session, **kwargs):
- """
- Access method - executed on the Server for sending data
- to Portal.
-
- Args:
- session (Session): Unique Session.
- kwargs (any, optiona): Extra data.
-
- """
- return self.send_data(MsgServer2Portal, session.sessid, **kwargs)
-
- # Server administration from the Portal side
- @AdminPortal2Server.responder
- def server_receive_adminportal2server(self, packed_data):
- """
- Receives admin data from the Portal (allows the portal to
- perform admin operations on the server). This is executed on
- the Server.
-
- Args:
- packed_data (str): Incoming, pickled data.
-
- """
- sessid, kwargs = loads(packed_data)
- operation = kwargs.pop("operation", "")
- server_sessionhandler = self.factory.server.sessions
-
- if operation == PCONN: # portal_session_connect
- # create a new session and sync it
- server_sessionhandler.portal_connect(kwargs.get("sessiondata"))
-
- elif operation == PCONNSYNC: # portal_session_sync
- server_sessionhandler.portal_session_sync(kwargs.get("sessiondata"))
-
- elif operation == PDISCONN: # portal_session_disconnect
- # session closed from portal sid
- session = server_sessionhandler.get(sessid)
- if session:
- server_sessionhandler.portal_disconnect(session)
-
- elif operation == PDISCONNALL: # portal_disconnect_all
- # portal orders all sessions to close
- server_sessionhandler.portal_disconnect_all()
-
- elif operation == PSYNC: # portal_session_sync
- # force a resync of sessions when portal reconnects to
- # server (e.g. after a server reboot) the data kwarg
- # contains a dict {sessid: {arg1:val1,...}}
- # representing the attributes to sync for each
- # session.
- server_sessionhandler.portal_sessions_sync(kwargs.get("sessiondata"))
- else:
- raise Exception("operation %(op)s not recognized." % {'op': operation})
- return {}
-
- def send_AdminPortal2Server(self, session, operation="", **kwargs):
- """
- Send Admin instructions from the Portal to the Server.
- Executed
- on the Portal.
-
- Args:
- session (Session): Session.
- operation (char, optional): Identifier for the server operation, as defined by the
- global variables in `evennia/server/amp.py`.
- data (str or dict, optional): Data used in the administrative operation.
-
- """
- return self.send_data(AdminPortal2Server, session.sessid, operation=operation, **kwargs)
-
- # Portal administration from the Server side
-
- @AdminServer2Portal.responder
- def portal_receive_adminserver2portal(self, packed_data):
- """
-
- Receives and handles admin operations sent to the Portal
- This is executed on the Portal.
-
- Args:
- packed_data (str): Data received, a pickled tuple (sessid, kwargs).
-
- """
- sessid, kwargs = loads(packed_data)
- operation = kwargs.pop("operation")
- portal_sessionhandler = self.factory.portal.sessions
-
- if operation == SLOGIN: # server_session_login
- # a session has authenticated; sync it.
- session = portal_sessionhandler.get(sessid)
- if session:
- portal_sessionhandler.server_logged_in(session, kwargs.get("sessiondata"))
-
- elif operation == SDISCONN: # server_session_disconnect
- # the server is ordering to disconnect the session
- session = portal_sessionhandler.get(sessid)
- if session:
- portal_sessionhandler.server_disconnect(session, reason=kwargs.get("reason"))
-
- elif operation == SDISCONNALL: # server_session_disconnect_all
- # server orders all sessions to disconnect
- portal_sessionhandler.server_disconnect_all(reason=kwargs.get("reason"))
-
- elif operation == SSHUTD: # server_shutdown
- # the server orders the portal to shut down
- self.factory.portal.shutdown(restart=False)
-
- elif operation == SSYNC: # server_session_sync
- # server wants to save session data to the portal,
- # maybe because it's about to shut down.
- portal_sessionhandler.server_session_sync(kwargs.get("sessiondata"),
- kwargs.get("clean", True))
- # set a flag in case we are about to shut down soon
- self.factory.server_restart_mode = True
-
- elif operation == SCONN: # server_force_connection (for irc/etc)
- portal_sessionhandler.server_connect(**kwargs)
-
- else:
- raise Exception("operation %(op)s not recognized." % {'op': operation})
- return {}
-
- def send_AdminServer2Portal(self, session, operation="", **kwargs):
- """
- Administrative access method called by the Server to send an
- instruction to the Portal.
-
- Args:
- session (Session): Session.
- operation (char, optional): Identifier for the server
- operation, as defined by the global variables in
- `evennia/server/amp.py`.
- data (str or dict, optional): Data going into the adminstrative.
-
- """
- return self.send_data(AdminServer2Portal, session.sessid, operation=operation, **kwargs)
-
- # Extra functions
-
- @FunctionCall.responder
- def receive_functioncall(self, module, function, func_args, func_kwargs):
- """
- This allows Portal- and Server-process to call an arbitrary
- function in the other process. It is intended for use by
- plugin modules.
-
- Args:
- module (str or module): The module containing the
- `function` to call.
- function (str): The name of the function to call in
- `module`.
- func_args (str): Pickled args tuple for use in `function` call.
- func_kwargs (str): Pickled kwargs dict for use in `function` call.
-
- """
- args = loads(func_args)
- kwargs = loads(func_kwargs)
-
- # call the function (don't catch tracebacks here)
- result = variable_from_module(module, function)(*args, **kwargs)
-
- if isinstance(result, Deferred):
- # if result is a deferred, attach handler to properly
- # wrap the return value
- result.addCallback(lambda r: {"result": dumps(r)})
- return result
- else:
- return {'result': dumps(result)}
-
- def send_FunctionCall(self, modulepath, functionname, *args, **kwargs):
- """
- Access method called by either process. This will call an arbitrary
- function on the other process (On Portal if calling from Server and
- vice versa).
-
- Inputs:
- modulepath (str) - python path to module holding function to call
- functionname (str) - name of function in given module
- *args, **kwargs will be used as arguments/keyword args for the
- remote function call
- Returns:
- A deferred that fires with the return value of the remote
- function call
-
- """
- return self.callRemote(FunctionCall,
- module=modulepath,
- function=functionname,
- args=dumps(args),
- kwargs=dumps(kwargs)).addCallback(
- lambda r: loads(r["result"])).addErrback(self.errback, "FunctionCall")
diff --git a/evennia/server/amp_client.py b/evennia/server/amp_client.py
new file mode 100644
index 0000000000..a4300adf4d
--- /dev/null
+++ b/evennia/server/amp_client.py
@@ -0,0 +1,241 @@
+"""
+The Evennia Server service acts as an AMP-client when talking to the
+Portal. This module sets up the Client-side communication.
+
+"""
+
+import os
+from evennia.server.portal import amp
+from twisted.internet import protocol
+from evennia.utils import logger
+
+
+class AMPClientFactory(protocol.ReconnectingClientFactory):
+ """
+ This factory creates an instance of an AMP client connection. This handles communication from
+ the be the Evennia 'Server' service to the 'Portal'. The client will try to auto-reconnect on a
+ connection error.
+
+ """
+ # Initial reconnect delay in seconds.
+ initialDelay = 1
+ factor = 1.5
+ maxDelay = 1
+ noisy = False
+
+ def __init__(self, server):
+ """
+ Initializes the client factory.
+
+ Args:
+ server (server): server instance.
+
+ """
+ self.server = server
+ self.protocol = AMPServerClientProtocol
+ self.maxDelay = 10
+ # not really used unless connecting to multiple servers, but
+ # avoids having to check for its existence on the protocol
+ self.broadcasts = []
+
+ def startedConnecting(self, connector):
+ """
+ Called when starting to try to connect to the MUD server.
+
+ Args:
+ connector (Connector): Twisted Connector instance representing
+ this connection.
+
+ """
+ pass
+
+ def buildProtocol(self, addr):
+ """
+ Creates an AMPProtocol instance when connecting to the AMP server.
+
+ Args:
+ addr (str): Connection address. Not used.
+
+ """
+ self.resetDelay()
+ self.server.amp_protocol = AMPServerClientProtocol()
+ self.server.amp_protocol.factory = self
+ return self.server.amp_protocol
+
+ def clientConnectionLost(self, connector, reason):
+ """
+ Called when the AMP connection to the MUD server is lost.
+
+ Args:
+ connector (Connector): Twisted Connector instance representing
+ this connection.
+ reason (str): Eventual text describing why connection was lost.
+
+ """
+ logger.log_info("Server disconnected from the portal.")
+ protocol.ReconnectingClientFactory.clientConnectionLost(self, connector, reason)
+
+ def clientConnectionFailed(self, connector, reason):
+ """
+ Called when an AMP connection attempt to the MUD server fails.
+
+ Args:
+ connector (Connector): Twisted Connector instance representing
+ this connection.
+ reason (str): Eventual text describing why connection failed.
+
+ """
+ logger.log_msg("Attempting to reconnect to Portal ...")
+ protocol.ReconnectingClientFactory.clientConnectionFailed(self, connector, reason)
+
+
+class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol):
+ """
+ This protocol describes the Server service (acting as an AMP-client)'s communication with the
+ Portal (which acts as the AMP-server)
+
+ """
+ # sending AMP data
+
+ def connectionMade(self):
+ """
+ Called when a new connection is established.
+
+ """
+ info_dict = self.factory.server.get_info_dict()
+ super(AMPServerClientProtocol, self).connectionMade()
+ # first thing we do is to request the Portal to sync all sessions
+ # back with the Server side. We also need the startup mode (reload, reset, shutdown)
+ self.send_AdminServer2Portal(
+ amp.DUMMYSESSION, operation=amp.PSYNC, spid=os.getpid(), info_dict=info_dict)
+ # run the intial setup if needed
+ self.factory.server.run_initial_setup()
+
+ def data_to_portal(self, command, sessid, **kwargs):
+ """
+ Send data across the wire to the Portal
+
+ Args:
+ command (AMP Command): A protocol send command.
+ sessid (int): A unique Session id.
+ kwargs (any): Any data to pickle into the command.
+
+ Returns:
+ deferred (deferred or None): A deferred with an errback.
+
+ Notes:
+ Data will be sent across the wire pickled as a tuple
+ (sessid, kwargs).
+
+ """
+ return self.callRemote(command, packed_data=amp.dumps((sessid, kwargs))).addErrback(
+ self.errback, command.key)
+
+ def send_MsgServer2Portal(self, session, **kwargs):
+ """
+ Access method - executed on the Server for sending data
+ to Portal.
+
+ Args:
+ session (Session): Unique Session.
+ kwargs (any, optiona): Extra data.
+
+ """
+ return self.data_to_portal(amp.MsgServer2Portal, session.sessid, **kwargs)
+
+ def send_AdminServer2Portal(self, session, operation="", **kwargs):
+ """
+ Administrative access method called by the Server to send an
+ instruction to the Portal.
+
+ Args:
+ session (Session): Session.
+ operation (char, optional): Identifier for the server
+ operation, as defined by the global variables in
+ `evennia/server/amp.py`.
+ kwargs (dict, optional): Data going into the adminstrative.
+
+ """
+ return self.data_to_portal(amp.AdminServer2Portal, session.sessid,
+ operation=operation, **kwargs)
+
+ # receiving AMP data
+
+ @amp.MsgStatus.responder
+ def server_receive_status(self, question):
+ return {"status": "OK"}
+
+ @amp.MsgPortal2Server.responder
+ @amp.catch_traceback
+ def server_receive_msgportal2server(self, packed_data):
+ """
+ Receives message arriving to server. This method is executed
+ on the Server.
+
+ Args:
+ packed_data (str): Data to receive (a pickled tuple (sessid,kwargs))
+
+ """
+ sessid, kwargs = self.data_in(packed_data)
+ session = self.factory.server.sessions.get(sessid, None)
+ if session:
+ self.factory.server.sessions.data_in(session, **kwargs)
+ return {}
+
+ @amp.AdminPortal2Server.responder
+ @amp.catch_traceback
+ def server_receive_adminportal2server(self, packed_data):
+ """
+ Receives admin data from the Portal (allows the portal to
+ perform admin operations on the server). This is executed on
+ the Server.
+
+ Args:
+ packed_data (str): Incoming, pickled data.
+
+ """
+ sessid, kwargs = self.data_in(packed_data)
+ operation = kwargs.pop("operation", "")
+ server_sessionhandler = self.factory.server.sessions
+
+ if operation == amp.PCONN: # portal_session_connect
+ # create a new session and sync it
+ server_sessionhandler.portal_connect(kwargs.get("sessiondata"))
+
+ elif operation == amp.PCONNSYNC: # portal_session_sync
+ server_sessionhandler.portal_session_sync(kwargs.get("sessiondata"))
+
+ elif operation == amp.PDISCONN: # portal_session_disconnect
+ # session closed from portal sid
+ session = server_sessionhandler.get(sessid)
+ if session:
+ server_sessionhandler.portal_disconnect(session)
+
+ elif operation == amp.PDISCONNALL: # portal_disconnect_all
+ # portal orders all sessions to close
+ server_sessionhandler.portal_disconnect_all()
+
+ elif operation == amp.PSYNC: # portal_session_sync
+ # force a resync of sessions from the portal side. This happens on
+ # first server-connect.
+ server_restart_mode = kwargs.get("server_restart_mode", "shutdown")
+ self.factory.server.run_init_hooks(server_restart_mode)
+ server_sessionhandler.portal_sessions_sync(kwargs.get("sessiondata"))
+
+ elif operation == amp.SRELOAD: # server reload
+ # shut down in reload mode
+ server_sessionhandler.all_sessions_portal_sync()
+ server_sessionhandler.server.shutdown(mode='reload')
+
+ elif operation == amp.SRESET:
+ # shut down in reset mode
+ server_sessionhandler.all_sessions_portal_sync()
+ server_sessionhandler.server.shutdown(mode='reset')
+
+ elif operation == amp.SSHUTD: # server shutdown
+ # shutdown in stop mode
+ server_sessionhandler.server.shutdown(mode='shutdown')
+
+ else:
+ raise Exception("operation %(op)s not recognized." % {'op': operation})
+ return {}
diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py
index 9ccf664410..779a1e4aa2 100644
--- a/evennia/server/evennia_launcher.py
+++ b/evennia/server/evennia_launcher.py
@@ -1,12 +1,13 @@
-#!/usr/bin/env python
+#!/usr/bin/python
"""
-EVENNIA SERVER LAUNCHER SCRIPT
+Evennia launcher program
This is the start point for running Evennia.
-Sets the appropriate environmental variables and launches the server
-and portal through the evennia_runner. Run without arguments to get a
-menu. Run the script with the -h flag to see usage information.
+Sets the appropriate environmental variables for managing an Evennia game. It will start and connect
+to the Portal, through which the Server is also controlled. This pprogram
+
+Run the script with the -h flag to see usage information.
"""
from __future__ import print_function
@@ -19,7 +20,16 @@ import shutil
import importlib
from distutils.version import LooseVersion
from argparse import ArgumentParser
+import argparse
from subprocess import Popen, check_output, call, CalledProcessError, STDOUT
+
+try:
+ import cPickle as pickle
+except ImportError:
+ import pickle
+
+from twisted.protocols import amp
+from twisted.internet import reactor, endpoints
import django
# Signal processing
@@ -29,10 +39,9 @@ CTRL_C_EVENT = 0 # Windows SIGINT-like signal
# Set up the main python paths to Evennia
EVENNIA_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-import evennia
+import evennia # noqa
EVENNIA_LIB = os.path.join(os.path.dirname(os.path.abspath(evennia.__file__)))
EVENNIA_SERVER = os.path.join(EVENNIA_LIB, "server")
-EVENNIA_RUNNER = os.path.join(EVENNIA_SERVER, "evennia_runner.py")
EVENNIA_TEMPLATE = os.path.join(EVENNIA_LIB, "game_template")
EVENNIA_PROFILING = os.path.join(EVENNIA_SERVER, "profiling")
EVENNIA_DUMMYRUNNER = os.path.join(EVENNIA_PROFILING, "dummyrunner.py")
@@ -49,31 +58,53 @@ CURRENT_DIR = os.getcwd()
GAMEDIR = CURRENT_DIR
# Operational setup
+
SERVER_LOGFILE = None
PORTAL_LOGFILE = None
HTTP_LOGFILE = None
+
SERVER_PIDFILE = None
PORTAL_PIDFILE = None
-SERVER_RESTART = None
-PORTAL_RESTART = None
+
SERVER_PY_FILE = None
PORTAL_PY_FILE = None
+
+SPROFILER_LOGFILE = None
+PPROFILER_LOGFILE = None
+
TEST_MODE = False
ENFORCED_SETTING = False
+REACTOR_RUN = False
+NO_REACTOR_STOP = False
+
+# communication constants
+
+AMP_PORT = None
+AMP_HOST = None
+AMP_INTERFACE = None
+AMP_CONNECTION = None
+
+SRELOAD = chr(14) # server reloading (have portal start a new server)
+SSTART = chr(15) # server start
+PSHUTD = chr(16) # portal (+server) shutdown
+SSHUTD = chr(17) # server-only shutdown
+PSTATUS = chr(18) # ping server or portal status
+SRESET = chr(19) # shutdown server in reset mode
+
# requirements
PYTHON_MIN = '2.7'
-TWISTED_MIN = '16.0.0'
+TWISTED_MIN = '18.0.0'
DJANGO_MIN = '1.11'
DJANGO_REC = '1.11'
sys.path[1] = EVENNIA_ROOT
-#------------------------------------------------------------
+# ------------------------------------------------------------
#
# Messages
#
-#------------------------------------------------------------
+# ------------------------------------------------------------
CREATED_NEW_GAMEDIR = \
"""
@@ -107,6 +138,11 @@ ERROR_INPUT = \
raised an error: '{traceback}'.
"""
+ERROR_NO_ALT_GAMEDIR = \
+ """
+ The path '{gamedir}' could not be found.
+"""
+
ERROR_NO_GAMEDIR = \
"""
ERROR: No Evennia settings file was found. Evennia looks for the
@@ -230,12 +266,8 @@ INFO_WINDOWS_BATFILE = \
"""
CMDLINE_HELP = \
- """
- Starts or operates the Evennia MU* server. Allows for
- initializing a new game directory and manages the game's database.
- Most standard django-admin arguments and options can also be
- passed.
- """
+ """Starts, initializes, manages and operates the Evennia MU* server.
+Most standard django management commands are also accepted."""
VERSION_INFO = \
@@ -255,52 +287,60 @@ ABOUT_INFO = \
Web: http://www.evennia.com
Irc: #evennia on FreeNode
Forum: http://www.evennia.com/discussions
- Maintainer (2010-): Griatch (griatch AT gmail DOT com)
Maintainer (2006-10): Greg Taylor
+ Maintainer (2010-): Griatch (griatch AT gmail DOT com)
Use -h for command line options.
"""
HELP_ENTRY = \
"""
- Enter 'evennia -h' for command-line options.
+ Evennia has two processes, the 'Server' and the 'Portal'.
+ External users connect to the Portal while the Server runs the
+ game/database. Restarting the Server will refresh code but not
+ disconnect users.
- Use option (1) in a production environment. During development (2) is
- usually enough, portal debugging is usually only useful if you are
- adding new protocols or are debugging Evennia itself.
+ To start a new game, use 'evennia --init mygame'.
+ For more ways to operate and manage Evennia, see 'evennia -h'.
- Reload with (5) to update the server with your changes without
- disconnecting any accounts.
+ If you want to add unit tests to your game, see
+ https://github.com/evennia/evennia/wiki/Unit-Testing
- Note: Reload and stop are sometimes poorly supported in Windows. If you
- have issues, log into the game to stop or restart the server instead.
+ Evennia's manual is found here:
+ https://github.com/evennia/evennia/wiki
"""
MENU = \
"""
+----Evennia Launcher-------------------------------------------+
- | |
- +--- Starting --------------------------------------------------+
- | |
- | 1) (normal): All output to logfiles |
- | 2) (server devel): Server logs to terminal (-i option) |
- | 3) (portal devel): Portal logs to terminal |
- | 4) (full devel): Both Server and Portal logs to terminal |
- | |
- +--- Restarting ------------------------------------------------+
- | |
- | 5) Reload the Server |
- | 6) Reload the Portal (only works with portal/full debug) |
- | |
- +--- Stopping --------------------------------------------------+
- | |
- | 7) Stopping both Portal and Server |
- | 8) Stopping only Server |
- | 9) Stopping only Portal |
- | |
- +---------------------------------------------------------------+
- | h) Help i) About info q) Abort |
+ {gameinfo}
+ +--- Common operations -----------------------------------------+
+ | 1) Start (also restart stopped Server) |
+ | 2) Reload (stop/start Server in 'reload' mode) |
+ | 3) Stop (shutdown Portal and Server) |
+ | 4) Reboot (shutdown then restart) |
+ +--- Other operations ------------------------------------------+
+ | 5) Reset (stop/start Server in 'shutdown' mode) |
+ | 6) Stop Server only |
+ | 7) Kill Server only (send kill signal to process) |
+ | 8) Kill Portal + Server |
+ +--- Information -----------------------------------------------+
+ | 9) Tail log files (quickly see errors) |
+ | 10) Status |
+ | 11) Port info |
+ +--- Testing ---------------------------------------------------+
+ | 12) Test gamedir (run gamedir test suite, if any) |
+ | 13) Test Evennia (run Evennia test suite) |
+---------------------------------------------------------------+
+ | h) Help i) About info q) Abort |
+ +---------------------------------------------------------------+"""
+
+ERROR_AMP_UNCONFIGURED = \
+ """
+ Can't find server info for connecting. Either run this command from
+ the game dir (it will then use the game's settings file) or specify
+ the path to your game's settings file manually with the --settings
+ option.
"""
ERROR_LOGDIR_MISSING = \
@@ -319,7 +359,6 @@ ERROR_LOGDIR_MISSING = \
you used git to clone a pre-created game directory - since log
files are in .gitignore they will not be cloned, which leads to
the log directory also not being created.)
-
"""
ERROR_PYTHON_VERSION = \
@@ -391,11 +430,759 @@ NOTE_TEST_CUSTOM = \
on the game dir.)
"""
-#------------------------------------------------------------
+PROCESS_ERROR = \
+ """
+ {component} process error: {traceback}.
+ """
+
+PORTAL_INFO = \
+ """{servername} Portal {version}
+ external ports:
+ {telnet}
+ {telnet_ssl}
+ {ssh}
+ {webserver_proxy}
+ {webclient}
+ internal_ports (to Server):
+ {webserver_internal}
+ {amp}
+"""
+
+
+SERVER_INFO = \
+ """{servername} Server {version}
+ internal ports (to Portal):
+ {webserver}
+ {amp}
+ {irc_rss}
+ {info}
+ {errors}"""
+
+
+ARG_OPTIONS = \
+ """Actions on installed server. One of:
+ start - launch server+portal if not running
+ reload - restart server in 'reload' mode
+ stop - shutdown server+portal
+ reboot - shutdown server+portal, then start again
+ reset - restart server in 'shutdown' mode
+ istart - start server in foreground (until reload)
+ ipstart - start portal in foreground
+ sstop - stop only server
+ kill - send kill signal to portal+server (force)
+ skill - send kill signal only to server
+ status - show server and portal run state
+ info - show server and portal port info
+ menu - show a menu of options
+Others, like migrate, test and shell is passed on to Django."""
+
+# ------------------------------------------------------------
#
-# Functions
+# Private helper functions
#
-#------------------------------------------------------------
+# ------------------------------------------------------------
+
+
+def _is_windows():
+ return os.name == 'nt'
+
+
+def _file_names_compact(filepath1, filepath2):
+ "Compact the output of filenames with same base dir"
+ dirname1 = os.path.dirname(filepath1)
+ dirname2 = os.path.dirname(filepath2)
+ if dirname1 == dirname2:
+ name2 = os.path.basename(filepath2)
+ return "{} and {}".format(filepath1, name2)
+ else:
+ return "{} and {}". format(filepath1, filepath2)
+
+
+def _print_info(portal_info_dict, server_info_dict):
+ """
+ Format info dicts from the Portal/Server for display
+
+ """
+ ind = " " * 8
+
+ def _prepare_dict(dct):
+ out = {}
+ for key, value in dct.iteritems():
+ if isinstance(value, list):
+ value = "\n{}".format(ind).join(value)
+ out[key] = value
+ return out
+
+ def _strip_empty_lines(string):
+ return "\n".join(line for line in string.split("\n") if line.strip())
+
+ pstr, sstr = "", ""
+ if portal_info_dict:
+ pdict = _prepare_dict(portal_info_dict)
+ pstr = _strip_empty_lines(PORTAL_INFO.format(**pdict))
+
+ if server_info_dict:
+ sdict = _prepare_dict(server_info_dict)
+ sstr = _strip_empty_lines(SERVER_INFO.format(**sdict))
+
+ info = pstr + ("\n\n" + sstr if sstr else "")
+ maxwidth = max(len(line) for line in info.split("\n"))
+ top_border = "-" * (maxwidth - 11) + " Evennia " + "---"
+ border = "-" * (maxwidth + 1)
+ print(top_border + "\n" + info + '\n' + border)
+
+
+def _parse_status(response):
+ "Unpack the status information"
+ return pickle.loads(response['status'])
+
+
+def _get_twistd_cmdline(pprofiler, sprofiler):
+ """
+ Compile the command line for starting a Twisted application using the 'twistd' executable.
+
+ """
+ portal_cmd = [TWISTED_BINARY,
+ "--python={}".format(PORTAL_PY_FILE)]
+ server_cmd = [TWISTED_BINARY,
+ "--python={}".format(SERVER_PY_FILE)]
+
+ if os.name != 'nt':
+ # PID files only for UNIX
+ portal_cmd.append("--pidfile={}".format(PORTAL_PIDFILE))
+ server_cmd.append("--pidfile={}".format(SERVER_PIDFILE))
+
+ if pprofiler:
+ portal_cmd.extend(["--savestats",
+ "--profiler=cprofile",
+ "--profile={}".format(PPROFILER_LOGFILE)])
+ if sprofiler:
+ server_cmd.extend(["--savestats",
+ "--profiler=cprofile",
+ "--profile={}".format(SPROFILER_LOGFILE)])
+
+ return portal_cmd, server_cmd
+
+
+def _reactor_stop():
+ if not NO_REACTOR_STOP:
+ reactor.stop()
+
+
+# ------------------------------------------------------------
+#
+# Protocol Evennia launcher - Portal/Server communication
+#
+# ------------------------------------------------------------
+
+
+class MsgStatus(amp.Command):
+ """
+ Ping between AMP services
+
+ """
+ key = "MsgStatus"
+ arguments = [('status', amp.String())]
+ errors = {Exception: 'EXCEPTION'}
+ response = [('status', amp.String())]
+
+
+class MsgLauncher2Portal(amp.Command):
+ """
+ Message Launcher -> Portal
+
+ """
+ key = "MsgLauncher2Portal"
+ arguments = [('operation', amp.String()),
+ ('arguments', amp.String())]
+ errors = {Exception: 'EXCEPTION'}
+ response = []
+
+
+class AMPLauncherProtocol(amp.AMP):
+ """
+ Defines callbacks to the launcher
+
+ """
+ def __init__(self):
+ self.on_status = []
+
+ def wait_for_status(self, callback):
+ """
+ Register a waiter for a status return.
+
+ """
+ self.on_status.append(callback)
+
+ @MsgStatus.responder
+ def receive_status_from_portal(self, status):
+ """
+ Get a status signal from portal - fire next queued
+ callback
+
+ """
+ try:
+ callback = self.on_status.pop()
+ except IndexError:
+ pass
+ else:
+ status = pickle.loads(status)
+ callback(status)
+ return {"status": ""}
+
+
+def send_instruction(operation, arguments, callback=None, errback=None):
+ """
+ Send instruction and handle the response.
+
+ """
+ global AMP_CONNECTION, REACTOR_RUN
+
+ if None in (AMP_HOST, AMP_PORT, AMP_INTERFACE):
+ print(ERROR_AMP_UNCONFIGURED)
+ sys.exit()
+
+ def _callback(result):
+ if callback:
+ callback(result)
+
+ def _errback(fail):
+ if errback:
+ errback(fail)
+
+ def _on_connect(prot):
+ """
+ This fires with the protocol when connection is established. We
+ immediately send off the instruction
+
+ """
+ global AMP_CONNECTION
+ AMP_CONNECTION = prot
+ _send()
+
+ def _on_connect_fail(fail):
+ "This is called if portal is not reachable."
+ errback(fail)
+
+ def _send():
+ if operation == PSTATUS:
+ return AMP_CONNECTION.callRemote(MsgStatus, status="").addCallbacks(_callback, _errback)
+ else:
+ return AMP_CONNECTION.callRemote(
+ MsgLauncher2Portal,
+ operation=operation,
+ arguments=pickle.dumps(arguments, pickle.HIGHEST_PROTOCOL)).addCallbacks(
+ _callback, _errback)
+
+ if AMP_CONNECTION:
+ # already connected - send right away
+ _send()
+ else:
+ # we must connect first, send once connected
+ point = endpoints.TCP4ClientEndpoint(reactor, AMP_HOST, AMP_PORT)
+ deferred = endpoints.connectProtocol(point, AMPLauncherProtocol())
+ deferred.addCallbacks(_on_connect, _on_connect_fail)
+ REACTOR_RUN = True
+
+
+def query_status(callback=None):
+ """
+ Send status ping to portal
+
+ """
+ wmap = {True: "RUNNING",
+ False: "NOT RUNNING"}
+
+ def _callback(response):
+ if callback:
+ callback(response)
+ else:
+ pstatus, sstatus, ppid, spid, pinfo, sinfo = _parse_status(response)
+ print("Portal: {}{}\nServer: {}{}".format(
+ wmap[pstatus], " (pid {})".format(
+ get_pid(PORTAL_PIDFILE, ppid)) if pstatus else "",
+ wmap[sstatus], " (pid {})".format(
+ get_pid(SERVER_PIDFILE, spid)) if sstatus else ""))
+ _reactor_stop()
+
+ def _errback(fail):
+ pstatus, sstatus = False, False
+ print("Portal: {}\nServer: {}".format(wmap[pstatus], wmap[sstatus]))
+ _reactor_stop()
+
+ send_instruction(PSTATUS, None, _callback, _errback)
+
+
+def wait_for_status_reply(callback):
+ """
+ Wait for an explicit STATUS signal to be sent back from Evennia.
+ """
+ if AMP_CONNECTION:
+ AMP_CONNECTION.wait_for_status(callback)
+ else:
+ print("No Evennia connection established.")
+
+
+def wait_for_status(portal_running=True, server_running=True, callback=None, errback=None,
+ rate=0.5, retries=20):
+ """
+ Repeat the status ping until the desired state combination is achieved.
+
+ Args:
+ portal_running (bool or None): Desired portal run-state. If None, any state
+ is accepted.
+ server_running (bool or None): Desired server run-state. If None, any state
+ is accepted. The portal must be running.
+ callback (callable): Will be called with portal_state, server_state when
+ condition is fulfilled.
+ errback (callable): Will be called with portal_state, server_state if the
+ request is timed out.
+ rate (float): How often to retry.
+ retries (int): How many times to retry before timing out and calling `errback`.
+ """
+ def _callback(response):
+ prun, srun, _, _, _, _ = _parse_status(response)
+ if ((portal_running is None or prun == portal_running) and
+ (server_running is None or srun == server_running)):
+ # the correct state was achieved
+ if callback:
+ callback(prun, srun)
+ else:
+ _reactor_stop()
+ else:
+ if retries <= 0:
+ if errback:
+ errback(prun, srun)
+ else:
+ print("Connection to Evennia timed out. Try again.")
+ _reactor_stop()
+ else:
+ reactor.callLater(rate, wait_for_status,
+ portal_running, server_running,
+ callback, errback, rate, retries - 1)
+
+ def _errback(fail):
+ """
+ Portal not running
+ """
+ if not portal_running:
+ # this is what we want
+ if callback:
+ callback(portal_running, server_running)
+ else:
+ _reactor_stop()
+ else:
+ if retries <= 0:
+ if errback:
+ errback(portal_running, server_running)
+ else:
+ print("Connection to Evennia timed out. Try again.")
+ _reactor_stop()
+ else:
+ reactor.callLater(rate, wait_for_status,
+ portal_running, server_running,
+ callback, errback, rate, retries - 1)
+
+ return send_instruction(PSTATUS, None, _callback, _errback)
+
+
+# ------------------------------------------------------------
+#
+# Operational functions
+#
+# ------------------------------------------------------------
+
+def start_evennia(pprofiler=False, sprofiler=False):
+ """
+ This will start Evennia anew by launching the Evennia Portal (which in turn
+ will start the Server)
+
+ """
+ portal_cmd, server_cmd = _get_twistd_cmdline(pprofiler, sprofiler)
+
+ def _fail(fail):
+ print(fail)
+ _reactor_stop()
+
+ def _server_started(response):
+ print("... Server started.\nEvennia running.")
+ if response:
+ _, _, _, _, pinfo, sinfo = response
+ _print_info(pinfo, sinfo)
+ _reactor_stop()
+
+ def _portal_started(*args):
+ print("... Portal started.\nServer starting {} ...".format(
+ "(under cProfile)" if sprofiler else ""))
+ wait_for_status_reply(_server_started)
+ send_instruction(SSTART, server_cmd)
+
+ def _portal_running(response):
+ prun, srun, ppid, spid, _, _ = _parse_status(response)
+ print("Portal is already running as process {pid}. Not restarted.".format(pid=ppid))
+ if srun:
+ print("Server is already running as process {pid}. Not restarted.".format(pid=spid))
+ _reactor_stop()
+ else:
+ print("Server starting {}...".format("(under cProfile)" if sprofiler else ""))
+ send_instruction(SSTART, server_cmd, _server_started, _fail)
+
+ def _portal_not_running(fail):
+ print("Portal starting {}...".format("(under cProfile)" if pprofiler else ""))
+ try:
+ if _is_windows():
+ # Windows requires special care
+ create_no_window = 0x08000000
+ Popen(portal_cmd, env=getenv(), bufsize=-1,
+ creationflags=create_no_window)
+ else:
+ Popen(portal_cmd, env=getenv(), bufsize=-1)
+ except Exception as e:
+ print(PROCESS_ERROR.format(component="Portal", traceback=e))
+ _reactor_stop()
+ wait_for_status(True, None, _portal_started)
+
+ send_instruction(PSTATUS, None, _portal_running, _portal_not_running)
+
+
+def reload_evennia(sprofiler=False, reset=False):
+ """
+ This will instruct the Portal to reboot the Server component. We
+ do this manually by telling the server to shutdown (in reload mode)
+ and wait for the portal to report back, at which point we start the
+ server again. This way we control the process exactly.
+
+ """
+ _, server_cmd = _get_twistd_cmdline(False, sprofiler)
+
+ def _server_restarted(*args):
+ print("... Server re-started.")
+ _reactor_stop()
+
+ def _server_reloaded(status):
+ print("... Server {}.".format("reset" if reset else "reloaded"))
+ _reactor_stop()
+
+ def _server_stopped(status):
+ wait_for_status_reply(_server_reloaded)
+ send_instruction(SSTART, server_cmd)
+
+ def _portal_running(response):
+ _, srun, _, _, _, _ = _parse_status(response)
+ if srun:
+ print("Server {}...".format("resetting" if reset else "reloading"))
+ wait_for_status_reply(_server_stopped)
+ send_instruction(SRESET if reset else SRELOAD, {})
+ else:
+ print("Server down. Re-starting ...")
+ wait_for_status_reply(_server_restarted)
+ send_instruction(SSTART, server_cmd)
+
+ def _portal_not_running(fail):
+ print("Evennia not running. Starting up ...")
+ start_evennia()
+
+ send_instruction(PSTATUS, None, _portal_running, _portal_not_running)
+
+
+def stop_evennia():
+ """
+ This instructs the Portal to stop the Server and then itself.
+
+ """
+ def _portal_stopped(*args):
+ print("... Portal stopped.\nEvennia shut down.")
+ _reactor_stop()
+
+ def _server_stopped(*args):
+ print("... Server stopped.\nStopping Portal ...")
+ send_instruction(PSHUTD, {})
+ wait_for_status(False, None, _portal_stopped)
+
+ def _portal_running(response):
+ prun, srun, ppid, spid, _, _ = _parse_status(response)
+ if srun:
+ print("Server stopping ...")
+ send_instruction(SSHUTD, {})
+ wait_for_status_reply(_server_stopped)
+ else:
+ print("Server already stopped.\nStopping Portal ...")
+ send_instruction(PSHUTD, {})
+ wait_for_status(False, None, _portal_stopped)
+
+ def _portal_not_running(fail):
+ print("Evennia not running.")
+ _reactor_stop()
+
+ send_instruction(PSTATUS, None, _portal_running, _portal_not_running)
+
+
+def reboot_evennia(pprofiler=False, sprofiler=False):
+ """
+ This is essentially an evennia stop && evennia start except we make sure
+ the system has successfully shut down before starting it again.
+
+ If evennia was not running, start it.
+
+ """
+ global AMP_CONNECTION
+
+ def _portal_stopped(*args):
+ print("... Portal stopped. Evennia shut down. Rebooting ...")
+ global AMP_CONNECTION
+ AMP_CONNECTION = None
+ start_evennia(pprofiler, sprofiler)
+
+ def _server_stopped(*args):
+ print("... Server stopped.\nStopping Portal ...")
+ send_instruction(PSHUTD, {})
+ wait_for_status(False, None, _portal_stopped)
+
+ def _portal_running(response):
+ prun, srun, ppid, spid, _, _ = _parse_status(response)
+ if srun:
+ print("Server stopping ...")
+ send_instruction(SSHUTD, {})
+ wait_for_status_reply(_server_stopped)
+ else:
+ print("Server already stopped.\nStopping Portal ...")
+ send_instruction(PSHUTD, {})
+ wait_for_status(False, None, _portal_stopped)
+
+ def _portal_not_running(fail):
+ print("Evennia not running. Starting up ...")
+ start_evennia()
+
+ send_instruction(PSTATUS, None, _portal_running, _portal_not_running)
+
+
+def start_server_interactive():
+ """
+ Start the Server under control of the launcher process (foreground)
+
+ """
+ def _iserver():
+ _, server_twistd_cmd = _get_twistd_cmdline(False, False)
+ server_twistd_cmd.append("--nodaemon")
+ print("Starting Server in interactive mode (stop with Ctrl-C)...")
+ try:
+ Popen(server_twistd_cmd, env=getenv(), stderr=STDOUT).wait()
+ except KeyboardInterrupt:
+ print("... Stopped Server with Ctrl-C.")
+ else:
+ print("... Server stopped (leaving interactive mode).")
+ stop_server_only(when_stopped=_iserver, interactive=True)
+
+
+def start_portal_interactive():
+ """
+ Start the Portal under control of the launcher process (foreground)
+
+ Notes:
+ In a normal start, the launcher waits for the Portal to start, then
+ tells it to start the Server. Since we can't do this here, we instead
+ start the Server first and then starts the Portal - the Server will
+ auto-reconnect to the Portal. To allow the Server to be reloaded, this
+ relies on a fixed server server-cmdline stored as a fallback on the
+ portal application in evennia/server/portal/portal.py.
+
+ """
+ def _iportal(fail):
+ portal_twistd_cmd, server_twistd_cmd = _get_twistd_cmdline(False, False)
+ portal_twistd_cmd.append("--nodaemon")
+
+ # starting Server first - it will auto-connect once Portal comes up
+ if _is_windows():
+ # Windows requires special care
+ create_no_window = 0x08000000
+ Popen(server_twistd_cmd, env=getenv(), bufsize=-1,
+ creationflags=create_no_window)
+ else:
+ Popen(server_twistd_cmd, env=getenv(), bufsize=-1)
+
+ print("Starting Portal in interactive mode (stop with Ctrl-C)...")
+ try:
+ Popen(portal_twistd_cmd, env=getenv(), stderr=STDOUT).wait()
+ except KeyboardInterrupt:
+ print("... Stopped Portal with Ctrl-C.")
+ else:
+ print("... Portal stopped (leaving interactive mode).")
+
+ def _portal_running(response):
+ print("Evennia must be shut down completely before running Portal in interactive mode.")
+ _reactor_stop()
+
+ send_instruction(PSTATUS, None, _portal_running, _iportal)
+
+
+def stop_server_only(when_stopped=None, interactive=False):
+ """
+ Only stop the Server-component of Evennia (this is not useful except for debug)
+
+ Args:
+ when_stopped (callable): This will be called with no arguments when Server has stopped (or
+ if it had already stopped when this is called).
+ interactive (bool, optional): Set if this is called as part of the interactive reload
+ mechanism.
+
+ """
+ def _server_stopped(*args):
+ if when_stopped:
+ when_stopped()
+ else:
+ print("... Server stopped.")
+ _reactor_stop()
+
+ def _portal_running(response):
+ _, srun, _, _, _, _ = _parse_status(response)
+ if srun:
+ print("Server stopping ...")
+ wait_for_status_reply(_server_stopped)
+ if interactive:
+ send_instruction(SRELOAD, {})
+ else:
+ send_instruction(SSHUTD, {})
+ else:
+ if when_stopped:
+ when_stopped()
+ else:
+ print("Server is not running.")
+ _reactor_stop()
+
+ def _portal_not_running(fail):
+ print("Evennia is not running.")
+ if interactive:
+ print("Start Evennia normally first, then use `istart` to switch to interactive mode.")
+ _reactor_stop()
+
+ send_instruction(PSTATUS, None, _portal_running, _portal_not_running)
+
+
+def query_info():
+ """
+ Display the info strings from the running Evennia
+
+ """
+ def _got_status(status):
+ _, _, _, _, pinfo, sinfo = _parse_status(status)
+ _print_info(pinfo, sinfo)
+ _reactor_stop()
+
+ def _portal_running(response):
+ query_status(_got_status)
+
+ def _portal_not_running(fail):
+ print("Evennia is not running.")
+
+ send_instruction(PSTATUS, None, _portal_running, _portal_not_running)
+
+
+def tail_log_files(filename1, filename2, start_lines1=20, start_lines2=20, rate=1):
+ """
+ Tail two logfiles interactively, combining their output to stdout
+
+ When first starting, this will display the tail of the log files. After
+ that it will poll the log files repeatedly and display changes.
+
+ Args:
+ filename1 (str): Path to first log file.
+ filename2 (str): Path to second log file.
+ start_lines1 (int): How many lines to show from existing first log.
+ start_lines2 (int): How many lines to show from existing second log.
+ rate (int, optional): How often to poll the log file.
+
+ """
+ global REACTOR_RUN
+
+ def _file_changed(filename, prev_size):
+ "Get size of file in bytes, get diff compared with previous size"
+ new_size = os.path.getsize(filename)
+ return new_size != prev_size, new_size
+
+ def _get_new_lines(filehandle, old_linecount):
+ "count lines, get the ones not counted before"
+
+ def _block(filehandle, size=65536):
+ "File block generator for quick traversal"
+ while True:
+ dat = filehandle.read(size)
+ if not dat:
+ break
+ yield dat
+
+ # count number of lines in file
+ new_linecount = sum(blck.count("\n") for blck in _block(filehandle))
+
+ if new_linecount < old_linecount:
+ # 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))
+ new_linecount = 0
+ old_linecount = 0
+
+ lines_to_get = max(0, new_linecount - old_linecount)
+
+ if not lines_to_get:
+ return [], old_linecount
+
+ lines_found = []
+ buffer_size = 4098
+ block_count = -1
+
+ while len(lines_found) < lines_to_get:
+ try:
+ # scan backwards in file, starting from the end
+ filehandle.seek(block_count * buffer_size, os.SEEK_END)
+ except IOError:
+ # file too small for current seek, include entire file
+ filehandle.seek(0)
+ lines_found = filehandle.readlines()
+ break
+ lines_found = filehandle.readlines()
+ block_count -= 1
+
+ # only actually return the new lines
+ return lines_found[-lines_to_get:], new_linecount
+
+ def _tail_file(filename, file_size, line_count, max_lines=None):
+ """This will cycle repeatedly, printing new lines"""
+
+ # poll for changes
+ has_changed, file_size = _file_changed(filename, file_size)
+
+ if has_changed:
+ try:
+ with open(filename, 'r') as filehandle:
+ new_lines, line_count = _get_new_lines(filehandle, line_count)
+ except IOError:
+ # the log file might not exist yet. Wait a little, then try again ...
+ pass
+ else:
+ if max_lines == 0:
+ # don't show any lines from old file
+ new_lines = []
+ elif max_lines:
+ # show some lines from first startup
+ new_lines = new_lines[-max_lines:]
+
+ # print to stdout without line break (log has its own line feeds)
+ sys.stdout.write("".join(new_lines))
+ sys.stdout.flush()
+
+ # set up the next poll
+ reactor.callLater(rate, _tail_file, filename, file_size, line_count, max_lines=100)
+
+ reactor.callLater(0, _tail_file, filename1, 0, 0, max_lines=start_lines1)
+ reactor.callLater(0, _tail_file, filename2, 0, 0, max_lines=start_lines2)
+
+ REACTOR_RUN = True
+
+
+# ------------------------------------------------------------
+#
+# Environment setup
+#
+# ------------------------------------------------------------
def evennia_version():
@@ -477,13 +1264,15 @@ def check_main_evennia_dependencies():
def set_gamedir(path):
"""
Set GAMEDIR based on path, by figuring out where the setting file
- is inside the directory tree.
+ is inside the directory tree. This allows for running the launcher
+ from elsewhere than the top of the gamedir folder.
"""
global GAMEDIR
Ndepth = 10
settings_path = os.path.join("server", "conf", "settings.py")
+ os.chdir(GAMEDIR)
for i in range(Ndepth):
gpath = os.getcwd()
if "server" in os.listdir(gpath):
@@ -537,10 +1326,10 @@ def create_settings_file(init=True, secret_settings=False):
if os.path.exists(settings_path):
inp = input("%s already exists. Do you want to reset it? y/[N]> " % settings_path)
if not inp.lower() == 'y':
- print ("Aborted.")
+ print("Aborted.")
return
else:
- print ("Reset the settings file.")
+ print("Reset the settings file.")
default_settings_path = os.path.join(EVENNIA_TEMPLATE, "server", "conf", "settings.py")
shutil.copy(default_settings_path, settings_path)
@@ -657,28 +1446,29 @@ def getenv():
env (dict): Environment global dict.
"""
- sep = ";" if os.name == 'nt' else ":"
+ sep = ";" if _is_windows() else ":"
env = os.environ.copy()
env['PYTHONPATH'] = sep.join(sys.path)
return env
-def get_pid(pidfile):
+def get_pid(pidfile, default=None):
"""
Get the PID (Process ID) by trying to access an PID file.
Args:
pidfile (str): The path of the pid file.
+ default (int, optional): What to return if file does not exist.
Returns:
- pid (str or None): The process id.
+ pid (str): The process id or `default`.
"""
if os.path.exists(pidfile):
with open(pidfile, 'r') as f:
pid = f.read()
return pid
- return None
+ return default
def del_pid(pidfile):
@@ -695,60 +1485,65 @@ def del_pid(pidfile):
os.remove(pidfile)
-def kill(pidfile, killsignal=SIG, succmsg="", errmsg="",
- restart_file=SERVER_RESTART, restart=False):
+def kill(pidfile, component='Server', callback=None, errback=None, killsignal=SIG):
"""
Send a kill signal to a process based on PID. A customized
success/error message will be returned. If clean=True, the system
- will attempt to manually remove the pid file.
+ will attempt to manually remove the pid file. On Windows, no arguments
+ are useful since Windows has no ability to direct signals except to all
+ children of a console.
Args:
- pidfile (str): The path of the pidfile to get the PID from.
- killsignal (int, optional): Signal identifier for signal to send.
- succmsg (str, optional): Message to log on success.
- errmsg (str, optional): Message to log on failure.
- restart_file (str, optional): Restart file location.
- restart (bool, optional): Are we in restart mode or not.
+ pidfile (str): The path of the pidfile to get the PID from. This is ignored
+ on Windows.
+ component (str, optional): Usually one of 'Server' or 'Portal'. This is
+ ignored on Windows.
+ errback (callable, optional): Called if signal failed to send. This
+ is ignored on Windows.
+ callback (callable, optional): Called if kill signal was sent successfully.
+ This is ignored on Windows.
+ killsignal (int, optional): Signal identifier for signal to send. This is
+ Ignored on Windows.
"""
- pid = get_pid(pidfile)
- if pid:
- if os.name == 'nt':
- os.remove(pidfile)
- # set restart/norestart flag
- if restart:
- django.core.management.call_command(
- 'collectstatic', interactive=False, verbosity=0)
- with open(restart_file, 'w') as f:
- f.write("reload")
- else:
- with open(restart_file, 'w') as f:
- f.write("shutdown")
+ if _is_windows():
+ # Windows signal sending is very limited.
+ from win32api import GenerateConsoleCtrlEvent, SetConsoleCtrlHandler
try:
- if os.name == 'nt':
- from win32api import GenerateConsoleCtrlEvent, SetConsoleCtrlHandler
- try:
- # Windows can only send a SIGINT-like signal to
- # *every* process spawned off the same console, so we must
- # avoid killing ourselves here.
- SetConsoleCtrlHandler(None, True)
- GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0)
- except KeyboardInterrupt:
- # We must catch and ignore the interrupt sent.
- pass
- else:
- # Linux can send the SIGINT signal directly
- # to the specified PID.
- os.kill(int(pid), killsignal)
+ # Windows can only send a SIGINT-like signal to
+ # *every* process spawned off the same console, so we must
+ # avoid killing ourselves here.
+ SetConsoleCtrlHandler(None, True)
+ GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0)
+ except KeyboardInterrupt:
+ # We must catch and ignore the interrupt sent.
+ pass
+ print("Sent kill signal to all spawned processes")
- except OSError:
- print("Process %(pid)s cannot be stopped. "
- "The PID file 'server/%(pidfile)s' seems stale. "
- "Try removing it." % {'pid': pid, 'pidfile': pidfile})
- return
- print("Evennia:", succmsg)
- return
- print("Evennia:", errmsg)
+ else:
+ # Linux/Unix/Mac can send kill signal directly to specific PIDs.
+ pid = get_pid(pidfile)
+ if pid:
+ if _is_windows():
+ os.remove(pidfile)
+ try:
+ os.kill(int(pid), killsignal)
+ except OSError:
+ print("{component} ({pid}) cannot be stopped. "
+ "The PID file '{pidfile}' seems stale. "
+ "Try removing it manually.".format(
+ component=component, pid=pid, pidfile=pidfile))
+ return
+ if callback:
+ callback()
+ else:
+ print("Sent kill signal to {component}.".format(component=component))
+ return
+ if errback:
+ errback()
+ else:
+ print("Could not send kill signal - {component} does "
+ "not appear to be running.".format(component=component))
def show_version_info(about=False):
@@ -804,7 +1599,7 @@ def error_check_python_modules():
_imp(settings.COMMAND_PARSER)
_imp(settings.SEARCH_AT_RESULT)
_imp(settings.CONNECTION_SCREEN_MODULE)
- #imp(settings.AT_INITIAL_SETUP_HOOK_MODULE, split=False)
+ # imp(settings.AT_INITIAL_SETUP_HOOK_MODULE, split=False)
for path in settings.LOCK_FUNC_MODULES:
_imp(path, split=False)
@@ -824,6 +1619,12 @@ def error_check_python_modules():
_imp(settings.BASE_SCRIPT_TYPECLASS)
+# ------------------------------------------------------------
+#
+# Options
+#
+# ------------------------------------------------------------
+
def init_game_directory(path, check_db=True):
"""
Try to analyze the given path to find settings.py - this defines
@@ -869,20 +1670,25 @@ def init_game_directory(path, check_db=True):
check_database()
# set up the Evennia executables and log file locations
+ global AMP_PORT, AMP_HOST, AMP_INTERFACE
global SERVER_PY_FILE, PORTAL_PY_FILE
global SERVER_LOGFILE, PORTAL_LOGFILE, HTTP_LOGFILE
global SERVER_PIDFILE, PORTAL_PIDFILE
- global SERVER_RESTART, PORTAL_RESTART
+ global SPROFILER_LOGFILE, PPROFILER_LOGFILE
global EVENNIA_VERSION
+ AMP_PORT = settings.AMP_PORT
+ AMP_HOST = settings.AMP_HOST
+ AMP_INTERFACE = settings.AMP_INTERFACE
+
SERVER_PY_FILE = os.path.join(EVENNIA_LIB, "server", "server.py")
- PORTAL_PY_FILE = os.path.join(EVENNIA_LIB, "portal", "portal", "portal.py")
+ PORTAL_PY_FILE = os.path.join(EVENNIA_LIB, "server", "portal", "portal.py")
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")
+ SPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "server.prof")
+ PPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "portal.prof")
SERVER_LOGFILE = settings.SERVER_LOG_FILE
PORTAL_LOGFILE = settings.PORTAL_LOG_FILE
@@ -898,7 +1704,7 @@ def init_game_directory(path, check_db=True):
print(ERROR_LOGDIR_MISSING.format(logfiles=errstr))
sys.exit()
- if os.name == 'nt':
+ if _is_windows():
# We need to handle Windows twisted separately. We create a
# batchfile in game/server, linking to the actual binary
@@ -1007,8 +1813,11 @@ def run_menu():
"""
while True:
# menu loop
+ gamedir = "/{}".format(os.path.basename(GAMEDIR))
+ leninfo = len(gamedir)
+ line = "|" + " " * (61 - leninfo) + gamedir + " " * 2 + "|"
- print(MENU)
+ print(MENU.format(gameinfo=line))
inp = input(" option > ")
# quitting and help
@@ -1030,223 +1839,104 @@ def run_menu():
print("Not a valid option.")
continue
if inp == 1:
- # start everything, log to log files
- server_operation("start", "all", False, False)
+ start_evennia(False, False)
elif inp == 2:
- # start everything, server interactive start
- server_operation("start", "all", True, False)
+ reload_evennia(False, False)
elif inp == 3:
- # start everything, portal interactive start
- server_operation("start", "server", False, False)
- server_operation("start", "portal", True, False)
+ stop_evennia()
elif inp == 4:
- # start both server and portal interactively
- server_operation("start", "server", True, False)
- server_operation("start", "portal", True, False)
+ reboot_evennia(False, False)
elif inp == 5:
- # reload the server
- server_operation("reload", "server", None, None)
+ reload_evennia(False, True)
elif inp == 6:
- # reload the portal
- server_operation("reload", "portal", None, None)
+ stop_server_only()
elif inp == 7:
- # stop server and portal
- server_operation("stop", "all", None, None)
+ if _is_windows():
+ print("This option is not supported on Windows.")
+ else:
+ kill(SERVER_PIDFILE, 'Server')
elif inp == 8:
- # stop server
- server_operation("stop", "server", None, None)
+ if _is_windows():
+ print("This option is not supported on Windows.")
+ else:
+ kill(SERVER_PIDFILE, 'Server')
+ kill(PORTAL_PIDFILE, 'Portal')
elif inp == 9:
- # stop portal
- server_operation("stop", "portal", None, None)
+ if not SERVER_LOGFILE:
+ init_game_directory(CURRENT_DIR, check_db=False)
+ tail_log_files(PORTAL_LOGFILE, SERVER_LOGFILE, 20, 20)
+ print(" Tailing logfiles {} (Ctrl-C to exit) ...".format(
+ _file_names_compact(SERVER_LOGFILE, PORTAL_LOGFILE)))
+ elif inp == 10:
+ query_status()
+ elif inp == 11:
+ query_info()
+ elif inp == 12:
+ print("Running 'evennia --settings settings.py test .' ...")
+ Popen([sys.executable, __file__, '--settings', 'settings.py', 'test', '.'],
+ env=getenv()).wait()
+ elif inp == 13:
+ print("Running 'evennia test evennia' ...")
+ Popen([sys.executable, __file__, 'test', 'evennia'], env=getenv()).wait()
else:
print("Not a valid option.")
continue
return
-def server_operation(mode, service, interactive, profiler, logserver=False, doexit=False):
- """
- Handle argument options given on the command line.
-
- Args:
- mode (str): Start/stop/restart and so on.
- service (str): "server", "portal" or "all".
- interactive (bool). Use interactive mode or daemon.
- profiler (bool): Run the service under the profiler.
- logserver (bool, optional): Log Server data to logfile
- specified by settings.SERVER_LOG_FILE.
- doexit (bool, optional): If True, immediately exit the runner after
- starting the relevant processes. If the runner exits, Evennia
- cannot be reloaded. This is meant to be used with an external
- process manager like Linux' start-stop-daemon.
-
- """
-
- cmdstr = [sys.executable, EVENNIA_RUNNER]
- errmsg = "The %s does not seem to be running."
-
- if mode == 'start':
-
- # launch the error checker. Best to catch the errors already here.
- error_check_python_modules()
-
- # starting one or many services
- if service == 'server':
- if profiler:
- cmdstr.append('--pserver')
- if interactive:
- cmdstr.append('--iserver')
- if logserver:
- cmdstr.append('--logserver')
- cmdstr.append('--noportal')
- elif service == 'portal':
- if profiler:
- cmdstr.append('--pportal')
- if interactive:
- cmdstr.append('--iportal')
- cmdstr.append('--noserver')
- django.core.management.call_command(
- 'collectstatic', verbosity=1, interactive=False)
- else:
- # all
- # for convenience we don't start logging of
- # portal, only of server with this command.
- if profiler:
- # this is the common case
- cmdstr.append('--pserver')
- if interactive:
- cmdstr.append('--iserver')
- if logserver:
- cmdstr.append('--logserver')
- django.core.management.call_command(
- 'collectstatic', verbosity=1, interactive=False)
- if doexit:
- cmdstr.append('--doexit')
- cmdstr.extend([
- GAMEDIR, TWISTED_BINARY, SERVER_LOGFILE,
- PORTAL_LOGFILE, HTTP_LOGFILE])
- # start the server
- process = Popen(cmdstr, env=getenv())
-
- if interactive:
- try:
- process.wait()
- except KeyboardInterrupt:
- server_operation("stop", "portal", False, False)
- return
- finally:
- print(NOTE_KEYBOARDINTERRUPT)
-
- elif mode == 'reload':
- # restarting services
- if os.name == 'nt':
- print(
- "Restarting from command line is not supported under Windows. "
- "Use the in-game command (@reload by default) "
- "or use 'evennia stop && evennia start' for a cold reboot.")
- return
- if service == 'server':
- kill(SERVER_PIDFILE, SIG, "Server reloaded.",
- errmsg % 'Server', SERVER_RESTART, restart=True)
- elif service == 'portal':
- print(
- "Note: Portal usually doesnt't need to be reloaded unless you "
- "are debugging in interactive mode. If Portal was running in "
- "default Daemon mode, it cannot be restarted. In that case "
- "you have to restart it manually with 'evennia.py "
- "start portal'")
- kill(PORTAL_PIDFILE, SIG,
- "Portal reloaded (or stopped, if it was in daemon mode).",
- errmsg % 'Portal', PORTAL_RESTART, restart=True)
- else:
- # all
- # default mode, only restart server
- kill(SERVER_PIDFILE, SIG,
- "Server reload.",
- errmsg % 'Server', SERVER_RESTART, restart=True)
-
- elif mode == 'stop':
- if os.name == "nt":
- print (
- "(Obs: You can use a single Ctrl-C to skip "
- "Windows' annoying 'Terminate batch job (Y/N)?' prompts.)")
- # stop processes, avoiding reload
- if service == 'server':
- kill(SERVER_PIDFILE, SIG,
- "Server stopped.", errmsg % 'Server', SERVER_RESTART)
- elif service == 'portal':
- kill(PORTAL_PIDFILE, SIG,
- "Portal stopped.", errmsg % 'Portal', PORTAL_RESTART)
- else:
- kill(PORTAL_PIDFILE, SIG,
- "Portal stopped.", errmsg % 'Portal', PORTAL_RESTART)
- kill(SERVER_PIDFILE, SIG,
- "Server stopped.", errmsg % 'Server', SERVER_RESTART)
-
-
def main():
"""
Run the evennia launcher main program.
"""
-
# set up argument parser
- parser = ArgumentParser(description=CMDLINE_HELP)
+ parser = ArgumentParser(description=CMDLINE_HELP, formatter_class=argparse.RawTextHelpFormatter)
parser.add_argument(
- '-v', '--version', action='store_true',
- dest='show_version', default=False,
- help="Show version info.")
+ '--gamedir', nargs=1, action='store', dest='altgamedir',
+ metavar="",
+ help="location of gamedir (default: current location)")
parser.add_argument(
- '-i', '--interactive', action='store_true',
- dest='interactive', default=False,
- help="Start given processes in interactive mode.")
+ '--init', action='store', dest="init", metavar="",
+ help="creates a new gamedir 'name' at current location")
parser.add_argument(
- '-l', '--log', action='store_true',
- dest="logserver", default=False,
- help="Log Server data to log file.")
+ '--log', '-l', action='store_true', dest='tail_log', default=False,
+ help="tail the portal and server logfiles and print to stdout")
parser.add_argument(
- '--init', action='store', dest="init", metavar="name",
- help="Creates a new game directory 'name' at the current location.")
- parser.add_argument(
- '--list', nargs='+', action='store', dest='listsetting', metavar="key",
- help=("List values for server settings. Use 'all' to list all "
- "available keys."))
- parser.add_argument(
- '--profiler', action='store_true', dest='profiler', default=False,
- help="Start given server component under the Python profiler.")
- parser.add_argument(
- '--dummyrunner', nargs=1, action='store', dest='dummyrunner',
- metavar="N",
- help="Test a running server by connecting N dummy accounts to it.")
+ '--list', nargs='+', action='store', dest='listsetting', metavar="all|",
+ help=("list settings, use 'all' to list all available keys"))
parser.add_argument(
'--settings', nargs=1, action='store', dest='altsettings',
- default=None, metavar="filename.py",
- help=("Start evennia with alternative settings file from "
- "gamedir/server/conf/. (default is settings.py)"))
+ default=None, metavar="",
+ help=("start evennia with alternative settings file from\n"
+ " gamedir/server/conf/. (default is settings.py)"))
parser.add_argument(
'--initsettings', action='store_true', dest="initsettings",
default=False,
- help="Create a new, empty settings file as gamedir/server/conf/settings.py.")
+ help="create a new, empty settings file as\n gamedir/server/conf/settings.py")
parser.add_argument(
- '--external-runner', action='store_true', dest="doexit",
- default=False,
- help="Handle server restart with an external process manager.")
+ '--profiler', action='store_true', dest='profiler', default=False,
+ help="start given server component under the Python profiler")
+ parser.add_argument(
+ '--dummyrunner', nargs=1, action='store', dest='dummyrunner',
+ metavar="",
+ help="test a server by connecting dummy accounts to it")
+ parser.add_argument(
+ '-v', '--version', action='store_true',
+ dest='show_version', default=False,
+ help="show version info")
+
parser.add_argument(
"operation", nargs='?', default="noop",
- help="Operation to perform: 'start', 'stop', 'reload' or 'menu'.")
- parser.add_argument(
- "service", metavar="component", nargs='?', default="all",
- help=("Which component to operate on: "
- "'server', 'portal' or 'all' (default if not set)."))
+ help=ARG_OPTIONS)
parser.epilog = (
- "Common usage: evennia start|stop|reload. Django-admin database commands:"
- "evennia migration|flush|shell|dbshell (see the django documentation for more django-admin commands.)")
+ "Common Django-admin commands are shell, dbshell, test and migrate.\n"
+ "See the Django documentation for more management commands.")
args, unknown_args = parser.parse_known_args()
# handle arguments
- option, service = args.operation, args.service
+ option = args.operation
# make sure we have everything
check_main_evennia_dependencies()
@@ -1255,7 +1945,17 @@ def main():
# show help pane
print(CMDLINE_HELP)
sys.exit()
- elif args.init:
+
+ if args.altgamedir:
+ # use alternative gamedir path
+ global GAMEDIR
+ altgamedir = args.altgamedir[0]
+ if not os.path.isdir(altgamedir) and not args.init:
+ print(ERROR_NO_ALT_GAMEDIR.format(gamedir=altgamedir))
+ sys.exit()
+ GAMEDIR = altgamedir
+
+ if args.init:
# initialization of game directory
create_game_directory(args.init)
print(CREATED_NEW_GAMEDIR.format(
@@ -1270,8 +1970,8 @@ def main():
if args.altsettings:
# use alternative settings file
- sfile = args.altsettings[0]
global SETTINGSFILE, SETTINGS_DOTPATH, ENFORCED_SETTING
+ sfile = args.altsettings[0]
SETTINGSFILE = sfile
ENFORCED_SETTING = True
SETTINGS_DOTPATH = "server.conf.%s" % sfile.rstrip(".py")
@@ -1280,8 +1980,6 @@ def main():
if args.initsettings:
# create new settings file
- global GAMEDIR
- GAMEDIR = os.getcwd()
try:
create_settings_file(init=False)
print(RECREATED_SETTINGS)
@@ -1289,6 +1987,21 @@ def main():
print(ERROR_INITSETTINGS)
sys.exit()
+ if args.tail_log:
+ # set up for tailing the log files
+ global NO_REACTOR_STOP
+ NO_REACTOR_STOP = True
+ if not SERVER_LOGFILE:
+ init_game_directory(CURRENT_DIR, check_db=False)
+
+ # adjust how many lines we show from existing logs
+ start_lines1, start_lines2 = 20, 20
+ if option not in ('reload', 'reset', 'noop'):
+ start_lines1, start_lines2 = 0, 0
+
+ tail_log_files(PORTAL_LOGFILE, SERVER_LOGFILE, start_lines1, start_lines2)
+ print(" Tailing logfiles {} (Ctrl-C to exit) ...".format(
+ _file_names_compact(SERVER_LOGFILE, PORTAL_LOGFILE)))
if args.dummyrunner:
# launch the dummy runner
init_game_directory(CURRENT_DIR, check_db=True)
@@ -1301,13 +2014,47 @@ def main():
# launch menu for operation
init_game_directory(CURRENT_DIR, check_db=True)
run_menu()
- elif option in ('start', 'reload', 'stop'):
+ elif option in ('status', 'info', 'start', 'istart', 'ipstart', 'reload', 'restart', 'reboot',
+ 'reset', 'stop', 'sstop', 'kill', 'skill'):
# operate the server directly
- init_game_directory(CURRENT_DIR, check_db=True)
- server_operation(option, service, args.interactive, args.profiler, args.logserver, doexit=args.doexit)
+ if not SERVER_LOGFILE:
+ init_game_directory(CURRENT_DIR, check_db=True)
+ if option == "status":
+ query_status()
+ elif option == "info":
+ query_info()
+ elif option == "start":
+ start_evennia(args.profiler, args.profiler)
+ elif option == "istart":
+ start_server_interactive()
+ elif option == "ipstart":
+ start_portal_interactive()
+ elif option in ('reload', 'restart'):
+ reload_evennia(args.profiler)
+ elif option == 'reboot':
+ reboot_evennia(args.profiler, args.profiler)
+ elif option == 'reset':
+ reload_evennia(args.profiler, reset=True)
+ elif option == 'stop':
+ stop_evennia()
+ elif option == 'sstop':
+ stop_server_only()
+ elif option == 'kill':
+ if _is_windows():
+ print("This option is not supported on Windows.")
+ else:
+ kill(SERVER_PIDFILE, 'Server')
+ kill(PORTAL_PIDFILE, 'Portal')
+ elif option == 'skill':
+ if _is_windows():
+ print("This option is not supported on Windows.")
+ else:
+ kill(SERVER_PIDFILE, 'Server')
elif option != "noop":
# pass-through to django manager
check_db = False
+
+ # handle special django commands
if option in ('runserver', 'testserver'):
print(WARNING_RUNSERVER)
if option in ("shell", "check"):
@@ -1317,12 +2064,12 @@ def main():
if option == "test":
global TEST_MODE
TEST_MODE = True
+
init_game_directory(CURRENT_DIR, check_db=check_db)
+ # pass on to the manager
args = [option]
kwargs = {}
- if service not in ("all", "server", "portal"):
- args.append(service)
if unknown_args:
for arg in unknown_args:
if arg.startswith("--"):
@@ -1340,10 +2087,14 @@ def main():
args = ", ".join(args)
kwargs = ", ".join(["--%s" % kw for kw in kwargs])
print(ERROR_INPUT.format(traceback=exc, args=args, kwargs=kwargs))
- else:
- # no input; print evennia info
+
+ elif not args.tail_log:
+ # no input; print evennia info (don't pring if we're tailing log)
print(ABOUT_INFO)
+ if REACTOR_RUN:
+ reactor.run()
+
if __name__ == '__main__':
# start Evennia from the command line
diff --git a/evennia/server/evennia_runner.py b/evennia/server/evennia_runner.py
deleted file mode 100644
index 83e7bf4093..0000000000
--- a/evennia/server/evennia_runner.py
+++ /dev/null
@@ -1,357 +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.
-
-"""
-from __future__ import print_function
-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 .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()
diff --git a/evennia/server/initial_setup.py b/evennia/server/initial_setup.py
index 985a54dc95..9852229e45 100644
--- a/evennia/server/initial_setup.py
+++ b/evennia/server/initial_setup.py
@@ -59,7 +59,7 @@ def create_objects():
"""
- logger.log_info("Creating objects (Account #1 and Limbo room) ...")
+ logger.log_info("Initial setup: Creating objects (Account #1 and Limbo room) ...")
# Set the initial User's account object's username on the #1 object.
# This object is pure django and only holds name, email and password.
@@ -121,7 +121,7 @@ def create_channels():
Creates some sensible default channels.
"""
- logger.log_info("Creating default channels ...")
+ logger.log_info("Initial setup: Creating default channels ...")
goduser = get_god_account()
for channeldict in settings.DEFAULT_CHANNELS:
@@ -144,11 +144,21 @@ def at_initial_setup():
mod = __import__(modname, fromlist=[None])
except (ImportError, ValueError):
return
- logger.log_info(" Running at_initial_setup() hook.")
+ logger.log_info("Initial setup: Running at_initial_setup() hook.")
if mod.__dict__.get("at_initial_setup", None):
mod.at_initial_setup()
+def collectstatic():
+ """
+ Run collectstatic to make sure all web assets are loaded.
+
+ """
+ from django.core.management import call_command
+ logger.log_info("Initial setup: Gathering static resources using 'collectstatic'")
+ call_command('collectstatic', '--noinput')
+
+
def reset_server():
"""
We end the initialization by resetting the server. This makes sure
@@ -159,8 +169,8 @@ def reset_server():
"""
ServerConfig.objects.conf("server_epoch", time.time())
from evennia.server.sessionhandler import SESSIONS
- logger.log_info(" Initial setup complete. Restarting Server once.")
- SESSIONS.server.shutdown(mode='reset')
+ logger.log_info("Initial setup complete. Restarting Server once.")
+ SESSIONS.portal_reset_server()
def handle_setup(last_step):
@@ -186,6 +196,7 @@ def handle_setup(last_step):
setup_queue = [create_objects,
create_channels,
at_initial_setup,
+ collectstatic,
reset_server]
# step through queue, from last completed function
diff --git a/evennia/server/portal/amp.py b/evennia/server/portal/amp.py
new file mode 100644
index 0000000000..71a1bcba91
--- /dev/null
+++ b/evennia/server/portal/amp.py
@@ -0,0 +1,438 @@
+"""
+The AMP (Asynchronous Message Protocol)-communication commands and constants used by Evennia.
+
+This module acts as a central place for AMP-servers and -clients to get commands to use.
+
+"""
+from __future__ import print_function
+from functools import wraps
+import time
+from twisted.protocols import amp
+from collections import defaultdict, namedtuple
+from cStringIO import StringIO
+from itertools import count
+import zlib # Used in Compressed class
+try:
+ import cPickle as pickle
+except ImportError:
+ import pickle
+
+from twisted.internet.defer import DeferredList, Deferred
+from evennia.utils.utils import to_str, variable_from_module
+
+# delayed import
+_LOGGER = None
+
+# communication bits
+# (chr(9) and chr(10) are \t and \n, so skipping them)
+
+PCONN = chr(1) # portal session connect
+PDISCONN = chr(2) # portal session disconnect
+PSYNC = chr(3) # portal session sync
+SLOGIN = chr(4) # server session login
+SDISCONN = chr(5) # server session disconnect
+SDISCONNALL = chr(6) # server session disconnect all
+SSHUTD = chr(7) # server shutdown
+SSYNC = chr(8) # server session sync
+SCONN = chr(11) # server creating new connection (for irc bots and etc)
+PCONNSYNC = chr(12) # portal post-syncing a session
+PDISCONNALL = chr(13) # portal session disconnect all
+SRELOAD = chr(14) # server shutdown in reload mode
+SSTART = chr(15) # server start (portal must already be running anyway)
+PSHUTD = chr(16) # portal (+server) shutdown
+SSHUTD = chr(17) # server shutdown
+PSTATUS = chr(18) # ping server or portal status
+SRESET = chr(19) # server shutdown in reset mode
+
+NUL = b'\0'
+NULNUL = '\0\0'
+
+AMP_MAXLEN = amp.MAX_VALUE_LENGTH # max allowed data length in AMP protocol (cannot be changed)
+
+# buffers
+_SENDBATCH = defaultdict(list)
+_MSGBUFFER = defaultdict(list)
+
+# resources
+
+DUMMYSESSION = namedtuple('DummySession', ['sessid'])(0)
+
+
+_HTTP_WARNING = """
+HTTP/1.1 200 OK
+Content-Type: text/html
+
+
+
+ This is Evennia's internal AMP port. It handles communication
+ between Evennia's different processes.
+
+
This port should NOT be publicly visible.
+
+
+""".strip()
+
+
+# Helper functions for pickling.
+
+def dumps(data):
+ return to_str(pickle.dumps(to_str(data), pickle.HIGHEST_PROTOCOL))
+
+
+def loads(data):
+ return pickle.loads(to_str(data))
+
+
+@wraps
+def catch_traceback(func):
+ "Helper decorator"
+ def decorator(*args, **kwargs):
+ try:
+ func(*args, **kwargs)
+ except Exception as err:
+ global _LOGGER
+ if not _LOGGER:
+ from evennia.utils import logger as _LOGGER
+ _LOGGER.log_trace()
+ raise # make sure the error is visible on the other side of the connection too
+ print(err)
+ return decorator
+
+
+# AMP Communication Command types
+
+class Compressed(amp.String):
+ """
+ This is a custom AMP command Argument that both handles too-long
+ sends as well as uses zlib for compression across the wire. The
+ batch-grouping of too-long sends is borrowed from the "mediumbox"
+ recipy at twisted-hacks's ~glyph/+junk/amphacks/mediumbox.
+
+ """
+
+ def fromBox(self, name, strings, objects, proto):
+ """
+ Converts from box string representation to python. We read back too-long batched data and
+ put it back together here.
+
+ """
+ value = StringIO()
+ value.write(self.fromStringProto(strings.get(name), proto))
+ for counter in count(2):
+ # count from 2 upwards
+ chunk = strings.get("%s.%d" % (name, counter))
+ if chunk is None:
+ break
+ value.write(self.fromStringProto(chunk, proto))
+ objects[name] = value.getvalue()
+
+ def toBox(self, name, strings, objects, proto):
+ """
+ Convert from python object to string box representation.
+ we break up too-long data snippets into multiple batches here.
+
+ """
+ value = StringIO(objects[name])
+ strings[name] = self.toStringProto(value.read(AMP_MAXLEN), proto)
+ for counter in count(2):
+ chunk = value.read(AMP_MAXLEN)
+ if not chunk:
+ break
+ strings["%s.%d" % (name, counter)] = self.toStringProto(chunk, proto)
+
+ def toString(self, inObject):
+ """
+ Convert to send as a string on the wire, with compression.
+ """
+ return zlib.compress(super(Compressed, self).toString(inObject), 9)
+
+ def fromString(self, inString):
+ """
+ Convert (decompress) from the string-representation on the wire to Python.
+ """
+ return super(Compressed, self).fromString(zlib.decompress(inString))
+
+
+class MsgLauncher2Portal(amp.Command):
+ """
+ Message Launcher -> Portal
+
+ """
+ key = "MsgLauncher2Portal"
+ arguments = [('operation', amp.String()),
+ ('arguments', amp.String())]
+ errors = {Exception: 'EXCEPTION'}
+ response = []
+
+
+class MsgPortal2Server(amp.Command):
+ """
+ Message Portal -> Server
+
+ """
+ key = "MsgPortal2Server"
+ arguments = [('packed_data', Compressed())]
+ errors = {Exception: 'EXCEPTION'}
+ response = []
+
+
+class MsgServer2Portal(amp.Command):
+ """
+ Message Server -> Portal
+
+ """
+ key = "MsgServer2Portal"
+ arguments = [('packed_data', Compressed())]
+ errors = {Exception: 'EXCEPTION'}
+ response = []
+
+
+class AdminPortal2Server(amp.Command):
+ """
+ Administration Portal -> Server
+
+ Sent when the portal needs to perform admin operations on the
+ server, such as when a new session connects or resyncs
+
+ """
+ key = "AdminPortal2Server"
+ arguments = [('packed_data', Compressed())]
+ errors = {Exception: 'EXCEPTION'}
+ response = []
+
+
+class AdminServer2Portal(amp.Command):
+ """
+ Administration Server -> Portal
+
+ Sent when the server needs to perform admin operations on the
+ portal.
+
+ """
+ key = "AdminServer2Portal"
+ arguments = [('packed_data', Compressed())]
+ errors = {Exception: 'EXCEPTION'}
+ response = []
+
+
+class MsgStatus(amp.Command):
+ """
+ Check Status between AMP services
+
+ """
+ key = "MsgStatus"
+ arguments = [('status', amp.String())]
+ errors = {Exception: 'EXCEPTION'}
+ response = [('status', amp.String())]
+
+
+class FunctionCall(amp.Command):
+ """
+ Bidirectional Server <-> Portal
+
+ Sent when either process needs to call an arbitrary function in
+ the other. This does not use the batch-send functionality.
+
+ """
+ key = "FunctionCall"
+ arguments = [('module', amp.String()),
+ ('function', amp.String()),
+ ('args', amp.String()),
+ ('kwargs', amp.String())]
+ errors = {Exception: 'EXCEPTION'}
+ response = [('result', amp.String())]
+
+
+# -------------------------------------------------------------
+# Core AMP protocol for communication Server <-> Portal
+# -------------------------------------------------------------
+
+class AMPMultiConnectionProtocol(amp.AMP):
+ """
+ AMP protocol that safely handle multiple connections to the same
+ server without dropping old ones - new clients will receive
+ all server returns (broadcast). Will also correctly handle
+ erroneous HTTP requests on the port and return a HTTP error response.
+
+ """
+
+ # helper methods
+
+ def __init__(self, *args, **kwargs):
+ """
+ Initialize protocol with some things that need to be in place
+ already before connecting both on portal and server.
+
+ """
+ self.send_batch_counter = 0
+ self.send_reset_time = time.time()
+ self.send_mode = True
+ self.send_task = None
+ self.multibatches = 0
+
+ def dataReceived(self, data):
+ """
+ Handle non-AMP messages, such as HTTP communication.
+ """
+ if data[0] == NUL:
+ # an AMP communication
+ if data[-2:] != NULNUL:
+ # an incomplete AMP box means more batches are forthcoming.
+ self.multibatches += 1
+ super(AMPMultiConnectionProtocol, self).dataReceived(data)
+ elif self.multibatches:
+ # invalid AMP, but we have a pending multi-batch that is not yet complete
+ if data[-2:] == NULNUL:
+ # end of existing multibatch
+ self.multibatches = max(0, self.multibatches - 1)
+ super(AMPMultiConnectionProtocol, self).dataReceived(data)
+ else:
+ # not an AMP communication, return warning
+ self.transport.write(_HTTP_WARNING)
+ self.transport.loseConnection()
+ print("HTML received: %s" % data)
+
+ def makeConnection(self, transport):
+ """
+ Swallow connection log message here. Copied from original
+ in the amp protocol.
+
+ """
+ # copied from original, removing the log message
+ if not self._ampInitialized:
+ amp.AMP.__init__(self)
+ self._transportPeer = transport.getPeer()
+ self._transportHost = transport.getHost()
+ amp.BinaryBoxProtocol.makeConnection(self, transport)
+
+ def connectionMade(self):
+ """
+ This is called when an AMP connection is (re-)established. AMP calls it on both sides.
+
+ """
+ self.factory.broadcasts.append(self)
+
+ def connectionLost(self, reason):
+ """
+ We swallow connection errors here. The reason is that during a
+ normal reload/shutdown there will almost always be cases where
+ either the portal or server shuts down before a message has
+ returned its (empty) return, triggering a connectionLost error
+ that is irrelevant. If a true connection error happens, the
+ portal will continuously try to reconnect, showing the problem
+ that way.
+ """
+ try:
+ self.factory.broadcasts.remove(self)
+ except ValueError:
+ pass
+
+ # Error handling
+
+ def errback(self, e, info):
+ """
+ Error callback.
+ Handles errors to avoid dropping connections on server tracebacks.
+
+ Args:
+ e (Failure): Deferred error instance.
+ info (str): Error string.
+
+ """
+ global _LOGGER
+ if not _LOGGER:
+ from evennia.utils import logger as _LOGGER
+ e.trap(Exception)
+ _LOGGER.log_err("AMP Error for %(info)s: %(e)s" % {'info': info,
+ 'e': e.getErrorMessage()})
+
+ def data_in(self, packed_data):
+ """
+ Process incoming packed data.
+
+ Args:
+ packed_data (bytes): Zip-packed data.
+ Returns:
+ unpaced_data (any): Unpacked package
+
+ """
+ return loads(packed_data)
+
+ def broadcast(self, command, sessid, **kwargs):
+ """
+ Send data across the wire to all connections.
+
+ Args:
+ command (AMP Command): A protocol send command.
+ sessid (int): A unique Session id.
+
+ Returns:
+ deferred (deferred or None): A deferred with an errback.
+
+ Notes:
+ Data will be sent across the wire pickled as a tuple
+ (sessid, kwargs).
+
+ """
+ deferreds = []
+ for protcl in self.factory.broadcasts:
+ deferreds.append(protcl.callRemote(command, **kwargs).addErrback(
+ self.errback, command.key))
+
+ return DeferredList(deferreds)
+
+ # generic function send/recvs
+
+ def send_FunctionCall(self, modulepath, functionname, *args, **kwargs):
+ """
+ Access method called by either process. This will call an arbitrary
+ function on the other process (On Portal if calling from Server and
+ vice versa).
+
+ Inputs:
+ modulepath (str) - python path to module holding function to call
+ functionname (str) - name of function in given module
+ *args, **kwargs will be used as arguments/keyword args for the
+ remote function call
+ Returns:
+ A deferred that fires with the return value of the remote
+ function call
+
+ """
+ return self.callRemote(FunctionCall,
+ module=modulepath,
+ function=functionname,
+ args=dumps(args),
+ kwargs=dumps(kwargs)).addCallback(
+ lambda r: loads(r["result"])).addErrback(self.errback, "FunctionCall")
+
+ @FunctionCall.responder
+ @catch_traceback
+ def receive_functioncall(self, module, function, func_args, func_kwargs):
+ """
+ This allows Portal- and Server-process to call an arbitrary
+ function in the other process. It is intended for use by
+ plugin modules.
+
+ Args:
+ module (str or module): The module containing the
+ `function` to call.
+ function (str): The name of the function to call in
+ `module`.
+ func_args (str): Pickled args tuple for use in `function` call.
+ func_kwargs (str): Pickled kwargs dict for use in `function` call.
+
+ """
+ args = loads(func_args)
+ kwargs = loads(func_kwargs)
+
+ # call the function (don't catch tracebacks here)
+ result = variable_from_module(module, function)(*args, **kwargs)
+
+ if isinstance(result, Deferred):
+ # if result is a deferred, attach handler to properly
+ # wrap the return value
+ result.addCallback(lambda r: {"result": dumps(r)})
+ return result
+ else:
+ return {'result': dumps(result)}
diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py
new file mode 100644
index 0000000000..c07b5c121d
--- /dev/null
+++ b/evennia/server/portal/amp_server.py
@@ -0,0 +1,458 @@
+"""
+The Evennia Portal service acts as an AMP-server, handling AMP
+communication to the AMP clients connecting to it (by default
+these are the Evennia Server and the evennia launcher).
+
+"""
+import os
+import sys
+from twisted.internet import protocol
+from evennia.server.portal import amp
+from django.conf import settings
+from subprocess import Popen, STDOUT
+from evennia.utils import logger
+
+
+def _is_windows():
+ return os.name == 'nt'
+
+
+def getenv():
+ """
+ Get current environment and add PYTHONPATH.
+
+ Returns:
+ env (dict): Environment global dict.
+
+ """
+ sep = ";" if _is_windows() else ":"
+ env = os.environ.copy()
+ env['PYTHONPATH'] = sep.join(sys.path)
+ return env
+
+
+class AMPServerFactory(protocol.ServerFactory):
+
+ """
+ This factory creates AMP Server connection. This acts as the 'Portal'-side communication to the
+ 'Server' process.
+
+ """
+ noisy = False
+
+ def logPrefix(self):
+ "How this is named in logs"
+ return "AMP"
+
+ def __init__(self, portal):
+ """
+ Initialize the factory. This is called as the Portal service starts.
+
+ Args:
+ portal (Portal): The Evennia Portal service instance.
+ protocol (Protocol): The protocol the factory creates
+ instances of.
+
+ """
+ self.portal = portal
+ self.protocol = AMPServerProtocol
+ self.broadcasts = []
+ self.server_connection = None
+ self.launcher_connection = None
+ self.disconnect_callbacks = {}
+ self.server_connect_callbacks = []
+
+ def buildProtocol(self, addr):
+ """
+ Start a new connection, and store it on the service object.
+
+ Args:
+ addr (str): Connection address. Not used.
+
+ Returns:
+ protocol (Protocol): The created protocol.
+
+ """
+ self.portal.amp_protocol = AMPServerProtocol()
+ self.portal.amp_protocol.factory = self
+ return self.portal.amp_protocol
+
+
+class AMPServerProtocol(amp.AMPMultiConnectionProtocol):
+ """
+ Protocol subclass for the AMP-server run by the Portal.
+
+ """
+ def connectionLost(self, reason):
+ """
+ Set up a simple callback mechanism to let the amp-server wait for a connection to close.
+
+ """
+ # wipe broadcast and data memory
+ super(AMPServerProtocol, self).connectionLost(reason)
+ if self.factory.server_connection == self:
+ self.factory.server_connection = None
+ self.factory.portal.server_info_dict = {}
+ if self.factory.launcher_connection == self:
+ self.factory.launcher_connection = None
+
+ callback, args, kwargs = self.factory.disconnect_callbacks.pop(self, (None, None, None))
+ if callback:
+ try:
+ callback(*args, **kwargs)
+ except Exception:
+ logger.log_trace()
+
+ def get_status(self):
+ """
+ Return status for the Evennia infrastructure.
+
+ Returns:
+ status (tuple): The portal/server status and pids
+ (portal_live, server_live, portal_PID, server_PID).
+
+ """
+ server_connected = bool(self.factory.server_connection and
+ self.factory.server_connection.transport.connected)
+ portal_info_dict = self.factory.portal.get_info_dict()
+ server_info_dict = self.factory.portal.server_info_dict
+ server_pid = self.factory.portal.server_process_id
+ portal_pid = os.getpid()
+ return (True, server_connected, portal_pid, server_pid, portal_info_dict, server_info_dict)
+
+ def data_to_server(self, command, sessid, **kwargs):
+ """
+ Send data across the wire to the Server.
+
+ Args:
+ command (AMP Command): A protocol send command.
+ sessid (int): A unique Session id.
+
+ Returns:
+ deferred (deferred or None): A deferred with an errback.
+
+ Notes:
+ Data will be sent across the wire pickled as a tuple
+ (sessid, kwargs).
+
+ """
+ if self.factory.server_connection:
+ return self.factory.server_connection.callRemote(
+ command, packed_data=amp.dumps((sessid, kwargs))).addErrback(
+ self.errback, command.key)
+ else:
+ # if no server connection is available, broadcast
+ return self.broadcast(command, sessid, packed_data=amp.dumps((sessid, kwargs)))
+
+ def start_server(self, server_twistd_cmd):
+ """
+ (Re-)Launch the Evennia server.
+
+ Args:
+ server_twisted_cmd (list): The server start instruction
+ to pass to POpen to start the server.
+
+ """
+ # start the Server
+ process = None
+ with open(settings.SERVER_LOG_FILE, 'a') as logfile:
+ # we link stdout to a file in order to catch
+ # eventual errors happening before the Server has
+ # opened its logger.
+ try:
+ if _is_windows():
+ # Windows requires special care
+ create_no_window = 0x08000000
+ process = Popen(server_twistd_cmd, env=getenv(), bufsize=-1,
+ stdout=logfile, stderr=STDOUT,
+ creationflags=create_no_window)
+
+ else:
+ process = Popen(server_twistd_cmd, env=getenv(), bufsize=-1,
+ stdout=logfile, stderr=STDOUT)
+ except Exception:
+ logger.log_trace()
+
+ self.factory.portal.server_twistd_cmd = server_twistd_cmd
+ logfile.flush()
+ if process and not _is_windows():
+ # avoid zombie-process on Unix/BSD
+ process.wait()
+ return
+
+ def wait_for_disconnect(self, callback, *args, **kwargs):
+ """
+ Add a callback for when this connection is lost.
+
+ Args:
+ callback (callable): Will be called with *args, **kwargs
+ once this protocol is disconnected.
+
+ """
+ self.factory.disconnect_callbacks[self] = (callback, args, kwargs)
+
+ def wait_for_server_connect(self, callback, *args, **kwargs):
+ """
+ Add a callback for when the Server is sure to have connected.
+
+ Args:
+ callback (callable): Will be called with *args, **kwargs
+ once the Server handshake with Portal is complete.
+
+ """
+ self.factory.server_connect_callbacks.append((callback, args, kwargs))
+
+ def stop_server(self, mode='shutdown'):
+ """
+ Shut down server in one or more modes.
+
+ Args:
+ mode (str): One of 'shutdown', 'reload' or 'reset'.
+
+ """
+ if mode == 'reload':
+ self.send_AdminPortal2Server(amp.DUMMYSESSION, operation=amp.SRELOAD)
+ elif mode == 'reset':
+ self.send_AdminPortal2Server(amp.DUMMYSESSION, operation=amp.SRESET)
+ elif mode == 'shutdown':
+ self.send_AdminPortal2Server(amp.DUMMYSESSION, operation=amp.SSHUTD)
+ self.factory.portal.server_restart_mode = mode
+
+ # sending amp data
+
+ def send_Status2Launcher(self):
+ """
+ Send a status stanza to the launcher.
+
+ """
+ if self.factory.launcher_connection:
+ self.factory.launcher_connection.callRemote(
+ amp.MsgStatus,
+ status=amp.dumps(self.get_status())).addErrback(
+ self.errback, amp.MsgStatus.key)
+
+ def send_MsgPortal2Server(self, session, **kwargs):
+ """
+ Access method called by the Portal and executed on the Portal.
+
+ Args:
+ session (session): Session
+ kwargs (any, optional): Optional data.
+
+ Returns:
+ deferred (Deferred): Asynchronous return.
+
+ """
+ return self.data_to_server(amp.MsgPortal2Server, session.sessid, **kwargs)
+
+ def send_AdminPortal2Server(self, session, operation="", **kwargs):
+ """
+ Send Admin instructions from the Portal to the Server.
+ Executed on the Portal.
+
+ Args:
+ session (Session): Session.
+ operation (char, optional): Identifier for the server operation, as defined by the
+ global variables in `evennia/server/amp.py`.
+ data (str or dict, optional): Data used in the administrative operation.
+
+ """
+ return self.data_to_server(amp.AdminPortal2Server, session.sessid,
+ operation=operation, **kwargs)
+
+ # receive amp data
+
+ @amp.MsgStatus.responder
+ @amp.catch_traceback
+ def portal_receive_status(self, status):
+ """
+ Returns run-status for the server/portal.
+
+ Args:
+ status (str): Not used.
+ Returns:
+ status (dict): The status is a tuple
+ (portal_running, server_running, portal_pid, server_pid).
+
+ """
+ return {"status": amp.dumps(self.get_status())}
+
+ @amp.MsgLauncher2Portal.responder
+ @amp.catch_traceback
+ def portal_receive_launcher2portal(self, operation, arguments):
+ """
+ Receives message arriving from evennia_launcher.
+ This method is executed on the Portal.
+
+ Args:
+ operation (str): The action to perform.
+ arguments (str): Possible argument to the instruction, or the empty string.
+
+ Returns:
+ result (dict): The result back to the launcher.
+
+ Notes:
+ This is the entrypoint for controlling the entire Evennia system from the evennia
+ launcher. It can obviously only accessed when the Portal is already up and running.
+
+ """
+ self.factory.launcher_connection = self
+
+ _, server_connected, _, _, _, _ = self.get_status()
+
+ # logger.log_msg("Evennia Launcher->Portal operation %s received" % (ord(operation)))
+
+ if operation == amp.SSTART: # portal start #15
+ # first, check if server is already running
+ if not server_connected:
+ self.wait_for_server_connect(self.send_Status2Launcher)
+ self.start_server(amp.loads(arguments))
+
+ elif operation == amp.SRELOAD: # reload server #14
+ if server_connected:
+ # We let the launcher restart us once they get the signal
+ self.factory.server_connection.wait_for_disconnect(
+ self.send_Status2Launcher)
+ self.stop_server(mode='reload')
+ else:
+ self.wait_for_server_connect(self.send_Status2Launcher)
+ self.start_server(amp.loads(arguments))
+
+ elif operation == amp.SRESET: # reload server #19
+ if server_connected:
+ self.factory.server_connection.wait_for_disconnect(
+ self.send_Status2Launcher)
+ self.stop_server(mode='reset')
+ else:
+ self.wait_for_server_connect(self.send_Status2Launcher)
+ self.start_server(amp.loads(arguments))
+
+ elif operation == amp.SSHUTD: # server-only shutdown #17
+ if server_connected:
+ self.factory.server_connection.wait_for_disconnect(
+ self.send_Status2Launcher)
+ self.stop_server(mode='shutdown')
+
+ elif operation == amp.PSHUTD: # portal + server shutdown #16
+ if server_connected:
+ self.factory.server_connection.wait_for_disconnect(
+ self.factory.portal.shutdown )
+ else:
+ self.factory.portal.shutdown()
+
+ else:
+ raise Exception("operation %(op)s not recognized." % {'op': operation})
+
+ return {}
+
+ @amp.MsgServer2Portal.responder
+ @amp.catch_traceback
+ def portal_receive_server2portal(self, packed_data):
+ """
+ Receives message arriving to Portal from Server.
+ This method is executed on the Portal.
+
+ Args:
+ packed_data (str): Pickled data (sessid, kwargs) coming over the wire.
+
+ """
+ try:
+ sessid, kwargs = self.data_in(packed_data)
+ session = self.factory.portal.sessions.get(sessid, None)
+ if session:
+ self.factory.portal.sessions.data_out(session, **kwargs)
+ except Exception:
+ logger.log_trace("packed_data len {}".format(len(packed_data)))
+ return {}
+
+ @amp.AdminServer2Portal.responder
+ @amp.catch_traceback
+ def portal_receive_adminserver2portal(self, packed_data):
+ """
+
+ Receives and handles admin operations sent to the Portal
+ This is executed on the Portal.
+
+ Args:
+ packed_data (str): Data received, a pickled tuple (sessid, kwargs).
+
+ """
+ self.factory.server_connection = self
+
+ sessid, kwargs = self.data_in(packed_data)
+ operation = kwargs.pop("operation")
+ portal_sessionhandler = self.factory.portal.sessions
+
+ if operation == amp.SLOGIN: # server_session_login
+ # a session has authenticated; sync it.
+ session = portal_sessionhandler.get(sessid)
+ if session:
+ portal_sessionhandler.server_logged_in(session, kwargs.get("sessiondata"))
+
+ elif operation == amp.SDISCONN: # server_session_disconnect
+ # the server is ordering to disconnect the session
+ session = portal_sessionhandler.get(sessid)
+ if session:
+ portal_sessionhandler.server_disconnect(session, reason=kwargs.get("reason"))
+
+ elif operation == amp.SDISCONNALL: # server_session_disconnect_all
+ # server orders all sessions to disconnect
+ portal_sessionhandler.server_disconnect_all(reason=kwargs.get("reason"))
+
+ elif operation == amp.SRELOAD: # server reload
+ self.factory.server_connection.wait_for_disconnect(
+ self.start_server, self.factory.portal.server_twistd_cmd)
+ self.stop_server(mode='reload')
+
+ elif operation == amp.SRESET: # server reset
+ self.factory.server_connection.wait_for_disconnect(
+ self.start_server, self.factory.portal.server_twistd_cmd)
+ self.stop_server(mode='reset')
+
+ elif operation == amp.SSHUTD: # server-only shutdown
+ self.stop_server(mode='shutdown')
+
+ elif operation == amp.PSHUTD: # full server+server shutdown
+ self.factory.server_connection.wait_for_disconnect(
+ self.factory.portal.shutdown)
+ self.stop_server(mode='shutdown')
+
+ elif operation == amp.PSYNC: # portal sync
+ # Server has (re-)connected and wants the session data from portal
+ self.factory.portal.server_info_dict = kwargs.get("info_dict", {})
+ self.factory.portal.server_process_id = kwargs.get("spid", None)
+ # this defaults to 'shutdown' or whatever value set in server_stop
+ server_restart_mode = self.factory.portal.server_restart_mode
+
+ sessdata = self.factory.portal.sessions.get_all_sync_data()
+ self.send_AdminPortal2Server(amp.DUMMYSESSION,
+ amp.PSYNC,
+ server_restart_mode=server_restart_mode,
+ sessiondata=sessdata)
+ self.factory.portal.sessions.at_server_connection()
+
+ if self.factory.server_connection:
+ # this is an indication the server has successfully connected, so
+ # we trigger any callbacks (usually to tell the launcher server is up)
+ for callback, args, kwargs in self.factory.server_connect_callbacks:
+ try:
+ callback(*args, **kwargs)
+ except Exception:
+ logger.log_trace()
+ self.factory.server_connect_callbacks = []
+
+ elif operation == amp.SSYNC: # server_session_sync
+ # server wants to save session data to the portal,
+ # maybe because it's about to shut down.
+ portal_sessionhandler.server_session_sync(kwargs.get("sessiondata"),
+ kwargs.get("clean", True))
+
+ # set a flag in case we are about to shut down soon
+ self.factory.server_restart_mode = True
+
+ elif operation == amp.SCONN: # server_force_connection (for irc/etc)
+ portal_sessionhandler.server_connect(**kwargs)
+
+ else:
+ raise Exception("operation %(op)s not recognized." % {'op': operation})
+ return {}
diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py
index 3b658d54fb..91b3efc7bc 100644
--- a/evennia/server/portal/portal.py
+++ b/evennia/server/portal/portal.py
@@ -7,14 +7,16 @@ sets up all the networking features. (this is done automatically
by game/evennia.py).
"""
-from __future__ import print_function
from builtins import object
import sys
import os
+from os.path import dirname, abspath
from twisted.application import internet, service
from twisted.internet import protocol, reactor
+from twisted.python.log import ILogObserver
+
import django
django.setup()
from django.conf import settings
@@ -24,6 +26,7 @@ evennia._init()
from evennia.utils.utils import get_evennia_version, mod_import, make_iter
from evennia.server.portal.portalsessionhandler import PORTAL_SESSIONS
+from evennia.utils import logger
from evennia.server.webserver import EvenniaReverseProxyResource
from django.db import connection
@@ -37,11 +40,6 @@ except Exception:
PORTAL_SERVICES_PLUGIN_MODULES = [mod_import(module) for module in make_iter(settings.PORTAL_SERVICES_PLUGIN_MODULES)]
LOCKDOWN_MODE = settings.LOCKDOWN_MODE
-PORTAL_PIDFILE = ""
-if os.name == 'nt':
- # For Windows we need to handle pid files manually.
- PORTAL_PIDFILE = os.path.join(settings.GAME_DIR, "server", 'portal.pid')
-
# -------------------------------------------------------------
# Evennia Portal settings
# -------------------------------------------------------------
@@ -77,10 +75,15 @@ AMP_PORT = settings.AMP_PORT
AMP_INTERFACE = settings.AMP_INTERFACE
AMP_ENABLED = AMP_HOST and AMP_PORT and AMP_INTERFACE
+INFO_DICT = {"servername": SERVERNAME, "version": VERSION, "errors": "", "info": "",
+ "lockdown_mode": "", "amp": "", "telnet": [], "telnet_ssl": [], "ssh": [],
+ "webclient": [], "webserver_proxy": [], "webserver_internal": []}
# -------------------------------------------------------------
# Portal Service object
# -------------------------------------------------------------
+
+
class Portal(object):
"""
@@ -105,41 +108,52 @@ class Portal(object):
self.amp_protocol = None # set by amp factory
self.sessions = PORTAL_SESSIONS
self.sessions.portal = self
+ self.process_id = os.getpid()
+
+ self.server_process_id = None
+ self.server_restart_mode = "shutdown"
+ self.server_info_dict = {}
+
+ # in non-interactive portal mode, this gets overwritten by
+ # cmdline sent by the evennia launcher
+ self.server_twistd_cmd = self._get_backup_server_twistd_cmd()
# set a callback if the server is killed abruptly,
# by Ctrl-C, reboot etc.
- reactor.addSystemEventTrigger('before', 'shutdown', self.shutdown, _reactor_stopping=True)
+ reactor.addSystemEventTrigger('before', 'shutdown',
+ self.shutdown, _reactor_stopping=True, _stop_server=True)
- self.game_running = False
-
- def set_restart_mode(self, mode=None):
+ def _get_backup_server_twistd_cmd(self):
"""
- This manages the flag file that tells the runner if the server
- should be restarted or is shutting down.
-
- Args:
- mode (bool or None): Valid modes are True/False and None.
- If mode is None, no change will be done to the flag file.
+ For interactive Portal mode there is no way to get the server cmdline from the launcher, so
+ we need to guess it here (it's very likely to not change)
+ Returns:
+ server_twistd_cmd (list): An instruction for starting the server, to pass to Popen.
"""
- if mode is None:
- return
- with open(PORTAL_RESTART, 'w') as f:
- print("writing mode=%(mode)s to %(portal_restart)s" % {'mode': mode, 'portal_restart': PORTAL_RESTART})
- f.write(str(mode))
+ server_twistd_cmd = [
+ "twistd",
+ "--python={}".format(os.path.join(dirname(dirname(abspath(__file__))), "server.py"))]
+ if os.name != 'nt':
+ gamedir = os.getcwd()
+ server_twistd_cmd.append("--pidfile={}".format(
+ os.path.join(gamedir, "server", "server.pid")))
+ return server_twistd_cmd
- def shutdown(self, restart=None, _reactor_stopping=False):
+ def get_info_dict(self):
+ "Return the Portal info, for display."
+ return INFO_DICT
+
+ def shutdown(self, _reactor_stopping=False, _stop_server=False):
"""
Shuts down the server from inside it.
Args:
- restart (bool or None, optional): True/False sets the
- flags so the server will be restarted or not. If None, the
- current flag setting (set at initialization or previous
- runs) is used.
_reactor_stopping (bool, optional): This is set if server
is already in the process of shutting down; in this case
we don't need to stop it again.
+ _stop_server (bool, optional): Only used in portal-interactive mode;
+ makes sure to stop the Server cleanly.
Note that restarting (regardless of the setting) will not work
if the Portal is currently running in daemon mode. In that
@@ -150,11 +164,11 @@ class Portal(object):
# we get here due to us calling reactor.stop below. No need
# to do the shutdown procedure again.
return
+
self.sessions.disconnect_all()
- self.set_restart_mode(restart)
- if os.name == 'nt' and os.path.exists(PORTAL_PIDFILE):
- # for Windows we need to remove pid files manually
- os.remove(PORTAL_PIDFILE)
+ 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.
@@ -172,14 +186,20 @@ class Portal(object):
# what to execute from.
application = service.Application('Portal')
+# custom logging
+
+if "--nodaemon" not in sys.argv:
+ logfile = logger.WeeklyLogFile(os.path.basename(settings.PORTAL_LOG_FILE),
+ os.path.dirname(settings.PORTAL_LOG_FILE))
+ application.setComponent(ILogObserver, logger.PortalLogObserver(logfile).emit)
+
# The main Portal server program. This sets up the database
# and is where we store all the other services.
PORTAL = Portal(application)
-print('-' * 50)
-print(' %(servername)s Portal (%(version)s) started.' % {'servername': SERVERNAME, 'version': VERSION})
if LOCKDOWN_MODE:
- print(' LOCKDOWN_MODE active: Only local connections.')
+
+ INFO_DICT["lockdown_mode"] = ' LOCKDOWN_MODE active: Only local connections.'
if AMP_ENABLED:
@@ -187,14 +207,14 @@ if AMP_ENABLED:
# the portal and the mud server. Only reason to ever deactivate
# it would be during testing and debugging.
- from evennia.server import amp
+ from evennia.server.portal import amp_server
- print(' amp (to Server): %s' % AMP_PORT)
+ INFO_DICT["amp"] = 'amp: %s' % AMP_PORT
- factory = amp.AmpClientFactory(PORTAL)
- amp_client = internet.TCPClient(AMP_HOST, AMP_PORT, factory)
- amp_client.setName('evennia_amp')
- PORTAL.services.addService(amp_client)
+ factory = amp_server.AMPServerFactory(PORTAL)
+ amp_service = internet.TCPServer(AMP_PORT, factory, interface=AMP_INTERFACE)
+ amp_service.setName("PortalAMPServer")
+ PORTAL.services.addService(amp_service)
# We group all the various services under the same twisted app.
@@ -212,7 +232,7 @@ if TELNET_ENABLED:
ifacestr = "-%s" % interface
for port in TELNET_PORTS:
pstring = "%s:%s" % (ifacestr, port)
- factory = protocol.ServerFactory()
+ factory = telnet.TelnetServerFactory()
factory.noisy = False
factory.protocol = telnet.TelnetProtocol
factory.sessionhandler = PORTAL_SESSIONS
@@ -220,12 +240,12 @@ if TELNET_ENABLED:
telnet_service.setName('EvenniaTelnet%s' % pstring)
PORTAL.services.addService(telnet_service)
- print(' telnet%s: %s' % (ifacestr, port))
+ INFO_DICT["telnet"].append('telnet%s: %s' % (ifacestr, port))
if SSL_ENABLED:
- # Start Telnet SSL game connection (requires PyOpenSSL).
+ # Start Telnet+SSL game connection (requires PyOpenSSL).
from evennia.server.portal import telnet_ssl
@@ -249,9 +269,10 @@ if SSL_ENABLED:
ssl_service.setName('EvenniaSSL%s' % pstring)
PORTAL.services.addService(ssl_service)
- print(" ssl%s: %s" % (ifacestr, port))
+ INFO_DICT["telnet_ssl"].append("telnet+ssl%s: %s" % (ifacestr, port))
else:
- print(" ssl%s: %s (deactivated - keys/certificate unset)" % (ifacestr, port))
+ INFO_DICT["telnet_ssl"].append(
+ "telnet+ssl%s: %s (deactivated - keys/cert unset)" % (ifacestr, port))
if SSH_ENABLED:
@@ -275,7 +296,7 @@ if SSH_ENABLED:
ssh_service.setName('EvenniaSSH%s' % pstring)
PORTAL.services.addService(ssh_service)
- print(" ssh%s: %s" % (ifacestr, port))
+ INFO_DICT["ssh"].append("ssh%s: %s" % (ifacestr, port))
if WEBSERVER_ENABLED:
@@ -289,7 +310,6 @@ if WEBSERVER_ENABLED:
if interface not in ('0.0.0.0', '::') or len(WEBSERVER_INTERFACES) > 1:
ifacestr = "-%s" % interface
for proxyport, serverport in WEBSERVER_PORTS:
- pstring = "%s:%s<->%s" % (ifacestr, proxyport, serverport)
web_root = EvenniaReverseProxyResource('127.0.0.1', serverport, '')
webclientstr = ""
if WEBCLIENT_ENABLED:
@@ -299,7 +319,7 @@ if WEBSERVER_ENABLED:
ajax_webclient = webclient_ajax.AjaxWebClient()
ajax_webclient.sessionhandler = PORTAL_SESSIONS
web_root.putChild("webclientdata", ajax_webclient)
- webclientstr = "\n + webclient (ajax only)"
+ webclientstr = "webclient (ajax only)"
if WEBSOCKET_CLIENT_ENABLED and not websocket_started:
# start websocket client port for the webclient
@@ -307,38 +327,39 @@ if WEBSERVER_ENABLED:
from evennia.server.portal import webclient
from evennia.utils.txws import WebSocketFactory
- interface = WEBSOCKET_CLIENT_INTERFACE
+ w_interface = WEBSOCKET_CLIENT_INTERFACE
+ w_ifacestr = ''
+ if w_interface not in ('0.0.0.0', '::') or len(WEBSERVER_INTERFACES) > 1:
+ w_ifacestr = "-%s" % interface
port = WEBSOCKET_CLIENT_PORT
- ifacestr = ""
- if interface not in ('0.0.0.0', '::'):
- ifacestr = "-%s" % interface
- pstring = "%s:%s" % (ifacestr, port)
- factory = protocol.ServerFactory()
+
+ class Websocket(protocol.ServerFactory):
+ "Only here for better naming in logs"
+ pass
+
+ factory = Websocket()
factory.noisy = False
factory.protocol = webclient.WebSocketClient
factory.sessionhandler = PORTAL_SESSIONS
- websocket_service = internet.TCPServer(port, WebSocketFactory(factory), interface=interface)
- websocket_service.setName('EvenniaWebSocket%s' % pstring)
+ websocket_service = internet.TCPServer(port, WebSocketFactory(factory),
+ interface=w_interface)
+ websocket_service.setName('EvenniaWebSocket%s:%s' % (w_ifacestr, port))
PORTAL.services.addService(websocket_service)
websocket_started = True
- webclientstr = "\n + webclient%s" % pstring
+ webclientstr = "webclient-websocket%s: %s" % (w_ifacestr, port)
+ INFO_DICT["webclient"].append(webclientstr)
web_root = Website(web_root, logPath=settings.HTTP_LOG_FILE)
+ web_root.is_portal = True
proxy_service = internet.TCPServer(proxyport,
web_root,
interface=interface)
- proxy_service.setName('EvenniaWebProxy%s' % pstring)
+ proxy_service.setName('EvenniaWebProxy%s:%s' % (ifacestr, proxyport))
PORTAL.services.addService(proxy_service)
- print(" webproxy%s:%s (<-> %s)%s" % (ifacestr, proxyport, serverport, webclientstr))
+ INFO_DICT["webserver_proxy"].append("webserver-proxy%s: %s" % (ifacestr, proxyport))
+ INFO_DICT["webserver_internal"].append("webserver: %s" % serverport)
for plugin_module in PORTAL_SERVICES_PLUGIN_MODULES:
# external plugin services to start
plugin_module.start_plugin_services(PORTAL)
-
-print('-' * 50) # end of terminal output
-
-if os.name == 'nt':
- # Windows only: Set PID file manually
- with open(PORTAL_PIDFILE, 'w') as f:
- f.write(str(os.getpid()))
diff --git a/evennia/server/portal/ssh.py b/evennia/server/portal/ssh.py
index 97d12ada7e..715d99b292 100644
--- a/evennia/server/portal/ssh.py
+++ b/evennia/server/portal/ssh.py
@@ -39,7 +39,7 @@ from twisted.conch.ssh import common
from twisted.conch.insults import insults
from twisted.conch.manhole_ssh import TerminalRealm, _Glue, ConchFactory
from twisted.conch.manhole import Manhole, recvline
-from twisted.internet import defer
+from twisted.internet import defer, protocol
from twisted.conch import interfaces as iconch
from twisted.python import components
from django.conf import settings
@@ -72,6 +72,15 @@ and put them here:
""".format(_PRIVATE_KEY_FILE, _PUBLIC_KEY_FILE)
+# not used atm
+class SSHServerFactory(protocol.ServerFactory):
+ "This is only to name this better in logs"
+ noisy = False
+
+ def logPrefix(self):
+ return "SSH"
+
+
class SshProtocol(Manhole, session.Session):
"""
Each account connecting over ssh gets this protocol assigned to
diff --git a/evennia/server/portal/telnet.py b/evennia/server/portal/telnet.py
index 558475cd34..955ea5e918 100644
--- a/evennia/server/portal/telnet.py
+++ b/evennia/server/portal/telnet.py
@@ -8,6 +8,7 @@ sessions etc.
"""
import re
+from twisted.internet import protocol
from twisted.internet.task import LoopingCall
from twisted.conch.telnet import Telnet, StatefulTelnetProtocol
from twisted.conch.telnet import IAC, NOP, LINEMODE, GA, WILL, WONT, ECHO, NULL
@@ -26,6 +27,14 @@ _RE_SCREENREADER_REGEX = re.compile(r"%s" % settings.SCREENREADER_REGEX_STRIP, r
_IDLE_COMMAND = settings.IDLE_COMMAND + "\n"
+class TelnetServerFactory(protocol.ServerFactory):
+ "This is only to name this better in logs"
+ noisy = False
+
+ def logPrefix(self):
+ return "Telnet"
+
+
class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
"""
Each player connecting over telnet (ie using most traditional mud
@@ -49,10 +58,13 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
# this number is counted down for every handshake that completes.
# when it reaches 0 the portal/server syncs their data
self.handshakes = 8 # suppress-go-ahead, naws, ttype, mccp, mssp, msdp, gmcp, mxp
+
self.init_session(self.protocol_key, client_address, self.factory.sessionhandler)
+ self.protocol_flags["ENCODING"] = settings.ENCODINGS[0] if settings.ENCODINGS else 'utf-8'
# add this new connection to sessionhandler so
# the Server becomes aware of it.
self.sessionhandler.connect(self)
+ # change encoding to ENCODINGS[0] which reflects Telnet default encoding
# suppress go-ahead
self.sga = suppress_ga.SuppressGA(self)
diff --git a/evennia/server/portal/ttype.py b/evennia/server/portal/ttype.py
index d143b69747..faf4737842 100644
--- a/evennia/server/portal/ttype.py
+++ b/evennia/server/portal/ttype.py
@@ -116,7 +116,7 @@ class Ttype(object):
self.protocol.protocol_flags["FORCEDENDLINE"] = False
if cupper.startswith("TINTIN++"):
- self.protocol.protocol_flags["FORCEDENDLINE"] = False
+ self.protocol.protocol_flags["FORCEDENDLINE"] = True
if (cupper.startswith("XTERM") or
cupper.endswith("-256COLOR") or
diff --git a/evennia/server/profiling/dummyrunner.py b/evennia/server/profiling/dummyrunner.py
index 34b6acafba..30201be5f1 100644
--- a/evennia/server/profiling/dummyrunner.py
+++ b/evennia/server/profiling/dummyrunner.py
@@ -106,7 +106,7 @@ ERROR_NO_MIXIN = \
error completely.
Warning: Don't run dummyrunner on a production database! It will
- create a lot of spammy objects and account accounts!
+ create a lot of spammy objects and accounts!
"""
diff --git a/evennia/server/server.py b/evennia/server/server.py
index 6e92b0b055..0e9193f723 100644
--- a/evennia/server/server.py
+++ b/evennia/server/server.py
@@ -7,7 +7,6 @@ sets up all the networking features. (this is done automatically
by evennia/server/server_runner.py).
"""
-from __future__ import print_function
from builtins import object
import time
import sys
@@ -17,6 +16,7 @@ from twisted.web import static
from twisted.application import internet, service
from twisted.internet import reactor, defer
from twisted.internet.task import LoopingCall
+from twisted.python.log import ILogObserver
import django
django.setup()
@@ -33,6 +33,7 @@ from evennia.server.models import ServerConfig
from evennia.server import initial_setup
from evennia.utils.utils import get_evennia_version, mod_import, make_iter
+from evennia.utils import logger
from evennia.comms import channelhandler
from evennia.server.sessionhandler import SESSIONS
@@ -40,11 +41,6 @@ from django.utils.translation import ugettext as _
_SA = object.__setattr__
-SERVER_PIDFILE = ""
-if os.name == 'nt':
- # For Windows we need to handle pid files manually.
- SERVER_PIDFILE = os.path.join(settings.GAME_DIR, "server", 'server.pid')
-
# a file with a flag telling the server to restart after shutdown or not.
SERVER_RESTART = os.path.join(settings.GAME_DIR, "server", 'server.restart')
@@ -53,16 +49,11 @@ SERVER_STARTSTOP_MODULE = mod_import(settings.AT_SERVER_STARTSTOP_MODULE)
# modules containing plugin services
SERVER_SERVICES_PLUGIN_MODULES = [mod_import(module) for module in make_iter(settings.SERVER_SERVICES_PLUGIN_MODULES)]
-try:
- WEB_PLUGINS_MODULE = mod_import(settings.WEB_PLUGINS_MODULE)
-except ImportError:
- WEB_PLUGINS_MODULE = None
- print ("WARNING: settings.WEB_PLUGINS_MODULE not found - "
- "copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf.")
-#------------------------------------------------------------
+
+# ------------------------------------------------------------
# Evennia Server settings
-#------------------------------------------------------------
+# ------------------------------------------------------------
SERVERNAME = settings.SERVERNAME
VERSION = get_evennia_version()
@@ -83,6 +74,17 @@ IRC_ENABLED = settings.IRC_ENABLED
RSS_ENABLED = settings.RSS_ENABLED
WEBCLIENT_ENABLED = settings.WEBCLIENT_ENABLED
+INFO_DICT = {"servername": SERVERNAME, "version": VERSION,
+ "amp": "", "errors": "", "info": "", "webserver": "", "irc_rss": ""}
+
+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.")
+
# Maintenance function - this is called repeatedly by the server
@@ -172,15 +174,13 @@ class Evennia(object):
self.amp_protocol = None # set by amp factory
self.sessions = SESSIONS
self.sessions.server = self
+ self.process_id = os.getpid()
# Database-specific startup optimizations.
self.sqlite3_prep()
self.start_time = time.time()
- # Run the initial setup if needed
- self.run_initial_setup()
-
# initialize channelhandler
channelhandler.CHANNELHANDLER.update()
@@ -192,18 +192,13 @@ class Evennia(object):
from twisted.internet.defer import Deferred
if hasattr(self, "web_root"):
d = self.web_root.empty_threadpool()
- d.addCallback(lambda _: self.shutdown(_reactor_stopping=True))
+ d.addCallback(lambda _: self.shutdown("reload", _reactor_stopping=True))
else:
- d = Deferred(lambda _: self.shutdown(_reactor_stopping=True))
+ d = Deferred(lambda _: self.shutdown("reload", _reactor_stopping=True))
d.addCallback(lambda _: reactor.stop())
reactor.callLater(1, d.callback, None)
reactor.sigInt = _wrap_sigint_handler
- self.game_running = True
-
- # track the server time
- self.run_init_hooks()
-
# Server startup methods
def sqlite3_prep(self):
@@ -211,7 +206,8 @@ class Evennia(object):
Optimize some SQLite stuff at startup since we
can't save it to the database.
"""
- if ((".".join(str(i) for i in django.VERSION) < "1.2" and settings.DATABASES.get('default', {}).get('ENGINE') == "sqlite3") or
+ if ((".".join(str(i) for i in django.VERSION) < "1.2" and
+ settings.DATABASES.get('default', {}).get('ENGINE') == "sqlite3") or
(hasattr(settings, 'DATABASES') and
settings.DATABASES.get("default", {}).get('ENGINE', None) ==
'django.db.backends.sqlite3')):
@@ -229,6 +225,8 @@ class Evennia(object):
typeclasses in the settings file and have them auto-update all
already existing objects.
"""
+ global INFO_DICT
+
# setting names
settings_names = ("CMDSET_CHARACTER", "CMDSET_ACCOUNT",
"BASE_ACCOUNT_TYPECLASS", "BASE_OBJECT_TYPECLASS",
@@ -247,7 +245,7 @@ class Evennia(object):
#from evennia.accounts.models import AccountDB
for i, prev, curr in ((i, tup[0], tup[1]) for i, tup in enumerate(settings_compare) if i in mismatches):
# update the database
- print(" %s:\n '%s' changed to '%s'. Updating unchanged entries in database ..." % (settings_names[i], prev, curr))
+ INFO_DICT['info'] = " %s:\n '%s' changed to '%s'. Updating unchanged entries in database ..." % (settings_names[i], prev, curr)
if i == 0:
ObjectDB.objects.filter(db_cmdset_storage__exact=prev).update(db_cmdset_storage=curr)
if i == 1:
@@ -273,33 +271,37 @@ class Evennia(object):
def run_initial_setup(self):
"""
+ This is triggered by the amp protocol when the connection
+ to the portal has been established.
This attempts to run the initial_setup script of the server.
It returns if this is not the first time the server starts.
Once finished the last_initial_setup_step is set to -1.
"""
+ global INFO_DICT
last_initial_setup_step = ServerConfig.objects.conf('last_initial_setup_step')
if not last_initial_setup_step:
# None is only returned if the config does not exist,
# i.e. this is an empty DB that needs populating.
- print(' Server started for the first time. Setting defaults.')
+ INFO_DICT['info'] = ' Server started for the first time. Setting defaults.'
initial_setup.handle_setup(0)
- print('-' * 50)
elif int(last_initial_setup_step) >= 0:
# a positive value means the setup crashed on one of its
# modules and setup will resume from this step, retrying
# the last failed module. When all are finished, the step
# is set to -1 to show it does not need to be run again.
- print(' Resuming initial setup from step %(last)s.' %
- {'last': last_initial_setup_step})
+ INFO_DICT['info'] = ' Resuming initial setup from step {last}.'.format(
+ last=last_initial_setup_step)
initial_setup.handle_setup(int(last_initial_setup_step))
- print('-' * 50)
- def run_init_hooks(self):
+ def run_init_hooks(self, mode):
"""
- Called every server start
+ Called by the amp client once receiving sync back from Portal
+
+ Args:
+ mode (str): One of shutdown, reload or reset
+
"""
from evennia.objects.models import ObjectDB
- #from evennia.accounts.models import AccountDB
# update eventual changed defaults
self.update_defaults()
@@ -307,47 +309,24 @@ class Evennia(object):
[o.at_init() for o in ObjectDB.get_all_cached_instances()]
[p.at_init() for p in AccountDB.get_all_cached_instances()]
- mode = self.getset_restart_mode()
-
# call correct server hook based on start file value
if mode == 'reload':
- # True was the old reload flag, kept for compatibilty
+ logger.log_msg("Server successfully reloaded.")
self.at_server_reload_start()
elif mode == 'reset':
# only run hook, don't purge sessions
self.at_server_cold_start()
- elif mode in ('reset', 'shutdown'):
+ logger.log_msg("Evennia Server successfully restarted in 'reset' mode.")
+ elif mode == 'shutdown':
self.at_server_cold_start()
# clear eventual lingering session storages
ObjectDB.objects.clear_all_sessids()
+ logger.log_msg("Evennia Server successfully started.")
# always call this regardless of start type
self.at_server_start()
- def getset_restart_mode(self, mode=None):
- """
- This manages the flag file that tells the runner if the server is
- reloading, resetting or shutting down.
-
- Args:
- mode (string or None, optional): Valid values are
- 'reload', 'reset', 'shutdown' and `None`. If mode is `None`,
- no change will be done to the flag file.
- Returns:
- mode (str): The currently active restart mode, either just
- set or previously set.
-
- """
- if mode is None:
- with open(SERVER_RESTART, 'r') as f:
- # mode is either shutdown, reset or reload
- mode = f.read()
- else:
- with open(SERVER_RESTART, 'w') as f:
- f.write(str(mode))
- return mode
-
@defer.inlineCallbacks
- def shutdown(self, mode=None, _reactor_stopping=False):
+ def shutdown(self, mode='reload', _reactor_stopping=False):
"""
Shuts down the server from inside it.
@@ -358,7 +337,6 @@ class Evennia(object):
at_shutdown hooks called but sessions will not
be disconnected.
'shutdown' - like reset, but server will not auto-restart.
- None - keep currently set flag from flag file.
_reactor_stopping - this is set if server is stopped by a kill
command OR this method was already called
once - in both cases the reactor is
@@ -369,10 +347,7 @@ class Evennia(object):
# once; we don't need to run the shutdown procedure again.
defer.returnValue(None)
- mode = self.getset_restart_mode(mode)
-
from evennia.objects.models import ObjectDB
- #from evennia.accounts.models import AccountDB
from evennia.server.models import ServerConfig
from evennia.utils import gametime as _GAMETIME_MODULE
@@ -381,7 +356,8 @@ class Evennia(object):
ServerConfig.objects.conf("server_restart_mode", "reload")
yield [o.at_server_reload() for o in ObjectDB.get_all_cached_instances()]
yield [p.at_server_reload() for p in AccountDB.get_all_cached_instances()]
- yield [(s.pause(manual_pause=False), s.at_server_reload()) for s in ScriptDB.get_all_cached_instances() if s.is_active]
+ yield [(s.pause(manual_pause=False), s.at_server_reload())
+ for s in ScriptDB.get_all_cached_instances() if s.is_active]
yield self.sessions.all_sessions_portal_sync()
self.at_server_reload_stop()
# only save monitor state on reload, not on shutdown/reset
@@ -411,10 +387,6 @@ class Evennia(object):
# always called, also for a reload
self.at_server_stop()
- if os.name == 'nt' and os.path.exists(SERVER_PIDFILE):
- # for Windows we need to remove pid files manually
- os.remove(SERVER_PIDFILE)
-
if hasattr(self, "web_root"): # not set very first start
yield self.web_root.empty_threadpool()
@@ -426,6 +398,10 @@ class Evennia(object):
# we make sure the proper gametime is saved as late as possible
ServerConfig.objects.conf("runtime", _GAMETIME_MODULE.runtime())
+ def get_info_dict(self):
+ "Return the server info, for display."
+ return INFO_DICT
+
# server start/stop hooks
def at_server_start(self):
@@ -451,13 +427,15 @@ class Evennia(object):
if SERVER_STARTSTOP_MODULE:
SERVER_STARTSTOP_MODULE.at_server_reload_start()
- def at_post_portal_sync(self):
+ def at_post_portal_sync(self, mode):
"""
This is called just after the portal has finished syncing back data to the server
after reconnecting.
+
+ Args:
+ mode (str): One of reload, reset or shutdown.
+
"""
- # one of reload, reset or shutdown
- mode = self.getset_restart_mode()
from evennia.scripts.monitorhandler import MONITOR_HANDLER
MONITOR_HANDLER.restore(mode == 'reload')
@@ -529,13 +507,16 @@ ServerConfig.objects.conf("server_starting_mode", True)
# what to execute from.
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))
+ application.setComponent(ILogObserver, logger.ServerLogObserver(logfile).emit)
+
# The main evennia server program. This sets up the database
# and is where we store all the other services.
EVENNIA = Evennia(application)
-print('-' * 50)
-print(' %(servername)s Server (%(version)s) started.' % {'servername': SERVERNAME, 'version': VERSION})
-
if AMP_ENABLED:
# The AMP protocol handles the communication between
@@ -545,20 +526,20 @@ if AMP_ENABLED:
ifacestr = ""
if AMP_INTERFACE != '127.0.0.1':
ifacestr = "-%s" % AMP_INTERFACE
- print(' amp (to Portal)%s: %s' % (ifacestr, AMP_PORT))
- from evennia.server import amp
+ INFO_DICT["amp"] = 'amp %s: %s' % (ifacestr, AMP_PORT)
- factory = amp.AmpServerFactory(EVENNIA)
- amp_service = internet.TCPServer(AMP_PORT, factory, interface=AMP_INTERFACE)
- amp_service.setName("EvenniaPortal")
+ from evennia.server import amp_client
+
+ factory = amp_client.AMPClientFactory(EVENNIA)
+ amp_service = internet.TCPClient(AMP_HOST, AMP_PORT, factory)
+ amp_service.setName('ServerAMPClient')
EVENNIA.services.addService(amp_service)
if WEBSERVER_ENABLED:
# Start a django-compatible webserver.
- #from twisted.python import threadpool
from evennia.server.webserver import DjangoWebRoot, WSGIWebServer, Website, LockableThreadPool
# start a thread pool and define the root url (/) as a wsgi resource
@@ -578,14 +559,16 @@ if WEBSERVER_ENABLED:
web_root = WEB_PLUGINS_MODULE.at_webserver_root_creation(web_root)
web_site = Website(web_root, logPath=settings.HTTP_LOG_FILE)
+ web_site.is_portal = False
+ INFO_DICT["webserver"] = ""
for proxyport, serverport in WEBSERVER_PORTS:
# create the webserver (we only need the port for this)
webserver = WSGIWebServer(threads, serverport, web_site, interface='127.0.0.1')
webserver.setName('EvenniaWebServer%s' % serverport)
EVENNIA.services.addService(webserver)
- print(" webserver: %s" % serverport)
+ INFO_DICT["webserver"] += "webserver: %s" % serverport
ENABLED = []
if IRC_ENABLED:
@@ -597,18 +580,11 @@ if RSS_ENABLED:
ENABLED.append('rss')
if ENABLED:
- print(" " + ", ".join(ENABLED) + " enabled.")
+ INFO_DICT["irc_rss"] = ", ".join(ENABLED) + " enabled."
for plugin_module in SERVER_SERVICES_PLUGIN_MODULES:
# external plugin protocols
plugin_module.start_plugin_services(EVENNIA)
-print('-' * 50) # end of terminal output
-
# clear server startup mode
ServerConfig.objects.conf("server_starting_mode", delete=True)
-
-if os.name == 'nt':
- # Windows only: Set PID file manually
- with open(SERVER_PIDFILE, 'w') as f:
- f.write(str(os.getpid()))
diff --git a/evennia/server/serversession.py b/evennia/server/serversession.py
index b7f74cef5d..c5de7cf5be 100644
--- a/evennia/server/serversession.py
+++ b/evennia/server/serversession.py
@@ -407,7 +407,7 @@ class ServerSession(Session):
else:
self.data_out(**kwargs)
- def execute_cmd(self, raw_string, **kwargs):
+ def execute_cmd(self, raw_string, session=None, **kwargs):
"""
Do something as this object. This method is normally never
called directly, instead incoming command instructions are
@@ -417,6 +417,9 @@ class ServerSession(Session):
Args:
raw_string (string): Raw command input
+ session (Session): This is here to make API consistent with
+ Account/Object.execute_cmd. If given, data is passed to
+ that Session, otherwise use self.
Kwargs:
Other keyword arguments will be added to the found command
object instace as variables before it executes. This is
@@ -426,7 +429,7 @@ class ServerSession(Session):
"""
# inject instruction into input stream
kwargs["text"] = ((raw_string,), {})
- self.sessionhandler.data_in(self, **kwargs)
+ self.sessionhandler.data_in(session or self, **kwargs)
def __eq__(self, other):
"""Handle session comparisons"""
diff --git a/evennia/server/session.py b/evennia/server/session.py
index 093a2c0d7a..eb77321a24 100644
--- a/evennia/server/session.py
+++ b/evennia/server/session.py
@@ -7,7 +7,6 @@ from builtins import object
import time
-
#------------------------------------------------------------
# Server Session
#------------------------------------------------------------
diff --git a/evennia/server/sessionhandler.py b/evennia/server/sessionhandler.py
index 5ea0d08c9f..8e439b42dd 100644
--- a/evennia/server/sessionhandler.py
+++ b/evennia/server/sessionhandler.py
@@ -58,6 +58,12 @@ SSYNC = chr(8) # server session sync
SCONN = chr(11) # server portal connection (for bots)
PCONNSYNC = chr(12) # portal post-syncing session
PDISCONNALL = chr(13) # portal session discnnect all
+SRELOAD = chr(14) # server reloading (have portal start a new server)
+SSTART = chr(15) # server start (portal must already be running anyway)
+PSHUTD = chr(16) # portal (+server) shutdown
+SSHUTD = chr(17) # server shutdown
+PSTATUS = chr(18) # ping server or portal status
+SRESET = chr(19) # server shutdown in reset mode
# i18n
from django.utils.translation import ugettext as _
@@ -272,7 +278,7 @@ class ServerSessionHandler(SessionHandler):
"""
super(ServerSessionHandler, self).__init__(*args, **kwargs)
- self.server = None
+ self.server = None # set at server initialization
self.server_data = {"servername": _SERVERNAME}
def _run_cmd_login(self, session):
@@ -284,7 +290,6 @@ class ServerSessionHandler(SessionHandler):
if not session.logged_in:
self.data_in(session, text=[[CMD_LOGINSTART], {}])
-
def portal_connect(self, portalsessiondata):
"""
Called by Portal when a new session has connected.
@@ -373,8 +378,10 @@ class ServerSessionHandler(SessionHandler):
self[sessid] = sess
sess.at_sync()
+ mode = 'reload'
+
# tell the server hook we synced
- self.server.at_post_portal_sync()
+ self.server.at_post_portal_sync(mode)
# announce the reconnection
self.announce_all(_(" ... Server restarted."))
@@ -432,13 +439,28 @@ class ServerSessionHandler(SessionHandler):
self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION, operation=SCONN,
protocol_path=protocol_path, config=configdict)
+ def portal_restart_server(self):
+ """
+ Called by server when reloading. We tell the portal to start a new server instance.
+
+ """
+ self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION, operation=SRELOAD)
+
+ def portal_reset_server(self):
+ """
+ Called by server when reloading. We tell the portal to start a new server instance.
+
+ """
+ self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION, operation=SRESET)
+
def portal_shutdown(self):
"""
- Called by server when shutting down the portal.
+ Called by server when it's time to shut down (the portal will shut us down and then shut
+ itself down)
"""
self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION,
- operation=SSHUTD)
+ operation=PSHUTD)
def login(self, session, account, force=False, testmode=False):
"""
@@ -564,8 +586,6 @@ class ServerSessionHandler(SessionHandler):
sessiondata=session_data,
clean=False)
-
-
def disconnect_all_sessions(self, reason="You have been disconnected."):
"""
Cleanly disconnect all of the connected sessions.
diff --git a/evennia/server/tests.py b/evennia/server/tests.py
index e821d58583..4d7f271417 100644
--- a/evennia/server/tests.py
+++ b/evennia/server/tests.py
@@ -24,8 +24,13 @@ try:
except ImportError:
import unittest
+from evennia.server.validators import EvenniaPasswordValidator
+from evennia.utils.test_resources import EvenniaTest
+
from django.test.runner import DiscoverRunner
+from evennia.server.throttle import Throttle
+
from .deprecations import check_errors
@@ -62,18 +67,80 @@ class TestDeprecations(TestCase):
"""
Class for testing deprecations.check_errors.
"""
- deprecated_settings = ("CMDSET_DEFAULT", "CMDSET_OOC", "BASE_COMM_TYPECLASS", "COMM_TYPECLASS_PATHS",
- "CHARACTER_DEFAULT_HOME", "OBJECT_TYPECLASS_PATHS", "SCRIPT_TYPECLASS_PATHS",
- "ACCOUNT_TYPECLASS_PATHS", "CHANNEL_TYPECLASS_PATHS", "SEARCH_MULTIMATCH_SEPARATOR",
- "TIME_SEC_PER_MIN", "TIME_MIN_PER_HOUR", "TIME_HOUR_PER_DAY", "TIME_DAY_PER_WEEK",
- "TIME_WEEK_PER_MONTH", "TIME_MONTH_PER_YEAR")
+ deprecated_settings = (
+ "CMDSET_DEFAULT", "CMDSET_OOC", "BASE_COMM_TYPECLASS", "COMM_TYPECLASS_PATHS",
+ "CHARACTER_DEFAULT_HOME", "OBJECT_TYPECLASS_PATHS", "SCRIPT_TYPECLASS_PATHS",
+ "ACCOUNT_TYPECLASS_PATHS", "CHANNEL_TYPECLASS_PATHS", "SEARCH_MULTIMATCH_SEPARATOR",
+ "TIME_SEC_PER_MIN", "TIME_MIN_PER_HOUR", "TIME_HOUR_PER_DAY", "TIME_DAY_PER_WEEK",
+ "TIME_WEEK_PER_MONTH", "TIME_MONTH_PER_YEAR")
def test_check_errors(self):
"""
- All settings in deprecated_settings should raise a DeprecationWarning if they exist. WEBSERVER_PORTS
- raises an error if the iterable value passed does not have a tuple as its first element.
+ All settings in deprecated_settings should raise a DeprecationWarning if they exist.
+ WEBSERVER_PORTS raises an error if the iterable value passed does not have a tuple as its
+ first element.
"""
for setting in self.deprecated_settings:
self.assertRaises(DeprecationWarning, check_errors, MockSettings(setting))
# test check for WEBSERVER_PORTS having correct value
- self.assertRaises(DeprecationWarning, check_errors, MockSettings("WEBSERVER_PORTS", value=["not a tuple"]))
+ self.assertRaises(
+ DeprecationWarning,
+ check_errors, MockSettings("WEBSERVER_PORTS", value=["not a tuple"]))
+
+
+class ValidatorTest(EvenniaTest):
+
+ def test_validator(self):
+ # Validator returns None on success and ValidationError on failure.
+ validator = EvenniaPasswordValidator()
+
+ # This password should meet Evennia standards.
+ self.assertFalse(validator.validate('testpassword', user=self.account))
+
+ # This password contains illegal characters and should raise an Exception.
+ from django.core.exceptions import ValidationError
+ self.assertRaises(ValidationError, validator.validate, '(#)[#]<>', user=self.account)
+
+
+class ThrottleTest(EvenniaTest):
+ """
+ Class for testing the connection/IP throttle.
+ """
+ def test_throttle(self):
+ ips = ('94.100.176.153', '45.56.148.77', '5.196.1.129')
+ kwargs = {
+ 'limit': 5,
+ 'timeout': 15 * 60
+ }
+
+ throttle = Throttle(**kwargs)
+
+ for ip in ips:
+ # Throttle should not be engaged by default
+ self.assertFalse(throttle.check(ip))
+
+ # Pretend to fail a bunch of events
+ for x in range(50):
+ obj = throttle.update(ip)
+ self.assertFalse(obj)
+
+ # Next ones should be blocked
+ self.assertTrue(throttle.check(ip))
+
+ for x in range(throttle.cache_size * 2):
+ obj = throttle.update(ip)
+ self.assertFalse(obj)
+
+ # Should still be blocked
+ self.assertTrue(throttle.check(ip))
+
+ # Number of values should be limited by cache size
+ self.assertEqual(throttle.cache_size, len(throttle.get(ip)))
+
+ cache = throttle.get()
+
+ # Make sure there are entries for each IP
+ self.assertEqual(len(ips), len(cache.keys()))
+
+ # There should only be (cache_size * num_ips) total in the Throttle cache
+ self.assertEqual(sum([len(cache[x]) for x in cache.keys()]), throttle.cache_size * len(ips))
diff --git a/evennia/server/throttle.py b/evennia/server/throttle.py
new file mode 100644
index 0000000000..56c88c63f2
--- /dev/null
+++ b/evennia/server/throttle.py
@@ -0,0 +1,101 @@
+from collections import defaultdict, deque
+import time
+
+class Throttle(object):
+ """
+ Keeps a running count of failed actions per IP address.
+
+ Available methods indicate whether or not the number of failures exceeds a
+ particular threshold.
+
+ This version of the throttle is usable by both the terminal server as well
+ as the web server, imposes limits on memory consumption by using deques
+ with length limits instead of open-ended lists, and removes sparse keys when
+ no recent failures have been recorded.
+ """
+
+ error_msg = 'Too many failed attempts; you must wait a few minutes before trying again.'
+
+ def __init__(self, **kwargs):
+ """
+ Allows setting of throttle parameters.
+
+ Kwargs:
+ limit (int): Max number of failures before imposing limiter
+ timeout (int): number of timeout seconds after
+ max number of tries has been reached.
+ cache_size (int): Max number of attempts to record per IP within a
+ rolling window; this is NOT the same as the limit after which
+ the throttle is imposed!
+ """
+ self.storage = defaultdict(deque)
+ self.cache_size = self.limit = kwargs.get('limit', 5)
+ self.timeout = kwargs.get('timeout', 5 * 60)
+
+ def get(self, ip=None):
+ """
+ Convenience function that returns the storage table, or part of.
+
+ Args:
+ ip (str, optional): IP address of requestor
+
+ Returns:
+ storage (dict): When no IP is provided, returns a dict of all
+ current IPs being tracked and the timestamps of their recent
+ failures.
+ timestamps (deque): When an IP is provided, returns a deque of
+ timestamps of recent failures only for that IP.
+
+ """
+ if ip: return self.storage.get(ip, deque(maxlen=self.cache_size))
+ else: return self.storage
+
+ def update(self, ip):
+ """
+ Store the time of the latest failure/
+
+ Args:
+ ip (str): IP address of requestor
+
+ Returns:
+ None
+
+ """
+ # Enforce length limits
+ if not self.storage[ip].maxlen:
+ self.storage[ip] = deque(maxlen=self.cache_size)
+
+ self.storage[ip].append(time.time())
+
+ def check(self, ip):
+ """
+ This will check the session's address against the
+ storage dictionary to check they haven't spammed too many
+ fails recently.
+
+ Args:
+ ip (str): IP address of requestor
+
+ Returns:
+ throttled (bool): True if throttling is active,
+ False otherwise.
+
+ """
+ now = time.time()
+ ip = str(ip)
+
+ # checking mode
+ latest_fails = self.storage[ip]
+ if latest_fails and len(latest_fails) >= self.limit:
+ # too many fails recently
+ if now - latest_fails[-1] < self.timeout:
+ # too soon - timeout in play
+ return True
+ else:
+ # timeout has passed. clear faillist
+ del(self.storage[ip])
+ return False
+ else:
+ return False
+
+
\ No newline at end of file
diff --git a/evennia/server/validators.py b/evennia/server/validators.py
new file mode 100644
index 0000000000..b10f990a8a
--- /dev/null
+++ b/evennia/server/validators.py
@@ -0,0 +1,51 @@
+from django.core.exceptions import ValidationError
+from django.utils.translation import gettext as _
+import re
+
+class EvenniaPasswordValidator:
+
+ def __init__(self, regex=r"^[\w. @+\-',]+$", policy="Password should contain a mix of letters, spaces, digits and @/./+/-/_/'/, only."):
+ """
+ Constructs a standard Django password validator.
+
+ Args:
+ regex (str): Regex pattern of valid characters to allow.
+ policy (str): Brief explanation of what the defined regex permits.
+
+ """
+ self.regex = regex
+ self.policy = policy
+
+ def validate(self, password, user=None):
+ """
+ Validates a password string to make sure it meets predefined Evennia
+ acceptable character policy.
+
+ Args:
+ password (str): Password to validate
+ user (None): Unused argument but required by Django
+
+ Returns:
+ None (None): None if password successfully validated,
+ raises ValidationError otherwise.
+
+ """
+ # Check complexity
+ if not re.findall(self.regex, password):
+ raise ValidationError(
+ _(self.policy),
+ code='evennia_password_policy',
+ )
+
+ def get_help_text(self):
+ """
+ Returns a user-facing explanation of the password policy defined
+ by this validator.
+
+ Returns:
+ text (str): Explanation of password policy.
+
+ """
+ return _(
+ "%s From a terminal client, you can also use a phrase of multiple words if you enclose the password in double quotes." % self.policy
+ )
\ No newline at end of file
diff --git a/evennia/server/webserver.py b/evennia/server/webserver.py
index 260b38071d..b1912bf120 100644
--- a/evennia/server/webserver.py
+++ b/evennia/server/webserver.py
@@ -212,6 +212,12 @@ class Website(server.Site):
"""
noisy = False
+ def logPrefix(self):
+ "How to be named in logs"
+ if hasattr(self, "is_portal") and self.is_portal:
+ return "Webserver-proxy"
+ return "Webserver"
+
def log(self, request):
"""Conditional logging"""
if _DEBUG:
diff --git a/evennia/settings_default.py b/evennia/settings_default.py
index 4a0d336db8..9efbb6314b 100644
--- a/evennia/settings_default.py
+++ b/evennia/settings_default.py
@@ -65,7 +65,7 @@ ALLOWED_HOSTS = ["*"]
# the Portal proxy presents to the world. The serverports are
# the internal ports the proxy uses to forward data to the Server-side
# webserver (these should not be publicly open)
-WEBSERVER_PORTS = [(4001, 4002)]
+WEBSERVER_PORTS = [(4001, 4005)]
# Interface addresses to listen to. If 0.0.0.0, listen to all. Use :: for IPv6.
WEBSERVER_INTERFACES = ['0.0.0.0']
# IP addresses that may talk to the server in a reverse proxy configuration,
@@ -83,15 +83,20 @@ WEBCLIENT_ENABLED = True
# default webclient will use this and only use the ajax version if the browser
# is too old to support websockets. Requires WEBCLIENT_ENABLED.
WEBSOCKET_CLIENT_ENABLED = True
-# Server-side websocket port to open for the webclient.
-WEBSOCKET_CLIENT_PORT = 4005
+# Server-side websocket port to open for the webclient. Note that this value will
+# be dynamically encoded in the webclient html page to allow the webclient to call
+# home. If the external encoded value needs to be different than this, due to
+# working through a proxy or docker port-remapping, the environment variable
+# WEBCLIENT_CLIENT_PROXY_PORT can be used to override this port only for the
+# front-facing client's sake.
+WEBSOCKET_CLIENT_PORT = 4002
# Interface addresses to listen to. If 0.0.0.0, listen to all. Use :: for IPv6.
WEBSOCKET_CLIENT_INTERFACE = '0.0.0.0'
# Actual URL for webclient component to reach the websocket. You only need
# to set this if you know you need it, like using some sort of proxy setup.
-# If given it must be on the form "ws://hostname" (WEBSOCKET_CLIENT_PORT will
-# be automatically appended). If left at None, the client will itself
-# figure out this url based on the server's hostname.
+# If given it must be on the form "ws[s]://hostname[:port]". If left at None,
+# the client will itself figure out this url based on the server's hostname.
+# e.g. ws://external.example.com or wss://external.example.com:443
WEBSOCKET_CLIENT_URL = None
# This determine's whether Evennia's custom admin page is used, or if the
# standard Django admin is used.
@@ -166,6 +171,7 @@ IDLE_COMMAND = "idle"
# given, this list is tried, in order, aborting on the first match.
# Add sets for languages/regions your accounts are likely to use.
# (see http://en.wikipedia.org/wiki/Character_encoding)
+# Telnet default encoding, unless specified by the client, will be ENCODINGS[0].
ENCODINGS = ["utf-8", "latin-1", "ISO-8859-1"]
# Regular expression applied to all output to a given session in order
# to strip away characters (usually various forms of decorations) for the benefit
@@ -348,6 +354,9 @@ LOCK_FUNC_MODULES = ("evennia.locks.lockfuncs", "server.conf.lockfuncs",)
INPUT_FUNC_MODULES = ["evennia.server.inputfuncs", "server.conf.inputfuncs"]
# Modules that contain prototypes for use with the spawner mechanism.
PROTOTYPE_MODULES = ["world.prototypes"]
+# Modules containining Prototype functions able to be embedded in prototype
+# definitions from in-game.
+PROT_FUNC_MODULES = ["evennia.prototypes.protfuncs"]
# Module holding settings/actions for the dummyrunner program (see the
# dummyrunner for more information)
DUMMYRUNNER_SETTINGS_MODULE = "evennia.server.profiling.dummyrunner_settings"
@@ -464,7 +473,7 @@ BASE_SCRIPT_TYPECLASS = "typeclasses.scripts.Script"
DEFAULT_HOME = "#2"
# The start position for new characters. Default is Limbo (#2).
# MULTISESSION_MODE = 0, 1 - used by default unloggedin create command
-# MULTISESSION_MODE = 2,3 - used by default character_create command
+# MULTISESSION_MODE = 2, 3 - used by default character_create command
START_LOCATION = "#2"
# Lookups of Attributes, Tags, Nicks, Aliases can be aggressively
# cached to avoid repeated database hits. This often gives noticeable
@@ -498,11 +507,16 @@ TIME_FACTOR = 2.0
# The starting point of your game time (the epoch), in seconds.
# In Python a value of 0 means Jan 1 1970 (use negatives for earlier
# start date). This will affect the returns from the utils.gametime
-# module.
+# module. If None, the server's first start-time is used as the epoch.
TIME_GAME_EPOCH = None
+# Normally, game time will only increase when the server runs. If this is True,
+# game time will not pause when the server reloads or goes offline. This setting
+# together with a time factor of 1 should keep the game in sync with
+# the real time (add a different epoch to shift time)
+TIME_IGNORE_DOWNTIMES = False
######################################################################
-# Inlinefunc
+# Inlinefunc & PrototypeFuncs
######################################################################
# Evennia supports inline function preprocessing. This allows users
# to supply inline calls on the form $func(arg, arg, ...) to do
@@ -514,6 +528,10 @@ INLINEFUNC_ENABLED = False
# is loaded from left-to-right, same-named functions will overload
INLINEFUNC_MODULES = ["evennia.utils.inlinefuncs",
"server.conf.inlinefuncs"]
+# Module holding handlers for OLCFuncs. These allow for embedding
+# functional code in prototypes
+PROTOTYPEFUNC_MODULES = ["evennia.utils.prototypefuncs",
+ "server.conf.prototypefuncs"]
######################################################################
# Default Account setup and access
@@ -534,9 +552,7 @@ INLINEFUNC_MODULES = ["evennia.utils.inlinefuncs",
# 3 - like mode 2, except multiple sessions can puppet one character, each
# session getting the same data.
MULTISESSION_MODE = 0
-# The maximum number of characters allowed for MULTISESSION_MODE 2,3. This is
-# checked by the default ooc char-creation command. Forced to 1 for
-# MULTISESSION_MODE 0 and 1.
+# The maximum number of characters allowed by the default ooc char-creation command
MAX_NR_CHARACTERS = 1
# The access hierarchy, in climbing order. A higher permission in the
# hierarchy includes access of all levels below it. Used by the perm()/pperm()
@@ -784,6 +800,16 @@ INSTALLED_APPS = (
# This should usually not be changed.
AUTH_USER_MODEL = "accounts.AccountDB"
+# Password validation plugins
+# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators
+AUTH_PASSWORD_VALIDATORS = [
+ {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
+ {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+ 'OPTIONS': {'min_length': 8}},
+ {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
+ {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
+ {'NAME': 'evennia.server.validators.EvenniaPasswordValidator'}]
+
# Use a custom test runner that just tests Evennia-specific apps.
TEST_RUNNER = 'evennia.server.tests.EvenniaTestSuiteRunner'
diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py
index 3f8b4cd742..1dc1902494 100644
--- a/evennia/typeclasses/attributes.py
+++ b/evennia/typeclasses/attributes.py
@@ -245,7 +245,7 @@ class AttributeHandler(object):
found from cache or database.
Notes:
When given a category only, a search for all objects
- of that cateogory is done and a the category *name* is is
+ of that cateogory is done and the category *name* is
stored. This tells the system on subsequent calls that the
list of cached attributes of this category is up-to-date
and that the cache can be queried for category matches
@@ -435,6 +435,7 @@ class AttributeHandler(object):
def __init__(self):
self.key = None
self.value = default
+ self.category = None
self.strvalue = str(default) if default is not None else None
ret = []
@@ -530,8 +531,8 @@ class AttributeHandler(object):
repeat-calling add when having many Attributes to add.
Args:
- indata (tuple): Tuples of varying length representing the
- Attribute to add to this object.
+ indata (list): List of tuples of varying length representing the
+ Attribute to add to this object. Supported tuples are
- `(key, value)`
- `(key, value, category)`
- `(key, value, category, lockstring)`
@@ -563,7 +564,7 @@ class AttributeHandler(object):
ntup = len(tup)
keystr = str(tup[0]).strip().lower()
new_value = tup[1]
- category = str(tup[2]).strip().lower() if ntup > 2 else None
+ category = str(tup[2]).strip().lower() if ntup > 2 and tup[2] is not None else None
lockstring = tup[3] if ntup > 3 else ""
attr_objs = self._getcache(keystr, category)
@@ -572,7 +573,7 @@ class AttributeHandler(object):
attr_obj = attr_objs[0]
# update an existing attribute object
attr_obj.db_category = category
- attr_obj.db_lock_storage = lockstring
+ attr_obj.db_lock_storage = lockstring or ''
attr_obj.save(update_fields=["db_category", "db_lock_storage"])
if strattr:
# store as a simple string (will not notify OOB handlers)
@@ -589,7 +590,7 @@ class AttributeHandler(object):
"db_attrtype": self._attrtype,
"db_value": None if strattr else to_pickle(new_value),
"db_strvalue": new_value if strattr else None,
- "db_lock_storage": lockstring}
+ "db_lock_storage": lockstring or ''}
new_attr = Attribute(**kwargs)
new_attr.save()
new_attrobjs.append(new_attr)
@@ -667,7 +668,7 @@ class AttributeHandler(object):
def all(self, accessing_obj=None, default_access=True):
"""
- Return all Attribute objects on this object.
+ Return all Attribute objects on this object, regardless of category.
Args:
accessing_obj (object, optional): Check the `attrread`
diff --git a/evennia/typeclasses/managers.py b/evennia/typeclasses/managers.py
index be9a6dd2c4..2ff78d310e 100644
--- a/evennia/typeclasses/managers.py
+++ b/evennia/typeclasses/managers.py
@@ -149,6 +149,7 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
# Tag manager methods
+
def get_tag(self, key=None, category=None, obj=None, tagtype=None, global_search=False):
"""
Return Tag objects by key, by category, by object (it is
@@ -228,25 +229,58 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
def get_by_tag(self, key=None, category=None, tagtype=None):
"""
- Return objects having tags with a given key or category or
- combination of the two.
+ Return objects having tags with a given key or category or combination of the two.
+ Also accepts multiple tags/category/tagtype
Args:
- key (str, optional): Tag key. Not case sensitive.
- category (str, optional): Tag category. Not case sensitive.
- tagtype (str or None, optional): 'type' of Tag, by default
+ 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.
+ tagtype (str, optional): 'type' of Tag, by default
this is either `None` (a normal Tag), `alias` or
- `permission`.
+ `permission`. This always apply to all queried tags.
+
Returns:
objects (list): Objects with matching tag.
+
+ Raises:
+ IndexError: If `key` and `category` are both lists and `category` is shorter
+ than `key`.
+
"""
+ if not (key or category):
+ return []
+
+ keys = make_iter(key) if key else []
+ categories = make_iter(category) if category else []
+ n_keys = len(keys)
+ n_categories = len(categories)
+
dbmodel = self.model.__dbclass__.__name__.lower()
- query = [("db_tags__db_tagtype", tagtype), ("db_tags__db_model", dbmodel)]
- if key:
- query.append(("db_tags__db_key", key.lower()))
- if category:
- query.append(("db_tags__db_category", category.lower()))
- return self.filter(**dict(query))
+ query = self.filter(db_tags__db_tagtype__iexact=tagtype,
+ db_tags__db_model__iexact=dbmodel).distinct()
+
+ if n_keys > 0:
+ # keys and/or categories given
+ if n_categories == 0:
+ categories = [None for _ in range(n_keys)]
+ elif n_categories == 1 and n_keys > 1:
+ cat = categories[0]
+ categories = [cat for _ in range(n_keys)]
+ elif 1 < n_categories < n_keys:
+ raise IndexError("get_by_tag needs a single category or a list of categories "
+ "the same length as the list of tags.")
+ for ikey, key in enumerate(keys):
+ query = query.filter(db_tags__db_key__iexact=key,
+ db_tags__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)
+
+ return query
def get_by_permission(self, key=None, category=None):
"""
diff --git a/evennia/typeclasses/tags.py b/evennia/typeclasses/tags.py
index e7c3bfdbb1..ea675366fd 100644
--- a/evennia/typeclasses/tags.py
+++ b/evennia/typeclasses/tags.py
@@ -269,14 +269,15 @@ class TagHandler(object):
def get(self, key=None, default=None, category=None, return_tagobj=False, return_list=False):
"""
- Get the tag for the given key or list of tags.
+ Get the tag for the given key, category or combination of the two.
Args:
- key (str or list): The tag or tags to retrieve.
+ key (str or list, optional): The tag or tags to retrieve.
default (any, optional): The value to return in case of no match.
category (str, optional): The Tag category to limit the
request to. Note that `None` is the valid, default
- category.
+ category. If no `key` is given, all tags of this category will be
+ returned.
return_tagobj (bool, optional): Return the Tag object itself
instead of a string representation of the Tag.
return_list (bool, optional): Always return a list, regardless
@@ -344,13 +345,14 @@ class TagHandler(object):
self._catcache = {}
self._cache_complete = False
- def all(self, return_key_and_category=False):
+ def all(self, return_key_and_category=False, return_objs=False):
"""
Get all tags in this handler, regardless of category.
Args:
return_key_and_category (bool, optional): Return a list of
tuples `[(key, category), ...]`.
+ return_objs (bool, optional): Return tag objects.
Returns:
tags (list): A list of tag keys `[tagkey, tagkey, ...]` or
@@ -364,6 +366,8 @@ class TagHandler(object):
if return_key_and_category:
# return tuple (key, category)
return [(to_str(tag.db_key), to_str(tag.db_category)) for tag in tags]
+ elif return_objs:
+ return tags
else:
return [to_str(tag.db_key) for tag in tags]
diff --git a/evennia/typeclasses/tests.py b/evennia/typeclasses/tests.py
new file mode 100644
index 0000000000..b4a2361aae
--- /dev/null
+++ b/evennia/typeclasses/tests.py
@@ -0,0 +1,59 @@
+"""
+Unit tests for typeclass base system
+
+"""
+
+from evennia.utils.test_resources import EvenniaTest
+
+# ------------------------------------------------------------
+# Manager tests
+# ------------------------------------------------------------
+
+
+class TestTypedObjectManager(EvenniaTest):
+ def _manager(self, methodname, *args, **kwargs):
+ return list(getattr(self.obj1.__class__.objects, methodname)(*args, **kwargs))
+
+ def test_get_by_tag_no_category(self):
+ self.obj1.tags.add("tag1")
+ self.obj1.tags.add("tag2")
+ self.obj1.tags.add("tag2c")
+ self.obj2.tags.add("tag2")
+ self.obj2.tags.add("tag2a")
+ self.obj2.tags.add("tag2b")
+ self.obj2.tags.add("tag3 with spaces")
+ self.obj2.tags.add("tag4")
+ self.obj2.tags.add("tag2c")
+ self.assertEquals(self._manager("get_by_tag", "tag1"), [self.obj1])
+ self.assertEquals(self._manager("get_by_tag", "tag2"), [self.obj1, self.obj2])
+ self.assertEquals(self._manager("get_by_tag", "tag2a"), [self.obj2])
+ self.assertEquals(self._manager("get_by_tag", "tag3 with spaces"), [self.obj2])
+ self.assertEquals(self._manager("get_by_tag", ["tag2a", "tag2b"]), [self.obj2])
+ self.assertEquals(self._manager("get_by_tag", ["tag2a", "tag1"]), [])
+ self.assertEquals(self._manager("get_by_tag", ["tag2a", "tag4", "tag2c"]), [self.obj2])
+
+ def test_get_by_tag_and_category(self):
+ self.obj1.tags.add("tag5", "category1")
+ self.obj1.tags.add("tag6", )
+ self.obj1.tags.add("tag7", "category1")
+ self.obj1.tags.add("tag6", "category3")
+ self.obj1.tags.add("tag7", "category4")
+ self.obj2.tags.add("tag5", "category1")
+ self.obj2.tags.add("tag5", "category2")
+ self.obj2.tags.add("tag6", "category3")
+ self.obj2.tags.add("tag7", "category1")
+ self.obj2.tags.add("tag7", "category5")
+ self.assertEquals(self._manager("get_by_tag", "tag5", "category1"), [self.obj1, self.obj2])
+ self.assertEquals(self._manager("get_by_tag", "tag6", "category1"), [])
+ self.assertEquals(self._manager("get_by_tag", "tag6", "category3"), [self.obj1, self.obj2])
+ self.assertEquals(self._manager("get_by_tag", ["tag5", "tag6"],
+ ["category1", "category3"]), [self.obj1, self.obj2])
+ self.assertEquals(self._manager("get_by_tag", ["tag5", "tag7"],
+ "category1"), [self.obj1, self.obj2])
+ self.assertEquals(self._manager("get_by_tag", category="category1"), [self.obj1, self.obj2])
+ self.assertEquals(self._manager("get_by_tag", category="category2"), [self.obj2])
+ self.assertEquals(self._manager("get_by_tag", category=["category1", "category3"]),
+ [self.obj1, self.obj2])
+ self.assertEquals(self._manager("get_by_tag", category=["category1", "category2"]),
+ [self.obj2])
+ self.assertEquals(self._manager("get_by_tag", category=["category5", "category4"]), [])
diff --git a/evennia/utils/create.py b/evennia/utils/create.py
index 1404e4caaa..36db7e5a60 100644
--- a/evennia/utils/create.py
+++ b/evennia/utils/create.py
@@ -54,7 +54,8 @@ _GA = object.__getattribute__
def create_object(typeclass=None, key=None, location=None, home=None,
permissions=None, locks=None, aliases=None, tags=None,
- destination=None, report_to=None, nohome=False):
+ destination=None, report_to=None, nohome=False, attributes=None,
+ nattributes=None):
"""
Create a new in-game object.
@@ -68,13 +69,18 @@ def create_object(typeclass=None, key=None, location=None, home=None,
permissions (list): A list of permission strings or tuples (permstring, category).
locks (str): one or more lockstrings, separated by semicolons.
aliases (list): A list of alternative keys or tuples (aliasstring, category).
- tags (list): List of tag keys or tuples (tagkey, category).
+ tags (list): List of tag keys or tuples (tagkey, category) or (tagkey, category, data).
destination (Object or str): Obj or #dbref to use as an Exit's
target.
report_to (Object): The object to return error messages to.
nohome (bool): This allows the creation of objects without a
default home location; only used when creating the default
location itself or during unittests.
+ attributes (list): Tuples on the form (key, value) or (key, value, category),
+ (key, value, lockstring) or (key, value, lockstring, default_access).
+ to set as Attributes on the new object.
+ nattributes (list): Non-persistent tuples on the form (key, value). Note that
+ adding this rarely makes sense since this data will not survive a reload.
Returns:
object (Object): A newly created object of the given typeclass.
@@ -95,6 +101,7 @@ def create_object(typeclass=None, key=None, location=None, home=None,
locks = make_iter(locks) if locks is not None else None
aliases = make_iter(aliases) if aliases is not None else None
tags = make_iter(tags) if tags is not None else None
+ attributes = make_iter(attributes) if attributes is not None else None
if isinstance(typeclass, basestring):
@@ -122,7 +129,8 @@ def create_object(typeclass=None, key=None, location=None, home=None,
# store the call signature for the signal
new_object._createdict = dict(key=key, location=location, destination=destination, home=home,
typeclass=typeclass.path, permissions=permissions, locks=locks,
- aliases=aliases, tags=tags, report_to=report_to, nohome=nohome)
+ aliases=aliases, tags=tags, report_to=report_to, nohome=nohome,
+ attributes=attributes, nattributes=nattributes)
# this will trigger the save signal which in turn calls the
# at_first_save hook on the typeclass, where the _createdict can be
# used.
@@ -139,7 +147,8 @@ object = create_object
def create_script(typeclass=None, key=None, obj=None, account=None, locks=None,
interval=None, start_delay=None, repeats=None,
- persistent=None, autostart=True, report_to=None, desc=None):
+ persistent=None, autostart=True, report_to=None, desc=None,
+ tags=None, attributes=None):
"""
Create a new script. All scripts are a combination of a database
object that communicates with the database, and an typeclass that
@@ -169,7 +178,9 @@ def create_script(typeclass=None, key=None, obj=None, account=None, locks=None,
created or if the `start` method must be called explicitly.
report_to (Object): The object to return error messages to.
desc (str): Optional description of script
-
+ tags (list): List of tags or tuples (tag, category).
+ attributes (list): List if tuples (key, value) or (key, value, category)
+ (key, value, lockstring) or (key, value, lockstring, default_access).
See evennia.scripts.manager for methods to manipulate existing
scripts in the database.
@@ -190,9 +201,9 @@ def create_script(typeclass=None, key=None, obj=None, account=None, locks=None,
if key:
kwarg["db_key"] = key
if account:
- kwarg["db_account"] = dbid_to_obj(account, _ScriptDB)
+ kwarg["db_account"] = dbid_to_obj(account, _AccountDB)
if obj:
- kwarg["db_obj"] = dbid_to_obj(obj, _ScriptDB)
+ kwarg["db_obj"] = dbid_to_obj(obj, _ObjectDB)
if interval:
kwarg["db_interval"] = interval
if start_delay:
@@ -203,6 +214,8 @@ def create_script(typeclass=None, key=None, obj=None, account=None, locks=None,
kwarg["db_persistent"] = persistent
if desc:
kwarg["db_desc"] = desc
+ tags = make_iter(tags) if tags is not None else None
+ attributes = make_iter(attributes) if attributes is not None else None
# create new instance
new_script = typeclass(**kwarg)
@@ -210,7 +223,8 @@ def create_script(typeclass=None, key=None, obj=None, account=None, locks=None,
# store the call signature for the signal
new_script._createdict = dict(key=key, obj=obj, account=account, locks=locks, interval=interval,
start_delay=start_delay, repeats=repeats, persistent=persistent,
- autostart=autostart, report_to=report_to)
+ autostart=autostart, report_to=report_to, desc=desc,
+ tags=tags, attributes=attributes)
# this will trigger the save signal which in turn calls the
# at_first_save hook on the typeclass, where the _createdict
# can be used.
diff --git a/evennia/utils/dbserialize.py b/evennia/utils/dbserialize.py
index c08704be0e..0ea024274e 100644
--- a/evennia/utils/dbserialize.py
+++ b/evennia/utils/dbserialize.py
@@ -29,7 +29,7 @@ except ImportError:
from pickle import dumps, loads
from django.core.exceptions import ObjectDoesNotExist
from django.contrib.contenttypes.models import ContentType
-from evennia.utils.utils import to_str, uses_database
+from evennia.utils.utils import to_str, uses_database, is_iter
from evennia.utils import logger
__all__ = ("to_pickle", "from_pickle", "do_pickle", "do_unpickle",
@@ -364,6 +364,31 @@ class _SaverDeque(_SaverMutable):
def rotate(self, *args):
self._data.rotate(*args)
+
+_DESERIALIZE_MAPPING = {_SaverList.__name__: list, _SaverDict.__name__: dict,
+ _SaverSet.__name__: set, _SaverOrderedDict.__name__: OrderedDict,
+ _SaverDeque.__name__: deque}
+
+
+def deserialize(obj):
+ """
+ Make sure to *fully* decouple a structure from the database, by turning all _Saver*-mutables
+ inside it back into their normal Python forms.
+
+ """
+ def _iter(obj):
+ typ = type(obj)
+ tname = typ.__name__
+ if tname in ('_SaverDict', 'dict'):
+ return {_iter(key): _iter(val) for key, val in obj.items()}
+ elif tname in _DESERIALIZE_MAPPING:
+ return _DESERIALIZE_MAPPING[tname](_iter(val) for val in obj)
+ elif is_iter(obj):
+ return typ(_iter(val) for val in obj)
+ return obj
+ return _iter(obj)
+
+
#
# serialization helpers
diff --git a/evennia/utils/evform.py b/evennia/utils/evform.py
index 55fa0ec9e2..3742f50da2 100644
--- a/evennia/utils/evform.py
+++ b/evennia/utils/evform.py
@@ -153,6 +153,22 @@ INVALID_FORMCHARS = r"\s\/\|\\\*\_\-\#\<\>\~\^\:\;\.\,"
_ANSI_ESCAPE = re.compile(r"\|\|")
+def _to_rect(lines):
+ """
+ Forces all lines to be as long as the longest
+
+ Args:
+ lines (list): list of `ANSIString`s
+
+ Returns:
+ (list): list of `ANSIString`s of
+ same length as the longest input line
+
+ """
+ maxl = max(len(line) for line in lines)
+ return [line + ' ' * (maxl - len(line)) for line in lines]
+
+
def _to_ansi(obj, regexable=False):
"convert to ANSIString"
if isinstance(obj, basestring):
@@ -184,7 +200,7 @@ class EvForm(object):
filename (str): Path to template file.
cells (dict): A dictionary mapping of {id:text}
tables (dict): A dictionary mapping of {id:EvTable}.
- form (dict): A dictionary of {"CELLCHAR":char,
+ form (dict): A dictionary of {"FORMCHAR":char,
"TABLECHAR":char,
"FORM":templatestring}
if this is given, filename is not read.
@@ -408,7 +424,9 @@ class EvForm(object):
self.tablechar = tablechar[0] if len(tablechar) > 1 else tablechar
# split into a list of list of lines. Form can be indexed with form[iy][ix]
- self.raw_form = _to_ansi(to_unicode(datadict.get("FORM", "")).split("\n"))
+ raw_form = _to_ansi(to_unicode(datadict.get("FORM", "")).split("\n"))
+ self.raw_form = _to_rect(raw_form)
+
# strip first line
self.raw_form = self.raw_form[1:] if self.raw_form else self.raw_form
@@ -440,7 +458,8 @@ def _test():
6: 5,
7: 18,
8: 10,
- 9: 3})
+ 9: 3,
+ "F": "rev 1"})
# create the EvTables
tableA = EvTable("HP", "MV", "MP",
table=[["**"], ["*****"], ["***"]],
diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py
index 69042e8516..e4a5e4c5e0 100644
--- a/evennia/utils/evmenu.py
+++ b/evennia/utils/evmenu.py
@@ -43,13 +43,18 @@ command definition too) with function definitions:
def node_with_other_name(caller, input_string):
# code
return text, options
+
+ def another_node(caller, input_string, **kwargs):
+ # code
+ return text, options
```
Where caller is the object using the menu and input_string is the
command entered by the user on the *previous* node (the command
entered to get to this node). The node function code will only be
executed once per node-visit and the system will accept nodes with
-both one or two arguments interchangeably.
+both one or two arguments interchangeably. It also accepts nodes
+that takes **kwargs.
The menu tree itself is available on the caller as
`caller.ndb._menutree`. This makes it a convenient place to store
@@ -63,23 +68,33 @@ menu is immediately exited and the default "look" command is called.
text (str, tuple or None): Text shown at this node. If a tuple, the
second element in the tuple is a help text to display at this
node when the user enters the menu help command there.
- options (tuple, dict or None): (
- {'key': name, # can also be a list of aliases. A special key is
- # "_default", which marks this option as the default
- # fallback when no other option matches the user input.
- 'desc': description, # optional description
- 'goto': nodekey, # node to go to when chosen. This can also be a callable with
- # caller and/or raw_string args. It must return a string
- # with the key pointing to the node to go to.
- 'exec': nodekey}, # node or callback to trigger as callback when chosen. This
- # will execute *before* going to the next node. Both node
- # and the explicit callback will be called as normal nodes
- # (with caller and/or raw_string args). If the callable/node
- # returns a single string (only), this will replace the current
- # goto location string in-place (if a goto callback, it will never fire).
- # Note that relying to much on letting exec assign the goto
- # location can make it hard to debug your menu logic.
- {...}, ...)
+ options (tuple, dict or None): If `None`, this exits the menu.
+ If a single dict, this is a single-option node. If a tuple,
+ it should be a tuple of option dictionaries. Option dicts have
+ the following keys:
+ - `key` (str or tuple, optional): What to enter to choose this option.
+ If a tuple, it must be a tuple of strings, where the first string is the
+ key which will be shown to the user and the others are aliases.
+ If unset, the options' number will be used. The special key `_default`
+ marks this option as the default fallback when no other option matches
+ the user input. There can only be one `_default` option per node. It
+ will not be displayed in the list.
+ - `desc` (str, optional): This describes what choosing the option will do.
+ - `goto` (str, tuple or callable): If string, should be the name of node to go to
+ when this option is selected. If a callable, it has the signature
+ `callable(caller[,raw_input][,**kwargs]). If a tuple, the first element
+ is the callable and the second is a dict with the **kwargs to pass to
+ the callable. Those kwargs will also be passed into the next node if possible.
+ Such a callable should return either a str or a (str, dict), where the
+ string is the name of the next node to go to and the dict is the new,
+ (possibly modified) kwarg to pass into the next node. If the callable returns
+ None or the empty string, the current node will be revisited.
+ - `exec` (str, callable or tuple, optional): This takes the same input as `goto` above
+ and runs before it. If given a node name, the node will be executed but will not
+ be considered the next node. If node/callback returns str or (str, dict), these will
+ replace the `goto` step (`goto` callbacks will not fire), with the string being the
+ next node name and the optional dict acting as the kwargs-input for the next node.
+ If an exec callable returns the empty string (only), the current node is re-run.
If key is not given, the option will automatically be identified by
its number 1..N.
@@ -95,7 +110,7 @@ Example:
"This is help text for this node")
options = ({"key": "testing",
"desc": "Select this to go to node 2",
- "goto": "node2",
+ "goto": ("node2", {"foo": "bar"}),
"exec": "callback1"},
{"desc": "Go to node 3.",
"goto": "node3"})
@@ -108,12 +123,13 @@ Example:
# by the normal 'goto' option key above.
caller.msg("Callback called!")
- def node2(caller):
+ def node2(caller, **kwargs):
text = '''
This is node 2. It only allows you to go back
to the original node1. This extra indent will
- be stripped. We don't include a help text.
- '''
+ be stripped. We don't include a help text but
+ here are the variables passed to us: {}
+ '''.format(kwargs)
options = {"goto": "node1"}
return text, options
@@ -148,16 +164,17 @@ evennia.utils.evmenu`.
"""
from __future__ import print_function
+import random
+import inspect
from builtins import object, range
-from textwrap import dedent
from inspect import isfunction, getargspec
from django.conf import settings
from evennia import Command, CmdSet
from evennia.utils import logger
from evennia.utils.evtable import EvTable
from evennia.utils.ansi import strip_ansi
-from evennia.utils.utils import mod_import, make_iter, pad, m_len
+from evennia.utils.utils import mod_import, make_iter, pad, to_str, m_len, is_iter, dedent, crop
from evennia.commands import cmdhandler
# read from protocol NAWS later?
@@ -172,7 +189,8 @@ _CMD_NOINPUT = cmdhandler.CMD_NOINPUT
# i18n
from django.utils.translation import ugettext as _
-_ERR_NOT_IMPLEMENTED = _("Menu node '{nodename}' is not implemented. Make another choice.")
+_ERR_NOT_IMPLEMENTED = _("Menu node '{nodename}' is either not implemented or "
+ "caused an error. Make another choice.")
_ERR_GENERAL = _("Error in menu node '{nodename}'.")
_ERR_NO_OPTION_DESC = _("No description.")
_HELP_FULL = _("Commands: , help, quit")
@@ -260,7 +278,7 @@ class CmdEvMenuNode(Command):
err = "Menu object not found as %s.ndb._menutree!" % orig_caller
orig_caller.msg(err) # don't give the session as a kwarg here, direct to original
raise EvMenuError(err)
- # we must do this after the caller with the menui has been correctly identified since it
+ # we must do this after the caller with the menu has been correctly identified since it
# can be either Account, Object or Session (in the latter case this info will be superfluous).
caller.ndb._menutree._session = self.session
# we have a menu, use it.
@@ -305,7 +323,7 @@ class EvMenu(object):
auto_quit=True, auto_look=True, auto_help=True,
cmd_on_exit="look",
persistent=False, startnode_input="", session=None,
- **kwargs):
+ debug=False, **kwargs):
"""
Initialize the menu tree and start the caller onto the first node.
@@ -358,15 +376,21 @@ class EvMenu(object):
*pickle*. When the server is reloaded, the latest node shown will be completely
re-run with the same input arguments - so be careful if you are counting
up some persistent counter or similar - the counter may be run twice if
- reload happens on the node that does that.
- startnode_input (str, optional): Send an input text to `startnode` as if
- a user input text from a fictional previous node. When the server reloads,
- the latest visited node will be re-run using this kwarg.
+ reload happens on the node that does that. Note that if `debug` is True,
+ this setting is ignored and assumed to be False.
+ startnode_input (str or (str, dict), optional): Send an input text to `startnode` as if
+ a user input text from a fictional previous node. If including the dict, this will
+ be passed as **kwargs to that node. When the server reloads,
+ the latest visited node will be re-run as `node(caller, raw_string, **kwargs)`.
session (Session, optional): This is useful when calling EvMenu from an account
in multisession mode > 2. Note that this session only really relevant
for the very first display of the first node - after that, EvMenu itself
will keep the session updated from the command input. So a persistent
menu will *not* be using this same session anymore after a reload.
+ debug (bool, optional): If set, the 'menudebug' command will be made available
+ by default in all nodes of the menu. This will print out the current state of
+ the menu. Deactivate for production use! When the debug flag is active, the
+ `persistent` flag is deactivated.
Kwargs:
any (any): All kwargs will become initialization variables on `caller.ndb._menutree`,
@@ -390,7 +414,8 @@ class EvMenu(object):
"""
self._startnode = startnode
self._menutree = self._parse_menudata(menudata)
- self._persistent = persistent
+ self._persistent = persistent if not debug else False
+ self._quitting = False
if startnode not in self._menutree:
raise EvMenuError("Start node '%s' not in menu tree!" % startnode)
@@ -403,6 +428,7 @@ class EvMenu(object):
self.auto_quit = auto_quit
self.auto_look = auto_look
self.auto_help = auto_help
+ self.debug_mode = debug
self._session = session
if isinstance(cmd_on_exit, str):
# At this point menu._session will have been replaced by the
@@ -417,6 +443,12 @@ class EvMenu(object):
self.nodetext = None
self.helptext = None
self.options = None
+ self.nodename = None
+ self.node_kwargs = {}
+
+ # used for testing
+ self.test_options = {}
+ self.test_nodetext = ""
# assign kwargs as initialization vars on ourselves.
if set(("_startnode", "_menutree", "_session", "_persistent",
@@ -463,8 +495,13 @@ class EvMenu(object):
menu_cmdset.priority = int(cmdset_priority)
self.caller.cmdset.add(menu_cmdset, permanent=persistent)
+ startnode_kwargs = {}
+ if isinstance(startnode_input, (tuple, list)) and len(startnode_input) > 1:
+ startnode_input, startnode_kwargs = startnode_input[:2]
+ if not isinstance(startnode_kwargs, dict):
+ raise EvMenuError("startnode_input must be either a str or a tuple (str, dict).")
# start the menu
- self.goto(self._startnode, startnode_input)
+ self.goto(self._startnode, startnode_input, **startnode_kwargs)
def _parse_menudata(self, menudata):
"""
@@ -519,7 +556,43 @@ class EvMenu(object):
# format the entire node
return self.node_formatter(nodetext, optionstext)
- def _execute_node(self, nodename, raw_string):
+ def _safe_call(self, callback, raw_string, **kwargs):
+ """
+ Call a node-like callable, with a variable number of raw_string, *args, **kwargs, all of
+ which should work also if not present (only `caller` is always required). Return its result.
+
+ """
+ try:
+ try:
+ nargs = len(getargspec(callback).args)
+ except TypeError:
+ raise EvMenuError("Callable {} doesn't accept any arguments!".format(callback))
+ supports_kwargs = bool(getargspec(callback).keywords)
+ if nargs <= 0:
+ raise EvMenuError("Callable {} doesn't accept any arguments!".format(callback))
+
+ if supports_kwargs:
+ if nargs > 1:
+ ret = callback(self.caller, raw_string, **kwargs)
+ # callback accepting raw_string, **kwargs
+ else:
+ # callback accepting **kwargs
+ ret = callback(self.caller, **kwargs)
+ elif nargs > 1:
+ # callback accepting raw_string
+ ret = callback(self.caller, raw_string)
+ else:
+ # normal callback, only the caller as arg
+ ret = callback(self.caller)
+ except EvMenuError:
+ errmsg = _ERR_GENERAL.format(nodename=callback)
+ self.caller.msg(errmsg, self._session)
+ logger.log_trace()
+ raise
+
+ return ret
+
+ def _execute_node(self, nodename, raw_string, **kwargs):
"""
Execute a node.
@@ -528,6 +601,7 @@ class EvMenu(object):
raw_string (str): The raw default string entered on the
previous node (only used if the node accepts it as an
argument)
+ kwargs (any, optional): Optional kwargs for the node.
Returns:
nodetext, options (tuple): The node text (a string or a
@@ -540,47 +614,27 @@ class EvMenu(object):
self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session)
raise EvMenuError
try:
- # the node should return data as (text, options)
- if len(getargspec(node).args) > 1:
- # a node accepting raw_string
- nodetext, options = node(self.caller, raw_string)
+ ret = self._safe_call(node, raw_string, **kwargs)
+ if isinstance(ret, (tuple, list)) and len(ret) > 1:
+ nodetext, options = ret[:2]
else:
- # a normal node, only accepting caller
- nodetext, options = node(self.caller)
+ nodetext, options = ret, None
except KeyError:
self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session)
+ logger.log_trace()
raise EvMenuError
except Exception:
self.caller.msg(_ERR_GENERAL.format(nodename=nodename), session=self._session)
+ logger.log_trace()
raise
+
+ # store options to make them easier to test
+ self.test_options = options
+ self.test_nodetext = nodetext
+
return nodetext, options
- def display_nodetext(self):
- self.caller.msg(self.nodetext, session=self._session)
-
- def display_helptext(self):
- self.caller.msg(self.helptext, session=self._session)
-
- def callback_goto(self, callback, goto, raw_string):
- """
- Call callback and goto in sequence.
-
- Args:
- callback (callable or str): Callback to run before goto. If
- the callback returns a string, this is used to replace
- the `goto` string before going to the next node.
- goto (str): The target node to go to next (unless replaced
- by `callable`)..
- raw_string (str): The original user input.
-
- """
- if callback:
- # replace goto only if callback returns
- goto = self.callback(callback, raw_string) or goto
- if goto:
- self.goto(goto, raw_string)
-
- def callback(self, nodename, raw_string):
+ def run_exec(self, nodename, raw_string, **kwargs):
"""
Run a function or node as a callback (with the 'exec' option key).
@@ -592,6 +646,8 @@ class EvMenu(object):
raw_string (str): The raw default string entered on the
previous node (only used if the node accepts it as an
argument)
+ kwargs (any): These are optional kwargs passed into goto
+
Returns:
new_goto (str or None): A replacement goto location string or
None (no replacement).
@@ -602,64 +658,111 @@ class EvMenu(object):
relying on this.
"""
- if callable(nodename):
- # this is a direct callable - execute it directly
- try:
- if len(getargspec(nodename).args) > 1:
- # callable accepting raw_string
- ret = nodename(self.caller, raw_string)
- else:
- # normal callable, only the caller as arg
- ret = nodename(self.caller)
- except Exception:
- self.caller.msg(_ERR_GENERAL.format(nodename=nodename), self._session)
- raise
- else:
- # nodename is a string; lookup as node
- try:
+ try:
+ if callable(nodename):
+ # this is a direct callable - execute it directly
+ ret = self._safe_call(nodename, raw_string, **kwargs)
+ if isinstance(ret, (tuple, list)):
+ if not len(ret) > 1 or not isinstance(ret[1], dict):
+ raise EvMenuError("exec callable must return either None, str or (str, dict)")
+ ret, kwargs = ret[:2]
+ else:
+ # nodename is a string; lookup as node and run as node in-place (don't goto it)
# execute the node
- ret = self._execute_node(nodename, raw_string)
- except EvMenuError as err:
- errmsg = "Error in exec '%s' (input: '%s'): %s" % (nodename, raw_string, err)
- self.caller.msg("|r%s|n" % errmsg)
- logger.log_trace(errmsg)
- return
+ ret = self._execute_node(nodename, raw_string, **kwargs)
+ if isinstance(ret, (tuple, list)):
+ if not len(ret) > 1 and ret[1] and not isinstance(ret[1], dict):
+ raise EvMenuError("exec node must return either None, str or (str, dict)")
+ ret, kwargs = ret[:2]
+ except EvMenuError as err:
+ errmsg = "Error in exec '%s' (input: '%s'): %s" % (nodename, raw_string.rstrip(), err)
+ self.caller.msg("|r%s|n" % errmsg)
+ logger.log_trace(errmsg)
+ return
+
if isinstance(ret, basestring):
# only return a value if a string (a goto target), ignore all other returns
- return ret
+ if not ret:
+ # an empty string - rerun the same node
+ return self.nodename
+ return ret, kwargs
return None
- def goto(self, nodename, raw_string):
+ def extract_goto_exec(self, nodename, option_dict):
"""
- Run a node by name
+ Helper: Get callables and their eventual kwargs.
+
+ Args:
+ nodename (str): The current node name (used for error reporting).
+ option_dict (dict): The seleted option's dict.
+
+ Returns:
+ goto (str, callable or None): The goto directive in the option.
+ goto_kwargs (dict): Kwargs for `goto` if the former is callable, otherwise empty.
+ execute (callable or None): Executable given by the `exec` directive.
+ exec_kwargs (dict): Kwargs for `execute` if it's callable, otherwise empty.
+
+ """
+ goto_kwargs, exec_kwargs = {}, {}
+ goto, execute = option_dict.get("goto", None), option_dict.get("exec", None)
+ if goto and isinstance(goto, (tuple, list)):
+ if len(goto) > 1:
+ goto, goto_kwargs = goto[:2] # ignore any extra arguments
+ if not hasattr(goto_kwargs, "__getitem__"):
+ # not a dict-like structure
+ raise EvMenuError("EvMenu node {}: goto kwargs is not a dict: {}".format(
+ nodename, goto_kwargs))
+ else:
+ goto = goto[0]
+ if execute and isinstance(execute, (tuple, list)):
+ if len(execute) > 1:
+ execute, exec_kwargs = execute[:2] # ignore any extra arguments
+ if not hasattr(exec_kwargs, "__getitem__"):
+ # not a dict-like structure
+ raise EvMenuError("EvMenu node {}: exec kwargs is not a dict: {}".format(
+ nodename, goto_kwargs))
+ else:
+ execute = execute[0]
+ return goto, goto_kwargs, execute, exec_kwargs
+
+ def goto(self, nodename, raw_string, **kwargs):
+ """
+ Run a node by name, optionally dynamically generating that name first.
Args:
nodename (str or callable): Name of node or a callable
- to be called as `function(caller, raw_string)` or `function(caller)`
- to return the actual goto string.
+ to be called as `function(caller, raw_string, **kwargs)` or
+ `function(caller, **kwargs)` to return the actual goto string or
+ a ("nodename", kwargs) tuple.
raw_string (str): The raw default string entered on the
previous node (only used if the node accepts it as an
argument)
+ Kwargs:
+ any: Extra arguments to goto callables.
"""
+
if callable(nodename):
- try:
- if len(getargspec(nodename).args) > 1:
- # callable accepting raw_string
- nodename = nodename(self.caller, raw_string)
- else:
- nodename = nodename(self.caller)
- except Exception:
- self.caller.msg(_ERR_GENERAL.format(nodename=nodename), self._session)
- raise
+ # run the "goto" callable, if possible
+ inp_nodename = nodename
+ nodename = self._safe_call(nodename, raw_string, **kwargs)
+ if isinstance(nodename, (tuple, list)):
+ if not len(nodename) > 1 or not isinstance(nodename[1], dict):
+ raise EvMenuError(
+ "{}: goto callable must return str or (str, dict)".format(inp_nodename))
+ nodename, kwargs = nodename[:2]
+ if not nodename:
+ # no nodename return. Re-run current node
+ nodename = self.nodename
try:
- # execute the node, make use of the returns.
- nodetext, options = self._execute_node(nodename, raw_string)
+ # execute the found node, make use of the returns.
+ nodetext, options = self._execute_node(nodename, raw_string, **kwargs)
except EvMenuError:
return
if self._persistent:
- self.caller.attributes.add("_menutree_saved_startnode", (nodename, raw_string))
+ self.caller.attributes.add("_menutree_saved_startnode",
+ (nodename, (raw_string, kwargs)))
# validation of the node return values
helptext = ""
@@ -680,26 +783,29 @@ class EvMenu(object):
for inum, dic in enumerate(options):
# fix up the option dicts
keys = make_iter(dic.get("key"))
+ desc = dic.get("desc", dic.get("text", None))
if "_default" in keys:
keys = [key for key in keys if key != "_default"]
- desc = dic.get("desc", dic.get("text", _ERR_NO_OPTION_DESC).strip())
- goto, execute = dic.get("goto", None), dic.get("exec", None)
- self.default = (goto, execute)
+ goto, goto_kwargs, execute, exec_kwargs = self.extract_goto_exec(nodename, dic)
+ self.default = (goto, goto_kwargs, execute, exec_kwargs)
else:
+ # use the key (only) if set, otherwise use the running number
keys = list(make_iter(dic.get("key", str(inum + 1).strip())))
- desc = dic.get("desc", dic.get("text", _ERR_NO_OPTION_DESC).strip())
- goto, execute = dic.get("goto", None), dic.get("exec", None)
+ goto, goto_kwargs, execute, exec_kwargs = self.extract_goto_exec(nodename, dic)
if keys:
display_options.append((keys[0], desc))
for key in keys:
if goto or execute:
- self.options[strip_ansi(key).strip().lower()] = (goto, execute)
+ self.options[strip_ansi(key).strip().lower()] = \
+ (goto, goto_kwargs, execute, exec_kwargs)
self.nodetext = self._format_node(nodetext, display_options)
+ self.node_kwargs = kwargs
+ self.nodename = nodename
# handle the helptext
if helptext:
- self.helptext = helptext
+ self.helptext = self.helptext_formatter(helptext)
elif options:
self.helptext = _HELP_FULL if self.auto_quit else _HELP_NO_QUIT
else:
@@ -709,17 +815,89 @@ class EvMenu(object):
if not options:
self.close_menu()
+ def run_exec_then_goto(self, runexec, goto, raw_string, runexec_kwargs=None, goto_kwargs=None):
+ """
+ Call 'exec' callback and goto (which may also be a callable) in sequence.
+
+ Args:
+ runexec (callable or str): Callback to run before goto. If
+ the callback returns a string, this is used to replace
+ the `goto` string/callable before being passed into the goto handler.
+ goto (str): The target node to go to next (may be replaced
+ by `runexec`)..
+ raw_string (str): The original user input.
+ runexec_kwargs (dict, optional): Optional kwargs for runexec.
+ goto_kwargs (dict, optional): Optional kwargs for goto.
+
+ """
+ if runexec:
+ # replace goto only if callback returns
+ goto, goto_kwargs = (
+ self.run_exec(runexec, raw_string,
+ **(runexec_kwargs if runexec_kwargs else {})) or
+ (goto, goto_kwargs))
+ if goto:
+ self.goto(goto, raw_string, **(goto_kwargs if goto_kwargs else {}))
+
def close_menu(self):
"""
Shutdown menu; occurs when reaching the end node or using the quit command.
"""
- self.caller.cmdset.remove(EvMenuCmdSet)
- del self.caller.ndb._menutree
- if self._persistent:
- self.caller.attributes.remove("_menutree_saved")
- self.caller.attributes.remove("_menutree_saved_startnode")
- if self.cmd_on_exit is not None:
- self.cmd_on_exit(self.caller, self)
+ if not self._quitting:
+ # avoid multiple calls from different sources
+ self._quitting = True
+ self.caller.cmdset.remove(EvMenuCmdSet)
+ del self.caller.ndb._menutree
+ if self._persistent:
+ self.caller.attributes.remove("_menutree_saved")
+ self.caller.attributes.remove("_menutree_saved_startnode")
+ if self.cmd_on_exit is not None:
+ self.cmd_on_exit(self.caller, self)
+
+ def print_debug_info(self, arg):
+ """
+ Messages the caller with the current menu state, for debug purposes.
+
+ Args:
+ arg (str): Arg to debug instruction, either nothing, 'full' or the name
+ of a property to inspect.
+
+ """
+ all_props = inspect.getmembers(self)
+ all_methods = [name for name, _ in inspect.getmembers(self, predicate=inspect.ismethod)]
+ all_builtins = [name for name, _ in inspect.getmembers(self, predicate=inspect.isbuiltin)]
+ props = {prop: value for prop, value in all_props if prop not in all_methods and
+ prop not in all_builtins and not prop.endswith("__")}
+
+ local = {key: var for key, var in locals().items()
+ if key not in all_props and not key.endswith("__")}
+
+ if arg:
+ if arg in props:
+ debugtxt = " |y* {}:|n\n{}".format(arg, props[arg])
+ elif arg in local:
+ debugtxt = " |y* {}:|n\n{}".format(arg, local[arg])
+ elif arg == 'full':
+ debugtxt = ("|yMENU DEBUG full ... |n\n" + "\n".join(
+ "|y *|n {}: {}".format(key, val)
+ for key, val in sorted(props.items())) +
+ "\n |yLOCAL VARS:|n\n" + "\n".join(
+ "|y *|n {}: {}".format(key, val)
+ for key, val in sorted(local.items())) +
+ "\n |y... END MENU DEBUG|n")
+ else:
+ debugtxt = "|yUsage: menudebug full||n"
+ else:
+ debugtxt = ("|yMENU DEBUG properties ... |n\n" + "\n".join(
+ "|y *|n {}: {}".format(
+ key, crop(to_str(val, force_string=True), width=50))
+ for key, val in sorted(props.items())) +
+ "\n |yLOCAL VARS:|n\n" + "\n".join(
+ "|y *|n {}: {}".format(
+ key, crop(to_str(val, force_string=True), width=50))
+ for key, val in sorted(local.items())) +
+ "\n |y... END MENU DEBUG|n")
+ self.caller.msg(debugtxt)
def parse_input(self, raw_string):
"""
@@ -734,25 +912,33 @@ class EvMenu(object):
should also report errors directly to the user.
"""
- cmd = raw_string.strip().lower()
+ cmd = strip_ansi(raw_string.strip().lower())
if cmd in self.options:
# this will take precedence over the default commands
# below
- goto, callback = self.options[cmd]
- self.callback_goto(callback, goto, raw_string)
+ goto, goto_kwargs, execfunc, exec_kwargs = self.options[cmd]
+ self.run_exec_then_goto(execfunc, goto, raw_string, exec_kwargs, goto_kwargs)
elif self.auto_look and cmd in ("look", "l"):
self.display_nodetext()
elif self.auto_help and cmd in ("help", "h"):
self.display_helptext()
elif self.auto_quit and cmd in ("quit", "q", "exit"):
self.close_menu()
+ elif self.debug_mode and cmd.startswith("menudebug"):
+ self.print_debug_info(cmd[9:].strip())
elif self.default:
- goto, callback = self.default
- self.callback_goto(callback, goto, raw_string)
+ goto, goto_kwargs, execfunc, exec_kwargs = self.default
+ self.run_exec_then_goto(execfunc, goto, raw_string, exec_kwargs, goto_kwargs)
else:
self.caller.msg(_HELP_NO_OPTION_MATCH, session=self._session)
+ def display_nodetext(self):
+ self.caller.msg(self.nodetext, session=self._session)
+
+ def display_helptext(self):
+ self.caller.msg(self.helptext, session=self._session)
+
# formatters - override in a child class
def nodetext_formatter(self, nodetext):
@@ -766,7 +952,20 @@ class EvMenu(object):
nodetext (str): The formatted node text.
"""
- return dedent(nodetext).strip()
+ return dedent(nodetext.strip('\n'), baseline_index=0).rstrip()
+
+ def helptext_formatter(self, helptext):
+ """
+ Format the node's help text
+
+ Args:
+ helptext (str): The unformatted help text for the node.
+
+ Returns:
+ helptext (str): The formatted help text.
+
+ """
+ return dedent(helptext.strip('\n'), baseline_index=0).rstrip()
def options_formatter(self, optionlist):
"""
@@ -795,16 +994,17 @@ class EvMenu(object):
for key, desc in optionlist:
if not (key or desc):
continue
+ desc_string = ": %s" % desc if desc else ""
table_width_max = max(table_width_max,
max(m_len(p) for p in key.split("\n")) +
- max(m_len(p) for p in desc.split("\n")) + colsep)
+ max(m_len(p) for p in desc_string.split("\n")) + colsep)
raw_key = strip_ansi(key)
if raw_key != key:
# already decorations in key definition
- table.append(" |lc%s|lt%s|le: %s" % (raw_key, key, desc))
+ table.append(" |lc%s|lt%s|le%s" % (raw_key, key, desc_string))
else:
# add a default white color to key
- table.append(" |lc%s|lt|w%s|n|le: %s" % (raw_key, raw_key, desc))
+ table.append(" |lc%s|lt|w%s|n|le%s" % (raw_key, raw_key, desc_string))
ncols = (_MAX_TEXT_WIDTH // table_width_max) + 1 # number of ncols
@@ -845,14 +1045,188 @@ class EvMenu(object):
node (str): The formatted node to display.
"""
+ if self._session:
+ screen_width = self._session.protocol_flags.get(
+ "SCREENWIDTH", {0: _MAX_TEXT_WIDTH})[0]
+ else:
+ screen_width = _MAX_TEXT_WIDTH
+
nodetext_width_max = max(m_len(line) for line in nodetext.split("\n"))
options_width_max = max(m_len(line) for line in optionstext.split("\n"))
- total_width = max(options_width_max, nodetext_width_max)
+ total_width = min(screen_width, max(options_width_max, nodetext_width_max))
separator1 = "_" * total_width + "\n\n" if nodetext_width_max else ""
separator2 = "\n" + "_" * total_width + "\n\n" if total_width else ""
return separator1 + "|n" + nodetext + "|n" + separator2 + "|n" + optionstext
+# -----------------------------------------------------------
+#
+# List node (decorator turning a node into a list with
+# look/edit/add functionality for the elements)
+#
+# -----------------------------------------------------------
+
+def list_node(option_generator, select=None, pagesize=10):
+ """
+ Decorator for making an EvMenu node into a multi-page list node. Will add new options,
+ prepending those options added in the node.
+
+ Args:
+ option_generator (callable or list): A list of strings indicating the options, or a callable
+ that is called as option_generator(caller) to produce such a list.
+ select (callable or str, optional): Node to redirect a selection to. Its `**kwargs` will
+ contain the `available_choices` list and `selection` will hold one of the elements in
+ that list. If a callable, it will be called as select(caller, menuchoice) where
+ menuchoice is the chosen option as a string. Should return the target node to goto after
+ this selection (or None to repeat the list-node). Note that if this is not given, the
+ decorated node must itself provide a way to continue from the node!
+ pagesize (int): How many options to show per page.
+
+ Example:
+ @list_node(['foo', 'bar'], select)
+ def node_index(caller):
+ text = "describing the list"
+ return text, []
+
+ Notes:
+ All normal `goto` or `exec` callables returned from the decorated nodes will, if they accept
+ **kwargs, get a new kwarg 'available_choices' injected. These are the ordered list of named
+ options (descs) visible on the current node page.
+
+ """
+
+ def decorator(func):
+
+ def _select_parser(caller, raw_string, **kwargs):
+ """
+ Parse the select action
+ """
+ available_choices = kwargs.get("available_choices", [])
+
+ try:
+ index = int(raw_string.strip()) - 1
+ selection = available_choices[index]
+ except Exception:
+ caller.msg("|rInvalid choice.|n")
+ else:
+ if callable(select):
+ try:
+ if bool(getargspec(select).keywords):
+ return select(caller, selection, available_choices=available_choices)
+ else:
+ return select(caller, selection)
+ except Exception:
+ logger.log_trace()
+ elif select:
+ # we assume a string was given, we inject the result into the kwargs
+ # to pass on to the next node
+ kwargs['selection'] = selection
+ return str(select)
+ # this means the previous node will be re-run with these same kwargs
+ return None
+
+ def _list_node(caller, raw_string, **kwargs):
+
+ option_list = option_generator(caller) \
+ if callable(option_generator) else option_generator
+
+ npages = 0
+ page_index = 0
+ page = []
+ options = []
+
+ if option_list:
+ nall_options = len(option_list)
+ pages = [option_list[ind:ind + pagesize]
+ for ind in range(0, nall_options, pagesize)]
+ npages = len(pages)
+
+ page_index = max(0, min(npages - 1, kwargs.get("optionpage_index", 0)))
+ page = pages[page_index]
+
+ text = ""
+ extra_text = None
+
+ # dynamic, multi-page option list. Each selection leads to the `select`
+ # callback being called with a result from the available choices
+ options.extend([{"desc": opt,
+ "goto": (_select_parser,
+ {"available_choices": page})} for opt in page])
+
+ if npages > 1:
+ # if the goto callable returns None, the same node is rerun, and
+ # kwargs not used by the callable are passed on to the node. This
+ # allows us to call ourselves over and over, using different kwargs.
+ options.append({"key": ("|Wcurrent|n", "c"),
+ "desc": "|W({}/{})|n".format(page_index + 1, npages),
+ "goto": (lambda caller: None,
+ {"optionpage_index": page_index})})
+ if page_index > 0:
+ options.append({"key": ("|wp|Wrevious page|n", "p"),
+ "goto": (lambda caller: None,
+ {"optionpage_index": page_index - 1})})
+ if page_index < npages - 1:
+ options.append({"key": ("|wn|Wext page|n", "n"),
+ "goto": (lambda caller: None,
+ {"optionpage_index": page_index + 1})})
+
+ # add data from the decorated node
+
+ decorated_options = []
+ supports_kwargs = bool(getargspec(func).keywords)
+ try:
+ if supports_kwargs:
+ text, decorated_options = func(caller, raw_string, **kwargs)
+ else:
+ text, decorated_options = func(caller, raw_string)
+ except TypeError:
+ try:
+ if supports_kwargs:
+ text, decorated_options = func(caller, **kwargs)
+ else:
+ text, decorated_options = func(caller)
+ except Exception:
+ raise
+ except Exception:
+ logger.log_trace()
+ else:
+ if isinstance(decorated_options, dict):
+ decorated_options = [decorated_options]
+ else:
+ decorated_options = make_iter(decorated_options)
+
+ extra_options = []
+ if isinstance(decorated_options, dict):
+ decorated_options = [decorated_options]
+ for eopt in decorated_options:
+ cback = ("goto" in eopt and "goto") or ("exec" in eopt and "exec") or None
+ if cback:
+ signature = eopt[cback]
+ if callable(signature):
+ # callable with no kwargs defined
+ eopt[cback] = (signature, {"available_choices": page})
+ elif is_iter(signature):
+ if len(signature) > 1 and isinstance(signature[1], dict):
+ signature[1]["available_choices"] = page
+ eopt[cback] = signature
+ elif signature:
+ # a callable alone in a tuple (i.e. no previous kwargs)
+ eopt[cback] = (signature[0], {"available_choices": page})
+ else:
+ # malformed input.
+ logger.log_err("EvMenu @list_node decorator found "
+ "malformed option to decorate: {}".format(eopt))
+ extra_options.append(eopt)
+
+ options.extend(extra_options)
+ text = text + "\n\n" + extra_text if extra_text else text
+
+ return text, options
+
+ return _list_node
+ return decorator
+
+
# -------------------------------------------------------------------------------------------------
#
# Simple input shortcuts
@@ -992,6 +1366,10 @@ def get_input(caller, prompt, callback, session=None, *args, **kwargs):
#
# -------------------------------------------------------------
+def _generate_goto(caller, **kwargs):
+ return kwargs.get("name", "test_dynamic_node"), {"name": "replaced!"}
+
+
def test_start_node(caller):
menu = caller.ndb._menutree
text = """
@@ -1016,6 +1394,9 @@ def test_start_node(caller):
{"key": ("|yV|niew", "v"),
"desc": "View your own name",
"goto": "test_view_node"},
+ {"key": ("|yD|nynamic", "d"),
+ "desc": "Dynamic node",
+ "goto": (_generate_goto, {"name": "test_dynamic_node"})},
{"key": ("|yQ|nuit", "quit", "q", "Q"),
"desc": "Quit this menu example.",
"goto": "test_end_node"},
@@ -1025,7 +1406,7 @@ def test_start_node(caller):
def test_look_node(caller):
- text = ""
+ text = "This is a custom look location!"
options = {"key": ("|yL|nook", "l"),
"desc": "Go back to the previous menu.",
"goto": "test_start_node"}
@@ -1052,12 +1433,11 @@ def test_set_node(caller):
""")
options = {"key": ("back (default)", "_default"),
- "desc": "back to main",
"goto": "test_start_node"}
return text, options
-def test_view_node(caller):
+def test_view_node(caller, **kwargs):
text = """
Your name is |g%s|n!
@@ -1067,9 +1447,14 @@ def test_view_node(caller):
-always- use numbers (1...N) to refer to listed options also if you
don't see a string option key (try it!).
""" % caller.key
- options = {"desc": "back to main",
- "goto": "test_start_node"}
- return text, options
+ if kwargs.get("executed_from_dynamic_node", False):
+ # we are calling this node as a exec, skip return values
+ caller.msg("|gCalled from dynamic node:|n \n {}".format(text))
+ return
+ else:
+ options = {"desc": "back to main",
+ "goto": "test_start_node"}
+ return text, options
def test_displayinput_node(caller, raw_string):
@@ -1085,12 +1470,48 @@ def test_displayinput_node(caller, raw_string):
makes it hidden from view. It catches all input (except the
in-menu help/quit commands) and will, in this case, bring you back
to the start node.
- """ % raw_string
+ """ % raw_string.rstrip()
options = {"key": "_default",
"goto": "test_start_node"}
return text, options
+def _test_call(caller, raw_input, **kwargs):
+ mode = kwargs.get("mode", "exec")
+
+ caller.msg("\n|y'{}' |n_test_call|y function called with\n "
+ "caller: |n{}\n |yraw_input: \"|n{}|y\" \n kwargs: |n{}\n".format(
+ mode, caller, raw_input.rstrip(), kwargs))
+
+ if mode == "exec":
+ kwargs = {"random": random.random()}
+ caller.msg("function modify kwargs to {}".format(kwargs))
+ else:
+ caller.msg("|ypassing function kwargs without modification.|n")
+
+ return "test_dynamic_node", kwargs
+
+
+def test_dynamic_node(caller, **kwargs):
+ text = """
+ This is a dynamic node with input:
+ {}
+ """.format(kwargs)
+ options = ({"desc": "pass a new random number to this node",
+ "goto": ("test_dynamic_node", {"random": random.random()})},
+ {"desc": "execute a func with kwargs",
+ "exec": (_test_call, {"mode": "exec", "test_random": random.random()})},
+ {"desc": "dynamic_goto",
+ "goto": (_test_call, {"mode": "goto", "goto_input": "test"})},
+ {"desc": "exec test_view_node with kwargs",
+ "exec": ("test_view_node", {"executed_from_dynamic_node": True}),
+ "goto": "test_dynamic_node"},
+ {"desc": "back to main",
+ "goto": "test_start_node"})
+
+ return text, options
+
+
def test_end_node(caller):
text = """
This is the end of the menu and since it has no options the menu
diff --git a/evennia/utils/evmore.py b/evennia/utils/evmore.py
index 169091396b..94173b9eca 100644
--- a/evennia/utils/evmore.py
+++ b/evennia/utils/evmore.py
@@ -122,7 +122,8 @@ class EvMore(object):
"""
def __init__(self, caller, text, always_page=False, session=None,
- justify_kwargs=None, exit_on_lastpage=False, **kwargs):
+ justify_kwargs=None, exit_on_lastpage=False,
+ exit_cmd=None, **kwargs):
"""
Initialization of the text handler.
@@ -141,6 +142,10 @@ class EvMore(object):
page being completely filled, exit pager immediately. If unset,
another move forward is required to exit. If set, the pager
exit message will not be shown.
+ exit_cmd (str, optional): If given, this command-string will be executed on
+ 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.
@@ -151,6 +156,7 @@ class EvMore(object):
self._npages = []
self._npos = []
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
@@ -202,15 +208,18 @@ class EvMore(object):
# goto top of the text
self.page_top()
- def display(self):
+ def display(self, show_footer=True):
"""
Pretty-print the page.
"""
pos = self._pos
text = self._pages[pos]
- page = _DISPLAY.format(text=text,
- pageno=pos + 1,
- pagemax=self._npages)
+ if show_footer:
+ page = _DISPLAY.format(text=text,
+ pageno=pos + 1,
+ pagemax=self._npages)
+ else:
+ page = text
# check to make sure our session is still valid
sessions = self._caller.sessions.get()
if not sessions:
@@ -245,9 +254,11 @@ class EvMore(object):
self.page_quit()
else:
self._pos += 1
- self.display()
- if self.exit_on_lastpage and self._pos >= self._npages - 1:
- self.page_quit()
+ if self.exit_on_lastpage and self._pos >= (self._npages - 1):
+ self.display(show_footer=False)
+ self.page_quit(quiet=True)
+ else:
+ self.display()
def page_back(self):
"""
@@ -256,16 +267,20 @@ class EvMore(object):
self._pos = max(0, self._pos - 1)
self.display()
- def page_quit(self):
+ def page_quit(self, quiet=False):
"""
Quit the pager
"""
del self._caller.ndb._more
- self._caller.msg(text=self._exit_msg, **self._kwargs)
+ if not quiet:
+ self._caller.msg(text=self._exit_msg, **self._kwargs)
self._caller.cmdset.remove(CmdSetMore)
+ if self.exit_cmd:
+ self._caller.execute_cmd(self.exit_cmd, session=self._session)
-def msg(caller, text="", always_page=False, session=None, justify_kwargs=None, **kwargs):
+def msg(caller, text="", always_page=False, session=None,
+ justify_kwargs=None, exit_on_lastpage=True, **kwargs):
"""
More-supported version of msg, mimicking the normal msg method.
@@ -280,9 +295,10 @@ def msg(caller, text="", always_page=False, session=None, justify_kwargs=None, *
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.
kwargs (any, optional): These will be passed on
to the `caller.msg` method.
"""
EvMore(caller, text, always_page=always_page, session=session,
- justify_kwargs=justify_kwargs, **kwargs)
+ justify_kwargs=justify_kwargs, exit_on_lastpage=exit_on_lastpage, **kwargs)
diff --git a/evennia/utils/gametime.py b/evennia/utils/gametime.py
index 793e348143..3736128819 100644
--- a/evennia/utils/gametime.py
+++ b/evennia/utils/gametime.py
@@ -19,6 +19,8 @@ from evennia.utils.create import create_script
# to real time.
TIMEFACTOR = settings.TIME_FACTOR
+IGNORE_DOWNTIMES = settings.TIME_IGNORE_DOWNTIMES
+
# Only set if gametime_reset was called at some point.
GAME_TIME_OFFSET = ServerConfig.objects.conf("gametime_offset", default=0)
@@ -133,7 +135,10 @@ def gametime(absolute=False):
"""
epoch = game_epoch() if absolute else 0
- gtime = epoch + (runtime() - GAME_TIME_OFFSET) * TIMEFACTOR
+ if IGNORE_DOWNTIMES:
+ gtime = epoch + (time.time() - server_epoch()) * TIMEFACTOR
+ else:
+ gtime = epoch + (runtime() - GAME_TIME_OFFSET) * TIMEFACTOR
return gtime
diff --git a/evennia/utils/idmapper/manager.py b/evennia/utils/idmapper/manager.py
index 9053149e8a..9ff096f454 100644
--- a/evennia/utils/idmapper/manager.py
+++ b/evennia/utils/idmapper/manager.py
@@ -5,10 +5,6 @@ from django.db.models.manager import Manager
class SharedMemoryManager(Manager):
- # CL: this ensures our manager is used when accessing instances via
- # ForeignKey etc. (see docs)
- use_for_related_fields = True
-
# TODO: improve on this implementation
# We need a way to handle reverse lookups so that this model can
# still use the singleton cache, but the active model isn't required
diff --git a/evennia/utils/idmapper/tests.py b/evennia/utils/idmapper/tests.py
index 4647b4a824..3ccd6ea74d 100644
--- a/evennia/utils/idmapper/tests.py
+++ b/evennia/utils/idmapper/tests.py
@@ -17,14 +17,14 @@ class RegularCategory(models.Model):
class Article(SharedMemoryModel):
name = models.CharField(max_length=32)
- category = models.ForeignKey(Category)
- category2 = models.ForeignKey(RegularCategory)
+ category = models.ForeignKey(Category, on_delete=models.CASCADE)
+ category2 = models.ForeignKey(RegularCategory, on_delete=models.CASCADE)
class RegularArticle(models.Model):
name = models.CharField(max_length=32)
- category = models.ForeignKey(Category)
- category2 = models.ForeignKey(RegularCategory)
+ category = models.ForeignKey(Category, on_delete=models.CASCADE)
+ category2 = models.ForeignKey(RegularCategory, on_delete=models.CASCADE)
class SharedMemorysTest(TestCase):
diff --git a/evennia/utils/inlinefuncs.py b/evennia/utils/inlinefuncs.py
index b09cc432e7..3012347541 100644
--- a/evennia/utils/inlinefuncs.py
+++ b/evennia/utils/inlinefuncs.py
@@ -1,467 +1,528 @@
-"""
-Inline functions (nested form).
-
-This parser accepts nested inlinefunctions on the form
-
-```
-$funcname(arg, arg, ...)
-```
-
-embedded in any text where any arg can be another $funcname{} call.
-This functionality is turned off by default - to activate,
-`settings.INLINEFUNC_ENABLED` must be set to `True`.
-
-Each token starts with "$funcname(" where there must be no space
-between the $funcname and (. It ends with a matched ending parentesis.
-")".
-
-Inside the inlinefunc definition, one can use `\` to escape. This is
-mainly needed for escaping commas in flowing text (which would
-otherwise be interpreted as an argument separator), or to escape `}`
-when not intended to close the function block. Enclosing text in
-matched `\"\"\"` (triple quotes) or `'''` (triple single-quotes) will
-also escape *everything* within without needing to escape individual
-characters.
-
-The available inlinefuncs are defined as global-level functions in
-modules defined by `settings.INLINEFUNC_MODULES`. They are identified
-by their function name (and ignored if this name starts with `_`). They
-should be on the following form:
-
-```python
-def funcname (*args, **kwargs):
- # ...
-```
-
-Here, the arguments given to `$funcname(arg1,arg2)` will appear as the
-`*args` tuple. This will be populated by the arguments given to the
-inlinefunc in-game - the only part that will be available from
-in-game. `**kwargs` are not supported from in-game but are only used
-internally by Evennia to make details about the caller available to
-the function. The kwarg passed to all functions is `session`, the
-Sessionobject for the object seeing the string. This may be `None` if
-the string is sent to a non-puppetable object. The inlinefunc should
-never raise an exception.
-
-There are two reserved function names:
-- "nomatch": This is called if the user uses a functionname that is
- not registered. The nomatch function will get the name of the
- not-found function as its first argument followed by the normal
- arguments to the given function. If not defined the default effect is
- to print `` to replace the unknown function.
-- "stackfull": This is called when the maximum nested function stack is reached.
- When this happens, the original parsed string is returned and the result of
- the `stackfull` inlinefunc is appended to the end. By default this is an
- error message.
-
-Error handling:
- Syntax errors, notably not completely closing all inlinefunc
- blocks, will lead to the entire string remaining unparsed.
-
-"""
-
-import re
-from django.conf import settings
-from evennia.utils import utils
-
-
-# example/testing inline functions
-
-def pad(*args, **kwargs):
- """
- Inlinefunc. Pads text to given width.
-
- Args:
- text (str, optional): Text to pad.
- width (str, optional): Will be converted to integer. Width
- of padding.
- align (str, optional): Alignment of padding; one of 'c', 'l' or 'r'.
- fillchar (str, optional): Character used for padding. Defaults to a space.
-
- Kwargs:
- session (Session): Session performing the pad.
-
- Example:
- `$pad(text, width, align, fillchar)`
-
- """
- text, width, align, fillchar = "", 78, 'c', ' '
- nargs = len(args)
- if nargs > 0:
- text = args[0]
- if nargs > 1:
- width = int(args[1]) if args[1].strip().isdigit() else 78
- if nargs > 2:
- align = args[2] if args[2] in ('c', 'l', 'r') else 'c'
- if nargs > 3:
- fillchar = args[3]
- return utils.pad(text, width=width, align=align, fillchar=fillchar)
-
-
-def crop(*args, **kwargs):
- """
- Inlinefunc. Crops ingoing text to given widths.
-
- Args:
- text (str, optional): Text to crop.
- width (str, optional): Will be converted to an integer. Width of
- crop in characters.
- suffix (str, optional): End string to mark the fact that a part
- of the string was cropped. Defaults to `[...]`.
- Kwargs:
- session (Session): Session performing the crop.
-
- Example:
- `$crop(text, width=78, suffix='[...]')`
-
- """
- text, width, suffix = "", 78, "[...]"
- nargs = len(args)
- if nargs > 0:
- text = args[0]
- if nargs > 1:
- width = int(args[1]) if args[1].strip().isdigit() else 78
- if nargs > 2:
- suffix = args[2]
- return utils.crop(text, width=width, suffix=suffix)
-
-
-def clr(*args, **kwargs):
- """
- Inlinefunc. Colorizes nested text.
-
- Args:
- startclr (str, optional): An ANSI color abbreviation without the
- prefix `|`, such as `r` (red foreground) or `[r` (red background).
- text (str, optional): Text
- endclr (str, optional): The color to use at the end of the string. Defaults
- to `|n` (reset-color).
- Kwargs:
- session (Session): Session object triggering inlinefunc.
-
- Example:
- `$clr(startclr, text, endclr)`
-
- """
- text = ""
- nargs = len(args)
- if nargs > 0:
- color = args[0].strip()
- if nargs > 1:
- text = args[1]
- text = "|" + color + text
- if nargs > 2:
- text += "|" + args[2].strip()
- else:
- text += "|n"
- return text
-
-
-# we specify a default nomatch function to use if no matching func was
-# found. This will be overloaded by any nomatch function defined in
-# the imported modules.
-_INLINE_FUNCS = {"nomatch": lambda *args, **kwargs: "",
- "stackfull": lambda *args, **kwargs: "\n (not parsed: inlinefunc stack size exceeded.)"}
-
-
-# load custom inline func modules.
-for module in utils.make_iter(settings.INLINEFUNC_MODULES):
- try:
- _INLINE_FUNCS.update(utils.callables_from_module(module))
- except ImportError as err:
- if module == "server.conf.inlinefuncs":
- # a temporary warning since the default module changed name
- raise ImportError("Error: %s\nPossible reason: mygame/server/conf/inlinefunc.py should "
- "be renamed to mygame/server/conf/inlinefuncs.py (note the S at the end)." % err)
- else:
- raise
-
-
-# remove the core function if we include examples in this module itself
-#_INLINE_FUNCS.pop("inline_func_parse", None)
-
-
-# The stack size is a security measure. Set to <=0 to disable.
-try:
- _STACK_MAXSIZE = settings.INLINEFUNC_STACK_MAXSIZE
-except AttributeError:
- _STACK_MAXSIZE = 20
-
-# regex definitions
-
-_RE_STARTTOKEN = re.compile(r"(?.*?)(?.*?)(?(?(?(? # escaped tokens to re-insert sans backslash
- \\\'|\\\"|\\\)|\\\$\w+\()|
- (?P # everything else to re-insert verbatim
- \$(?!\w+\()|\'{1}|\"{1}|\\{1}|[^),$\'\"\\]+)""",
- re.UNICODE | re.IGNORECASE | re.VERBOSE | re.DOTALL)
-
-
-# Cache for function lookups.
-_PARSING_CACHE = utils.LimitedSizeOrderedDict(size_limit=1000)
-
-
-class ParseStack(list):
- """
- Custom stack that always concatenates strings together when the
- strings are added next to one another. Tuples are stored
- separately and None is used to mark that a string should be broken
- up into a new chunk. Below is the resulting stack after separately
- appending 3 strings, None, 2 strings, a tuple and finally 2
- strings:
-
- [string + string + string,
- None
- string + string,
- tuple,
- string + string]
-
- """
-
- def __init__(self, *args, **kwargs):
- super(ParseStack, self).__init__(*args, **kwargs)
- # always start stack with the empty string
- list.append(self, "")
- # indicates if the top of the stack is a string or not
- self._string_last = True
-
- def __eq__(self, other):
- return (super(ParseStack).__eq__(other) and
- hasattr(other, "_string_last") and self._string_last == other._string_last)
-
- def __ne__(self, other):
- return not self.__eq__(other)
-
- def append(self, item):
- """
- The stack will merge strings, add other things as normal
- """
- if isinstance(item, basestring):
- if self._string_last:
- self[-1] += item
- else:
- list.append(self, item)
- self._string_last = True
- else:
- # everything else is added as normal
- list.append(self, item)
- self._string_last = False
-
-
-class InlinefuncError(RuntimeError):
- pass
-
-
-def parse_inlinefunc(string, strip=False, **kwargs):
- """
- Parse the incoming string.
-
- Args:
- string (str): The incoming string to parse.
- strip (bool, optional): Whether to strip function calls rather than
- execute them.
- Kwargs:
- session (Session): This is sent to this function by Evennia when triggering
- it. It is passed to the inlinefunc.
- kwargs (any): All other kwargs are also passed on to the inlinefunc.
-
-
- """
- global _PARSING_CACHE
- if string in _PARSING_CACHE:
- # stack is already cached
- stack = _PARSING_CACHE[string]
- elif not _RE_STARTTOKEN.search(string):
- # if there are no unescaped start tokens at all, return immediately.
- return string
- else:
- # no cached stack; build a new stack and continue
- stack = ParseStack()
-
- # process string on stack
- ncallable = 0
- for match in _RE_TOKEN.finditer(string):
- gdict = match.groupdict()
- if gdict["singlequote"]:
- stack.append(gdict["singlequote"])
- elif gdict["doublequote"]:
- stack.append(gdict["doublequote"])
- elif gdict["end"]:
- if ncallable <= 0:
- stack.append(")")
- continue
- args = []
- while stack:
- operation = stack.pop()
- if callable(operation):
- if not strip:
- stack.append((operation, [arg for arg in reversed(args)]))
- ncallable -= 1
- break
- else:
- args.append(operation)
- elif gdict["start"]:
- funcname = _RE_STARTTOKEN.match(gdict["start"]).group(1)
- try:
- # try to fetch the matching inlinefunc from storage
- stack.append(_INLINE_FUNCS[funcname])
- except KeyError:
- stack.append(_INLINE_FUNCS["nomatch"])
- stack.append(funcname)
- ncallable += 1
- elif gdict["escaped"]:
- # escaped tokens
- token = gdict["escaped"].lstrip("\\")
- stack.append(token)
- elif gdict["comma"]:
- if ncallable > 0:
- # commas outside strings and inside a callable are
- # used to mark argument separation - we use None
- # in the stack to indicate such a separation.
- stack.append(None)
- else:
- # no callable active - just a string
- stack.append(",")
- else:
- # the rest
- stack.append(gdict["rest"])
-
- if ncallable > 0:
- # this means not all inlinefuncs were complete
- return string
-
- if _STACK_MAXSIZE > 0 and _STACK_MAXSIZE < len(stack):
- # if stack is larger than limit, throw away parsing
- return string + gdict["stackfull"](*args, **kwargs)
- else:
- # cache the stack
- _PARSING_CACHE[string] = stack
-
- # run the stack recursively
- def _run_stack(item, depth=0):
- retval = item
- if isinstance(item, tuple):
- if strip:
- return ""
- else:
- func, arglist = item
- args = [""]
- for arg in arglist:
- if arg is None:
- # an argument-separating comma - start a new arg
- args.append("")
- else:
- # all other args should merge into one string
- args[-1] += _run_stack(arg, depth=depth + 1)
- # execute the inlinefunc at this point or strip it.
- kwargs["inlinefunc_stack_depth"] = depth
- retval = "" if strip else func(*args, **kwargs)
- return utils.to_str(retval, force_string=True)
-
- # execute the stack from the cache
- return "".join(_run_stack(item) for item in _PARSING_CACHE[string])
-
-#
-# Nick templating
-#
-
-
-"""
-This supports the use of replacement templates in nicks:
-
-This happens in two steps:
-
-1) The user supplies a template that is converted to a regex according
- to the unix-like templating language.
-2) This regex is tested against nicks depending on which nick replacement
- strategy is considered (most commonly inputline).
-3) If there is a template match and there are templating markers,
- these are replaced with the arguments actually given.
-
-@desc $1 $2 $3
-
-This will be converted to the following regex:
-
-\@desc (?P<1>\w+) (?P<2>\w+) $(?P<3>\w+)
-
-Supported template markers (through fnmatch)
- * matches anything (non-greedy) -> .*?
- ? matches any single character ->
- [seq] matches any entry in sequence
- [!seq] matches entries not in sequence
-Custom arg markers
- $N argument position (1-99)
-
-"""
-import fnmatch
-_RE_NICK_ARG = re.compile(r"\\(\$)([1-9][0-9]?)")
-_RE_NICK_TEMPLATE_ARG = re.compile(r"(\$)([1-9][0-9]?)")
-_RE_NICK_SPACE = re.compile(r"\\ ")
-
-
-class NickTemplateInvalid(ValueError):
- pass
-
-
-def initialize_nick_templates(in_template, out_template):
- """
- Initialize the nick templates for matching and remapping a string.
-
- Args:
- in_template (str): The template to be used for nick recognition.
- out_template (str): The template to be used to replace the string
- matched by the in_template.
-
- Returns:
- regex (regex): Regex to match against strings
- template (str): Template with markers {arg1}, {arg2}, etc for
- replacement using the standard .format method.
-
- Raises:
- NickTemplateInvalid: If the in/out template does not have a matching
- number of $args.
-
- """
- # create the regex for in_template
- regex_string = fnmatch.translate(in_template)
- n_inargs = len(_RE_NICK_ARG.findall(regex_string))
- regex_string = _RE_NICK_SPACE.sub("\s+", regex_string)
- regex_string = _RE_NICK_ARG.sub(lambda m: "(?P.+?)" % m.group(2), regex_string)
-
- # create the out_template
- template_string = _RE_NICK_TEMPLATE_ARG.sub(lambda m: "{arg%s}" % m.group(2), out_template)
-
- # validate the tempaltes - they should at least have the same number of args
- n_outargs = len(_RE_NICK_TEMPLATE_ARG.findall(out_template))
- if n_inargs != n_outargs:
- print n_inargs, n_outargs
- raise NickTemplateInvalid
-
- return re.compile(regex_string), template_string
-
-
-def parse_nick_template(string, template_regex, outtemplate):
- """
- Parse a text using a template and map it to another template
-
- Args:
- string (str): The input string to processj
- template_regex (regex): A template regex created with
- initialize_nick_template.
- outtemplate (str): The template to which to map the matches
- produced by the template_regex. This should have $1, $2,
- etc to match the regex.
-
- """
- match = template_regex.match(string)
- if match:
- return outtemplate.format(**match.groupdict())
- return string
+"""
+Inline functions (nested form).
+
+This parser accepts nested inlinefunctions on the form
+
+```
+$funcname(arg, arg, ...)
+```
+
+embedded in any text where any arg can be another $funcname{} call.
+This functionality is turned off by default - to activate,
+`settings.INLINEFUNC_ENABLED` must be set to `True`.
+
+Each token starts with "$funcname(" where there must be no space
+between the $funcname and (. It ends with a matched ending parentesis.
+")".
+
+Inside the inlinefunc definition, one can use `\` to escape. This is
+mainly needed for escaping commas in flowing text (which would
+otherwise be interpreted as an argument separator), or to escape `}`
+when not intended to close the function block. Enclosing text in
+matched `\"\"\"` (triple quotes) or `'''` (triple single-quotes) will
+also escape *everything* within without needing to escape individual
+characters.
+
+The available inlinefuncs are defined as global-level functions in
+modules defined by `settings.INLINEFUNC_MODULES`. They are identified
+by their function name (and ignored if this name starts with `_`). They
+should be on the following form:
+
+```python
+def funcname (*args, **kwargs):
+ # ...
+```
+
+Here, the arguments given to `$funcname(arg1,arg2)` will appear as the
+`*args` tuple. This will be populated by the arguments given to the
+inlinefunc in-game - the only part that will be available from
+in-game. `**kwargs` are not supported from in-game but are only used
+internally by Evennia to make details about the caller available to
+the function. The kwarg passed to all functions is `session`, the
+Sessionobject for the object seeing the string. This may be `None` if
+the string is sent to a non-puppetable object. The inlinefunc should
+never raise an exception.
+
+There are two reserved function names:
+- "nomatch": This is called if the user uses a functionname that is
+ not registered. The nomatch function will get the name of the
+ not-found function as its first argument followed by the normal
+ arguments to the given function. If not defined the default effect is
+ to print `` to replace the unknown function.
+- "stackfull": This is called when the maximum nested function stack is reached.
+ When this happens, the original parsed string is returned and the result of
+ the `stackfull` inlinefunc is appended to the end. By default this is an
+ error message.
+
+Error handling:
+ Syntax errors, notably not completely closing all inlinefunc
+ blocks, will lead to the entire string remaining unparsed.
+
+"""
+
+import re
+import fnmatch
+from django.conf import settings
+
+from evennia.utils import utils, logger
+
+
+# example/testing inline functions
+
+def pad(*args, **kwargs):
+ """
+ Inlinefunc. Pads text to given width.
+
+ Args:
+ text (str, optional): Text to pad.
+ width (str, optional): Will be converted to integer. Width
+ of padding.
+ align (str, optional): Alignment of padding; one of 'c', 'l' or 'r'.
+ fillchar (str, optional): Character used for padding. Defaults to a space.
+
+ Kwargs:
+ session (Session): Session performing the pad.
+
+ Example:
+ `$pad(text, width, align, fillchar)`
+
+ """
+ text, width, align, fillchar = "", 78, 'c', ' '
+ nargs = len(args)
+ if nargs > 0:
+ text = args[0]
+ if nargs > 1:
+ width = int(args[1]) if args[1].strip().isdigit() else 78
+ if nargs > 2:
+ align = args[2] if args[2] in ('c', 'l', 'r') else 'c'
+ if nargs > 3:
+ fillchar = args[3]
+ return utils.pad(text, width=width, align=align, fillchar=fillchar)
+
+
+def crop(*args, **kwargs):
+ """
+ Inlinefunc. Crops ingoing text to given widths.
+
+ Args:
+ text (str, optional): Text to crop.
+ width (str, optional): Will be converted to an integer. Width of
+ crop in characters.
+ suffix (str, optional): End string to mark the fact that a part
+ of the string was cropped. Defaults to `[...]`.
+ Kwargs:
+ session (Session): Session performing the crop.
+
+ Example:
+ `$crop(text, width=78, suffix='[...]')`
+
+ """
+ text, width, suffix = "", 78, "[...]"
+ nargs = len(args)
+ if nargs > 0:
+ text = args[0]
+ if nargs > 1:
+ width = int(args[1]) if args[1].strip().isdigit() else 78
+ if nargs > 2:
+ suffix = args[2]
+ return utils.crop(text, width=width, suffix=suffix)
+
+
+def clr(*args, **kwargs):
+ """
+ Inlinefunc. Colorizes nested text.
+
+ Args:
+ startclr (str, optional): An ANSI color abbreviation without the
+ prefix `|`, such as `r` (red foreground) or `[r` (red background).
+ text (str, optional): Text
+ endclr (str, optional): The color to use at the end of the string. Defaults
+ to `|n` (reset-color).
+ Kwargs:
+ session (Session): Session object triggering inlinefunc.
+
+ Example:
+ `$clr(startclr, text, endclr)`
+
+ """
+ text = ""
+ nargs = len(args)
+ if nargs > 0:
+ color = args[0].strip()
+ if nargs > 1:
+ text = args[1]
+ text = "|" + color + text
+ if nargs > 2:
+ text += "|" + args[2].strip()
+ else:
+ text += "|n"
+ return text
+
+
+def null(*args, **kwargs):
+ return args[0] if args else ''
+
+
+def nomatch(name, *args, **kwargs):
+ """
+ Default implementation of nomatch returns the function as-is as a string.
+
+ """
+ kwargs.pop("inlinefunc_stack_depth", None)
+ kwargs.pop("session")
+
+ return "${name}({args}{kwargs})".format(
+ name=name,
+ args=",".join(args),
+ kwargs=",".join("{}={}".format(key, val) for key, val in kwargs.items()))
+
+_INLINE_FUNCS = {}
+
+# we specify a default nomatch function to use if no matching func was
+# found. This will be overloaded by any nomatch function defined in
+# the imported modules.
+_DEFAULT_FUNCS = {"nomatch": lambda *args, **kwargs: "",
+ "stackfull": lambda *args, **kwargs: "\n (not parsed: "}
+
+_INLINE_FUNCS.update(_DEFAULT_FUNCS)
+
+# load custom inline func modules.
+for module in utils.make_iter(settings.INLINEFUNC_MODULES):
+ try:
+ _INLINE_FUNCS.update(utils.callables_from_module(module))
+ except ImportError as err:
+ if module == "server.conf.inlinefuncs":
+ # a temporary warning since the default module changed name
+ raise ImportError("Error: %s\nPossible reason: mygame/server/conf/inlinefunc.py should "
+ "be renamed to mygame/server/conf/inlinefuncs.py (note "
+ "the S at the end)." % err)
+ else:
+ raise
+
+
+# The stack size is a security measure. Set to <=0 to disable.
+try:
+ _STACK_MAXSIZE = settings.INLINEFUNC_STACK_MAXSIZE
+except AttributeError:
+ _STACK_MAXSIZE = 20
+
+# regex definitions
+
+_RE_STARTTOKEN = re.compile(r"(?.*?)(?.*?)(?(?(?(?(? # escaped tokens to re-insert sans backslash
+ \\\'|\\\"|\\\)|\\\$\w+\(|\\\()|
+ (?P # everything else to re-insert verbatim
+ \$(?!\w+\()|\'|\"|\\|[^),$\'\"\\\(]+)""",
+ re.UNICODE | re.IGNORECASE | re.VERBOSE | re.DOTALL)
+
+# Cache for function lookups.
+_PARSING_CACHE = utils.LimitedSizeOrderedDict(size_limit=1000)
+
+
+class ParseStack(list):
+ """
+ Custom stack that always concatenates strings together when the
+ strings are added next to one another. Tuples are stored
+ separately and None is used to mark that a string should be broken
+ up into a new chunk. Below is the resulting stack after separately
+ appending 3 strings, None, 2 strings, a tuple and finally 2
+ strings:
+
+ [string + string + string,
+ None
+ string + string,
+ tuple,
+ string + string]
+
+ """
+
+ def __init__(self, *args, **kwargs):
+ super(ParseStack, self).__init__(*args, **kwargs)
+ # always start stack with the empty string
+ list.append(self, "")
+ # indicates if the top of the stack is a string or not
+ self._string_last = True
+
+ def __eq__(self, other):
+ return (super(ParseStack).__eq__(other) and
+ hasattr(other, "_string_last") and self._string_last == other._string_last)
+
+ def __ne__(self, other):
+ return not self.__eq__(other)
+
+ def append(self, item):
+ """
+ The stack will merge strings, add other things as normal
+ """
+ if isinstance(item, basestring):
+ if self._string_last:
+ self[-1] += item
+ else:
+ list.append(self, item)
+ self._string_last = True
+ else:
+ # everything else is added as normal
+ list.append(self, item)
+ self._string_last = False
+
+
+class InlinefuncError(RuntimeError):
+ pass
+
+
+def parse_inlinefunc(string, strip=False, available_funcs=None, stacktrace=False, **kwargs):
+ """
+ Parse the incoming string.
+
+ Args:
+ string (str): The incoming string to parse.
+ strip (bool, optional): Whether to strip function calls rather than
+ execute them.
+ available_funcs (dict, optional): Define an alternative source of functions to parse for.
+ If unset, use the functions found through `settings.INLINEFUNC_MODULES`.
+ stacktrace (bool, optional): If set, print the stacktrace to log.
+ Kwargs:
+ session (Session): This is sent to this function by Evennia when triggering
+ it. It is passed to the inlinefunc.
+ kwargs (any): All other kwargs are also passed on to the inlinefunc.
+
+
+ """
+ global _PARSING_CACHE
+ usecache = False
+ if not available_funcs:
+ available_funcs = _INLINE_FUNCS
+ usecache = True
+ else:
+ # make sure the default keys are available, but also allow overriding
+ tmp = _DEFAULT_FUNCS.copy()
+ tmp.update(available_funcs)
+ available_funcs = tmp
+
+ if usecache and string in _PARSING_CACHE:
+ # stack is already cached
+ stack = _PARSING_CACHE[string]
+ elif not _RE_STARTTOKEN.search(string):
+ # if there are no unescaped start tokens at all, return immediately.
+ return string
+ else:
+ # no cached stack; build a new stack and continue
+ stack = ParseStack()
+
+ # process string on stack
+ ncallable = 0
+ nlparens = 0
+ nvalid = 0
+
+ if stacktrace:
+ out = "STRING: {} =>".format(string)
+ print(out)
+ logger.log_info(out)
+
+ for match in _RE_TOKEN.finditer(string):
+ gdict = match.groupdict()
+
+ if stacktrace:
+ out = " MATCH: {}".format({key: val for key, val in gdict.items() if val})
+ print(out)
+ logger.log_info(out)
+
+ if gdict["singlequote"]:
+ stack.append(gdict["singlequote"])
+ elif gdict["doublequote"]:
+ stack.append(gdict["doublequote"])
+ elif gdict["leftparens"]:
+ # we have a left-parens inside a callable
+ if ncallable:
+ nlparens += 1
+ stack.append("(")
+ elif gdict["end"]:
+ if nlparens > 0:
+ nlparens -= 1
+ stack.append(")")
+ continue
+ if ncallable <= 0:
+ stack.append(")")
+ continue
+ args = []
+ while stack:
+ operation = stack.pop()
+ if callable(operation):
+ if not strip:
+ stack.append((operation, [arg for arg in reversed(args)]))
+ ncallable -= 1
+ break
+ else:
+ args.append(operation)
+ elif gdict["start"]:
+ funcname = _RE_STARTTOKEN.match(gdict["start"]).group(1)
+ try:
+ # try to fetch the matching inlinefunc from storage
+ stack.append(available_funcs[funcname])
+ nvalid += 1
+ except KeyError:
+ stack.append(available_funcs["nomatch"])
+ stack.append(funcname)
+ stack.append(None)
+ ncallable += 1
+ elif gdict["escaped"]:
+ # escaped tokens
+ token = gdict["escaped"].lstrip("\\")
+ stack.append(token)
+ elif gdict["comma"]:
+ if ncallable > 0:
+ # commas outside strings and inside a callable are
+ # used to mark argument separation - we use None
+ # in the stack to indicate such a separation.
+ stack.append(None)
+ else:
+ # no callable active - just a string
+ stack.append(",")
+ else:
+ # the rest
+ stack.append(gdict["rest"])
+
+ if ncallable > 0:
+ # this means not all inlinefuncs were complete
+ return string
+
+ if _STACK_MAXSIZE > 0 and _STACK_MAXSIZE < nvalid:
+ # if stack is larger than limit, throw away parsing
+ return string + available_funcs["stackfull"](*args, **kwargs)
+ elif usecache:
+ # cache the stack - we do this also if we don't check the cache above
+ _PARSING_CACHE[string] = stack
+
+ # run the stack recursively
+ def _run_stack(item, depth=0):
+ retval = item
+ if isinstance(item, tuple):
+ if strip:
+ return ""
+ else:
+ func, arglist = item
+ args = [""]
+ for arg in arglist:
+ if arg is None:
+ # an argument-separating comma - start a new arg
+ args.append("")
+ else:
+ # all other args should merge into one string
+ args[-1] += _run_stack(arg, depth=depth + 1)
+ # execute the inlinefunc at this point or strip it.
+ kwargs["inlinefunc_stack_depth"] = depth
+ retval = "" if strip else func(*args, **kwargs)
+ return utils.to_str(retval, force_string=True)
+ retval = "".join(_run_stack(item) for item in stack)
+ if stacktrace:
+ out = "STACK: \n{} => {}\n".format(stack, retval)
+ print(out)
+ logger.log_info(out)
+
+ # execute the stack
+ return retval
+
+#
+# Nick templating
+#
+
+
+"""
+This supports the use of replacement templates in nicks:
+
+This happens in two steps:
+
+1) The user supplies a template that is converted to a regex according
+ to the unix-like templating language.
+2) This regex is tested against nicks depending on which nick replacement
+ strategy is considered (most commonly inputline).
+3) If there is a template match and there are templating markers,
+ these are replaced with the arguments actually given.
+
+@desc $1 $2 $3
+
+This will be converted to the following regex:
+
+\@desc (?P<1>\w+) (?P<2>\w+) $(?P<3>\w+)
+
+Supported template markers (through fnmatch)
+ * matches anything (non-greedy) -> .*?
+ ? matches any single character ->
+ [seq] matches any entry in sequence
+ [!seq] matches entries not in sequence
+Custom arg markers
+ $N argument position (1-99)
+
+"""
+
+_RE_NICK_ARG = re.compile(r"\\(\$)([1-9][0-9]?)")
+_RE_NICK_TEMPLATE_ARG = re.compile(r"(\$)([1-9][0-9]?)")
+_RE_NICK_SPACE = re.compile(r"\\ ")
+
+
+class NickTemplateInvalid(ValueError):
+ pass
+
+
+def initialize_nick_templates(in_template, out_template):
+ """
+ Initialize the nick templates for matching and remapping a string.
+
+ Args:
+ in_template (str): The template to be used for nick recognition.
+ out_template (str): The template to be used to replace the string
+ matched by the in_template.
+
+ Returns:
+ regex (regex): Regex to match against strings
+ template (str): Template with markers {arg1}, {arg2}, etc for
+ replacement using the standard .format method.
+
+ Raises:
+ NickTemplateInvalid: If the in/out template does not have a matching
+ number of $args.
+
+ """
+ # create the regex for in_template
+ regex_string = fnmatch.translate(in_template)
+ n_inargs = len(_RE_NICK_ARG.findall(regex_string))
+ regex_string = _RE_NICK_SPACE.sub("\s+", regex_string)
+ regex_string = _RE_NICK_ARG.sub(lambda m: "(?P.+?)" % m.group(2), regex_string)
+
+ # create the out_template
+ template_string = _RE_NICK_TEMPLATE_ARG.sub(lambda m: "{arg%s}" % m.group(2), out_template)
+
+ # validate the tempaltes - they should at least have the same number of args
+ n_outargs = len(_RE_NICK_TEMPLATE_ARG.findall(out_template))
+ if n_inargs != n_outargs:
+ raise NickTemplateInvalid
+
+ return re.compile(regex_string), template_string
+
+
+def parse_nick_template(string, template_regex, outtemplate):
+ """
+ Parse a text using a template and map it to another template
+
+ Args:
+ string (str): The input string to processj
+ template_regex (regex): A template regex created with
+ initialize_nick_template.
+ outtemplate (str): The template to which to map the matches
+ produced by the template_regex. This should have $1, $2,
+ etc to match the regex.
+
+ """
+ match = template_regex.match(string)
+ if match:
+ return outtemplate.format(**match.groupdict())
+ return string
diff --git a/evennia/utils/logger.py b/evennia/utils/logger.py
index ac8c358a76..0a87d36010 100644
--- a/evennia/utils/logger.py
+++ b/evennia/utils/logger.py
@@ -20,6 +20,7 @@ import time
from datetime import datetime
from traceback import format_exc
from twisted.python import log, logfile
+from twisted.python import util as twisted_util
from twisted.internet.threads import deferToThread
@@ -29,10 +30,15 @@ _TIMEZONE = None
_CHANNEL_LOG_NUM_TAIL_LINES = None
+# logging overrides
+
+
def timeformat(when=None):
"""
This helper function will format the current time in the same
- way as twisted's logger does, including time zone info.
+ way as the twisted logger does, including time zone info. Only
+ difference from official logger is that we only use two digits
+ for the year and don't show timezone for CET times.
Args:
when (int, optional): This is a time in POSIX seconds on the form
@@ -49,14 +55,70 @@ def timeformat(when=None):
tz_offset = tz_offset.days * 86400 + tz_offset.seconds
# correct given time to utc
when = datetime.utcfromtimestamp(when - tz_offset)
- tz_hour = abs(int(tz_offset // 3600))
- tz_mins = abs(int(tz_offset // 60 % 60))
- tz_sign = "-" if tz_offset >= 0 else "+"
- return '%d-%02d-%02d %02d:%02d:%02d%s%02d%02d' % (
- when.year, when.month, when.day,
- when.hour, when.minute, when.second,
- tz_sign, tz_hour, tz_mins)
+ if tz_offset == 0:
+ tz = ""
+ else:
+ tz_hour = abs(int(tz_offset // 3600))
+ tz_mins = abs(int(tz_offset // 60 % 60))
+ tz_sign = "-" if tz_offset >= 0 else "+"
+ tz = "%s%02d%s" % (tz_sign, tz_hour,
+ (":%02d" % tz_mins if tz_mins else ""))
+
+ return '%d-%02d-%02d %02d:%02d:%02d%s' % (
+ when.year - 2000, when.month, when.day,
+ when.hour, when.minute, when.second, tz)
+
+
+class WeeklyLogFile(logfile.DailyLogFile):
+ """
+ Log file that rotates once per week
+
+ """
+ day_rotation = 7
+
+ 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)
+
+ def write(self, data):
+ "Write data to log file"
+ logfile.BaseLogFile.write(self, data)
+ self.lastDate = max(self.lastDate, self.toDate())
+
+
+class PortalLogObserver(log.FileLogObserver):
+ """
+ Reformat logging
+ """
+ timeFormat = None
+ prefix = " |Portal| "
+
+ def emit(self, eventDict):
+ """
+ Copied from Twisted parent, to change logging output
+
+ """
+ text = log.textFromEventDict(eventDict)
+ if text is None:
+ return
+
+ # timeStr = self.formatTime(eventDict["time"])
+ timeStr = timeformat(eventDict["time"])
+ fmtDict = {
+ "text": text.replace("\n", "\n\t")}
+
+ msgStr = log._safeFormat("%(text)s\n", fmtDict)
+
+ twisted_util.untilConcludes(self.write, timeStr + "%s" % self.prefix + msgStr)
+ twisted_util.untilConcludes(self.flush)
+
+
+class ServerLogObserver(PortalLogObserver):
+ prefix = " "
def log_msg(msg):
@@ -124,6 +186,20 @@ def log_err(errmsg):
log_errmsg = log_err
+def log_server(servermsg):
+ """
+ This is for the Portal to log captured Server stdout messages (it's
+ usually only used during startup, before Server log is open)
+
+ """
+ try:
+ servermsg = str(servermsg)
+ except Exception as e:
+ servermsg = str(e)
+ for line in servermsg.splitlines():
+ log_msg('[Server] %s' % line)
+
+
def log_warn(warnmsg):
"""
Prints/logs any warnings that aren't critical but should be noted.
@@ -178,6 +254,23 @@ def log_dep(depmsg):
log_depmsg = log_dep
+def log_sec(secmsg):
+ """
+ Prints a security-related message.
+
+ Args:
+ secmsg (str): The security message to log.
+ """
+ try:
+ secmsg = str(secmsg)
+ except Exception as e:
+ secmsg = str(e)
+ for line in secmsg.splitlines():
+ log_msg('[SS] %s' % line)
+
+
+log_secmsg = log_sec
+
# Arbitrary file logger
diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py
deleted file mode 100644
index 3fe1af496e..0000000000
--- a/evennia/utils/spawner.py
+++ /dev/null
@@ -1,342 +0,0 @@
-"""
-Spawner
-
-The spawner takes input files containing object definitions in
-dictionary forms. These use a prototype architecture to define
-unique objects without having to make a Typeclass for each.
-
-The main function is `spawn(*prototype)`, where the `prototype`
-is a dictionary like this:
-
-```python
-GOBLIN = {
- "typeclass": "types.objects.Monster",
- "key": "goblin grunt",
- "health": lambda: randint(20,30),
- "resists": ["cold", "poison"],
- "attacks": ["fists"],
- "weaknesses": ["fire", "light"]
- "tags": ["mob", "evil", ('greenskin','mob')]
- "args": [("weapon", "sword")]
- }
-```
-
-Possible keywords are:
- prototype - string parent prototype
- key - string, the main object identifier
- typeclass - string, if not set, will use `settings.BASE_OBJECT_TYPECLASS`
- location - this should be a valid object or #dbref
- home - valid object or #dbref
- destination - only valid for exits (object or dbref)
-
- permissions - string or list of permission strings
- locks - a lock-string
- aliases - string or list of strings
- exec - this is a string of python code to execute or a list of such codes.
- This can be used e.g. to trigger custom handlers on the object. The
- execution namespace contains 'evennia' for the library and 'obj'
- tags - string or list of strings or tuples `(tagstr, category)`. Plain
- strings will be result in tags with no category (default tags).
- attrs - tuple or list of tuples of Attributes to add. This form allows
- more complex Attributes to be set. Tuples at least specify `(key, value)`
- but can also specify up to `(key, value, category, lockstring)`. If
- you want to specify a lockstring but not a category, set the category
- to `None`.
- ndb_ - value of a nattribute (ndb_ is stripped)
- other - any other name is interpreted as the key of an Attribute with
- its value. Such Attributes have no categories.
-
-Each value can also be a callable that takes no arguments. It should
-return the value to enter into the field and will be called every time
-the prototype is used to spawn an object. Note, if you want to store
-a callable in an Attribute, embed it in a tuple to the `args` keyword.
-
-By specifying the "prototype" key, the prototype becomes a child of
-that prototype, inheritng all prototype slots it does not explicitly
-define itself, while overloading those that it does specify.
-
-```python
-GOBLIN_WIZARD = {
- "prototype": GOBLIN,
- "key": "goblin wizard",
- "spells": ["fire ball", "lighting bolt"]
- }
-
-GOBLIN_ARCHER = {
- "prototype": GOBLIN,
- "key": "goblin archer",
- "attacks": ["short bow"]
-}
-```
-
-One can also have multiple prototypes. These are inherited from the
-left, with the ones further to the right taking precedence.
-
-```python
-ARCHWIZARD = {
- "attack": ["archwizard staff", "eye of doom"]
-
-GOBLIN_ARCHWIZARD = {
- "key" : "goblin archwizard"
- "prototype": (GOBLIN_WIZARD, ARCHWIZARD),
-}
-```
-
-The *goblin archwizard* will have some different attacks, but will
-otherwise have the same spells as a *goblin wizard* who in turn shares
-many traits with a normal *goblin*.
-
-"""
-from __future__ import print_function
-
-import copy
-from django.conf import settings
-from random import randint
-import evennia
-from evennia.objects.models import ObjectDB
-from evennia.utils.utils import make_iter, all_from_module, dbid_to_obj
-
-_CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination")
-
-
-def _handle_dbref(inp):
- return dbid_to_obj(inp, ObjectDB)
-
-
-def _validate_prototype(key, prototype, protparents, visited):
- """
- Run validation on a prototype, checking for inifinite regress.
-
- """
- assert isinstance(prototype, dict)
- if id(prototype) in visited:
- raise RuntimeError("%s has infinite nesting of prototypes." % key or prototype)
- visited.append(id(prototype))
- protstrings = prototype.get("prototype")
- if protstrings:
- for protstring in make_iter(protstrings):
- if key is not None and protstring == key:
- raise RuntimeError("%s tries to prototype itself." % key or prototype)
- protparent = protparents.get(protstring)
- if not protparent:
- raise RuntimeError("%s's prototype '%s' was not found." % (key or prototype, protstring))
- _validate_prototype(protstring, protparent, protparents, visited)
-
-
-def _get_prototype(dic, prot, protparents):
- """
- Recursively traverse a prototype dictionary, including multiple
- inheritance. Use _validate_prototype before this, we don't check
- for infinite recursion here.
-
- """
- if "prototype" in dic:
- # move backwards through the inheritance
- for prototype in make_iter(dic["prototype"]):
- # Build the prot dictionary in reverse order, overloading
- new_prot = _get_prototype(protparents.get(prototype, {}), prot, protparents)
- prot.update(new_prot)
- prot.update(dic)
- prot.pop("prototype", None) # we don't need this anymore
- return prot
-
-
-def _batch_create_object(*objparams):
- """
- This is a cut-down version of the create_object() function,
- optimized for speed. It does NOT check and convert various input
- so make sure the spawned Typeclass works before using this!
-
- Args:
- objsparams (tuple): Parameters for the respective creation/add
- handlers in the following order:
- - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`.
- - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`.
- - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`.
- - `aliases` (list): A list of alias strings for
- adding with `new_object.aliases.batch_add(*aliases)`.
- - `nattributes` (list): list of tuples `(key, value)` to be loop-added to
- add with `new_obj.nattributes.add(*tuple)`.
- - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for
- adding with `new_obj.attributes.batch_add(*attributes)`.
- - `tags` (list): list of tuples `(key, category)` for adding
- with `new_obj.tags.batch_add(*tags)`.
- - `execs` (list): Code strings to execute together with the creation
- of each object. They will be executed with `evennia` and `obj`
- (the newly created object) available in the namespace. Execution
- will happend after all other properties have been assigned and
- is intended for calling custom handlers etc.
- for the respective creation/add handlers in the following
- order: (create_kwargs, permissions, locks, aliases, nattributes,
- attributes, tags, execs)
-
- Returns:
- objects (list): A list of created objects
-
- Notes:
- The `exec` list will execute arbitrary python code so don't allow this to be availble to
- unprivileged users!
-
- """
-
- # bulk create all objects in one go
-
- # unfortunately this doesn't work since bulk_create doesn't creates pks;
- # the result would be duplicate objects at the next stage, so we comment
- # it out for now:
- # dbobjs = _ObjectDB.objects.bulk_create(dbobjs)
-
- dbobjs = [ObjectDB(**objparam[0]) for objparam in objparams]
- objs = []
- for iobj, obj in enumerate(dbobjs):
- # call all setup hooks on each object
- objparam = objparams[iobj]
- # setup
- obj._createdict = {"permissions": make_iter(objparam[1]),
- "locks": objparam[2],
- "aliases": make_iter(objparam[3]),
- "nattributes": objparam[4],
- "attributes": objparam[5],
- "tags": make_iter(objparam[6])}
- # this triggers all hooks
- obj.save()
- # run eventual extra code
- for code in objparam[7]:
- if code:
- exec(code, {}, {"evennia": evennia, "obj": obj})
- objs.append(obj)
- return objs
-
-
-def spawn(*prototypes, **kwargs):
- """
- Spawn a number of prototyped objects.
-
- Args:
- prototypes (dict): Each argument should be a prototype
- dictionary.
- Kwargs:
- prototype_modules (str or list): A python-path to a prototype
- module, or a list of such paths. These will be used to build
- the global protparents dictionary accessible by the input
- prototypes. If not given, it will instead look for modules
- defined by settings.PROTOTYPE_MODULES.
- prototype_parents (dict): A dictionary holding a custom
- prototype-parent dictionary. Will overload same-named
- prototypes from prototype_modules.
- return_prototypes (bool): Only return a list of the
- prototype-parents (no object creation happens)
-
- """
-
- protparents = {}
- protmodules = make_iter(kwargs.get("prototype_modules", []))
- if not protmodules and hasattr(settings, "PROTOTYPE_MODULES"):
- protmodules = make_iter(settings.PROTOTYPE_MODULES)
- for prototype_module in protmodules:
- protparents.update(dict((key, val) for key, val in
- all_from_module(prototype_module).items() if isinstance(val, dict)))
- # overload module's protparents with specifically given protparents
- protparents.update(kwargs.get("prototype_parents", {}))
- for key, prototype in protparents.items():
- _validate_prototype(key, prototype, protparents, [])
-
- if "return_prototypes" in kwargs:
- # only return the parents
- return copy.deepcopy(protparents)
-
- objsparams = []
- for prototype in prototypes:
-
- _validate_prototype(None, prototype, protparents, [])
- prot = _get_prototype(prototype, {}, protparents)
- if not prot:
- continue
-
- # extract the keyword args we need to create the object itself. If we get a callable,
- # call that to get the value (don't catch errors)
- create_kwargs = {}
- keyval = prot.pop("key", "Spawned Object %06i" % randint(1, 100000))
- create_kwargs["db_key"] = keyval() if callable(keyval) else keyval
-
- locval = prot.pop("location", None)
- create_kwargs["db_location"] = locval() if callable(locval) else _handle_dbref(locval)
-
- homval = prot.pop("home", settings.DEFAULT_HOME)
- create_kwargs["db_home"] = homval() if callable(homval) else _handle_dbref(homval)
-
- destval = prot.pop("destination", None)
- create_kwargs["db_destination"] = destval() if callable(destval) else _handle_dbref(destval)
-
- typval = prot.pop("typeclass", settings.BASE_OBJECT_TYPECLASS)
- create_kwargs["db_typeclass_path"] = typval() if callable(typval) else typval
-
- # extract calls to handlers
- permval = prot.pop("permissions", [])
- permission_string = permval() if callable(permval) else permval
- lockval = prot.pop("locks", "")
- lock_string = lockval() if callable(lockval) else lockval
- aliasval = prot.pop("aliases", "")
- alias_string = aliasval() if callable(aliasval) else aliasval
- tagval = prot.pop("tags", [])
- tags = tagval() if callable(tagval) else tagval
- attrval = prot.pop("attrs", [])
- attributes = attrval() if callable(tagval) else attrval
-
- exval = prot.pop("exec", "")
- execs = make_iter(exval() if callable(exval) else exval)
-
- # extract ndb assignments
- nattributes = dict((key.split("_", 1)[1], value() if callable(value) else value)
- for key, value in prot.items() if key.startswith("ndb_"))
-
- # the rest are attributes
- simple_attributes = [(key, value()) if callable(value) else (key, value)
- for key, value in prot.items() if not key.startswith("ndb_")]
- attributes = attributes + simple_attributes
- attributes = [tup for tup in attributes if not tup[0] in _CREATE_OBJECT_KWARGS]
-
- # pack for call into _batch_create_object
- objsparams.append((create_kwargs, permission_string, lock_string,
- alias_string, nattributes, attributes, tags, execs))
-
- return _batch_create_object(*objsparams)
-
-
-if __name__ == "__main__":
- # testing
-
- protparents = {
- "NOBODY": {},
- # "INFINITE" : {
- # "prototype":"INFINITE"
- # },
- "GOBLIN": {
- "key": "goblin grunt",
- "health": lambda: randint(20, 30),
- "resists": ["cold", "poison"],
- "attacks": ["fists"],
- "weaknesses": ["fire", "light"]
- },
- "GOBLIN_WIZARD": {
- "prototype": "GOBLIN",
- "key": "goblin wizard",
- "spells": ["fire ball", "lighting bolt"]
- },
- "GOBLIN_ARCHER": {
- "prototype": "GOBLIN",
- "key": "goblin archer",
- "attacks": ["short bow"]
- },
- "ARCHWIZARD": {
- "attacks": ["archwizard staff"],
- },
- "GOBLIN_ARCHWIZARD": {
- "key": "goblin archwizard",
- "prototype": ("GOBLIN_WIZARD", "ARCHWIZARD")
- }
- }
- # test
- print([o.key for o in spawn(protparents["GOBLIN"],
- protparents["GOBLIN_ARCHWIZARD"],
- prototype_parents=protparents)])
diff --git a/evennia/utils/tests/data/evform_example.py b/evennia/utils/tests/data/evform_example.py
index 572a2fff5e..bd6b42fc0b 100644
--- a/evennia/utils/tests/data/evform_example.py
+++ b/evennia/utils/tests/data/evform_example.py
@@ -6,6 +6,7 @@ Test form
FORMCHAR = "x"
TABLECHAR = "c"
+
FORM = """
.------------------------------------------------.
| |
@@ -27,4 +28,6 @@ FORM = """
| ccccccccc | ccccccccccccccccBccccccccccccccccc |
| | |
-----------`-------------------------------------
+ Footer: xxxFxxx
+ info
"""
diff --git a/evennia/utils/tests/test_evform.py b/evennia/utils/tests/test_evform.py
index e6a0d26049..fce6d6582e 100644
--- a/evennia/utils/tests/test_evform.py
+++ b/evennia/utils/tests/test_evform.py
@@ -19,7 +19,7 @@ class TestEvForm(TestCase):
u'|\n'
u'| \x1b[0m\x1b[1m\x1b[32mBouncer\x1b[0m \x1b[0m |\n'
u'| |\n'
- u' >----------------------------------------------<\n'
+ u' >----------------------------------------------< \n'
u'| |\n'
u'| Desc: \x1b[0mA sturdy \x1b[0m \x1b[0m'
u' STR: \x1b[0m12 \x1b[0m\x1b[0m\x1b[0m\x1b[0m'
@@ -31,7 +31,7 @@ class TestEvForm(TestCase):
u' LUC: \x1b[0m10 \x1b[0m\x1b[0m\x1b[0m'
u' MAG: \x1b[0m3 \x1b[0m\x1b[0m\x1b[0m |\n'
u'| |\n'
- u' >----------.-----------------------------------<\n'
+ u' >----------.-----------------------------------< \n'
u'| | |\n'
u'| \x1b[0mHP\x1b[0m|\x1b[0mMV \x1b[0m|\x1b[0mMP\x1b[0m '
u'| \x1b[0mSkill \x1b[0m|\x1b[0mValue \x1b[0m'
@@ -47,7 +47,10 @@ class TestEvForm(TestCase):
u'| \x1b[0mSmithing \x1b[0m|\x1b[0m9 \x1b[0m'
u'|\x1b[0m205/900 \x1b[0m\x1b[0m\x1b[0m\x1b[0m\x1b[0m\x1b[0m\x1b[0m |\n'
u'| | |\n'
- u' -----------`-------------------------------------\n')
+ u' -----------`-------------------------------------\n'
+ u' Footer: \x1b[0mrev 1 \x1b[0m \n'
+ u' info \n'
+ u' ')
def test_ansi_escape(self):
# note that in a msg() call, the result would be the correct |-----,
diff --git a/evennia/utils/tests/test_evmenu.py b/evennia/utils/tests/test_evmenu.py
index 4436fdccd6..a6959c0509 100644
--- a/evennia/utils/tests/test_evmenu.py
+++ b/evennia/utils/tests/test_evmenu.py
@@ -1,24 +1,230 @@
"""
Unit tests for the EvMenu system
-TODO: This need expansion.
+This sets up a testing parent for testing EvMenu trees. It is configured by subclassing the
+`TestEvMenu` class from this module and setting the class variables to point to the menu that should
+be tested and how it should be called.
+
+Without adding any further test methods, the tester will process all nodes of the menu, depth first,
+by stepping through all options for every node. Optionally, it can check that all nodes are visited.
+It will create a hierarchical list of node names that describes the tree structure. This can then be
+compared against a template to make sure the menu structure is sound. Easiest way to use this is to
+run the test once to see how the structure looks.
+
+The system also allows for testing the returns of each node as part of the parsing.
+
+To help debug the menu, turn on `debug_output`, which will print the traversal process in detail.
"""
+import copy
from django.test import TestCase
from evennia.utils import evmenu
-from mock import Mock
+from evennia.utils import ansi
+from mock import MagicMock
class TestEvMenu(TestCase):
"Run the EvMenu testing."
+ menutree = {} # can also be the path to the menu tree
+ startnode = "start"
+ cmdset_mergetype = "Replace"
+ cmdset_priority = 1
+ auto_quit = True
+ auto_look = True
+ auto_help = True
+ cmd_on_exit = "look"
+ persistent = False
+ startnode_input = ""
+ kwargs = {}
+
+ # if all nodes must be visited for the test to pass. This is not on
+ # by default since there may be exec-nodes that are made to not be
+ # visited.
+ expect_all_nodes = False
+
+ # this is compared against the full tree structure generated
+ expected_tree = []
+ # this allows for verifying that a given node returns a given text. The
+ # text is compared with .startswith, so the entire text need not be matched.
+ expected_node_texts = {}
+ # just check the number of options from each node
+ expected_node_options_count = {}
+ # check the actual options
+ expected_node_options = {}
+
+ # set this to print the traversal as it happens (debugging)
+ debug_output = False
+
+ def _debug_output(self, indent, msg):
+ if self.debug_output:
+ print(" " * indent + ansi.strip_ansi(msg))
+
+ def _test_menutree(self, menu):
+ """
+ This is a automatic tester of the menu tree by recursively progressing through the
+ structure.
+ """
+
+ def _depth_first(menu, tree, visited, indent):
+
+ # we are in a given node here
+ nodename = menu.nodename
+ options = menu.test_options
+ if isinstance(options, dict):
+ options = (options, )
+
+ # run validation tests for this node
+ compare_text = self.expected_node_texts.get(nodename, None)
+ if compare_text is not None:
+ compare_text = ansi.strip_ansi(compare_text.strip())
+ node_text = menu.test_nodetext
+ self.assertIsNotNone(
+ bool(node_text),
+ "node: {}: node-text is None, which was not expected.".format(nodename))
+ if isinstance(node_text, tuple):
+ node_text, helptext = node_text
+ node_text = ansi.strip_ansi(node_text.strip())
+ self.assertTrue(
+ node_text.startswith(compare_text),
+ "\nnode \"{}\':\nOutput:\n{}\n\nExpected (startswith):\n{}".format(
+ nodename, node_text, compare_text))
+ compare_options_count = self.expected_node_options_count.get(nodename, None)
+ if compare_options_count is not None:
+ self.assertEqual(
+ len(options), compare_options_count,
+ "Not the right number of options returned from node {}.".format(nodename))
+ compare_options = self.expected_node_options.get(nodename, None)
+ if compare_options:
+ self.assertEqual(
+ options, compare_options,
+ "Options returned from node {} does not match.".format(nodename))
+
+ self._debug_output(indent, "*{}".format(nodename))
+ subtree = []
+
+ if not options:
+ # an end node
+ if nodename not in visited:
+ visited.append(nodename)
+ subtree = nodename
+ else:
+ for inum, optdict in enumerate(options):
+
+ key, desc, execute, goto = optdict.get("key", ""), optdict.get("desc", None),\
+ optdict.get("exec", None), optdict.get("goto", None)
+
+ # prepare the key to pass to the menu
+ if isinstance(key, (tuple, list)) and len(key) > 1:
+ key = key[0]
+ if key == "_default":
+ key = "test raw input"
+ if not key:
+ key = str(inum + 1)
+
+ backup_menu = copy.copy(menu)
+
+ # step the menu
+ menu.parse_input(key)
+
+ # from here on we are likely in a different node
+ nodename = menu.nodename
+
+ if menu.close_menu.called:
+ # this was an end node
+ self._debug_output(indent, " .. menu exited! Back to previous node.")
+ menu = backup_menu
+ menu.close_menu = MagicMock()
+ visited.append(nodename)
+ subtree.append(nodename)
+ elif nodename not in visited:
+ visited.append(nodename)
+ subtree.append(nodename)
+ _depth_first(menu, subtree, visited, indent + 2)
+ #self._debug_output(indent, " -> arrived at {}".format(nodename))
+ else:
+ subtree.append(nodename)
+ #self._debug_output( indent, " -> arrived at {} (circular call)".format(nodename))
+ self._debug_output(indent, "-- {} ({}) -> {}".format(key, desc, goto))
+
+ if subtree:
+ tree.append(subtree)
+
+ # the start node has already fired at this point
+ visited_nodes = [menu.nodename]
+ traversal_tree = [menu.nodename]
+ _depth_first(menu, traversal_tree, visited_nodes, 1)
+
+ if self.expect_all_nodes:
+ self.assertGreaterEqual(len(menu._menutree), len(visited_nodes))
+ self.assertEqual(traversal_tree, self.expected_tree)
def setUp(self):
- self.caller = Mock()
- self.caller.msg = Mock()
- self.menu = evmenu.EvMenu(self.caller, "evennia.utils.evmenu", startnode="test_start_node",
- persistent=True, cmdset_mergetype="Replace", testval="val",
- testval2="val2")
+ self.menu = None
+ if self.menutree:
+ self.caller = MagicMock()
+ self.caller.key = "Test"
+ self.caller2 = MagicMock()
+ self.caller2.key = "Test"
+ self.caller.msg = MagicMock()
+ self.caller2.msg = MagicMock()
+ self.session = MagicMock()
+ self.session2 = MagicMock()
+
+ self.menu = evmenu.EvMenu(self.caller, self.menutree, startnode=self.startnode,
+ cmdset_mergetype=self.cmdset_mergetype,
+ cmdset_priority=self.cmdset_priority,
+ auto_quit=self.auto_quit, auto_look=self.auto_look,
+ auto_help=self.auto_help,
+ cmd_on_exit=self.cmd_on_exit, persistent=False,
+ startnode_input=self.startnode_input, session=self.session,
+ **self.kwargs)
+ # persistent version
+ self.pmenu = evmenu.EvMenu(self.caller2, self.menutree, startnode=self.startnode,
+ cmdset_mergetype=self.cmdset_mergetype,
+ cmdset_priority=self.cmdset_priority,
+ auto_quit=self.auto_quit, auto_look=self.auto_look,
+ auto_help=self.auto_help,
+ cmd_on_exit=self.cmd_on_exit, persistent=True,
+ startnode_input=self.startnode_input, session=self.session2,
+ **self.kwargs)
+
+ self.menu.close_menu = MagicMock()
+ self.pmenu.close_menu = MagicMock()
+
+ def test_menu_structure(self):
+ if self.menu:
+ self._test_menutree(self.menu)
+ self._test_menutree(self.pmenu)
+
+
+class TestEvMenuExample(TestEvMenu):
+
+ menutree = "evennia.utils.evmenu"
+ startnode = "test_start_node"
+ kwargs = {"testval": "val", "testval2": "val2"}
+ debug_output = False
+
+ expected_node_texts = {
+ "test_view_node": "Your name is"}
+
+ expected_tree = \
+ ['test_start_node',
+ ['test_set_node',
+ ['test_start_node'],
+ 'test_look_node',
+ ['test_start_node'],
+ 'test_view_node',
+ ['test_start_node'],
+ 'test_dynamic_node',
+ ['test_dynamic_node',
+ 'test_dynamic_node',
+ 'test_dynamic_node',
+ 'test_dynamic_node',
+ 'test_start_node'],
+ 'test_end_node',
+ 'test_displayinput_node',
+ ['test_start_node']]]
def test_kwargsave(self):
self.assertTrue(hasattr(self.menu, "testval"))
diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py
index 550b11eac3..c7c6a03d06 100644
--- a/evennia/utils/utils.py
+++ b/evennia/utils/utils.py
@@ -20,18 +20,20 @@ import textwrap
import random
from os.path import join as osjoin
from importlib import import_module
-from inspect import ismodule, trace, getmembers, getmodule
+from inspect import ismodule, trace, getmembers, getmodule, getmro
from collections import defaultdict, OrderedDict
from twisted.internet import threads, reactor, task
from django.conf import settings
from django.utils import timezone
from django.utils.translation import ugettext as _
+from django.apps import apps
from evennia.utils import logger
_MULTIMATCH_TEMPLATE = settings.SEARCH_MULTIMATCH_TEMPLATE
_EVENNIA_DIR = settings.EVENNIA_DIR
_GAME_DIR = settings.GAME_DIR
+
try:
import cPickle as pickle
except ImportError:
@@ -42,8 +44,6 @@ _GA = object.__getattribute__
_SA = object.__setattr__
_DA = object.__delattr__
-_DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
-
def is_iter(iterable):
"""
@@ -79,7 +79,7 @@ def make_iter(obj):
return not hasattr(obj, '__iter__') and [obj] or obj
-def wrap(text, width=_DEFAULT_WIDTH, indent=0):
+def wrap(text, width=None, indent=0):
"""
Safely wrap text to a certain number of characters.
@@ -92,6 +92,7 @@ def wrap(text, width=_DEFAULT_WIDTH, indent=0):
text (str): Properly wrapped text.
"""
+ width = width if width else settings.CLIENT_DEFAULT_WIDTH
if not text:
return ""
text = to_unicode(text)
@@ -103,7 +104,7 @@ def wrap(text, width=_DEFAULT_WIDTH, indent=0):
fill = wrap
-def pad(text, width=_DEFAULT_WIDTH, align="c", fillchar=" "):
+def pad(text, width=None, align="c", fillchar=" "):
"""
Pads to a given width.
@@ -118,6 +119,7 @@ def pad(text, width=_DEFAULT_WIDTH, align="c", fillchar=" "):
text (str): The padded text.
"""
+ width = width if width else settings.CLIENT_DEFAULT_WIDTH
align = align if align in ('c', 'l', 'r') else 'c'
fillchar = fillchar[0] if fillchar else " "
if align == 'l':
@@ -128,7 +130,7 @@ def pad(text, width=_DEFAULT_WIDTH, align="c", fillchar=" "):
return text.center(width, fillchar)
-def crop(text, width=_DEFAULT_WIDTH, suffix="[...]"):
+def crop(text, width=None, suffix="[...]"):
"""
Crop text to a certain width, throwing away text from too-long
lines.
@@ -146,7 +148,7 @@ def crop(text, width=_DEFAULT_WIDTH, suffix="[...]"):
text (str): The cropped text.
"""
-
+ width = width if width else settings.CLIENT_DEFAULT_WIDTH
utext = to_unicode(text)
ltext = len(utext)
if ltext <= width:
@@ -157,12 +159,16 @@ def crop(text, width=_DEFAULT_WIDTH, suffix="[...]"):
return to_str(utext)
-def dedent(text):
+def dedent(text, baseline_index=None):
"""
Safely clean all whitespace at the left of a paragraph.
Args:
text (str): The text to dedent.
+ baseline_index (int or None, optional): Which row to use as a 'base'
+ for the indentation. Lines will be dedented to this level but
+ no further. If None, indent so as to completely deindent the
+ least indented text.
Returns:
text (str): Dedented string.
@@ -175,10 +181,17 @@ def dedent(text):
"""
if not text:
return ""
- return textwrap.dedent(text)
+ if baseline_index is None:
+ return textwrap.dedent(text)
+ else:
+ lines = text.split('\n')
+ baseline = lines[baseline_index]
+ spaceremove = len(baseline) - len(baseline.lstrip(' '))
+ return "\n".join(line[min(spaceremove, len(line) - len(line.lstrip(' '))):]
+ for line in lines)
-def justify(text, width=_DEFAULT_WIDTH, align="f", indent=0):
+def justify(text, width=None, align="f", indent=0):
"""
Fully justify a text so that it fits inside `width`. When using
full justification (default) this will be done by padding between
@@ -197,6 +210,7 @@ def justify(text, width=_DEFAULT_WIDTH, align="f", indent=0):
justified (str): The justified and indented block of text.
"""
+ width = width if width else settings.CLIENT_DEFAULT_WIDTH
def _process_line(line):
"""
@@ -208,18 +222,27 @@ def justify(text, width=_DEFAULT_WIDTH, align="f", indent=0):
gap = " " # minimum gap between words
if line_rest > 0:
if align == 'l':
- line[-1] += " " * line_rest
+ if line[-1] == "\n\n":
+ line[-1] = " " * (line_rest-1) + "\n" + " " * width + "\n" + " " * width
+ else:
+ line[-1] += " " * line_rest
elif align == 'r':
line[0] = " " * line_rest + line[0]
elif align == 'c':
pad = " " * (line_rest // 2)
line[0] = pad + line[0]
- line[-1] = line[-1] + pad + " " * (line_rest % 2)
+ if line[-1] == "\n\n":
+ line[-1] += pad + " " * (line_rest % 2 - 1) + \
+ "\n" + " " * width + "\n" + " " * width
+ else:
+ line[-1] = line[-1] + pad + " " * (line_rest % 2)
else: # align 'f'
gap += " " * (line_rest // max(1, ngaps))
rest_gap = line_rest % max(1, ngaps)
for i in range(rest_gap):
line[i] += " "
+ elif not any(line):
+ return [" " * width]
return gap.join(line)
# split into paragraphs and words
@@ -260,6 +283,62 @@ def justify(text, width=_DEFAULT_WIDTH, align="f", indent=0):
return "\n".join([indentstring + line for line in lines])
+def columnize(string, columns=2, spacing=4, align='l', width=None):
+ """
+ Break a string into a number of columns, using as little
+ vertical space as possible.
+
+ Args:
+ string (str): The string to columnize.
+ columns (int, optional): The number of columns to use.
+ spacing (int, optional): How much space to have between columns.
+ width (int, optional): The max width of the columns.
+ Defaults to client's default width.
+
+ Returns:
+ columns (str): Text divided into columns.
+
+ Raises:
+ RuntimeError: If given invalid values.
+
+ """
+ columns = max(1, columns)
+ spacing = max(1, spacing)
+ width = width if width else settings.CLIENT_DEFAULT_WIDTH
+
+ w_spaces = (columns - 1) * spacing
+ w_txt = max(1, width - w_spaces)
+
+ if w_spaces + columns > width: # require at least 1 char per column
+ raise RuntimeError("Width too small to fit columns")
+
+ colwidth = int(w_txt / (1.0 * columns))
+
+ # first make a single column which we then split
+ onecol = justify(string, width=colwidth, align=align)
+ onecol = onecol.split("\n")
+
+ nrows, dangling = divmod(len(onecol), columns)
+ nrows = [nrows + 1 if i < dangling else nrows for i in range(columns)]
+
+ height = max(nrows)
+ cols = []
+ istart = 0
+ for irows in nrows:
+ cols.append(onecol[istart:istart+irows])
+ istart = istart + irows
+ for col in cols:
+ if len(col) < height:
+ col.append(" " * colwidth)
+
+ sep = " " * spacing
+ rows = []
+ for irow in range(height):
+ rows.append(sep.join(col[irow] for col in cols))
+
+ return "\n".join(rows)
+
+
def list_to_string(inlist, endsep="and", addquote=False):
"""
This pretty-formats a list as string output, adding an optional
@@ -931,17 +1010,17 @@ def delay(timedelay, callback, *args, **kwargs):
Delay the return of a value.
Args:
- timedelay (int or float): The delay in seconds
- callback (callable): Will be called with optional
- arguments after `timedelay` seconds.
- args (any, optional): Will be used as arguments to callback
+ timedelay (int or float): The delay in seconds
+ callback (callable): Will be called as `callback(*args, **kwargs)`
+ after `timedelay` seconds.
+ args (any, optional): Will be used as arguments to callback
Kwargs:
- persistent (bool, optional): should make the delay persistent
- over a reboot or reload
- any (any): Will be used to call the callback.
+ persistent (bool, optional): should make the delay persistent
+ over a reboot or reload
+ any (any): Will be used as keyword arguments to callback.
Returns:
- deferred (deferred): Will fire fire with callback after
+ deferred (deferred): Will fire with callback after
`timedelay` seconds. Note that if `timedelay()` is used in the
commandhandler callback chain, the callback chain can be
defined directly in the command body and don't need to be
@@ -1546,6 +1625,7 @@ def format_table(table, extra_space=1):
Examples:
```python
+ ftable = format_table([[...], [...], ...])
for ir, row in enumarate(ftable):
if ir == 0:
# make first row white
@@ -1786,8 +1866,12 @@ def at_search_result(matches, caller, query="", quiet=False, **kwargs):
error = kwargs.get("nofound_string") or _("Could not find '%s'." % query)
matches = None
elif len(matches) > 1:
- error = kwargs.get("multimatch_string") or \
- _("More than one match for '%s' (please narrow target):\n" % query)
+ multimatch_string = kwargs.get("multimatch_string")
+ if multimatch_string:
+ error = "%s\n" % multimatch_string
+ else:
+ error = _("More than one match for '%s' (please narrow target):\n" % query)
+
for num, result in enumerate(matches):
# we need to consider Commands, where .aliases is a list
aliases = result.aliases.all() if hasattr(result.aliases, "all") else result.aliases
@@ -1875,3 +1959,29 @@ def get_game_dir_path():
else:
os.chdir(os.pardir)
raise RuntimeError("server/conf/settings.py not found: Must start from inside game dir.")
+
+
+def get_all_typeclasses(parent=None):
+ """
+ List available typeclasses from all available modules.
+
+ Args:
+ parent (str, optional): If given, only return typeclasses inheriting (at any distance)
+ from this parent.
+
+ Returns:
+ typeclasses (dict): On the form {"typeclass.path": typeclass, ...}
+
+ Notes:
+ This will dynamicall retrieve all abstract django models inheriting at any distance
+ from the TypedObject base (aka a Typeclass) so it will work fine with any custom
+ classes being added.
+
+ """
+ from evennia.typeclasses.models import TypedObject
+ typeclasses = {"{}.{}".format(model.__module__, model.__name__): model
+ for model in apps.get_models() if TypedObject in getmro(model)}
+ if parent:
+ typeclasses = {name: typeclass for name, typeclass in typeclasses.items()
+ if inherits_from(typeclass, parent)}
+ return typeclasses
diff --git a/evennia/web/urls.py b/evennia/web/urls.py
index 332c969031..87d0e2cd15 100644
--- a/evennia/web/urls.py
+++ b/evennia/web/urls.py
@@ -17,7 +17,7 @@ urlpatterns = [
url(r'^', include('evennia.web.website.urls')), # , namespace='website', app_name='website')),
# webclient
- url(r'^webclient/', include('evennia.web.webclient.urls', namespace='webclient', app_name='webclient')),
+ url(r'^webclient/', include('evennia.web.webclient.urls', namespace='webclient')),
# favicon
url(r'^favicon\.ico$', RedirectView.as_view(url='/media/images/favicon.ico', permanent=False))
diff --git a/evennia/web/utils/general_context.py b/evennia/web/utils/general_context.py
index 27edd79c86..44bc8b3cb3 100644
--- a/evennia/web/utils/general_context.py
+++ b/evennia/web/utils/general_context.py
@@ -6,6 +6,7 @@
# tuple.
#
+import os
from django.conf import settings
from evennia.utils.utils import get_evennia_version
@@ -52,7 +53,11 @@ def set_webclient_settings():
global WEBCLIENT_ENABLED, WEBSOCKET_CLIENT_ENABLED, WEBSOCKET_PORT, WEBSOCKET_URL
WEBCLIENT_ENABLED = settings.WEBCLIENT_ENABLED
WEBSOCKET_CLIENT_ENABLED = settings.WEBSOCKET_CLIENT_ENABLED
- WEBSOCKET_PORT = settings.WEBSOCKET_CLIENT_PORT
+ # if we are working through a proxy or uses docker port-remapping, the webclient port encoded
+ # in the webclient should be different than the one the server expects. Use the environment
+ # variable WEBSOCKET_CLIENT_PROXY_PORT if this is the case.
+ WEBSOCKET_PORT = int(os.environ.get("WEBSOCKET_CLIENT_PROXY_PORT", settings.WEBSOCKET_CLIENT_PORT))
+ # this is determined dynamically by the client and is less of an issue
WEBSOCKET_URL = settings.WEBSOCKET_CLIENT_URL
set_webclient_settings()
diff --git a/evennia/web/utils/tests.py b/evennia/web/utils/tests.py
index b2d42891ae..e2b28c3510 100644
--- a/evennia/web/utils/tests.py
+++ b/evennia/web/utils/tests.py
@@ -51,9 +51,9 @@ class TestGeneralContext(TestCase):
mock_settings.WEBCLIENT_ENABLED = "webclient"
mock_settings.WEBSOCKET_CLIENT_URL = "websocket_url"
mock_settings.WEBSOCKET_CLIENT_ENABLED = "websocket_client"
- mock_settings.WEBSOCKET_CLIENT_PORT = "websocket_port"
+ mock_settings.WEBSOCKET_CLIENT_PORT = 5000
general_context.set_webclient_settings()
self.assertEqual(general_context.WEBCLIENT_ENABLED, "webclient")
self.assertEqual(general_context.WEBSOCKET_URL, "websocket_url")
self.assertEqual(general_context.WEBSOCKET_CLIENT_ENABLED, "websocket_client")
- self.assertEqual(general_context.WEBSOCKET_PORT, "websocket_port")
+ self.assertEqual(general_context.WEBSOCKET_PORT, 5000)
diff --git a/evennia/web/webclient/static/webclient/css/webclient.css b/evennia/web/webclient/static/webclient/css/webclient.css
index 1c94a1f9fd..00281d82d4 100644
--- a/evennia/web/webclient/static/webclient/css/webclient.css
+++ b/evennia/web/webclient/static/webclient/css/webclient.css
@@ -8,10 +8,11 @@
--- */
/* Overall element look */
-html, body, #clientwrapper { height: 100% }
+html, body {
+ height: 100%;
+ width: 100%;
+}
body {
- margin: 0;
- padding: 0;
background: #000;
color: #ccc;
font-size: .9em;
@@ -19,6 +20,12 @@ body {
line-height: 1.6em;
overflow: hidden;
}
+@media screen and (max-width: 480px) {
+ body {
+ font-size: .5rem;
+ line-height: .7rem;
+ }
+}
a:link, a:visited { color: inherit; }
@@ -74,93 +81,116 @@ div {margin:0px;}
}
/* Style specific classes corresponding to formatted, narative text. */
-
+.wrapper {
+ height: 100%;
+}
/* Container surrounding entire client */
-#wrapper {
- position: relative;
- height: 100%
+#clientwrapper {
+ height: 100%;
}
/* Main scrolling message area */
+
#messagewindow {
- position: absolute;
- overflow: auto;
- padding: 1em;
- -webkit-box-sizing: border-box;
- -moz-box-sizing: border-box;
- box-sizing: border-box;
- top: 0;
- left: 0;
- right: 0;
- bottom: 70px;
+ overflow-y: auto;
+ overflow-x: hidden;
+ overflow-wrap: break-word;
}
-/* Input area containing input field and button */
-#inputform {
- position: absolute;
- width: 100%;
- padding: 0;
- bottom: 0;
- margin: 0;
- padding-bottom: 10px;
- border-top: 1px solid #555;
-}
-
-#inputcontrol {
- width: 100%;
- padding: 0;
+#messagewindow {
+ overflow-y: auto;
+ overflow-x: hidden;
+ overflow-wrap: break-word;
}
/* Input field */
-#inputfield, #inputsend, #inputsizer {
- display: block;
- -webkit-box-sizing: border-box;
- -moz-box-sizing: border-box;
- box-sizing: border-box;
- height: 50px;
- background: #000;
- color: #fff;
- padding: 0 .45em;
- font-size: 1.1em;
- font-family: 'DejaVu Sans Mono', Consolas, Inconsolata, 'Lucida Console', monospace;
+#input {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ width: 100%;
}
#inputfield, #inputsizer {
- float: left;
- width: 95%;
- border: 0;
+ height: 100%;
+ background: #000;
+ color: #fff;
+ padding: 0 .45rem;
+ font-size: 1.1rem;
+ font-family: 'DejaVu Sans Mono', Consolas, Inconsolata, 'Lucida Console', monospace;
resize: none;
- line-height: normal;
+}
+#inputsend {
+ height: 100%;
+}
+#inputcontrol {
+ height: 100%;
}
#inputfield:focus {
- outline: 0;
-}
-
-#inputsizer {
- margin-left: -9999px;
-}
-
-/* Input 'send' button */
-#inputsend {
- float: right;
- width: 3%;
- max-width: 25px;
- margin-right: 10px;
- border: 0;
- background: #555;
}
/* prompt area above input field */
-#prompt {
- margin-top: 10px;
- padding: 0 .45em;
+.prompt {
+ max-height: 3rem;
+}
+
+#splitbutton {
+ width: 2rem;
+ font-size: 2rem;
+ color: #a6a6a6;
+ background-color: transparent;
+ border: 0px;
+}
+
+#splitbutton:hover {
+ color: white;
+ cursor: pointer;
+}
+
+#panebutton {
+ width: 2rem;
+ font-size: 2rem;
+ color: #a6a6a6;
+ background-color: transparent;
+ border: 0px;
+}
+
+#panebutton:hover {
+ color: white;
+ cursor: pointer;
+}
+
+#undobutton {
+ width: 2rem;
+ font-size: 2rem;
+ color: #a6a6a6;
+ background-color: transparent;
+ border: 0px;
+}
+
+#undobutton:hover {
+ color: white;
+ cursor: pointer;
+}
+
+.button {
+ width: fit-content;
+ padding: 1em;
+ color: black;
+ border: 1px solid black;
+ background-color: darkgray;
+ margin: 0 auto;
+}
+
+.splitbutton:hover {
+ cursor: pointer;
}
#optionsbutton {
- width: 40px;
- font-size: 20px;
+ width: 2rem;
+ font-size: 2rem;
color: #a6a6a6;
background-color: transparent;
border: 0px;
@@ -173,8 +203,8 @@ div {margin:0px;}
#toolbar {
position: fixed;
- top: 0;
- right: 5px;
+ top: .5rem;
+ right: .5rem;
z-index: 1;
}
@@ -195,7 +225,8 @@ div {margin:0px;}
z-index: 10;
background-color: #fefefe;
border: 1px solid #888;
- color: black;
+ color: lightgray;
+ background-color: #2c2c2c;
}
@@ -233,11 +264,12 @@ div {margin:0px;}
cursor: move;
font-weight: bold;
font-size: 16px;
- background-color: #d9d9d9;
+ color: white;
+ background-color: #595959;
}
.dialogclose {
- color: #aaa;
+ color: #d5d5d5;
float: right;
font-size: 28px;
font-weight: bold;
@@ -248,6 +280,52 @@ div {margin:0px;}
text-decoration: none;
cursor: pointer;
}
+.gutter.gutter-vertical {
+ cursor: row-resize;
+ background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFAQMAAABo7865AAAABlBMVEVHcEzMzMzyAv2sAAAAAXRSTlMAQObYZgAAABBJREFUeF5jOAMEEAIEEFwAn3kMwcB6I2AAAAAASUVORK5CYII=')
+}
+
+.gutter.gutter-horizontal {
+ cursor: col-resize;
+ background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==')
+}
+
+.split {
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+.split-sub {
+ padding: .5rem;
+}
+
+.content {
+ border: 1px solid #C0C0C0;
+ box-shadow: inset 0 1px 2px #e4e4e4;
+ background-color: black;
+ padding: 1rem;
+}
+@media screen and (max-width: 480px) {
+ .content {
+ padding: .5rem;
+ }
+}
+
+.gutter {
+ background-color: grey;
+
+ background-repeat: no-repeat;
+ background-position: 50%;
+}
+
+.split.split-horizontal, .gutter.gutter-horizontal {
+ height: 100%;
+ float: left;
+}
/* XTERM256 colors */
diff --git a/evennia/web/webclient/static/webclient/js/plugins/default_in.js b/evennia/web/webclient/static/webclient/js/plugins/default_in.js
new file mode 100644
index 0000000000..02fd401706
--- /dev/null
+++ b/evennia/web/webclient/static/webclient/js/plugins/default_in.js
@@ -0,0 +1,45 @@
+/*
+ *
+ * Evennia Webclient default 'send-text-on-enter-key' IO plugin
+ *
+ */
+let defaultin_plugin = (function () {
+
+ //
+ // handle the default key triggering onSend()
+ var onKeydown = function (event) {
+ $("#inputfield").focus();
+ if ( (event.which === 13) && (!event.shiftKey) ) { // Enter Key without shift
+ var inputfield = $("#inputfield");
+ var outtext = inputfield.val();
+ var lines = outtext.trim().replace(/[\r]+/,"\n").replace(/[\n]+/, "\n").split("\n");
+ for (var i = 0; i < lines.length; i++) {
+ plugin_handler.onSend( lines[i].trim() );
+ }
+ inputfield.val('');
+ event.preventDefault();
+ }
+
+ return true;
+ }
+
+ //
+ // Mandatory plugin init function
+ var init = function () {
+ // Handle pressing the send button
+ $("#inputsend")
+ .bind("click", function (event) {
+ var e = $.Event( "keydown" );
+ e.which = 13;
+ $('#inputfield').trigger(e);
+ });
+
+ console.log('DefaultIn initialized');
+ }
+
+ return {
+ init: init,
+ onKeydown: onKeydown,
+ }
+})();
+plugin_handler.add('defaultin', defaultin_plugin);
diff --git a/evennia/web/webclient/static/webclient/js/plugins/default_out.js b/evennia/web/webclient/static/webclient/js/plugins/default_out.js
new file mode 100644
index 0000000000..01be2f9c62
--- /dev/null
+++ b/evennia/web/webclient/static/webclient/js/plugins/default_out.js
@@ -0,0 +1,60 @@
+/*
+ *
+ * Evennia Webclient default outputs plugin
+ *
+ */
+let defaultout_plugin = (function () {
+
+ //
+ // By default add all unclaimed onText messages to the #messagewindow and scroll
+ var onText = function (args, kwargs) {
+ // append message to default pane, then scroll so latest is at the bottom.
+ var mwin = $("#messagewindow");
+ var cls = kwargs == null ? 'out' : kwargs['cls'];
+ mwin.append("
" + args[0] + "
");
+ var scrollHeight = mwin.parent().parent().prop("scrollHeight");
+ mwin.parent().parent().animate({ scrollTop: scrollHeight }, 0);
+
+ return true;
+ }
+
+ //
+ // By default just show the prompt.
+ var onPrompt = function (args, kwargs) {
+ // show prompt
+ $('#prompt')
+ .addClass("out")
+ .html(args[0]);
+
+ return true;
+ }
+
+ //
+ // By default just show an error for the Unhandled Event.
+ var onUnknownCmd = function (args, kwargs) {
+ var mwin = $("#messagewindow");
+ mwin.append(
+ "
"
+ + "Error or Unhandled event:
"
+ + cmdname + ", "
+ + JSON.stringify(args) + ", "
+ + JSON.stringify(kwargs) + "
");
+ mwin.scrollTop(mwin[0].scrollHeight);
+
+ return true;
+ }
+
+ //
+ // Mandatory plugin init function
+ var init = function () {
+ console.log('DefaultOut initialized');
+ }
+
+ return {
+ init: init,
+ onText: onText,
+ onPrompt: onPrompt,
+ onUnknownCmd: onUnknownCmd,
+ }
+})();
+plugin_handler.add('defaultout', defaultout_plugin);
diff --git a/evennia/web/webclient/static/webclient/js/plugins/default_unload.js b/evennia/web/webclient/static/webclient/js/plugins/default_unload.js
new file mode 100644
index 0000000000..42fa41c930
--- /dev/null
+++ b/evennia/web/webclient/static/webclient/js/plugins/default_unload.js
@@ -0,0 +1,17 @@
+/*
+ *
+ * Evennia Webclient default unload plugin
+ *
+ */
+let unload_plugin = (function () {
+
+ let onBeforeUnload = function () {
+ return "You are about to leave the game. Please confirm.";
+ }
+
+ return {
+ init: function () {},
+ onBeforeUnload: onBeforeUnload,
+ }
+})();
+plugin_handler.add('unload', unload_plugin);
diff --git a/evennia/web/webclient/static/webclient/js/plugins/history.js b/evennia/web/webclient/static/webclient/js/plugins/history.js
new file mode 100644
index 0000000000..c33dbcabf9
--- /dev/null
+++ b/evennia/web/webclient/static/webclient/js/plugins/history.js
@@ -0,0 +1,98 @@
+/*
+ *
+ * Evennia Webclient Command History plugin
+ *
+ */
+let history_plugin = (function () {
+
+ // Manage history for input line
+ var history_max = 21;
+ var history = new Array();
+ var history_pos = 0;
+
+ history[0] = ''; // the very latest input is empty for new entry.
+
+ //
+ // move back in the history
+ var back = function () {
+ // step backwards in history stack
+ history_pos = Math.min(++history_pos, history.length - 1);
+ return history[history.length - 1 - history_pos];
+ }
+
+ //
+ // move forward in the history
+ var fwd = function () {
+ // step forwards in history stack
+ history_pos = Math.max(--history_pos, 0);
+ return history[history.length - 1 - history_pos];
+ }
+
+ //
+ // add a new history line
+ var add = function (input) {
+ // add a new entry to history, don't repeat latest
+ if (input && input != history[history.length-2]) {
+ if (history.length >= history_max) {
+ history.shift(); // kill oldest entry
+ }
+ history[history.length-1] = input;
+ history[history.length] = '';
+ }
+ // reset the position to the last history entry
+ history_pos = 0;
+ }
+
+ //
+ // Add input to the scratch line
+ var scratch = function (input) {
+ // Put the input into the last history entry (which is normally empty)
+ // without making the array larger as with add.
+ // Allows for in-progress editing to be saved.
+ history[history.length-1] = input;
+ }
+
+ // Public
+
+ //
+ // Handle up arrow and down arrow events.
+ var onKeydown = function(event) {
+ var code = event.which;
+ var history_entry = null;
+ var inputfield = $("#inputfield");
+
+ if (code === 38) { // Arrow up
+ history_entry = back();
+ }
+ else if (code === 40) { // Arrow down
+ history_entry = fwd();
+ }
+
+ if (history_entry !== null) {
+ // Doing a history navigation; replace the text in the input.
+ inputfield.val(history_entry);
+ }
+
+ return false;
+ }
+
+ //
+ // Listen for onSend lines to add to history
+ var onSend = function (line) {
+ add(line);
+ return null; // we are not returning an altered input line
+ }
+
+ //
+ // Init function
+ var init = function () {
+ console.log('History Plugin Initialized.');
+ }
+
+ return {
+ init: init,
+ onKeydown: onKeydown,
+ onSend: onSend,
+ }
+})()
+plugin_handler.add('history', history_plugin);
diff --git a/evennia/web/webclient/static/webclient/js/plugins/hotbuttons.js b/evennia/web/webclient/static/webclient/js/plugins/hotbuttons.js
new file mode 100644
index 0000000000..e3d2ea8a7a
--- /dev/null
+++ b/evennia/web/webclient/static/webclient/js/plugins/hotbuttons.js
@@ -0,0 +1,154 @@
+/*
+ *
+ * Assignable 'hot-buttons' Plugin
+ *
+ * This adds a bar of 9 buttons that can be shift-click assigned whatever is in the textinput buffer, so you can simply
+ * click the button again and have it execute those commands, instead of having to type it all out again and again.
+ *
+ * It stores these commands as server side options.
+ *
+ * NOTE: This is a CONTRIB. To use this in your game:
+ *
+ * Stop Evennia
+ *
+ * Copy this file to mygame/web/static_overrides/webclient/js/plugins/hotbuttons.js
+ * Copy evennia/web/webclient/templates/webclient/base.html to mygame/web/template_overrides/webclient/base.html
+ *
+ * Edit mygame/web/template_overrides/webclient/base.html to add:
+ *
+ * after the other plugin tags.
+ *
+ * Run: evennia collectstatic (say 'yes' to the overwrite prompt)
+ * Start Evennia
+ */
+plugin_handler.add('hotbuttons', (function () {
+
+ var num_buttons = 9;
+ var command_cache = new Array(num_buttons);
+
+ //
+ // Add Buttons
+ var addButtonsUI = function () {
+ var buttons = $( [
+ '
',
+ ].join("\n") );
+
+ // Add buttons in front of the existing #inputform
+ buttons.insertBefore('#inputform');
+ $('#inputform').addClass('split split-vertical');
+
+ Split(['#buttons','#inputform'], {
+ direction: 'vertical',
+ sizes: [50,50],
+ gutterSize: 4,
+ minSize: 150,
+ });
+ }
+
+ //
+ // collect command text
+ var assignButton = function(n, text) { // n is 1-based
+ // make sure text has something in it
+ if( text && text.length ) {
+ // cache the command text
+ command_cache[n] = text;
+
+ // is there a space in the command, indicating "command argument" syntax?
+ if( text.indexOf(" ") > 0 ) {
+ // use the first word as the text on the button
+ $("#assign_button"+n).text( text.slice(0, text.indexOf(" ")) );
+ } else {
+ // use the single-word-text on the button
+ $("#assign_button"+n).text( text );
+ }
+ }
+ }
+
+ //
+ // Shift click a button to clear it
+ var clearButton = function(n) {
+ // change button text to "unassigned"
+ $("#assign_button"+n).text( "unassigned" );
+ // clear current command
+ command_cache[n] = "unassigned";
+ }
+
+ //
+ // actually send the command associated with the button that is clicked
+ var sendImmediate = function(n) {
+ var text = command_cache[n];
+ if( text.length ) {
+ Evennia.msg("text", [text], {});
+ }
+ }
+
+ //
+ // send, assign, or clear the button
+ var hotButtonClicked = function(e) {
+ var button = $("#assign_button"+e.data);
+ console.log("button " + e.data + " clicked");
+ if( button.text() == "unassigned" ) {
+ // Assign the button and send the full button state to the server using a Webclient_Options event
+ assignButton( e.data, $('#inputfield').val() );
+ Evennia.msg("webclient_options", [], { "HotButtons": command_cache });
+ } else {
+ if( e.shiftKey ) {
+ // Clear the button and send the full button state to the server using a Webclient_Options event
+ clearButton(e.data);
+ Evennia.msg("webclient_options", [], { "HotButtons": command_cache });
+ } else {
+ sendImmediate(e.data);
+ }
+ }
+ }
+
+ // Public
+
+ //
+ // Handle the HotButtons part of a Webclient_Options event
+ var onGotOptions = function(args, kwargs) {
+ console.log( args );
+ console.log( kwargs );
+ if( kwargs['HotButtons'] ) {
+ var buttons = kwargs['HotButtons'];
+ $.each( buttons, function( key, value ) {
+ assignButton(key, value);
+ });
+ }
+ }
+
+ //
+ // Initialize me
+ var init = function() {
+
+ // Add buttons to the UI
+ addButtonsUI();
+
+ // assign button cache
+ for( var n=0; n
]+)>)/ig,""),
+ icon: "/static/website/images/evennia_logo.png"
+ }
+
+ var n = new Notification(title, options);
+ n.onclick = function(e) {
+ e.preventDefault();
+ window.focus();
+ this.close();
+ }
+ }
+ });
+ }
+ if (("notification_sound" in options) && (options["notification_sound"])) {
+ var audio = new Audio("/static/webclient/media/notification.wav");
+ audio.play();
+ }
+ }
+ }
+
+ return false;
+ }
+
+ //
+ // required init function
+ var init = function () {
+ if ("Notification" in window) {
+ Notification.requestPermission();
+ }
+
+ favico = new Favico({
+ animation: 'none'
+ });
+
+ $(window).blur(onBlur);
+ $(window).focus(onFocus);
+
+ console.log('Notifications Plugin Initialized.');
+ }
+
+ return {
+ init: init,
+ onText: onText,
+ }
+})()
+plugin_handler.add('notifications', notifications_plugin);
diff --git a/evennia/web/webclient/static/webclient/js/plugins/oob.js b/evennia/web/webclient/static/webclient/js/plugins/oob.js
new file mode 100644
index 0000000000..55cedc9a3d
--- /dev/null
+++ b/evennia/web/webclient/static/webclient/js/plugins/oob.js
@@ -0,0 +1,35 @@
+/*
+ *
+ * OOB Plugin
+ * enables '##send { "command", [ args ], { kwargs } }' as a way to inject OOB instructions
+ *
+ */
+let oob_plugin = (function () {
+
+ //
+ // Check outgoing text for handtyped/injected JSON OOB instruction
+ var onSend = function (line) {
+ if (line.length > 7 && line.substr(0, 7) == "##send ") {
+ // send a specific oob instruction ["cmdname",[args],{kwargs}]
+ line = line.slice(7);
+ var cmdarr = JSON.parse(line);
+ var cmdname = cmdarr[0];
+ var args = cmdarr[1];
+ var kwargs = cmdarr[2];
+ log(cmdname, args, kwargs);
+ return (cmdname, args, kwargs);
+ }
+ }
+
+ //
+ // init function
+ var init = function () {
+ console.log('OOB Plugin Initialized.');
+ }
+
+ return {
+ init: init,
+ onSend: onSend,
+ }
+})()
+plugin_handler.add('oob', oob_plugin);
diff --git a/evennia/web/webclient/static/webclient/js/plugins/options.js b/evennia/web/webclient/static/webclient/js/plugins/options.js
new file mode 100644
index 0000000000..114cd6cf0e
--- /dev/null
+++ b/evennia/web/webclient/static/webclient/js/plugins/options.js
@@ -0,0 +1,160 @@
+/*
+ *
+ * Evennia Options GUI plugin
+ *
+ * This code deals with all of the UI and events related to Options.
+ *
+ */
+let options_plugin = (function () {
+ //
+ // addOptionsUI
+ var addOptionsUI = function () {
+ var content = [ // TODO dynamically create this based on the options{} hash
+ ' Don\'t echo prompts to the main text area ',
+ ' ',
+ ' Open help in popup window ',
+ ' ',
+ ' ',
+ ' Popup notification ',
+ ' ',
+ ' Play a sound ',
+ ' ',
+ ].join("\n");
+
+ // Create a new options Dialog
+ plugins['popups'].createDialog( 'optionsdialog', 'Options', content );
+ }
+
+ //
+ // addHelpUI
+ var addHelpUI = function () {
+ // Create a new Help Dialog
+ plugins['popups'].createDialog( 'helpdialog', 'Help', "" );
+ }
+
+ // addToolbarButton
+ var addToolbarButton = function () {
+ var optionsbutton = $( [
+ '',
+ '⚙',
+ 'Settings ',
+ ' ',
+ ].join("") );
+ $('#toolbar').append( optionsbutton );
+ }
+
+ //
+ // Opens the options dialog
+ var doOpenOptions = function () {
+ if (!Evennia.isConnected()) {
+ alert("You need to be connected.");
+ return;
+ }
+
+ plugins['popups'].togglePopup("#optionsdialog");
+ }
+
+ //
+ // When the user changes a setting from the interface
+ var onOptionCheckboxChanged = function () {
+ var name = $(this).data("setting");
+ var value = this.checked;
+
+ var changedoptions = {};
+ changedoptions[name] = value;
+ Evennia.msg("webclient_options", [], changedoptions);
+
+ options[name] = value;
+ }
+
+ // Public functions
+
+ //
+ // onKeydown check for 'ESC' key.
+ var onKeydown = function (event) {
+ var code = event.which;
+
+ if (code === 27) { // Escape key
+ if ($('#helpdialog').is(':visible')) {
+ plugins['popups'].closePopup("#helpdialog");
+ } else {
+ plugins['popups'].closePopup("#optionsdialog");
+ }
+ return true;
+ }
+ return false;
+ }
+
+ //
+ // Called when options settings are sent from server
+ var onGotOptions = function (args, kwargs) {
+ options = kwargs;
+
+ $.each(kwargs, function(key, value) {
+ var elem = $("[data-setting='" + key + "']");
+ if (elem.length === 0) {
+ console.log("Could not find option: " + key);
+ console.log(args);
+ console.log(kwargs);
+ } else {
+ elem.prop('checked', value);
+ };
+ });
+ }
+
+ //
+ // Called when the user logged in
+ var onLoggedIn = function (args, kwargs) {
+ $('#optionsbutton').removeClass('hidden');
+ Evennia.msg("webclient_options", [], {});
+ }
+
+ //
+ // Display a "prompt" command from the server
+ var onPrompt = function (args, kwargs) {
+ // also display the prompt in the output window if gagging is disabled
+ if (("gagprompt" in options) && (!options["gagprompt"])) {
+ plugin_handler.onText(args, kwargs);
+ }
+
+ // don't claim this Prompt as completed.
+ return false;
+ }
+
+ //
+ // Make sure to close any dialogs on connection lost
+ var onConnectionClose = function () {
+ $('#optionsbutton').addClass('hidden');
+ plugins['popups'].closePopup("#optionsdialog");
+ plugins['popups'].closePopup("#helpdialog");
+ }
+
+ //
+ // Register and init plugin
+ var init = function () {
+ // Add GUI components
+ addOptionsUI();
+ addHelpUI();
+
+ // Add Options toolbar button.
+ addToolbarButton();
+
+ // Pressing the settings button
+ $("#optionsbutton").bind("click", doOpenOptions);
+
+ // Checking a checkbox in the settings dialog
+ $("[data-setting]").bind("change", onOptionCheckboxChanged);
+
+ console.log('Options Plugin Initialized.');
+ }
+
+ return {
+ init: init,
+ onKeydown: onKeydown,
+ onLoggedIn: onLoggedIn,
+ onGotOptions: onGotOptions,
+ onPrompt: onPrompt,
+ onConnectionClose: onConnectionClose,
+ }
+})()
+plugin_handler.add('options', options_plugin);
diff --git a/evennia/web/webclient/static/webclient/js/plugins/popups.js b/evennia/web/webclient/static/webclient/js/plugins/popups.js
new file mode 100644
index 0000000000..7d9667a79f
--- /dev/null
+++ b/evennia/web/webclient/static/webclient/js/plugins/popups.js
@@ -0,0 +1,101 @@
+/*
+ * Popups GUI functions plugin
+ */
+let popups_plugin = (function () {
+
+ //
+ // openPopup
+ var openPopup = function (dialogname, content) {
+ var dialog = $(dialogname);
+ if (!dialog.length) {
+ console.log("Dialog " + renderto + " not found.");
+ return;
+ }
+
+ if (content) {
+ var contentel = dialog.find(".dialogcontent");
+ contentel.html(content);
+ }
+ dialog.show();
+ }
+
+ //
+ // closePopup
+ var closePopup = function (dialogname) {
+ var dialog = $(dialogname);
+ dialog.hide();
+ }
+
+ //
+ // togglePopup
+ var togglePopup = function (dialogname, content) {
+ var dialog = $(dialogname);
+ if (dialog.css('display') == 'none') {
+ openPopup(dialogname, content);
+ } else {
+ closePopup(dialogname);
+ }
+ }
+
+ //
+ // createDialog
+ var createDialog = function (dialogid, dialogtitle, content) {
+ var dialog = $( [
+ '',
+ '
'+ dialogtitle +'×
',
+ '
',
+ '
'+ content +'
',
+ '
',
+ '
',
+ ' ',
+ ].join("\n") );
+
+ $('body').append( dialog );
+
+ $('#'+ dialogid +' .dialogclose').bind('click', function (event) { $('#'+dialogid).hide(); });
+ }
+
+ //
+ // User clicked on a dialog to drag it
+ var doStartDragDialog = function (event) {
+ var dialog = $(event.target).closest(".dialog");
+ dialog.css('cursor', 'move');
+
+ var position = dialog.offset();
+ var diffx = event.pageX;
+ var diffy = event.pageY;
+
+ var drag = function(event) {
+ var y = position.top + event.pageY - diffy;
+ var x = position.left + event.pageX - diffx;
+ dialog.offset({top: y, left: x});
+ };
+
+ var undrag = function() {
+ $(document).unbind("mousemove", drag);
+ $(document).unbind("mouseup", undrag);
+ dialog.css('cursor', '');
+ }
+
+ $(document).bind("mousemove", drag);
+ $(document).bind("mouseup", undrag);
+ }
+
+ //
+ // required plugin function
+ var init = function () {
+ // Makes dialogs draggable
+ $(".dialogtitle").bind("mousedown", doStartDragDialog);
+
+ console.log('Popups Plugin Initialized.');
+ }
+
+ return {
+ init: init,
+ openPopup: openPopup,
+ closePopup: closePopup,
+ togglePopup: togglePopup,
+ createDialog: createDialog,
+ }
+})()
+plugin_handler.add('popups', popups_plugin);
diff --git a/evennia/web/webclient/static/webclient/js/plugins/splithandler.js b/evennia/web/webclient/static/webclient/js/plugins/splithandler.js
new file mode 100644
index 0000000000..4667065856
--- /dev/null
+++ b/evennia/web/webclient/static/webclient/js/plugins/splithandler.js
@@ -0,0 +1,414 @@
+/*
+ *
+ * Plugin to use split.js to create a basic windowed ui
+ *
+ */
+let splithandler_plugin = (function () {
+
+ var num_splits = 0;
+ var split_panes = {};
+ var backout_list = [];
+
+ var known_types = ['all', 'rest'];
+
+ // Exported Functions
+
+ //
+ // function to assign "Text types to catch" to a pane
+ var set_pane_types = function (splitpane, types) {
+ split_panes[splitpane]['types'] = types;
+ }
+
+ //
+ // Add buttons to the Evennia webcilent toolbar
+ function addToolbarButtons () {
+ var toolbar = $('#toolbar');
+ toolbar.append( $('⇹ ') );
+ toolbar.append( $('⚙ ') );
+ toolbar.append( $('↶ ') );
+ $('#undobutton').hide();
+ }
+
+ function addSplitDialog () {
+ plugins['popups'].createDialog('splitdialog', 'Split Pane', '');
+ }
+
+ function addPaneDialog () {
+ plugins['popups'].createDialog('panedialog', 'Assign Pane Options', '');
+ }
+
+ //
+ // Handle resizing the InputField after a client resize event so that the splits dont get too big.
+ function resizeInputField () {
+ var wrapper = $("#inputform")
+ var input = $("#inputcontrol")
+ var prompt = $("#prompt")
+
+ input.height( wrapper.height() - (input.offset().top - wrapper.offset().top) );
+ }
+
+ //
+ // Handle resizing of client
+ function doWindowResize() {
+ var resizable = $("[data-update-append]");
+ var parents = resizable.closest(".split");
+
+ resizeInputField();
+
+ parents.animate({
+ scrollTop: parents.prop("scrollHeight")
+ }, 0);
+ }
+
+ //
+ // create a new UI split
+ var dynamic_split = function (splitpane, direction, pane_name1, pane_name2, update_method1, update_method2, sizes) {
+ // find the sub-div of the pane we are being asked to split
+ splitpanesub = splitpane + '-sub';
+
+ // create the new div stack to replace the sub-div with.
+ var first_div = $( '
' )
+ var first_sub = $( '
' )
+ var second_div = $( '
' )
+ var second_sub = $( '
' )
+
+ // check to see if this sub-pane contains anything
+ contents = $('#'+splitpanesub).contents();
+ if( contents ) {
+ // it does, so move it to the first new div-sub (TODO -- selectable between first/second?)
+ contents.appendTo(first_sub);
+ }
+ first_div.append( first_sub );
+ second_div.append( second_sub );
+
+ // update the split_panes array to remove this pane name, but store it for the backout stack
+ var backout_settings = split_panes[splitpane];
+ delete( split_panes[splitpane] );
+
+ // now vaporize the current split_N-sub placeholder and create two new panes.
+ $('#'+splitpane).append(first_div);
+ $('#'+splitpane).append(second_div);
+ $('#'+splitpane+'-sub').remove();
+
+ // And split
+ Split(['#'+pane_name1,'#'+pane_name2], {
+ direction: direction,
+ sizes: sizes,
+ gutterSize: 4,
+ minSize: [50,50],
+ });
+
+ // store our new split sub-divs for future splits/uses by the main UI.
+ split_panes[pane_name1] = { 'types': [], 'update_method': update_method1 };
+ split_panes[pane_name2] = { 'types': [], 'update_method': update_method2 };
+
+ // add our new split to the backout stack
+ backout_list.push( {'pane1': pane_name1, 'pane2': pane_name2, 'undo': backout_settings} );
+
+ $('#undobutton').show();
+ }
+
+ //
+ // Reverse the last UI split
+ var undo_split = function () {
+ // pop off the last split pair
+ var back = backout_list.pop();
+ if( !back ) {
+ return;
+ }
+
+ if( backout_list.length === 0 ) {
+ $('#undobutton').hide();
+ }
+
+ // Collect all the divs/subs in play
+ var pane1 = back['pane1'];
+ var pane2 = back['pane2'];
+ var pane1_sub = $('#'+pane1+'-sub');
+ var pane2_sub = $('#'+pane2+'-sub');
+ var pane1_parent = $('#'+pane1).parent();
+ var pane2_parent = $('#'+pane2).parent();
+
+ if( pane1_parent.attr('id') != pane2_parent.attr('id') ) {
+ // sanity check failed...somebody did something weird...bail out
+ console.log( pane1 );
+ console.log( pane2 );
+ console.log( pane1_parent );
+ console.log( pane2_parent );
+ return;
+ }
+
+ // create a new sub-pane in the panes parent
+ var parent_sub = $( '
' )
+
+ // check to see if the special #messagewindow is in either of our sub-panes.
+ var msgwindow = pane1_sub.find('#messagewindow')
+ if( !msgwindow ) {
+ //didn't find it in pane 1, try pane 2
+ msgwindow = pane2_sub.find('#messagewindow')
+ }
+ if( msgwindow ) {
+ // It is, so collect all contents into it instead of our parent_sub div
+ // then move it to parent sub div, this allows future #messagewindow divs to flow properly
+ msgwindow.append( pane1_sub.contents() );
+ msgwindow.append( pane2_sub.contents() );
+ parent_sub.append( msgwindow );
+ } else {
+ //didn't find it, so move the contents of the two panes' sub-panes into the new sub-pane
+ parent_sub.append( pane1_sub.contents() );
+ parent_sub.append( pane2_sub.contents() );
+ }
+
+ // clear the parent
+ pane1_parent.empty();
+
+ // add the new sub-pane back to the parent div
+ pane1_parent.append(parent_sub);
+
+ // pull the sub-div's from split_panes
+ delete split_panes[pane1];
+ delete split_panes[pane2];
+
+ // add our parent pane back into the split_panes list for future splitting
+ split_panes[pane1_parent.attr('id')] = back['undo'];
+ }
+
+ //
+ // UI elements
+ //
+
+ //
+ // Draw "Split Controls" Dialog
+ var onSplitDialog = function () {
+ var dialog = $("#splitdialogcontent");
+ dialog.empty();
+
+ var selection = '';
+ for ( var pane in split_panes ) {
+ selection = selection + '' + pane + ' ';
+ }
+ selection = "Pane to split: " + selection + " ";
+ dialog.append(selection);
+
+ dialog.append(' top/bottom >');
+ dialog.append(' side-by-side ');
+
+ dialog.append('Pane 1: ');
+ dialog.append(' newlines >');
+ dialog.append(' replace >');
+ dialog.append(' append ');
+
+ dialog.append('Pane 2: ');
+ dialog.append(' newlines >');
+ dialog.append(' replace >');
+ dialog.append(' append ');
+
+ dialog.append('Split
');
+
+ $("#splitclose").bind("click", onSplitDialogClose);
+
+ plugins['popups'].togglePopup("#splitdialog");
+ }
+
+ //
+ // Close "Split Controls" Dialog
+ var onSplitDialogClose = function () {
+ var pane = $("select[name=pane]").val();
+ var direction = $("input[name=direction]:checked").attr("value");
+ var new_pane1 = $("input[name=new_pane1]").val();
+ var new_pane2 = $("input[name=new_pane2]").val();
+ var flow1 = $("input[name=flow1]:checked").attr("value");
+ var flow2 = $("input[name=flow2]:checked").attr("value");
+
+ if( new_pane1 == "" ) {
+ new_pane1 = 'pane_'+num_splits;
+ num_splits++;
+ }
+
+ if( new_pane2 == "" ) {
+ new_pane2 = 'pane_'+num_splits;
+ num_splits++;
+ }
+
+ if( document.getElementById(new_pane1) ) {
+ alert('An element: "' + new_pane1 + '" already exists');
+ return;
+ }
+
+ if( document.getElementById(new_pane2) ) {
+ alert('An element: "' + new_pane2 + '" already exists');
+ return;
+ }
+
+ dynamic_split( pane, direction, new_pane1, new_pane2, flow1, flow2, [50,50] );
+
+ plugins['popups'].closePopup("#splitdialog");
+ }
+
+ //
+ // Draw "Pane Controls" dialog
+ var onPaneControlDialog = function () {
+ var dialog = $("#panedialogcontent");
+ dialog.empty();
+
+ var selection = '';
+ for ( var pane in split_panes ) {
+ selection = selection + '' + pane + ' ';
+ }
+ selection = "Assign to pane: " + selection + " ";
+ dialog.append(selection);
+
+ var multiple = '';
+ for ( var type in known_types ) {
+ multiple = multiple + '' + known_types[type] + ' ';
+ }
+ multiple = "Content types: " + multiple + " ";
+ dialog.append(multiple);
+
+ dialog.append('Assign
');
+
+ $("#paneclose").bind("click", onPaneControlDialogClose);
+
+ plugins['popups'].togglePopup("#panedialog");
+ }
+
+ //
+ // Close "Pane Controls" dialog
+ var onPaneControlDialogClose = function () {
+ var pane = $("select[name=assign-pane]").val();
+ var types = $("select[name=assign-type]").val();
+
+ // var types = new Array;
+ // $('#splitdialogcontent input[type=checkbox]:checked').each(function() {
+ // types.push( $(this).attr('value') );
+ // });
+
+ set_pane_types( pane, types );
+
+ plugins['popups'].closePopup("#panedialog");
+ }
+ //
+ // helper function sending text to a pane
+ var txtToPane = function (panekey, txt) {
+ var pane = split_panes[panekey];
+ var text_div = $('#' + panekey + '-sub');
+
+ if ( pane['update_method'] == 'replace' ) {
+ text_div.html(txt)
+ } else if ( pane['update_method'] == 'append' ) {
+ text_div.append(txt);
+ var scrollHeight = text_div.parent().prop("scrollHeight");
+ text_div.parent().animate({ scrollTop: scrollHeight }, 0);
+ } else { // line feed
+ text_div.append("" + txt + "
");
+ var scrollHeight = text_div.parent().prop("scrollHeight");
+ text_div.parent().animate({ scrollTop: scrollHeight }, 0);
+ }
+
+ }
+
+
+ //
+ // plugin functions
+ //
+
+
+ //
+ // Accept plugin onText events
+ var onText = function (args, kwargs) {
+
+ // If the message is not itself tagged, we'll assume it
+ // should go into any panes with 'all' or 'rest' set
+ var msgtype = "rest";
+
+ if ( kwargs && 'type' in kwargs ) {
+ msgtype = kwargs['type'];
+ if ( ! known_types.includes(msgtype) ) {
+ // this is a new output type that can be mapped to panes
+ console.log('detected new output type: ' + msgtype)
+ known_types.push(msgtype);
+ }
+ }
+ var target_panes = [];
+ var rest_panes = [];
+
+ for (var key in split_panes) {
+ var pane = split_panes[key];
+ // is this message type mapped to this pane (or does the pane has an 'all' type)?
+ if (pane['types'].length > 0) {
+ if (pane['types'].includes(msgtype) || pane['types'].includes('all')) {
+ target_panes.push(key);
+ } else if (pane['types'].includes('rest')) {
+ // store rest-panes in case we have no explicit to send to
+ rest_panes.push(key);
+ }
+ } else {
+ // unassigned panes are assumed to be rest-panes too
+ rest_panes.push(key);
+ }
+ }
+ var ntargets = target_panes.length;
+ var nrests = rest_panes.length;
+ if (ntargets > 0) {
+ // we have explicit target panes to send to
+ for (var i=0; i 0) {
+ // no targets, send remainder to rest-panes/unassigned
+ for (var i=0; i= history_max) {
- history.shift(); // kill oldest entry
- }
- history[history.length-1] = input;
- history[history.length] = '';
- };
- // reset the position to the last history entry
- history_pos = 0;
- };
- var end = function () {
- // move to the end of the history stack
- history_pos = 0;
- return history[history.length -1];
- }
-
- var scratch = function (input) {
- // Put the input into the last history entry (which is normally empty)
- // without making the array larger as with add.
- // Allows for in-progress editing to be saved.
- history[history.length-1] = input;
- }
-
- return {back: back,
- fwd: fwd,
- add: add,
- end: end,
- scratch: scratch}
-}();
-
-function openPopup(dialogname, content) {
- var dialog = $(dialogname);
- if (!dialog.length) {
- console.log("Dialog " + renderto + " not found.");
- return;
- }
-
- if (content) {
- var contentel = dialog.find(".dialogcontent");
- contentel.html(content);
- }
- dialog.show();
-}
-
-function closePopup(dialogname) {
- var dialog = $(dialogname);
- dialog.hide();
-}
-
-function togglePopup(dialogname, content) {
- var dialog = $(dialogname);
- if (dialog.css('display') == 'none') {
- openPopup(dialogname, content);
- } else {
- closePopup(dialogname);
- }
-}
+var plugins = {}; // Global plugin objects by name.
+ // Each must have an init() function.
//
-// GUI Event Handlers
+// Global plugin_handler
//
+var plugin_handler = (function () {
+ "use strict"
-// Grab text from inputline and send to Evennia
-function doSendText() {
- if (!Evennia.isConnected()) {
- var reconnect = confirm("Not currently connected. Reconnect?");
- if (reconnect) {
- onText(["Attempting to reconnnect..."], {cls: "sys"});
- Evennia.connect();
- }
- // Don't try to send anything until the connection is back.
- return;
- }
- var inputfield = $("#inputfield");
- var outtext = inputfield.val();
- var lines = outtext.trim().replace(/[\r]+/,"\n").replace(/[\n]+/, "\n").split("\n");
- for (var i = 0; i < lines.length; i++) {
- var line = lines[i].trim();
- if (line.length > 7 && line.substr(0, 7) == "##send ") {
- // send a specific oob instruction ["cmdname",[args],{kwargs}]
- line = line.slice(7);
- var cmdarr = JSON.parse(line);
- var cmdname = cmdarr[0];
- var args = cmdarr[1];
- var kwargs = cmdarr[2];
- log(cmdname, args, kwargs);
- Evennia.msg(cmdname, args, kwargs);
- } else {
- input_history.add(line);
- inputfield.val("");
- Evennia.msg("text", [line], {});
- }
- }
-}
+ var ordered_plugins = new Array; // plugins in loaded order
-// Opens the options dialog
-function doOpenOptions() {
- if (!Evennia.isConnected()) {
- alert("You need to be connected.");
- return;
+ //
+ // Plugin Support Functions
+ //
+
+ // Add a new plugin
+ var add = function (name, plugin) {
+ plugins[name] = plugin;
+ ordered_plugins.push( plugin );
}
- togglePopup("#optionsdialog");
-}
-// Closes the currently open dialog
-function doCloseDialog(event) {
- var dialog = $(event.target).closest(".dialog");
- dialog.hide();
-}
+ //
+ // GUI Event Handlers
+ //
-// catch all keyboard input, handle special chars
-function onKeydown (event) {
- var code = event.which;
- var history_entry = null;
- var inputfield = $("#inputfield");
- inputfield.focus();
-
- if (code === 13) { // Enter key sends text
- doSendText();
- event.preventDefault();
- }
- else if (inputfield[0].selectionStart == inputfield.val().length) {
- // Only process up/down arrow if cursor is at the end of the line.
- if (code === 38) { // Arrow up
- history_entry = input_history.back();
- }
- else if (code === 40) { // Arrow down
- history_entry = input_history.fwd();
- }
- }
-
- if (code === 27) { // Escape key
- if ($('#helpdialog').is(':visible')) {
- closePopup("#helpdialog");
- } else {
- closePopup("#optionsdialog");
- }
- }
-
- if (history_entry !== null) {
- // Doing a history navigation; replace the text in the input.
- inputfield.val(history_entry);
- event.preventDefault();
- }
- else {
- // Save the current contents of the input to the history scratch area.
- setTimeout(function () {
- // Need to wait until after the key-up to capture the value.
- input_history.scratch(inputfield.val());
- input_history.end();
- }, 0);
- }
-};
-
-function onKeyPress (event) {
- // Prevent carriage returns inside the input area.
- if (event.which === 13) {
- event.preventDefault();
- }
-}
-
-var resizeInputField = function () {
- var min_height = 50;
- var max_height = 300;
- var prev_text_len = 0;
-
- // Check to see if we should change the height of the input area
- return function () {
- var inputfield = $("#inputfield");
- var scrollh = inputfield.prop("scrollHeight");
- var clienth = inputfield.prop("clientHeight");
- var newh = 0;
- var curr_text_len = inputfield.val().length;
-
- if (scrollh > clienth && scrollh <= max_height) {
- // Need to make it bigger
- newh = scrollh;
- }
- else if (curr_text_len < prev_text_len) {
- // There is less text in the field; try to make it smaller
- // To avoid repaints, we draw the text in an offscreen element and
- // determine its dimensions.
- var sizer = $('#inputsizer')
- .css("width", inputfield.prop("clientWidth"))
- .text(inputfield.val());
- newh = sizer.prop("scrollHeight");
- }
-
- if (newh != 0) {
- newh = Math.min(newh, max_height);
- if (clienth != newh) {
- inputfield.css("height", newh + "px");
- doWindowResize();
+ // catch all keyboard input, handle special chars
+ var onKeydown = function (event) {
+ // cycle through each plugin's keydown
+ for( let n=0; n < ordered_plugins.length; n++ ) {
+ let plugin = ordered_plugins[n];
+ // does this plugin handle keydown events?
+ if( 'onKeydown' in plugin ) {
+ // yes, does this plugin claim this event exclusively?
+ if( plugin.onKeydown(event) ) {
+ // 'true' claims this event has been handled
+ return;
+ }
}
}
- prev_text_len = curr_text_len;
+ console.log('NO plugin handled this Keydown');
}
-}();
-// Handle resizing of client
-function doWindowResize() {
- var formh = $('#inputform').outerHeight(true);
- var message_scrollh = $("#messagewindow").prop("scrollHeight");
- $("#messagewindow")
- .css({"bottom": formh}) // leave space for the input form
- .scrollTop(message_scrollh); // keep the output window scrolled to the bottom
-}
-// Handle text coming from the server
-function onText(args, kwargs) {
- // append message to previous ones, then scroll so latest is at
- // the bottom. Send 'cls' kwarg to modify the output class.
- var renderto = "main";
- if (kwargs["type"] == "help") {
- if (("helppopup" in options) && (options["helppopup"])) {
- renderto = "#helpdialog";
+ // Ask if user really wants to exit session when closing
+ // the tab or reloading the page. Note: the message is not shown
+ // in Firefox, there it's a standard error.
+ var onBeforeUnload = function () {
+ // cycle through each plugin to look for unload handlers
+ for( let n=0; n < ordered_plugins.length; n++ ) {
+ let plugin = ordered_plugins[n];
+ if( 'onBeforeUnload' in plugin ) {
+ plugin.onBeforeUnload();
+ }
}
}
- if (renderto == "main") {
- var mwin = $("#messagewindow");
- var cls = kwargs == null ? 'out' : kwargs['cls'];
- mwin.append("" + args[0] + "
");
- mwin.animate({
- scrollTop: document.getElementById("messagewindow").scrollHeight
- }, 0);
- onNewLine(args[0], null);
- } else {
- openPopup(renderto, args[0]);
- }
-}
+ //
+ // Evennia Public Event Handlers
+ //
-// Handle prompt output from the server
-function onPrompt(args, kwargs) {
- // show prompt
- $('#prompt')
- .addClass("out")
- .html(args[0]);
- doWindowResize();
-
- // also display the prompt in the output window if gagging is disabled
- if (("gagprompt" in options) && (!options["gagprompt"])) {
- onText(args, kwargs);
- }
-}
-
-// Called when the user logged in
-function onLoggedIn() {
- $('#optionsbutton').removeClass('hidden');
- Evennia.msg("webclient_options", [], {});
-}
-
-// Called when a setting changed
-function onGotOptions(args, kwargs) {
- options = kwargs;
-
- $.each(kwargs, function(key, value) {
- var elem = $("[data-setting='" + key + "']");
- if (elem.length === 0) {
- console.log("Could not find option: " + key);
- } else {
- elem.prop('checked', value);
- };
- });
-}
-
-// Called when the user changed a setting from the interface
-function onOptionCheckboxChanged() {
- var name = $(this).data("setting");
- var value = this.checked;
-
- var changedoptions = {};
- changedoptions[name] = value;
- Evennia.msg("webclient_options", [], changedoptions);
-
- options[name] = value;
-}
-
-// Silences events we don't do anything with.
-function onSilence(cmdname, args, kwargs) {}
-
-// Handle the server connection closing
-function onConnectionClose(conn_name, evt) {
- $('#optionsbutton').addClass('hidden');
- closePopup("#optionsdialog");
- onText(["The connection was closed or lost."], {'cls': 'err'});
-}
-
-// Handle unrecognized commands from server
-function onDefault(cmdname, args, kwargs) {
- var mwin = $("#messagewindow");
- mwin.append(
- ""
- + "Error or Unhandled event:
"
- + cmdname + ", "
- + JSON.stringify(args) + ", "
- + JSON.stringify(kwargs) + "
");
- mwin.scrollTop(mwin[0].scrollHeight);
-}
-
-// Ask if user really wants to exit session when closing
-// the tab or reloading the page. Note: the message is not shown
-// in Firefox, there it's a standard error.
-function onBeforeUnload() {
- return "You are about to leave the game. Please confirm.";
-}
-
-// Notifications
-var unread = 0;
-var originalTitle = document.title;
-var focused = true;
-var favico;
-
-function onBlur(e) {
- focused = false;
-}
-
-// Notifications for unfocused window
-function onFocus(e) {
- focused = true;
- document.title = originalTitle;
- unread = 0;
- favico.badge(0);
-}
-
-function onNewLine(text, originator) {
- if(!focused) {
- // Changes unfocused browser tab title to number of unread messages
- unread++;
- favico.badge(unread);
- document.title = "(" + unread + ") " + originalTitle;
- if ("Notification" in window){
- if (("notification_popup" in options) && (options["notification_popup"])) {
- Notification.requestPermission().then(function(result) {
- if(result === "granted") {
- var title = originalTitle === "" ? "Evennia" : originalTitle;
- var options = {
- body: text.replace(/(<([^>]+)>)/ig,""),
- icon: "/static/website/images/evennia_logo.png"
- }
-
- var n = new Notification(title, options);
- n.onclick = function(e) {
- e.preventDefault();
- window.focus();
- this.close();
- }
+ // Handle onLoggedIn from the server
+ var onLoggedIn = function (args, kwargs) {
+ for( let n=0; n < ordered_plugins.length; n++ ) {
+ let plugin = ordered_plugins[n];
+ if( 'onLoggedIn' in plugin ) {
+ plugin.onLoggedIn(args, kwargs);
}
- });
- }
- if (("notification_sound" in options) && (options["notification_sound"])) {
- var audio = new Audio("/static/webclient/media/notification.wav");
- audio.play();
- }
- }
- }
-}
-
-// User clicked on a dialog to drag it
-function doStartDragDialog(event) {
- var dialog = $(event.target).closest(".dialog");
- dialog.css('cursor', 'move');
-
- var position = dialog.offset();
- var diffx = event.pageX;
- var diffy = event.pageY;
-
- var drag = function(event) {
- var y = position.top + event.pageY - diffy;
- var x = position.left + event.pageX - diffx;
- dialog.offset({top: y, left: x});
- };
-
- var undrag = function() {
- $(document).unbind("mousemove", drag);
- $(document).unbind("mouseup", undrag);
- dialog.css('cursor', '');
+ }
}
- $(document).bind("mousemove", drag);
- $(document).bind("mouseup", undrag);
-}
+
+ // Handle onGotOptions from the server
+ var onGotOptions = function (args, kwargs) {
+ // does any plugin handle Options?
+ for( let n=0; n < ordered_plugins.length; n++ ) {
+ let plugin = ordered_plugins[n];
+ if( 'onGotOptions' in plugin ) {
+ plugin.onGotOptions(args, kwargs);
+ }
+ }
+ }
+
+
+ // Handle text coming from the server
+ var onText = function (args, kwargs) {
+ // does this plugin handle this onText event?
+ for( let n=0; n < ordered_plugins.length; n++ ) {
+ let plugin = ordered_plugins[n];
+ if( 'onText' in plugin ) {
+ if( plugin.onText(args, kwargs) ) {
+ // True -- means this plugin claims this Text exclusively.
+ return;
+ }
+ }
+ }
+ console.log('NO plugin handled this Text');
+ }
+
+
+ // Handle prompt output from the server
+ var onPrompt = function (args, kwargs) {
+ // does this plugin handle this onPrompt event?
+ for( let n=0; n < ordered_plugins.length; n++ ) {
+ let plugin = ordered_plugins[n];
+ if( 'onPrompt' in plugin ) {
+ if( plugin.onPrompt(args, kwargs) ) {
+ // True -- means this plugin claims this Prompt exclusively.
+ return;
+ }
+ }
+ }
+ console.log('NO plugin handled this Prompt');
+ }
+
+
+ // Handle unrecognized commands from server
+ var onDefault = function (cmdname, args, kwargs) {
+ // does this plugin handle this UnknownCmd?
+ for( let n=0; n < ordered_plugins.length; n++ ) {
+ let plugin = ordered_plugins[n];
+ if( 'onUnknownCmd' in plugin ) {
+ if( plugin.onUnknownCmd(args, kwargs) ) {
+ // True -- means this plugin claims this UnknownCmd exclusively.
+ return;
+ }
+ }
+ }
+ console.log('NO plugin handled this Unknown Evennia Command');
+ }
+
+
+ // Handle the server connection closing
+ var onConnectionClose = function (args, kwargs) {
+ // give every plugin a chance to do stuff onConnectionClose
+ for( let n=0; n < ordered_plugins.length; n++ ) {
+ let plugin = ordered_plugins[n];
+ if( 'onConnectionClose' in plugin ) {
+ plugin.onConnectionClose(args, kwargs);
+ }
+ }
+
+ onText(["The connection was closed or lost."], {'cls': 'err'});
+ }
+
+
+ // Silences events we don't do anything with.
+ var onSilence = function (cmdname, args, kwargs) {}
+
+
+ //
+ // Global onSend() function to iterate through all plugins before sending text to the server.
+ // This can be called by other plugins for "Triggers", , and other automated sends
+ //
+ var onSend = function (line) {
+ if (!Evennia.isConnected()) {
+ var reconnect = confirm("Not currently connected. Reconnect?");
+ if (reconnect) {
+ onText(["Attempting to reconnnect..."], {cls: "sys"});
+ Evennia.connect();
+ }
+ // Don't try to send anything until the connection is back.
+ return;
+ }
+
+ // default output command
+ var cmd = {
+ command: "text",
+ args: [ line ],
+ kwargs: {}
+ };
+
+ // Give each plugin a chance to use/modify the outgoing command for aliases/history/etc
+ for( let n=0; n < ordered_plugins.length; n++ ) {
+ let plugin = ordered_plugins[n];
+ if( 'onSend' in plugin ) {
+ var outCmd = plugin.onSend(line);
+ if( outCmd ) {
+ cmd = outCmd;
+ }
+ }
+ }
+
+ // console.log('sending: ' + cmd.command + ', [' + cmd.args[0].toString() + '], ' + cmd.kwargs.toString() );
+ Evennia.msg(cmd.command, cmd.args, cmd.kwargs);
+ }
+
+
+ //
+ // call each plugins' init function (the only required function)
+ //
+ var init = function () {
+ for( let n=0; n < ordered_plugins.length; n++ ) {
+ ordered_plugins[n].init();
+ }
+ }
+
+
+ return {
+ add: add,
+ onKeydown: onKeydown,
+ onBeforeUnload: onBeforeUnload,
+ onLoggedIn: onLoggedIn,
+ onText: onText,
+ onGotOptions: onGotOptions,
+ onPrompt: onPrompt,
+ onDefault: onDefault,
+ onSilence: onSilence,
+ onConnectionClose: onConnectionClose,
+ onSend: onSend,
+ init: init,
+ }
+})();
+
//
-// Register Events
+// Webclient Initialization
//
// Event when client finishes loading
$(document).ready(function() {
-
- if ("Notification" in window) {
- Notification.requestPermission();
- }
-
- favico = new Favico({
- animation: 'none'
- });
-
- // Event when client window changes
- $(window).bind("resize", doWindowResize);
-
- $(window).blur(onBlur);
- $(window).focus(onFocus);
-
- //$(document).on("visibilitychange", onVisibilityChange);
-
- $("#inputfield").bind("resize", doWindowResize)
- .keypress(onKeyPress)
- .bind("paste", resizeInputField)
- .bind("cut", resizeInputField);
-
- // Event when any key is pressed
- $(document).keydown(onKeydown)
- .keyup(resizeInputField);
-
- // Pressing the send button
- $("#inputsend").bind("click", doSendText);
-
- // Pressing the settings button
- $("#optionsbutton").bind("click", doOpenOptions);
-
- // Checking a checkbox in the settings dialog
- $("[data-setting]").bind("change", onOptionCheckboxChanged);
-
- // Pressing the close button on a dialog
- $(".dialogclose").bind("click", doCloseDialog);
-
- // Makes dialogs draggable
- $(".dialogtitle").bind("mousedown", doStartDragDialog);
-
// This is safe to call, it will always only
// initialize once.
Evennia.init();
- // register listeners
- Evennia.emitter.on("text", onText);
- Evennia.emitter.on("prompt", onPrompt);
- Evennia.emitter.on("default", onDefault);
- Evennia.emitter.on("connection_close", onConnectionClose);
- Evennia.emitter.on("logged_in", onLoggedIn);
- Evennia.emitter.on("webclient_options", onGotOptions);
- // silence currently unused events
- Evennia.emitter.on("connection_open", onSilence);
- Evennia.emitter.on("connection_error", onSilence);
- // Handle pressing the send button
- $("#inputsend").bind("click", doSendText);
+ // register listeners
+ Evennia.emitter.on("logged_in", plugin_handler.onLoggedIn);
+ Evennia.emitter.on("text", plugin_handler.onText);
+ Evennia.emitter.on("webclient_options", plugin_handler.onGotOptions);
+ Evennia.emitter.on("prompt", plugin_handler.onPrompt);
+ Evennia.emitter.on("default", plugin_handler.onDefault);
+ Evennia.emitter.on("connection_close", plugin_handler.onConnectionClose);
+
+ // silence currently unused events
+ Evennia.emitter.on("connection_open", plugin_handler.onSilence);
+ Evennia.emitter.on("connection_error", plugin_handler.onSilence);
+
// Event when closing window (have to have Evennia initialized)
- $(window).bind("beforeunload", onBeforeUnload);
+ $(window).bind("beforeunload", plugin_handler.onBeforeUnload);
$(window).bind("unload", Evennia.connection.close);
- doWindowResize();
+ // Event when any key is pressed
+ $(document).keydown(plugin_handler.onKeydown)
+
// set an idle timer to send idle every 3 minutes,
// to avoid proxy servers timing out on us
- setInterval(function() {
- // Connect to server
- Evennia.msg("text", ["idle"], {});
- },
- 60000*3
+ setInterval( function() { // Connect to server
+ Evennia.msg("text", ["idle"], {});
+ },
+ 60000*3
);
+ // Initialize all plugins
+ plugin_handler.init();
+ console.log("Completed Webclient setup");
});
-
-})();
diff --git a/evennia/web/webclient/templates/webclient/base.html b/evennia/web/webclient/templates/webclient/base.html
index f5f47b230f..1b506c8cd5 100644
--- a/evennia/web/webclient/templates/webclient/base.html
+++ b/evennia/web/webclient/templates/webclient/base.html
@@ -13,6 +13,10 @@ JQuery available.
+
+
+
+
@@ -20,7 +24,7 @@ JQuery available.
{% block jquery_import %}
-
+
{% endblock %}
+
+
+
+
+
+
+
{% block guilib_import %}
+
+
+
+
+
+
+
+
{% endblock %}
@@ -63,7 +87,11 @@ JQuery available.
}
-
+
+
+
+ {% block scripts %}
+ {% endblock %}
@@ -86,10 +114,9 @@ JQuery available.
-
+
{% block client %}
{% endblock %}
-