diff --git a/CHANGELOG.md b/CHANGELOG.md
index a05d65fdc0..22e14ae163 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,91 @@
-# Evennia Changelog
+# Changelog
-# Sept 2017:
-Release of Evennia 0.7; upgrade to Django 1.11, change 'Player' to
+## Evennia 0.8 (2018)
+
+### 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.
+- 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.
+
+### Prototype changes
+
+- 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.
+- The OLC allows for updating all objects previously created using a given prototype with any
+ changes done.
+
+### 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 of persistence and makes the `menudebug`
+ command available for examining the current menu state.
+
+
+### Webclient
+
+- Refactoring of webclient structure.
+
+### 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.
+
+### General
+
+- Start structuring the `CHANGELOG` to list features in more detail.
+- Inflection and grouping of multiple objects in default room (an box, three boxes)
+
+### Contribs
+
+- `Health Bar` (Tim Ashley Jenkins): Easily create colorful bars/meters.
+- `Tree select` (Fluttersprite): Wrapper around EvMenu to easier create
+ a common form 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 migrate is found here:
https://groups.google.com/forum/#!msg/evennia/0JYYNGY-NfE/cDFaIwmPBAAJ
@@ -14,9 +98,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 +117,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/CODING_STYLE.md b/CODING_STYLE.md
index 79488e94ec..460dfc15d5 100644
--- a/CODING_STYLE.md
+++ b/CODING_STYLE.md
@@ -97,15 +97,15 @@ def funcname(a, b, c, d=False, **kwargs):
Args:
a (str): This is a string argument that we can talk about
over multiple lines.
- b (int or str): Another argument
- c (list): A list argument
- d (bool, optional): An optional keyword argument
+ b (int or str): Another argument.
+ c (list): A list argument.
+ d (bool, optional): An optional keyword argument.
Kwargs:
- test (list): A test keyword
+ test (list): A test keyword.
Returns:
- e (str): The result of the function
+ e (str): The result of the function.
Raises:
RuntimeException: If there is a critical error,
diff --git a/Dockerfile b/Dockerfile
index 4ca00d254b..381c83f925 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -21,20 +21,30 @@
#
FROM alpine
-MAINTAINER www.evennia.com
+LABEL maintainer="www.evennia.com"
# install compilation environment
-RUN apk update && apk add python py-pip python-dev py-setuptools gcc musl-dev jpeg-dev zlib-dev bash
+RUN apk update && apk add bash gcc jpeg-dev musl-dev procps py-pip \
+py-setuptools py2-openssl python python-dev zlib-dev
-# add the project source
-ADD . /usr/src/evennia
+# add the files required for pip installation
+COPY ./setup.py /usr/src/evennia/
+COPY ./requirements.txt /usr/src/evennia/
+COPY ./evennia/VERSION.txt /usr/src/evennia/evennia/
+COPY ./bin /usr/src/evennia/bin/
# install dependencies
-RUN pip install --upgrade pip && pip install /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
+# expensive operations have completed to avoid prematurely
+# invalidating the build cache.
+COPY . /usr/src/evennia
# add the game source when rebuilding a new docker image from inside
# a game dir
-ONBUILD ADD . /usr/src/game
+ONBUILD COPY . /usr/src/game
# make the game source hierarchy persistent with a named volume.
# mount on-disk game location here when using the container
diff --git a/evennia/__init__.py b/evennia/__init__.py
index 6fdc4aaece..fc916351ad 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
diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py
index ff77eb0b90..c4c8c37df7 100644
--- a/evennia/accounts/accounts.py
+++ b/evennia/accounts/accounts.py
@@ -421,17 +421,19 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
kwargs["options"] = options
- if not (isinstance(text, basestring) or isinstance(text, tuple)):
- # sanitize text before sending across the wire
- try:
- text = to_str(text, force_string=True)
- except Exception:
- text = repr(text)
+ if text is not None:
+ if not (isinstance(text, basestring) or isinstance(text, tuple)):
+ # sanitize text before sending across the wire
+ try:
+ text = to_str(text, force_string=True)
+ except Exception:
+ text = repr(text)
+ kwargs['text'] = text
# session relay
sessions = make_iter(session) if session else self.sessions.all()
for session in sessions:
- session.data_out(text=text, **kwargs)
+ session.data_out(**kwargs)
def execute_cmd(self, raw_string, session=None, **kwargs):
"""
@@ -631,10 +633,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)
@@ -775,7 +798,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
# 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,
- session=session))
+ session=session), session=session)
def at_failed_login(self, session, **kwargs):
"""
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/commands/cmdset.py b/evennia/commands/cmdset.py
index c363f87f80..6f127da1c2 100644
--- a/evennia/commands/cmdset.py
+++ b/evennia/commands/cmdset.py
@@ -296,9 +296,9 @@ class CmdSet(with_metaclass(_CmdSetMeta, object)):
result (any): An instantiated Command or the input unmodified.
"""
- try:
+ if callable(cmd):
return cmd()
- except TypeError:
+ else:
return cmd
def _duplicate(self):
diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py
index 702f2e241a..bd4fb5e188 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
@@ -1458,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)
@@ -1702,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
@@ -1736,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"
@@ -1744,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:
@@ -1760,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
@@ -2179,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)
@@ -2270,11 +2321,12 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
@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.
- loc - display object location if exists and match has one result
+ 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
@@ -2285,7 +2337,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
key = "@find"
aliases = "@search, @locate"
- switch_options = ("room", "exit", "char", "exact", "loc")
+ switch_options = ("room", "exit", "char", "exact", "loc", "startswith")
locks = "cmd:perm(find) or perm(Builder)"
help_category = "Building"
@@ -2359,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()
@@ -2733,101 +2789,312 @@ 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"
- switch_options = ("noloc", )
+ 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:
+ protlib.validate_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_unloggedin.py b/evennia/commands/default/cmdset_unloggedin.py
index 5e43d5e8c8..1c45d908aa 100644
--- a/evennia/commands/default/cmdset_unloggedin.py
+++ b/evennia/commands/default/cmdset_unloggedin.py
@@ -23,3 +23,4 @@ class UnloggedinCmdSet(CmdSet):
self.add(unloggedin.CmdUnconnectedHelp())
self.add(unloggedin.CmdUnconnectedEncoding())
self.add(unloggedin.CmdUnconnectedScreenreader())
+ self.add(unloggedin.CmdUnconnectedInfo())
diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py
index eafb7670b0..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):
diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py
index bf01ac3039..19277c168a 100644
--- a/evennia/commands/default/tests.py
+++ b/evennia/commands/default/tests.py
@@ -14,24 +14,26 @@ main test suite started with
import re
import types
+import datetime
from django.conf import settings
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
+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
+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
-_RE = re.compile(r"^\+|-+\+|\+-+|--*|\|(?:\s|$)", re.MULTILINE)
+_RE = re.compile(r"^\+|-+\+|\+-+|--+|\|(?:\s|$)", re.MULTILINE)
# ------------------------------------------------------------
@@ -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,37 +75,58 @@ class CommandTest(EvenniaTest):
cmdobj.obj = obj or (caller if caller else self.char1)
# test
old_msg = receiver.msg
+ inputs = inputs or []
+
try:
receiver.msg = Mock()
- cmdobj.at_pre_cmd()
+ 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:
- returned_msg = "||".join(_RE.sub("", mess) for mess in stored_msg)
- returned_msg = ansi.parse_ansi(returned_msg, strip_ansi=noansi).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
@@ -127,11 +150,11 @@ class TestGeneral(CommandTest):
def test_nick(self):
self.call(general.CmdNick(), "testalias = testaliasedstring1",
- "Inputlinenick 'testalias' mapped to 'testaliasedstring1'.")
+ "Inputline-nick 'testalias' mapped to 'testaliasedstring1'.")
self.call(general.CmdNick(), "/account testalias = testaliasedstring2",
- "Accountnick 'testalias' mapped to 'testaliasedstring2'.")
+ "Account-nick 'testalias' mapped to 'testaliasedstring2'.")
self.call(general.CmdNick(), "/object testalias = testaliasedstring3",
- "Objectnick 'testalias' mapped to 'testaliasedstring3'.")
+ "Object-nick 'testalias' mapped to 'testaliasedstring3'.")
self.assertEqual(u"testaliasedstring1", self.char1.nicks.get("testalias"))
self.assertEqual(u"testaliasedstring2", self.char1.nicks.get("testalias", category="account"))
self.assertEqual(None, self.char1.account.nicks.get("testalias", category="account"))
@@ -194,7 +217,7 @@ class TestSystem(CommandTest):
self.call(system.CmdPy(), "1+2", ">>> 1+2|3")
def test_scripts(self):
- self.call(system.CmdScripts(), "", "| dbref |")
+ self.call(system.CmdScripts(), "", "dbref ")
def test_objects(self):
self.call(system.CmdObjects(), "", "Object subtype totals")
@@ -218,14 +241,14 @@ class TestAdmin(CommandTest):
self.call(admin.CmdWall(), "Test", "Announcing to all connected sessions ...")
def test_ban(self):
- self.call(admin.CmdBan(), "Char", "NameBan char was added.")
+ self.call(admin.CmdBan(), "Char", "Name-Ban char was added.")
class TestAccount(CommandTest):
def test_ooc_look(self):
if settings.MULTISESSION_MODE < 2:
- self.call(account.CmdOOCLook(), "", "You are outofcharacter (OOC).", caller=self.account)
+ self.call(account.CmdOOCLook(), "", "You are out-of-character (OOC).", caller=self.account)
if settings.MULTISESSION_MODE == 2:
self.call(account.CmdOOCLook(), "", "Account TestAccount (you are OutofCharacter)", caller=self.account)
@@ -282,8 +305,8 @@ class TestBuilding(CommandTest):
def test_attribute_commands(self):
self.call(building.CmdSetAttribute(), "Obj/test1=\"value1\"", "Created attribute Obj/test1 = 'value1'")
self.call(building.CmdSetAttribute(), "Obj2/test2=\"value2\"", "Created attribute Obj2/test2 = 'value2'")
- self.call(building.CmdMvAttr(), "Obj2/test2 = Obj/test3", "Moved Obj2.test2 > Obj.test3")
- self.call(building.CmdCpAttr(), "Obj/test1 = Obj2/test3", "Copied Obj.test1 > Obj2.test3")
+ self.call(building.CmdMvAttr(), "Obj2/test2 = Obj/test3", "Moved Obj2.test2 -> Obj.test3")
+ self.call(building.CmdCpAttr(), "Obj/test1 = Obj2/test3", "Copied Obj.test1 -> Obj2.test3")
self.call(building.CmdWipe(), "Obj2/test2/test3", "Wiped attributes test2,test3 on Obj2.")
def test_name(self):
@@ -309,7 +332,7 @@ class TestBuilding(CommandTest):
def test_exit_commands(self):
self.call(building.CmdOpen(), "TestExit1=Room2", "Created new Exit 'TestExit1' from Room to Room2")
- self.call(building.CmdLink(), "TestExit1=Room", "Link created TestExit1 > Room (one way).")
+ self.call(building.CmdLink(), "TestExit1=Room", "Link created TestExit1 -> Room (one way).")
self.call(building.CmdUnLink(), "TestExit1", "Former exit TestExit1 no longer links anywhere.")
def test_set_home(self):
@@ -327,9 +350,9 @@ class TestBuilding(CommandTest):
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")
- expect = "One Match(#1#7, loc):\n " +\
- "Char2(#7) evennia.objects.objects.DefaultCharacter (location: Room(#1))"
+ 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,
@@ -337,6 +360,7 @@ class TestBuilding(CommandTest):
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")
@@ -344,7 +368,7 @@ class TestBuilding(CommandTest):
def test_teleport(self):
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 Nonelocation.")
+ "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
@@ -356,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
@@ -364,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")
+ "/save {'prototype_key': 'testprot', 'key':'Test Char', "
+ "'typeclass':'evennia.objects.objects.DefaultCharacter'}",
+ "Saved prototype: testprot", inputs=['y'])
- # Tests that the spawned object's type is a DefaultCharacter.
- self.assertIsInstance(goblin, DefaultCharacter)
+ 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
@@ -384,14 +412,23 @@ class TestBuilding(CommandTest):
spawnLoc = self.room1
self.call(building.CmdSpawn(),
- "{'prototype':'GOBLIN', 'key':'goblin', 'location':'%s'}"
- % spawnLoc.dbref, "Spawned goblin")
+ "{'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)
@@ -404,10 +441,14 @@ class TestBuilding(CommandTest):
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'}"
+ "/noloc {'prototype_parent':'TESTBALL', 'key': 'Ball', 'prototype_key': 'foo', 'location':'%s'}"
% spawnLoc.dbref, "Spawned Ball")
ball = getObject(self, "Ball")
self.assertEqual(ball.location, spawnLoc)
@@ -416,6 +457,9 @@ class TestBuilding(CommandTest):
# test calling spawn with an invalid prototype.
self.call(building.CmdSpawn(), "'NO_EXIST'", "No prototype named 'NO_EXIST'")
+ # Test listing commands
+ self.call(building.CmdSpawn(), "/list", "Key ")
+
class TestComms(CommandTest):
@@ -471,7 +515,7 @@ 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 Batchcommand processor Automatic mode for 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
@@ -494,3 +538,12 @@ class TestInterruptCommand(CommandTest):
def test_interrupt_command(self):
ret = self.call(CmdInterrupt(), "")
self.assertEqual(ret, "")
+
+
+class TestUnconnectedCommand(CommandTest):
+ def test_info_command(self):
+ expected = "## BEGIN INFO 1.1\nName: %s\nUptime: %s\nConnected: %d\nVersion: Evennia %s\n## END INFO" % (
+ settings.SERVERNAME,
+ datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(),
+ SESSIONS.account_count(), utils.get_evennia_version())
+ self.call(unloggedin.CmdUnconnectedInfo(), "", expected)
diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py
index 67e15dfd38..bc7e69934f 100644
--- a/evennia/commands/default/unloggedin.py
+++ b/evennia/commands/default/unloggedin.py
@@ -3,6 +3,7 @@ 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
@@ -11,8 +12,9 @@ from evennia.accounts.models import AccountDB
from evennia.objects.models import ObjectDB
from evennia.server.models import ServerConfig
from evennia.comms.models import ChannelDB
+from evennia.server.sessionhandler import SESSIONS
-from evennia.utils import create, logger, utils
+from evennia.utils import create, logger, utils, gametime
from evennia.commands.cmdhandler import CMD_LOGINSTART
COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS)
@@ -516,6 +518,24 @@ class CmdUnconnectedScreenreader(COMMAND_DEFAULT_CLASS):
self.session.sessionhandler.session_portal_sync(self.session)
+class CmdUnconnectedInfo(COMMAND_DEFAULT_CLASS):
+ """
+ Provides MUDINFO output, so that Evennia games can be added to Mudconnector
+ and Mudstats. Sadly, the MUDINFO specification seems to have dropped off the
+ face of the net, but it is still used by some crawlers. This implementation
+ was created by looking at the MUDINFO implementation in MUX2, TinyMUSH, Rhost,
+ and PennMUSH.
+ """
+ key = "info"
+ locks = "cmd:all()"
+
+ def func(self):
+ self.caller.msg("## BEGIN INFO 1.1\nName: %s\nUptime: %s\nConnected: %d\nVersion: Evennia %s\n## END INFO" % (
+ settings.SERVERNAME,
+ datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(),
+ SESSIONS.account_count(), utils.get_evennia_version()))
+
+
def _create_account(session, accountname, password, permissions, typeclass=None, email=None):
"""
Helper function, creates an account of the specified typeclass.
diff --git a/evennia/comms/comms.py b/evennia/comms/comms.py
index 7ff62dfc27..a7d74ae0e4 100644
--- a/evennia/comms/comms.py
+++ b/evennia/comms/comms.py
@@ -97,7 +97,8 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)):
@property
def wholist(self):
subs = self.subscriptions.all()
- listening = [ob for ob in subs if ob.is_connected and ob not in self.mutelist]
+ muted = list(self.mutelist)
+ listening = [ob for ob in subs if ob.is_connected and ob not in muted]
if subs:
# display listening subscribers in bold
string = ", ".join([account.key if account not in listening else "|w%s|n" % account.key for account in subs])
diff --git a/evennia/contrib/README.md b/evennia/contrib/README.md
index 5ca11b1799..4785be6197 100644
--- a/evennia/contrib/README.md
+++ b/evennia/contrib/README.md
@@ -31,6 +31,7 @@ things you want from here into your game folder and change them there.
multiple descriptions for time and season as well as details.
* 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.
@@ -53,6 +54,9 @@ things you want from here into your game folder and change them there.
* 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.
diff --git a/evennia/contrib/mail.py b/evennia/contrib/mail.py
index 6e8585136d..dbecb62fcd 100644
--- a/evennia/contrib/mail.py
+++ b/evennia/contrib/mail.py
@@ -253,7 +253,7 @@ class CmdMail(default_cmds.MuxCommand):
index += 1
table.reformat_column(0, width=6)
- table.reformat_column(1, width=17)
+ table.reformat_column(1, width=18)
table.reformat_column(2, width=34)
table.reformat_column(3, width=13)
table.reformat_column(4, width=7)
diff --git a/evennia/contrib/rpsystem.py b/evennia/contrib/rpsystem.py
index a56d2de731..efba0fe7fd 100644
--- a/evennia/contrib/rpsystem.py
+++ b/evennia/contrib/rpsystem.py
@@ -1088,7 +1088,7 @@ class CmdMask(RPCommand):
if self.cmdstring == "mask":
# wear a mask
if not self.args:
- caller.msg("Usage: (un)wearmask sdesc")
+ caller.msg("Usage: (un)mask sdesc")
return
if caller.db.unmasked_sdesc:
caller.msg("You are already wearing a mask.")
@@ -1111,7 +1111,7 @@ class CmdMask(RPCommand):
del caller.db.unmasked_sdesc
caller.locks.remove("enable_recog")
caller.sdesc.add(old_sdesc)
- caller.msg("You remove your mask and is again '%s'." % old_sdesc)
+ caller.msg("You remove your mask and are again '%s'." % old_sdesc)
class RPSystemCmdSet(CmdSet):
diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py
index 69af89a3b6..b651198ccf 100644
--- a/evennia/contrib/tests.py
+++ b/evennia/contrib/tests.py
@@ -697,7 +697,7 @@ class TestMail(CommandTest):
"You have received a new @mail from TestAccount2(account 2)|You sent your message.", caller=self.account2)
self.call(mail.CmdMail(), "TestAccount=Message 1", "You sent your message.", caller=self.account2)
self.call(mail.CmdMail(), "TestAccount=Message 2", "You sent your message.", caller=self.account2)
- self.call(mail.CmdMail(), "", "| ID: From: Subject:", caller=self.account)
+ self.call(mail.CmdMail(), "", "| ID: From: Subject:", caller=self.account)
self.call(mail.CmdMail(), "2", "From: TestAccount2", caller=self.account)
self.call(mail.CmdMail(), "/forward TestAccount2 = 1/Forward message", "You sent your message.|Message forwarded.", caller=self.account)
self.call(mail.CmdMail(), "/reply 2=Reply Message2", "You sent your message.", caller=self.account)
@@ -723,9 +723,9 @@ class TestMapBuilder(CommandTest):
"evennia.contrib.mapbuilder.EXAMPLE2_MAP evennia.contrib.mapbuilder.EXAMPLE2_LEGEND",
"""Creating Map...|≈ ≈ ≈ ≈ ≈
-≈ ♣♣♣ ≈
+≈ ♣-♣-♣ ≈
≈ ♣ ♣ ♣ ≈
- ≈ ♣♣♣ ≈
+ ≈ ♣-♣-♣ ≈
≈ ≈ ≈ ≈ ≈
|Creating Landmass...|""")
@@ -768,8 +768,8 @@ from evennia.contrib import simpledoor
class TestSimpleDoor(CommandTest):
def test_cmdopen(self):
self.call(simpledoor.CmdOpen(), "newdoor;door:contrib.simpledoor.SimpleDoor,backdoor;door = Room2",
- "Created new Exit 'newdoor' from Room to Room2 (aliases: door).|Note: A doortype exit was "
- "created ignored eventual custom returnexit type.|Created new Exit 'newdoor' from Room2 to Room (aliases: door).")
+ "Created new Exit 'newdoor' from Room to Room2 (aliases: door).|Note: A door-type exit was "
+ "created - ignored eventual custom return-exit type.|Created new Exit 'newdoor' from Room2 to Room (aliases: door).")
self.call(simpledoor.CmdOpenCloseDoor(), "newdoor", "You close newdoor.", cmdstring="close")
self.call(simpledoor.CmdOpenCloseDoor(), "newdoor", "newdoor is already closed.", cmdstring="close")
self.call(simpledoor.CmdOpenCloseDoor(), "newdoor", "You open newdoor.", cmdstring="open")
@@ -798,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()
@@ -953,13 +953,13 @@ class TestTutorialWorldRooms(CommandTest):
# test turnbattle
-from evennia.contrib.turnbattle import tb_basic, tb_equip, tb_range
+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(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)")
@@ -967,13 +967,19 @@ class TestTurnBattleCmd(CommandTest):
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 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.
- testweapon = create_object(tb_equip.TBEWeapon, key="test weapon")
- testarmor = create_object(tb_equip.TBEArmor, key="test armor")
- testweapon.move_to(self.char1)
- testarmor.move_to(self.char1)
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.")
@@ -985,6 +991,8 @@ class TestTurnBattleCmd(CommandTest):
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.
@@ -1000,257 +1008,531 @@ class TestTurnBattleCmd(CommandTest):
self.call(tb_range.CmdRest(), "", "Char rests to recover HP.")
-class TestTurnBattleFunc(EvenniaTest):
+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_tbbasicfunc(self):
- attacker = create_object(tb_basic.TBBasicCharacter, key="Attacker")
- defender = create_object(tb_basic.TBBasicCharacter, key="Defender")
- testroom = create_object(DefaultRoom, key="Test Room")
- attacker.location = testroom
- defender.loaction = testroom
# Initiative roll
- initiative = tb_basic.roll_init(attacker)
+ initiative = tb_basic.roll_init(self.attacker)
self.assertTrue(initiative >= 0 and initiative <= 1000)
# Attack roll
- attack_roll = tb_basic.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 = tb_basic.get_defense(attacker, defender)
+ defense_roll = tb_basic.get_defense(self.attacker, self.defender)
self.assertTrue(defense_roll == 50)
# Damage roll
- damage_roll = tb_basic.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
- tb_basic.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
- tb_basic.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
- tb_basic.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(tb_basic.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(tb_basic.TBBasicTurnHandler)
- 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.
- turnhandler.interval = 10000
+ 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(tb_basic.is_turn(attacker))
+ self.assertTrue(tb_basic.is_turn(self.attacker))
# Spend actions
- attacker.db.Combat_ActionsLeft = 1
- tb_basic.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(tb_basic.TBBasicCharacter, 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):
- attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker")
- defender = create_object(tb_equip.TBEquipCharacter, key="Defender")
- testroom = create_object(DefaultRoom, key="Test Room")
- attacker.location = testroom
- defender.loaction = testroom
# Initiative roll
- initiative = tb_equip.roll_init(attacker)
+ initiative = tb_equip.roll_init(self.attacker)
self.assertTrue(initiative >= 0 and initiative <= 1000)
# Attack roll
- attack_roll = tb_equip.get_attack(attacker, defender)
+ 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(attacker, defender)
+ defense_roll = tb_equip.get_defense(self.attacker, self.defender)
self.assertTrue(defense_roll == 50)
# Damage roll
- damage_roll = tb_equip.get_damage(attacker, defender)
+ damage_roll = tb_equip.get_damage(self.attacker, self.defender)
self.assertTrue(damage_roll >= 0 and damage_roll <= 50)
# Apply damage
- defender.db.hp = 10
- tb_equip.apply_damage(defender, 3)
- self.assertTrue(defender.db.hp == 7)
+ self.defender.db.hp = 10
+ tb_equip.apply_damage(self.defender, 3)
+ self.assertTrue(self.defender.db.hp == 7)
# Resolve attack
- defender.db.hp = 40
- tb_equip.resolve_attack(attacker, defender, attack_value=20, defense_value=10)
- self.assertTrue(defender.db.hp < 40)
+ 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
- attacker.db.Combat_attribute = True
- tb_equip.combat_cleanup(attacker)
- self.assertFalse(attacker.db.combat_attribute)
+ 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(attacker))
+ self.assertFalse(tb_equip.is_in_combat(self.attacker))
# Set up turn handler script for further tests
- attacker.location.scripts.add(tb_equip.TBEquipTurnHandler)
- turnhandler = attacker.db.combat_TurnHandler
- self.assertTrue(attacker.db.combat_TurnHandler)
+ 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.
- turnhandler.interval = 10000
+ 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(tb_equip.is_turn(attacker))
+ self.assertTrue(tb_equip.is_turn(self.attacker))
# Spend actions
- attacker.db.Combat_ActionsLeft = 1
- tb_equip.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_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
- 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(tb_equip.TBEquipCharacter, 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.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):
- testroom = create_object(DefaultRoom, key="Test Room")
- attacker = create_object(tb_range.TBRangeCharacter, key="Attacker", location=testroom)
- defender = create_object(tb_range.TBRangeCharacter, key="Defender", location=testroom)
# Initiative roll
- initiative = tb_range.roll_init(attacker)
+ initiative = tb_range.roll_init(self.attacker)
self.assertTrue(initiative >= 0 and initiative <= 1000)
# Attack roll
- attack_roll = tb_range.get_attack(attacker, defender, "test")
+ 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(attacker, defender, "test")
+ defense_roll = tb_range.get_defense(self.attacker, self.defender, "test")
self.assertTrue(defense_roll == 50)
# Damage roll
- damage_roll = tb_range.get_damage(attacker, defender)
+ damage_roll = tb_range.get_damage(self.attacker, self.defender)
self.assertTrue(damage_roll >= 15 and damage_roll <= 25)
# Apply damage
- defender.db.hp = 10
- tb_range.apply_damage(defender, 3)
- self.assertTrue(defender.db.hp == 7)
+ self.defender.db.hp = 10
+ tb_range.apply_damage(self.defender, 3)
+ self.assertTrue(self.defender.db.hp == 7)
# Resolve attack
- defender.db.hp = 40
- tb_range.resolve_attack(attacker, defender, "test", attack_value=20, defense_value=10)
- self.assertTrue(defender.db.hp < 40)
+ 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
- attacker.db.Combat_attribute = True
- tb_range.combat_cleanup(attacker)
- self.assertFalse(attacker.db.combat_attribute)
+ 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(attacker))
+ self.assertFalse(tb_range.is_in_combat(self.attacker))
# Set up turn handler script for further tests
- attacker.location.scripts.add(tb_range.TBRangeTurnHandler)
- turnhandler = attacker.db.combat_TurnHandler
- self.assertTrue(attacker.db.combat_TurnHandler)
+ 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.
- turnhandler.interval = 10000
+ 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(tb_range.is_turn(attacker))
+ self.assertTrue(tb_range.is_turn(self.attacker))
# Spend actions
- attacker.db.Combat_ActionsLeft = 1
- tb_range.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_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
- 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")
# Set up ranges again, since initialize_for_combat clears them
- attacker.db.combat_range = {}
- attacker.db.combat_range[attacker] = 0
- attacker.db.combat_range[defender] = 1
- defender.db.combat_range = {}
- defender.db.combat_range[defender] = 0
- defender.db.combat_range[attacker] = 1
+ 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
- defender.db.Combat_ActionsLeft = 0
- turnhandler.start_turn(defender)
- self.assertTrue(defender.db.Combat_ActionsLeft == 2)
+ self.defender.db.Combat_ActionsLeft = 0
+ self.turnhandler.start_turn(self.defender)
+ self.assertTrue(self.defender.db.Combat_ActionsLeft == 2)
# 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(tb_range.TBRangeCharacter, key="Joiner", location=testroom)
- 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])
+ 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(attacker, defender) == 1)
+ self.assertTrue(tb_range.get_range(self.attacker, self.defender) == 1)
# Approach
- tb_range.approach(attacker, defender)
- self.assertTrue(tb_range.get_range(attacker, defender) == 0)
+ tb_range.approach(self.attacker, self.defender)
+ self.assertTrue(tb_range.get_range(self.attacker, self.defender) == 0)
# Withdraw
- tb_range.withdraw(attacker, defender)
- self.assertTrue(tb_range.get_range(attacker, defender) == 1)
- # Remove the script at the end
- turnhandler.stop()
+ 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
@@ -1263,6 +1545,7 @@ Bar
--Baz 2
-Qux"""
+
class TestTreeSelectFunc(EvenniaTest):
def test_tree_functions(self):
diff --git a/evennia/contrib/turnbattle/README.md b/evennia/contrib/turnbattle/README.md
index 729c42a099..fd2563bceb 100644
--- a/evennia/contrib/turnbattle/README.md
+++ b/evennia/contrib/turnbattle/README.md
@@ -21,6 +21,19 @@ implemented and customized:
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
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/tutorial_world/objects.py b/evennia/contrib/tutorial_world/objects.py
index b260770577..f83462ad6b 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
# -------------------------------------------------------------
#
@@ -674,7 +674,7 @@ class CrumblingWall(TutorialObject, DefaultExit):
# we found the button by moving the roots
result = ["Having moved all the roots aside, you find that the center of the wall, "
"previously hidden by the vegetation, hid a curious square depression. It was maybe once "
- "concealed and made to look a part of the wall, but with the crumbling of stone around it,"
+ "concealed and made to look a part of the wall, but with the crumbling of stone around it, "
"it's now easily identifiable as some sort of button."]
elif self.db.exit_open:
# we pressed the button; the exit is open
@@ -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/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..19bfbec707 100644
--- a/evennia/locks/lockhandler.py
+++ b/evennia/locks/lockhandler.py
@@ -287,7 +287,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 +296,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 +310,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 +356,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 +455,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 +515,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 +529,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 +581,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 +603,77 @@ 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 = ''
+
+_LOCK_HANDLER = LockHandler(_ObjDummy())
+
+
+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.
+
+ """
+ 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.
+
+ """
+ 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/manager.py b/evennia/objects/manager.py
index 4c81ea186a..9907c960d6 100644
--- a/evennia/objects/manager.py
+++ b/evennia/objects/manager.py
@@ -437,7 +437,7 @@ class ObjectDBManager(TypedObjectManager):
"""
Create and return a new object as a copy of the original object. All
will be identical to the original except for the arguments given
- specifically to this method.
+ specifically to this method. Object contents will not be copied.
Args:
original_object (Object): The object to make a copy from.
@@ -502,6 +502,10 @@ class ObjectDBManager(TypedObjectManager):
for script in original_object.scripts.all():
ScriptDB.objects.copy_script(script, new_obj=new_object)
+ # copy over all tags, if any
+ for tag in original_object.tags.get(return_tagobj=True, return_list=True):
+ new_object.tags.add(tag=tag.key, category=tag.category, data=tag.data)
+
return new_object
def clear_all_sessids(self):
diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py
index dfe9dbf6ec..27d8147999 100644
--- a/evennia/objects/objects.py
+++ b/evennia/objects/objects.py
@@ -23,6 +23,7 @@ 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, list_to_string,
to_str)
@@ -305,12 +306,13 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
count (int): Number of objects of this type
looker (Object): Onlooker. Not used by default.
Kwargs:
- key (str): Optional key to pluralize, use this instead of the object's key.
+ 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)
@@ -569,17 +571,19 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
except Exception:
logger.log_trace()
- if not (isinstance(text, basestring) or isinstance(text, tuple)):
- # sanitize text before sending across the wire
- try:
- text = to_str(text, force_string=True)
- except Exception:
- text = repr(text)
+ if text is not None:
+ if not (isinstance(text, basestring) or isinstance(text, tuple)):
+ # sanitize text before sending across the wire
+ try:
+ text = to_str(text, force_string=True)
+ except Exception:
+ text = repr(text)
+ kwargs['text'] = text
# relay to session(s)
sessions = make_iter(session) if session else self.sessions.all()
for session in sessions:
- session.data_out(text=text, **kwargs)
+ session.data_out(**kwargs)
def for_contents(self, func, exclude=None, **kwargs):
@@ -1001,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
@@ -1751,6 +1755,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
else:
msg_self = '{self} say, "{speech}"' if msg_self is True else msg_self
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
@@ -1873,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..edef289962
--- /dev/null
+++ b/evennia/prototypes/menus.py
@@ -0,0 +1,2400 @@
+"""
+
+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"""
+ protototype = _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)})
+
+ if "index" not in (prev_node, 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 out:
+ 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, 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):
+ key = kwargs['key']
+ diff = kwargs['diff']
+ diff[key] = "KEEP"
+
+
+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)
+
+ 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)
+
+ 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)]
+
+ helptext = """
+ 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) or even do the update manually if you are
+ unsure that the results will be acceptable. """
+
+ options = []
+
+ ichanges = 0
+ for (key, inst) in sorted(((key, val) for key, val in diff.items()), key=lambda tup: tup[0]):
+
+ if key in protlib._PROTOTYPE_META_NAMES:
+ continue
+
+ line = "{iopt} |w{key}|n: {old}{sep}{new} {change}"
+ old_val = str(obj_prototype.get(key, ""))
+
+ if inst == "KEEP":
+ inst = "|b{}|n".format(inst)
+ text.append(line.format(iopt='', key=key, old=old_val,
+ sep=" ", new='', change=inst))
+ continue
+
+ if key in prototype:
+ new_val = str(spawner.init_spawn_value(prototype[key]))
+ else:
+ new_val = ""
+ ichanges += 1
+ if inst in ("UPDATE", "REPLACE"):
+ inst = "|y{}|n".format(inst)
+ text.append(line.format(iopt=ichanges, key=key, old=old_val,
+ sep=" |y->|n ", new=new_val, change=inst))
+ options.append(_keep_option(key, prototype,
+ base_obj, obj_prototype, diff, update_objects, back_node))
+ elif inst == "REMOVE":
+ inst = "|r{}|n".format(inst)
+ text.append(line.format(iopt=ichanges, key=key, old=old_val,
+ sep=" |r->|n ", new='', change=inst))
+ options.append(_keep_option(key, prototype,
+ base_obj, obj_prototype, diff, update_objects, back_node))
+ 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})}])
+
+ if ichanges < 1:
+ 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))})
+ 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 ''
+ 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..6dff62ef96
--- /dev/null
+++ b/evennia/prototypes/protfuncs.py
@@ -0,0 +1,317 @@
+"""
+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
+
+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 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..0cc016300f
--- /dev/null
+++ b/evennia/prototypes/prototypes.py
@@ -0,0 +1,695 @@
+"""
+
+Handling storage of prototypes, both database-based ones (DBPrototypes) and those defined in modules
+(Read-only prototypes).
+
+"""
+
+import re
+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)
+from evennia.locks.lockhandler import validate_lockstring, check_lockstring
+from evennia.utils import logger
+from evennia.utils import inlinefuncs
+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"(? 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 and typeclass not in get_all_typeclasses("evennia.objects.models.ObjectDB"):
+ _flags['errors'].append(
+ "Prototype {} is based on typeclass {}, which could not be imported!".format(
+ 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
diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py
new file mode 100644
index 0000000000..5ead6239e7
--- /dev/null
+++ b/evennia/prototypes/spawner.py
@@ -0,0 +1,613 @@
+"""
+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')]
+ "attrs": [("weapon", "sword")]
+ }
+```
+
+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)
+ 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" 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
+import random
+
+
+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")
+_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:
+ 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()
+ if perms:
+ prot['permissions'] = make_iter(perms)
+ aliases = obj.aliases.get()
+ if aliases:
+ prot['aliases'] = aliases
+ tags = [(tag.db_key, tag.db_category, tag.db_data)
+ for tag in obj.tags.get(return_tagobj=True, return_list=True) if tag]
+ if tags:
+ prot['tags'] = tags
+ attrs = [(attr.key, attr.value, attr.category, attr.locks.all())
+ for attr in obj.attributes.get(return_obj=True, return_list=True) if attr]
+ if attrs:
+ prot['attrs'] = attrs
+
+ return prot
+
+
+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): Prototype.
+ obj (Object): Object to
+
+ Returns:
+ diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...}
+ other_prototype (dict): The prototype for the given object. The diff is a how to convert
+ this prototype into the new prototype.
+
+ """
+ prot1 = prototype
+ prot2 = prototype_from_object(obj)
+
+ diff = {}
+ for key, value in prot1.items():
+ diff[key] = "KEEP"
+ if key in prot2:
+ if callable(prot2[key]) or value != prot2[key]:
+ if key in ('attrs', 'tags', 'permissions', 'locks', 'aliases'):
+ diff[key] = 'REPLACE'
+ else:
+ diff[key] = "UPDATE"
+ elif key not in prot2:
+ diff[key] = "UPDATE"
+ for key in prot2:
+ if key not in diff and key not in prot1:
+ diff[key] = "REMOVE"
+
+ return diff, prot2
+
+
+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.
+
+ """
+ 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])
+
+ 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(val, make_iter))
+ elif key == 'aliases':
+ if directive == 'REPLACE':
+ obj.aliases.clear()
+ obj.aliases.batch_add(*init_spawn_value(val, make_iter))
+ elif key == 'tags':
+ if directive == 'REPLACE':
+ obj.tags.clear()
+ obj.tags.batch_add(*init_spawn_value(val, make_iter))
+ elif key == 'attrs':
+ if directive == 'REPLACE':
+ obj.attributes.clear()
+ obj.attributes.batch_add(*init_spawn_value(val, make_iter))
+ 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. 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, return dict of prototype parents.
+
+ """
+ # get available protparents
+ protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()}
+
+ # 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 tags:
+ tags.append((init_spawn_value(val, 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(val), category, locks))
+
+ simple_attributes = []
+ for key, value in ((key, value) for key, value in prot.items()
+ if not (key.startswith("ndb_"))):
+ if key in _PROTOTYPE_META_NAMES:
+ continue
+
+ if is_iter(value) and len(value) > 1:
+ # (value, category)
+ simple_attributes.append((key,
+ init_spawn_value(value[0], value_to_obj_or_any),
+ init_spawn_value(value[1], str)))
+ else:
+ simple_attributes.append((key,
+ init_spawn_value(value, value_to_obj_or_any)))
+
+ 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..1c77fd85c3
--- /dev/null
+++ b/evennia/prototypes/tests.py
@@ -0,0 +1,531 @@
+"""
+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_remove')
+
+ old_prot = spawner.prototype_from_object(self.obj1)
+
+ # modify object away from prototype
+ self.obj1.attributes.add('test', 'testval')
+ self.obj1.aliases.add('foo')
+ self.obj1.key = 'NewObj'
+
+ # 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'
+
+ # diff obj/prototype
+ pdiff = spawner.prototype_diff_from_object(old_prot, self.obj1)
+
+ self.assertEqual(
+ pdiff,
+ ({'aliases': 'REMOVE',
+ 'attrs': 'REPLACE',
+ 'home': 'KEEP',
+ 'key': 'UPDATE',
+ 'location': 'KEEP',
+ 'locks': 'KEEP',
+ 'new': 'UPDATE',
+ 'permissions': 'UPDATE',
+ 'prototype_desc': 'UPDATE',
+ 'prototype_key': 'UPDATE',
+ 'prototype_locks': 'KEEP',
+ 'prototype_tags': 'KEEP',
+ 'test': 'UPDATE',
+ 'typeclass': 'KEEP'},
+ {'attrs': [('oldtest', 'to_remove', None, ['']),
+ ('test', 'testval', None, [''])],
+ 'prototype_locks': 'spawn:all();edit:all()',
+ 'prototype_key': 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_tags': [],
+ 'location': "#1",
+ 'key': 'NewObj',
+ 'home': '#1',
+ 'typeclass': 'evennia.objects.objects.DefaultObject',
+ 'prototype_desc': 'Built from NewObj',
+ 'aliases': 'foo'})
+ )
+
+ # apply diff
+ count = spawner.batch_update_objects_with_prototype(
+ old_prot, diff=pdiff[0], objects=[self.obj1])
+ self.assertEqual(count, 1)
+
+ new_prot = spawner.prototype_from_object(self.obj1)
+ self.assertEqual({'attrs': [('test', 'testval_changed', None, ['']),
+ ('new', 'new_val', 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):
+
+ 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
+ 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'}) )
+
+
+@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_validate_prototype', ['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_examine_entity', ['node_prototype_locks', 'node_prototype_locks', 'node_prototype_locks'], 'node_examine_entity', 'node_prototype_locks', 'node_index', 'node_validate_prototype', 'node_index'], 'node_validate_prototype', 'node_index', 'node_prototype_spawn', ['node_index', 'node_validate_prototype'], 'node_index', 'node_search_object', ['node_index', 'node_index']]]
diff --git a/evennia/scripts/scripts.py b/evennia/scripts/scripts.py
index db6e9652cb..8bff161cf5 100644
--- a/evennia/scripts/scripts.py
+++ b/evennia/scripts/scripts.py
@@ -18,6 +18,17 @@ from future.utils import with_metaclass
__all__ = ["DefaultScript", "DoNothing", "Store"]
+FLUSHING_INSTANCES = False # whether we're in the process of flushing scripts from the cache
+SCRIPT_FLUSH_TIMERS = {} # stores timers for scripts that are currently being flushed
+
+
+def restart_scripts_after_flush():
+ """After instances are flushed, validate scripts so they're not dead for a long period of time"""
+ global FLUSHING_INSTANCES
+ ScriptDB.objects.validate()
+ FLUSHING_INSTANCES = False
+
+
class ExtendedLoopingCall(LoopingCall):
"""
LoopingCall that can start at a delay different
@@ -141,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.
@@ -239,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):
"""
@@ -278,6 +369,27 @@ class DefaultScript(ScriptBase):
return max(0, self.db_repeats - task.callcount)
return None
+ def at_idmapper_flush(self):
+ """If we're flushing this object, make sure the LoopingCall is gone too"""
+ ret = super(DefaultScript, self).at_idmapper_flush()
+ if ret and self.ndb._task:
+ try:
+ from twisted.internet import reactor
+ global FLUSHING_INSTANCES
+ # store the current timers for the _task and stop it to avoid duplicates after cache flush
+ paused_time = self.ndb._task.next_call_time()
+ callcount = self.ndb._task.callcount
+ self._stop_task()
+ SCRIPT_FLUSH_TIMERS[self.id] = (paused_time, callcount)
+ # here we ensure that the restart call only happens once, not once per script
+ if not FLUSHING_INSTANCES:
+ FLUSHING_INSTANCES = True
+ reactor.callLater(2, restart_scripts_after_flush)
+ except Exception:
+ import traceback
+ traceback.print_exc()
+ return ret
+
def start(self, force_restart=False):
"""
Called every time the script is started (for persistent
@@ -294,9 +406,19 @@ class DefaultScript(ScriptBase):
started or not. Used in counting.
"""
-
if self.is_active and not force_restart:
- # script already runs and should not be restarted.
+ # The script is already running, but make sure we have a _task if this is after a cache flush
+ if not self.ndb._task and self.db_interval >= 0:
+ self.ndb._task = ExtendedLoopingCall(self._step_task)
+ try:
+ start_delay, callcount = SCRIPT_FLUSH_TIMERS[self.id]
+ del SCRIPT_FLUSH_TIMERS[self.id]
+ now = False
+ except (KeyError, ValueError, TypeError):
+ now = not self.db_start_delay
+ start_delay = None
+ callcount = 0
+ self.ndb._task.start(self.db_interval, now=now, start_delay=start_delay, count_start=callcount)
return 0
obj = self.obj
@@ -472,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_client.py b/evennia/server/amp_client.py
index 8b9f9d4e8e..a4300adf4d 100644
--- a/evennia/server/amp_client.py
+++ b/evennia/server/amp_client.py
@@ -51,7 +51,7 @@ class AMPClientFactory(protocol.ReconnectingClientFactory):
def buildProtocol(self, addr):
"""
- Creates an AMPProtocol instance when connecting to the server.
+ Creates an AMPProtocol instance when connecting to the AMP server.
Args:
addr (str): Connection address. Not used.
@@ -108,6 +108,8 @@ class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol):
# 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):
"""
diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py
index c82b922ca0..c83d869336 100644
--- a/evennia/server/evennia_launcher.py
+++ b/evennia/server/evennia_launcher.py
@@ -467,9 +467,10 @@ ARG_OPTIONS = \
stop - shutdown server+portal
reboot - shutdown server+portal, then start again
reset - restart server in 'shutdown' mode
- sstart - start only server (requires portal)
+ istart - start server in the foreground (until reload)
+ sstop - stop only server
kill - send kill signal to portal+server (force)
- skill = send kill signal only to server
+ 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
@@ -955,14 +956,39 @@ def reboot_evennia(pprofiler=False, sprofiler=False):
send_instruction(PSTATUS, None, _portal_running, _portal_not_running)
-def stop_server_only():
+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)
+
+
+def stop_server_only(when_stopped=None):
"""
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).
+
"""
def _server_stopped(*args):
- print("... Server stopped.")
- _reactor_stop()
+ if when_stopped:
+ when_stopped()
+ else:
+ print("... Server stopped.")
+ _reactor_stop()
def _portal_running(response):
_, srun, _, _, _, _ = _parse_status(response)
@@ -971,8 +997,11 @@ def stop_server_only():
wait_for_status_reply(_server_stopped)
send_instruction(SSHUTD, {})
else:
- print("Server is not running.")
- _reactor_stop()
+ if when_stopped:
+ when_stopped()
+ else:
+ print("Server is not running.")
+ _reactor_stop()
def _portal_not_running(fail):
print("Evennia is not running.")
@@ -1037,9 +1066,11 @@ def tail_log_files(filename1, filename2, start_lines1=20, start_lines2=20, rate=
new_linecount = sum(blck.count("\n") for blck in _block(filehandle))
if new_linecount < old_linecount:
- # this could happen if the file was manually deleted or edited
- print("Log file has shrunk. Restart log reader.")
- sys.exit()
+ # 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)
@@ -1935,7 +1966,7 @@ def main():
# launch menu for operation
init_game_directory(CURRENT_DIR, check_db=True)
run_menu()
- elif option in ('status', 'info', 'start', 'reload', 'reboot',
+ elif option in ('status', 'info', 'start', 'istart', 'reload', 'reboot',
'reset', 'stop', 'sstop', 'kill', 'skill'):
# operate the server directly
if not SERVER_LOGFILE:
@@ -1946,6 +1977,8 @@ def main():
query_info()
elif option == "start":
start_evennia(args.profiler, args.profiler)
+ elif option == "istart":
+ start_server_interactive()
elif option == 'reload':
reload_evennia(args.profiler)
elif option == 'reboot':
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
index 4ff4732708..71a1bcba91 100644
--- a/evennia/server/portal/amp.py
+++ b/evennia/server/portal/amp.py
@@ -44,9 +44,10 @@ 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)
-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)
@@ -61,11 +62,15 @@ _HTTP_WARNING = """
HTTP/1.1 200 OK
Content-Type: text/html
-
-This is Evennia's interal AMP port. It handles communication
-between Evennia's different processes.This port should NOT be
-publicly visible.
-""".strip()
+
+
+ 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.
@@ -107,43 +112,45 @@ class Compressed(amp.String):
def fromBox(self, name, strings, objects, proto):
"""
- Converts from box representation to python. We
- group very long data into batches.
+ Converts from box string representation to python. We read back too-long batched data and
+ put it back together here.
+
"""
value = StringIO()
- value.write(strings.get(name))
+ 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(chunk)
+ value.write(self.fromStringProto(chunk, proto))
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.
+ 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] = value.read(AMP_MAXLEN)
+ 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)] = chunk
+ strings["%s.%d" % (name, counter)] = self.toStringProto(chunk, proto)
def toString(self, inObject):
"""
- Convert to send on the wire, with compression.
+ Convert to send as a string on the wire, with compression.
"""
- return zlib.compress(inObject, 9)
+ return zlib.compress(super(Compressed, self).toString(inObject), 9)
def fromString(self, inString):
"""
- Convert (decompress) from the wire to Python.
+ Convert (decompress) from the string-representation on the wire to Python.
"""
- return zlib.decompress(inString)
+ return super(Compressed, self).fromString(zlib.decompress(inString))
class MsgLauncher2Portal(amp.Command):
@@ -261,16 +268,29 @@ class AMPMultiConnectionProtocol(amp.AMP):
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] != b'\0':
+ 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()
- else:
- super(AMPMultiConnectionProtocol, self).dataReceived(data)
+ print("HTML received: %s" % data)
def makeConnection(self, transport):
"""
diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py
index c550a648c3..38e39fb464 100644
--- a/evennia/server/portal/amp_server.py
+++ b/evennia/server/portal/amp_server.py
@@ -356,10 +356,13 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol):
packed_data (str): Pickled data (sessid, kwargs) coming over the wire.
"""
- 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)
+ 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
diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py
index a720c73c71..6b15cde73a 100644
--- a/evennia/server/portal/portal.py
+++ b/evennia/server/portal/portal.py
@@ -343,7 +343,7 @@ if WEBSERVER_ENABLED:
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)
INFO_DICT["webserver_proxy"].append("webserver-proxy%s: %s" % (ifacestr, proxyport))
INFO_DICT["webserver_internal"].append("webserver: %s" % serverport)
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/server.py b/evennia/server/server.py
index 1be9b5be0b..5a225704c2 100644
--- a/evennia/server/server.py
+++ b/evennia/server/server.py
@@ -181,9 +181,6 @@ class Evennia(object):
self.start_time = time.time()
- # Run the initial setup if needed
- self.run_initial_setup()
-
# initialize channelhandler
channelhandler.CHANNELHANDLER.update()
@@ -274,6 +271,8 @@ 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.
@@ -508,10 +507,11 @@ ServerConfig.objects.conf("server_starting_mode", True)
# what to execute from.
application = service.Application('Evennia')
-# custom logging
-logfile = logger.WeeklyLogFile(os.path.basename(settings.SERVER_LOG_FILE),
- os.path.dirname(settings.SERVER_LOG_FILE))
-application.setComponent(ILogObserver, logger.ServerLogObserver(logfile).emit)
+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.
diff --git a/evennia/server/sessionhandler.py b/evennia/server/sessionhandler.py
index dcb9adb689..8e439b42dd 100644
--- a/evennia/server/sessionhandler.py
+++ b/evennia/server/sessionhandler.py
@@ -278,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):
@@ -290,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.
diff --git a/evennia/settings_default.py b/evennia/settings_default.py
index a5c4b7255d..9c34a6165a 100644
--- a/evennia/settings_default.py
+++ b/evennia/settings_default.py
@@ -142,7 +142,7 @@ LOCKWARNING_LOG_FILE = os.path.join(LOG_DIR, 'lockwarnings.log')
CYCLE_LOGFILES = True
# Number of lines to append to rotating channel logs when they rotate
CHANNEL_LOG_NUM_TAIL_LINES = 20
-# Max size of channel log files before they rotate
+# Max size (in bytes) of channel log files before they rotate
CHANNEL_LOG_ROTATE_SIZE = 1000000
# Local time zone for this installation. All choices can be found here:
# http://www.postgresql.org/docs/8.0/interactive/datetime-keywords.html#DATETIME-TIMEZONE-SET-TABLE
@@ -354,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"
@@ -513,7 +516,7 @@ TIME_GAME_EPOCH = None
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
@@ -525,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
diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py
index a97a81b1be..eb698e6f0e 100644
--- a/evennia/typeclasses/attributes.py
+++ b/evennia/typeclasses/attributes.py
@@ -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)`
diff --git a/evennia/typeclasses/managers.py b/evennia/typeclasses/managers.py
index 25488f4987..2ff78d310e 100644
--- a/evennia/typeclasses/managers.py
+++ b/evennia/typeclasses/managers.py
@@ -653,6 +653,42 @@ class TypeclassManager(TypedObjectManager):
"""
return super(TypeclassManager, self).filter(db_typeclass_path=self.model.path).count()
+ def annotate(self, *args, **kwargs):
+ """
+ Overload annotate method to filter on typeclass before annotating.
+ Args:
+ *args (any): Positional arguments passed along to queryset annotate method.
+ **kwargs (any): Keyword arguments passed along to queryset annotate method.
+
+ Returns:
+ Annotated queryset.
+ """
+ return super(TypeclassManager, self).filter(db_typeclass_path=self.model.path).annotate(*args, **kwargs)
+
+ def values(self, *args, **kwargs):
+ """
+ Overload values method to filter on typeclass first.
+ Args:
+ *args (any): Positional arguments passed along to values method.
+ **kwargs (any): Keyword arguments passed along to values method.
+
+ Returns:
+ Queryset of values dictionaries, just filtered by typeclass first.
+ """
+ return super(TypeclassManager, self).filter(db_typeclass_path=self.model.path).values(*args, **kwargs)
+
+ def values_list(self, *args, **kwargs):
+ """
+ Overload values method to filter on typeclass first.
+ Args:
+ *args (any): Positional arguments passed along to values_list method.
+ **kwargs (any): Keyword arguments passed along to values_list method.
+
+ Returns:
+ Queryset of value_list tuples, just filtered by typeclass first.
+ """
+ return super(TypeclassManager, self).filter(db_typeclass_path=self.model.path).values_list(*args, **kwargs)
+
def _get_subclasses(self, cls):
"""
Recursively get all subclasses to a class.
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 80203923e8..c08704be0e 100644
--- a/evennia/utils/dbserialize.py
+++ b/evennia/utils/dbserialize.py
@@ -237,10 +237,13 @@ class _SaverList(_SaverMutable, MutableSequence):
self._data = list()
@_save
- def __add__(self, otherlist):
+ def __iadd__(self, otherlist):
self._data = self._data.__add__(otherlist)
return self._data
+ def __add__(self, otherlist):
+ return list(self._data) + otherlist
+
@_save
def insert(self, index, value):
self._data.insert(index, self._convert_mutables(value))
diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py
index 3d8fb6b789..f82dc5cb1f 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
@@ -82,12 +87,14 @@ menu is immediately exited and the default "look" command is called.
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.
+ (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.
@@ -158,16 +165,16 @@ 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?
@@ -182,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")
@@ -315,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.
@@ -368,7 +376,8 @@ 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.
+ 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,
@@ -378,6 +387,10 @@ class EvMenu(object):
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`,
@@ -401,7 +414,7 @@ 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:
@@ -415,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
@@ -573,6 +587,7 @@ class EvMenu(object):
except EvMenuError:
errmsg = _ERR_GENERAL.format(nodename=callback)
self.caller.msg(errmsg, self._session)
+ logger.log_trace()
raise
return ret
@@ -606,9 +621,11 @@ class EvMenu(object):
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
@@ -665,9 +682,49 @@ class EvMenu(object):
if isinstance(ret, basestring):
# only return a value if a string (a goto target), ignore all other returns
+ if not ret:
+ # an empty string - rerun the same node
+ return self.nodename
return ret, kwargs
return None
+ def extract_goto_exec(self, nodename, option_dict):
+ """
+ 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.
@@ -681,29 +738,6 @@ class EvMenu(object):
argument)
"""
- def _extract_goto_exec(option_dict):
- "Helper: Get callables and their eventual kwargs"
- 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
if callable(nodename):
# run the "goto" callable, if possible
@@ -714,6 +748,9 @@ class EvMenu(object):
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 found node, make use of the returns.
nodetext, options = self._execute_node(nodename, raw_string, **kwargs)
@@ -746,12 +783,12 @@ class EvMenu(object):
desc = dic.get("desc", dic.get("text", None))
if "_default" in keys:
keys = [key for key in keys if key != "_default"]
- goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic)
+ 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())))
- goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic)
+ goto, goto_kwargs, execute, exec_kwargs = self.extract_goto_exec(nodename, dic)
if keys:
display_options.append((keys[0], desc))
for key in keys:
@@ -765,7 +802,7 @@ class EvMenu(object):
# 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:
@@ -814,6 +851,51 @@ class EvMenu(object):
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):
"""
Parses the incoming string from the menu user.
@@ -840,6 +922,8 @@ class EvMenu(object):
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, goto_kwargs, execfunc, exec_kwargs = self.default
self.run_exec_then_goto(execfunc, goto, raw_string, exec_kwargs, goto_kwargs)
@@ -865,7 +949,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):
"""
@@ -945,14 +1042,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
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/evtable.py b/evennia/utils/evtable.py
index 31218189ab..ffb29873c4 100644
--- a/evennia/utils/evtable.py
+++ b/evennia/utils/evtable.py
@@ -893,6 +893,9 @@ class EvColumn(object):
"""
col = self.column
+ # fixed options for the column will override those requested in the call!
+ # this is particularly relevant to things like width/height, to avoid
+ # fixed-widths columns from being auto-balanced
kwargs.update(self.options)
# use fixed width or adjust to the largest cell
if "width" not in kwargs:
@@ -1283,25 +1286,59 @@ class EvTable(object):
cwidths_min = [max(cell.get_min_width() for cell in col) for col in self.worktable]
cwmin = sum(cwidths_min)
- if cwmin > width:
- # we cannot shrink any more
- raise Exception("Cannot shrink table width to %s. Minimum size is %s." % (self.width, cwmin))
+ # get which cols have separately set widths - these should be locked
+ # note that we need to remove cwidths_min for each lock to avoid counting
+ # it twice (in cwmin and in locked_cols)
+ locked_cols = {icol: col.options['width'] - cwidths_min[icol]
+ for icol, col in enumerate(self.worktable) if 'width' in col.options}
+ locked_width = sum(locked_cols.values())
+
+ excess = width - cwmin - locked_width
+
+ if len(locked_cols) >= ncols and excess:
+ # we can't adjust the width at all - all columns are locked
+ raise Exception("Cannot balance table to width %s - "
+ "all columns have a set, fixed width summing to %s!" % (
+ self.width, sum(cwidths)))
+
+ if excess < 0:
+ # the locked cols makes it impossible
+ raise Exception("Cannot shrink table width to %s. "
+ "Minimum size (and/or fixed-width columns) "
+ "sets minimum at %s." % (self.width, cwmin + locked_width))
- excess = width - cwmin
if self.evenwidth:
# make each column of equal width
- for _ in range(excess):
+ # use cwidths as a work-array to track weights
+ cwidths = copy(cwidths_min)
+ correction = 0
+ while correction < excess:
# flood-fill the minimum table starting with the smallest columns
- ci = cwidths_min.index(min(cwidths_min))
- cwidths_min[ci] += 1
+ ci = cwidths.index(min(cwidths))
+ if ci in locked_cols:
+ # locked column, make sure it's not picked again
+ cwidths[ci] += 9999
+ cwidths_min[ci] = locked_cols[ci]
+ else:
+ cwidths_min[ci] += 1
+ correction += 1
cwidths = cwidths_min
else:
# make each column expand more proportional to their data size
- for _ in range(excess):
+ # we use cwidth as a work-array to track weights
+ correction = 0
+ while correction < excess:
# fill wider columns first
ci = cwidths.index(max(cwidths))
- cwidths_min[ci] += 1
- cwidths[ci] -= 3
+ if ci in locked_cols:
+ # locked column, make sure it's not picked again
+ cwidths[ci] -= 9999
+ cwidths_min[ci] = locked_cols[ci]
+ else:
+ cwidths_min[ci] += 1
+ correction += 1
+ # give a just changed col less prio next run
+ cwidths[ci] -= 3
cwidths = cwidths_min
# reformat worktable (for width align)
@@ -1323,28 +1360,46 @@ class EvTable(object):
for cell in (col[iy] for col in self.worktable)) for iy in range(nrowmax)]
chmin = sum(cheights_min)
+ # get which cols have separately set heights - these should be locked
+ # note that we need to remove cheights_min for each lock to avoid counting
+ # it twice (in chmin and in locked_cols)
+ locked_cols = {icol: col.options['height'] - cheights_min[icol]
+ for icol, col in enumerate(self.worktable) if 'height' in col.options}
+ locked_height = sum(locked_cols.values())
+
+ excess = self.height - chmin - locked_height
+
if chmin > self.height:
# we cannot shrink any more
- raise Exception("Cannot shrink table height to %s. Minimum size is %s." % (self.height, chmin))
+ raise Exception("Cannot shrink table height to %s. Minimum "
+ "size (and/or fixed-height rows) sets minimum at %s." % (
+ self.height, chmin + locked_height))
# now we add all the extra height up to the desired table-height.
# We do this so that the tallest cells gets expanded first (and
# thus avoid getting cropped)
- excess = self.height - chmin
even = self.height % 2 == 0
- for position in range(excess):
+ correction = 0
+ while correction < excess:
# expand the cells with the most rows first
- if 0 <= position < nrowmax and nrowmax > 1:
+ if 0 <= correction < nrowmax and nrowmax > 1:
# avoid adding to header first round (looks bad on very small tables)
ci = cheights[1:].index(max(cheights[1:])) + 1
else:
ci = cheights.index(max(cheights))
- cheights_min[ci] += 1
- if ci == 0 and self.header:
- # it doesn't look very good if header expands too fast
- cheights[ci] -= 2 if even else 3
- cheights[ci] -= 2 if even else 1
+ if ci in locked_cols:
+ # locked row, make sure it's not picked again
+ cheights[ci] -= 9999
+ cheights_min[ci] = locked_cols[ci]
+ else:
+ cheights_min[ci] += 1
+ # change balance
+ if ci == 0 and self.header:
+ # it doesn't look very good if header expands too fast
+ cheights[ci] -= 2 if even else 3
+ cheights[ci] -= 2 if even else 1
+ correction += 1
cheights = cheights_min
# we must tell cells to crop instead of expanding
@@ -1554,6 +1609,8 @@ class EvTable(object):
"""
if index > len(self.table):
raise Exception("Not a valid column index")
+ # we update the columns' options which means eventual width/height
+ # will be 'locked in' and withstand auto-balancing width/height from the table later
self.table[index].options.update(kwargs)
self.table[index].reformat(**kwargs)
@@ -1569,6 +1626,7 @@ class EvTable(object):
def __str__(self):
"""print table (this also balances it)"""
+ # h = "12345678901234567890123456789012345678901234567890123456789012345678901234567890"
return str(unicode(ANSIString("\n").join([line for line in self._generate_lines()])))
def __unicode__(self):
diff --git a/evennia/utils/inlinefuncs.py b/evennia/utils/inlinefuncs.py
index e103e217d7..3012347541 100644
--- a/evennia/utils/inlinefuncs.py
+++ b/evennia/utils/inlinefuncs.py
@@ -1,464 +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"(?.*?)(?.*?)(?(?(?(?\\'|\\"|\\\)|\\$\w+\()| # escaped tokens should re-appear in text
- (?P[\w\s.-\/#!%\^&\*;:=\-_`~\|\(}{\[\]]+|\"{1}|\'{1}) # everything else should also be included""",
- 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/spawner.py b/evennia/utils/spawner.py
deleted file mode 100644
index 86b24e5e4f..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 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
-
-
-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/test_evmenu.py b/evennia/utils/tests/test_evmenu.py
index 04310c90ed..a6959c0509 100644
--- a/evennia/utils/tests/test_evmenu.py
+++ b/evennia/utils/tests/test_evmenu.py
@@ -58,7 +58,7 @@ class TestEvMenu(TestCase):
def _debug_output(self, indent, msg):
if self.debug_output:
- print(" " * indent + msg)
+ print(" " * indent + ansi.strip_ansi(msg))
def _test_menutree(self, menu):
"""
@@ -82,6 +82,8 @@ class TestEvMenu(TestCase):
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),
@@ -168,6 +170,7 @@ class TestEvMenu(TestCase):
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,
diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py
index 10621f0feb..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
@@ -1879,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/webclient/static/webclient/css/webclient.css b/evennia/web/webclient/static/webclient/css/webclient.css
index 1c94a1f9fd..7a33cfa207 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,109 @@ 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;
+#inputfield, #inputsizer {
+ height: 100%;
background: #000;
color: #fff;
- padding: 0 .45em;
- font-size: 1.1em;
+ padding: 0 .45rem;
+ font-size: 1.1rem;
font-family: 'DejaVu Sans Mono', Consolas, Inconsolata, 'Lucida Console', monospace;
-}
-
-#inputfield, #inputsizer {
- float: left;
- width: 95%;
- border: 0;
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 +196,8 @@ div {margin:0px;}
#toolbar {
position: fixed;
- top: 0;
- right: 5px;
+ top: .5rem;
+ right: .5rem;
z-index: 1;
}
@@ -248,6 +271,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/splithandler.js b/evennia/web/webclient/static/webclient/js/splithandler.js
new file mode 100644
index 0000000000..81210df854
--- /dev/null
+++ b/evennia/web/webclient/static/webclient/js/splithandler.js
@@ -0,0 +1,145 @@
+// Use split.js to create a basic ui
+var SplitHandler = (function () {
+ var split_panes = {};
+ var backout_list = new Array;
+
+ var set_pane_types = function(splitpane, types) {
+ split_panes[splitpane]['types'] = types;
+ }
+
+
+ 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} );
+ }
+
+
+ var undo_split = function() {
+ // pop off the last split pair
+ var back = backout_list.pop();
+ if( !back ) {
+ return;
+ }
+
+ // 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'];
+ }
+
+
+ var init = function(settings) {
+ //change Mustache tags to ruby-style (Django gets mad otherwise)
+ var customTags = [ '<%', '%>' ];
+ Mustache.tags = customTags;
+
+ var input_template = $('#input-template').html();
+ Mustache.parse(input_template);
+
+ Split(['#main','#input'], {
+ direction: 'vertical',
+ sizes: [90,10],
+ gutterSize: 4,
+ minSize: [50,50],
+ });
+
+ split_panes['main'] = { 'types': [], 'update_method': 'append' };
+
+ var input_render = Mustache.render(input_template);
+ $('[data-role-input]').html(input_render);
+ console.log("SplitHandler initialized");
+ }
+
+ return {
+ init: init,
+ set_pane_types: set_pane_types,
+ dynamic_split: dynamic_split,
+ split_panes: split_panes,
+ undo_split: undo_split,
+ }
+})();
diff --git a/evennia/web/webclient/static/webclient/js/webclient_gui.js b/evennia/web/webclient/static/webclient/js/webclient_gui.js
index 9dde7da801..e1ed4d31fd 100644
--- a/evennia/web/webclient/static/webclient/js/webclient_gui.js
+++ b/evennia/web/webclient/static/webclient/js/webclient_gui.js
@@ -15,8 +15,13 @@
(function () {
"use strict"
+var num_splits = 0; //unique id counter for default split-panel names
var options = {};
+
+var known_types = new Array();
+ known_types.push('help');
+
//
// GUI Elements
//
@@ -106,6 +111,7 @@ function togglePopup(dialogname, content) {
// Grab text from inputline and send to Evennia
function doSendText() {
+ console.log("sending text");
if (!Evennia.isConnected()) {
var reconnect = confirm("Not currently connected. Reconnect?");
if (reconnect) {
@@ -158,7 +164,11 @@ function onKeydown (event) {
var code = event.which;
var history_entry = null;
var inputfield = $("#inputfield");
- inputfield.focus();
+ if (code === 9) {
+ return;
+ }
+
+ //inputfield.focus();
if (code === 13) { // Enter key sends text
doSendText();
@@ -205,74 +215,68 @@ function onKeyPress (event) {
}
var resizeInputField = function () {
- var min_height = 50;
- var max_height = 300;
- var prev_text_len = 0;
+ return function() {
+ var wrapper = $("#inputform")
+ var input = $("#inputcontrol")
+ var prompt = $("#prompt")
- // 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();
- }
- }
- prev_text_len = curr_text_len;
+ input.height(wrapper.height() - (input.offset().top - wrapper.offset().top));
}
}();
// 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
+ resizeInputField();
+ var resizable = $("[data-update-append]");
+ var parents = resizable.closest(".split")
+ parents.animate({
+ scrollTop: parents.prop("scrollHeight")
+ }, 0);
}
// 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";
+ var use_default_pane = true;
+
+ if ( kwargs && 'type' in kwargs ) {
+ var 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);
+ }
+
+ // pass this message to each pane that has this msgtype mapped
+ if( SplitHandler ) {
+ for ( var key in SplitHandler.split_panes) {
+ var pane = SplitHandler.split_panes[key];
+ // is this message type mapped to this pane?
+ if ( (pane['types'].length > 0) && pane['types'].includes(msgtype) ) {
+ // yes, so append/replace this pane's inner div with this message
+ var text_div = $('#'+key+'-sub');
+ if ( pane['update_method'] == 'replace' ) {
+ text_div.html(args[0])
+ } else {
+ text_div.append(args[0]);
+ var scrollHeight = text_div.parent().prop("scrollHeight");
+ text_div.parent().animate({ scrollTop: scrollHeight }, 0);
+ }
+ // record sending this message to a pane, no need to update the default div
+ use_default_pane = false;
+ }
+ }
}
}
- if (renderto == "main") {
+ // append message to default pane, then scroll so latest is at the bottom.
+ if(use_default_pane) {
var mwin = $("#messagewindow");
var cls = kwargs == null ? 'out' : kwargs['cls'];
mwin.append("" + args[0] + "
");
- mwin.animate({
- scrollTop: document.getElementById("messagewindow").scrollHeight
- }, 0);
+ var scrollHeight = mwin.parent().parent().prop("scrollHeight");
+ mwin.parent().parent().animate({ scrollTop: scrollHeight }, 0);
onNewLine(args[0], null);
- } else {
- openPopup(renderto, args[0]);
}
}
@@ -430,6 +434,105 @@ function doStartDragDialog(event) {
$(document).bind("mouseup", undrag);
}
+function onSplitDialogClose() {
+ var pane = $("input[name=pane]:checked").attr("value");
+ 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;
+ }
+
+ SplitHandler.dynamic_split( pane, direction, new_pane1, new_pane2, flow1, flow2, [50,50] );
+
+ closePopup("#splitdialog");
+}
+
+function onSplitDialog() {
+ var dialog = $("#splitdialogcontent");
+ dialog.empty();
+
+ dialog.append("Split? ");
+ dialog.append(' top/bottom ');
+ dialog.append(' side-by-side ');
+
+ dialog.append("Split Which Pane? ");
+ for ( var pane in SplitHandler.split_panes ) {
+ dialog.append(' '+ pane +' ');
+ }
+
+ dialog.append("New Pane Names ");
+ dialog.append(' ');
+ dialog.append(' ');
+
+ dialog.append("New First Pane ");
+ dialog.append(' append new incoming messages ');
+ dialog.append(' replace old messages with new ones ');
+
+ dialog.append("New Second Pane ");
+ dialog.append(' append new incoming messages ');
+ dialog.append(' replace old messages with new ones ');
+
+ dialog.append('Split It
');
+
+ $("#splitclose").bind("click", onSplitDialogClose);
+
+ togglePopup("#splitdialog");
+}
+
+function onPaneControlDialogClose() {
+ var pane = $("input[name=pane]:checked").attr("value");
+
+ var types = new Array;
+ $('#splitdialogcontent input[type=checkbox]:checked').each(function() {
+ types.push( $(this).attr('value') );
+ });
+
+ SplitHandler.set_pane_types( pane, types );
+
+ closePopup("#splitdialog");
+}
+
+function onPaneControlDialog() {
+ var dialog = $("#splitdialogcontent");
+ dialog.empty();
+
+ dialog.append("Set Which Pane? ");
+ for ( var pane in SplitHandler.split_panes ) {
+ dialog.append(' '+ pane +' ');
+ }
+
+ dialog.append("Which content types? ");
+ for ( var type in known_types ) {
+ dialog.append(' '+ known_types[type] +' ');
+ }
+
+ dialog.append('Make It So
');
+
+ $("#paneclose").bind("click", onPaneControlDialogClose);
+
+ togglePopup("#splitdialog");
+}
+
//
// Register Events
//
@@ -437,6 +540,18 @@ function doStartDragDialog(event) {
// Event when client finishes loading
$(document).ready(function() {
+ if( SplitHandler ) {
+ SplitHandler.init();
+ $("#splitbutton").bind("click", onSplitDialog);
+ $("#panebutton").bind("click", onPaneControlDialog);
+ $("#undobutton").bind("click", SplitHandler.undo_split);
+ $("#optionsbutton").hide();
+ } else {
+ $("#splitbutton").hide();
+ $("#panebutton").hide();
+ $("#undobutton").hide();
+ }
+
if ("Notification" in window) {
Notification.requestPermission();
}
@@ -453,7 +568,7 @@ $(document).ready(function() {
//$(document).on("visibilitychange", onVisibilityChange);
- $("#inputfield").bind("resize", doWindowResize)
+ $("[data-role-input]").bind("resize", doWindowResize)
.keypress(onKeyPress)
.bind("paste", resizeInputField)
.bind("cut", resizeInputField);
@@ -506,6 +621,7 @@ $(document).ready(function() {
},
60000*3
);
+ console.log("Completed GUI setup");
});
diff --git a/evennia/web/webclient/templates/webclient/base.html b/evennia/web/webclient/templates/webclient/base.html
index 863a90ba11..a5c65fad2c 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 %}
@@ -63,7 +81,11 @@ JQuery available.
}
-
+
+
+
+ {% block scripts %}
+ {% endblock %}
@@ -86,10 +108,9 @@ JQuery available.
-
+
{% block client %}
{% endblock %}
-