Merge branch 'develop' into master

This commit is contained in:
Brenden Tuck 2018-03-18 20:16:06 -04:00 committed by GitHub
commit ab1bd6415d
64 changed files with 7206 additions and 2051 deletions

View file

@ -3,7 +3,7 @@
# Sept 2017:
Release of Evennia 0.7; upgrade to Django 1.11, change 'Player' to
'Account', rework the website template and a slew of other updates.
Info on what changed and how to migrat is found here:
Info on what changed and how to migrate is found here:
https://groups.google.com/forum/#!msg/evennia/0JYYNGY-NfE/cDFaIwmPBAAJ
## Feb 2017:

View file

@ -5,11 +5,11 @@
# install `docker` (http://docker.com)
#
# Usage:
# cd to a folder where you want your game data to be (or where it already is).
# cd to a folder where you want your game data to be (or where it already is).
#
# docker run -it -p 4000:4000 -p 4001:4001 -p 4005:4005 -v $PWD:/usr/src/game evennia/evennia
#
# (If your OS does not support $PWD, replace it with the full path to your current
#
# (If your OS does not support $PWD, replace it with the full path to your current
# folder).
#
# You will end up in a shell where the `evennia` command is available. From here you
@ -30,10 +30,10 @@ RUN apk update && apk add python py-pip python-dev py-setuptools gcc musl-dev jp
ADD . /usr/src/evennia
# install dependencies
RUN pip install -e /usr/src/evennia --trusted-host pypi.python.org
RUN pip install --upgrade pip && pip install /usr/src/evennia --trusted-host pypi.python.org
# add the game source when rebuilding a new docker image from inside
# a game dir
# a game dir
ONBUILD ADD . /usr/src/game
# make the game source hierarchy persistent with a named volume.
@ -48,7 +48,7 @@ WORKDIR /usr/src/game
ENV PS1 "evennia|docker \w $ "
# startup a shell when we start the container
ENTRYPOINT ["bash"]
ENTRYPOINT bash -c "source /usr/src/evennia/bin/unix/evennia-docker-start.sh"
# expose the telnet, webserver and websocket client ports
EXPOSE 4000 4001 4005

View file

@ -0,0 +1,13 @@
#! /bin/bash
# called by the Dockerfile to start the server in docker mode
# remove leftover .pid files (such as from when dropping the container)
rm /usr/src/game/server/*.pid >& /dev/null || true
# start evennia server; log to server.log but also output to stdout so it can
# be viewed with docker-compose logs
exec 3>&1; evennia start 2>&1 1>&3 | tee /usr/src/game/server/logs/server.log; exec 3>&-
# start a shell to keep the container running
bash

View file

@ -1 +1 @@
0.7.0
0.8.0-dev

View file

@ -771,7 +771,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
elif _MULTISESSION_MODE in (2, 3):
# In this mode we by default end up at a character selection
# screen. We execute look on the account.
# we make sure to clean up the _playable_characers list in case
# we make sure to clean up the _playable_characters list in case
# any was deleted in the interim.
self.db._playable_characters = [char for char in self.db._playable_characters if char]
self.msg(self.at_look(target=self.db._playable_characters,

View file

@ -297,7 +297,7 @@ class Command(with_metaclass(CommandMeta, object)):
Args:
srcobj (Object): Object trying to gain permission
access_type (str, optional): The lock type to check.
default (bool, optional): The fallbacl result if no lock
default (bool, optional): The fallback result if no lock
of matching `access_type` is found on this Command.
"""

View file

@ -168,7 +168,7 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS):
if desc:
new_character.db.desc = desc
elif not new_character.db.desc:
new_character.db.desc = "This is an Account."
new_character.db.desc = "This is a character."
self.msg("Created new character %s. Use |w@ic %s|n to enter the game as this character."
% (new_character.key, new_character.key))
@ -455,7 +455,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS):
Usage:
@option[/save] [name = value]
Switch:
Switches:
save - Save the current option settings for future logins.
clear - Clear the saved options.
@ -467,6 +467,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS):
"""
key = "@option"
aliases = "@options"
switch_options = ("save", "clear")
locks = "cmd:all()"
# this is used by the parent
@ -650,6 +651,7 @@ class CmdQuit(COMMAND_DEFAULT_CLASS):
game. Use the /all switch to disconnect from all sessions.
"""
key = "@quit"
switch_options = ("all",)
locks = "cmd:all()"
# this is used by the parent

View file

@ -36,6 +36,7 @@ class CmdBoot(COMMAND_DEFAULT_CLASS):
"""
key = "@boot"
switch_options = ("quiet", "sid")
locks = "cmd:perm(boot) or perm(Admin)"
help_category = "Admin"
@ -265,6 +266,7 @@ class CmdDelAccount(COMMAND_DEFAULT_CLASS):
"""
key = "@delaccount"
switch_options = ("delobj",)
locks = "cmd:perm(delaccount) or perm(Developer)"
help_category = "Admin"
@ -329,9 +331,9 @@ class CmdEmit(COMMAND_DEFAULT_CLASS):
@pemit [<obj>, <obj>, ... =] <message>
Switches:
room : limit emits to rooms only (default)
accounts : limit emits to accounts only
contents : send to the contents of matched objects too
room - limit emits to rooms only (default)
accounts - limit emits to accounts only
contents - send to the contents of matched objects too
Emits a message to the selected objects or to
your immediate surroundings. If the object is a room,
@ -341,6 +343,7 @@ class CmdEmit(COMMAND_DEFAULT_CLASS):
"""
key = "@emit"
aliases = ["@pemit", "@remit"]
switch_options = ("room", "accounts", "contents")
locks = "cmd:perm(emit) or perm(Builder)"
help_category = "Admin"
@ -442,14 +445,15 @@ class CmdPerm(COMMAND_DEFAULT_CLASS):
@perm[/switch] *<account> [= <permission>[,<permission>,...]]
Switches:
del : delete the given permission from <object> or <account>.
account : set permission on an account (same as adding * to name)
del - delete the given permission from <object> or <account>.
account - set permission on an account (same as adding * to name)
This command sets/clears individual permission strings on an object
or account. If no permission is given, list all permissions on <object>.
"""
key = "@perm"
aliases = "@setperm"
switch_options = ("del", "account")
locks = "cmd:perm(perm) or perm(Developer)"
help_category = "Admin"

View file

@ -237,6 +237,7 @@ class CmdBatchCommands(_COMMAND_DEFAULT_CLASS):
"""
key = "@batchcommands"
aliases = ["@batchcommand", "@batchcmd"]
switch_options = ("interactive",)
locks = "cmd:perm(batchcommands) or perm(Developer)"
help_category = "Building"
@ -347,6 +348,7 @@ class CmdBatchCode(_COMMAND_DEFAULT_CLASS):
"""
key = "@batchcode"
aliases = ["@batchcodes"]
switch_options = ("interactive", "debug")
locks = "cmd:superuser()"
help_category = "Building"

View file

@ -106,9 +106,15 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS):
Usage:
@alias <obj> [= [alias[,alias,alias,...]]]
@alias <obj> =
@alias/category <obj> = [alias[,alias,...]:<category>
Switches:
category - requires ending input with :category, to store the
given aliases with the given category.
Assigns aliases to an object so it can be referenced by more
than one name. Assign empty to remove all aliases from object.
than one name. Assign empty to remove all aliases from object. If
assigning a category, all aliases given will be using this category.
Observe that this is not the same thing as personal aliases
created with the 'nick' command! Aliases set with @alias are
@ -118,6 +124,7 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS):
key = "@alias"
aliases = "@setobjalias"
switch_options = ("category",)
locks = "cmd:perm(setobjalias) or perm(Builder)"
help_category = "Building"
@ -138,9 +145,12 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS):
return
if self.rhs is None:
# no =, so we just list aliases on object.
aliases = obj.aliases.all()
aliases = obj.aliases.all(return_key_and_category=True)
if aliases:
caller.msg("Aliases for '%s': %s" % (obj.get_display_name(caller), ", ".join(aliases)))
caller.msg("Aliases for %s: %s" % (
obj.get_display_name(caller),
", ".join("'%s'%s" % (alias, "" if category is None else "[category:'%s']" % category)
for (alias, category) in aliases)))
else:
caller.msg("No aliases exist for '%s'." % obj.get_display_name(caller))
return
@ -159,17 +169,27 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS):
caller.msg("No aliases to clear.")
return
category = None
if "category" in self.switches:
if ":" in self.rhs:
rhs, category = self.rhs.rsplit(':', 1)
category = category.strip()
else:
caller.msg("If specifying the /category switch, the category must be given "
"as :category at the end.")
else:
rhs = self.rhs
# merge the old and new aliases (if any)
old_aliases = obj.aliases.all()
new_aliases = [alias.strip().lower() for alias in self.rhs.split(',')
if alias.strip()]
old_aliases = obj.aliases.get(category=category, return_list=True)
new_aliases = [alias.strip().lower() for alias in rhs.split(',') if alias.strip()]
# make the aliases only appear once
old_aliases.extend(new_aliases)
aliases = list(set(old_aliases))
# save back to object.
obj.aliases.add(aliases)
obj.aliases.add(aliases, category=category)
# we need to trigger this here, since this will force
# (default) Exits to rebuild their Exit commands with the new
@ -177,7 +197,8 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS):
obj.at_cmdset_get(force_init=True)
# report all aliases on the object
caller.msg("Alias(es) for '%s' set to %s." % (obj.get_display_name(caller), str(obj.aliases)))
caller.msg("Alias(es) for '%s' set to '%s'%s." % (obj.get_display_name(caller),
str(obj.aliases), " (category: '%s')" % category if category else ""))
class CmdCopy(ObjManipCommand):
@ -198,6 +219,7 @@ class CmdCopy(ObjManipCommand):
"""
key = "@copy"
switch_options = ("reset",)
locks = "cmd:perm(copy) or perm(Builder)"
help_category = "Building"
@ -279,6 +301,7 @@ class CmdCpAttr(ObjManipCommand):
If you don't supply a source object, yourself is used.
"""
key = "@cpattr"
switch_options = ("move",)
locks = "cmd:perm(cpattr) or perm(Builder)"
help_category = "Building"
@ -420,6 +443,7 @@ class CmdMvAttr(ObjManipCommand):
object. If you don't supply a source object, yourself is used.
"""
key = "@mvattr"
switch_options = ("copy",)
locks = "cmd:perm(mvattr) or perm(Builder)"
help_category = "Building"
@ -468,6 +492,7 @@ class CmdCreate(ObjManipCommand):
"""
key = "@create"
switch_options = ("drop",)
locks = "cmd:perm(create) or perm(Builder)"
help_category = "Building"
@ -553,6 +578,7 @@ class CmdDesc(COMMAND_DEFAULT_CLASS):
"""
key = "@desc"
aliases = "@describe"
switch_options = ("edit",)
locks = "cmd:perm(desc) or perm(Builder)"
help_category = "Building"
@ -614,11 +640,11 @@ class CmdDestroy(COMMAND_DEFAULT_CLASS):
Usage:
@destroy[/switches] [obj, obj2, obj3, [dbref-dbref], ...]
switches:
Switches:
override - The @destroy command will usually avoid accidentally
destroying account objects. This switch overrides this safety.
force - destroy without confirmation.
examples:
Examples:
@destroy house, roof, door, 44-78
@destroy 5-10, flower, 45
@destroy/force north
@ -631,6 +657,7 @@ class CmdDestroy(COMMAND_DEFAULT_CLASS):
key = "@destroy"
aliases = ["@delete", "@del"]
switch_options = ("override", "force")
locks = "cmd:perm(destroy) or perm(Builder)"
help_category = "Building"
@ -754,6 +781,7 @@ class CmdDig(ObjManipCommand):
would be 'north;no;n'.
"""
key = "@dig"
switch_options = ("teleport",)
locks = "cmd:perm(dig) or perm(Builder)"
help_category = "Building"
@ -863,7 +891,7 @@ class CmdDig(ObjManipCommand):
new_back_exit.dbref,
alias_string)
caller.msg("%s%s%s" % (room_string, exit_to_string, exit_back_string))
if new_room and ('teleport' in self.switches or "tel" in self.switches):
if new_room and 'teleport' in self.switches:
caller.move_to(new_room)
@ -896,6 +924,7 @@ class CmdTunnel(COMMAND_DEFAULT_CLASS):
key = "@tunnel"
aliases = ["@tun"]
switch_options = ("oneway", "tel")
locks = "cmd: perm(tunnel) or perm(Builder)"
help_category = "Building"
@ -1458,6 +1487,13 @@ class CmdSetAttribute(ObjManipCommand):
Switch:
edit: Open the line editor (string values only)
script: If we're trying to set an attribute on a script
channel: If we're trying to set an attribute on a channel
account: If we're trying to set an attribute on an account
room: Setting an attribute on a room (global search)
exit: Setting an attribute on an exit (global search)
char: Setting an attribute on a character (global search)
character: Alias for char, as above.
Sets attributes on objects. The second form clears
a previously set attribute while the last form
@ -1558,6 +1594,38 @@ class CmdSetAttribute(ObjManipCommand):
# start the editor
EvEditor(self.caller, load, save, key="%s/%s" % (obj, attr))
def search_for_obj(self, objname):
"""
Searches for an object matching objname. The object may be of different typeclasses.
Args:
objname: Name of the object we're looking for
Returns:
A typeclassed object, or None if nothing is found.
"""
from evennia.utils.utils import variable_from_module
_AT_SEARCH_RESULT = variable_from_module(*settings.SEARCH_AT_RESULT.rsplit('.', 1))
caller = self.caller
if objname.startswith('*') or "account" in self.switches:
found_obj = caller.search_account(objname.lstrip('*'))
elif "script" in self.switches:
found_obj = _AT_SEARCH_RESULT(search.search_script(objname), caller)
elif "channel" in self.switches:
found_obj = _AT_SEARCH_RESULT(search.search_channel(objname), caller)
else:
global_search = True
if "char" in self.switches or "character" in self.switches:
typeclass = settings.BASE_CHARACTER_TYPECLASS
elif "room" in self.switches:
typeclass = settings.BASE_ROOM_TYPECLASS
elif "exit" in self.switches:
typeclass = settings.BASE_EXIT_TYPECLASS
else:
global_search = False
typeclass = None
found_obj = caller.search(objname, global_search=global_search, typeclass=typeclass)
return found_obj
def func(self):
"""Implement the set attribute - a limited form of @py."""
@ -1571,10 +1639,7 @@ class CmdSetAttribute(ObjManipCommand):
objname = self.lhs_objattr[0]['name']
attrs = self.lhs_objattr[0]['attrs']
if objname.startswith('*'):
obj = caller.search_account(objname.lstrip('*'))
else:
obj = caller.search(objname)
obj = self.search_for_obj(objname)
if not obj:
return
@ -2202,12 +2267,14 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
Usage:
@find[/switches] <name or dbref or *account> [= dbrefmin[-dbrefmax]]
@locate - this is a shorthand for using the /loc switch.
Switches:
room - only look for rooms (location=None)
exit - only look for exits (destination!=None)
char - only look for characters (BASE_CHARACTER_TYPECLASS)
exact- only exact matches are returned.
loc - display object location if exists and match has one result
Searches the database for an object of a particular name or exact #dbref.
Use *accountname to search for an account. The switches allows for
@ -2218,6 +2285,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
key = "@find"
aliases = "@search, @locate"
switch_options = ("room", "exit", "char", "exact", "loc")
locks = "cmd:perm(find) or perm(Builder)"
help_category = "Building"
@ -2230,6 +2298,9 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
caller.msg("Usage: @find <string> [= low [-high]]")
return
if "locate" in self.cmdstring: # Use option /loc as a default for @locate command alias
switches.append('loc')
searchstring = self.lhs
low, high = 1, ObjectDB.objects.all().order_by("-id")[0].id
if self.rhs:
@ -2251,7 +2322,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
restrictions = ""
if self.switches:
restrictions = ", %s" % (",".join(self.switches))
restrictions = ", %s" % (", ".join(self.switches))
if is_dbref or is_account:
@ -2279,6 +2350,8 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
else:
result = result[0]
string += "\n|g %s - %s|n" % (result.get_display_name(caller), result.path)
if "loc" in self.switches and not is_account and result.location:
string += " (|wlocation|n: |g{}|n)".format(result.location.get_display_name(caller))
else:
# Not an account/dbref search but a wider search; build a queryset.
# Searchs for key and aliases
@ -2314,6 +2387,8 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
else:
string = "|wOne Match|n(#%i-#%i%s):" % (low, high, restrictions)
string += "\n |g%s - %s|n" % (results[0].get_display_name(caller), results[0].path)
if "loc" in self.switches and nresults == 1 and results[0].location:
string += " (|wlocation|n: |g{}|n)".format(results[0].location.get_display_name(caller))
else:
string = "|wMatch|n(#%i-#%i%s):" % (low, high, restrictions)
string += "\n |RNo matches found for '%s'|n" % searchstring
@ -2327,7 +2402,7 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS):
teleport object to another location
Usage:
@tel/switch [<object> =] <target location>
@tel/switch [<object> to||=] <target location>
Examples:
@tel Limbo
@ -2351,6 +2426,8 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS):
"""
key = "@tel"
aliases = "@teleport"
switch_options = ("quiet", "intoexit", "tonone", "loc")
rhs_split = ("=", " to ") # Prefer = delimiter, but allow " to " usage.
locks = "cmd:perm(teleport) or perm(Builder)"
help_category = "Building"
@ -2458,6 +2535,7 @@ class CmdScript(COMMAND_DEFAULT_CLASS):
key = "@script"
aliases = "@addscript"
switch_options = ("start", "stop")
locks = "cmd:perm(script) or perm(Builder)"
help_category = "Building"
@ -2557,6 +2635,7 @@ class CmdTag(COMMAND_DEFAULT_CLASS):
key = "@tag"
aliases = ["@tags"]
options = ("search", "del")
locks = "cmd:perm(tag) or perm(Builder)"
help_category = "Building"
arg_regex = r"(/\w+?(\s|$))|\s|$"
@ -2698,6 +2777,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
"""
key = "@spawn"
switch_options = ("noloc", )
locks = "cmd:perm(spawn) or perm(Builder)"
help_category = "Building"

View file

@ -11,7 +11,7 @@ command method rather than caller.msg().
from evennia.commands.cmdset import CmdSet
from evennia.commands.default import help, comms, admin, system
from evennia.commands.default import building, account
from evennia.commands.default import building, account, general
class AccountCmdSet(CmdSet):
@ -39,6 +39,9 @@ class AccountCmdSet(CmdSet):
self.add(account.CmdColorTest())
self.add(account.CmdQuell())
# nicks
self.add(general.CmdNick())
# testing
self.add(building.CmdExamine())

View file

@ -377,7 +377,7 @@ class CmdCBoot(COMMAND_DEFAULT_CLASS):
Usage:
@cboot[/quiet] <channel> = <account> [:reason]
Switches:
Switch:
quiet - don't notify the channel
Kicks an account or object from a channel you control.
@ -385,6 +385,7 @@ class CmdCBoot(COMMAND_DEFAULT_CLASS):
"""
key = "@cboot"
switch_options = ("quiet",)
locks = "cmd: not pperm(channel_banned)"
help_category = "Comms"
@ -453,6 +454,7 @@ class CmdCemit(COMMAND_DEFAULT_CLASS):
key = "@cemit"
aliases = ["@cmsg"]
switch_options = ("sendername", "quiet")
locks = "cmd: not pperm(channel_banned) and pperm(Player)"
help_category = "Comms"
@ -683,6 +685,7 @@ class CmdPage(COMMAND_DEFAULT_CLASS):
key = "page"
aliases = ['tell']
switch_options = ("last", "list")
locks = "cmd:not pperm(page_banned)"
help_category = "Comms"
@ -850,6 +853,7 @@ class CmdIRC2Chan(COMMAND_DEFAULT_CLASS):
"""
key = "@irc2chan"
switch_options = ("delete", "remove", "disconnect", "list", "ssl")
locks = "cmd:serversetting(IRC_ENABLED) and pperm(Developer)"
help_category = "Comms"
@ -1016,6 +1020,7 @@ class CmdRSS2Chan(COMMAND_DEFAULT_CLASS):
"""
key = "@rss2chan"
switch_options = ("disconnect", "remove", "list")
locks = "cmd:serversetting(RSS_ENABLED) and pperm(Developer)"
help_category = "Comms"

View file

@ -88,8 +88,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
Switches:
inputline - replace on the inputline (default)
object - replace on object-lookup
account - replace on account-lookup
account - replace on account-lookup
list - show all defined aliases (also "nicks" works)
delete - remove nick by index in /list
clearall - clear all nicks
@ -118,7 +117,8 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
"""
key = "nick"
aliases = ["nickname", "nicks", "alias"]
switch_options = ("inputline", "object", "account", "list", "delete", "clearall")
aliases = ["nickname", "nicks"]
locks = "cmd:all()"
def parse(self):
@ -143,7 +143,6 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
return re.sub(r"(\$[0-9]+|\*|\?|\[.+?\])", r"|Y\1|n", string)
caller = self.caller
account = self.caller.account or caller
switches = self.switches
nicktypes = [switch for switch in switches if switch in ("object", "account", "inputline")]
specified_nicktype = bool(nicktypes)
@ -151,7 +150,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
nicklist = (utils.make_iter(caller.nicks.get(category="inputline", return_obj=True) or []) +
utils.make_iter(caller.nicks.get(category="object", return_obj=True) or []) +
utils.make_iter(account.nicks.get(category="account", return_obj=True) or []))
utils.make_iter(caller.nicks.get(category="account", return_obj=True) or []))
if 'list' in switches or self.cmdstring in ("nicks", "@nicks"):
@ -174,29 +173,77 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
if 'delete' in switches or 'del' in switches:
if not self.args or not self.lhs:
caller.msg("usage nick/delete #num ('nicks' for list)")
caller.msg("usage nick/delete <nick> or <#num> ('nicks' for list)")
return
# see if a number was given
arg = self.args.lstrip("#")
oldnicks = []
if arg.isdigit():
# we are given a index in nicklist
delindex = int(arg)
if 0 < delindex <= len(nicklist):
oldnick = nicklist[delindex - 1]
_, _, old_nickstring, old_replstring = oldnick.value
oldnicks.append(nicklist[delindex - 1])
else:
caller.msg("Not a valid nick index. See 'nicks' for a list.")
return
nicktype = oldnick.category
nicktypestr = "%s-nick" % nicktype.capitalize()
else:
if not specified_nicktype:
nicktypes = ("object", "account", "inputline")
for nicktype in nicktypes:
oldnicks.append(caller.nicks.get(arg, category=nicktype, return_obj=True))
if nicktype == "account":
account.nicks.remove(old_nickstring, category=nicktype)
else:
oldnicks = [oldnick for oldnick in oldnicks if oldnick]
if oldnicks:
for oldnick in oldnicks:
nicktype = oldnick.category
nicktypestr = "%s-nick" % nicktype.capitalize()
_, _, old_nickstring, old_replstring = oldnick.value
caller.nicks.remove(old_nickstring, category=nicktype)
caller.msg("%s removed: '|w%s|n' -> |w%s|n." % (
nicktypestr, old_nickstring, old_replstring))
return
caller.msg("%s removed: '|w%s|n' -> |w%s|n." % (
nicktypestr, old_nickstring, old_replstring))
else:
caller.msg("No matching nicks to remove.")
return
if not self.rhs and self.lhs:
# check what a nick is set to
strings = []
if not specified_nicktype:
nicktypes = ("object", "account", "inputline")
for nicktype in nicktypes:
nicks = utils.make_iter(caller.nicks.get(category=nicktype, return_obj=True))
for nick in nicks:
_, _, nick, repl = nick.value
if nick.startswith(self.lhs):
strings.append("{}-nick: '{}' -> '{}'".format(
nicktype.capitalize(), nick, repl))
if strings:
caller.msg("\n".join(strings))
else:
caller.msg("No nicks found matching '{}'".format(self.lhs))
return
if not self.rhs and self.lhs:
# check what a nick is set to
strings = []
if not specified_nicktype:
nicktypes = ("object", "account", "inputline")
for nicktype in nicktypes:
if nicktype == "account":
obj = account
else:
obj = caller
nicks = utils.make_iter(obj.nicks.get(category=nicktype, return_obj=True))
for nick in nicks:
_, _, nick, repl = nick.value
if nick.startswith(self.lhs):
strings.append("{}-nick: '{}' -> '{}'".format(
nicktype.capitalize(), nick, repl))
if strings:
caller.msg("\n".join(strings))
else:
caller.msg("No nicks found matching '{}'".format(self.lhs))
return
if not self.rhs and self.lhs:
# check what a nick is set to
@ -237,16 +284,11 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
errstring = ""
string = ""
for nicktype in nicktypes:
if nicktype == "account":
obj = account
else:
obj = caller
nicktypestr = "%s-nick" % nicktype.capitalize()
old_nickstring = None
old_replstring = None
oldnick = obj.nicks.get(key=nickstring, category=nicktype, return_obj=True)
oldnick = caller.nicks.get(key=nickstring, category=nicktype, return_obj=True)
if oldnick:
_, _, old_nickstring, old_replstring = oldnick.value
if replstring:
@ -261,7 +303,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
else:
string += "\n%s '|w%s|n' mapped to '|w%s|n'." % (nicktypestr, nickstring, replstring)
try:
obj.nicks.add(nickstring, replstring, category=nicktype)
caller.nicks.add(nickstring, replstring, category=nicktype)
except NickTemplateInvalid:
caller.msg("You must use the same $-markers both in the nick and in the replacement.")
return
@ -337,13 +379,17 @@ class CmdGet(COMMAND_DEFAULT_CLASS):
caller.msg("You can't get that.")
return
# calling at_before_get hook method
if not obj.at_before_get(caller):
return
obj.move_to(caller, quiet=True)
caller.msg("You pick up %s." % obj.name)
caller.location.msg_contents("%s picks up %s." %
(caller.name,
obj.name),
exclude=caller)
# calling hook method
# calling at_get hook method
obj.at_get(caller)
@ -378,6 +424,10 @@ class CmdDrop(COMMAND_DEFAULT_CLASS):
if not obj:
return
# Call the object script's at_before_drop() method.
if not obj.at_before_drop(caller):
return
obj.move_to(caller.location, quiet=True)
caller.msg("You drop %s." % (obj.name,))
caller.location.msg_contents("%s drops %s." %
@ -392,12 +442,13 @@ class CmdGive(COMMAND_DEFAULT_CLASS):
give away something to someone
Usage:
give <inventory obj> = <target>
give <inventory obj> <to||=> <target>
Gives an items from your inventory to another character,
placing it in their inventory.
"""
key = "give"
rhs_split = ("=", " to ") # Prefer = delimiter, but allow " to " usage.
locks = "cmd:all()"
arg_regex = r"\s|$"
@ -420,6 +471,11 @@ class CmdGive(COMMAND_DEFAULT_CLASS):
if not to_give.location == caller:
caller.msg("You are not holding %s." % to_give.key)
return
# calling at_before_give hook method
if not to_give.at_before_give(caller, target):
return
# give object
caller.msg("You give %s to %s." % (to_give.key, target.key))
to_give.move_to(target, quiet=True)
@ -496,7 +552,7 @@ class CmdWhisper(COMMAND_DEFAULT_CLASS):
Usage:
whisper <character> = <message>
whisper <char1>, <char2> = <message?
whisper <char1>, <char2> = <message>
Talk privately to one or more characters in your current location, without
others in the room being informed.

View file

@ -317,6 +317,7 @@ class CmdSetHelp(COMMAND_DEFAULT_CLASS):
"""
key = "@sethelp"
switch_options = ("edit", "replace", "append", "extend", "delete")
locks = "cmd:perm(Helper)"
help_category = "Building"

View file

@ -79,6 +79,13 @@ class MuxCommand(Command):
it here). The rest of the command is stored in self.args, which can
start with the switch indicator /.
Optional variables to aid in parsing, if set:
self.switch_options - (tuple of valid /switches expected by this
command (without the /))
self.rhs_split - Alternate string delimiter or tuple of strings
to separate left/right hand sides. tuple form
gives priority split to first string delimiter.
This parser breaks self.args into its constituents and stores them in the
following variables:
self.switches = [list of /switches (without the /)]
@ -97,9 +104,18 @@ class MuxCommand(Command):
"""
raw = self.args
args = raw.strip()
# Without explicitly setting these attributes, they assume default values:
if not hasattr(self, "switch_options"):
self.switch_options = None
if not hasattr(self, "rhs_split"):
self.rhs_split = "="
if not hasattr(self, "account_caller"):
self.account_caller = False
# split out switches
switches = []
switches, delimiters = [], self.rhs_split
if self.switch_options:
self.switch_options = [opt.lower() for opt in self.switch_options]
if args and len(args) > 1 and raw[0] == "/":
# we have a switch, or a set of switches. These end with a space.
switches = args[1:].split(None, 1)
@ -109,16 +125,50 @@ class MuxCommand(Command):
else:
args = ""
switches = switches[0].split('/')
# If user-provides switches, parse them with parser switch options.
if switches and self.switch_options:
valid_switches, unused_switches, extra_switches = [], [], []
for element in switches:
option_check = [opt for opt in self.switch_options if opt == element]
if not option_check:
option_check = [opt for opt in self.switch_options if opt.startswith(element)]
match_count = len(option_check)
if match_count > 1:
extra_switches.extend(option_check) # Either the option provided is ambiguous,
elif match_count == 1:
valid_switches.extend(option_check) # or it is a valid option abbreviation,
elif match_count == 0:
unused_switches.append(element) # or an extraneous option to be ignored.
if extra_switches: # User provided switches
self.msg('|g%s|n: |wAmbiguous switch supplied: Did you mean /|C%s|w?' %
(self.cmdstring, ' |nor /|C'.join(extra_switches)))
if unused_switches:
plural = '' if len(unused_switches) == 1 else 'es'
self.msg('|g%s|n: |wExtra switch%s "/|C%s|w" ignored.' %
(self.cmdstring, plural, '|n, /|C'.join(unused_switches)))
switches = valid_switches # Only include valid_switches in command function call
arglist = [arg.strip() for arg in args.split()]
# check for arg1, arg2, ... = argA, argB, ... constructs
lhs, rhs = args, None
lhslist, rhslist = [arg.strip() for arg in args.split(',')], []
if args and '=' in args:
lhs, rhs = [arg.strip() for arg in args.split('=', 1)]
lhslist = [arg.strip() for arg in lhs.split(',')]
rhslist = [arg.strip() for arg in rhs.split(',')]
lhs, rhs = args.strip(), None
if lhs:
if delimiters and hasattr(delimiters, '__iter__'): # If delimiter is iterable,
best_split = delimiters[0] # (default to first delimiter)
for this_split in delimiters: # try each delimiter
if this_split in lhs: # to find first successful split
best_split = this_split # to be the best split.
break
else:
best_split = delimiters
# Parse to separate left into left/right sides using best_split delimiter string
if best_split in lhs:
lhs, rhs = lhs.split(best_split, 1)
# Trim user-injected whitespace
rhs = rhs.strip() if rhs is not None else None
lhs = lhs.strip()
# Further split left/right sides by comma delimiter
lhslist = [arg.strip() for arg in lhs.split(',')] if lhs is not None else ""
rhslist = [arg.strip() for arg in rhs.split(',')] if rhs is not None else ""
# save to object properties:
self.raw = raw
self.switches = switches
@ -133,7 +183,7 @@ class MuxCommand(Command):
# sure that self.caller is always the account if possible. We also create
# a special property "character" for the puppeted object, if any. This
# is convenient for commands defined on the Account only.
if hasattr(self, "account_caller") and self.account_caller:
if self.account_caller:
if utils.inherits_from(self.caller, "evennia.objects.objects.DefaultObject"):
# caller is an Object/Character
self.character = self.caller
@ -169,6 +219,8 @@ class MuxCommand(Command):
string += "\nraw argument (self.raw): |w%s|n \n" % self.raw
string += "cmd args (self.args): |w%s|n\n" % self.args
string += "cmd switches (self.switches): |w%s|n\n" % self.switches
string += "cmd options (self.switch_options): |w%s|n\n" % self.switch_options
string += "cmd parse left/right using (self.rhs_split): |w%s|n\n" % self.rhs_split
string += "space-separated arg list (self.arglist): |w%s|n\n" % self.arglist
string += "lhs, left-hand side of '=' (self.lhs): |w%s|n\n" % self.lhs
string += "lhs, comma separated (self.lhslist): |w%s|n\n" % self.lhslist
@ -193,18 +245,4 @@ class MuxAccountCommand(MuxCommand):
character is actually attached to this Account and Session.
"""
def parse(self):
"""
We run the parent parser as usual, then fix the result
"""
super(MuxAccountCommand, self).parse()
if utils.inherits_from(self.caller, "evennia.objects.objects.DefaultObject"):
# caller is an Object/Character
self.character = self.caller
self.caller = self.caller.account
elif utils.inherits_from(self.caller, "evennia.accounts.accounts.DefaultAccount"):
# caller was already an Account
self.character = self.caller.get_puppet(self.session)
else:
self.character = None
account_caller = True # Using MuxAccountCommand explicitly defaults the caller to an account

View file

@ -58,7 +58,7 @@ class CmdReload(COMMAND_DEFAULT_CLASS):
if self.args:
reason = "(Reason: %s) " % self.args.rstrip(".")
SESSIONS.announce_all(" Server restart initiated %s..." % reason)
SESSIONS.server.shutdown(mode='reload')
SESSIONS.portal_restart_server()
class CmdReset(COMMAND_DEFAULT_CLASS):
@ -91,7 +91,7 @@ class CmdReset(COMMAND_DEFAULT_CLASS):
Reload the system.
"""
SESSIONS.announce_all(" Server resetting/restarting ...")
SESSIONS.server.shutdown(mode='reset')
SESSIONS.portal_reset_server()
class CmdShutdown(COMMAND_DEFAULT_CLASS):
@ -119,7 +119,6 @@ class CmdShutdown(COMMAND_DEFAULT_CLASS):
announcement += "%s\n" % self.args
logger.log_info('Server shutdown by %s.' % self.caller.name)
SESSIONS.announce_all(announcement)
SESSIONS.server.shutdown(mode='shutdown')
SESSIONS.portal_shutdown()
@ -246,6 +245,7 @@ class CmdPy(COMMAND_DEFAULT_CLASS):
"""
key = "@py"
aliases = ["!"]
switch_options = ("time", "edit")
locks = "cmd:perm(py) or perm(Developer)"
help_category = "System"
@ -329,6 +329,7 @@ class CmdScripts(COMMAND_DEFAULT_CLASS):
"""
key = "@scripts"
aliases = ["@globalscript", "@listscripts"]
switch_options = ("start", "stop", "kill", "validate")
locks = "cmd:perm(listscripts) or perm(Admin)"
help_category = "System"
@ -522,6 +523,7 @@ class CmdService(COMMAND_DEFAULT_CLASS):
key = "@service"
aliases = ["@services"]
switch_options = ("list", "start", "stop", "delete")
locks = "cmd:perm(service) or perm(Developer)"
help_category = "System"
@ -673,7 +675,7 @@ class CmdServerLoad(COMMAND_DEFAULT_CLASS):
Usage:
@server[/mem]
Switch:
Switches:
mem - return only a string of the current memory usage
flushmem - flush the idmapper cache
@ -704,6 +706,7 @@ class CmdServerLoad(COMMAND_DEFAULT_CLASS):
"""
key = "@server"
aliases = ["@serverload", "@serverprocess"]
switch_options = ("mem", "flushmem")
locks = "cmd:perm(list) or perm(Developer)"
help_category = "System"

View file

@ -21,6 +21,7 @@ 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.muxcommand import MuxCommand
from evennia.commands.command import Command, InterruptCommand
from evennia.utils import ansi, utils
from evennia.server.sessionhandler import SESSIONS
@ -72,7 +73,6 @@ class CommandTest(EvenniaTest):
cmdobj.obj = obj or (caller if caller else self.char1)
# test
old_msg = receiver.msg
returned_msg = ""
try:
receiver.msg = Mock()
cmdobj.at_pre_cmd()
@ -126,18 +126,48 @@ class TestGeneral(CommandTest):
self.call(general.CmdPose(), "looks around", "Char looks around")
def test_nick(self):
self.call(general.CmdNick(), "testalias = testaliasedstring1", "Inputlinenick 'testalias' mapped to 'testaliasedstring1'.")
self.call(general.CmdNick(), "/account testalias = testaliasedstring2", "Accountnick 'testalias' mapped to 'testaliasedstring2'.")
self.call(general.CmdNick(), "/object testalias = testaliasedstring3", "Objectnick 'testalias' mapped to 'testaliasedstring3'.")
self.call(general.CmdNick(), "testalias = testaliasedstring1",
"Inputlinenick 'testalias' mapped to 'testaliasedstring1'.")
self.call(general.CmdNick(), "/account testalias = testaliasedstring2",
"Accountnick 'testalias' mapped to 'testaliasedstring2'.")
self.call(general.CmdNick(), "/object testalias = testaliasedstring3",
"Objectnick 'testalias' mapped to 'testaliasedstring3'.")
self.assertEqual(u"testaliasedstring1", self.char1.nicks.get("testalias"))
self.assertEqual(None, self.char1.nicks.get("testalias", category="account"))
self.assertEqual(u"testaliasedstring2", self.char1.account.nicks.get("testalias", category="account"))
self.assertEqual(u"testaliasedstring2", self.char1.nicks.get("testalias", category="account"))
self.assertEqual(None, self.char1.account.nicks.get("testalias", category="account"))
self.assertEqual(u"testaliasedstring3", self.char1.nicks.get("testalias", category="object"))
def test_get_and_drop(self):
self.call(general.CmdGet(), "Obj", "You pick up Obj.")
self.call(general.CmdDrop(), "Obj", "You drop Obj.")
def test_give(self):
self.call(general.CmdGive(), "Obj to Char2", "You aren't carrying Obj.")
self.call(general.CmdGive(), "Obj = Char2", "You aren't carrying Obj.")
self.call(general.CmdGet(), "Obj", "You pick up Obj.")
self.call(general.CmdGive(), "Obj to Char2", "You give")
self.call(general.CmdGive(), "Obj = Char", "You give", caller=self.char2)
def test_mux_command(self):
class CmdTest(MuxCommand):
key = 'test'
switch_options = ('test', 'testswitch', 'testswitch2')
def func(self):
self.msg("Switches matched: {}".format(self.switches))
self.call(CmdTest(), "/test/testswitch/testswitch2", "Switches matched: ['test', 'testswitch', 'testswitch2']")
self.call(CmdTest(), "/test", "Switches matched: ['test']")
self.call(CmdTest(), "/test/testswitch", "Switches matched: ['test', 'testswitch']")
self.call(CmdTest(), "/testswitch/testswitch2", "Switches matched: ['testswitch', 'testswitch2']")
self.call(CmdTest(), "/testswitch", "Switches matched: ['testswitch']")
self.call(CmdTest(), "/testswitch2", "Switches matched: ['testswitch2']")
self.call(CmdTest(), "/t", "test: Ambiguous switch supplied: "
"Did you mean /test or /testswitch or /testswitch2?|Switches matched: []")
self.call(CmdTest(), "/tests", "test: Ambiguous switch supplied: "
"Did you mean /testswitch or /testswitch2?|Switches matched: []")
def test_say(self):
self.call(general.CmdSay(), "Testing", "You say, \"Testing\"")
@ -225,7 +255,8 @@ class TestAccount(CommandTest):
self.call(account.CmdColorTest(), "ansi", "ANSI colors:", caller=self.account)
def test_char_create(self):
self.call(account.CmdCharCreate(), "Test1=Test char", "Created new character Test1. Use @ic Test1 to enter the game", caller=self.account)
self.call(account.CmdCharCreate(), "Test1=Test char",
"Created new character Test1. Use @ic Test1 to enter the game", caller=self.account)
def test_quell(self):
self.call(account.CmdQuell(), "", "Quelling to current puppet's permissions (developer).", caller=self.account)
@ -234,16 +265,19 @@ class TestAccount(CommandTest):
class TestBuilding(CommandTest):
def test_create(self):
name = settings.BASE_OBJECT_TYPECLASS.rsplit('.', 1)[1]
self.call(building.CmdCreate(), "/drop TestObj1", "You create a new %s: TestObj1." % name)
self.call(building.CmdCreate(), "/d TestObj1", # /d switch is abbreviated form of /drop
"You create a new %s: TestObj1." % name)
def test_examine(self):
self.call(building.CmdExamine(), "Obj", "Name/key: Obj")
def test_set_obj_alias(self):
self.call(building.CmdSetObjAlias(), "Obj = TestObj1b", "Alias(es) for 'Obj(#4)' set to testobj1b.")
self.call(building.CmdSetObjAlias(), "Obj =", "Cleared aliases from Obj(#4)")
self.call(building.CmdSetObjAlias(), "Obj = TestObj1b", "Alias(es) for 'Obj(#4)' set to 'testobj1b'.")
def test_copy(self):
self.call(building.CmdCopy(), "Obj = TestObj2;TestObj2b, TestObj3;TestObj3b", "Copied Obj to 'TestObj3' (aliases: ['TestObj3b']")
self.call(building.CmdCopy(), "Obj = TestObj2;TestObj2b, TestObj3;TestObj3b",
"Copied Obj to 'TestObj3' (aliases: ['TestObj3b']")
def test_attribute_commands(self):
self.call(building.CmdSetAttribute(), "Obj/test1=\"value1\"", "Created attribute Obj/test1 = 'value1'")
@ -286,19 +320,35 @@ class TestBuilding(CommandTest):
def test_typeclass(self):
self.call(building.CmdTypeclass(), "Obj = evennia.objects.objects.DefaultExit",
"Obj changed typeclass from evennia.objects.objects.DefaultObject to evennia.objects.objects.DefaultExit.")
"Obj changed typeclass from evennia.objects.objects.DefaultObject "
"to evennia.objects.objects.DefaultExit.")
def test_lock(self):
self.call(building.CmdLock(), "Obj = test:perm(Developer)", "Added lock 'test:perm(Developer)' to Obj.")
def test_find(self):
self.call(building.CmdFind(), "Room2", "One Match")
expect = "One Match(#1#7, loc):\n " +\
"Char2(#7) evennia.objects.objects.DefaultCharacter (location: Room(#1))"
self.call(building.CmdFind(), "Char2", expect, cmdstring="locate")
self.call(building.CmdFind(), "/ex Char2", # /ex is an ambiguous switch
"locate: Ambiguous switch supplied: Did you mean /exit or /exact?|" + expect,
cmdstring="locate")
self.call(building.CmdFind(), "Char2", expect, cmdstring="@locate")
self.call(building.CmdFind(), "/l Char2", expect, cmdstring="find") # /l switch is abbreviated form of /loc
self.call(building.CmdFind(), "Char2", "One Match", cmdstring="@find")
def test_script(self):
self.call(building.CmdScript(), "Obj = scripts.Script", "Script scripts.Script successfully added")
def test_teleport(self):
self.call(building.CmdTeleport(), "Room2", "Room2(#2)\n|Teleported to Room2.")
self.call(building.CmdTeleport(), "/quiet Room2", "Room2(#2)\n|Teleported to Room2.")
self.call(building.CmdTeleport(), "/t", # /t switch is abbreviated form of /tonone
"Cannot teleport a puppeted object (Char, puppeted by TestAccount(account 1)) to a Nonelocation.")
self.call(building.CmdTeleport(), "/l Room2", # /l switch is abbreviated form of /loc
"Destination has no location.")
self.call(building.CmdTeleport(), "/q me to Room2", # /q switch is abbreviated form of /quiet
"Char is already at Room2.")
def test_spawn(self):
def getObject(commandTest, objKeyStr):
@ -314,7 +364,7 @@ class TestBuilding(CommandTest):
self.call(building.CmdSpawn(), " ", "Usage: @spawn")
# Tests "@spawn <prototype_dictionary>" without specifying location.
self.call(building.CmdSpawn(), \
self.call(building.CmdSpawn(),
"{'key':'goblin', 'typeclass':'evennia.DefaultCharacter'}", "Spawned goblin")
goblin = getObject(self, "goblin")
@ -333,8 +383,8 @@ class TestBuilding(CommandTest):
# char1's default location in the future...
spawnLoc = self.room1
self.call(building.CmdSpawn(), \
"{'prototype':'GOBLIN', 'key':'goblin', 'location':'%s'}" \
self.call(building.CmdSpawn(),
"{'prototype':'GOBLIN', 'key':'goblin', 'location':'%s'}"
% spawnLoc.dbref, "Spawned goblin")
goblin = getObject(self, "goblin")
self.assertEqual(goblin.location, spawnLoc)
@ -347,70 +397,81 @@ class TestBuilding(CommandTest):
self.assertIsInstance(ball, DefaultObject)
ball.delete()
# Tests "@spawn/noloc ..." without specifying a location.
# Tests "@spawn/n ..." without specifying a location.
# Location should be "None".
self.call(building.CmdSpawn(), "/noloc 'BALL'", "Spawned Ball")
self.call(building.CmdSpawn(), "/n 'BALL'", "Spawned Ball") # /n switch is abbreviated form of /noloc
ball = getObject(self, "Ball")
self.assertIsNone(ball.location)
ball.delete()
# Tests "@spawn/noloc ...", but DO specify a location.
# Location should be the specified location.
self.call(building.CmdSpawn(), \
"/noloc {'prototype':'BALL', 'location':'%s'}" \
self.call(building.CmdSpawn(),
"/noloc {'prototype':'BALL', 'location':'%s'}"
% spawnLoc.dbref, "Spawned Ball")
ball = getObject(self, "Ball")
self.assertEqual(ball.location, spawnLoc)
ball.delete()
# test calling spawn with an invalid prototype.
self.call(building.CmdSpawn(), \
"'NO_EXIST'", "No prototype named 'NO_EXIST'")
self.call(building.CmdSpawn(), "'NO_EXIST'", "No prototype named 'NO_EXIST'")
class TestComms(CommandTest):
def setUp(self):
super(CommandTest, self).setUp()
self.call(comms.CmdChannelCreate(), "testchan;test=Test Channel", "Created channel testchan and connected to it.", receiver=self.account)
self.call(comms.CmdChannelCreate(), "testchan;test=Test Channel",
"Created channel testchan and connected to it.", receiver=self.account)
def test_toggle_com(self):
self.call(comms.CmdAddCom(), "tc = testchan", "You are already connected to channel testchan. You can now", receiver=self.account)
self.call(comms.CmdAddCom(), "tc = testchan",
"You are already connected to channel testchan. You can now", receiver=self.account)
self.call(comms.CmdDelCom(), "tc", "Your alias 'tc' for channel testchan was cleared.", receiver=self.account)
def test_channels(self):
self.call(comms.CmdChannels(), "", "Available channels (use comlist,addcom and delcom to manage", receiver=self.account)
self.call(comms.CmdChannels(), "",
"Available channels (use comlist,addcom and delcom to manage", receiver=self.account)
def test_all_com(self):
self.call(comms.CmdAllCom(), "", "Available channels (use comlist,addcom and delcom to manage", receiver=self.account)
self.call(comms.CmdAllCom(), "",
"Available channels (use comlist,addcom and delcom to manage", receiver=self.account)
def test_clock(self):
self.call(comms.CmdClock(), "testchan=send:all()", "Lock(s) applied. Current locks on testchan:", receiver=self.account)
self.call(comms.CmdClock(),
"testchan=send:all()", "Lock(s) applied. Current locks on testchan:", receiver=self.account)
def test_cdesc(self):
self.call(comms.CmdCdesc(), "testchan = Test Channel", "Description of channel 'testchan' set to 'Test Channel'.", receiver=self.account)
self.call(comms.CmdCdesc(), "testchan = Test Channel",
"Description of channel 'testchan' set to 'Test Channel'.", receiver=self.account)
def test_cemit(self):
self.call(comms.CmdCemit(), "testchan = Test Message", "[testchan] Test Message|Sent to channel testchan: Test Message", receiver=self.account)
self.call(comms.CmdCemit(), "testchan = Test Message",
"[testchan] Test Message|Sent to channel testchan: Test Message", receiver=self.account)
def test_cwho(self):
self.call(comms.CmdCWho(), "testchan", "Channel subscriptions\ntestchan:\n TestAccount", receiver=self.account)
def test_page(self):
self.call(comms.CmdPage(), "TestAccount2 = Test", "TestAccount2 is offline. They will see your message if they list their pages later.|You paged TestAccount2 with: 'Test'.", receiver=self.account)
self.call(comms.CmdPage(), "TestAccount2 = Test",
"TestAccount2 is offline. They will see your message if they list their pages later."
"|You paged TestAccount2 with: 'Test'.", receiver=self.account)
def test_cboot(self):
# No one else connected to boot
self.call(comms.CmdCBoot(), "", "Usage: @cboot[/quiet] <channel> = <account> [:reason]", receiver=self.account)
def test_cdestroy(self):
self.call(comms.CmdCdestroy(), "testchan", "[testchan] TestAccount: testchan is being destroyed. Make sure to change your aliases.|Channel 'testchan' was destroyed.", receiver=self.account)
self.call(comms.CmdCdestroy(), "testchan",
"[testchan] TestAccount: testchan is being destroyed. Make sure to change your aliases."
"|Channel 'testchan' was destroyed.", receiver=self.account)
class TestBatchProcess(CommandTest):
def test_batch_commands(self):
# cannot test batchcode here, it must run inside the server process
self.call(batchprocess.CmdBatchCommands(), "example_batch_cmds", "Running Batchcommand processor Automatic mode for example_batch_cmds")
self.call(batchprocess.CmdBatchCommands(), "example_batch_cmds",
"Running Batchcommand 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

View file

@ -293,7 +293,7 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
session.msg(string)
return
if not re.findall(r"^[\w. @+\-']+$", password) or not (3 < len(password)):
string = "\n\r Password should be longer than 3 characers. Letters, spaces, digits and @/./+/-/_/' only." \
string = "\n\r Password should be longer than 3 characters. Letters, spaces, digits and @/./+/-/_/' only." \
"\nFor best security, make it longer than 8 characters. You can also use a phrase of" \
"\nmany words if you enclose the password in double quotes."
session.msg(string)
@ -557,7 +557,7 @@ def _create_character(session, new_account, typeclass, home, permissions):
# If no description is set, set a default description
if not new_character.db.desc:
new_character.db.desc = "This is an Account."
new_character.db.desc = "This is a character."
# We need to set this to have @ic auto-connect to this character
new_account.db._last_puppet = new_character
except Exception as e:

View file

@ -26,6 +26,7 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)):
the hooks called by this method.
"""
self.basetype_setup()
self.at_channel_creation()
self.attributes.add("log_file", "channel_%s.log" % self.key)
if hasattr(self, "_createdict"):
@ -46,11 +47,7 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)):
if cdict.get("desc"):
self.attributes.add("desc", cdict["desc"])
def at_channel_creation(self):
"""
Called once, when the channel is first created.
"""
def basetype_setup(self):
# delayed import of the channelhandler
global _CHANNEL_HANDLER
if not _CHANNEL_HANDLER:
@ -58,6 +55,15 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)):
# register ourselves with the channelhandler.
_CHANNEL_HANDLER.add(self)
self.locks.add("send:all();listen:all();control:perm(Admin)")
def at_channel_creation(self):
"""
Called once, when the channel is first created.
"""
pass
# helper methods, for easy overloading
def has_connection(self, subscriber):

View file

@ -16,10 +16,10 @@ things you want from here into your game folder and change them there.
## Contrib modules
* Barter system (Griatch 2012) - A safe and effective barter-system
for any game. Allows safe trading of any godds (including coin)
for any game. Allows safe trading of any goods (including coin).
* CharGen (Griatch 2011) - A simple Character creator for OOC mode.
Meant as a starting point for a more fleshed-out system.
* Clothing (BattleJenkins 2017) - A layered clothing system with
* Clothing (FlutterSprite 2017) - A layered clothing system with
slots for different types of garments auto-showing in description.
* Color-markups (Griatch, 2017) - Alternative in-game color markups.
* Custom gametime (Griatch, vlgeoff 2017) - Implements Evennia's
@ -33,7 +33,7 @@ things you want from here into your game folder and change them there.
on a character and access it in an emote with a custom marker.
* Mail (grungies1138 2016) - An in-game mail system for communication.
* Menu login (Griatch 2011) - A login system using menus asking
for name/password rather than giving them as one command
for name/password rather than giving them as one command.
* Map Builder (CloudKeeper 2016) - Build a game area based on a 2D
"graphical" unicode map. Supports assymmetric exits.
* Menu Login (Vincent-lg 2016) - Alternate login system using EvMenu.
@ -50,8 +50,9 @@ things you want from here into your game folder and change them there.
time to pass depending on if you are walking/running etc.
* Talking NPC (Griatch 2011) - A talking NPC object that offers a
menu-driven conversation tree.
* Turnbattle (BattleJenkins 2017) - A turn-based combat engine meant
as a start to build from. Has attack/disengage and turn timeouts.
* Tree Select (FlutterSprite 2017) - A simple system for creating a
branching EvMenu with selection options sourced from a single
multi-line string.
* Wilderness (titeuf87 2017) - Make infinitely large wilderness areas
with dynamically created locations.
* UnixCommand (Vincent Le Geoff 2017) - Add commands with UNIX-style syntax.
@ -59,9 +60,12 @@ things you want from here into your game folder and change them there.
## Contrib packages
* EGI_Client (gtaylor 2016) - Client for reporting game status
to the Evennia game index (games.evennia.com)
to the Evennia game index (games.evennia.com).
* In-game Python (Vincent Le Goff 2017) - Allow trusted builders to script
objects and events using Python from in-game.
* Turnbattle (FlutterSprite 2017) - A turn-based combat engine meant
as a start to build from. Has attack/disengage and turn timeouts,
and includes optional expansions for equipment and combat movement.
* Tutorial examples (Griatch 2011, 2015) - A folder of basic
example objects, commands and scripts.
* Tutorial world (Griatch 2011, 2015) - A folder containing the

View file

@ -197,7 +197,7 @@ class CmdUnconnectedCreate(MuxCommand):
session.msg(string)
return
if not re.findall(r"^[\w. @+\-']+$", password) or not (3 < len(password)):
string = "\n\r Password should be longer than 3 characers. Letters, spaces, digits and @/./+/-/_/' only." \
string = "\n\r Password should be longer than 3 characters. Letters, spaces, digits and @/./+/-/_/' only." \
"\nFor best security, make it longer than 8 characters. You can also use a phrase of" \
"\nmany words if you enclose the password in double quotes."
session.msg(string)

View file

@ -213,37 +213,39 @@ class ExtendedRoom(DefaultRoom):
return detail
return None
def return_appearance(self, looker):
def return_appearance(self, looker, **kwargs):
"""
This is called when e.g. the look command wants to retrieve
the description of this object.
Args:
looker (Object): The object looking at us.
**kwargs (dict): Arbitrary, optional arguments for users
overriding the call (unused by default).
Returns:
description (str): Our description.
"""
update = False
# ensures that our description is current based on time/season
self.update_current_description()
# run the normal return_appearance method, now that desc is updated.
return super(ExtendedRoom, self).return_appearance(looker, **kwargs)
def update_current_description(self):
"""
This will update the description of the room if the time or season
has changed since last checked.
"""
update = False
# get current time and season
curr_season, curr_timeslot = self.get_time_and_season()
# compare with previously stored slots
last_season = self.ndb.last_season
last_timeslot = self.ndb.last_timeslot
if curr_season != last_season:
# season changed. Load new desc, or a fallback.
if curr_season == 'spring':
new_raw_desc = self.db.spring_desc
elif curr_season == 'summer':
new_raw_desc = self.db.summer_desc
elif curr_season == 'autumn':
new_raw_desc = self.db.autumn_desc
else:
new_raw_desc = self.db.winter_desc
new_raw_desc = self.attributes.get("%s_desc" % curr_season)
if new_raw_desc:
raw_desc = new_raw_desc
else:
@ -252,19 +254,15 @@ class ExtendedRoom(DefaultRoom):
self.db.raw_desc = raw_desc
self.ndb.last_season = curr_season
update = True
if curr_timeslot != last_timeslot:
# timeslot changed. Set update flag.
self.ndb.last_timeslot = curr_timeslot
update = True
if update:
# if anything changed we have to re-parse
# the raw_desc for time markers
# and re-save the description again.
self.db.desc = self.replace_timeslots(self.db.raw_desc, curr_timeslot)
# run the normal return_appearance method, now that desc is updated.
return super(ExtendedRoom, self).return_appearance(looker)
# Custom Look command supporting Room details. Add this to
@ -369,6 +367,7 @@ class CmdExtendedDesc(default_cmds.CmdDesc):
"""
aliases = ["describe", "detail"]
switch_options = () # Inherits from default_cmds.CmdDesc, but unused here
def reset_times(self, obj):
"""By deleteting the caches we force a re-load."""

View file

@ -0,0 +1,103 @@
"""
Health Bar
Contrib - Tim Ashley Jenkins 2017
The function provided in this module lets you easily display visual
bars or meters - "health bar" is merely the most obvious use for this,
though these bars are highly customizable and can be used for any sort
of appropriate data besides player health.
Today's players may be more used to seeing statistics like health,
stamina, magic, and etc. displayed as bars rather than bare numerical
values, so using this module to present this data this way may make it
more accessible. Keep in mind, however, that players may also be using
a screen reader to connect to your game, which will not be able to
represent the colors of the bar in any way. By default, the values
represented are rendered as text inside the bar which can be read by
screen readers.
The health bar will account for current values above the maximum or
below 0, rendering them as a completely full or empty bar with the
values displayed within.
"""
def display_meter(cur_value, max_value,
length=30, fill_color=["R", "Y", "G"],
empty_color="B", text_color="w",
align="left", pre_text="", post_text="",
show_values=True):
"""
Represents a current and maximum value given as a "bar" rendered with
ANSI or xterm256 background colors.
Args:
cur_value (int): Current value to display
max_value (int): Maximum value to display
Options:
length (int): Length of meter returned, in characters
fill_color (list): List of color codes for the full portion
of the bar, sans any sort of prefix - both ANSI and xterm256
colors are usable. When the bar is empty, colors toward the
start of the list will be chosen - when the bar is full, colors
towards the end are picked. You can adjust the 'weights' of
the changing colors by adding multiple entries of the same
color - for example, if you only want the bar to change when
it's close to empty, you could supply ['R','Y','G','G','G']
empty_color (str): Color code for the empty portion of the bar.
text_color (str): Color code for text inside the bar.
align (str): "left", "right", or "center" - alignment of text in the bar
pre_text (str): Text to put before the numbers in the bar
post_text (str): Text to put after the numbers in the bar
show_values (bool): If true, shows the numerical values represented by
the bar. It's highly recommended you keep this on, especially if
there's no info given in pre_text or post_text, as players on screen
readers will be unable to read the graphical aspect of the bar.
"""
# Start by building the base string.
num_text = ""
if show_values:
num_text = "%i / %i" % (cur_value, max_value)
bar_base_str = pre_text + num_text + post_text
# Cut down the length of the base string if needed
if len(bar_base_str) > length:
bar_base_str = bar_base_str[:length]
# Pad and align the bar base string
if align == "right":
bar_base_str = bar_base_str.rjust(length, " ")
elif align == "center":
bar_base_str = bar_base_str.center(length, " ")
else:
bar_base_str = bar_base_str.ljust(length, " ")
if max_value < 1: # Prevent divide by zero
max_value = 1
if cur_value < 0: # Prevent weirdly formatted 'negative bars'
cur_value = 0
if cur_value > max_value: # Display overfull bars correctly
cur_value = max_value
# Now it's time to determine where to put the color codes.
percent_full = float(cur_value) / float(max_value)
split_index = round(float(length) * percent_full)
# Determine point at which to split the bar
split_index = int(split_index)
# Separate the bar string into full and empty portions
full_portion = bar_base_str[:split_index]
empty_portion = bar_base_str[split_index:]
# Pick which fill color to use based on how full the bar is
fillcolor_index = (float(len(fill_color)) * percent_full)
fillcolor_index = int(round(fillcolor_index)) - 1
fillcolor_code = "|[" + fill_color[fillcolor_index]
# Make color codes for empty bar portion and text_color
emptycolor_code = "|[" + empty_color
textcolor_code = "|" + text_color
# Assemble the final bar
final_bar = fillcolor_code + textcolor_code + full_portion + "|n" + emptycolor_code + textcolor_code + empty_portion + "|n"
return final_bar

View file

@ -430,7 +430,7 @@ class EventCharacter(DefaultCharacter):
# Browse all the room's other characters
for obj in location.contents:
if obj is self or not inherits_from(obj, "objects.objects.DefaultCharacter"):
if obj is self or not inherits_from(obj, "evennia.objects.objects.DefaultCharacter"):
continue
allow = obj.callbacks.call("can_say", self, obj, message, parameters=message)
@ -491,7 +491,7 @@ class EventCharacter(DefaultCharacter):
parameters=message)
# Call the other characters' "say" event
presents = [obj for obj in location.contents if obj is not self and inherits_from(obj, "objects.objects.DefaultCharacter")]
presents = [obj for obj in location.contents if obj is not self and inherits_from(obj, "evennia.objects.objects.DefaultCharacter")]
for present in presents:
present.callbacks.call("say", self, present, message, parameters=message)

View file

@ -670,6 +670,15 @@ class TestGenderSub(CommandTest):
char = create_object(gendersub.GenderCharacter, key="Gendered", location=self.room1)
txt = "Test |p gender"
self.assertEqual(gendersub._RE_GENDER_PRONOUN.sub(char._get_pronoun, txt), "Test their gender")
# test health bar contrib
from evennia.contrib import health_bar
class TestHealthBar(EvenniaTest):
def test_healthbar(self):
expected_bar_str = "|[G|w |n|[B|w test24 / 200test |n"
self.assertTrue(health_bar.display_meter(24, 200, length=40, pre_text="test", post_text="test", align="center") == expected_bar_str)
# test mail contrib
@ -944,7 +953,7 @@ class TestTutorialWorldRooms(CommandTest):
# test turnbattle
from evennia.contrib import turnbattle
from evennia.contrib.turnbattle import tb_basic, tb_equip, tb_range
from evennia.objects.objects import DefaultRoom
@ -952,60 +961,94 @@ class TestTurnBattleCmd(CommandTest):
# Test combat commands
def test_turnbattlecmd(self):
self.call(turnbattle.CmdFight(), "", "You can't start a fight if you've been defeated!")
self.call(turnbattle.CmdAttack(), "", "You can only do that in combat. (see: help fight)")
self.call(turnbattle.CmdPass(), "", "You can only do that in combat. (see: help fight)")
self.call(turnbattle.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
self.call(turnbattle.CmdRest(), "", "Char rests to recover HP.")
self.call(tb_basic.CmdFight(), "", "You can't start a fight if you've been defeated!")
self.call(tb_basic.CmdAttack(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_basic.CmdPass(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_basic.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_basic.CmdRest(), "", "Char rests to recover HP.")
# 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.")
self.call(tb_equip.CmdDoff(), "", "Char removes test armor.")
# Also test the commands that are the same in the basic module
self.call(tb_equip.CmdFight(), "", "You can't start a fight if you've been defeated!")
self.call(tb_equip.CmdAttack(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_equip.CmdPass(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_equip.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_equip.CmdRest(), "", "Char rests to recover HP.")
# Test range commands
def test_turnbattlerangecmd(self):
# Start with range module specific commands.
self.call(tb_range.CmdShoot(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_range.CmdApproach(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_range.CmdWithdraw(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_range.CmdStatus(), "", "HP Remaining: 100 / 100")
# Also test the commands that are the same in the basic module
self.call(tb_range.CmdFight(), "", "There's nobody here to fight!")
self.call(tb_range.CmdAttack(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_range.CmdPass(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_range.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_range.CmdRest(), "", "Char rests to recover HP.")
class TestTurnBattleFunc(EvenniaTest):
# Test combat functions
def test_turnbattlefunc(self):
attacker = create_object(turnbattle.BattleCharacter, key="Attacker")
defender = create_object(turnbattle.BattleCharacter, key="Defender")
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 = turnbattle.roll_init(attacker)
initiative = tb_basic.roll_init(attacker)
self.assertTrue(initiative >= 0 and initiative <= 1000)
# Attack roll
attack_roll = turnbattle.get_attack(attacker, defender)
attack_roll = tb_basic.get_attack(attacker, defender)
self.assertTrue(attack_roll >= 0 and attack_roll <= 100)
# Defense roll
defense_roll = turnbattle.get_defense(attacker, defender)
defense_roll = tb_basic.get_defense(attacker, defender)
self.assertTrue(defense_roll == 50)
# Damage roll
damage_roll = turnbattle.get_damage(attacker, defender)
damage_roll = tb_basic.get_damage(attacker, defender)
self.assertTrue(damage_roll >= 15 and damage_roll <= 25)
# Apply damage
defender.db.hp = 10
turnbattle.apply_damage(defender, 3)
tb_basic.apply_damage(defender, 3)
self.assertTrue(defender.db.hp == 7)
# Resolve attack
defender.db.hp = 40
turnbattle.resolve_attack(attacker, defender, attack_value=20, defense_value=10)
tb_basic.resolve_attack(attacker, defender, attack_value=20, defense_value=10)
self.assertTrue(defender.db.hp < 40)
# Combat cleanup
attacker.db.Combat_attribute = True
turnbattle.combat_cleanup(attacker)
tb_basic.combat_cleanup(attacker)
self.assertFalse(attacker.db.combat_attribute)
# Is in combat
self.assertFalse(turnbattle.is_in_combat(attacker))
self.assertFalse(tb_basic.is_in_combat(attacker))
# Set up turn handler script for further tests
attacker.location.scripts.add(turnbattle.TurnHandler)
attacker.location.scripts.add(tb_basic.TBBasicTurnHandler)
turnhandler = attacker.db.combat_TurnHandler
self.assertTrue(attacker.db.combat_TurnHandler)
# Set the turn handler's interval very high to keep it from repeating during tests.
turnhandler.interval = 10000
# Force turn order
turnhandler.db.fighters = [attacker, defender]
turnhandler.db.turn = 0
# Test is turn
self.assertTrue(turnbattle.is_turn(attacker))
self.assertTrue(tb_basic.is_turn(attacker))
# Spend actions
attacker.db.Combat_ActionsLeft = 1
turnbattle.spend_action(attacker, 1, action_name="Test")
tb_basic.spend_action(attacker, 1, action_name="Test")
self.assertTrue(attacker.db.Combat_ActionsLeft == 0)
self.assertTrue(attacker.db.Combat_LastAction == "Test")
# Initialize for combat
@ -1029,7 +1072,7 @@ class TestTurnBattleFunc(EvenniaTest):
turnhandler.turn_end_check(attacker)
self.assertTrue(turnhandler.db.turn == 1)
# Join fight
joiner = create_object(turnbattle.BattleCharacter, key="Joiner")
joiner = create_object(tb_basic.TBBasicCharacter, key="Joiner")
turnhandler.db.fighters = [attacker, defender]
turnhandler.db.turn = 0
turnhandler.join_fight(joiner)
@ -1037,7 +1080,208 @@ class TestTurnBattleFunc(EvenniaTest):
self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender])
# Remove the script at the end
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)
self.assertTrue(initiative >= 0 and initiative <= 1000)
# Attack roll
attack_roll = tb_equip.get_attack(attacker, defender)
self.assertTrue(attack_roll >= -50 and attack_roll <= 150)
# Defense roll
defense_roll = tb_equip.get_defense(attacker, defender)
self.assertTrue(defense_roll == 50)
# Damage roll
damage_roll = tb_equip.get_damage(attacker, 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)
# Resolve attack
defender.db.hp = 40
tb_equip.resolve_attack(attacker, defender, attack_value=20, defense_value=10)
self.assertTrue(defender.db.hp < 40)
# Combat cleanup
attacker.db.Combat_attribute = True
tb_equip.combat_cleanup(attacker)
self.assertFalse(attacker.db.combat_attribute)
# Is in combat
self.assertFalse(tb_equip.is_in_combat(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)
# Set the turn handler's interval very high to keep it from repeating during tests.
turnhandler.interval = 10000
# Force turn order
turnhandler.db.fighters = [attacker, defender]
turnhandler.db.turn = 0
# Test is turn
self.assertTrue(tb_equip.is_turn(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")
# 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")
# Start turn
defender.db.Combat_ActionsLeft = 0
turnhandler.start_turn(defender)
self.assertTrue(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)
# 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)
# 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()
# 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)
self.assertTrue(initiative >= 0 and initiative <= 1000)
# Attack roll
attack_roll = tb_range.get_attack(attacker, defender, "test")
self.assertTrue(attack_roll >= 0 and attack_roll <= 100)
# Defense roll
defense_roll = tb_range.get_defense(attacker, defender, "test")
self.assertTrue(defense_roll == 50)
# Damage roll
damage_roll = tb_range.get_damage(attacker, 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)
# 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)
# Combat cleanup
attacker.db.Combat_attribute = True
tb_range.combat_cleanup(attacker)
self.assertFalse(attacker.db.combat_attribute)
# Is in combat
self.assertFalse(tb_range.is_in_combat(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)
# Set the turn handler's interval very high to keep it from repeating during tests.
turnhandler.interval = 10000
# Force turn order
turnhandler.db.fighters = [attacker, defender]
turnhandler.db.turn = 0
# Test is turn
self.assertTrue(tb_range.is_turn(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")
# 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")
# 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
# Start turn
defender.db.Combat_ActionsLeft = 0
turnhandler.start_turn(defender)
self.assertTrue(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)
# 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)
# 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])
# Now, test for approach/withdraw functions
self.assertTrue(tb_range.get_range(attacker, defender) == 1)
# Approach
tb_range.approach(attacker, defender)
self.assertTrue(tb_range.get_range(attacker, 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()
# Test tree select
from evennia.contrib import tree_select
TREE_MENU_TESTSTR = """Foo
Bar
-Baz
--Baz 1
--Baz 2
-Qux"""
class TestTreeSelectFunc(EvenniaTest):
def test_tree_functions(self):
# Dash counter
self.assertTrue(tree_select.dashcount("--test") == 2)
# Is category
self.assertTrue(tree_select.is_category(TREE_MENU_TESTSTR, 1) == True)
# Parse options
self.assertTrue(tree_select.parse_opts(TREE_MENU_TESTSTR, category_index=2) == [(3, "Baz 1"), (4, "Baz 2")])
# Index to selection
self.assertTrue(tree_select.index_to_selection(TREE_MENU_TESTSTR, 2) == "Baz")
# Go up one category
self.assertTrue(tree_select.go_up_one_category(TREE_MENU_TESTSTR, 4) == 2)
# Option list to menu options
test_optlist = tree_select.parse_opts(TREE_MENU_TESTSTR, category_index=2)
optlist_to_menu_expected_result = [{'goto': ['menunode_treeselect', {'newindex': 3}], 'key': 'Baz 1'},
{'goto': ['menunode_treeselect', {'newindex': 4}], 'key': 'Baz 2'},
{'goto': ['menunode_treeselect', {'newindex': 1}], 'key': ['<< Go Back', 'go back', 'back'], 'desc': 'Return to the previous menu.'}]
self.assertTrue(tree_select.optlist_to_menuoptions(TREE_MENU_TESTSTR, test_optlist, 2, True, True) == optlist_to_menu_expected_result)
# Test of the unixcommand module

View file

@ -0,0 +1,535 @@
"""
Easy menu selection tree
Contrib - Tim Ashley Jenkins 2017
This module allows you to create and initialize an entire branching EvMenu
instance with nothing but a multi-line string passed to one function.
EvMenu is incredibly powerful and flexible, but using it for simple menus
can often be fairly cumbersome - a simple menu that can branch into five
categories would require six nodes, each with options represented as a list
of dictionaries.
This module provides a function, init_tree_selection, which acts as a frontend
for EvMenu, dynamically sourcing the options from a multi-line string you provide.
For example, if you define a string as such:
TEST_MENU = '''Foo
Bar
Baz
Qux'''
And then use TEST_MENU as the 'treestr' source when you call init_tree_selection
on a player:
init_tree_selection(TEST_MENU, caller, callback)
The player will be presented with an EvMenu, like so:
___________________________
Make your selection:
___________________________
Foo
Bar
Baz
Qux
Making a selection will pass the selection's key to the specified callback as a
string along with the caller, as well as the index of the selection (the line number
on the source string) along with the source string for the tree itself.
In addition to specifying selections on the menu, you can also specify categories.
Categories are indicated by putting options below it preceded with a '-' character.
If a selection is a category, then choosing it will bring up a new menu node, prompting
the player to select between those options, or to go back to the previous menu. In
addition, categories are marked by default with a '[+]' at the end of their key. Both
this marker and the option to go back can be disabled.
Categories can be nested in other categories as well - just go another '-' deeper. You
can do this as many times as you like. There's no hard limit to the number of
categories you can go down.
For example, let's add some more options to our menu, turning 'Bar' into a category.
TEST_MENU = '''Foo
Bar
-You've got to know
--When to hold em
--When to fold em
--When to walk away
Baz
Qux'''
Now when we call the menu, we can see that 'Bar' has become a category instead of a
selectable option.
_______________________________
Make your selection:
_______________________________
Foo
Bar [+]
Baz
Qux
Note the [+] next to 'Bar'. If we select 'Bar', it'll show us the option listed under it.
________________________________________________________________
Bar
________________________________________________________________
You've got to know [+]
<< Go Back: Return to the previous menu.
Just the one option, which is a category itself, and the option to go back, which will
take us back to the previous menu. Let's select 'You've got to know'.
________________________________________________________________
You've got to know
________________________________________________________________
When to hold em
When to fold em
When to walk away
<< Go Back: Return to the previous menu.
Now we see the three options listed under it, too. We can select one of them or use 'Go
Back' to return to the 'Bar' menu we were just at before. It's very simple to make a
branching tree of selections!
One last thing - you can set the descriptions for the various options simply by adding a
':' character followed by the description to the option's line. For example, let's add a
description to 'Baz' in our menu:
TEST_MENU = '''Foo
Bar
-You've got to know
--When to hold em
--When to fold em
--When to walk away
Baz: Look at this one: the best option.
Qux'''
Now we see that the Baz option has a description attached that's separate from its key:
_______________________________________________________________
Make your selection:
_______________________________________________________________
Foo
Bar [+]
Baz: Look at this one: the best option.
Qux
Once the player makes a selection - let's say, 'Foo' - the menu will terminate and call
your specified callback with the selection, like so:
callback(caller, TEST_MENU, 0, "Foo")
The index of the selection is given along with a string containing the selection's key.
That way, if you have two selections in the menu with the same key, you can still
differentiate between them.
And that's all there is to it! For simple branching-tree selections, using this system is
much easier than manually creating EvMenu nodes. It also makes generating menus with dynamic
options much easier - since the source of the menu tree is just a string, you could easily
generate that string procedurally before passing it to the init_tree_selection function.
For example, if a player casts a spell or does an attack without specifying a target, instead
of giving them an error, you could present them with a list of valid targets to select by
generating a multi-line string of targets and passing it to init_tree_selection, with the
callable performing the maneuver once a selection is made.
This selection system only works for simple branching trees - doing anything really complicated
like jumping between categories or prompting for arbitrary input would still require a full
EvMenu implementation. For simple selections, however, I'm sure you will find using this function
to be much easier!
Included in this module is a sample menu and function which will let a player change the color
of their name - feel free to mess with it to get a feel for how this system works by importing
this module in your game's default_cmdsets.py module and adding CmdNameColor to your default
character's command set.
"""
from evennia.utils import evmenu
from evennia.utils.logger import log_trace
from evennia import Command
def init_tree_selection(treestr, caller, callback,
index=None, mark_category=True, go_back=True,
cmd_on_exit="look",
start_text="Make your selection:"):
"""
Prompts a player to select an option from a menu tree given as a multi-line string.
Args:
treestr (str): Multi-lne string representing menu options
caller (obj): Player to initialize the menu for
callback (callable): Function to run when a selection is made. Must take 4 args:
caller (obj): Caller given above
treestr (str): Menu tree string given above
index (int): Index of final selection
selection (str): Key of final selection
Options:
index (int or None): Index to start the menu at, or None for top level
mark_category (bool): If True, marks categories with a [+] symbol in the menu
go_back (bool): If True, present an option to go back to previous categories
start_text (str): Text to display at the top level of the menu
cmd_on_exit(str): Command to enter when the menu exits - 'look' by default
Notes:
This function will initialize an instance of EvMenu with options generated
dynamically from the source string, and passes the menu user's selection to
a function of your choosing. The EvMenu is made of a single, repeating node,
which will call itself over and over at different levels of the menu tree as
categories are selected.
Once a non-category selection is made, the user's selection will be passed to
the given callable, both as a string and as an index number. The index is given
to ensure every selection has a unique identifier, so that selections with the
same key in different categories can be distinguished between.
The menus called by this function are not persistent and cannot perform
complicated tasks like prompt for arbitrary input or jump multiple category
levels at once - you'll have to use EvMenu itself if you want to take full
advantage of its features.
"""
# Pass kwargs to store data needed in the menu
kwargs = {
"index":index,
"mark_category":mark_category,
"go_back":go_back,
"treestr":treestr,
"callback":callback,
"start_text":start_text
}
# Initialize menu of selections
evmenu.EvMenu(caller, "evennia.contrib.tree_select", startnode="menunode_treeselect",
startnode_input=None, cmd_on_exit=cmd_on_exit, **kwargs)
def dashcount(entry):
"""
Counts the number of dashes at the beginning of a string. This
is needed to determine the depth of options in categories.
Args:
entry (str): String to count the dashes at the start of
Returns:
dashes (int): Number of dashes at the start
"""
dashes = 0
for char in entry:
if char == "-":
dashes += 1
else:
return dashes
return dashes
def is_category(treestr, index):
"""
Determines whether an option in a tree string is a category by
whether or not there are additional options below it.
Args:
treestr (str): Multi-line string representing menu options
index (int): Which line of the string to test
Returns:
is_category (bool): Whether the option is a category
"""
opt_list = treestr.split('\n')
# Not a category if it's the last one in the list
if index == len(opt_list) - 1:
return False
# Not a category if next option is not one level deeper
return not bool(dashcount(opt_list[index+1]) != dashcount(opt_list[index]) + 1)
def parse_opts(treestr, category_index=None):
"""
Parses a tree string and given index into a list of options. If
category_index is none, returns all the options at the top level of
the menu. If category_index corresponds to a category, returns a list
of options under that category. If category_index corresponds to
an option that is not a category, it's a selection and returns True.
Args:
treestr (str): Multi-line string representing menu options
category_index (int): Index of category or None for top level
Returns:
kept_opts (list or True): Either a list of options in the selected
category or True if a selection was made
"""
dash_depth = 0
opt_list = treestr.split('\n')
kept_opts = []
# If a category index is given
if category_index != None:
# If given index is not a category, it's a selection - return True.
if not is_category(treestr, category_index):
return True
# Otherwise, change the dash depth to match the new category.
dash_depth = dashcount(opt_list[category_index]) + 1
# Delete everything before the category index
opt_list = opt_list [category_index+1:]
# Keep every option (referenced by index) at the appropriate depth
cur_index = 0
for option in opt_list:
if dashcount(option) == dash_depth:
if category_index == None:
kept_opts.append((cur_index, option[dash_depth:]))
else:
kept_opts.append((cur_index + category_index + 1, option[dash_depth:]))
# Exits the loop if leaving a category
if dashcount(option) < dash_depth:
return kept_opts
cur_index += 1
return kept_opts
def index_to_selection(treestr, index, desc=False):
"""
Given a menu tree string and an index, returns the corresponding selection's
name as a string. If 'desc' is set to True, will return the selection's
description as a string instead.
Args:
treestr (str): Multi-line string representing menu options
index (int): Index to convert to selection key or description
Options:
desc (bool): If true, returns description instead of key
Returns:
selection (str): Selection key or description if 'desc' is set
"""
opt_list = treestr.split('\n')
# Fetch the given line
selection = opt_list[index]
# Strip out the dashes at the start
selection = selection[dashcount(selection):]
# Separate out description, if any
if ":" in selection:
# Split string into key and description
selection = selection.split(':', 1)
selection[1] = selection[1].strip(" ")
else:
# If no description given, set description to None
selection = [selection, None]
if not desc:
return selection[0]
else:
return selection[1]
def go_up_one_category(treestr, index):
"""
Given a menu tree string and an index, returns the category that the given option
belongs to. Used for the 'go back' option.
Args:
treestr (str): Multi-line string representing menu options
index (int): Index to determine the parent category of
Returns:
parent_category (int): Index of parent category
"""
opt_list = treestr.split('\n')
# Get the number of dashes deep the given index is
dash_level = dashcount(opt_list[index])
# Delete everything after the current index
opt_list = opt_list[:index+1]
# If there's no dash, return 'None' to return to base menu
if dash_level == 0:
return None
current_index = index
# Go up through each option until we find one that's one category above
for selection in reversed(opt_list):
if dashcount(selection) == dash_level - 1:
return current_index
current_index -= 1
def optlist_to_menuoptions(treestr, optlist, index, mark_category, go_back):
"""
Takes a list of options processed by parse_opts and turns it into
a list/dictionary of menu options for use in menunode_treeselect.
Args:
treestr (str): Multi-line string representing menu options
optlist (list): List of options to convert to EvMenu's option format
index (int): Index of current category
mark_category (bool): Whether or not to mark categories with [+]
go_back (bool): Whether or not to add an option to go back in the menu
Returns:
menuoptions (list of dicts): List of menu options formatted for use
in EvMenu, each passing a different "newindex" kwarg that changes
the menu level or makes a selection
"""
menuoptions = []
cur_index = 0
for option in optlist:
index_to_add = optlist[cur_index][0]
menuitem = {}
keystr = index_to_selection(treestr, index_to_add)
if mark_category and is_category(treestr, index_to_add):
# Add the [+] to the key if marking categories, and the key by itself as an alias
menuitem["key"] = [keystr + " [+]", keystr]
else:
menuitem["key"] = keystr
# Get the option's description
desc = index_to_selection(treestr, index_to_add, desc=True)
if desc:
menuitem["desc"] = desc
# Passing 'newindex' as a kwarg to the node is how we move through the menu!
menuitem["goto"] = ["menunode_treeselect", {"newindex":index_to_add}]
menuoptions.append(menuitem)
cur_index += 1
# Add option to go back, if needed
if index != None and go_back == True:
gobackitem = {"key":["<< Go Back", "go back", "back"],
"desc":"Return to the previous menu.",
"goto":["menunode_treeselect", {"newindex":go_up_one_category(treestr, index)}]}
menuoptions.append(gobackitem)
return menuoptions
def menunode_treeselect(caller, raw_string, **kwargs):
"""
This is the repeating menu node that handles the tree selection.
"""
# If 'newindex' is in the kwargs, change the stored index.
if "newindex" in kwargs:
caller.ndb._menutree.index = kwargs["newindex"]
# Retrieve menu info
index = caller.ndb._menutree.index
mark_category = caller.ndb._menutree.mark_category
go_back = caller.ndb._menutree.go_back
treestr = caller.ndb._menutree.treestr
callback = caller.ndb._menutree.callback
start_text = caller.ndb._menutree.start_text
# List of options if index is 'None' or category, or 'True' if a selection
optlist = parse_opts(treestr, category_index=index)
# If given index returns optlist as 'True', it's a selection. Pass to callback and end the menu.
if optlist == True:
selection = index_to_selection(treestr, index)
try:
callback(caller, treestr, index, selection)
except Exception:
log_trace("Error in tree selection callback.")
# Returning None, None ends the menu.
return None, None
# Otherwise, convert optlist to a list of menu options.
else:
options = optlist_to_menuoptions(treestr, optlist, index, mark_category, go_back)
if index == None:
# Use start_text for the menu text on the top level
text = start_text
else:
# Use the category name and description (if any) as the menu text
if index_to_selection(treestr, index, desc=True) != None:
text = "|w" + index_to_selection(treestr, index) + "|n: " + index_to_selection(treestr, index, desc=True)
else:
text = "|w" + index_to_selection(treestr, index) + "|n"
return text, options
# The rest of this module is for the example menu and command! It'll change the color of your name.
"""
Here's an example string that you can initialize a menu from. Note the dashes at
the beginning of each line - that's how menu option depth and hierarchy is determined.
"""
NAMECOLOR_MENU = """Set name color: Choose a color for your name!
-Red shades: Various shades of |511red|n
--Red: |511Set your name to Red|n
--Pink: |533Set your name to Pink|n
--Maroon: |301Set your name to Maroon|n
-Orange shades: Various shades of |531orange|n
--Orange: |531Set your name to Orange|n
--Brown: |321Set your name to Brown|n
--Sienna: |420Set your name to Sienna|n
-Yellow shades: Various shades of |551yellow|n
--Yellow: |551Set your name to Yellow|n
--Gold: |540Set your name to Gold|n
--Dandelion: |553Set your name to Dandelion|n
-Green shades: Various shades of |141green|n
--Green: |141Set your name to Green|n
--Lime: |350Set your name to Lime|n
--Forest: |032Set your name to Forest|n
-Blue shades: Various shades of |115blue|n
--Blue: |115Set your name to Blue|n
--Cyan: |155Set your name to Cyan|n
--Navy: |113Set your name to Navy|n
-Purple shades: Various shades of |415purple|n
--Purple: |415Set your name to Purple|n
--Lavender: |535Set your name to Lavender|n
--Fuchsia: |503Set your name to Fuchsia|n
Remove name color: Remove your name color, if any"""
class CmdNameColor(Command):
"""
Set or remove a special color on your name. Just an example for the
easy menu selection tree contrib.
"""
key = "namecolor"
def func(self):
# This is all you have to do to initialize a menu!
init_tree_selection(NAMECOLOR_MENU, self.caller,
change_name_color,
start_text="Name color options:")
def change_name_color(caller, treestr, index, selection):
"""
Changes a player's name color.
Args:
caller (obj): Character whose name to color.
treestr (str): String for the color change menu - unused
index (int): Index of menu selection - unused
selection (str): Selection made from the name color menu - used
to determine the color the player chose.
"""
# Store the caller's uncolored name
if not caller.db.uncolored_name:
caller.db.uncolored_name = caller.key
# Dictionary matching color selection names to color codes
colordict = { "Red":"|511", "Pink":"|533", "Maroon":"|301",
"Orange":"|531", "Brown":"|321", "Sienna":"|420",
"Yellow":"|551", "Gold":"|540", "Dandelion":"|553",
"Green":"|141", "Lime":"|350", "Forest":"|032",
"Blue":"|115", "Cyan":"|155", "Navy":"|113",
"Purple":"|415", "Lavender":"|535", "Fuchsia":"|503"}
# I know this probably isn't the best way to do this. It's just an example!
if selection == "Remove name color": # Player chose to remove their name color
caller.key = caller.db.uncolored_name
caller.msg("Name color removed.")
elif selection in colordict:
newcolor = colordict[selection] # Retrieve color code based on menu selection
caller.key = newcolor + caller.db.uncolored_name + "|n" # Add color code to caller's name
caller.msg(newcolor + ("Name color changed to %s!" % selection) + "|n")

View file

@ -0,0 +1,42 @@
# Turn based battle system framework
Contrib - Tim Ashley Jenkins 2017
This is a framework for a simple turn-based combat system, similar
to those used in D&D-style tabletop role playing games. It allows
any character to start a fight in a room, at which point initiative
is rolled and a turn order is established. Each participant in combat
has a limited time to decide their action for that turn (30 seconds by
default), and combat progresses through the turn order, looping through
the participants until the fight ends.
This folder contains multiple examples of how such a system can be
implemented and customized:
tb_basic.py - The simplest system, which implements initiative and turn
order, attack rolls against defense values, and damage to hit
points. Only very basic game mechanics are included.
tb_equip.py - Adds weapons and armor to the basic implementation of
the battle system, including commands for wielding weapons and
donning armor, and modifiers to accuracy and damage based on
currently used equipment.
tb_range.py - Adds a system for abstract positioning and movement, which
tracks the distance between different characters and objects in
combat, as well as differentiates between melee and ranged
attacks.
This system is meant as a basic framework to start from, and is modeled
after the combat systems of popular tabletop role playing games rather than
the real-time battle systems that many MMOs and some MUDs use. As such, it
may be better suited to role-playing or more story-oriented games, or games
meant to closely emulate the experience of playing a tabletop RPG.
Each of these modules contains the full functionality of the battle system
with different customizations added in - the instructions to install each
one is contained in the module itself. It's recommended that you install
and test tb_basic first, so you can better understand how the other
modules expand on it and get a better idea of how you can customize the
system to your liking and integrate the subsystems presented here into
your own combat system.

View file

@ -0,0 +1 @@

View file

@ -16,26 +16,26 @@ is easily extensible and can be used as the foundation for implementing
the rules from your turn-based tabletop game of choice or making your
own battle system.
To install and test, import this module's BattleCharacter object into
To install and test, import this module's TBBasicCharacter object into
your game's character.py module:
from evennia.contrib.turnbattle import BattleCharacter
from evennia.contrib.turnbattle.tb_basic import TBBasicCharacter
And change your game's character typeclass to inherit from BattleCharacter
And change your game's character typeclass to inherit from TBBasicCharacter
instead of the default:
class Character(BattleCharacter):
class Character(TBBasicCharacter):
Next, import this module into your default_cmdsets.py module:
from evennia.contrib import turnbattle
from evennia.contrib.turnbattle import tb_basic
And add the battle command set to your default command set:
#
# any commands you add below will overload the default ones.
#
self.add(turnbattle.BattleCmdSet())
self.add(tb_basic.BattleCmdSet())
This module is meant to be heavily expanded on, so you may want to copy it
to your game's 'world' folder and modify it there rather than importing it
@ -48,10 +48,18 @@ from evennia.commands.default.help import CmdHelp
"""
----------------------------------------------------------------------------
COMBAT FUNCTIONS START HERE
OPTIONS
----------------------------------------------------------------------------
"""
TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds
ACTIONS_PER_TURN = 1 # Number of actions allowed per turn
"""
----------------------------------------------------------------------------
COMBAT FUNCTIONS START HERE
----------------------------------------------------------------------------
"""
def roll_init(character):
"""
@ -167,6 +175,20 @@ def apply_damage(defender, damage):
if defender.db.hp <= 0:
defender.db.hp = 0
def at_defeat(defeated):
"""
Announces the defeat of a fighter in combat.
Args:
defeated (obj): Fighter that's been defeated.
Notes:
All this does is announce a defeat message by default, but if you
want anything else to happen to defeated fighters (like putting them
into a dying state or something similar) then this is the place to
do it.
"""
defeated.location.msg_contents("%s has been defeated!" % defeated)
def resolve_attack(attacker, defender, attack_value=None, defense_value=None):
"""
@ -195,10 +217,9 @@ def resolve_attack(attacker, defender, attack_value=None, defense_value=None):
# Announce damage dealt and apply damage.
attacker.location.msg_contents("%s hits %s for %i damage!" % (attacker, defender, damage_value))
apply_damage(defender, damage_value)
# If defender HP is reduced to 0 or less, announce defeat.
# If defender HP is reduced to 0 or less, call at_defeat.
if defender.db.hp <= 0:
attacker.location.msg_contents("%s has been defeated!" % defender)
at_defeat(defender)
def combat_cleanup(character):
"""
@ -226,9 +247,7 @@ def is_in_combat(character):
Returns:
(bool): True if in combat or False if not in combat
"""
if character.db.Combat_TurnHandler:
return True
return False
return bool(character.db.combat_turnhandler)
def is_turn(character):
@ -241,11 +260,9 @@ def is_turn(character):
Returns:
(bool): True if it is their turn or False otherwise
"""
turnhandler = character.db.Combat_TurnHandler
turnhandler = character.db.combat_turnhandler
currentchar = turnhandler.db.fighters[turnhandler.db.turn]
if character == currentchar:
return True
return False
return bool(character == currentchar)
def spend_action(character, actions, action_name=None):
@ -261,14 +278,14 @@ def spend_action(character, actions, action_name=None):
combat to provided string
"""
if action_name:
character.db.Combat_LastAction = action_name
character.db.combat_lastaction = action_name
if actions == 'all': # If spending all actions
character.db.Combat_ActionsLeft = 0 # Set actions to 0
character.db.combat_actionsleft = 0 # Set actions to 0
else:
character.db.Combat_ActionsLeft -= actions # Use up actions.
if character.db.Combat_ActionsLeft < 0:
character.db.Combat_ActionsLeft = 0 # Can't have fewer than 0 actions
character.db.Combat_TurnHandler.turn_end_check(character) # Signal potential end of turn.
character.db.combat_actionsleft -= actions # Use up actions.
if character.db.combat_actionsleft < 0:
character.db.combat_actionsleft = 0 # Can't have fewer than 0 actions
character.db.combat_turnhandler.turn_end_check(character) # Signal potential end of turn.
"""
@ -278,7 +295,7 @@ CHARACTER TYPECLASS
"""
class BattleCharacter(DefaultCharacter):
class TBBasicCharacter(DefaultCharacter):
"""
A character able to participate in turn-based combat. Has attributes for current
and maximum HP, and access to combat commands.
@ -324,7 +341,182 @@ class BattleCharacter(DefaultCharacter):
return False
return True
"""
----------------------------------------------------------------------------
SCRIPTS START HERE
----------------------------------------------------------------------------
"""
class TBBasicTurnHandler(DefaultScript):
"""
This is the script that handles the progression of combat through turns.
On creation (when a fight is started) it adds all combat-ready characters
to its roster and then sorts them into a turn order. There can only be one
fight going on in a single room at a time, so the script is assigned to a
room as its object.
Fights persist until only one participant is left with any HP or all
remaining participants choose to end the combat with the 'disengage' command.
"""
def at_script_creation(self):
"""
Called once, when the script is created.
"""
self.key = "Combat Turn Handler"
self.interval = 5 # Once every 5 seconds
self.persistent = True
self.db.fighters = []
# Add all fighters in the room with at least 1 HP to the combat."
for thing in self.obj.contents:
if thing.db.hp:
self.db.fighters.append(thing)
# Initialize each fighter for combat
for fighter in self.db.fighters:
self.initialize_for_combat(fighter)
# Add a reference to this script to the room
self.obj.db.combat_turnhandler = self
# Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order.
# The initiative roll is determined by the roll_init function and can be customized easily.
ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True)
self.db.fighters = ordered_by_roll
# Announce the turn order.
self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters))
# Start first fighter's turn.
self.start_turn(self.db.fighters[0])
# Set up the current turn and turn timeout delay.
self.db.turn = 0
self.db.timer = TURN_TIMEOUT # Set timer to turn timeout specified in options
def at_stop(self):
"""
Called at script termination.
"""
for fighter in self.db.fighters:
combat_cleanup(fighter) # Clean up the combat attributes for every fighter.
self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location
def at_repeat(self):
"""
Called once every self.interval seconds.
"""
currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order.
self.db.timer -= self.interval # Count down the timer.
if self.db.timer <= 0:
# Force current character to disengage if timer runs out.
self.obj.msg_contents("%s's turn timed out!" % currentchar)
spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions.
return
elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left
# Warn the current character if they're about to time out.
currentchar.msg("WARNING: About to time out!")
self.db.timeout_warning_given = True
def initialize_for_combat(self, character):
"""
Prepares a character for combat when starting or entering a fight.
Args:
character (obj): Character to initialize for combat.
"""
combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case.
character.db.combat_actionsleft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0
character.db.combat_turnhandler = self # Add a reference to this turn handler script to the character
character.db.combat_lastaction = "null" # Track last action taken in combat
def start_turn(self, character):
"""
Readies a character for the start of their turn by replenishing their
available actions and notifying them that their turn has come up.
Args:
character (obj): Character to be readied.
Notes:
Here, you only get one action per turn, but you might want to allow more than
one per turn, or even grant a number of actions based on a character's
attributes. You can even add multiple different kinds of actions, I.E. actions
separated for movement, by adding "character.db.combat_movesleft = 3" or
something similar.
"""
character.db.combat_actionsleft = ACTIONS_PER_TURN # Replenish actions
# Prompt the character for their turn and give some information.
character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp)
def next_turn(self):
"""
Advances to the next character in the turn order.
"""
# Check to see if every character disengaged as their last action. If so, end combat.
disengage_check = True
for fighter in self.db.fighters:
if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage
disengage_check = False
if disengage_check: # All characters have disengaged
self.obj.msg_contents("All fighters have disengaged! Combat is over!")
self.stop() # Stop this script and end combat.
return
# Check to see if only one character is left standing. If so, end combat.
defeated_characters = 0
for fighter in self.db.fighters:
if fighter.db.HP == 0:
defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated)
if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated
for fighter in self.db.fighters:
if fighter.db.HP != 0:
LastStanding = fighter # Pick the one fighter left with HP remaining
self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding)
self.stop() # Stop this script and end combat.
return
# Cycle to the next turn.
currentchar = self.db.fighters[self.db.turn]
self.db.turn += 1 # Go to the next in the turn order.
if self.db.turn > len(self.db.fighters) - 1:
self.db.turn = 0 # Go back to the first in the turn order once you reach the end.
newchar = self.db.fighters[self.db.turn] # Note the new character
self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer.
self.db.timeout_warning_given = False # Reset the timeout warning.
self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar))
self.start_turn(newchar) # Start the new character's turn.
def turn_end_check(self, character):
"""
Tests to see if a character's turn is over, and cycles to the next turn if it is.
Args:
character (obj): Character to test for end of turn
"""
if not character.db.combat_actionsleft: # Character has no actions remaining
self.next_turn()
return
def join_fight(self, character):
"""
Adds a new character to a fight already in progress.
Args:
character (obj): Character to be added to the fight.
"""
# Inserts the fighter to the turn order, right behind whoever's turn it currently is.
self.db.fighters.insert(self.db.turn, character)
# Tick the turn counter forward one to compensate.
self.db.turn += 1
# Initialize the character like you do at the start.
self.initialize_for_combat(character)
"""
----------------------------------------------------------------------------
COMMANDS START HERE
@ -365,13 +557,13 @@ class CmdFight(Command):
if len(fighters) <= 1: # If you're the only able fighter in the room
self.caller.msg("There's nobody here to fight!")
return
if here.db.Combat_TurnHandler: # If there's already a fight going on...
if here.db.combat_turnhandler: # If there's already a fight going on...
here.msg_contents("%s joins the fight!" % self.caller)
here.db.Combat_TurnHandler.join_fight(self.caller) # Join the fight!
here.db.combat_turnhandler.join_fight(self.caller) # Join the fight!
return
here.msg_contents("%s starts a fight!" % self.caller)
# Add a turn handler script to the room, which starts combat.
here.scripts.add("contrib.turnbattle.TurnHandler")
here.scripts.add("contrib.turnbattle.tb_basic.TBBasicTurnHandler")
# Remember you'll have to change the path to the script if you copy this code to your own modules!
@ -559,177 +751,4 @@ class BattleCmdSet(default_cmds.CharacterCmdSet):
self.add(CmdRest())
self.add(CmdPass())
self.add(CmdDisengage())
self.add(CmdCombatHelp())
"""
----------------------------------------------------------------------------
SCRIPTS START HERE
----------------------------------------------------------------------------
"""
class TurnHandler(DefaultScript):
"""
This is the script that handles the progression of combat through turns.
On creation (when a fight is started) it adds all combat-ready characters
to its roster and then sorts them into a turn order. There can only be one
fight going on in a single room at a time, so the script is assigned to a
room as its object.
Fights persist until only one participant is left with any HP or all
remaining participants choose to end the combat with the 'disengage' command.
"""
def at_script_creation(self):
"""
Called once, when the script is created.
"""
self.key = "Combat Turn Handler"
self.interval = 5 # Once every 5 seconds
self.persistent = True
self.db.fighters = []
# Add all fighters in the room with at least 1 HP to the combat."
for object in self.obj.contents:
if object.db.hp:
self.db.fighters.append(object)
# Initialize each fighter for combat
for fighter in self.db.fighters:
self.initialize_for_combat(fighter)
# Add a reference to this script to the room
self.obj.db.Combat_TurnHandler = self
# Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order.
# The initiative roll is determined by the roll_init function and can be customized easily.
ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True)
self.db.fighters = ordered_by_roll
# Announce the turn order.
self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters))
# Set up the current turn and turn timeout delay.
self.db.turn = 0
self.db.timer = 30 # 30 seconds
def at_stop(self):
"""
Called at script termination.
"""
for fighter in self.db.fighters:
combat_cleanup(fighter) # Clean up the combat attributes for every fighter.
self.obj.db.Combat_TurnHandler = None # Remove reference to turn handler in location
def at_repeat(self):
"""
Called once every self.interval seconds.
"""
currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order.
self.db.timer -= self.interval # Count down the timer.
if self.db.timer <= 0:
# Force current character to disengage if timer runs out.
self.obj.msg_contents("%s's turn timed out!" % currentchar)
spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions.
return
elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left
# Warn the current character if they're about to time out.
currentchar.msg("WARNING: About to time out!")
self.db.timeout_warning_given = True
def initialize_for_combat(self, character):
"""
Prepares a character for combat when starting or entering a fight.
Args:
character (obj): Character to initialize for combat.
"""
combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case.
character.db.Combat_ActionsLeft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0
character.db.Combat_TurnHandler = self # Add a reference to this turn handler script to the character
character.db.Combat_LastAction = "null" # Track last action taken in combat
def start_turn(self, character):
"""
Readies a character for the start of their turn by replenishing their
available actions and notifying them that their turn has come up.
Args:
character (obj): Character to be readied.
Notes:
Here, you only get one action per turn, but you might want to allow more than
one per turn, or even grant a number of actions based on a character's
attributes. You can even add multiple different kinds of actions, I.E. actions
separated for movement, by adding "character.db.Combat_MovesLeft = 3" or
something similar.
"""
character.db.Combat_ActionsLeft = 1 # 1 action per turn.
# Prompt the character for their turn and give some information.
character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp)
def next_turn(self):
"""
Advances to the next character in the turn order.
"""
# Check to see if every character disengaged as their last action. If so, end combat.
disengage_check = True
for fighter in self.db.fighters:
if fighter.db.Combat_LastAction != "disengage": # If a character has done anything but disengage
disengage_check = False
if disengage_check: # All characters have disengaged
self.obj.msg_contents("All fighters have disengaged! Combat is over!")
self.stop() # Stop this script and end combat.
return
# Check to see if only one character is left standing. If so, end combat.
defeated_characters = 0
for fighter in self.db.fighters:
if fighter.db.HP == 0:
defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated)
if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated
for fighter in self.db.fighters:
if fighter.db.HP != 0:
LastStanding = fighter # Pick the one fighter left with HP remaining
self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding)
self.stop() # Stop this script and end combat.
return
# Cycle to the next turn.
currentchar = self.db.fighters[self.db.turn]
self.db.turn += 1 # Go to the next in the turn order.
if self.db.turn > len(self.db.fighters) - 1:
self.db.turn = 0 # Go back to the first in the turn order once you reach the end.
newchar = self.db.fighters[self.db.turn] # Note the new character
self.db.timer = 30 + self.time_until_next_repeat() # Reset the timer.
self.db.timeout_warning_given = False # Reset the timeout warning.
self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar))
self.start_turn(newchar) # Start the new character's turn.
def turn_end_check(self, character):
"""
Tests to see if a character's turn is over, and cycles to the next turn if it is.
Args:
character (obj): Character to test for end of turn
"""
if not character.db.Combat_ActionsLeft: # Character has no actions remaining
self.next_turn()
return
def join_fight(self, character):
"""
Adds a new character to a fight already in progress.
Args:
character (obj): Character to be added to the fight.
"""
# Inserts the fighter to the turn order, right behind whoever's turn it currently is.
self.db.fighters.insert(self.db.turn, character)
# Tick the turn counter forward one to compensate.
self.db.turn += 1
# Initialize the character like you do at the start.
self.initialize_for_combat(character)
self.add(CmdCombatHelp())

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -34,27 +34,6 @@ from evennia.settings_default import *
# This is the name of your game. Make it catchy!
SERVERNAME = {servername}
# Server ports. If enabled and marked as "visible", the port
# should be visible to the outside world on a production server.
# Note that there are many more options available beyond these.
# Telnet ports. Visible.
TELNET_ENABLED = True
TELNET_PORTS = [4000]
# (proxy, internal). Only proxy should be visible.
WEBSERVER_ENABLED = True
WEBSERVER_PORTS = [(4001, 4002)]
# Telnet+SSL ports, for supporting clients. Visible.
SSL_ENABLED = False
SSL_PORTS = [4003]
# SSH client ports. Requires crypto lib. Visible.
SSH_ENABLED = False
SSH_PORTS = [4004]
# Websocket-client port. Visible.
WEBSOCKET_CLIENT_ENABLED = True
WEBSOCKET_CLIENT_PORT = 4005
# Internal Server-Portal port. Not visible.
AMP_PORT = 4006
######################################################################
# Settings given in secret_settings.py override those in this file.
@ -62,4 +41,4 @@ AMP_PORT = 4006
try:
from server.conf.secret_settings import *
except ImportError:
print "secret_settings.py file not found or failed to import."
print("secret_settings.py file not found or failed to import.")

View file

@ -6,8 +6,10 @@ entities.
"""
import time
import inflect
from builtins import object
from future.utils import with_metaclass
from collections import defaultdict
from django.conf import settings
@ -22,9 +24,11 @@ from evennia.commands import cmdhandler
from evennia.utils import search
from evennia.utils import logger
from evennia.utils.utils import (variable_from_module, lazy_property,
make_iter, to_unicode, is_iter, to_str)
make_iter, to_unicode, is_iter, list_to_string,
to_str)
from django.utils.translation import ugettext as _
_INFLECT = inflect.engine()
_MULTISESSION_MODE = settings.MULTISESSION_MODE
_ScriptDB = None
@ -289,9 +293,39 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
return "{}(#{})".format(self.name, self.id)
return self.name
def get_numbered_name(self, count, looker, **kwargs):
"""
Return the numbered (singular, plural) forms of this object's key. This is by default called
by return_appearance and is used for grouping multiple same-named of this object. Note that
this will be called on *every* member of a group even though the plural name will be only
shown once. Also the singular display version, such as 'an apple', 'a tree' is determined
from this method.
Args:
count (int): Number of objects of this type
looker (Object): Onlooker. Not used by default.
Kwargs:
key (str): Optional key to pluralize, 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)
plural = _INFLECT.plural(key, 2)
plural = "%s %s" % (_INFLECT.number_to_words(count, threshold=12), plural)
singular = _INFLECT.an(key)
if not self.aliases.get(plural, category="plural_key"):
# we need to wipe any old plurals/an/a in case key changed in the interrim
self.aliases.clear(category="plural_key")
self.aliases.add(plural, category="plural_key")
# save the singular form as an alias here too so we can display "an egg" and also
# look at 'an egg'.
self.aliases.add(singular, category="plural_key")
return singular, plural
def search(self, searchdata,
global_search=False,
use_nicks=True, # should this default to off?
use_nicks=True,
typeclass=None,
location=None,
attribute_name=None,
@ -1448,7 +1482,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
# get and identify all objects
visible = (con for con in self.contents if con != looker and
con.access(looker, "view"))
exits, users, things = [], [], []
exits, users, things = [], [], defaultdict(list)
for con in visible:
key = con.get_display_name(looker)
if con.destination:
@ -1456,16 +1490,28 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
elif con.has_account:
users.append("|c%s|n" % key)
else:
things.append(key)
# things can be pluralized
things[key].append(con)
# get description, build string
string = "|c%s|n\n" % self.get_display_name(looker)
desc = self.db.desc
if desc:
string += "%s" % desc
if exits:
string += "\n|wExits:|n " + ", ".join(exits)
string += "\n|wExits:|n " + list_to_string(exits)
if users or things:
string += "\n|wYou see:|n " + ", ".join(users + things)
# handle pluralization of things (never pluralize users)
thing_strings = []
for key, itemlist in sorted(things.iteritems()):
nitem = len(itemlist)
if nitem == 1:
key, _ = itemlist[0].get_numbered_name(nitem, looker, key=key)
else:
key = [item.get_numbered_name(nitem, looker, key=key)[1] for item in itemlist][0]
thing_strings.append(key)
string += "\n|wYou see:|n " + list_to_string(users + thing_strings)
return string
def at_look(self, target, **kwargs):
@ -1512,6 +1558,25 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
"""
pass
def at_before_get(self, getter, **kwargs):
"""
Called by the default `get` command before this object has been
picked up.
Args:
getter (Object): The object about to get this object.
**kwargs (dict): Arbitrary, optional arguments for users
overriding the call (unused by default).
Returns:
shouldget (bool): If the object should be gotten or not.
Notes:
If this method returns False/None, the getting is cancelled
before it is even started.
"""
return True
def at_get(self, getter, **kwargs):
"""
Called by the default `get` command when this object has been
@ -1524,11 +1589,32 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
Notes:
This hook cannot stop the pickup from happening. Use
permissions for that.
permissions or the at_before_get() hook for that.
"""
pass
def at_before_give(self, giver, getter, **kwargs):
"""
Called by the default `give` command before this object has been
given.
Args:
giver (Object): The object about to give this object.
getter (Object): The object about to get this object.
**kwargs (dict): Arbitrary, optional arguments for users
overriding the call (unused by default).
Returns:
shouldgive (bool): If the object should be given or not.
Notes:
If this method returns False/None, the giving is cancelled
before it is even started.
"""
return True
def at_give(self, giver, getter, **kwargs):
"""
Called by the default `give` command when this object has been
@ -1542,11 +1628,31 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
Notes:
This hook cannot stop the give from happening. Use
permissions for that.
permissions or the at_before_give() hook for that.
"""
pass
def at_before_drop(self, dropper, **kwargs):
"""
Called by the default `drop` command before this object has been
dropped.
Args:
dropper (Object): The object which will drop this object.
**kwargs (dict): Arbitrary, optional arguments for users
overriding the call (unused by default).
Returns:
shoulddrop (bool): If the object should be dropped or not.
Notes:
If this method returns False/None, the dropping is cancelled
before it is even started.
"""
return True
def at_drop(self, dropper, **kwargs):
"""
Called by the default `drop` command when this object has been
@ -1559,7 +1665,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
Notes:
This hook cannot stop the drop from happening. Use
permissions from that.
permissions or the at_before_drop() hook for that.
"""
pass
@ -1639,11 +1745,11 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
# whisper mode
msg_type = 'whisper'
msg_self = '{self} whisper to {all_receivers}, "{speech}"' if msg_self is True else msg_self
msg_receivers = '{object} whispers: "{speech}"'
msg_receivers = msg_receivers or '{object} whispers: "{speech}"'
msg_location = None
else:
msg_self = '{self} say, "{speech}"' if msg_self is True else msg_self
msg_receivers = None
msg_location = msg_location or '{object} says, "{speech}"'
custom_mapping = kwargs.get('mapping', {})
@ -1689,9 +1795,14 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
"receiver": None,
"speech": message}
location_mapping.update(custom_mapping)
exclude = []
if msg_self:
exclude.append(self)
if receivers:
exclude.extend(receivers)
self.location.msg_contents(text=(msg_location, {"type": msg_type}),
from_obj=self,
exclude=(self, ) if msg_self else None,
exclude=exclude,
mapping=location_mapping)

View file

@ -214,7 +214,7 @@ class ScriptDBManager(TypedObjectManager):
VALIDATE_ITERATION -= 1
return nr_started, nr_stopped
def search_script(self, ostring, obj=None, only_timed=False):
def search_script(self, ostring, obj=None, only_timed=False, typeclass=None):
"""
Search for a particular script.
@ -224,6 +224,7 @@ class ScriptDBManager(TypedObjectManager):
this object
only_timed (bool): Limit search only to scripts that run
on a timer.
typeclass (class or str): Typeclass or path to typeclass.
"""
@ -237,10 +238,17 @@ class ScriptDBManager(TypedObjectManager):
(only_timed and dbref_match.interval)):
return [dbref_match]
if typeclass:
if callable(typeclass):
typeclass = u"%s.%s" % (typeclass.__module__, typeclass.__name__)
else:
typeclass = u"%s" % typeclass
# not a dbref; normal search
obj_restriction = obj and Q(db_obj=obj) or Q()
timed_restriction = only_timed and Q(interval__gt=0) or Q()
scripts = self.filter(timed_restriction & obj_restriction & Q(db_key__iexact=ostring))
timed_restriction = only_timed and Q(db_interval__gt=0) or Q()
typeclass_restriction = typeclass and Q(db_typeclass_path=typeclass) or Q()
scripts = self.filter(timed_restriction & obj_restriction & typeclass_restriction & Q(db_key__iexact=ostring))
return scripts
# back-compatibility alias
script_search = search_script

View file

@ -1,670 +0,0 @@
"""
Contains the protocols, commands, and client factory needed for the Server
and Portal to communicate with each other, letting Portal work as a proxy.
Both sides use this same protocol.
The separation works like this:
Portal - (AMP client) handles protocols. It contains a list of connected
sessions in a dictionary for identifying the respective account
connected. If it loses the AMP connection it will automatically
try to reconnect.
Server - (AMP server) Handles all mud operations. The server holds its own list
of sessions tied to account objects. This is synced against the portal
at startup and when a session connects/disconnects
"""
from __future__ import print_function
# imports needed on both server and portal side
import os
import time
from collections import defaultdict, namedtuple
from itertools import count
from cStringIO import StringIO
try:
import cPickle as pickle
except ImportError:
import pickle
from twisted.protocols import amp
from twisted.internet import protocol
from twisted.internet.defer import Deferred
from evennia.utils import logger
from evennia.utils.utils import to_str, variable_from_module
import zlib # Used in Compressed class
DUMMYSESSION = namedtuple('DummySession', ['sessid'])(0)
# communication bits
# (chr(9) and chr(10) are \t and \n, so skipping them)
PCONN = chr(1) # portal session connect
PDISCONN = chr(2) # portal session disconnect
PSYNC = chr(3) # portal session sync
SLOGIN = chr(4) # server session login
SDISCONN = chr(5) # server session disconnect
SDISCONNALL = chr(6) # server session disconnect all
SSHUTD = chr(7) # server shutdown
SSYNC = chr(8) # server session sync
SCONN = chr(11) # server creating new connection (for irc bots and etc)
PCONNSYNC = chr(12) # portal post-syncing a session
PDISCONNALL = chr(13) # portal session disconnect all
AMP_MAXLEN = amp.MAX_VALUE_LENGTH # max allowed data length in AMP protocol (cannot be changed)
BATCH_RATE = 250 # max commands/sec before switching to batch-sending
BATCH_TIMEOUT = 0.5 # how often to poll to empty batch queue, in seconds
# buffers
_SENDBATCH = defaultdict(list)
_MSGBUFFER = defaultdict(list)
def get_restart_mode(restart_file):
"""
Parse the server/portal restart status
Args:
restart_file (str): Path to restart.dat file.
Returns:
restart_mode (bool): If the file indicates the server is in
restart mode or not.
"""
if os.path.exists(restart_file):
flag = open(restart_file, 'r').read()
return flag == "True"
return False
class AmpServerFactory(protocol.ServerFactory):
"""
This factory creates the Server as a new AMPProtocol instance for accepting
connections from the Portal.
"""
noisy = False
def __init__(self, server):
"""
Initialize the factory.
Args:
server (Server): The Evennia server service instance.
protocol (Protocol): The protocol the factory creates
instances of.
"""
self.server = server
self.protocol = AMPProtocol
def buildProtocol(self, addr):
"""
Start a new connection, and store it on the service object.
Args:
addr (str): Connection address. Not used.
Returns:
protocol (Protocol): The created protocol.
"""
self.server.amp_protocol = AMPProtocol()
self.server.amp_protocol.factory = self
return self.server.amp_protocol
class AmpClientFactory(protocol.ReconnectingClientFactory):
"""
This factory creates an instance of the Portal, an AMPProtocol
instances to use to connect
"""
# Initial reconnect delay in seconds.
initialDelay = 1
factor = 1.5
maxDelay = 1
noisy = False
def __init__(self, portal):
"""
Initializes the client factory.
Args:
portal (Portal): Portal instance.
"""
self.portal = portal
self.protocol = AMPProtocol
def startedConnecting(self, connector):
"""
Called when starting to try to connect to the MUD server.
Args:
connector (Connector): Twisted Connector instance representing
this connection.
"""
pass
def buildProtocol(self, addr):
"""
Creates an AMPProtocol instance when connecting to the server.
Args:
addr (str): Connection address. Not used.
"""
self.resetDelay()
self.portal.amp_protocol = AMPProtocol()
self.portal.amp_protocol.factory = self
return self.portal.amp_protocol
def clientConnectionLost(self, connector, reason):
"""
Called when the AMP connection to the MUD server is lost.
Args:
connector (Connector): Twisted Connector instance representing
this connection.
reason (str): Eventual text describing why connection was lost.
"""
if hasattr(self, "server_restart_mode"):
self.portal.sessions.announce_all(" Server restarting ...")
self.maxDelay = 2
else:
# Don't translate this; avoid loading django on portal side.
self.maxDelay = 10
self.portal.sessions.announce_all(" ... Portal lost connection to Server.")
protocol.ReconnectingClientFactory.clientConnectionLost(self, connector, reason)
def clientConnectionFailed(self, connector, reason):
"""
Called when an AMP connection attempt to the MUD server fails.
Args:
connector (Connector): Twisted Connector instance representing
this connection.
reason (str): Eventual text describing why connection failed.
"""
if hasattr(self, "server_restart_mode"):
self.maxDelay = 2
else:
self.maxDelay = 10
self.portal.sessions.announce_all(" ...")
protocol.ReconnectingClientFactory.clientConnectionFailed(self, connector, reason)
# AMP Communication Command types
class Compressed(amp.String):
"""
This is a customn AMP command Argument that both handles too-long
sends as well as uses zlib for compression across the wire. The
batch-grouping of too-long sends is borrowed from the "mediumbox"
recipy at twisted-hacks's ~glyph/+junk/amphacks/mediumbox.
"""
def fromBox(self, name, strings, objects, proto):
"""
Converts from box representation to python. We
group very long data into batches.
"""
value = StringIO()
value.write(strings.get(name))
for counter in count(2):
# count from 2 upwards
chunk = strings.get("%s.%d" % (name, counter))
if chunk is None:
break
value.write(chunk)
objects[name] = value.getvalue()
def toBox(self, name, strings, objects, proto):
"""
Convert from data to box. We handled too-long
batched data and put it together here.
"""
value = StringIO(objects[name])
strings[name] = value.read(AMP_MAXLEN)
for counter in count(2):
chunk = value.read(AMP_MAXLEN)
if not chunk:
break
strings["%s.%d" % (name, counter)] = chunk
def toString(self, inObject):
"""
Convert to send on the wire, with compression.
"""
return zlib.compress(inObject, 9)
def fromString(self, inString):
"""
Convert (decompress) from the wire to Python.
"""
return zlib.decompress(inString)
class MsgPortal2Server(amp.Command):
"""
Message Portal -> Server
"""
key = "MsgPortal2Server"
arguments = [('packed_data', Compressed())]
errors = {Exception: 'EXCEPTION'}
response = []
class MsgServer2Portal(amp.Command):
"""
Message Server -> Portal
"""
key = "MsgServer2Portal"
arguments = [('packed_data', Compressed())]
errors = {Exception: 'EXCEPTION'}
response = []
class AdminPortal2Server(amp.Command):
"""
Administration Portal -> Server
Sent when the portal needs to perform admin operations on the
server, such as when a new session connects or resyncs
"""
key = "AdminPortal2Server"
arguments = [('packed_data', Compressed())]
errors = {Exception: 'EXCEPTION'}
response = []
class AdminServer2Portal(amp.Command):
"""
Administration Server -> Portal
Sent when the server needs to perform admin operations on the
portal.
"""
key = "AdminServer2Portal"
arguments = [('packed_data', Compressed())]
errors = {Exception: 'EXCEPTION'}
response = []
class FunctionCall(amp.Command):
"""
Bidirectional Server <-> Portal
Sent when either process needs to call an arbitrary function in
the other. This does not use the batch-send functionality.
"""
key = "FunctionCall"
arguments = [('module', amp.String()),
('function', amp.String()),
('args', amp.String()),
('kwargs', amp.String())]
errors = {Exception: 'EXCEPTION'}
response = [('result', amp.String())]
# Helper functions for pickling.
def dumps(data):
return to_str(pickle.dumps(to_str(data), pickle.HIGHEST_PROTOCOL))
def loads(data):
return pickle.loads(to_str(data))
# -------------------------------------------------------------
# Core AMP protocol for communication Server <-> Portal
# -------------------------------------------------------------
class AMPProtocol(amp.AMP):
"""
This is the protocol that the MUD server and the proxy server
communicate to each other with. AMP is a bi-directional protocol,
so both the proxy and the MUD use the same commands and protocol.
AMP specifies responder methods here and connect them to
amp.Command subclasses that specify the datatypes of the
input/output of these methods.
"""
# helper methods
def __init__(self, *args, **kwargs):
"""
Initialize protocol with some things that need to be in place
already before connecting both on portal and server.
"""
self.send_batch_counter = 0
self.send_reset_time = time.time()
self.send_mode = True
self.send_task = None
def connectionMade(self):
"""
This is called when an AMP connection is (re-)established
between server and portal. AMP calls it on both sides, so we
need to make sure to only trigger resync from the portal side.
"""
# this makes for a factor x10 faster sends across the wire
self.transport.setTcpNoDelay(True)
if hasattr(self.factory, "portal"):
# only the portal has the 'portal' property, so we know we are
# on the portal side and can initialize the connection.
sessdata = self.factory.portal.sessions.get_all_sync_data()
self.send_AdminPortal2Server(DUMMYSESSION,
PSYNC,
sessiondata=sessdata)
self.factory.portal.sessions.at_server_connection()
if hasattr(self.factory, "server_restart_mode"):
del self.factory.server_restart_mode
def connectionLost(self, reason):
"""
We swallow connection errors here. The reason is that during a
normal reload/shutdown there will almost always be cases where
either the portal or server shuts down before a message has
returned its (empty) return, triggering a connectionLost error
that is irrelevant. If a true connection error happens, the
portal will continuously try to reconnect, showing the problem
that way.
"""
pass
# Error handling
def errback(self, e, info):
"""
Error callback.
Handles errors to avoid dropping connections on server tracebacks.
Args:
e (Failure): Deferred error instance.
info (str): Error string.
"""
e.trap(Exception)
logger.log_err("AMP Error for %(info)s: %(e)s" % {'info': info,
'e': e.getErrorMessage()})
def send_data(self, command, sessid, **kwargs):
"""
Send data across the wire.
Args:
command (AMP Command): A protocol send command.
sessid (int): A unique Session id.
Returns:
deferred (deferred or None): A deferred with an errback.
Notes:
Data will be sent across the wire pickled as a tuple
(sessid, kwargs).
"""
return self.callRemote(command,
packed_data=dumps((sessid, kwargs))
).addErrback(self.errback, command.key)
# Message definition + helper methods to call/create each message type
# Portal -> Server Msg
@MsgPortal2Server.responder
def server_receive_msgportal2server(self, packed_data):
"""
Receives message arriving to server. This method is executed
on the Server.
Args:
packed_data (str): Data to receive (a pickled tuple (sessid,kwargs))
"""
sessid, kwargs = loads(packed_data)
session = self.factory.server.sessions.get(sessid, None)
if session:
self.factory.server.sessions.data_in(session, **kwargs)
return {}
def send_MsgPortal2Server(self, session, **kwargs):
"""
Access method called by the Portal and executed on the Portal.
Args:
session (session): Session
kwargs (any, optional): Optional data.
Returns:
deferred (Deferred): Asynchronous return.
"""
return self.send_data(MsgPortal2Server, session.sessid, **kwargs)
# Server -> Portal message
@MsgServer2Portal.responder
def portal_receive_server2portal(self, packed_data):
"""
Receives message arriving to Portal from Server.
This method is executed on the Portal.
Args:
packed_data (str): Pickled data (sessid, kwargs) coming over the wire.
"""
sessid, kwargs = loads(packed_data)
session = self.factory.portal.sessions.get(sessid, None)
if session:
self.factory.portal.sessions.data_out(session, **kwargs)
return {}
def send_MsgServer2Portal(self, session, **kwargs):
"""
Access method - executed on the Server for sending data
to Portal.
Args:
session (Session): Unique Session.
kwargs (any, optiona): Extra data.
"""
return self.send_data(MsgServer2Portal, session.sessid, **kwargs)
# Server administration from the Portal side
@AdminPortal2Server.responder
def server_receive_adminportal2server(self, packed_data):
"""
Receives admin data from the Portal (allows the portal to
perform admin operations on the server). This is executed on
the Server.
Args:
packed_data (str): Incoming, pickled data.
"""
sessid, kwargs = loads(packed_data)
operation = kwargs.pop("operation", "")
server_sessionhandler = self.factory.server.sessions
if operation == PCONN: # portal_session_connect
# create a new session and sync it
server_sessionhandler.portal_connect(kwargs.get("sessiondata"))
elif operation == PCONNSYNC: # portal_session_sync
server_sessionhandler.portal_session_sync(kwargs.get("sessiondata"))
elif operation == PDISCONN: # portal_session_disconnect
# session closed from portal sid
session = server_sessionhandler.get(sessid)
if session:
server_sessionhandler.portal_disconnect(session)
elif operation == PDISCONNALL: # portal_disconnect_all
# portal orders all sessions to close
server_sessionhandler.portal_disconnect_all()
elif operation == PSYNC: # portal_session_sync
# force a resync of sessions when portal reconnects to
# server (e.g. after a server reboot) the data kwarg
# contains a dict {sessid: {arg1:val1,...}}
# representing the attributes to sync for each
# session.
server_sessionhandler.portal_sessions_sync(kwargs.get("sessiondata"))
else:
raise Exception("operation %(op)s not recognized." % {'op': operation})
return {}
def send_AdminPortal2Server(self, session, operation="", **kwargs):
"""
Send Admin instructions from the Portal to the Server.
Executed
on the Portal.
Args:
session (Session): Session.
operation (char, optional): Identifier for the server operation, as defined by the
global variables in `evennia/server/amp.py`.
data (str or dict, optional): Data used in the administrative operation.
"""
return self.send_data(AdminPortal2Server, session.sessid, operation=operation, **kwargs)
# Portal administration from the Server side
@AdminServer2Portal.responder
def portal_receive_adminserver2portal(self, packed_data):
"""
Receives and handles admin operations sent to the Portal
This is executed on the Portal.
Args:
packed_data (str): Data received, a pickled tuple (sessid, kwargs).
"""
sessid, kwargs = loads(packed_data)
operation = kwargs.pop("operation")
portal_sessionhandler = self.factory.portal.sessions
if operation == SLOGIN: # server_session_login
# a session has authenticated; sync it.
session = portal_sessionhandler.get(sessid)
if session:
portal_sessionhandler.server_logged_in(session, kwargs.get("sessiondata"))
elif operation == SDISCONN: # server_session_disconnect
# the server is ordering to disconnect the session
session = portal_sessionhandler.get(sessid)
if session:
portal_sessionhandler.server_disconnect(session, reason=kwargs.get("reason"))
elif operation == SDISCONNALL: # server_session_disconnect_all
# server orders all sessions to disconnect
portal_sessionhandler.server_disconnect_all(reason=kwargs.get("reason"))
elif operation == SSHUTD: # server_shutdown
# the server orders the portal to shut down
self.factory.portal.shutdown(restart=False)
elif operation == SSYNC: # server_session_sync
# server wants to save session data to the portal,
# maybe because it's about to shut down.
portal_sessionhandler.server_session_sync(kwargs.get("sessiondata"),
kwargs.get("clean", True))
# set a flag in case we are about to shut down soon
self.factory.server_restart_mode = True
elif operation == SCONN: # server_force_connection (for irc/etc)
portal_sessionhandler.server_connect(**kwargs)
else:
raise Exception("operation %(op)s not recognized." % {'op': operation})
return {}
def send_AdminServer2Portal(self, session, operation="", **kwargs):
"""
Administrative access method called by the Server to send an
instruction to the Portal.
Args:
session (Session): Session.
operation (char, optional): Identifier for the server
operation, as defined by the global variables in
`evennia/server/amp.py`.
data (str or dict, optional): Data going into the adminstrative.
"""
return self.send_data(AdminServer2Portal, session.sessid, operation=operation, **kwargs)
# Extra functions
@FunctionCall.responder
def receive_functioncall(self, module, function, func_args, func_kwargs):
"""
This allows Portal- and Server-process to call an arbitrary
function in the other process. It is intended for use by
plugin modules.
Args:
module (str or module): The module containing the
`function` to call.
function (str): The name of the function to call in
`module`.
func_args (str): Pickled args tuple for use in `function` call.
func_kwargs (str): Pickled kwargs dict for use in `function` call.
"""
args = loads(func_args)
kwargs = loads(func_kwargs)
# call the function (don't catch tracebacks here)
result = variable_from_module(module, function)(*args, **kwargs)
if isinstance(result, Deferred):
# if result is a deferred, attach handler to properly
# wrap the return value
result.addCallback(lambda r: {"result": dumps(r)})
return result
else:
return {'result': dumps(result)}
def send_FunctionCall(self, modulepath, functionname, *args, **kwargs):
"""
Access method called by either process. This will call an arbitrary
function on the other process (On Portal if calling from Server and
vice versa).
Inputs:
modulepath (str) - python path to module holding function to call
functionname (str) - name of function in given module
*args, **kwargs will be used as arguments/keyword args for the
remote function call
Returns:
A deferred that fires with the return value of the remote
function call
"""
return self.callRemote(FunctionCall,
module=modulepath,
function=functionname,
args=dumps(args),
kwargs=dumps(kwargs)).addCallback(
lambda r: loads(r["result"])).addErrback(self.errback, "FunctionCall")

View file

@ -0,0 +1,239 @@
"""
The Evennia Server service acts as an AMP-client when talking to the
Portal. This module sets up the Client-side communication.
"""
import os
from evennia.server.portal import amp
from twisted.internet import protocol
from evennia.utils import logger
class AMPClientFactory(protocol.ReconnectingClientFactory):
"""
This factory creates an instance of an AMP client connection. This handles communication from
the be the Evennia 'Server' service to the 'Portal'. The client will try to auto-reconnect on a
connection error.
"""
# Initial reconnect delay in seconds.
initialDelay = 1
factor = 1.5
maxDelay = 1
noisy = False
def __init__(self, server):
"""
Initializes the client factory.
Args:
server (server): server instance.
"""
self.server = server
self.protocol = AMPServerClientProtocol
self.maxDelay = 10
# not really used unless connecting to multiple servers, but
# avoids having to check for its existence on the protocol
self.broadcasts = []
def startedConnecting(self, connector):
"""
Called when starting to try to connect to the MUD server.
Args:
connector (Connector): Twisted Connector instance representing
this connection.
"""
pass
def buildProtocol(self, addr):
"""
Creates an AMPProtocol instance when connecting to the server.
Args:
addr (str): Connection address. Not used.
"""
self.resetDelay()
self.server.amp_protocol = AMPServerClientProtocol()
self.server.amp_protocol.factory = self
return self.server.amp_protocol
def clientConnectionLost(self, connector, reason):
"""
Called when the AMP connection to the MUD server is lost.
Args:
connector (Connector): Twisted Connector instance representing
this connection.
reason (str): Eventual text describing why connection was lost.
"""
logger.log_info("Server disconnected from the portal.")
protocol.ReconnectingClientFactory.clientConnectionLost(self, connector, reason)
def clientConnectionFailed(self, connector, reason):
"""
Called when an AMP connection attempt to the MUD server fails.
Args:
connector (Connector): Twisted Connector instance representing
this connection.
reason (str): Eventual text describing why connection failed.
"""
logger.log_msg("Attempting to reconnect to Portal ...")
protocol.ReconnectingClientFactory.clientConnectionFailed(self, connector, reason)
class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol):
"""
This protocol describes the Server service (acting as an AMP-client)'s communication with the
Portal (which acts as the AMP-server)
"""
# sending AMP data
def connectionMade(self):
"""
Called when a new connection is established.
"""
info_dict = self.factory.server.get_info_dict()
super(AMPServerClientProtocol, self).connectionMade()
# first thing we do is to request the Portal to sync all sessions
# back with the Server side. We also need the startup mode (reload, reset, shutdown)
self.send_AdminServer2Portal(
amp.DUMMYSESSION, operation=amp.PSYNC, spid=os.getpid(), info_dict=info_dict)
def data_to_portal(self, command, sessid, **kwargs):
"""
Send data across the wire to the Portal
Args:
command (AMP Command): A protocol send command.
sessid (int): A unique Session id.
kwargs (any): Any data to pickle into the command.
Returns:
deferred (deferred or None): A deferred with an errback.
Notes:
Data will be sent across the wire pickled as a tuple
(sessid, kwargs).
"""
return self.callRemote(command, packed_data=amp.dumps((sessid, kwargs))).addErrback(
self.errback, command.key)
def send_MsgServer2Portal(self, session, **kwargs):
"""
Access method - executed on the Server for sending data
to Portal.
Args:
session (Session): Unique Session.
kwargs (any, optiona): Extra data.
"""
return self.data_to_portal(amp.MsgServer2Portal, session.sessid, **kwargs)
def send_AdminServer2Portal(self, session, operation="", **kwargs):
"""
Administrative access method called by the Server to send an
instruction to the Portal.
Args:
session (Session): Session.
operation (char, optional): Identifier for the server
operation, as defined by the global variables in
`evennia/server/amp.py`.
kwargs (dict, optional): Data going into the adminstrative.
"""
return self.data_to_portal(amp.AdminServer2Portal, session.sessid,
operation=operation, **kwargs)
# receiving AMP data
@amp.MsgStatus.responder
def server_receive_status(self, question):
return {"status": "OK"}
@amp.MsgPortal2Server.responder
@amp.catch_traceback
def server_receive_msgportal2server(self, packed_data):
"""
Receives message arriving to server. This method is executed
on the Server.
Args:
packed_data (str): Data to receive (a pickled tuple (sessid,kwargs))
"""
sessid, kwargs = self.data_in(packed_data)
session = self.factory.server.sessions.get(sessid, None)
if session:
self.factory.server.sessions.data_in(session, **kwargs)
return {}
@amp.AdminPortal2Server.responder
@amp.catch_traceback
def server_receive_adminportal2server(self, packed_data):
"""
Receives admin data from the Portal (allows the portal to
perform admin operations on the server). This is executed on
the Server.
Args:
packed_data (str): Incoming, pickled data.
"""
sessid, kwargs = self.data_in(packed_data)
operation = kwargs.pop("operation", "")
server_sessionhandler = self.factory.server.sessions
if operation == amp.PCONN: # portal_session_connect
# create a new session and sync it
server_sessionhandler.portal_connect(kwargs.get("sessiondata"))
elif operation == amp.PCONNSYNC: # portal_session_sync
server_sessionhandler.portal_session_sync(kwargs.get("sessiondata"))
elif operation == amp.PDISCONN: # portal_session_disconnect
# session closed from portal sid
session = server_sessionhandler.get(sessid)
if session:
server_sessionhandler.portal_disconnect(session)
elif operation == amp.PDISCONNALL: # portal_disconnect_all
# portal orders all sessions to close
server_sessionhandler.portal_disconnect_all()
elif operation == amp.PSYNC: # portal_session_sync
# force a resync of sessions from the portal side. This happens on
# first server-connect.
server_restart_mode = kwargs.get("server_restart_mode", "shutdown")
self.factory.server.run_init_hooks(server_restart_mode)
server_sessionhandler.portal_sessions_sync(kwargs.get("sessiondata"))
elif operation == amp.SRELOAD: # server reload
# shut down in reload mode
server_sessionhandler.all_sessions_portal_sync()
server_sessionhandler.server.shutdown(mode='reload')
elif operation == amp.SRESET:
# shut down in reset mode
server_sessionhandler.all_sessions_portal_sync()
server_sessionhandler.server.shutdown(mode='reset')
elif operation == amp.SSHUTD: # server shutdown
# shutdown in stop mode
server_sessionhandler.server.shutdown(mode='shutdown')
else:
raise Exception("operation %(op)s not recognized." % {'op': operation})
return {}

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,418 @@
"""
The AMP (Asynchronous Message Protocol)-communication commands and constants used by Evennia.
This module acts as a central place for AMP-servers and -clients to get commands to use.
"""
from __future__ import print_function
from functools import wraps
import time
from twisted.protocols import amp
from collections import defaultdict, namedtuple
from cStringIO import StringIO
from itertools import count
import zlib # Used in Compressed class
try:
import cPickle as pickle
except ImportError:
import pickle
from twisted.internet.defer import DeferredList, Deferred
from evennia.utils.utils import to_str, variable_from_module
# delayed import
_LOGGER = None
# communication bits
# (chr(9) and chr(10) are \t and \n, so skipping them)
PCONN = chr(1) # portal session connect
PDISCONN = chr(2) # portal session disconnect
PSYNC = chr(3) # portal session sync
SLOGIN = chr(4) # server session login
SDISCONN = chr(5) # server session disconnect
SDISCONNALL = chr(6) # server session disconnect all
SSHUTD = chr(7) # server shutdown
SSYNC = chr(8) # server session sync
SCONN = chr(11) # server creating new connection (for irc bots and etc)
PCONNSYNC = chr(12) # portal post-syncing a session
PDISCONNALL = chr(13) # portal session disconnect all
SRELOAD = chr(14) # server shutdown in reload mode
SSTART = chr(15) # server start (portal must already be running anyway)
PSHUTD = chr(16) # portal (+server) shutdown
SSHUTD = chr(17) # server shutdown
PSTATUS = chr(18) # ping server or portal status
SRESET = chr(19) # server shutdown in reset mode
AMP_MAXLEN = amp.MAX_VALUE_LENGTH # max allowed data length in AMP protocol (cannot be changed)
BATCH_RATE = 250 # max commands/sec before switching to batch-sending
BATCH_TIMEOUT = 0.5 # how often to poll to empty batch queue, in seconds
# buffers
_SENDBATCH = defaultdict(list)
_MSGBUFFER = defaultdict(list)
# resources
DUMMYSESSION = namedtuple('DummySession', ['sessid'])(0)
_HTTP_WARNING = """
HTTP/1.1 200 OK
Content-Type: text/html
<html><body>
This is Evennia's interal AMP port. It handles communication
between Evennia's different processes.<h3><p>This port should NOT be
publicly visible.</p></h3>
</body></html>""".strip()
# Helper functions for pickling.
def dumps(data):
return to_str(pickle.dumps(to_str(data), pickle.HIGHEST_PROTOCOL))
def loads(data):
return pickle.loads(to_str(data))
@wraps
def catch_traceback(func):
"Helper decorator"
def decorator(*args, **kwargs):
try:
func(*args, **kwargs)
except Exception as err:
global _LOGGER
if not _LOGGER:
from evennia.utils import logger as _LOGGER
_LOGGER.log_trace()
raise # make sure the error is visible on the other side of the connection too
print(err)
return decorator
# AMP Communication Command types
class Compressed(amp.String):
"""
This is a custom AMP command Argument that both handles too-long
sends as well as uses zlib for compression across the wire. The
batch-grouping of too-long sends is borrowed from the "mediumbox"
recipy at twisted-hacks's ~glyph/+junk/amphacks/mediumbox.
"""
def fromBox(self, name, strings, objects, proto):
"""
Converts from box representation to python. We
group very long data into batches.
"""
value = StringIO()
value.write(strings.get(name))
for counter in count(2):
# count from 2 upwards
chunk = strings.get("%s.%d" % (name, counter))
if chunk is None:
break
value.write(chunk)
objects[name] = value.getvalue()
def toBox(self, name, strings, objects, proto):
"""
Convert from data to box. We handled too-long
batched data and put it together here.
"""
value = StringIO(objects[name])
strings[name] = value.read(AMP_MAXLEN)
for counter in count(2):
chunk = value.read(AMP_MAXLEN)
if not chunk:
break
strings["%s.%d" % (name, counter)] = chunk
def toString(self, inObject):
"""
Convert to send on the wire, with compression.
"""
return zlib.compress(inObject, 9)
def fromString(self, inString):
"""
Convert (decompress) from the wire to Python.
"""
return zlib.decompress(inString)
class MsgLauncher2Portal(amp.Command):
"""
Message Launcher -> Portal
"""
key = "MsgLauncher2Portal"
arguments = [('operation', amp.String()),
('arguments', amp.String())]
errors = {Exception: 'EXCEPTION'}
response = []
class MsgPortal2Server(amp.Command):
"""
Message Portal -> Server
"""
key = "MsgPortal2Server"
arguments = [('packed_data', Compressed())]
errors = {Exception: 'EXCEPTION'}
response = []
class MsgServer2Portal(amp.Command):
"""
Message Server -> Portal
"""
key = "MsgServer2Portal"
arguments = [('packed_data', Compressed())]
errors = {Exception: 'EXCEPTION'}
response = []
class AdminPortal2Server(amp.Command):
"""
Administration Portal -> Server
Sent when the portal needs to perform admin operations on the
server, such as when a new session connects or resyncs
"""
key = "AdminPortal2Server"
arguments = [('packed_data', Compressed())]
errors = {Exception: 'EXCEPTION'}
response = []
class AdminServer2Portal(amp.Command):
"""
Administration Server -> Portal
Sent when the server needs to perform admin operations on the
portal.
"""
key = "AdminServer2Portal"
arguments = [('packed_data', Compressed())]
errors = {Exception: 'EXCEPTION'}
response = []
class MsgStatus(amp.Command):
"""
Check Status between AMP services
"""
key = "MsgStatus"
arguments = [('status', amp.String())]
errors = {Exception: 'EXCEPTION'}
response = [('status', amp.String())]
class FunctionCall(amp.Command):
"""
Bidirectional Server <-> Portal
Sent when either process needs to call an arbitrary function in
the other. This does not use the batch-send functionality.
"""
key = "FunctionCall"
arguments = [('module', amp.String()),
('function', amp.String()),
('args', amp.String()),
('kwargs', amp.String())]
errors = {Exception: 'EXCEPTION'}
response = [('result', amp.String())]
# -------------------------------------------------------------
# Core AMP protocol for communication Server <-> Portal
# -------------------------------------------------------------
class AMPMultiConnectionProtocol(amp.AMP):
"""
AMP protocol that safely handle multiple connections to the same
server without dropping old ones - new clients will receive
all server returns (broadcast). Will also correctly handle
erroneous HTTP requests on the port and return a HTTP error response.
"""
# helper methods
def __init__(self, *args, **kwargs):
"""
Initialize protocol with some things that need to be in place
already before connecting both on portal and server.
"""
self.send_batch_counter = 0
self.send_reset_time = time.time()
self.send_mode = True
self.send_task = None
def dataReceived(self, data):
"""
Handle non-AMP messages, such as HTTP communication.
"""
if data[0] != b'\0':
self.transport.write(_HTTP_WARNING)
self.transport.loseConnection()
else:
super(AMPMultiConnectionProtocol, self).dataReceived(data)
def makeConnection(self, transport):
"""
Swallow connection log message here. Copied from original
in the amp protocol.
"""
# copied from original, removing the log message
if not self._ampInitialized:
amp.AMP.__init__(self)
self._transportPeer = transport.getPeer()
self._transportHost = transport.getHost()
amp.BinaryBoxProtocol.makeConnection(self, transport)
def connectionMade(self):
"""
This is called when an AMP connection is (re-)established. AMP calls it on both sides.
"""
self.factory.broadcasts.append(self)
def connectionLost(self, reason):
"""
We swallow connection errors here. The reason is that during a
normal reload/shutdown there will almost always be cases where
either the portal or server shuts down before a message has
returned its (empty) return, triggering a connectionLost error
that is irrelevant. If a true connection error happens, the
portal will continuously try to reconnect, showing the problem
that way.
"""
try:
self.factory.broadcasts.remove(self)
except ValueError:
pass
# Error handling
def errback(self, e, info):
"""
Error callback.
Handles errors to avoid dropping connections on server tracebacks.
Args:
e (Failure): Deferred error instance.
info (str): Error string.
"""
global _LOGGER
if not _LOGGER:
from evennia.utils import logger as _LOGGER
e.trap(Exception)
_LOGGER.log_err("AMP Error for %(info)s: %(e)s" % {'info': info,
'e': e.getErrorMessage()})
def data_in(self, packed_data):
"""
Process incoming packed data.
Args:
packed_data (bytes): Zip-packed data.
Returns:
unpaced_data (any): Unpacked package
"""
return loads(packed_data)
def broadcast(self, command, sessid, **kwargs):
"""
Send data across the wire to all connections.
Args:
command (AMP Command): A protocol send command.
sessid (int): A unique Session id.
Returns:
deferred (deferred or None): A deferred with an errback.
Notes:
Data will be sent across the wire pickled as a tuple
(sessid, kwargs).
"""
deferreds = []
for protcl in self.factory.broadcasts:
deferreds.append(protcl.callRemote(command, **kwargs).addErrback(
self.errback, command.key))
return DeferredList(deferreds)
# generic function send/recvs
def send_FunctionCall(self, modulepath, functionname, *args, **kwargs):
"""
Access method called by either process. This will call an arbitrary
function on the other process (On Portal if calling from Server and
vice versa).
Inputs:
modulepath (str) - python path to module holding function to call
functionname (str) - name of function in given module
*args, **kwargs will be used as arguments/keyword args for the
remote function call
Returns:
A deferred that fires with the return value of the remote
function call
"""
return self.callRemote(FunctionCall,
module=modulepath,
function=functionname,
args=dumps(args),
kwargs=dumps(kwargs)).addCallback(
lambda r: loads(r["result"])).addErrback(self.errback, "FunctionCall")
@FunctionCall.responder
@catch_traceback
def receive_functioncall(self, module, function, func_args, func_kwargs):
"""
This allows Portal- and Server-process to call an arbitrary
function in the other process. It is intended for use by
plugin modules.
Args:
module (str or module): The module containing the
`function` to call.
function (str): The name of the function to call in
`module`.
func_args (str): Pickled args tuple for use in `function` call.
func_kwargs (str): Pickled kwargs dict for use in `function` call.
"""
args = loads(func_args)
kwargs = loads(func_kwargs)
# call the function (don't catch tracebacks here)
result = variable_from_module(module, function)(*args, **kwargs)
if isinstance(result, Deferred):
# if result is a deferred, attach handler to properly
# wrap the return value
result.addCallback(lambda r: {"result": dumps(r)})
return result
else:
return {'result': dumps(result)}

View file

@ -0,0 +1,455 @@
"""
The Evennia Portal service acts as an AMP-server, handling AMP
communication to the AMP clients connecting to it (by default
these are the Evennia Server and the evennia launcher).
"""
import os
import sys
from twisted.internet import protocol
from evennia.server.portal import amp
from django.conf import settings
from subprocess import Popen, STDOUT
from evennia.utils import logger
def _is_windows():
return os.name == 'nt'
def getenv():
"""
Get current environment and add PYTHONPATH.
Returns:
env (dict): Environment global dict.
"""
sep = ";" if _is_windows() else ":"
env = os.environ.copy()
env['PYTHONPATH'] = sep.join(sys.path)
return env
class AMPServerFactory(protocol.ServerFactory):
"""
This factory creates AMP Server connection. This acts as the 'Portal'-side communication to the
'Server' process.
"""
noisy = False
def logPrefix(self):
"How this is named in logs"
return "AMP"
def __init__(self, portal):
"""
Initialize the factory. This is called as the Portal service starts.
Args:
portal (Portal): The Evennia Portal service instance.
protocol (Protocol): The protocol the factory creates
instances of.
"""
self.portal = portal
self.protocol = AMPServerProtocol
self.broadcasts = []
self.server_connection = None
self.launcher_connection = None
self.disconnect_callbacks = {}
self.server_connect_callbacks = []
def buildProtocol(self, addr):
"""
Start a new connection, and store it on the service object.
Args:
addr (str): Connection address. Not used.
Returns:
protocol (Protocol): The created protocol.
"""
self.portal.amp_protocol = AMPServerProtocol()
self.portal.amp_protocol.factory = self
return self.portal.amp_protocol
class AMPServerProtocol(amp.AMPMultiConnectionProtocol):
"""
Protocol subclass for the AMP-server run by the Portal.
"""
def connectionLost(self, reason):
"""
Set up a simple callback mechanism to let the amp-server wait for a connection to close.
"""
# wipe broadcast and data memory
super(AMPServerProtocol, self).connectionLost(reason)
if self.factory.server_connection == self:
self.factory.server_connection = None
self.factory.portal.server_info_dict = {}
if self.factory.launcher_connection == self:
self.factory.launcher_connection = None
callback, args, kwargs = self.factory.disconnect_callbacks.pop(self, (None, None, None))
if callback:
try:
callback(*args, **kwargs)
except Exception:
logger.log_trace()
def get_status(self):
"""
Return status for the Evennia infrastructure.
Returns:
status (tuple): The portal/server status and pids
(portal_live, server_live, portal_PID, server_PID).
"""
server_connected = bool(self.factory.server_connection and
self.factory.server_connection.transport.connected)
portal_info_dict = self.factory.portal.get_info_dict()
server_info_dict = self.factory.portal.server_info_dict
server_pid = self.factory.portal.server_process_id
portal_pid = os.getpid()
return (True, server_connected, portal_pid, server_pid, portal_info_dict, server_info_dict)
def data_to_server(self, command, sessid, **kwargs):
"""
Send data across the wire to the Server.
Args:
command (AMP Command): A protocol send command.
sessid (int): A unique Session id.
Returns:
deferred (deferred or None): A deferred with an errback.
Notes:
Data will be sent across the wire pickled as a tuple
(sessid, kwargs).
"""
if self.factory.server_connection:
return self.factory.server_connection.callRemote(
command, packed_data=amp.dumps((sessid, kwargs))).addErrback(
self.errback, command.key)
else:
# if no server connection is available, broadcast
return self.broadcast(command, sessid, packed_data=amp.dumps((sessid, kwargs)))
def start_server(self, server_twistd_cmd):
"""
(Re-)Launch the Evennia server.
Args:
server_twisted_cmd (list): The server start instruction
to pass to POpen to start the server.
"""
# start the Server
process = None
with open(settings.SERVER_LOG_FILE, 'a') as logfile:
# we link stdout to a file in order to catch
# eventual errors happening before the Server has
# opened its logger.
try:
if _is_windows():
# Windows requires special care
create_no_window = 0x08000000
process = Popen(server_twistd_cmd, env=getenv(), bufsize=-1,
stdout=logfile, stderr=STDOUT,
creationflags=create_no_window)
else:
process = Popen(server_twistd_cmd, env=getenv(), bufsize=-1,
stdout=logfile, stderr=STDOUT)
except Exception:
logger.log_trace()
self.factory.portal.server_twistd_cmd = server_twistd_cmd
logfile.flush()
if process and not _is_windows():
# avoid zombie-process on Unix/BSD
process.wait()
return
def wait_for_disconnect(self, callback, *args, **kwargs):
"""
Add a callback for when this connection is lost.
Args:
callback (callable): Will be called with *args, **kwargs
once this protocol is disconnected.
"""
self.factory.disconnect_callbacks[self] = (callback, args, kwargs)
def wait_for_server_connect(self, callback, *args, **kwargs):
"""
Add a callback for when the Server is sure to have connected.
Args:
callback (callable): Will be called with *args, **kwargs
once the Server handshake with Portal is complete.
"""
self.factory.server_connect_callbacks.append((callback, args, kwargs))
def stop_server(self, mode='shutdown'):
"""
Shut down server in one or more modes.
Args:
mode (str): One of 'shutdown', 'reload' or 'reset'.
"""
if mode == 'reload':
self.send_AdminPortal2Server(amp.DUMMYSESSION, operation=amp.SRELOAD)
elif mode == 'reset':
self.send_AdminPortal2Server(amp.DUMMYSESSION, operation=amp.SRESET)
elif mode == 'shutdown':
self.send_AdminPortal2Server(amp.DUMMYSESSION, operation=amp.SSHUTD)
self.factory.portal.server_restart_mode = mode
# sending amp data
def send_Status2Launcher(self):
"""
Send a status stanza to the launcher.
"""
if self.factory.launcher_connection:
self.factory.launcher_connection.callRemote(
amp.MsgStatus,
status=amp.dumps(self.get_status())).addErrback(
self.errback, amp.MsgStatus.key)
def send_MsgPortal2Server(self, session, **kwargs):
"""
Access method called by the Portal and executed on the Portal.
Args:
session (session): Session
kwargs (any, optional): Optional data.
Returns:
deferred (Deferred): Asynchronous return.
"""
return self.data_to_server(amp.MsgPortal2Server, session.sessid, **kwargs)
def send_AdminPortal2Server(self, session, operation="", **kwargs):
"""
Send Admin instructions from the Portal to the Server.
Executed on the Portal.
Args:
session (Session): Session.
operation (char, optional): Identifier for the server operation, as defined by the
global variables in `evennia/server/amp.py`.
data (str or dict, optional): Data used in the administrative operation.
"""
return self.data_to_server(amp.AdminPortal2Server, session.sessid,
operation=operation, **kwargs)
# receive amp data
@amp.MsgStatus.responder
@amp.catch_traceback
def portal_receive_status(self, status):
"""
Returns run-status for the server/portal.
Args:
status (str): Not used.
Returns:
status (dict): The status is a tuple
(portal_running, server_running, portal_pid, server_pid).
"""
return {"status": amp.dumps(self.get_status())}
@amp.MsgLauncher2Portal.responder
@amp.catch_traceback
def portal_receive_launcher2portal(self, operation, arguments):
"""
Receives message arriving from evennia_launcher.
This method is executed on the Portal.
Args:
operation (str): The action to perform.
arguments (str): Possible argument to the instruction, or the empty string.
Returns:
result (dict): The result back to the launcher.
Notes:
This is the entrypoint for controlling the entire Evennia system from the evennia
launcher. It can obviously only accessed when the Portal is already up and running.
"""
self.factory.launcher_connection = self
_, server_connected, _, _, _, _ = self.get_status()
# logger.log_msg("Evennia Launcher->Portal operation %s received" % (ord(operation)))
if operation == amp.SSTART: # portal start #15
# first, check if server is already running
if not server_connected:
self.wait_for_server_connect(self.send_Status2Launcher)
self.start_server(amp.loads(arguments))
elif operation == amp.SRELOAD: # reload server #14
if server_connected:
# We let the launcher restart us once they get the signal
self.factory.server_connection.wait_for_disconnect(
self.send_Status2Launcher)
self.stop_server(mode='reload')
else:
self.wait_for_server_connect(self.send_Status2Launcher)
self.start_server(amp.loads(arguments))
elif operation == amp.SRESET: # reload server #19
if server_connected:
self.factory.server_connection.wait_for_disconnect(
self.send_Status2Launcher)
self.stop_server(mode='reset')
else:
self.wait_for_server_connect(self.send_Status2Launcher)
self.start_server(amp.loads(arguments))
elif operation == amp.SSHUTD: # server-only shutdown #17
if server_connected:
self.factory.server_connection.wait_for_disconnect(
self.send_Status2Launcher)
self.stop_server(mode='shutdown')
elif operation == amp.PSHUTD: # portal + server shutdown #16
if server_connected:
self.factory.server_connection.wait_for_disconnect(
self.factory.portal.shutdown, restart=False)
else:
self.factory.portal.shutdown(restart=False)
else:
raise Exception("operation %(op)s not recognized." % {'op': operation})
return {}
@amp.MsgServer2Portal.responder
@amp.catch_traceback
def portal_receive_server2portal(self, packed_data):
"""
Receives message arriving to Portal from Server.
This method is executed on the Portal.
Args:
packed_data (str): Pickled data (sessid, kwargs) coming over the wire.
"""
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)
return {}
@amp.AdminServer2Portal.responder
@amp.catch_traceback
def portal_receive_adminserver2portal(self, packed_data):
"""
Receives and handles admin operations sent to the Portal
This is executed on the Portal.
Args:
packed_data (str): Data received, a pickled tuple (sessid, kwargs).
"""
self.factory.server_connection = self
sessid, kwargs = self.data_in(packed_data)
operation = kwargs.pop("operation")
portal_sessionhandler = self.factory.portal.sessions
if operation == amp.SLOGIN: # server_session_login
# a session has authenticated; sync it.
session = portal_sessionhandler.get(sessid)
if session:
portal_sessionhandler.server_logged_in(session, kwargs.get("sessiondata"))
elif operation == amp.SDISCONN: # server_session_disconnect
# the server is ordering to disconnect the session
session = portal_sessionhandler.get(sessid)
if session:
portal_sessionhandler.server_disconnect(session, reason=kwargs.get("reason"))
elif operation == amp.SDISCONNALL: # server_session_disconnect_all
# server orders all sessions to disconnect
portal_sessionhandler.server_disconnect_all(reason=kwargs.get("reason"))
elif operation == amp.SRELOAD: # server reload
self.factory.server_connection.wait_for_disconnect(
self.start_server, self.factory.portal.server_twistd_cmd)
self.stop_server(mode='reload')
elif operation == amp.SRESET: # server reset
self.factory.server_connection.wait_for_disconnect(
self.start_server, self.factory.portal.server_twistd_cmd)
self.stop_server(mode='reset')
elif operation == amp.SSHUTD: # server-only shutdown
self.stop_server(mode='shutdown')
elif operation == amp.PSHUTD: # full server+server shutdown
self.factory.server_connection.wait_for_disconnect(
self.factory.portal.shutdown, restart=False)
self.stop_server(mode='shutdown')
elif operation == amp.PSYNC: # portal sync
# Server has (re-)connected and wants the session data from portal
self.factory.portal.server_info_dict = kwargs.get("info_dict", {})
self.factory.portal.server_process_id = kwargs.get("spid", None)
# this defaults to 'shutdown' or whatever value set in server_stop
server_restart_mode = self.factory.portal.server_restart_mode
sessdata = self.factory.portal.sessions.get_all_sync_data()
self.send_AdminPortal2Server(amp.DUMMYSESSION,
amp.PSYNC,
server_restart_mode=server_restart_mode,
sessiondata=sessdata)
self.factory.portal.sessions.at_server_connection()
if self.factory.server_connection:
# this is an indication the server has successfully connected, so
# we trigger any callbacks (usually to tell the launcher server is up)
for callback, args, kwargs in self.factory.server_connect_callbacks:
try:
callback(*args, **kwargs)
except Exception:
logger.log_trace()
self.factory.server_connect_callbacks = []
elif operation == amp.SSYNC: # server_session_sync
# server wants to save session data to the portal,
# maybe because it's about to shut down.
portal_sessionhandler.server_session_sync(kwargs.get("sessiondata"),
kwargs.get("clean", True))
# set a flag in case we are about to shut down soon
self.factory.server_restart_mode = True
elif operation == amp.SCONN: # server_force_connection (for irc/etc)
portal_sessionhandler.server_connect(**kwargs)
else:
raise Exception("operation %(op)s not recognized." % {'op': operation})
return {}

View file

@ -7,7 +7,6 @@ sets up all the networking features. (this is done automatically
by game/evennia.py).
"""
from __future__ import print_function
from builtins import object
import sys
@ -15,6 +14,8 @@ import os
from twisted.application import internet, service
from twisted.internet import protocol, reactor
from twisted.python.log import ILogObserver
import django
django.setup()
from django.conf import settings
@ -24,6 +25,7 @@ evennia._init()
from evennia.utils.utils import get_evennia_version, mod_import, make_iter
from evennia.server.portal.portalsessionhandler import PORTAL_SESSIONS
from evennia.utils import logger
from evennia.server.webserver import EvenniaReverseProxyResource
from django.db import connection
@ -37,11 +39,6 @@ except Exception:
PORTAL_SERVICES_PLUGIN_MODULES = [mod_import(module) for module in make_iter(settings.PORTAL_SERVICES_PLUGIN_MODULES)]
LOCKDOWN_MODE = settings.LOCKDOWN_MODE
PORTAL_PIDFILE = ""
if os.name == 'nt':
# For Windows we need to handle pid files manually.
PORTAL_PIDFILE = os.path.join(settings.GAME_DIR, "server", 'portal.pid')
# -------------------------------------------------------------
# Evennia Portal settings
# -------------------------------------------------------------
@ -77,10 +74,15 @@ AMP_PORT = settings.AMP_PORT
AMP_INTERFACE = settings.AMP_INTERFACE
AMP_ENABLED = AMP_HOST and AMP_PORT and AMP_INTERFACE
INFO_DICT = {"servername": SERVERNAME, "version": VERSION, "errors": "", "info": "",
"lockdown_mode": "", "amp": "", "telnet": [], "telnet_ssl": [], "ssh": [],
"webclient": [], "webserver_proxy": [], "webserver_internal": []}
# -------------------------------------------------------------
# Portal Service object
# -------------------------------------------------------------
class Portal(object):
"""
@ -105,12 +107,19 @@ class Portal(object):
self.amp_protocol = None # set by amp factory
self.sessions = PORTAL_SESSIONS
self.sessions.portal = self
self.process_id = os.getpid()
self.server_process_id = None
self.server_restart_mode = "shutdown"
self.server_info_dict = {}
# set a callback if the server is killed abruptly,
# by Ctrl-C, reboot etc.
reactor.addSystemEventTrigger('before', 'shutdown', self.shutdown, _reactor_stopping=True)
self.game_running = False
def get_info_dict(self):
"Return the Portal info, for display."
return INFO_DICT
def set_restart_mode(self, mode=None):
"""
@ -125,7 +134,6 @@ class Portal(object):
if mode is None:
return
with open(PORTAL_RESTART, 'w') as f:
print("writing mode=%(mode)s to %(portal_restart)s" % {'mode': mode, 'portal_restart': PORTAL_RESTART})
f.write(str(mode))
def shutdown(self, restart=None, _reactor_stopping=False):
@ -152,9 +160,7 @@ class Portal(object):
return
self.sessions.disconnect_all()
self.set_restart_mode(restart)
if os.name == 'nt' and os.path.exists(PORTAL_PIDFILE):
# for Windows we need to remove pid files manually
os.remove(PORTAL_PIDFILE)
if not _reactor_stopping:
# shutting down the reactor will trigger another signal. We set
# a flag to avoid loops.
@ -172,14 +178,18 @@ class Portal(object):
# what to execute from.
application = service.Application('Portal')
# custom logging
logfile = logger.WeeklyLogFile(os.path.basename(settings.PORTAL_LOG_FILE),
os.path.dirname(settings.PORTAL_LOG_FILE))
application.setComponent(ILogObserver, logger.PortalLogObserver(logfile).emit)
# The main Portal server program. This sets up the database
# and is where we store all the other services.
PORTAL = Portal(application)
print('-' * 50)
print(' %(servername)s Portal (%(version)s) started.' % {'servername': SERVERNAME, 'version': VERSION})
if LOCKDOWN_MODE:
print(' LOCKDOWN_MODE active: Only local connections.')
INFO_DICT["lockdown_mode"] = ' LOCKDOWN_MODE active: Only local connections.'
if AMP_ENABLED:
@ -187,14 +197,14 @@ if AMP_ENABLED:
# the portal and the mud server. Only reason to ever deactivate
# it would be during testing and debugging.
from evennia.server import amp
from evennia.server.portal import amp_server
print(' amp (to Server): %s' % AMP_PORT)
INFO_DICT["amp"] = 'amp: %s' % AMP_PORT
factory = amp.AmpClientFactory(PORTAL)
amp_client = internet.TCPClient(AMP_HOST, AMP_PORT, factory)
amp_client.setName('evennia_amp')
PORTAL.services.addService(amp_client)
factory = amp_server.AMPServerFactory(PORTAL)
amp_service = internet.TCPServer(AMP_PORT, factory, interface=AMP_INTERFACE)
amp_service.setName("PortalAMPServer")
PORTAL.services.addService(amp_service)
# We group all the various services under the same twisted app.
@ -212,7 +222,7 @@ if TELNET_ENABLED:
ifacestr = "-%s" % interface
for port in TELNET_PORTS:
pstring = "%s:%s" % (ifacestr, port)
factory = protocol.ServerFactory()
factory = telnet.TelnetServerFactory()
factory.noisy = False
factory.protocol = telnet.TelnetProtocol
factory.sessionhandler = PORTAL_SESSIONS
@ -220,12 +230,12 @@ if TELNET_ENABLED:
telnet_service.setName('EvenniaTelnet%s' % pstring)
PORTAL.services.addService(telnet_service)
print(' telnet%s: %s' % (ifacestr, port))
INFO_DICT["telnet"].append('telnet%s: %s' % (ifacestr, port))
if SSL_ENABLED:
# Start Telnet SSL game connection (requires PyOpenSSL).
# Start Telnet+SSL game connection (requires PyOpenSSL).
from evennia.server.portal import telnet_ssl
@ -249,9 +259,10 @@ if SSL_ENABLED:
ssl_service.setName('EvenniaSSL%s' % pstring)
PORTAL.services.addService(ssl_service)
print(" ssl%s: %s" % (ifacestr, port))
INFO_DICT["telnet_ssl"].append("telnet+ssl%s: %s" % (ifacestr, port))
else:
print(" ssl%s: %s (deactivated - keys/certificate unset)" % (ifacestr, port))
INFO_DICT["telnet_ssl"].append(
"telnet+ssl%s: %s (deactivated - keys/cert unset)" % (ifacestr, port))
if SSH_ENABLED:
@ -275,7 +286,7 @@ if SSH_ENABLED:
ssh_service.setName('EvenniaSSH%s' % pstring)
PORTAL.services.addService(ssh_service)
print(" ssh%s: %s" % (ifacestr, port))
INFO_DICT["ssh"].append("ssh%s: %s" % (ifacestr, port))
if WEBSERVER_ENABLED:
@ -289,7 +300,6 @@ if WEBSERVER_ENABLED:
if interface not in ('0.0.0.0', '::') or len(WEBSERVER_INTERFACES) > 1:
ifacestr = "-%s" % interface
for proxyport, serverport in WEBSERVER_PORTS:
pstring = "%s:%s<->%s" % (ifacestr, proxyport, serverport)
web_root = EvenniaReverseProxyResource('127.0.0.1', serverport, '')
webclientstr = ""
if WEBCLIENT_ENABLED:
@ -299,7 +309,7 @@ if WEBSERVER_ENABLED:
ajax_webclient = webclient_ajax.AjaxWebClient()
ajax_webclient.sessionhandler = PORTAL_SESSIONS
web_root.putChild("webclientdata", ajax_webclient)
webclientstr = "\n + webclient (ajax only)"
webclientstr = "webclient (ajax only)"
if WEBSOCKET_CLIENT_ENABLED and not websocket_started:
# start websocket client port for the webclient
@ -307,38 +317,38 @@ if WEBSERVER_ENABLED:
from evennia.server.portal import webclient
from evennia.utils.txws import WebSocketFactory
interface = WEBSOCKET_CLIENT_INTERFACE
w_interface = WEBSOCKET_CLIENT_INTERFACE
w_ifacestr = ''
if w_interface not in ('0.0.0.0', '::') or len(WEBSERVER_INTERFACES) > 1:
w_ifacestr = "-%s" % interface
port = WEBSOCKET_CLIENT_PORT
ifacestr = ""
if interface not in ('0.0.0.0', '::'):
ifacestr = "-%s" % interface
pstring = "%s:%s" % (ifacestr, port)
factory = protocol.ServerFactory()
class Websocket(protocol.ServerFactory):
"Only here for better naming in logs"
pass
factory = Websocket()
factory.noisy = False
factory.protocol = webclient.WebSocketClient
factory.sessionhandler = PORTAL_SESSIONS
websocket_service = internet.TCPServer(port, WebSocketFactory(factory), interface=interface)
websocket_service.setName('EvenniaWebSocket%s' % pstring)
websocket_service = internet.TCPServer(port, WebSocketFactory(factory), interface=w_interface)
websocket_service.setName('EvenniaWebSocket%s:%s' % (w_ifacestr, port))
PORTAL.services.addService(websocket_service)
websocket_started = True
webclientstr = "\n + webclient%s" % pstring
webclientstr = "webclient-websocket%s: %s" % (w_ifacestr, port)
INFO_DICT["webclient"].append(webclientstr)
web_root = Website(web_root, logPath=settings.HTTP_LOG_FILE)
web_root.is_portal = True
proxy_service = internet.TCPServer(proxyport,
web_root,
interface=interface)
proxy_service.setName('EvenniaWebProxy%s' % pstring)
PORTAL.services.addService(proxy_service)
print(" webproxy%s:%s (<-> %s)%s" % (ifacestr, proxyport, serverport, webclientstr))
INFO_DICT["webserver_proxy"].append("webserver-proxy%s: %s" % (ifacestr, proxyport))
INFO_DICT["webserver_internal"].append("webserver: %s" % serverport)
for plugin_module in PORTAL_SERVICES_PLUGIN_MODULES:
# external plugin services to start
plugin_module.start_plugin_services(PORTAL)
print('-' * 50) # end of terminal output
if os.name == 'nt':
# Windows only: Set PID file manually
with open(PORTAL_PIDFILE, 'w') as f:
f.write(str(os.getpid()))

View file

@ -39,7 +39,7 @@ from twisted.conch.ssh import common
from twisted.conch.insults import insults
from twisted.conch.manhole_ssh import TerminalRealm, _Glue, ConchFactory
from twisted.conch.manhole import Manhole, recvline
from twisted.internet import defer
from twisted.internet import defer, protocol
from twisted.conch import interfaces as iconch
from twisted.python import components
from django.conf import settings
@ -72,6 +72,15 @@ and put them here:
""".format(_PRIVATE_KEY_FILE, _PUBLIC_KEY_FILE)
# not used atm
class SSHServerFactory(protocol.ServerFactory):
"This is only to name this better in logs"
noisy = False
def logPrefix(self):
return "SSH"
class SshProtocol(Manhole, session.Session):
"""
Each account connecting over ssh gets this protocol assigned to

View file

@ -8,6 +8,7 @@ sessions etc.
"""
import re
from twisted.internet import protocol
from twisted.internet.task import LoopingCall
from twisted.conch.telnet import Telnet, StatefulTelnetProtocol
from twisted.conch.telnet import IAC, NOP, LINEMODE, GA, WILL, WONT, ECHO, NULL
@ -26,6 +27,14 @@ _RE_SCREENREADER_REGEX = re.compile(r"%s" % settings.SCREENREADER_REGEX_STRIP, r
_IDLE_COMMAND = settings.IDLE_COMMAND + "\n"
class TelnetServerFactory(protocol.ServerFactory):
"This is only to name this better in logs"
noisy = False
def logPrefix(self):
return "Telnet"
class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
"""
Each player connecting over telnet (ie using most traditional mud
@ -49,10 +58,13 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
# this number is counted down for every handshake that completes.
# when it reaches 0 the portal/server syncs their data
self.handshakes = 8 # suppress-go-ahead, naws, ttype, mccp, mssp, msdp, gmcp, mxp
self.init_session(self.protocol_key, client_address, self.factory.sessionhandler)
self.protocol_flags["ENCODING"] = settings.ENCODINGS[0] if settings.ENCODINGS else 'utf-8'
# add this new connection to sessionhandler so
# the Server becomes aware of it.
self.sessionhandler.connect(self)
# change encoding to ENCODINGS[0] which reflects Telnet default encoding
# suppress go-ahead
self.sga = suppress_ga.SuppressGA(self)

View file

@ -106,7 +106,7 @@ ERROR_NO_MIXIN = \
error completely.
Warning: Don't run dummyrunner on a production database! It will
create a lot of spammy objects and account accounts!
create a lot of spammy objects and accounts!
"""

View file

@ -7,7 +7,6 @@ sets up all the networking features. (this is done automatically
by evennia/server/server_runner.py).
"""
from __future__ import print_function
from builtins import object
import time
import sys
@ -17,6 +16,7 @@ from twisted.web import static
from twisted.application import internet, service
from twisted.internet import reactor, defer
from twisted.internet.task import LoopingCall
from twisted.python.log import ILogObserver
import django
django.setup()
@ -33,6 +33,7 @@ from evennia.server.models import ServerConfig
from evennia.server import initial_setup
from evennia.utils.utils import get_evennia_version, mod_import, make_iter
from evennia.utils import logger
from evennia.comms import channelhandler
from evennia.server.sessionhandler import SESSIONS
@ -40,11 +41,6 @@ from django.utils.translation import ugettext as _
_SA = object.__setattr__
SERVER_PIDFILE = ""
if os.name == 'nt':
# For Windows we need to handle pid files manually.
SERVER_PIDFILE = os.path.join(settings.GAME_DIR, "server", 'server.pid')
# a file with a flag telling the server to restart after shutdown or not.
SERVER_RESTART = os.path.join(settings.GAME_DIR, "server", 'server.restart')
@ -53,16 +49,11 @@ SERVER_STARTSTOP_MODULE = mod_import(settings.AT_SERVER_STARTSTOP_MODULE)
# modules containing plugin services
SERVER_SERVICES_PLUGIN_MODULES = [mod_import(module) for module in make_iter(settings.SERVER_SERVICES_PLUGIN_MODULES)]
try:
WEB_PLUGINS_MODULE = mod_import(settings.WEB_PLUGINS_MODULE)
except ImportError:
WEB_PLUGINS_MODULE = None
print ("WARNING: settings.WEB_PLUGINS_MODULE not found - "
"copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf.")
#------------------------------------------------------------
# ------------------------------------------------------------
# Evennia Server settings
#------------------------------------------------------------
# ------------------------------------------------------------
SERVERNAME = settings.SERVERNAME
VERSION = get_evennia_version()
@ -83,6 +74,17 @@ IRC_ENABLED = settings.IRC_ENABLED
RSS_ENABLED = settings.RSS_ENABLED
WEBCLIENT_ENABLED = settings.WEBCLIENT_ENABLED
INFO_DICT = {"servername": SERVERNAME, "version": VERSION,
"amp": "", "errors": "", "info": "", "webserver": "", "irc_rss": ""}
try:
WEB_PLUGINS_MODULE = mod_import(settings.WEB_PLUGINS_MODULE)
except ImportError:
WEB_PLUGINS_MODULE = None
INFO_DICT["errors"] = (
"WARNING: settings.WEB_PLUGINS_MODULE not found - "
"copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf.")
# Maintenance function - this is called repeatedly by the server
@ -172,6 +174,7 @@ class Evennia(object):
self.amp_protocol = None # set by amp factory
self.sessions = SESSIONS
self.sessions.server = self
self.process_id = os.getpid()
# Database-specific startup optimizations.
self.sqlite3_prep()
@ -192,18 +195,13 @@ class Evennia(object):
from twisted.internet.defer import Deferred
if hasattr(self, "web_root"):
d = self.web_root.empty_threadpool()
d.addCallback(lambda _: self.shutdown(_reactor_stopping=True))
d.addCallback(lambda _: self.shutdown("shutdown", _reactor_stopping=True))
else:
d = Deferred(lambda _: self.shutdown(_reactor_stopping=True))
d = Deferred(lambda _: self.shutdown("shutdown", _reactor_stopping=True))
d.addCallback(lambda _: reactor.stop())
reactor.callLater(1, d.callback, None)
reactor.sigInt = _wrap_sigint_handler
self.game_running = True
# track the server time
self.run_init_hooks()
# Server startup methods
def sqlite3_prep(self):
@ -211,7 +209,8 @@ class Evennia(object):
Optimize some SQLite stuff at startup since we
can't save it to the database.
"""
if ((".".join(str(i) for i in django.VERSION) < "1.2" and settings.DATABASES.get('default', {}).get('ENGINE') == "sqlite3") or
if ((".".join(str(i) for i in django.VERSION) < "1.2" and
settings.DATABASES.get('default', {}).get('ENGINE') == "sqlite3") or
(hasattr(settings, 'DATABASES') and
settings.DATABASES.get("default", {}).get('ENGINE', None) ==
'django.db.backends.sqlite3')):
@ -229,6 +228,8 @@ class Evennia(object):
typeclasses in the settings file and have them auto-update all
already existing objects.
"""
global INFO_DICT
# setting names
settings_names = ("CMDSET_CHARACTER", "CMDSET_ACCOUNT",
"BASE_ACCOUNT_TYPECLASS", "BASE_OBJECT_TYPECLASS",
@ -247,7 +248,7 @@ class Evennia(object):
#from evennia.accounts.models import AccountDB
for i, prev, curr in ((i, tup[0], tup[1]) for i, tup in enumerate(settings_compare) if i in mismatches):
# update the database
print(" %s:\n '%s' changed to '%s'. Updating unchanged entries in database ..." % (settings_names[i], prev, curr))
INFO_DICT['info'] = " %s:\n '%s' changed to '%s'. Updating unchanged entries in database ..." % (settings_names[i], prev, curr)
if i == 0:
ObjectDB.objects.filter(db_cmdset_storage__exact=prev).update(db_cmdset_storage=curr)
if i == 1:
@ -277,29 +278,31 @@ class Evennia(object):
It returns if this is not the first time the server starts.
Once finished the last_initial_setup_step is set to -1.
"""
global INFO_DICT
last_initial_setup_step = ServerConfig.objects.conf('last_initial_setup_step')
if not last_initial_setup_step:
# None is only returned if the config does not exist,
# i.e. this is an empty DB that needs populating.
print(' Server started for the first time. Setting defaults.')
INFO_DICT['info'] = ' Server started for the first time. Setting defaults.'
initial_setup.handle_setup(0)
print('-' * 50)
elif int(last_initial_setup_step) >= 0:
# a positive value means the setup crashed on one of its
# modules and setup will resume from this step, retrying
# the last failed module. When all are finished, the step
# is set to -1 to show it does not need to be run again.
print(' Resuming initial setup from step %(last)s.' %
{'last': last_initial_setup_step})
INFO_DICT['info'] = ' Resuming initial setup from step {last}.'.format(
last=last_initial_setup_step)
initial_setup.handle_setup(int(last_initial_setup_step))
print('-' * 50)
def run_init_hooks(self):
def run_init_hooks(self, mode):
"""
Called every server start
Called by the amp client once receiving sync back from Portal
Args:
mode (str): One of shutdown, reload or reset
"""
from evennia.objects.models import ObjectDB
#from evennia.accounts.models import AccountDB
# update eventual changed defaults
self.update_defaults()
@ -307,47 +310,24 @@ class Evennia(object):
[o.at_init() for o in ObjectDB.get_all_cached_instances()]
[p.at_init() for p in AccountDB.get_all_cached_instances()]
mode = self.getset_restart_mode()
# call correct server hook based on start file value
if mode == 'reload':
# True was the old reload flag, kept for compatibilty
logger.log_msg("Server successfully reloaded.")
self.at_server_reload_start()
elif mode == 'reset':
# only run hook, don't purge sessions
self.at_server_cold_start()
elif mode in ('reset', 'shutdown'):
logger.log_msg("Evennia Server successfully restarted in 'reset' mode.")
elif mode == 'shutdown':
self.at_server_cold_start()
# clear eventual lingering session storages
ObjectDB.objects.clear_all_sessids()
logger.log_msg("Evennia Server successfully started.")
# always call this regardless of start type
self.at_server_start()
def getset_restart_mode(self, mode=None):
"""
This manages the flag file that tells the runner if the server is
reloading, resetting or shutting down.
Args:
mode (string or None, optional): Valid values are
'reload', 'reset', 'shutdown' and `None`. If mode is `None`,
no change will be done to the flag file.
Returns:
mode (str): The currently active restart mode, either just
set or previously set.
"""
if mode is None:
with open(SERVER_RESTART, 'r') as f:
# mode is either shutdown, reset or reload
mode = f.read()
else:
with open(SERVER_RESTART, 'w') as f:
f.write(str(mode))
return mode
@defer.inlineCallbacks
def shutdown(self, mode=None, _reactor_stopping=False):
def shutdown(self, mode='reload', _reactor_stopping=False):
"""
Shuts down the server from inside it.
@ -358,7 +338,6 @@ class Evennia(object):
at_shutdown hooks called but sessions will not
be disconnected.
'shutdown' - like reset, but server will not auto-restart.
None - keep currently set flag from flag file.
_reactor_stopping - this is set if server is stopped by a kill
command OR this method was already called
once - in both cases the reactor is
@ -369,10 +348,7 @@ class Evennia(object):
# once; we don't need to run the shutdown procedure again.
defer.returnValue(None)
mode = self.getset_restart_mode(mode)
from evennia.objects.models import ObjectDB
#from evennia.accounts.models import AccountDB
from evennia.server.models import ServerConfig
from evennia.utils import gametime as _GAMETIME_MODULE
@ -381,7 +357,8 @@ class Evennia(object):
ServerConfig.objects.conf("server_restart_mode", "reload")
yield [o.at_server_reload() for o in ObjectDB.get_all_cached_instances()]
yield [p.at_server_reload() for p in AccountDB.get_all_cached_instances()]
yield [(s.pause(manual_pause=False), s.at_server_reload()) for s in ScriptDB.get_all_cached_instances() if s.is_active]
yield [(s.pause(manual_pause=False), s.at_server_reload())
for s in ScriptDB.get_all_cached_instances() if s.is_active]
yield self.sessions.all_sessions_portal_sync()
self.at_server_reload_stop()
# only save monitor state on reload, not on shutdown/reset
@ -411,10 +388,6 @@ class Evennia(object):
# always called, also for a reload
self.at_server_stop()
if os.name == 'nt' and os.path.exists(SERVER_PIDFILE):
# for Windows we need to remove pid files manually
os.remove(SERVER_PIDFILE)
if hasattr(self, "web_root"): # not set very first start
yield self.web_root.empty_threadpool()
@ -426,6 +399,10 @@ class Evennia(object):
# we make sure the proper gametime is saved as late as possible
ServerConfig.objects.conf("runtime", _GAMETIME_MODULE.runtime())
def get_info_dict(self):
"Return the server info, for display."
return INFO_DICT
# server start/stop hooks
def at_server_start(self):
@ -451,13 +428,15 @@ class Evennia(object):
if SERVER_STARTSTOP_MODULE:
SERVER_STARTSTOP_MODULE.at_server_reload_start()
def at_post_portal_sync(self):
def at_post_portal_sync(self, mode):
"""
This is called just after the portal has finished syncing back data to the server
after reconnecting.
Args:
mode (str): One of reload, reset or shutdown.
"""
# one of reload, reset or shutdown
mode = self.getset_restart_mode()
from evennia.scripts.monitorhandler import MONITOR_HANDLER
MONITOR_HANDLER.restore(mode == 'reload')
@ -529,13 +508,15 @@ 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)
# The main evennia server program. This sets up the database
# and is where we store all the other services.
EVENNIA = Evennia(application)
print('-' * 50)
print(' %(servername)s Server (%(version)s) started.' % {'servername': SERVERNAME, 'version': VERSION})
if AMP_ENABLED:
# The AMP protocol handles the communication between
@ -545,20 +526,20 @@ if AMP_ENABLED:
ifacestr = ""
if AMP_INTERFACE != '127.0.0.1':
ifacestr = "-%s" % AMP_INTERFACE
print(' amp (to Portal)%s: %s' % (ifacestr, AMP_PORT))
from evennia.server import amp
INFO_DICT["amp"] = 'amp %s: %s' % (ifacestr, AMP_PORT)
factory = amp.AmpServerFactory(EVENNIA)
amp_service = internet.TCPServer(AMP_PORT, factory, interface=AMP_INTERFACE)
amp_service.setName("EvenniaPortal")
from evennia.server import amp_client
factory = amp_client.AMPClientFactory(EVENNIA)
amp_service = internet.TCPClient(AMP_HOST, AMP_PORT, factory)
amp_service.setName('ServerAMPClient')
EVENNIA.services.addService(amp_service)
if WEBSERVER_ENABLED:
# Start a django-compatible webserver.
#from twisted.python import threadpool
from evennia.server.webserver import DjangoWebRoot, WSGIWebServer, Website, LockableThreadPool
# start a thread pool and define the root url (/) as a wsgi resource
@ -578,14 +559,16 @@ if WEBSERVER_ENABLED:
web_root = WEB_PLUGINS_MODULE.at_webserver_root_creation(web_root)
web_site = Website(web_root, logPath=settings.HTTP_LOG_FILE)
web_site.is_portal = False
INFO_DICT["webserver"] = ""
for proxyport, serverport in WEBSERVER_PORTS:
# create the webserver (we only need the port for this)
webserver = WSGIWebServer(threads, serverport, web_site, interface='127.0.0.1')
webserver.setName('EvenniaWebServer%s' % serverport)
EVENNIA.services.addService(webserver)
print(" webserver: %s" % serverport)
INFO_DICT["webserver"] += "webserver: %s" % serverport
ENABLED = []
if IRC_ENABLED:
@ -597,18 +580,11 @@ if RSS_ENABLED:
ENABLED.append('rss')
if ENABLED:
print(" " + ", ".join(ENABLED) + " enabled.")
INFO_DICT["irc_rss"] = ", ".join(ENABLED) + " enabled."
for plugin_module in SERVER_SERVICES_PLUGIN_MODULES:
# external plugin protocols
plugin_module.start_plugin_services(EVENNIA)
print('-' * 50) # end of terminal output
# clear server startup mode
ServerConfig.objects.conf("server_starting_mode", delete=True)
if os.name == 'nt':
# Windows only: Set PID file manually
with open(SERVER_PIDFILE, 'w') as f:
f.write(str(os.getpid()))

View file

@ -7,7 +7,6 @@ from builtins import object
import time
#------------------------------------------------------------
# Server Session
#------------------------------------------------------------

View file

@ -58,6 +58,12 @@ SSYNC = chr(8) # server session sync
SCONN = chr(11) # server portal connection (for bots)
PCONNSYNC = chr(12) # portal post-syncing session
PDISCONNALL = chr(13) # portal session discnnect all
SRELOAD = chr(14) # server reloading (have portal start a new server)
SSTART = chr(15) # server start (portal must already be running anyway)
PSHUTD = chr(16) # portal (+server) shutdown
SSHUTD = chr(17) # server shutdown
PSTATUS = chr(18) # ping server or portal status
SRESET = chr(19) # server shutdown in reset mode
# i18n
from django.utils.translation import ugettext as _
@ -373,8 +379,10 @@ class ServerSessionHandler(SessionHandler):
self[sessid] = sess
sess.at_sync()
mode = 'reload'
# tell the server hook we synced
self.server.at_post_portal_sync()
self.server.at_post_portal_sync(mode)
# announce the reconnection
self.announce_all(_(" ... Server restarted."))
@ -432,13 +440,28 @@ class ServerSessionHandler(SessionHandler):
self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION, operation=SCONN,
protocol_path=protocol_path, config=configdict)
def portal_restart_server(self):
"""
Called by server when reloading. We tell the portal to start a new server instance.
"""
self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION, operation=SRELOAD)
def portal_reset_server(self):
"""
Called by server when reloading. We tell the portal to start a new server instance.
"""
self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION, operation=SRESET)
def portal_shutdown(self):
"""
Called by server when shutting down the portal.
Called by server when it's time to shut down (the portal will shut us down and then shut
itself down)
"""
self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION,
operation=SSHUTD)
operation=PSHUTD)
def login(self, session, account, force=False, testmode=False):
"""
@ -564,8 +587,6 @@ class ServerSessionHandler(SessionHandler):
sessiondata=session_data,
clean=False)
def disconnect_all_sessions(self, reason="You have been disconnected."):
"""
Cleanly disconnect all of the connected sessions.

View file

@ -212,6 +212,12 @@ class Website(server.Site):
"""
noisy = False
def logPrefix(self):
"How to be named in logs"
if hasattr(self, "is_portal") and self.is_portal:
return "Webserver-proxy"
return "Webserver"
def log(self, request):
"""Conditional logging"""
if _DEBUG:

View file

@ -65,7 +65,7 @@ ALLOWED_HOSTS = ["*"]
# the Portal proxy presents to the world. The serverports are
# the internal ports the proxy uses to forward data to the Server-side
# webserver (these should not be publicly open)
WEBSERVER_PORTS = [(4001, 4002)]
WEBSERVER_PORTS = [(4001, 4005)]
# Interface addresses to listen to. If 0.0.0.0, listen to all. Use :: for IPv6.
WEBSERVER_INTERFACES = ['0.0.0.0']
# IP addresses that may talk to the server in a reverse proxy configuration,
@ -83,15 +83,20 @@ WEBCLIENT_ENABLED = True
# default webclient will use this and only use the ajax version if the browser
# is too old to support websockets. Requires WEBCLIENT_ENABLED.
WEBSOCKET_CLIENT_ENABLED = True
# Server-side websocket port to open for the webclient.
WEBSOCKET_CLIENT_PORT = 4005
# Server-side websocket port to open for the webclient. Note that this value will
# be dynamically encoded in the webclient html page to allow the webclient to call
# home. If the external encoded value needs to be different than this, due to
# working through a proxy or docker port-remapping, the environment variable
# WEBCLIENT_CLIENT_PROXY_PORT can be used to override this port only for the
# front-facing client's sake.
WEBSOCKET_CLIENT_PORT = 4002
# Interface addresses to listen to. If 0.0.0.0, listen to all. Use :: for IPv6.
WEBSOCKET_CLIENT_INTERFACE = '0.0.0.0'
# Actual URL for webclient component to reach the websocket. You only need
# to set this if you know you need it, like using some sort of proxy setup.
# If given it must be on the form "ws://hostname" (WEBSOCKET_CLIENT_PORT will
# be automatically appended). If left at None, the client will itself
# figure out this url based on the server's hostname.
# If given it must be on the form "ws[s]://hostname[:port]". If left at None,
# the client will itself figure out this url based on the server's hostname.
# e.g. ws://external.example.com or wss://external.example.com:443
WEBSOCKET_CLIENT_URL = None
# This determine's whether Evennia's custom admin page is used, or if the
# standard Django admin is used.
@ -166,6 +171,7 @@ IDLE_COMMAND = "idle"
# given, this list is tried, in order, aborting on the first match.
# Add sets for languages/regions your accounts are likely to use.
# (see http://en.wikipedia.org/wiki/Character_encoding)
# Telnet default encoding, unless specified by the client, will be ENCODINGS[0].
ENCODINGS = ["utf-8", "latin-1", "ISO-8859-1"]
# Regular expression applied to all output to a given session in order
# to strip away characters (usually various forms of decorations) for the benefit
@ -464,7 +470,7 @@ BASE_SCRIPT_TYPECLASS = "typeclasses.scripts.Script"
DEFAULT_HOME = "#2"
# The start position for new characters. Default is Limbo (#2).
# MULTISESSION_MODE = 0, 1 - used by default unloggedin create command
# MULTISESSION_MODE = 2,3 - used by default character_create command
# MULTISESSION_MODE = 2, 3 - used by default character_create command
START_LOCATION = "#2"
# Lookups of Attributes, Tags, Nicks, Aliases can be aggressively
# cached to avoid repeated database hits. This often gives noticeable
@ -498,8 +504,13 @@ TIME_FACTOR = 2.0
# The starting point of your game time (the epoch), in seconds.
# In Python a value of 0 means Jan 1 1970 (use negatives for earlier
# start date). This will affect the returns from the utils.gametime
# module.
# module. If None, the server's first start-time is used as the epoch.
TIME_GAME_EPOCH = None
# Normally, game time will only increase when the server runs. If this is True,
# game time will not pause when the server reloads or goes offline. This setting
# together with a time factor of 1 should keep the game in sync with
# the real time (add a different epoch to shift time)
TIME_IGNORE_DOWNTIMES = False
######################################################################
# Inlinefunc
@ -534,8 +545,8 @@ INLINEFUNC_MODULES = ["evennia.utils.inlinefuncs",
# 3 - like mode 2, except multiple sessions can puppet one character, each
# session getting the same data.
MULTISESSION_MODE = 0
# The maximum number of characters allowed for MULTISESSION_MODE 2,3. This is
# checked by the default ooc char-creation command. Forced to 1 for
# The maximum number of characters allowed for MULTISESSION_MODE 2, 3.
# This is checked by the default ooc char-creation command. Forced to 1 for
# MULTISESSION_MODE 0 and 1.
MAX_NR_CHARACTERS = 1
# The access hierarchy, in climbing order. A higher permission in the

View file

@ -245,7 +245,7 @@ class AttributeHandler(object):
found from cache or database.
Notes:
When given a category only, a search for all objects
of that cateogory is done and a the category *name* is is
of that cateogory is done and the category *name* is
stored. This tells the system on subsequent calls that the
list of cached attributes of this category is up-to-date
and that the cache can be queried for category matches

View file

@ -149,6 +149,7 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
# Tag manager methods
def get_tag(self, key=None, category=None, obj=None, tagtype=None, global_search=False):
"""
Return Tag objects by key, by category, by object (it is
@ -228,25 +229,58 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
def get_by_tag(self, key=None, category=None, tagtype=None):
"""
Return objects having tags with a given key or category or
combination of the two.
Return objects having tags with a given key or category or combination of the two.
Also accepts multiple tags/category/tagtype
Args:
key (str, optional): Tag key. Not case sensitive.
category (str, optional): Tag category. Not case sensitive.
tagtype (str or None, optional): 'type' of Tag, by default
key (str or list, optional): Tag key or list of keys. Not case sensitive.
category (str or list, optional): Tag category. Not case sensitive. If `key` is
a list, a single category can either apply to all keys in that list or this
must be a list matching the `key` list element by element. If no `key` is given,
all objects with tags of this category are returned.
tagtype (str, optional): 'type' of Tag, by default
this is either `None` (a normal Tag), `alias` or
`permission`.
`permission`. This always apply to all queried tags.
Returns:
objects (list): Objects with matching tag.
Raises:
IndexError: If `key` and `category` are both lists and `category` is shorter
than `key`.
"""
if not (key or category):
return []
keys = make_iter(key) if key else []
categories = make_iter(category) if category else []
n_keys = len(keys)
n_categories = len(categories)
dbmodel = self.model.__dbclass__.__name__.lower()
query = [("db_tags__db_tagtype", tagtype), ("db_tags__db_model", dbmodel)]
if key:
query.append(("db_tags__db_key", key.lower()))
if category:
query.append(("db_tags__db_category", category.lower()))
return self.filter(**dict(query))
query = self.filter(db_tags__db_tagtype__iexact=tagtype,
db_tags__db_model__iexact=dbmodel).distinct()
if n_keys > 0:
# keys and/or categories given
if n_categories == 0:
categories = [None for _ in range(n_keys)]
elif n_categories == 1 and n_keys > 1:
cat = categories[0]
categories = [cat for _ in range(n_keys)]
elif 1 < n_categories < n_keys:
raise IndexError("get_by_tag needs a single category or a list of categories "
"the same length as the list of tags.")
for ikey, key in enumerate(keys):
query = query.filter(db_tags__db_key__iexact=key,
db_tags__db_category__iexact=categories[ikey])
else:
# only one or more categories given
for category in categories:
query = query.filter(db_tags__db_category__iexact=category)
return query
def get_by_permission(self, key=None, category=None):
"""

View file

@ -269,14 +269,15 @@ class TagHandler(object):
def get(self, key=None, default=None, category=None, return_tagobj=False, return_list=False):
"""
Get the tag for the given key or list of tags.
Get the tag for the given key, category or combination of the two.
Args:
key (str or list): The tag or tags to retrieve.
key (str or list, optional): The tag or tags to retrieve.
default (any, optional): The value to return in case of no match.
category (str, optional): The Tag category to limit the
request to. Note that `None` is the valid, default
category.
category. If no `key` is given, all tags of this category will be
returned.
return_tagobj (bool, optional): Return the Tag object itself
instead of a string representation of the Tag.
return_list (bool, optional): Always return a list, regardless

View file

@ -0,0 +1,59 @@
"""
Unit tests for typeclass base system
"""
from evennia.utils.test_resources import EvenniaTest
# ------------------------------------------------------------
# Manager tests
# ------------------------------------------------------------
class TestTypedObjectManager(EvenniaTest):
def _manager(self, methodname, *args, **kwargs):
return list(getattr(self.obj1.__class__.objects, methodname)(*args, **kwargs))
def test_get_by_tag_no_category(self):
self.obj1.tags.add("tag1")
self.obj1.tags.add("tag2")
self.obj1.tags.add("tag2c")
self.obj2.tags.add("tag2")
self.obj2.tags.add("tag2a")
self.obj2.tags.add("tag2b")
self.obj2.tags.add("tag3 with spaces")
self.obj2.tags.add("tag4")
self.obj2.tags.add("tag2c")
self.assertEquals(self._manager("get_by_tag", "tag1"), [self.obj1])
self.assertEquals(self._manager("get_by_tag", "tag2"), [self.obj1, self.obj2])
self.assertEquals(self._manager("get_by_tag", "tag2a"), [self.obj2])
self.assertEquals(self._manager("get_by_tag", "tag3 with spaces"), [self.obj2])
self.assertEquals(self._manager("get_by_tag", ["tag2a", "tag2b"]), [self.obj2])
self.assertEquals(self._manager("get_by_tag", ["tag2a", "tag1"]), [])
self.assertEquals(self._manager("get_by_tag", ["tag2a", "tag4", "tag2c"]), [self.obj2])
def test_get_by_tag_and_category(self):
self.obj1.tags.add("tag5", "category1")
self.obj1.tags.add("tag6", )
self.obj1.tags.add("tag7", "category1")
self.obj1.tags.add("tag6", "category3")
self.obj1.tags.add("tag7", "category4")
self.obj2.tags.add("tag5", "category1")
self.obj2.tags.add("tag5", "category2")
self.obj2.tags.add("tag6", "category3")
self.obj2.tags.add("tag7", "category1")
self.obj2.tags.add("tag7", "category5")
self.assertEquals(self._manager("get_by_tag", "tag5", "category1"), [self.obj1, self.obj2])
self.assertEquals(self._manager("get_by_tag", "tag6", "category1"), [])
self.assertEquals(self._manager("get_by_tag", "tag6", "category3"), [self.obj1, self.obj2])
self.assertEquals(self._manager("get_by_tag", ["tag5", "tag6"],
["category1", "category3"]), [self.obj1, self.obj2])
self.assertEquals(self._manager("get_by_tag", ["tag5", "tag7"],
"category1"), [self.obj1, self.obj2])
self.assertEquals(self._manager("get_by_tag", category="category1"), [self.obj1, self.obj2])
self.assertEquals(self._manager("get_by_tag", category="category2"), [self.obj2])
self.assertEquals(self._manager("get_by_tag", category=["category1", "category3"]),
[self.obj1, self.obj2])
self.assertEquals(self._manager("get_by_tag", category=["category1", "category2"]),
[self.obj2])
self.assertEquals(self._manager("get_by_tag", category=["category5", "category4"]), [])

View file

@ -63,23 +63,31 @@ menu is immediately exited and the default "look" command is called.
text (str, tuple or None): Text shown at this node. If a tuple, the
second element in the tuple is a help text to display at this
node when the user enters the menu help command there.
options (tuple, dict or None): (
{'key': name, # can also be a list of aliases. A special key is
# "_default", which marks this option as the default
# fallback when no other option matches the user input.
'desc': description, # optional description
'goto': nodekey, # node to go to when chosen. This can also be a callable with
# caller and/or raw_string args. It must return a string
# with the key pointing to the node to go to.
'exec': nodekey}, # node or callback to trigger as callback when chosen. This
# will execute *before* going to the next node. Both node
# and the explicit callback will be called as normal nodes
# (with caller and/or raw_string args). If the callable/node
# returns a single string (only), this will replace the current
# goto location string in-place (if a goto callback, it will never fire).
# Note that relying to much on letting exec assign the goto
# location can make it hard to debug your menu logic.
{...}, ...)
options (tuple, dict or None): If `None`, this exits the menu.
If a single dict, this is a single-option node. If a tuple,
it should be a tuple of option dictionaries. Option dicts have
the following keys:
- `key` (str or tuple, optional): What to enter to choose this option.
If a tuple, it must be a tuple of strings, where the first string is the
key which will be shown to the user and the others are aliases.
If unset, the options' number will be used. The special key `_default`
marks this option as the default fallback when no other option matches
the user input. There can only be one `_default` option per node. It
will not be displayed in the list.
- `desc` (str, optional): This describes what choosing the option will do.
- `goto` (str, tuple or callable): If string, should be the name of node to go to
when this option is selected. If a callable, it has the signature
`callable(caller[,raw_input][,**kwargs]). If a tuple, the first element
is the callable and the second is a dict with the **kwargs to pass to
the callable. Those kwargs will also be passed into the next node if possible.
Such a callable should return either a str or a (str, dict), where the
string is the name of the next node to go to and the dict is the new,
(possibly modified) kwarg to pass into the next node.
- `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 key is not given, the option will automatically be identified by
its number 1..N.
@ -95,7 +103,7 @@ Example:
"This is help text for this node")
options = ({"key": "testing",
"desc": "Select this to go to node 2",
"goto": "node2",
"goto": ("node2", {"foo": "bar"}),
"exec": "callback1"},
{"desc": "Go to node 3.",
"goto": "node3"})
@ -108,12 +116,13 @@ Example:
# by the normal 'goto' option key above.
caller.msg("Callback called!")
def node2(caller):
def node2(caller, **kwargs):
text = '''
This is node 2. It only allows you to go back
to the original node1. This extra indent will
be stripped. We don't include a help text.
'''
be stripped. We don't include a help text but
here are the variables passed to us: {}
'''.format(kwargs)
options = {"goto": "node1"}
return text, options
@ -148,6 +157,7 @@ evennia.utils.evmenu`.
"""
from __future__ import print_function
import random
from builtins import object, range
from textwrap import dedent
@ -260,7 +270,7 @@ class CmdEvMenuNode(Command):
err = "Menu object not found as %s.ndb._menutree!" % orig_caller
orig_caller.msg(err) # don't give the session as a kwarg here, direct to original
raise EvMenuError(err)
# we must do this after the caller with the menui has been correctly identified since it
# we must do this after the caller with the menu has been correctly identified since it
# can be either Account, Object or Session (in the latter case this info will be superfluous).
caller.ndb._menutree._session = self.session
# we have a menu, use it.
@ -359,9 +369,10 @@ class EvMenu(object):
re-run with the same input arguments - so be careful if you are counting
up some persistent counter or similar - the counter may be run twice if
reload happens on the node that does that.
startnode_input (str, optional): Send an input text to `startnode` as if
a user input text from a fictional previous node. When the server reloads,
the latest visited node will be re-run using this kwarg.
startnode_input (str or (str, dict), optional): Send an input text to `startnode` as if
a user input text from a fictional previous node. If including the dict, this will
be passed as **kwargs to that node. When the server reloads,
the latest visited node will be re-run as `node(caller, raw_string, **kwargs)`.
session (Session, optional): This is useful when calling EvMenu from an account
in multisession mode > 2. Note that this session only really relevant
for the very first display of the first node - after that, EvMenu itself
@ -391,6 +402,7 @@ class EvMenu(object):
self._startnode = startnode
self._menutree = self._parse_menudata(menudata)
self._persistent = persistent
self._quitting = False
if startnode not in self._menutree:
raise EvMenuError("Start node '%s' not in menu tree!" % startnode)
@ -417,6 +429,12 @@ class EvMenu(object):
self.nodetext = None
self.helptext = None
self.options = None
self.nodename = None
self.node_kwargs = {}
# used for testing
self.test_options = {}
self.test_nodetext = ""
# assign kwargs as initialization vars on ourselves.
if set(("_startnode", "_menutree", "_session", "_persistent",
@ -463,8 +481,13 @@ class EvMenu(object):
menu_cmdset.priority = int(cmdset_priority)
self.caller.cmdset.add(menu_cmdset, permanent=persistent)
startnode_kwargs = {}
if isinstance(startnode_input, (tuple, list)) and len(startnode_input) > 1:
startnode_input, startnode_kwargs = startnode_input[:2]
if not isinstance(startnode_kwargs, dict):
raise EvMenuError("startnode_input must be either a str or a tuple (str, dict).")
# start the menu
self.goto(self._startnode, startnode_input)
self.goto(self._startnode, startnode_input, **startnode_kwargs)
def _parse_menudata(self, menudata):
"""
@ -519,7 +542,42 @@ class EvMenu(object):
# format the entire node
return self.node_formatter(nodetext, optionstext)
def _execute_node(self, nodename, raw_string):
def _safe_call(self, callback, raw_string, **kwargs):
"""
Call a node-like callable, with a variable number of raw_string, *args, **kwargs, all of
which should work also if not present (only `caller` is always required). Return its result.
"""
try:
try:
nargs = len(getargspec(callback).args)
except TypeError:
raise EvMenuError("Callable {} doesn't accept any arguments!".format(callback))
supports_kwargs = bool(getargspec(callback).keywords)
if nargs <= 0:
raise EvMenuError("Callable {} doesn't accept any arguments!".format(callback))
if supports_kwargs:
if nargs > 1:
ret = callback(self.caller, raw_string, **kwargs)
# callback accepting raw_string, **kwargs
else:
# callback accepting **kwargs
ret = callback(self.caller, **kwargs)
elif nargs > 1:
# callback accepting raw_string
ret = callback(self.caller, raw_string)
else:
# normal callback, only the caller as arg
ret = callback(self.caller)
except EvMenuError:
errmsg = _ERR_GENERAL.format(nodename=callback)
self.caller.msg(errmsg, self._session)
raise
return ret
def _execute_node(self, nodename, raw_string, **kwargs):
"""
Execute a node.
@ -528,6 +586,7 @@ class EvMenu(object):
raw_string (str): The raw default string entered on the
previous node (only used if the node accepts it as an
argument)
kwargs (any, optional): Optional kwargs for the node.
Returns:
nodetext, options (tuple): The node text (a string or a
@ -540,47 +599,25 @@ class EvMenu(object):
self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session)
raise EvMenuError
try:
# the node should return data as (text, options)
if len(getargspec(node).args) > 1:
# a node accepting raw_string
nodetext, options = node(self.caller, raw_string)
ret = self._safe_call(node, raw_string, **kwargs)
if isinstance(ret, (tuple, list)) and len(ret) > 1:
nodetext, options = ret[:2]
else:
# a normal node, only accepting caller
nodetext, options = node(self.caller)
nodetext, options = ret, None
except KeyError:
self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session)
raise EvMenuError
except Exception:
self.caller.msg(_ERR_GENERAL.format(nodename=nodename), session=self._session)
raise
# store options to make them easier to test
self.test_options = options
self.test_nodetext = nodetext
return nodetext, options
def display_nodetext(self):
self.caller.msg(self.nodetext, session=self._session)
def display_helptext(self):
self.caller.msg(self.helptext, session=self._session)
def callback_goto(self, callback, goto, raw_string):
"""
Call callback and goto in sequence.
Args:
callback (callable or str): Callback to run before goto. If
the callback returns a string, this is used to replace
the `goto` string before going to the next node.
goto (str): The target node to go to next (unless replaced
by `callable`)..
raw_string (str): The original user input.
"""
if callback:
# replace goto only if callback returns
goto = self.callback(callback, raw_string) or goto
if goto:
self.goto(goto, raw_string)
def callback(self, nodename, raw_string):
def run_exec(self, nodename, raw_string, **kwargs):
"""
Run a function or node as a callback (with the 'exec' option key).
@ -592,6 +629,8 @@ class EvMenu(object):
raw_string (str): The raw default string entered on the
previous node (only used if the node accepts it as an
argument)
kwargs (any): These are optional kwargs passed into goto
Returns:
new_goto (str or None): A replacement goto location string or
None (no replacement).
@ -602,36 +641,36 @@ class EvMenu(object):
relying on this.
"""
if callable(nodename):
# this is a direct callable - execute it directly
try:
if len(getargspec(nodename).args) > 1:
# callable accepting raw_string
ret = nodename(self.caller, raw_string)
else:
# normal callable, only the caller as arg
ret = nodename(self.caller)
except Exception:
self.caller.msg(_ERR_GENERAL.format(nodename=nodename), self._session)
raise
else:
# nodename is a string; lookup as node
try:
try:
if callable(nodename):
# this is a direct callable - execute it directly
ret = self._safe_call(nodename, raw_string, **kwargs)
if isinstance(ret, (tuple, list)):
if not len(ret) > 1 or not isinstance(ret[1], dict):
raise EvMenuError("exec callable must return either None, str or (str, dict)")
ret, kwargs = ret[:2]
else:
# nodename is a string; lookup as node and run as node in-place (don't goto it)
# execute the node
ret = self._execute_node(nodename, raw_string)
except EvMenuError as err:
errmsg = "Error in exec '%s' (input: '%s'): %s" % (nodename, raw_string, err)
self.caller.msg("|r%s|n" % errmsg)
logger.log_trace(errmsg)
return
ret = self._execute_node(nodename, raw_string, **kwargs)
if isinstance(ret, (tuple, list)):
if not len(ret) > 1 and ret[1] and not isinstance(ret[1], dict):
raise EvMenuError("exec node must return either None, str or (str, dict)")
ret, kwargs = ret[:2]
except EvMenuError as err:
errmsg = "Error in exec '%s' (input: '%s'): %s" % (nodename, raw_string.rstrip(), err)
self.caller.msg("|r%s|n" % errmsg)
logger.log_trace(errmsg)
return
if isinstance(ret, basestring):
# only return a value if a string (a goto target), ignore all other returns
return ret
return ret, kwargs
return None
def goto(self, nodename, raw_string):
def goto(self, nodename, raw_string, **kwargs):
"""
Run a node by name
Run a node by name, optionally dynamically generating that name first.
Args:
nodename (str or callable): Name of node or a callable
@ -642,24 +681,48 @@ class EvMenu(object):
argument)
"""
if callable(nodename):
try:
if len(getargspec(nodename).args) > 1:
# callable accepting raw_string
nodename = nodename(self.caller, raw_string)
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:
nodename = nodename(self.caller)
except Exception:
self.caller.msg(_ERR_GENERAL.format(nodename=nodename), self._session)
raise
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
inp_nodename = nodename
nodename = self._safe_call(nodename, raw_string, **kwargs)
if isinstance(nodename, (tuple, list)):
if not len(nodename) > 1 or not isinstance(nodename[1], dict):
raise EvMenuError(
"{}: goto callable must return str or (str, dict)".format(inp_nodename))
nodename, kwargs = nodename[:2]
try:
# execute the node, make use of the returns.
nodetext, options = self._execute_node(nodename, raw_string)
# execute the found node, make use of the returns.
nodetext, options = self._execute_node(nodename, raw_string, **kwargs)
except EvMenuError:
return
if self._persistent:
self.caller.attributes.add("_menutree_saved_startnode", (nodename, raw_string))
self.caller.attributes.add("_menutree_saved_startnode",
(nodename, (raw_string, kwargs)))
# validation of the node return values
helptext = ""
@ -680,22 +743,25 @@ class EvMenu(object):
for inum, dic in enumerate(options):
# fix up the option dicts
keys = make_iter(dic.get("key"))
desc = dic.get("desc", dic.get("text", None))
if "_default" in keys:
keys = [key for key in keys if key != "_default"]
desc = dic.get("desc", dic.get("text", _ERR_NO_OPTION_DESC).strip())
goto, execute = dic.get("goto", None), dic.get("exec", None)
self.default = (goto, execute)
goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic)
self.default = (goto, goto_kwargs, execute, exec_kwargs)
else:
# use the key (only) if set, otherwise use the running number
keys = list(make_iter(dic.get("key", str(inum + 1).strip())))
desc = dic.get("desc", dic.get("text", _ERR_NO_OPTION_DESC).strip())
goto, execute = dic.get("goto", None), dic.get("exec", None)
goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic)
if keys:
display_options.append((keys[0], desc))
for key in keys:
if goto or execute:
self.options[strip_ansi(key).strip().lower()] = (goto, execute)
self.options[strip_ansi(key).strip().lower()] = \
(goto, goto_kwargs, execute, exec_kwargs)
self.nodetext = self._format_node(nodetext, display_options)
self.node_kwargs = kwargs
self.nodename = nodename
# handle the helptext
if helptext:
@ -709,17 +775,44 @@ class EvMenu(object):
if not options:
self.close_menu()
def run_exec_then_goto(self, runexec, goto, raw_string, runexec_kwargs=None, goto_kwargs=None):
"""
Call 'exec' callback and goto (which may also be a callable) in sequence.
Args:
runexec (callable or str): Callback to run before goto. If
the callback returns a string, this is used to replace
the `goto` string/callable before being passed into the goto handler.
goto (str): The target node to go to next (may be replaced
by `runexec`)..
raw_string (str): The original user input.
runexec_kwargs (dict, optional): Optional kwargs for runexec.
goto_kwargs (dict, optional): Optional kwargs for goto.
"""
if runexec:
# replace goto only if callback returns
goto, goto_kwargs = (
self.run_exec(runexec, raw_string,
**(runexec_kwargs if runexec_kwargs else {})) or
(goto, goto_kwargs))
if goto:
self.goto(goto, raw_string, **(goto_kwargs if goto_kwargs else {}))
def close_menu(self):
"""
Shutdown menu; occurs when reaching the end node or using the quit command.
"""
self.caller.cmdset.remove(EvMenuCmdSet)
del self.caller.ndb._menutree
if self._persistent:
self.caller.attributes.remove("_menutree_saved")
self.caller.attributes.remove("_menutree_saved_startnode")
if self.cmd_on_exit is not None:
self.cmd_on_exit(self.caller, self)
if not self._quitting:
# avoid multiple calls from different sources
self._quitting = True
self.caller.cmdset.remove(EvMenuCmdSet)
del self.caller.ndb._menutree
if self._persistent:
self.caller.attributes.remove("_menutree_saved")
self.caller.attributes.remove("_menutree_saved_startnode")
if self.cmd_on_exit is not None:
self.cmd_on_exit(self.caller, self)
def parse_input(self, raw_string):
"""
@ -734,13 +827,13 @@ class EvMenu(object):
should also report errors directly to the user.
"""
cmd = raw_string.strip().lower()
cmd = strip_ansi(raw_string.strip().lower())
if cmd in self.options:
# this will take precedence over the default commands
# below
goto, callback = self.options[cmd]
self.callback_goto(callback, goto, raw_string)
goto, goto_kwargs, execfunc, exec_kwargs = self.options[cmd]
self.run_exec_then_goto(execfunc, goto, raw_string, exec_kwargs, goto_kwargs)
elif self.auto_look and cmd in ("look", "l"):
self.display_nodetext()
elif self.auto_help and cmd in ("help", "h"):
@ -748,11 +841,17 @@ class EvMenu(object):
elif self.auto_quit and cmd in ("quit", "q", "exit"):
self.close_menu()
elif self.default:
goto, callback = self.default
self.callback_goto(callback, goto, raw_string)
goto, goto_kwargs, execfunc, exec_kwargs = self.default
self.run_exec_then_goto(execfunc, goto, raw_string, exec_kwargs, goto_kwargs)
else:
self.caller.msg(_HELP_NO_OPTION_MATCH, session=self._session)
def display_nodetext(self):
self.caller.msg(self.nodetext, session=self._session)
def display_helptext(self):
self.caller.msg(self.helptext, session=self._session)
# formatters - override in a child class
def nodetext_formatter(self, nodetext):
@ -795,16 +894,17 @@ class EvMenu(object):
for key, desc in optionlist:
if not (key or desc):
continue
desc_string = ": %s" % desc if desc else ""
table_width_max = max(table_width_max,
max(m_len(p) for p in key.split("\n")) +
max(m_len(p) for p in desc.split("\n")) + colsep)
max(m_len(p) for p in desc_string.split("\n")) + colsep)
raw_key = strip_ansi(key)
if raw_key != key:
# already decorations in key definition
table.append(" |lc%s|lt%s|le: %s" % (raw_key, key, desc))
table.append(" |lc%s|lt%s|le%s" % (raw_key, key, desc_string))
else:
# add a default white color to key
table.append(" |lc%s|lt|w%s|n|le: %s" % (raw_key, raw_key, desc))
table.append(" |lc%s|lt|w%s|n|le%s" % (raw_key, raw_key, desc_string))
ncols = (_MAX_TEXT_WIDTH // table_width_max) + 1 # number of ncols
@ -992,6 +1092,10 @@ def get_input(caller, prompt, callback, session=None, *args, **kwargs):
#
# -------------------------------------------------------------
def _generate_goto(caller, **kwargs):
return kwargs.get("name", "test_dynamic_node"), {"name": "replaced!"}
def test_start_node(caller):
menu = caller.ndb._menutree
text = """
@ -1016,6 +1120,9 @@ def test_start_node(caller):
{"key": ("|yV|niew", "v"),
"desc": "View your own name",
"goto": "test_view_node"},
{"key": ("|yD|nynamic", "d"),
"desc": "Dynamic node",
"goto": (_generate_goto, {"name": "test_dynamic_node"})},
{"key": ("|yQ|nuit", "quit", "q", "Q"),
"desc": "Quit this menu example.",
"goto": "test_end_node"},
@ -1025,7 +1132,7 @@ def test_start_node(caller):
def test_look_node(caller):
text = ""
text = "This is a custom look location!"
options = {"key": ("|yL|nook", "l"),
"desc": "Go back to the previous menu.",
"goto": "test_start_node"}
@ -1052,12 +1159,11 @@ def test_set_node(caller):
""")
options = {"key": ("back (default)", "_default"),
"desc": "back to main",
"goto": "test_start_node"}
return text, options
def test_view_node(caller):
def test_view_node(caller, **kwargs):
text = """
Your name is |g%s|n!
@ -1067,9 +1173,14 @@ def test_view_node(caller):
-always- use numbers (1...N) to refer to listed options also if you
don't see a string option key (try it!).
""" % caller.key
options = {"desc": "back to main",
"goto": "test_start_node"}
return text, options
if kwargs.get("executed_from_dynamic_node", False):
# we are calling this node as a exec, skip return values
caller.msg("|gCalled from dynamic node:|n \n {}".format(text))
return
else:
options = {"desc": "back to main",
"goto": "test_start_node"}
return text, options
def test_displayinput_node(caller, raw_string):
@ -1085,12 +1196,48 @@ def test_displayinput_node(caller, raw_string):
makes it hidden from view. It catches all input (except the
in-menu help/quit commands) and will, in this case, bring you back
to the start node.
""" % raw_string
""" % raw_string.rstrip()
options = {"key": "_default",
"goto": "test_start_node"}
return text, options
def _test_call(caller, raw_input, **kwargs):
mode = kwargs.get("mode", "exec")
caller.msg("\n|y'{}' |n_test_call|y function called with\n "
"caller: |n{}\n |yraw_input: \"|n{}|y\" \n kwargs: |n{}\n".format(
mode, caller, raw_input.rstrip(), kwargs))
if mode == "exec":
kwargs = {"random": random.random()}
caller.msg("function modify kwargs to {}".format(kwargs))
else:
caller.msg("|ypassing function kwargs without modification.|n")
return "test_dynamic_node", kwargs
def test_dynamic_node(caller, **kwargs):
text = """
This is a dynamic node with input:
{}
""".format(kwargs)
options = ({"desc": "pass a new random number to this node",
"goto": ("test_dynamic_node", {"random": random.random()})},
{"desc": "execute a func with kwargs",
"exec": (_test_call, {"mode": "exec", "test_random": random.random()})},
{"desc": "dynamic_goto",
"goto": (_test_call, {"mode": "goto", "goto_input": "test"})},
{"desc": "exec test_view_node with kwargs",
"exec": ("test_view_node", {"executed_from_dynamic_node": True}),
"goto": "test_dynamic_node"},
{"desc": "back to main",
"goto": "test_start_node"})
return text, options
def test_end_node(caller):
text = """
This is the end of the menu and since it has no options the menu

View file

@ -19,6 +19,8 @@ from evennia.utils.create import create_script
# to real time.
TIMEFACTOR = settings.TIME_FACTOR
IGNORE_DOWNTIMES = settings.TIME_IGNORE_DOWNTIMES
# Only set if gametime_reset was called at some point.
GAME_TIME_OFFSET = ServerConfig.objects.conf("gametime_offset", default=0)
@ -133,7 +135,10 @@ def gametime(absolute=False):
"""
epoch = game_epoch() if absolute else 0
gtime = epoch + (runtime() - GAME_TIME_OFFSET) * TIMEFACTOR
if IGNORE_DOWNTIMES:
gtime = epoch + (time.time() - server_epoch()) * TIMEFACTOR
else:
gtime = epoch + (runtime() - GAME_TIME_OFFSET) * TIMEFACTOR
return gtime

View file

@ -20,6 +20,7 @@ import time
from datetime import datetime
from traceback import format_exc
from twisted.python import log, logfile
from twisted.python import util as twisted_util
from twisted.internet.threads import deferToThread
@ -29,10 +30,15 @@ _TIMEZONE = None
_CHANNEL_LOG_NUM_TAIL_LINES = None
# logging overrides
def timeformat(when=None):
"""
This helper function will format the current time in the same
way as twisted's logger does, including time zone info.
way as the twisted logger does, including time zone info. Only
difference from official logger is that we only use two digits
for the year and don't show timezone for CET times.
Args:
when (int, optional): This is a time in POSIX seconds on the form
@ -49,14 +55,70 @@ def timeformat(when=None):
tz_offset = tz_offset.days * 86400 + tz_offset.seconds
# correct given time to utc
when = datetime.utcfromtimestamp(when - tz_offset)
tz_hour = abs(int(tz_offset // 3600))
tz_mins = abs(int(tz_offset // 60 % 60))
tz_sign = "-" if tz_offset >= 0 else "+"
return '%d-%02d-%02d %02d:%02d:%02d%s%02d%02d' % (
when.year, when.month, when.day,
when.hour, when.minute, when.second,
tz_sign, tz_hour, tz_mins)
if tz_offset == 0:
tz = ""
else:
tz_hour = abs(int(tz_offset // 3600))
tz_mins = abs(int(tz_offset // 60 % 60))
tz_sign = "-" if tz_offset >= 0 else "+"
tz = "%s%02d%s" % (tz_sign, tz_hour,
(":%02d" % tz_mins if tz_mins else ""))
return '%d-%02d-%02d %02d:%02d:%02d%s' % (
when.year - 2000, when.month, when.day,
when.hour, when.minute, when.second, tz)
class WeeklyLogFile(logfile.DailyLogFile):
"""
Log file that rotates once per week
"""
day_rotation = 7
def shouldRotate(self):
"""Rotate when the date has changed since last write"""
# all dates here are tuples (year, month, day)
now = self.toDate()
then = self.lastDate
return now[0] > then[0] or now[1] > then[1] or now[2] > (then[2] + self.day_rotation)
def write(self, data):
"Write data to log file"
logfile.BaseLogFile.write(self, data)
self.lastDate = max(self.lastDate, self.toDate())
class PortalLogObserver(log.FileLogObserver):
"""
Reformat logging
"""
timeFormat = None
prefix = " |Portal| "
def emit(self, eventDict):
"""
Copied from Twisted parent, to change logging output
"""
text = log.textFromEventDict(eventDict)
if text is None:
return
# timeStr = self.formatTime(eventDict["time"])
timeStr = timeformat(eventDict["time"])
fmtDict = {
"text": text.replace("\n", "\n\t")}
msgStr = log._safeFormat("%(text)s\n", fmtDict)
twisted_util.untilConcludes(self.write, timeStr + "%s" % self.prefix + msgStr)
twisted_util.untilConcludes(self.flush)
class ServerLogObserver(PortalLogObserver):
prefix = " "
def log_msg(msg):
@ -124,6 +186,20 @@ def log_err(errmsg):
log_errmsg = log_err
def log_server(servermsg):
"""
This is for the Portal to log captured Server stdout messages (it's
usually only used during startup, before Server log is open)
"""
try:
servermsg = str(servermsg)
except Exception as e:
servermsg = str(e)
for line in servermsg.splitlines():
log_msg('[Server] %s' % line)
def log_warn(warnmsg):
"""
Prints/logs any warnings that aren't critical but should be noted.

View file

@ -174,7 +174,7 @@ def _batch_create_object(*objparams):
objects (list): A list of created objects
Notes:
The `exec` list will execute arbitrary python code so don't allow this to be availble to
The `exec` list will execute arbitrary python code so don't allow this to be available to
unprivileged users!
"""

View file

@ -1,24 +1,227 @@
"""
Unit tests for the EvMenu system
TODO: This need expansion.
This sets up a testing parent for testing EvMenu trees. It is configured by subclassing the
`TestEvMenu` class from this module and setting the class variables to point to the menu that should
be tested and how it should be called.
Without adding any further test methods, the tester will process all nodes of the menu, depth first,
by stepping through all options for every node. Optionally, it can check that all nodes are visited.
It will create a hierarchical list of node names that describes the tree structure. This can then be
compared against a template to make sure the menu structure is sound. Easiest way to use this is to
run the test once to see how the structure looks.
The system also allows for testing the returns of each node as part of the parsing.
To help debug the menu, turn on `debug_output`, which will print the traversal process in detail.
"""
import copy
from django.test import TestCase
from evennia.utils import evmenu
from mock import Mock
from evennia.utils import ansi
from mock import MagicMock
class TestEvMenu(TestCase):
"Run the EvMenu testing."
menutree = {} # can also be the path to the menu tree
startnode = "start"
cmdset_mergetype = "Replace"
cmdset_priority = 1
auto_quit = True
auto_look = True
auto_help = True
cmd_on_exit = "look"
persistent = False
startnode_input = ""
kwargs = {}
# if all nodes must be visited for the test to pass. This is not on
# by default since there may be exec-nodes that are made to not be
# visited.
expect_all_nodes = False
# this is compared against the full tree structure generated
expected_tree = []
# this allows for verifying that a given node returns a given text. The
# text is compared with .startswith, so the entire text need not be matched.
expected_node_texts = {}
# just check the number of options from each node
expected_node_options_count = {}
# check the actual options
expected_node_options = {}
# set this to print the traversal as it happens (debugging)
debug_output = False
def _debug_output(self, indent, msg):
if self.debug_output:
print(" " * indent + msg)
def _test_menutree(self, menu):
"""
This is a automatic tester of the menu tree by recursively progressing through the
structure.
"""
def _depth_first(menu, tree, visited, indent):
# we are in a given node here
nodename = menu.nodename
options = menu.test_options
if isinstance(options, dict):
options = (options, )
# run validation tests for this node
compare_text = self.expected_node_texts.get(nodename, None)
if compare_text is not None:
compare_text = ansi.strip_ansi(compare_text.strip())
node_text = menu.test_nodetext
self.assertIsNotNone(
bool(node_text),
"node: {}: node-text is None, which was not expected.".format(nodename))
node_text = ansi.strip_ansi(node_text.strip())
self.assertTrue(
node_text.startswith(compare_text),
"\nnode \"{}\':\nOutput:\n{}\n\nExpected (startswith):\n{}".format(
nodename, node_text, compare_text))
compare_options_count = self.expected_node_options_count.get(nodename, None)
if compare_options_count is not None:
self.assertEqual(
len(options), compare_options_count,
"Not the right number of options returned from node {}.".format(nodename))
compare_options = self.expected_node_options.get(nodename, None)
if compare_options:
self.assertEqual(
options, compare_options,
"Options returned from node {} does not match.".format(nodename))
self._debug_output(indent, "*{}".format(nodename))
subtree = []
if not options:
# an end node
if nodename not in visited:
visited.append(nodename)
subtree = nodename
else:
for inum, optdict in enumerate(options):
key, desc, execute, goto = optdict.get("key", ""), optdict.get("desc", None),\
optdict.get("exec", None), optdict.get("goto", None)
# prepare the key to pass to the menu
if isinstance(key, (tuple, list)) and len(key) > 1:
key = key[0]
if key == "_default":
key = "test raw input"
if not key:
key = str(inum + 1)
backup_menu = copy.copy(menu)
# step the menu
menu.parse_input(key)
# from here on we are likely in a different node
nodename = menu.nodename
if menu.close_menu.called:
# this was an end node
self._debug_output(indent, " .. menu exited! Back to previous node.")
menu = backup_menu
menu.close_menu = MagicMock()
visited.append(nodename)
subtree.append(nodename)
elif nodename not in visited:
visited.append(nodename)
subtree.append(nodename)
_depth_first(menu, subtree, visited, indent + 2)
#self._debug_output(indent, " -> arrived at {}".format(nodename))
else:
subtree.append(nodename)
#self._debug_output( indent, " -> arrived at {} (circular call)".format(nodename))
self._debug_output(indent, "-- {} ({}) -> {}".format(key, desc, goto))
if subtree:
tree.append(subtree)
# the start node has already fired at this point
visited_nodes = [menu.nodename]
traversal_tree = [menu.nodename]
_depth_first(menu, traversal_tree, visited_nodes, 1)
if self.expect_all_nodes:
self.assertGreaterEqual(len(menu._menutree), len(visited_nodes))
self.assertEqual(traversal_tree, self.expected_tree)
def setUp(self):
self.caller = Mock()
self.caller.msg = Mock()
self.menu = evmenu.EvMenu(self.caller, "evennia.utils.evmenu", startnode="test_start_node",
persistent=True, cmdset_mergetype="Replace", testval="val",
testval2="val2")
self.menu = None
if self.menutree:
self.caller = MagicMock()
self.caller.key = "Test"
self.caller2 = MagicMock()
self.caller2.key = "Test"
self.caller.msg = MagicMock()
self.caller2.msg = MagicMock()
self.session = MagicMock()
self.session2 = MagicMock()
self.menu = evmenu.EvMenu(self.caller, self.menutree, startnode=self.startnode,
cmdset_mergetype=self.cmdset_mergetype,
cmdset_priority=self.cmdset_priority,
auto_quit=self.auto_quit, auto_look=self.auto_look,
auto_help=self.auto_help,
cmd_on_exit=self.cmd_on_exit, persistent=False,
startnode_input=self.startnode_input, session=self.session,
**self.kwargs)
# persistent version
self.pmenu = evmenu.EvMenu(self.caller2, self.menutree, startnode=self.startnode,
cmdset_mergetype=self.cmdset_mergetype,
cmdset_priority=self.cmdset_priority,
auto_quit=self.auto_quit, auto_look=self.auto_look,
auto_help=self.auto_help,
cmd_on_exit=self.cmd_on_exit, persistent=True,
startnode_input=self.startnode_input, session=self.session2,
**self.kwargs)
self.menu.close_menu = MagicMock()
self.pmenu.close_menu = MagicMock()
def test_menu_structure(self):
if self.menu:
self._test_menutree(self.menu)
self._test_menutree(self.pmenu)
class TestEvMenuExample(TestEvMenu):
menutree = "evennia.utils.evmenu"
startnode = "test_start_node"
kwargs = {"testval": "val", "testval2": "val2"}
debug_output = False
expected_node_texts = {
"test_view_node": "Your name is"}
expected_tree = \
['test_start_node',
['test_set_node',
['test_start_node'],
'test_look_node',
['test_start_node'],
'test_view_node',
['test_start_node'],
'test_dynamic_node',
['test_dynamic_node',
'test_dynamic_node',
'test_dynamic_node',
'test_dynamic_node',
'test_start_node'],
'test_end_node',
'test_displayinput_node',
['test_start_node']]]
def test_kwargsave(self):
self.assertTrue(hasattr(self.menu, "testval"))

View file

@ -1786,8 +1786,12 @@ def at_search_result(matches, caller, query="", quiet=False, **kwargs):
error = kwargs.get("nofound_string") or _("Could not find '%s'." % query)
matches = None
elif len(matches) > 1:
error = kwargs.get("multimatch_string") or \
_("More than one match for '%s' (please narrow target):\n" % query)
multimatch_string = kwargs.get("multimatch_string")
if multimatch_string:
error = "%s\n" % multimatch_string
else:
error = _("More than one match for '%s' (please narrow target):\n" % query)
for num, result in enumerate(matches):
# we need to consider Commands, where .aliases is a list
aliases = result.aliases.all() if hasattr(result.aliases, "all") else result.aliases

View file

@ -6,6 +6,7 @@
# tuple.
#
import os
from django.conf import settings
from evennia.utils.utils import get_evennia_version
@ -52,7 +53,11 @@ def set_webclient_settings():
global WEBCLIENT_ENABLED, WEBSOCKET_CLIENT_ENABLED, WEBSOCKET_PORT, WEBSOCKET_URL
WEBCLIENT_ENABLED = settings.WEBCLIENT_ENABLED
WEBSOCKET_CLIENT_ENABLED = settings.WEBSOCKET_CLIENT_ENABLED
WEBSOCKET_PORT = settings.WEBSOCKET_CLIENT_PORT
# if we are working through a proxy or uses docker port-remapping, the webclient port encoded
# in the webclient should be different than the one the server expects. Use the environment
# variable WEBSOCKET_CLIENT_PROXY_PORT if this is the case.
WEBSOCKET_PORT = int(os.environ.get("WEBSOCKET_CLIENT_PROXY_PORT", settings.WEBSOCKET_CLIENT_PORT))
# this is determined dynamically by the client and is less of an issue
WEBSOCKET_URL = settings.WEBSOCKET_CLIENT_URL
set_webclient_settings()

View file

@ -51,9 +51,9 @@ class TestGeneralContext(TestCase):
mock_settings.WEBCLIENT_ENABLED = "webclient"
mock_settings.WEBSOCKET_CLIENT_URL = "websocket_url"
mock_settings.WEBSOCKET_CLIENT_ENABLED = "websocket_client"
mock_settings.WEBSOCKET_CLIENT_PORT = "websocket_port"
mock_settings.WEBSOCKET_CLIENT_PORT = 5000
general_context.set_webclient_settings()
self.assertEqual(general_context.WEBCLIENT_ENABLED, "webclient")
self.assertEqual(general_context.WEBSOCKET_URL, "websocket_url")
self.assertEqual(general_context.WEBSOCKET_CLIENT_ENABLED, "websocket_client")
self.assertEqual(general_context.WEBSOCKET_PORT, "websocket_port")
self.assertEqual(general_context.WEBSOCKET_PORT, 5000)

View file

@ -16,9 +16,43 @@ JQuery available.
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
<!-- Import JQuery and warn if there is a problem -->
{% block jquery_import %}
<script src="https://code.jquery.com/jquery-2.1.1.min.js" type="text/javascript" charset="utf-8"></script>
{% endblock %}
<script type="text/javascript" charset="utf-8">
if(!window.jQuery) {
document.write("<div class='err'>jQuery library not found or the online version could not be reached. Check so Javascript is not blocked in your browser.</div>");
}
</script>
<!-- Set up Websocket url and load the evennia.js library-->
<script language="javascript" type="text/javascript">
{% if websocket_enabled %}
var wsactive = true;
{% else %}
var wsactive = false;
{% endif %}
{% if browser_sessid %}
var csessid = "{{browser_sessid}}";
{% else %}
var csessid = false;
{% endif %}
{% if websocket_url %}
var wsurl = "{{websocket_url}}";
{% else %}
var wsurl = "ws://" + this.location.hostname + ":{{websocket_port}}";
{% endif %}
</script>
<script src={% static "webclient/js/evennia.js" %} language="javascript" type="text/javascript" charset="utf-8"/></script>
<link rel='stylesheet' type="text/css" media="screen" href={% static "webclient/css/webclient.css" %}>
<script src="https://cdn.rawgit.com/ejci/favico.js/master/favico-0.3.10.min.js" language="javascript" type="text/javascript" charset="utf-8"></script>

View file

@ -7,3 +7,4 @@ pillow == 2.9.0
pytz
future >= 0.15.2
django-sekizai
inflect

View file

@ -10,3 +10,4 @@ pillow == 2.9.0
pytz
future >= 0.15.2
django-sekizai
inflect