mirror of
https://github.com/evennia/evennia.git
synced 2026-03-31 13:07:16 +02:00
Merge branch 'develop' into master
This commit is contained in:
commit
ab1bd6415d
64 changed files with 7206 additions and 2051 deletions
|
|
@ -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:
|
||||
|
|
|
|||
12
Dockerfile
12
Dockerfile
|
|
@ -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
|
||||
|
|
|
|||
13
bin/unix/evennia-docker-start.sh
Normal file
13
bin/unix/evennia-docker-start.sh
Normal 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
|
||||
|
|
@ -1 +1 @@
|
|||
0.7.0
|
||||
0.8.0-dev
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
103
evennia/contrib/health_bar.py
Normal file
103
evennia/contrib/health_bar.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
535
evennia/contrib/tree_select.py
Normal file
535
evennia/contrib/tree_select.py
Normal 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")
|
||||
|
||||
42
evennia/contrib/turnbattle/README.md
Normal file
42
evennia/contrib/turnbattle/README.md
Normal 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.
|
||||
1
evennia/contrib/turnbattle/__init__.py
Normal file
1
evennia/contrib/turnbattle/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -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())
|
||||
1086
evennia/contrib/turnbattle/tb_equip.py
Normal file
1086
evennia/contrib/turnbattle/tb_equip.py
Normal file
File diff suppressed because it is too large
Load diff
1383
evennia/contrib/turnbattle/tb_range.py
Normal file
1383
evennia/contrib/turnbattle/tb_range.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
239
evennia/server/amp_client.py
Normal file
239
evennia/server/amp_client.py
Normal 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
|
|
@ -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()
|
||||
418
evennia/server/portal/amp.py
Normal file
418
evennia/server/portal/amp.py
Normal 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)}
|
||||
455
evennia/server/portal/amp_server.py
Normal file
455
evennia/server/portal/amp_server.py
Normal 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 {}
|
||||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
"""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ from builtins import object
|
|||
|
||||
import time
|
||||
|
||||
|
||||
#------------------------------------------------------------
|
||||
# Server Session
|
||||
#------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
59
evennia/typeclasses/tests.py
Normal file
59
evennia/typeclasses/tests.py
Normal 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"]), [])
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -7,3 +7,4 @@ pillow == 2.9.0
|
|||
pytz
|
||||
future >= 0.15.2
|
||||
django-sekizai
|
||||
inflect
|
||||
|
|
|
|||
|
|
@ -10,3 +10,4 @@ pillow == 2.9.0
|
|||
pytz
|
||||
future >= 0.15.2
|
||||
django-sekizai
|
||||
inflect
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue