Merge pull request #7 from evennia/develop

Merge Develop
This commit is contained in:
FlutterSprite 2018-05-26 19:34:45 -07:00 committed by GitHub
commit 8af527c6c0
80 changed files with 4779 additions and 2300 deletions

View file

@ -1 +1 @@
0.7.0 0.8.0-dev

View file

@ -21,7 +21,7 @@ from evennia.objects.models import ObjectDB
from evennia.comms.models import ChannelDB from evennia.comms.models import ChannelDB
from evennia.commands import cmdhandler from evennia.commands import cmdhandler
from evennia.utils import logger from evennia.utils import logger
from evennia.utils.utils import (lazy_property, from evennia.utils.utils import (lazy_property, to_str,
make_iter, to_unicode, is_iter, make_iter, to_unicode, is_iter,
variable_from_module) variable_from_module)
from evennia.typeclasses.attributes import NickHandler from evennia.typeclasses.attributes import NickHandler
@ -421,10 +421,19 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
kwargs["options"] = options kwargs["options"] = options
if text is not None:
if not (isinstance(text, basestring) or isinstance(text, tuple)):
# sanitize text before sending across the wire
try:
text = to_str(text, force_string=True)
except Exception:
text = repr(text)
kwargs['text'] = text
# session relay # session relay
sessions = make_iter(session) if session else self.sessions.all() sessions = make_iter(session) if session else self.sessions.all()
for session in sessions: for session in sessions:
session.data_out(text=text, **kwargs) session.data_out(**kwargs)
def execute_cmd(self, raw_string, session=None, **kwargs): def execute_cmd(self, raw_string, session=None, **kwargs):
""" """
@ -457,7 +466,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
callertype="account", session=session, **kwargs) callertype="account", session=session, **kwargs)
def search(self, searchdata, return_puppet=False, search_object=False, def search(self, searchdata, return_puppet=False, search_object=False,
typeclass=None, nofound_string=None, multimatch_string=None, **kwargs): typeclass=None, nofound_string=None, multimatch_string=None, use_nicks=True, **kwargs):
""" """
This is similar to `DefaultObject.search` but defaults to searching This is similar to `DefaultObject.search` but defaults to searching
for Accounts only. for Accounts only.
@ -481,6 +490,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
multimatch_string (str, optional): A one-time error multimatch_string (str, optional): A one-time error
message to echo if `searchdata` leads to multiple matches. message to echo if `searchdata` leads to multiple matches.
If not given, will fall back to the default handler. If not given, will fall back to the default handler.
use_nicks (bool, optional): Use account-level nick replacement.
Return: Return:
match (Account, Object or None): A single Account or Object match. match (Account, Object or None): A single Account or Object match.
@ -496,8 +506,10 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
if searchdata.lower() in ("me", "*me", "self", "*self",): if searchdata.lower() in ("me", "*me", "self", "*self",):
return self return self
if search_object: if search_object:
matches = ObjectDB.objects.object_search(searchdata, typeclass=typeclass) matches = ObjectDB.objects.object_search(searchdata, typeclass=typeclass, use_nicks=use_nicks)
else: else:
searchdata = self.nicks.nickreplace(searchdata, categories=("account", ), include_account=False)
matches = AccountDB.objects.account_search(searchdata, typeclass=typeclass) matches = AccountDB.objects.account_search(searchdata, typeclass=typeclass)
matches = _AT_SEARCH_RESULT(matches, self, query=searchdata, matches = _AT_SEARCH_RESULT(matches, self, query=searchdata,
nofound_string=nofound_string, nofound_string=nofound_string,
@ -616,7 +628,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
self.basetype_setup() self.basetype_setup()
self.at_account_creation() self.at_account_creation()
permissions = settings.PERMISSION_ACCOUNT_DEFAULT permissions = [settings.PERMISSION_ACCOUNT_DEFAULT]
if hasattr(self, "_createdict"): if hasattr(self, "_createdict"):
# this will only be set if the utils.create_account # this will only be set if the utils.create_account
# function was used to create the object. # function was used to create the object.

View file

@ -297,7 +297,7 @@ class Command(with_metaclass(CommandMeta, object)):
Args: Args:
srcobj (Object): Object trying to gain permission srcobj (Object): Object trying to gain permission
access_type (str, optional): The lock type to check. 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. of matching `access_type` is found on this Command.
""" """

View file

@ -455,7 +455,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS):
Usage: Usage:
@option[/save] [name = value] @option[/save] [name = value]
Switch: Switches:
save - Save the current option settings for future logins. save - Save the current option settings for future logins.
clear - Clear the saved options. clear - Clear the saved options.
@ -467,6 +467,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS):
""" """
key = "@option" key = "@option"
aliases = "@options" aliases = "@options"
switch_options = ("save", "clear")
locks = "cmd:all()" locks = "cmd:all()"
# this is used by the parent # this is used by the parent
@ -549,8 +550,11 @@ class CmdOption(COMMAND_DEFAULT_CLASS):
try: try:
old_val = flags.get(new_name, False) old_val = flags.get(new_name, False)
new_val = validator(new_val) new_val = validator(new_val)
flags[new_name] = new_val if old_val == new_val:
self.msg("Option |w%s|n was changed from '|w%s|n' to '|w%s|n'." % (new_name, old_val, new_val)) self.msg("Option |w%s|n was kept as '|w%s|n'." % (new_name, old_val))
else:
flags[new_name] = new_val
self.msg("Option |w%s|n was changed from '|w%s|n' to '|w%s|n'." % (new_name, old_val, new_val))
return {new_name: new_val} return {new_name: new_val}
except Exception as err: except Exception as err:
self.msg("|rCould not set option |w%s|r:|n %s" % (new_name, err)) self.msg("|rCould not set option |w%s|r:|n %s" % (new_name, err))
@ -572,7 +576,8 @@ class CmdOption(COMMAND_DEFAULT_CLASS):
"TERM": utils.to_str, "TERM": utils.to_str,
"UTF-8": validate_bool, "UTF-8": validate_bool,
"XTERM256": validate_bool, "XTERM256": validate_bool,
"INPUTDEBUG": validate_bool} "INPUTDEBUG": validate_bool,
"FORCEDENDLINE": validate_bool}
name = self.lhs.upper() name = self.lhs.upper()
val = self.rhs.strip() val = self.rhs.strip()
@ -646,6 +651,7 @@ class CmdQuit(COMMAND_DEFAULT_CLASS):
game. Use the /all switch to disconnect from all sessions. game. Use the /all switch to disconnect from all sessions.
""" """
key = "@quit" key = "@quit"
switch_options = ("all",)
locks = "cmd:all()" locks = "cmd:all()"
# this is used by the parent # this is used by the parent

View file

@ -36,6 +36,7 @@ class CmdBoot(COMMAND_DEFAULT_CLASS):
""" """
key = "@boot" key = "@boot"
switch_options = ("quiet", "sid")
locks = "cmd:perm(boot) or perm(Admin)" locks = "cmd:perm(boot) or perm(Admin)"
help_category = "Admin" help_category = "Admin"
@ -265,6 +266,7 @@ class CmdDelAccount(COMMAND_DEFAULT_CLASS):
""" """
key = "@delaccount" key = "@delaccount"
switch_options = ("delobj",)
locks = "cmd:perm(delaccount) or perm(Developer)" locks = "cmd:perm(delaccount) or perm(Developer)"
help_category = "Admin" help_category = "Admin"
@ -329,9 +331,9 @@ class CmdEmit(COMMAND_DEFAULT_CLASS):
@pemit [<obj>, <obj>, ... =] <message> @pemit [<obj>, <obj>, ... =] <message>
Switches: Switches:
room : limit emits to rooms only (default) room - limit emits to rooms only (default)
accounts : limit emits to accounts only accounts - limit emits to accounts only
contents : send to the contents of matched objects too contents - send to the contents of matched objects too
Emits a message to the selected objects or to Emits a message to the selected objects or to
your immediate surroundings. If the object is a room, your immediate surroundings. If the object is a room,
@ -341,6 +343,7 @@ class CmdEmit(COMMAND_DEFAULT_CLASS):
""" """
key = "@emit" key = "@emit"
aliases = ["@pemit", "@remit"] aliases = ["@pemit", "@remit"]
switch_options = ("room", "accounts", "contents")
locks = "cmd:perm(emit) or perm(Builder)" locks = "cmd:perm(emit) or perm(Builder)"
help_category = "Admin" help_category = "Admin"
@ -442,14 +445,15 @@ class CmdPerm(COMMAND_DEFAULT_CLASS):
@perm[/switch] *<account> [= <permission>[,<permission>,...]] @perm[/switch] *<account> [= <permission>[,<permission>,...]]
Switches: Switches:
del : delete the given permission from <object> or <account>. del - delete the given permission from <object> or <account>.
account : set permission on an account (same as adding * to name) account - set permission on an account (same as adding * to name)
This command sets/clears individual permission strings on an object This command sets/clears individual permission strings on an object
or account. If no permission is given, list all permissions on <object>. or account. If no permission is given, list all permissions on <object>.
""" """
key = "@perm" key = "@perm"
aliases = "@setperm" aliases = "@setperm"
switch_options = ("del", "account")
locks = "cmd:perm(perm) or perm(Developer)" locks = "cmd:perm(perm) or perm(Developer)"
help_category = "Admin" help_category = "Admin"
@ -544,7 +548,8 @@ class CmdWall(COMMAND_DEFAULT_CLASS):
Usage: Usage:
@wall <message> @wall <message>
Announces a message to all connected accounts. Announces a message to all connected sessions
including all currently unlogged in.
""" """
key = "@wall" key = "@wall"
locks = "cmd:perm(wall) or perm(Admin)" locks = "cmd:perm(wall) or perm(Admin)"
@ -556,5 +561,5 @@ class CmdWall(COMMAND_DEFAULT_CLASS):
self.caller.msg("Usage: @wall <message>") self.caller.msg("Usage: @wall <message>")
return return
message = "%s shouts \"%s\"" % (self.caller.name, self.args) message = "%s shouts \"%s\"" % (self.caller.name, self.args)
self.msg("Announcing to all connected accounts ...") self.msg("Announcing to all connected sessions ...")
SESSIONS.announce_all(message) SESSIONS.announce_all(message)

View file

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

View file

@ -106,9 +106,15 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS):
Usage: Usage:
@alias <obj> [= [alias[,alias,alias,...]]] @alias <obj> [= [alias[,alias,alias,...]]]
@alias <obj> = @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 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 Observe that this is not the same thing as personal aliases
created with the 'nick' command! Aliases set with @alias are created with the 'nick' command! Aliases set with @alias are
@ -118,6 +124,7 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS):
key = "@alias" key = "@alias"
aliases = "@setobjalias" aliases = "@setobjalias"
switch_options = ("category",)
locks = "cmd:perm(setobjalias) or perm(Builder)" locks = "cmd:perm(setobjalias) or perm(Builder)"
help_category = "Building" help_category = "Building"
@ -138,9 +145,12 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS):
return return
if self.rhs is None: if self.rhs is None:
# no =, so we just list aliases on object. # no =, so we just list aliases on object.
aliases = obj.aliases.all() aliases = obj.aliases.all(return_key_and_category=True)
if aliases: 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: else:
caller.msg("No aliases exist for '%s'." % obj.get_display_name(caller)) caller.msg("No aliases exist for '%s'." % obj.get_display_name(caller))
return return
@ -159,17 +169,27 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS):
caller.msg("No aliases to clear.") caller.msg("No aliases to clear.")
return 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) # merge the old and new aliases (if any)
old_aliases = obj.aliases.all() old_aliases = obj.aliases.get(category=category, return_list=True)
new_aliases = [alias.strip().lower() for alias in self.rhs.split(',') new_aliases = [alias.strip().lower() for alias in rhs.split(',') if alias.strip()]
if alias.strip()]
# make the aliases only appear once # make the aliases only appear once
old_aliases.extend(new_aliases) old_aliases.extend(new_aliases)
aliases = list(set(old_aliases)) aliases = list(set(old_aliases))
# save back to object. # save back to object.
obj.aliases.add(aliases) obj.aliases.add(aliases, category=category)
# we need to trigger this here, since this will force # we need to trigger this here, since this will force
# (default) Exits to rebuild their Exit commands with the new # (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) obj.at_cmdset_get(force_init=True)
# report all aliases on the object # 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): class CmdCopy(ObjManipCommand):
@ -198,6 +219,7 @@ class CmdCopy(ObjManipCommand):
""" """
key = "@copy" key = "@copy"
switch_options = ("reset",)
locks = "cmd:perm(copy) or perm(Builder)" locks = "cmd:perm(copy) or perm(Builder)"
help_category = "Building" help_category = "Building"
@ -279,6 +301,7 @@ class CmdCpAttr(ObjManipCommand):
If you don't supply a source object, yourself is used. If you don't supply a source object, yourself is used.
""" """
key = "@cpattr" key = "@cpattr"
switch_options = ("move",)
locks = "cmd:perm(cpattr) or perm(Builder)" locks = "cmd:perm(cpattr) or perm(Builder)"
help_category = "Building" help_category = "Building"
@ -420,6 +443,7 @@ class CmdMvAttr(ObjManipCommand):
object. If you don't supply a source object, yourself is used. object. If you don't supply a source object, yourself is used.
""" """
key = "@mvattr" key = "@mvattr"
switch_options = ("copy",)
locks = "cmd:perm(mvattr) or perm(Builder)" locks = "cmd:perm(mvattr) or perm(Builder)"
help_category = "Building" help_category = "Building"
@ -468,6 +492,7 @@ class CmdCreate(ObjManipCommand):
""" """
key = "@create" key = "@create"
switch_options = ("drop",)
locks = "cmd:perm(create) or perm(Builder)" locks = "cmd:perm(create) or perm(Builder)"
help_category = "Building" help_category = "Building"
@ -553,6 +578,7 @@ class CmdDesc(COMMAND_DEFAULT_CLASS):
""" """
key = "@desc" key = "@desc"
aliases = "@describe" aliases = "@describe"
switch_options = ("edit",)
locks = "cmd:perm(desc) or perm(Builder)" locks = "cmd:perm(desc) or perm(Builder)"
help_category = "Building" help_category = "Building"
@ -568,6 +594,9 @@ class CmdDesc(COMMAND_DEFAULT_CLASS):
if not obj: if not obj:
return return
if not (obj.access(self.caller, 'control') or obj.access(self.caller, 'edit')):
self.caller.msg("You don't have permission to edit the description of %s." % obj.key)
self.caller.db.evmenu_target = obj self.caller.db.evmenu_target = obj
# launch the editor # launch the editor
EvEditor(self.caller, loadfunc=_desc_load, savefunc=_desc_save, EvEditor(self.caller, loadfunc=_desc_load, savefunc=_desc_save,
@ -597,7 +626,7 @@ class CmdDesc(COMMAND_DEFAULT_CLASS):
if not obj: if not obj:
return return
desc = self.args desc = self.args
if obj.access(caller, "edit"): if (obj.access(self.caller, 'control') or obj.access(self.caller, 'edit')):
obj.db.desc = desc obj.db.desc = desc
caller.msg("The description was set on %s." % obj.get_display_name(caller)) caller.msg("The description was set on %s." % obj.get_display_name(caller))
else: else:
@ -611,11 +640,11 @@ class CmdDestroy(COMMAND_DEFAULT_CLASS):
Usage: Usage:
@destroy[/switches] [obj, obj2, obj3, [dbref-dbref], ...] @destroy[/switches] [obj, obj2, obj3, [dbref-dbref], ...]
switches: Switches:
override - The @destroy command will usually avoid accidentally override - The @destroy command will usually avoid accidentally
destroying account objects. This switch overrides this safety. destroying account objects. This switch overrides this safety.
force - destroy without confirmation. force - destroy without confirmation.
examples: Examples:
@destroy house, roof, door, 44-78 @destroy house, roof, door, 44-78
@destroy 5-10, flower, 45 @destroy 5-10, flower, 45
@destroy/force north @destroy/force north
@ -628,6 +657,7 @@ class CmdDestroy(COMMAND_DEFAULT_CLASS):
key = "@destroy" key = "@destroy"
aliases = ["@delete", "@del"] aliases = ["@delete", "@del"]
switch_options = ("override", "force")
locks = "cmd:perm(destroy) or perm(Builder)" locks = "cmd:perm(destroy) or perm(Builder)"
help_category = "Building" help_category = "Building"
@ -751,6 +781,7 @@ class CmdDig(ObjManipCommand):
would be 'north;no;n'. would be 'north;no;n'.
""" """
key = "@dig" key = "@dig"
switch_options = ("teleport",)
locks = "cmd:perm(dig) or perm(Builder)" locks = "cmd:perm(dig) or perm(Builder)"
help_category = "Building" help_category = "Building"
@ -860,7 +891,7 @@ class CmdDig(ObjManipCommand):
new_back_exit.dbref, new_back_exit.dbref,
alias_string) alias_string)
caller.msg("%s%s%s" % (room_string, exit_to_string, exit_back_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) caller.move_to(new_room)
@ -893,6 +924,7 @@ class CmdTunnel(COMMAND_DEFAULT_CLASS):
key = "@tunnel" key = "@tunnel"
aliases = ["@tun"] aliases = ["@tun"]
switch_options = ("oneway", "tel")
locks = "cmd: perm(tunnel) or perm(Builder)" locks = "cmd: perm(tunnel) or perm(Builder)"
help_category = "Building" help_category = "Building"
@ -1455,6 +1487,13 @@ class CmdSetAttribute(ObjManipCommand):
Switch: Switch:
edit: Open the line editor (string values only) 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 Sets attributes on objects. The second form clears
a previously set attribute while the last form a previously set attribute while the last form
@ -1555,6 +1594,38 @@ class CmdSetAttribute(ObjManipCommand):
# start the editor # start the editor
EvEditor(self.caller, load, save, key="%s/%s" % (obj, attr)) 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): def func(self):
"""Implement the set attribute - a limited form of @py.""" """Implement the set attribute - a limited form of @py."""
@ -1568,10 +1639,7 @@ class CmdSetAttribute(ObjManipCommand):
objname = self.lhs_objattr[0]['name'] objname = self.lhs_objattr[0]['name']
attrs = self.lhs_objattr[0]['attrs'] attrs = self.lhs_objattr[0]['attrs']
if objname.startswith('*'): obj = self.search_for_obj(objname)
obj = caller.search_account(objname.lstrip('*'))
else:
obj = caller.search(objname)
if not obj: if not obj:
return return
@ -1581,6 +1649,10 @@ class CmdSetAttribute(ObjManipCommand):
result = [] result = []
if "edit" in self.switches: if "edit" in self.switches:
# edit in the line editor # edit in the line editor
if not (obj.access(self.caller, 'control') or obj.access(self.caller, 'edit')):
caller.msg("You don't have permission to edit %s." % obj.key)
return
if len(attrs) > 1: if len(attrs) > 1:
caller.msg("The Line editor can only be applied " caller.msg("The Line editor can only be applied "
"to one attribute at a time.") "to one attribute at a time.")
@ -1601,12 +1673,18 @@ class CmdSetAttribute(ObjManipCommand):
return return
else: else:
# deleting the attribute(s) # deleting the attribute(s)
if not (obj.access(self.caller, 'control') or obj.access(self.caller, 'edit')):
caller.msg("You don't have permission to edit %s." % obj.key)
return
for attr in attrs: for attr in attrs:
if not self.check_attr(obj, attr): if not self.check_attr(obj, attr):
continue continue
result.append(self.rm_attr(obj, attr)) result.append(self.rm_attr(obj, attr))
else: else:
# setting attribute(s). Make sure to convert to real Python type before saving. # setting attribute(s). Make sure to convert to real Python type before saving.
if not (obj.access(self.caller, 'control') or obj.access(self.caller, 'edit')):
caller.msg("You don't have permission to edit %s." % obj.key)
return
for attr in attrs: for attr in attrs:
if not self.check_attr(obj, attr): if not self.check_attr(obj, attr):
continue continue
@ -1807,13 +1885,13 @@ class CmdLock(ObjManipCommand):
For example: For example:
'get: id(25) or perm(Admin)' 'get: id(25) or perm(Admin)'
The 'get' access_type is checked by the get command and will The 'get' lock access_type is checked e.g. by the 'get' command.
an object locked with this string will only be possible to An object locked with this example lock will only be possible to pick up
pick up by Admins or by object with id=25. by Admins or by an object with id=25.
You can add several access_types after one another by separating You can add several access_types after one another by separating
them by ';', i.e: them by ';', i.e:
'get:id(25);delete:perm(Builder)' 'get:id(25); delete:perm(Builder)'
""" """
key = "@lock" key = "@lock"
aliases = ["@locks"] aliases = ["@locks"]
@ -1840,9 +1918,16 @@ class CmdLock(ObjManipCommand):
obj = caller.search(objname) obj = caller.search(objname)
if not obj: if not obj:
return return
if not (obj.access(caller, 'control') or obj.access(caller, "edit")): has_control_access = obj.access(caller, 'control')
if access_type == 'control' and not has_control_access:
# only allow to change 'control' access if you have 'control' access already
caller.msg("You need 'control' access to change this type of lock.")
return
if not has_control_access or obj.access(caller, "edit"):
caller.msg("You are not allowed to do that.") caller.msg("You are not allowed to do that.")
return return
lockdef = obj.locks.get(access_type) lockdef = obj.locks.get(access_type)
if lockdef: if lockdef:
@ -2182,12 +2267,15 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
Usage: Usage:
@find[/switches] <name or dbref or *account> [= dbrefmin[-dbrefmax]] @find[/switches] <name or dbref or *account> [= dbrefmin[-dbrefmax]]
@locate - this is a shorthand for using the /loc switch.
Switches: Switches:
room - only look for rooms (location=None) room - only look for rooms (location=None)
exit - only look for exits (destination!=None) exit - only look for exits (destination!=None)
char - only look for characters (BASE_CHARACTER_TYPECLASS) char - only look for characters (BASE_CHARACTER_TYPECLASS)
exact- only exact matches are returned. exact - only exact matches are returned.
loc - display object location if exists and match has one result
startswith - search for names starting with the string, rather than containing
Searches the database for an object of a particular name or exact #dbref. Searches the database for an object of a particular name or exact #dbref.
Use *accountname to search for an account. The switches allows for Use *accountname to search for an account. The switches allows for
@ -2198,6 +2286,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
key = "@find" key = "@find"
aliases = "@search, @locate" aliases = "@search, @locate"
switch_options = ("room", "exit", "char", "exact", "loc", "startswith")
locks = "cmd:perm(find) or perm(Builder)" locks = "cmd:perm(find) or perm(Builder)"
help_category = "Building" help_category = "Building"
@ -2210,6 +2299,9 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
caller.msg("Usage: @find <string> [= low [-high]]") caller.msg("Usage: @find <string> [= low [-high]]")
return return
if "locate" in self.cmdstring: # Use option /loc as a default for @locate command alias
switches.append('loc')
searchstring = self.lhs searchstring = self.lhs
low, high = 1, ObjectDB.objects.all().order_by("-id")[0].id low, high = 1, ObjectDB.objects.all().order_by("-id")[0].id
if self.rhs: if self.rhs:
@ -2231,7 +2323,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
restrictions = "" restrictions = ""
if self.switches: if self.switches:
restrictions = ", %s" % (",".join(self.switches)) restrictions = ", %s" % (", ".join(self.switches))
if is_dbref or is_account: if is_dbref or is_account:
@ -2259,6 +2351,8 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
else: else:
result = result[0] result = result[0]
string += "\n|g %s - %s|n" % (result.get_display_name(caller), result.path) 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: else:
# Not an account/dbref search but a wider search; build a queryset. # Not an account/dbref search but a wider search; build a queryset.
# Searchs for key and aliases # Searchs for key and aliases
@ -2266,10 +2360,14 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
keyquery = Q(db_key__iexact=searchstring, id__gte=low, id__lte=high) keyquery = Q(db_key__iexact=searchstring, id__gte=low, id__lte=high)
aliasquery = Q(db_tags__db_key__iexact=searchstring, aliasquery = Q(db_tags__db_key__iexact=searchstring,
db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high) db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high)
else: elif "startswith" in switches:
keyquery = Q(db_key__istartswith=searchstring, id__gte=low, id__lte=high) keyquery = Q(db_key__istartswith=searchstring, id__gte=low, id__lte=high)
aliasquery = Q(db_tags__db_key__istartswith=searchstring, aliasquery = Q(db_tags__db_key__istartswith=searchstring,
db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high) db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high)
else:
keyquery = Q(db_key__icontains=searchstring, id__gte=low, id__lte=high)
aliasquery = Q(db_tags__db_key__icontains=searchstring,
db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high)
results = ObjectDB.objects.filter(keyquery | aliasquery).distinct() results = ObjectDB.objects.filter(keyquery | aliasquery).distinct()
nresults = results.count() nresults = results.count()
@ -2294,6 +2392,8 @@ class CmdFind(COMMAND_DEFAULT_CLASS):
else: else:
string = "|wOne Match|n(#%i-#%i%s):" % (low, high, restrictions) 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) 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: else:
string = "|wMatch|n(#%i-#%i%s):" % (low, high, restrictions) string = "|wMatch|n(#%i-#%i%s):" % (low, high, restrictions)
string += "\n |RNo matches found for '%s'|n" % searchstring string += "\n |RNo matches found for '%s'|n" % searchstring
@ -2307,11 +2407,11 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS):
teleport object to another location teleport object to another location
Usage: Usage:
@tel/switch [<object> =] <target location> @tel/switch [<object> to||=] <target location>
Examples: Examples:
@tel Limbo @tel Limbo
@tel/quiet box Limbo @tel/quiet box = Limbo
@tel/tonone box @tel/tonone box
Switches: Switches:
@ -2327,9 +2427,12 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS):
loc - teleport object to the target's location instead of its contents loc - teleport object to the target's location instead of its contents
Teleports an object somewhere. If no object is given, you yourself Teleports an object somewhere. If no object is given, you yourself
is teleported to the target location. """ is teleported to the target location.
"""
key = "@tel" key = "@tel"
aliases = "@teleport" aliases = "@teleport"
switch_options = ("quiet", "intoexit", "tonone", "loc")
rhs_split = ("=", " to ") # Prefer = delimiter, but allow " to " usage.
locks = "cmd:perm(teleport) or perm(Builder)" locks = "cmd:perm(teleport) or perm(Builder)"
help_category = "Building" help_category = "Building"
@ -2437,6 +2540,7 @@ class CmdScript(COMMAND_DEFAULT_CLASS):
key = "@script" key = "@script"
aliases = "@addscript" aliases = "@addscript"
switch_options = ("start", "stop")
locks = "cmd:perm(script) or perm(Builder)" locks = "cmd:perm(script) or perm(Builder)"
help_category = "Building" help_category = "Building"
@ -2536,6 +2640,7 @@ class CmdTag(COMMAND_DEFAULT_CLASS):
key = "@tag" key = "@tag"
aliases = ["@tags"] aliases = ["@tags"]
options = ("search", "del")
locks = "cmd:perm(tag) or perm(Builder)" locks = "cmd:perm(tag) or perm(Builder)"
help_category = "Building" help_category = "Building"
arg_regex = r"(/\w+?(\s|$))|\s|$" arg_regex = r"(/\w+?(\s|$))|\s|$"
@ -2677,6 +2782,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
""" """
key = "@spawn" key = "@spawn"
switch_options = ("noloc", )
locks = "cmd:perm(spawn) or perm(Builder)" locks = "cmd:perm(spawn) or perm(Builder)"
help_category = "Building" help_category = "Building"
@ -2686,7 +2792,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS):
def _show_prototypes(prototypes): def _show_prototypes(prototypes):
"""Helper to show a list of available prototypes""" """Helper to show a list of available prototypes"""
prots = ", ".join(sorted(prototypes.keys())) prots = ", ".join(sorted(prototypes.keys()))
return "\nAvailable prototypes (case sensistive): %s" % ( return "\nAvailable prototypes (case sensitive): %s" % (
"\n" + utils.fill(prots) if prots else "None") "\n" + utils.fill(prots) if prots else "None")
prototypes = spawn(return_prototypes=True) prototypes = spawn(return_prototypes=True)

View file

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

View file

@ -23,3 +23,4 @@ class UnloggedinCmdSet(CmdSet):
self.add(unloggedin.CmdUnconnectedHelp()) self.add(unloggedin.CmdUnconnectedHelp())
self.add(unloggedin.CmdUnconnectedEncoding()) self.add(unloggedin.CmdUnconnectedEncoding())
self.add(unloggedin.CmdUnconnectedScreenreader()) self.add(unloggedin.CmdUnconnectedScreenreader())
self.add(unloggedin.CmdUnconnectedInfo())

View file

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

View file

@ -1,6 +1,7 @@
""" """
General Character commands usually available to all characters General Character commands usually available to all characters
""" """
import re
from django.conf import settings from django.conf import settings
from evennia.utils import utils, evtable from evennia.utils import utils, evtable
from evennia.typeclasses.attributes import NickTemplateInvalid from evennia.typeclasses.attributes import NickTemplateInvalid
@ -70,42 +71,45 @@ class CmdLook(COMMAND_DEFAULT_CLASS):
target = caller.search(self.args) target = caller.search(self.args)
if not target: if not target:
return return
self.msg(caller.at_look(target)) self.msg((caller.at_look(target), {'type':'look'}), options=None)
class CmdNick(COMMAND_DEFAULT_CLASS): class CmdNick(COMMAND_DEFAULT_CLASS):
""" """
define a personal alias/nick define a personal alias/nick by defining a string to
match and replace it with another on the fly
Usage: Usage:
nick[/switches] <string> [= [replacement_string]] nick[/switches] <string> [= [replacement_string]]
nick[/switches] <template> = <replacement_template> nick[/switches] <template> = <replacement_template>
nick/delete <string> or number nick/delete <string> or number
nick/test <test string> nicks
Switches: Switches:
inputline - replace on the inputline (default) inputline - replace on the inputline (default)
object - replace on object-lookup object - replace on object-lookup
account - replace on account-lookup account - replace on account-lookup
delete - remove nick by name or by index given by /list
clearall - clear all nicks
list - show all defined aliases (also "nicks" works) list - show all defined aliases (also "nicks" works)
test - test input to see what it matches with delete - remove nick by index in /list
clearall - clear all nicks
Examples: Examples:
nick hi = say Hello, I'm Sarah! nick hi = say Hello, I'm Sarah!
nick/object tom = the tall man nick/object tom = the tall man
nick build $1 $2 = @create/drop $1;$2 - (template) nick build $1 $2 = @create/drop $1;$2
nick tell $1 $2=@page $1=$2 - (template) nick tell $1 $2=@page $1=$2
nick tm?$1=@page tallman=$1
nick tm\=$1=@page tallman=$1
A 'nick' is a personal string replacement. Use $1, $2, ... to catch arguments. A 'nick' is a personal string replacement. Use $1, $2, ... to catch arguments.
Put the last $-marker without an ending space to catch all remaining text. You Put the last $-marker without an ending space to catch all remaining text. You
can also use unix-glob matching: can also use unix-glob matching for the left-hand side <string>:
* - matches everything * - matches everything
? - matches a single character ? - matches 0 or 1 single characters
[seq] - matches all chars in sequence [abcd] - matches these chars in any order
[!seq] - matches everything not in sequence [!abcd] - matches everything not among these chars
\= - escape literal '=' you want in your <string>
Note that no objects are actually renamed or changed by this command - your nicks Note that no objects are actually renamed or changed by this command - your nicks
are only available to you. If you want to permanently add keywords to an object are only available to you. If you want to permanently add keywords to an object
@ -113,17 +117,40 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
""" """
key = "nick" key = "nick"
aliases = ["nickname", "nicks", "alias"] switch_options = ("inputline", "object", "account", "list", "delete", "clearall")
aliases = ["nickname", "nicks"]
locks = "cmd:all()" locks = "cmd:all()"
def parse(self):
"""
Support escaping of = with \=
"""
super(CmdNick, self).parse()
args = (self.lhs or "") + (" = %s" % self.rhs if self.rhs else "")
parts = re.split(r"(?<!\\)=", args, 1)
self.rhs = None
if len(parts) < 2:
self.lhs = parts[0].strip()
else:
self.lhs, self.rhs = [part.strip() for part in parts]
self.lhs = self.lhs.replace("\=", "=")
def func(self): def func(self):
"""Create the nickname""" """Create the nickname"""
def _cy(string):
"add color to the special markers"
return re.sub(r"(\$[0-9]+|\*|\?|\[.+?\])", r"|Y\1|n", string)
caller = self.caller caller = self.caller
switches = self.switches switches = self.switches
nicktypes = [switch for switch in switches if switch in ("object", "account", "inputline")] or ["inputline"] nicktypes = [switch for switch in switches if switch in ("object", "account", "inputline")]
specified_nicktype = bool(nicktypes)
nicktypes = nicktypes if specified_nicktype else ["inputline"]
nicklist = utils.make_iter(caller.nicks.get(return_obj=True) or []) 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(caller.nicks.get(category="account", return_obj=True) or []))
if 'list' in switches or self.cmdstring in ("nicks", "@nicks"): if 'list' in switches or self.cmdstring in ("nicks", "@nicks"):
@ -133,24 +160,121 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
table = evtable.EvTable("#", "Type", "Nick match", "Replacement") table = evtable.EvTable("#", "Type", "Nick match", "Replacement")
for inum, nickobj in enumerate(nicklist): for inum, nickobj in enumerate(nicklist):
_, _, nickvalue, replacement = nickobj.value _, _, nickvalue, replacement = nickobj.value
table.add_row(str(inum + 1), nickobj.db_category, nickvalue, replacement) table.add_row(str(inum + 1), nickobj.db_category, _cy(nickvalue), _cy(replacement))
string = "|wDefined Nicks:|n\n%s" % table string = "|wDefined Nicks:|n\n%s" % table
caller.msg(string) caller.msg(string)
return return
if 'clearall' in switches: if 'clearall' in switches:
caller.nicks.clear() caller.nicks.clear()
caller.account.nicks.clear()
caller.msg("Cleared all nicks.") caller.msg("Cleared all nicks.")
return return
if 'delete' in switches or 'del' in switches:
if not self.args or not self.lhs:
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):
oldnicks.append(nicklist[delindex - 1])
else:
caller.msg("Not a valid nick index. See 'nicks' for a list.")
return
else:
if not specified_nicktype:
nicktypes = ("object", "account", "inputline")
for nicktype in nicktypes:
oldnicks.append(caller.nicks.get(arg, category=nicktype, return_obj=True))
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))
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
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.args or not self.lhs: if not self.args or not self.lhs:
caller.msg("Usage: nick[/switches] nickname = [realname]") caller.msg("Usage: nick[/switches] nickname = [realname]")
return return
# setting new nicks
nickstring = self.lhs nickstring = self.lhs
replstring = self.rhs replstring = self.rhs
old_nickstring = None
old_replstring = None
if replstring == nickstring: if replstring == nickstring:
caller.msg("No point in setting nick same as the string to replace...") caller.msg("No point in setting nick same as the string to replace...")
@ -160,36 +284,24 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
errstring = "" errstring = ""
string = "" string = ""
for nicktype in nicktypes: for nicktype in nicktypes:
nicktypestr = "%s-nick" % nicktype.capitalize()
old_nickstring = None
old_replstring = None
oldnick = caller.nicks.get(key=nickstring, category=nicktype, return_obj=True) oldnick = caller.nicks.get(key=nickstring, category=nicktype, return_obj=True)
if oldnick: if oldnick:
_, _, old_nickstring, old_replstring = oldnick.value _, _, old_nickstring, old_replstring = oldnick.value
else: if replstring:
# no old nick, see if a number was given
arg = self.args.lstrip("#")
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
else:
errstring += "Not a valid nick index."
else:
errstring += "Nick not found."
if "delete" in switches or "del" in switches:
# clear the nick
if old_nickstring and caller.nicks.has(old_nickstring, category=nicktype):
caller.nicks.remove(old_nickstring, category=nicktype)
string += "\nNick removed: '|w%s|n' -> |w%s|n." % (old_nickstring, old_replstring)
else:
errstring += "\nNick '|w%s|n' was not deleted." % old_nickstring
elif replstring:
# creating new nick # creating new nick
errstring = "" errstring = ""
if oldnick: if oldnick:
string += "\nNick '|w%s|n' updated to map to '|w%s|n'." % (old_nickstring, replstring) if replstring == old_replstring:
string += "\nIdentical %s already set." % nicktypestr.lower()
else:
string += "\n%s '|w%s|n' updated to map to '|w%s|n'." % (
nicktypestr, old_nickstring, replstring)
else: else:
string += "\nNick '|w%s|n' mapped to '|w%s|n'." % (nickstring, replstring) string += "\n%s '|w%s|n' mapped to '|w%s|n'." % (nicktypestr, nickstring, replstring)
try: try:
caller.nicks.add(nickstring, replstring, category=nicktype) caller.nicks.add(nickstring, replstring, category=nicktype)
except NickTemplateInvalid: except NickTemplateInvalid:
@ -197,10 +309,10 @@ class CmdNick(COMMAND_DEFAULT_CLASS):
return return
elif old_nickstring and old_replstring: elif old_nickstring and old_replstring:
# just looking at the nick # just looking at the nick
string += "\nNick '|w%s|n' maps to '|w%s|n'." % (old_nickstring, old_replstring) string += "\n%s '|w%s|n' maps to '|w%s|n'." % (nicktypestr, old_nickstring, old_replstring)
errstring = "" errstring = ""
string = errstring if errstring else string string = errstring if errstring else string
caller.msg(string) caller.msg(_cy(string))
class CmdInventory(COMMAND_DEFAULT_CLASS): class CmdInventory(COMMAND_DEFAULT_CLASS):
@ -330,12 +442,13 @@ class CmdGive(COMMAND_DEFAULT_CLASS):
give away something to someone give away something to someone
Usage: Usage:
give <inventory obj> = <target> give <inventory obj> <to||=> <target>
Gives an items from your inventory to another character, Gives an items from your inventory to another character,
placing it in their inventory. placing it in their inventory.
""" """
key = "give" key = "give"
rhs_split = ("=", " to ") # Prefer = delimiter, but allow " to " usage.
locks = "cmd:all()" locks = "cmd:all()"
arg_regex = r"\s|$" arg_regex = r"\s|$"
@ -439,7 +552,7 @@ class CmdWhisper(COMMAND_DEFAULT_CLASS):
Usage: Usage:
whisper <character> = <message> whisper <character> = <message>
whisper <char1>, <char2> = <message? whisper <char1>, <char2> = <message>
Talk privately to one or more characters in your current location, without Talk privately to one or more characters in your current location, without
others in the room being informed. others in the room being informed.

View file

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

View file

@ -79,6 +79,13 @@ class MuxCommand(Command):
it here). The rest of the command is stored in self.args, which can it here). The rest of the command is stored in self.args, which can
start with the switch indicator /. 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 This parser breaks self.args into its constituents and stores them in the
following variables: following variables:
self.switches = [list of /switches (without the /)] self.switches = [list of /switches (without the /)]
@ -97,9 +104,18 @@ class MuxCommand(Command):
""" """
raw = self.args raw = self.args
args = raw.strip() 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 # 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] == "/": if args and len(args) > 1 and raw[0] == "/":
# we have a switch, or a set of switches. These end with a space. # we have a switch, or a set of switches. These end with a space.
switches = args[1:].split(None, 1) switches = args[1:].split(None, 1)
@ -109,16 +125,50 @@ class MuxCommand(Command):
else: else:
args = "" args = ""
switches = switches[0].split('/') 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()] arglist = [arg.strip() for arg in args.split()]
# check for arg1, arg2, ... = argA, argB, ... constructs # check for arg1, arg2, ... = argA, argB, ... constructs
lhs, rhs = args, None lhs, rhs = args.strip(), None
lhslist, rhslist = [arg.strip() for arg in args.split(',')], [] if lhs:
if args and '=' in args: if delimiters and hasattr(delimiters, '__iter__'): # If delimiter is iterable,
lhs, rhs = [arg.strip() for arg in args.split('=', 1)] best_split = delimiters[0] # (default to first delimiter)
lhslist = [arg.strip() for arg in lhs.split(',')] for this_split in delimiters: # try each delimiter
rhslist = [arg.strip() for arg in rhs.split(',')] 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: # save to object properties:
self.raw = raw self.raw = raw
self.switches = switches self.switches = switches
@ -133,7 +183,7 @@ class MuxCommand(Command):
# sure that self.caller is always the account if possible. We also create # sure that self.caller is always the account if possible. We also create
# a special property "character" for the puppeted object, if any. This # a special property "character" for the puppeted object, if any. This
# is convenient for commands defined on the Account only. # 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"): if utils.inherits_from(self.caller, "evennia.objects.objects.DefaultObject"):
# caller is an Object/Character # caller is an Object/Character
self.character = self.caller self.character = self.caller
@ -169,6 +219,8 @@ class MuxCommand(Command):
string += "\nraw argument (self.raw): |w%s|n \n" % self.raw string += "\nraw argument (self.raw): |w%s|n \n" % self.raw
string += "cmd args (self.args): |w%s|n\n" % self.args string += "cmd args (self.args): |w%s|n\n" % self.args
string += "cmd switches (self.switches): |w%s|n\n" % self.switches 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 += "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, left-hand side of '=' (self.lhs): |w%s|n\n" % self.lhs
string += "lhs, comma separated (self.lhslist): |w%s|n\n" % self.lhslist 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. character is actually attached to this Account and Session.
""" """
def parse(self): account_caller = True # Using MuxAccountCommand explicitly defaults the caller to an account
"""
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

View file

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

View file

@ -14,15 +14,17 @@ main test suite started with
import re import re
import types import types
import datetime
from django.conf import settings from django.conf import settings
from mock import Mock from mock import Mock, mock
from evennia.commands.default.cmdset_character import CharacterCmdSet from evennia.commands.default.cmdset_character import CharacterCmdSet
from evennia.utils.test_resources import EvenniaTest from evennia.utils.test_resources import EvenniaTest
from evennia.commands.default import help, general, system, admin, account, building, batchprocess, comms from evennia.commands.default import help, general, system, admin, account, building, batchprocess, comms, unloggedin
from evennia.commands.default.muxcommand import MuxCommand
from evennia.commands.command import Command, InterruptCommand from evennia.commands.command import Command, InterruptCommand
from evennia.utils import ansi, utils from evennia.utils import ansi, utils, gametime
from evennia.server.sessionhandler import SESSIONS from evennia.server.sessionhandler import SESSIONS
from evennia import search_object from evennia import search_object
from evennia import DefaultObject, DefaultCharacter from evennia import DefaultObject, DefaultCharacter
@ -37,12 +39,13 @@ _RE = re.compile(r"^\+|-+\+|\+-+|--*|\|(?:\s|$)", re.MULTILINE)
# Command testing # Command testing
# ------------------------------------------------------------ # ------------------------------------------------------------
class CommandTest(EvenniaTest): class CommandTest(EvenniaTest):
""" """
Tests a command Tests a command
""" """
def call(self, cmdobj, args, msg=None, cmdset=None, noansi=True, caller=None,
def call(self, cmdobj, args, msg=None, cmdset=None, noansi=True, caller=None, receiver=None, cmdstring=None, obj=None): receiver=None, cmdstring=None, obj=None):
""" """
Test a command by assigning all the needed Test a command by assigning all the needed
properties to cmdobj and running properties to cmdobj and running
@ -71,10 +74,10 @@ class CommandTest(EvenniaTest):
cmdobj.obj = obj or (caller if caller else self.char1) cmdobj.obj = obj or (caller if caller else self.char1)
# test # test
old_msg = receiver.msg old_msg = receiver.msg
returned_msg = ""
try: try:
receiver.msg = Mock() receiver.msg = Mock()
cmdobj.at_pre_cmd() if cmdobj.at_pre_cmd():
return
cmdobj.parse() cmdobj.parse()
ret = cmdobj.func() ret = cmdobj.func()
if isinstance(ret, types.GeneratorType): if isinstance(ret, types.GeneratorType):
@ -125,17 +128,48 @@ class TestGeneral(CommandTest):
self.call(general.CmdPose(), "looks around", "Char looks around") self.call(general.CmdPose(), "looks around", "Char looks around")
def test_nick(self): def test_nick(self):
self.call(general.CmdNick(), "testalias = testaliasedstring1", "Nick 'testalias' mapped to 'testaliasedstring1'.") self.call(general.CmdNick(), "testalias = testaliasedstring1",
self.call(general.CmdNick(), "/account testalias = testaliasedstring2", "Nick 'testalias' mapped to 'testaliasedstring2'.") "Inputlinenick 'testalias' mapped to 'testaliasedstring1'.")
self.call(general.CmdNick(), "/object testalias = testaliasedstring3", "Nick 'testalias' mapped to 'testaliasedstring3'.") 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(u"testaliasedstring1", self.char1.nicks.get("testalias"))
self.assertEqual(u"testaliasedstring2", self.char1.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")) self.assertEqual(u"testaliasedstring3", self.char1.nicks.get("testalias", category="object"))
def test_get_and_drop(self): def test_get_and_drop(self):
self.call(general.CmdGet(), "Obj", "You pick up Obj.") self.call(general.CmdGet(), "Obj", "You pick up Obj.")
self.call(general.CmdDrop(), "Obj", "You drop 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): def test_say(self):
self.call(general.CmdSay(), "Testing", "You say, \"Testing\"") self.call(general.CmdSay(), "Testing", "You say, \"Testing\"")
@ -183,7 +217,7 @@ class TestAdmin(CommandTest):
self.call(admin.CmdPerm(), "Char2 = Builder", "Permission 'Builder' given to Char2 (the Object/Character).") self.call(admin.CmdPerm(), "Char2 = Builder", "Permission 'Builder' given to Char2 (the Object/Character).")
def test_wall(self): def test_wall(self):
self.call(admin.CmdWall(), "Test", "Announcing to all connected accounts ...") self.call(admin.CmdWall(), "Test", "Announcing to all connected sessions ...")
def test_ban(self): def test_ban(self):
self.call(admin.CmdBan(), "Char", "NameBan char was added.") self.call(admin.CmdBan(), "Char", "NameBan char was added.")
@ -223,7 +257,8 @@ class TestAccount(CommandTest):
self.call(account.CmdColorTest(), "ansi", "ANSI colors:", caller=self.account) self.call(account.CmdColorTest(), "ansi", "ANSI colors:", caller=self.account)
def test_char_create(self): 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): def test_quell(self):
self.call(account.CmdQuell(), "", "Quelling to current puppet's permissions (developer).", caller=self.account) self.call(account.CmdQuell(), "", "Quelling to current puppet's permissions (developer).", caller=self.account)
@ -232,16 +267,19 @@ class TestAccount(CommandTest):
class TestBuilding(CommandTest): class TestBuilding(CommandTest):
def test_create(self): def test_create(self):
name = settings.BASE_OBJECT_TYPECLASS.rsplit('.', 1)[1] 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): def test_examine(self):
self.call(building.CmdExamine(), "Obj", "Name/key: Obj") self.call(building.CmdExamine(), "Obj", "Name/key: Obj")
def test_set_obj_alias(self): 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): 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): def test_attribute_commands(self):
self.call(building.CmdSetAttribute(), "Obj/test1=\"value1\"", "Created attribute Obj/test1 = 'value1'") self.call(building.CmdSetAttribute(), "Obj/test1=\"value1\"", "Created attribute Obj/test1 = 'value1'")
@ -284,19 +322,36 @@ class TestBuilding(CommandTest):
def test_typeclass(self): def test_typeclass(self):
self.call(building.CmdTypeclass(), "Obj = evennia.objects.objects.DefaultExit", 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): def test_lock(self):
self.call(building.CmdLock(), "Obj = test:perm(Developer)", "Added lock 'test:perm(Developer)' to Obj.") self.call(building.CmdLock(), "Obj = test:perm(Developer)", "Added lock 'test:perm(Developer)' to Obj.")
def test_find(self): def test_find(self):
self.call(building.CmdFind(), "Room2", "One Match") self.call(building.CmdFind(), "oom2", "One Match")
expect = "One Match(#1#7, loc):\n " +\
"Char2(#7) evennia.objects.objects.DefaultCharacter (location: Room(#1))"
self.call(building.CmdFind(), "Char2", expect, cmdstring="locate")
self.call(building.CmdFind(), "/ex Char2", # /ex is an ambiguous switch
"locate: Ambiguous switch supplied: Did you mean /exit or /exact?|" + expect,
cmdstring="locate")
self.call(building.CmdFind(), "Char2", expect, cmdstring="@locate")
self.call(building.CmdFind(), "/l Char2", expect, cmdstring="find") # /l switch is abbreviated form of /loc
self.call(building.CmdFind(), "Char2", "One Match", cmdstring="@find")
self.call(building.CmdFind(), "/startswith Room2", "One Match")
def test_script(self): def test_script(self):
self.call(building.CmdScript(), "Obj = scripts.Script", "Script scripts.Script successfully added") self.call(building.CmdScript(), "Obj = scripts.Script", "Script scripts.Script successfully added")
def test_teleport(self): 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 test_spawn(self):
def getObject(commandTest, objKeyStr): def getObject(commandTest, objKeyStr):
@ -312,7 +367,7 @@ class TestBuilding(CommandTest):
self.call(building.CmdSpawn(), " ", "Usage: @spawn") self.call(building.CmdSpawn(), " ", "Usage: @spawn")
# Tests "@spawn <prototype_dictionary>" without specifying location. # Tests "@spawn <prototype_dictionary>" without specifying location.
self.call(building.CmdSpawn(), \ self.call(building.CmdSpawn(),
"{'key':'goblin', 'typeclass':'evennia.DefaultCharacter'}", "Spawned goblin") "{'key':'goblin', 'typeclass':'evennia.DefaultCharacter'}", "Spawned goblin")
goblin = getObject(self, "goblin") goblin = getObject(self, "goblin")
@ -331,8 +386,8 @@ class TestBuilding(CommandTest):
# char1's default location in the future... # char1's default location in the future...
spawnLoc = self.room1 spawnLoc = self.room1
self.call(building.CmdSpawn(), \ self.call(building.CmdSpawn(),
"{'prototype':'GOBLIN', 'key':'goblin', 'location':'%s'}" \ "{'prototype':'GOBLIN', 'key':'goblin', 'location':'%s'}"
% spawnLoc.dbref, "Spawned goblin") % spawnLoc.dbref, "Spawned goblin")
goblin = getObject(self, "goblin") goblin = getObject(self, "goblin")
self.assertEqual(goblin.location, spawnLoc) self.assertEqual(goblin.location, spawnLoc)
@ -345,70 +400,81 @@ class TestBuilding(CommandTest):
self.assertIsInstance(ball, DefaultObject) self.assertIsInstance(ball, DefaultObject)
ball.delete() ball.delete()
# Tests "@spawn/noloc ..." without specifying a location. # Tests "@spawn/n ..." without specifying a location.
# Location should be "None". # 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") ball = getObject(self, "Ball")
self.assertIsNone(ball.location) self.assertIsNone(ball.location)
ball.delete() ball.delete()
# Tests "@spawn/noloc ...", but DO specify a location. # Tests "@spawn/noloc ...", but DO specify a location.
# Location should be the specified location. # Location should be the specified location.
self.call(building.CmdSpawn(), \ self.call(building.CmdSpawn(),
"/noloc {'prototype':'BALL', 'location':'%s'}" \ "/noloc {'prototype':'BALL', 'location':'%s'}"
% spawnLoc.dbref, "Spawned Ball") % spawnLoc.dbref, "Spawned Ball")
ball = getObject(self, "Ball") ball = getObject(self, "Ball")
self.assertEqual(ball.location, spawnLoc) self.assertEqual(ball.location, spawnLoc)
ball.delete() ball.delete()
# test calling spawn with an invalid prototype. # test calling spawn with an invalid prototype.
self.call(building.CmdSpawn(), \ self.call(building.CmdSpawn(), "'NO_EXIST'", "No prototype named 'NO_EXIST'")
"'NO_EXIST'", "No prototype named 'NO_EXIST'")
class TestComms(CommandTest): class TestComms(CommandTest):
def setUp(self): def setUp(self):
super(CommandTest, self).setUp() 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): 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) self.call(comms.CmdDelCom(), "tc", "Your alias 'tc' for channel testchan was cleared.", receiver=self.account)
def test_channels(self): 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): 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): 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): 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): 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): def test_cwho(self):
self.call(comms.CmdCWho(), "testchan", "Channel subscriptions\ntestchan:\n TestAccount", receiver=self.account) self.call(comms.CmdCWho(), "testchan", "Channel subscriptions\ntestchan:\n TestAccount", receiver=self.account)
def test_page(self): 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): def test_cboot(self):
# No one else connected to boot # No one else connected to boot
self.call(comms.CmdCBoot(), "", "Usage: @cboot[/quiet] <channel> = <account> [:reason]", receiver=self.account) self.call(comms.CmdCBoot(), "", "Usage: @cboot[/quiet] <channel> = <account> [:reason]", receiver=self.account)
def test_cdestroy(self): 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): class TestBatchProcess(CommandTest):
def test_batch_commands(self): def test_batch_commands(self):
# cannot test batchcode here, it must run inside the server process # 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 # we make sure to delete the button again here to stop the running reactor
confirm = building.CmdDestroy.confirm confirm = building.CmdDestroy.confirm
building.CmdDestroy.confirm = False building.CmdDestroy.confirm = False
@ -431,3 +497,12 @@ class TestInterruptCommand(CommandTest):
def test_interrupt_command(self): def test_interrupt_command(self):
ret = self.call(CmdInterrupt(), "") ret = self.call(CmdInterrupt(), "")
self.assertEqual(ret, "") self.assertEqual(ret, "")
class TestUnconnectedCommand(CommandTest):
def test_info_command(self):
expected = "## BEGIN INFO 1.1\nName: %s\nUptime: %s\nConnected: %d\nVersion: Evennia %s\n## END INFO" % (
settings.SERVERNAME,
datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(),
SESSIONS.account_count(), utils.get_evennia_version().replace("-", ""))
self.call(unloggedin.CmdUnconnectedInfo(), "", expected)

View file

@ -3,6 +3,7 @@ Commands that are available from the connect screen.
""" """
import re import re
import time import time
import datetime
from collections import defaultdict from collections import defaultdict
from random import getrandbits from random import getrandbits
from django.conf import settings from django.conf import settings
@ -11,8 +12,9 @@ from evennia.accounts.models import AccountDB
from evennia.objects.models import ObjectDB from evennia.objects.models import ObjectDB
from evennia.server.models import ServerConfig from evennia.server.models import ServerConfig
from evennia.comms.models import ChannelDB from evennia.comms.models import ChannelDB
from evennia.server.sessionhandler import SESSIONS
from evennia.utils import create, logger, utils from evennia.utils import create, logger, utils, gametime
from evennia.commands.cmdhandler import CMD_LOGINSTART from evennia.commands.cmdhandler import CMD_LOGINSTART
COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS) COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS)
@ -516,6 +518,24 @@ class CmdUnconnectedScreenreader(COMMAND_DEFAULT_CLASS):
self.session.sessionhandler.session_portal_sync(self.session) self.session.sessionhandler.session_portal_sync(self.session)
class CmdUnconnectedInfo(COMMAND_DEFAULT_CLASS):
"""
Provides MUDINFO output, so that Evennia games can be added to Mudconnector
and Mudstats. Sadly, the MUDINFO specification seems to have dropped off the
face of the net, but it is still used by some crawlers. This implementation
was created by looking at the MUDINFO implementation in MUX2, TinyMUSH, Rhost,
and PennMUSH.
"""
key = "info"
locks = "cmd:all()"
def func(self):
self.caller.msg("## BEGIN INFO 1.1\nName: %s\nUptime: %s\nConnected: %d\nVersion: Evennia %s\n## END INFO" % (
settings.SERVERNAME,
datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(),
SESSIONS.account_count(), utils.get_evennia_version()))
def _create_account(session, accountname, password, permissions, typeclass=None, email=None): def _create_account(session, accountname, password, permissions, typeclass=None, email=None):
""" """
Helper function, creates an account of the specified typeclass. Helper function, creates an account of the specified typeclass.

View file

@ -264,14 +264,20 @@ class TestCmdSetMergers(TestCase):
# test cmdhandler functions # test cmdhandler functions
import sys
from evennia.commands import cmdhandler from evennia.commands import cmdhandler
from twisted.trial.unittest import TestCase as TwistedTestCase from twisted.trial.unittest import TestCase as TwistedTestCase
def _mockdelay(time, func, *args, **kwargs):
return func(*args, **kwargs)
class TestGetAndMergeCmdSets(TwistedTestCase, EvenniaTest): class TestGetAndMergeCmdSets(TwistedTestCase, EvenniaTest):
"Test the cmdhandler.get_and_merge_cmdsets function." "Test the cmdhandler.get_and_merge_cmdsets function."
def setUp(self): def setUp(self):
self.patch(sys.modules['evennia.server.sessionhandler'], 'delay', _mockdelay)
super(TestGetAndMergeCmdSets, self).setUp() super(TestGetAndMergeCmdSets, self).setUp()
self.cmdset_a = _CmdSetA() self.cmdset_a = _CmdSetA()
self.cmdset_b = _CmdSetB() self.cmdset_b = _CmdSetB()
@ -325,6 +331,7 @@ class TestGetAndMergeCmdSets(TwistedTestCase, EvenniaTest):
a.no_exits = True a.no_exits = True
a.no_channels = True a.no_channels = True
self.set_cmdsets(self.obj1, a, b, c, d) self.set_cmdsets(self.obj1, a, b, c, d)
deferred = cmdhandler.get_and_merge_cmdsets(self.obj1, None, None, self.obj1, "object", "") deferred = cmdhandler.get_and_merge_cmdsets(self.obj1, None, None, self.obj1, "object", "")
def _callback(cmdset): def _callback(cmdset):

View file

@ -159,7 +159,7 @@ class ChannelHandler(object):
""" """
The ChannelHandler manages all active in-game channels and The ChannelHandler manages all active in-game channels and
dynamically creates channel commands for users so that they can dynamically creates channel commands for users so that they can
just give the channek's key or alias to write to it. Whenever a just give the channel's key or alias to write to it. Whenever a
new channel is created in the database, the update() method on new channel is created in the database, the update() method on
this handler must be called to sync it with the database (this is this handler must be called to sync it with the database (this is
done automatically if creating the channel with done automatically if creating the channel with

View file

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

View file

@ -355,15 +355,16 @@ class ChannelDBManager(TypedObjectManager):
channel (Channel or None): A channel match. channel (Channel or None): A channel match.
""" """
# first check the channel key dbref = self.dbref(channelkey)
channels = self.filter(db_key__iexact=channelkey) if dbref:
if not channels: try:
# also check aliases return self.get(id=dbref)
channels = [channel for channel in self.all() except self.model.DoesNotExist:
if channelkey in channel.aliases.all()] pass
if channels: results = self.filter(Q(db_key__iexact=channelkey) |
return channels[0] Q(db_tags__db_tagtype__iexact="alias",
return None db_tags__db_key__iexact=channelkey)).distinct()
return results[0] if results else None
def get_subscriptions(self, subscriber): def get_subscriptions(self, subscriber):
""" """
@ -393,26 +394,20 @@ class ChannelDBManager(TypedObjectManager):
case sensitive) match. case sensitive) match.
""" """
channels = [] dbref = self.dbref(ostring)
if not ostring: if dbref:
return channels try:
try: return self.get(id=dbref)
# try an id match first except self.model.DoesNotExist:
dbref = int(ostring.strip('#')) pass
channels = self.filter(id=dbref) if exact:
except Exception: channels = self.filter(Q(db_key__iexact=ostring) |
# Usually because we couldn't convert to int - not a dbref Q(db_tags__db_tagtype__iexact="alias",
pass db_tags__db_key__iexact=ostring)).distinct()
if not channels: else:
# no id match. Search on the key. channels = self.filter(Q(db_key__icontains=ostring) |
if exact: Q(db_tags__db_tagtype__iexact="alias",
channels = self.filter(db_key__iexact=ostring) db_tags__db_key__icontains=ostring)).distinct()
else:
channels = self.filter(db_key__icontains=ostring)
if not channels:
# still no match. Search by alias.
channels = [channel for channel in self.all()
if ostring.lower() in [a.lower for a in channel.aliases.all()]]
return channels return channels
# back-compatibility alias # back-compatibility alias
channel_search = search_channel channel_search = search_channel

View file

@ -584,9 +584,7 @@ class SubscriptionHandler(object):
for obj in self.all(): for obj in self.all():
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
try: try:
if hasattr(obj, 'account'): if hasattr(obj, 'account') and obj.account:
if not obj.account:
continue
obj = obj.account obj = obj.account
if not obj.is_connected: if not obj.is_connected:
continue continue

View file

@ -16,7 +16,7 @@ things you want from here into your game folder and change them there.
## Contrib modules ## Contrib modules
* Barter system (Griatch 2012) - A safe and effective barter-system * 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. * CharGen (Griatch 2011) - A simple Character creator for OOC mode.
Meant as a starting point for a more fleshed-out system. Meant as a starting point for a more fleshed-out system.
* Clothing (FlutterSprite 2017) - A layered clothing system with * Clothing (FlutterSprite 2017) - A layered clothing system with
@ -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. on a character and access it in an emote with a custom marker.
* Mail (grungies1138 2016) - An in-game mail system for communication. * Mail (grungies1138 2016) - An in-game mail system for communication.
* Menu login (Griatch 2011) - A login system using menus asking * 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 * Map Builder (CloudKeeper 2016) - Build a game area based on a 2D
"graphical" unicode map. Supports assymmetric exits. "graphical" unicode map. Supports assymmetric exits.
* Menu Login (Vincent-lg 2016) - Alternate login system using EvMenu. * Menu Login (Vincent-lg 2016) - Alternate login system using EvMenu.
@ -60,7 +60,7 @@ things you want from here into your game folder and change them there.
## Contrib packages ## Contrib packages
* EGI_Client (gtaylor 2016) - Client for reporting game status * 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 * In-game Python (Vincent Le Goff 2017) - Allow trusted builders to script
objects and events using Python from in-game. objects and events using Python from in-game.
* Turnbattle (FlutterSprite 2017) - A turn-based combat engine meant * Turnbattle (FlutterSprite 2017) - A turn-based combat engine meant

View file

@ -189,7 +189,7 @@ class ExtendedRoom(DefaultRoom):
key (str): A detail identifier. key (str): A detail identifier.
Returns: Returns:
detail (str or None): A detail mathing the given key. detail (str or None): A detail matching the given key.
Notes: Notes:
A detail is a way to offer more things to look at in a room A detail is a way to offer more things to look at in a room
@ -213,37 +213,39 @@ class ExtendedRoom(DefaultRoom):
return detail return detail
return None 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 This is called when e.g. the look command wants to retrieve
the description of this object. the description of this object.
Args: Args:
looker (Object): The object looking at us. looker (Object): The object looking at us.
**kwargs (dict): Arbitrary, optional arguments for users
overriding the call (unused by default).
Returns: Returns:
description (str): Our description. 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 # get current time and season
curr_season, curr_timeslot = self.get_time_and_season() curr_season, curr_timeslot = self.get_time_and_season()
# compare with previously stored slots # compare with previously stored slots
last_season = self.ndb.last_season last_season = self.ndb.last_season
last_timeslot = self.ndb.last_timeslot last_timeslot = self.ndb.last_timeslot
if curr_season != last_season: if curr_season != last_season:
# season changed. Load new desc, or a fallback. # season changed. Load new desc, or a fallback.
if curr_season == 'spring': new_raw_desc = self.attributes.get("%s_desc" % curr_season)
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
if new_raw_desc: if new_raw_desc:
raw_desc = new_raw_desc raw_desc = new_raw_desc
else: else:
@ -252,19 +254,15 @@ class ExtendedRoom(DefaultRoom):
self.db.raw_desc = raw_desc self.db.raw_desc = raw_desc
self.ndb.last_season = curr_season self.ndb.last_season = curr_season
update = True update = True
if curr_timeslot != last_timeslot: if curr_timeslot != last_timeslot:
# timeslot changed. Set update flag. # timeslot changed. Set update flag.
self.ndb.last_timeslot = curr_timeslot self.ndb.last_timeslot = curr_timeslot
update = True update = True
if update: if update:
# if anything changed we have to re-parse # if anything changed we have to re-parse
# the raw_desc for time markers # the raw_desc for time markers
# and re-save the description again. # and re-save the description again.
self.db.desc = self.replace_timeslots(self.db.raw_desc, curr_timeslot) 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 # Custom Look command supporting Room details. Add this to
@ -369,6 +367,7 @@ class CmdExtendedDesc(default_cmds.CmdDesc):
""" """
aliases = ["describe", "detail"] aliases = ["describe", "detail"]
switch_options = () # Inherits from default_cmds.CmdDesc, but unused here
def reset_times(self, obj): def reset_times(self, obj):
"""By deleteting the caches we force a re-load.""" """By deleteting the caches we force a re-load."""

View file

@ -430,7 +430,7 @@ class EventCharacter(DefaultCharacter):
# Browse all the room's other characters # Browse all the room's other characters
for obj in location.contents: 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 continue
allow = obj.callbacks.call("can_say", self, obj, message, parameters=message) allow = obj.callbacks.call("can_say", self, obj, message, parameters=message)
@ -491,7 +491,7 @@ class EventCharacter(DefaultCharacter):
parameters=message) parameters=message)
# Call the other characters' "say" event # 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: for present in presents:
present.callbacks.call("say", self, present, message, parameters=message) present.callbacks.call("say", self, present, message, parameters=message)

View file

@ -253,7 +253,7 @@ class CmdMail(default_cmds.MuxCommand):
index += 1 index += 1
table.reformat_column(0, width=6) table.reformat_column(0, width=6)
table.reformat_column(1, width=17) table.reformat_column(1, width=18)
table.reformat_column(2, width=34) table.reformat_column(2, width=34)
table.reformat_column(3, width=13) table.reformat_column(3, width=13)
table.reformat_column(4, width=7) table.reformat_column(4, width=7)

View file

@ -708,12 +708,15 @@ class RecogHandler(object):
than `max_length`. than `max_length`.
""" """
if not obj.access(self.obj, "enable_recog", default=True):
raise SdescError("This person is unrecognizeable.")
# strip emote components from recog # strip emote components from recog
recog = _RE_REF.sub(r"\1", recog = _RE_REF.sub(
_RE_REF_LANG.sub(r"\1", r"\1", _RE_REF_LANG.sub(
_RE_SELF_REF.sub(r"", r"\1", _RE_SELF_REF.sub(
_RE_LANGUAGE.sub(r"", r"", _RE_LANGUAGE.sub(
_RE_OBJ_REF_START.sub(r"", recog))))) r"", _RE_OBJ_REF_START.sub(r"", recog)))))
# make an recog clean of ANSI codes # make an recog clean of ANSI codes
cleaned_recog = ansi.strip_ansi(recog) cleaned_recog = ansi.strip_ansi(recog)
@ -1085,7 +1088,7 @@ class CmdMask(RPCommand):
if self.cmdstring == "mask": if self.cmdstring == "mask":
# wear a mask # wear a mask
if not self.args: if not self.args:
caller.msg("Usage: (un)wearmask sdesc") caller.msg("Usage: (un)mask sdesc")
return return
if caller.db.unmasked_sdesc: if caller.db.unmasked_sdesc:
caller.msg("You are already wearing a mask.") caller.msg("You are already wearing a mask.")
@ -1108,7 +1111,7 @@ class CmdMask(RPCommand):
del caller.db.unmasked_sdesc del caller.db.unmasked_sdesc
caller.locks.remove("enable_recog") caller.locks.remove("enable_recog")
caller.sdesc.add(old_sdesc) caller.sdesc.add(old_sdesc)
caller.msg("You remove your mask and is again '%s'." % old_sdesc) caller.msg("You remove your mask and are again '%s'." % old_sdesc)
class RPSystemCmdSet(CmdSet): class RPSystemCmdSet(CmdSet):
@ -1200,7 +1203,7 @@ class ContribRPObject(DefaultObject):
below. below.
exact (bool): if unset (default) - prefers to match to beginning of exact (bool): if unset (default) - prefers to match to beginning of
string rather than not matching at all. If set, requires string rather than not matching at all. If set, requires
exact mathing of entire string. exact matching of entire string.
candidates (list of objects): this is an optional custom list of objects candidates (list of objects): this is an optional custom list of objects
to search (filter) between. It is ignored if `global_search` to search (filter) between. It is ignored if `global_search`
is given. If not set, this list will automatically be defined is given. If not set, this list will automatically be defined

View file

@ -670,7 +670,7 @@ class TestGenderSub(CommandTest):
char = create_object(gendersub.GenderCharacter, key="Gendered", location=self.room1) char = create_object(gendersub.GenderCharacter, key="Gendered", location=self.room1)
txt = "Test |p gender" txt = "Test |p gender"
self.assertEqual(gendersub._RE_GENDER_PRONOUN.sub(char._get_pronoun, txt), "Test their gender") self.assertEqual(gendersub._RE_GENDER_PRONOUN.sub(char._get_pronoun, txt), "Test their gender")
# test health bar contrib # test health bar contrib
from evennia.contrib import health_bar from evennia.contrib import health_bar
@ -697,7 +697,7 @@ class TestMail(CommandTest):
"You have received a new @mail from TestAccount2(account 2)|You sent your message.", caller=self.account2) "You have received a new @mail from TestAccount2(account 2)|You sent your message.", caller=self.account2)
self.call(mail.CmdMail(), "TestAccount=Message 1", "You sent your message.", caller=self.account2) self.call(mail.CmdMail(), "TestAccount=Message 1", "You sent your message.", caller=self.account2)
self.call(mail.CmdMail(), "TestAccount=Message 2", "You sent your message.", caller=self.account2) self.call(mail.CmdMail(), "TestAccount=Message 2", "You sent your message.", caller=self.account2)
self.call(mail.CmdMail(), "", "| ID: From: Subject:", caller=self.account) self.call(mail.CmdMail(), "", "| ID: From: Subject:", caller=self.account)
self.call(mail.CmdMail(), "2", "From: TestAccount2", caller=self.account) self.call(mail.CmdMail(), "2", "From: TestAccount2", caller=self.account)
self.call(mail.CmdMail(), "/forward TestAccount2 = 1/Forward message", "You sent your message.|Message forwarded.", caller=self.account) self.call(mail.CmdMail(), "/forward TestAccount2 = 1/Forward message", "You sent your message.|Message forwarded.", caller=self.account)
self.call(mail.CmdMail(), "/reply 2=Reply Message2", "You sent your message.", caller=self.account) self.call(mail.CmdMail(), "/reply 2=Reply Message2", "You sent your message.", caller=self.account)
@ -798,7 +798,7 @@ from evennia.contrib import talking_npc
class TestTalkingNPC(CommandTest): class TestTalkingNPC(CommandTest):
def test_talkingnpc(self): def test_talkingnpc(self):
npc = create_object(talking_npc.TalkingNPC, key="npctalker", location=self.room1) npc = create_object(talking_npc.TalkingNPC, key="npctalker", location=self.room1)
self.call(talking_npc.CmdTalk(), "", "(You walk up and talk to Char.)|") self.call(talking_npc.CmdTalk(), "", "(You walk up and talk to Char.)")
npc.delete() npc.delete()
@ -824,9 +824,30 @@ class TestTutorialWorldMob(EvenniaTest):
from evennia.contrib.tutorial_world import objects as tutobjects from evennia.contrib.tutorial_world import objects as tutobjects
from mock.mock import MagicMock
from twisted.trial.unittest import TestCase as TwistedTestCase
from twisted.internet.base import DelayedCall
DelayedCall.debug = True
class TestTutorialWorldObjects(CommandTest): def _mockdelay(tim, func, *args, **kwargs):
func(*args, **kwargs)
return MagicMock()
def _mockdeferLater(reactor, timedelay, callback, *args, **kwargs):
callback(*args, **kwargs)
return MagicMock()
class TestTutorialWorldObjects(TwistedTestCase, CommandTest):
def setUp(self):
self.patch(sys.modules['evennia.contrib.tutorial_world.objects'], 'delay', _mockdelay)
self.patch(sys.modules['evennia.scripts.taskhandler'], 'deferLater', _mockdeferLater)
super(TestTutorialWorldObjects, self).setUp()
def test_tutorialobj(self): def test_tutorialobj(self):
obj1 = create_object(tutobjects.TutorialObject, key="tutobj") obj1 = create_object(tutobjects.TutorialObject, key="tutobj")
obj1.reset() obj1.reset()
@ -848,10 +869,7 @@ class TestTutorialWorldObjects(CommandTest):
def test_lightsource(self): def test_lightsource(self):
light = create_object(tutobjects.LightSource, key="torch", location=self.room1) light = create_object(tutobjects.LightSource, key="torch", location=self.room1)
self.call(tutobjects.CmdLight(), "", "You light torch.", obj=light) self.call(tutobjects.CmdLight(), "", "A torch on the floor flickers and dies.|You light torch.", obj=light)
light._burnout()
if hasattr(light, "deferred"):
light.deferred.cancel()
self.assertFalse(light.pk) self.assertFalse(light.pk)
def test_crumblingwall(self): def test_crumblingwall(self):
@ -869,12 +887,12 @@ class TestTutorialWorldObjects(CommandTest):
"You shift the weedy green root upwards.|Holding aside the root you think you notice something behind it ...", obj=wall) "You shift the weedy green root upwards.|Holding aside the root you think you notice something behind it ...", obj=wall)
self.call(tutobjects.CmdPressButton(), "", self.call(tutobjects.CmdPressButton(), "",
"You move your fingers over the suspicious depression, then gives it a decisive push. First", obj=wall) "You move your fingers over the suspicious depression, then gives it a decisive push. First", obj=wall)
self.assertTrue(wall.db.button_exposed) # we patch out the delay, so these are closed immediately
self.assertTrue(wall.db.exit_open) self.assertFalse(wall.db.button_exposed)
self.assertFalse(wall.db.exit_open)
wall.reset() wall.reset()
if hasattr(wall, "deferred"):
wall.deferred.cancel()
wall.delete() wall.delete()
return wall.deferred
def test_weapon(self): def test_weapon(self):
weapon = create_object(tutobjects.Weapon, key="sword", location=self.char1) weapon = create_object(tutobjects.Weapon, key="sword", location=self.char1)
@ -948,7 +966,7 @@ class TestTurnBattleCmd(CommandTest):
self.call(tb_basic.CmdPass(), "", "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.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_basic.CmdRest(), "", "Char rests to recover HP.") self.call(tb_basic.CmdRest(), "", "Char rests to recover HP.")
# Test equipment commands # Test equipment commands
def test_turnbattleequipcmd(self): def test_turnbattleequipcmd(self):
# Start with equip module specific commands. # Start with equip module specific commands.
@ -966,7 +984,7 @@ class TestTurnBattleCmd(CommandTest):
self.call(tb_equip.CmdPass(), "", "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.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_equip.CmdRest(), "", "Char rests to recover HP.") self.call(tb_equip.CmdRest(), "", "Char rests to recover HP.")
# Test range commands # Test range commands
def test_turnbattlerangecmd(self): def test_turnbattlerangecmd(self):
# Start with range module specific commands. # Start with range module specific commands.
@ -980,7 +998,7 @@ class TestTurnBattleCmd(CommandTest):
self.call(tb_range.CmdPass(), "", "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.CmdDisengage(), "", "You can only do that in combat. (see: help fight)")
self.call(tb_range.CmdRest(), "", "Char rests to recover HP.") self.call(tb_range.CmdRest(), "", "Char rests to recover HP.")
class TestTurnBattleFunc(EvenniaTest): class TestTurnBattleFunc(EvenniaTest):
@ -1062,7 +1080,7 @@ class TestTurnBattleFunc(EvenniaTest):
self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender])
# Remove the script at the end # Remove the script at the end
turnhandler.stop() turnhandler.stop()
# Test the combat functions in tb_equip too. They work mostly the same. # Test the combat functions in tb_equip too. They work mostly the same.
def test_tbequipfunc(self): def test_tbequipfunc(self):
attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker") attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker")
@ -1141,7 +1159,7 @@ class TestTurnBattleFunc(EvenniaTest):
self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender])
# Remove the script at the end # Remove the script at the end
turnhandler.stop() turnhandler.stop()
# Test combat functions in tb_range too. # Test combat functions in tb_range too.
def test_tbrangefunc(self): def test_tbrangefunc(self):
testroom = create_object(DefaultRoom, key="Test Room") testroom = create_object(DefaultRoom, key="Test Room")
@ -1246,7 +1264,7 @@ Bar
-Qux""" -Qux"""
class TestTreeSelectFunc(EvenniaTest): class TestTreeSelectFunc(EvenniaTest):
def test_tree_functions(self): def test_tree_functions(self):
# Dash counter # Dash counter
self.assertTrue(tree_select.dashcount("--test") == 2) self.assertTrue(tree_select.dashcount("--test") == 2)
@ -1261,7 +1279,7 @@ class TestTreeSelectFunc(EvenniaTest):
# Option list to menu options # Option list to menu options
test_optlist = tree_select.parse_opts(TREE_MENU_TESTSTR, category_index=2) 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'}, 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': 4}], 'key': 'Baz 2'},
{'goto': ['menunode_treeselect', {'newindex': 1}], 'key': ['<< Go Back', 'go back', 'back'], 'desc': 'Return to the previous menu.'}] {'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) self.assertTrue(tree_select.optlist_to_menuoptions(TREE_MENU_TESTSTR, test_optlist, 2, True, True) == optlist_to_menu_expected_result)

View file

@ -23,8 +23,7 @@ from future.utils import listvalues
import random import random
from evennia import DefaultObject, DefaultExit, Command, CmdSet from evennia import DefaultObject, DefaultExit, Command, CmdSet
from evennia import utils from evennia.utils import search, delay
from evennia.utils import search
from evennia.utils.spawner import spawn from evennia.utils.spawner import spawn
# ------------------------------------------------------------- # -------------------------------------------------------------
@ -373,7 +372,7 @@ class LightSource(TutorialObject):
# start the burn timer. When it runs out, self._burnout # start the burn timer. When it runs out, self._burnout
# will be called. We store the deferred so it can be # will be called. We store the deferred so it can be
# killed in unittesting. # killed in unittesting.
self.deferred = utils.delay(60 * 3, self._burnout) self.deferred = delay(60 * 3, self._burnout)
return True return True
@ -645,7 +644,7 @@ class CrumblingWall(TutorialObject, DefaultExit):
self.db.exit_open = True self.db.exit_open = True
# start a 45 second timer before closing again. We store the deferred so it can be # start a 45 second timer before closing again. We store the deferred so it can be
# killed in unittesting. # killed in unittesting.
self.deferred = utils.delay(45, self.reset) self.deferred = delay(45, self.reset)
def _translate_position(self, root, ipos): def _translate_position(self, root, ipos):
"""Translates the position into words""" """Translates the position into words"""

View file

@ -168,7 +168,7 @@ class CmdTutorialLook(default_cmds.CmdLook):
else: else:
# no detail found, delegate our result to the normal # no detail found, delegate our result to the normal
# error message handler. # error message handler.
_SEARCH_AT_RESULT(None, caller, args, looking_at_obj) _SEARCH_AT_RESULT(looking_at_obj, caller, args)
return return
else: else:
# we found a match, extract it from the list and carry on # we found a match, extract it from the list and carry on

View file

@ -34,27 +34,6 @@ from evennia.settings_default import *
# This is the name of your game. Make it catchy! # This is the name of your game. Make it catchy!
SERVERNAME = {servername} 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. # Settings given in secret_settings.py override those in this file.
@ -62,4 +41,4 @@ AMP_PORT = 4006
try: try:
from server.conf.secret_settings import * from server.conf.secret_settings import *
except ImportError: except ImportError:
print "secret_settings.py file not found or failed to import." print("secret_settings.py file not found or failed to import.")

View file

@ -89,10 +89,14 @@ DefaultLock: Exits: controls who may traverse the exit to
""" """
from __future__ import print_function from __future__ import print_function
from ast import literal_eval
from django.conf import settings from django.conf import settings
from evennia.utils import utils from evennia.utils import utils
_PERMISSION_HIERARCHY = [pe.lower() for pe in settings.PERMISSION_HIERARCHY] _PERMISSION_HIERARCHY = [pe.lower() for pe in settings.PERMISSION_HIERARCHY]
# also accept different plural forms
_PERMISSION_HIERARCHY_PLURAL = [pe + 's' if not pe.endswith('s') else pe
for pe in _PERMISSION_HIERARCHY]
def _to_account(accessing_obj): def _to_account(accessing_obj):
@ -158,49 +162,77 @@ def perm(accessing_obj, accessed_obj, *args, **kwargs):
""" """
# this allows the perm_above lockfunc to make use of this function too # this allows the perm_above lockfunc to make use of this function too
gtmode = kwargs.pop("_greater_than", False)
try: try:
permission = args[0].lower() permission = args[0].lower()
perms_object = [p.lower() for p in accessing_obj.permissions.all()] perms_object = accessing_obj.permissions.all()
except (AttributeError, IndexError): except (AttributeError, IndexError):
return False return False
if utils.inherits_from(accessing_obj, "evennia.objects.objects.DefaultObject") and accessing_obj.account: gtmode = kwargs.pop("_greater_than", False)
account = accessing_obj.account is_quell = False
# we strip eventual plural forms, so Builders == Builder
perms_account = [p.lower().rstrip("s") for p in account.permissions.all()] account = (utils.inherits_from(accessing_obj, "evennia.objects.objects.DefaultObject") and
accessing_obj.account)
# check object perms (note that accessing_obj could be an Account too)
perms_account = []
if account:
perms_account = account.permissions.all()
is_quell = account.attributes.get("_quell") is_quell = account.attributes.get("_quell")
if permission in _PERMISSION_HIERARCHY: # Check hirarchy matches; handle both singular/plural forms in hierarchy
# check hierarchy without allowing escalation obj->account hpos_target = None
hpos_target = _PERMISSION_HIERARCHY.index(permission) if permission in _PERMISSION_HIERARCHY:
hpos_account = [hpos for hpos, hperm in enumerate(_PERMISSION_HIERARCHY) if hperm in perms_account] hpos_target = _PERMISSION_HIERARCHY.index(permission)
if permission.endswith('s') and permission[:-1] in _PERMISSION_HIERARCHY:
hpos_target = _PERMISSION_HIERARCHY.index(permission[:-1])
if hpos_target is not None:
# hieratchy match
hpos_account = -1
hpos_object = -1
if account:
# we have an account puppeting this object. We must check what perms it has
perms_account_single = [p[:-1] if p.endswith('s') else p for p in perms_account]
hpos_account = [hpos for hpos, hperm in enumerate(_PERMISSION_HIERARCHY)
if hperm in perms_account_single]
hpos_account = hpos_account and hpos_account[-1] or -1 hpos_account = hpos_account and hpos_account[-1] or -1
if is_quell:
hpos_object = [hpos for hpos, hperm in enumerate(_PERMISSION_HIERARCHY) if hperm in perms_object] if not account or is_quell:
hpos_object = hpos_object and hpos_object[-1] or -1 # only get the object-level perms if there is no account or quelling
if gtmode: perms_object_single = [p[:-1] if p.endswith('s') else p for p in perms_object]
return hpos_target < min(hpos_account, hpos_object) hpos_object = [hpos for hpos, hperm in enumerate(_PERMISSION_HIERARCHY)
else: if hperm in perms_object_single]
return hpos_target <= min(hpos_account, hpos_object) hpos_object = hpos_object and hpos_object[-1] or -1
elif gtmode:
if account and is_quell:
# quell mode: use smallest perm from account and object
if gtmode:
return hpos_target < min(hpos_account, hpos_object)
else:
return hpos_target <= min(hpos_account, hpos_object)
elif account:
# use account perm
if gtmode:
return hpos_target < hpos_account return hpos_target < hpos_account
else: else:
return hpos_target <= hpos_account return hpos_target <= hpos_account
elif not is_quell and permission in perms_account: else:
# if we get here, check account perms first, otherwise # use object perm
# continue as normal if gtmode:
return hpos_target < hpos_object
else:
return hpos_target <= hpos_object
else:
# no hierarchy match - check direct matches
if account:
# account exists, check it first unless quelled
if is_quell and permission in perms_object:
return True
elif permission in perms_account:
return True
elif permission in perms_object:
return True return True
if permission in perms_object:
# simplest case - we have direct match
return True
if permission in _PERMISSION_HIERARCHY:
# check if we have a higher hierarchy position
hpos_target = _PERMISSION_HIERARCHY.index(permission)
return any(1 for hpos, hperm in enumerate(_PERMISSION_HIERARCHY)
if hperm in perms_object and hpos_target < hpos)
return False return False
@ -229,7 +261,6 @@ def pperm(accessing_obj, accessed_obj, *args, **kwargs):
""" """
return perm(_to_account(accessing_obj), accessed_obj, *args, **kwargs) return perm(_to_account(accessing_obj), accessed_obj, *args, **kwargs)
def pperm_above(accessing_obj, accessed_obj, *args, **kwargs): def pperm_above(accessing_obj, accessed_obj, *args, **kwargs):
""" """
Only allow Account objects with a permission *higher* in the permission Only allow Account objects with a permission *higher* in the permission
@ -482,7 +513,7 @@ def tag(accessing_obj, accessed_obj, *args, **kwargs):
accessing_obj = accessing_obj.obj accessing_obj = accessing_obj.obj
tagkey = args[0] if args else None tagkey = args[0] if args else None
category = args[1] if len(args) > 1 else None category = args[1] if len(args) > 1 else None
return accessing_obj.tags.get(tagkey, category=category) return bool(accessing_obj.tags.get(tagkey, category=category))
def objtag(accessing_obj, accessed_obj, *args, **kwargs): def objtag(accessing_obj, accessed_obj, *args, **kwargs):
@ -494,7 +525,7 @@ def objtag(accessing_obj, accessed_obj, *args, **kwargs):
Only true if accessed_obj has the specified tag and optional Only true if accessed_obj has the specified tag and optional
category. category.
""" """
return accessed_obj.tags.get(*args) return bool(accessed_obj.tags.get(*args))
def inside(accessing_obj, accessed_obj, *args, **kwargs): def inside(accessing_obj, accessed_obj, *args, **kwargs):
@ -592,7 +623,9 @@ def serversetting(accessing_obj, accessed_obj, *args, **kwargs):
serversetting(IRC_ENABLED) serversetting(IRC_ENABLED)
serversetting(BASE_SCRIPT_PATH, ['types']) serversetting(BASE_SCRIPT_PATH, ['types'])
A given True/False or integers will be converted properly. A given True/False or integers will be converted properly. Note that
everything will enter this function as strings, so they have to be
unpacked to their real value. We only support basic properties.
""" """
if not args or not args[0]: if not args or not args[0]:
return False return False
@ -602,12 +635,12 @@ def serversetting(accessing_obj, accessed_obj, *args, **kwargs):
else: else:
setting, val = args[0], args[1] setting, val = args[0], args[1]
# convert # convert
if val == 'True': try:
val = True val = literal_eval(val)
elif val == 'False': except Exception:
val = False # we swallow errors here, lockfuncs has noone to report to
elif val.isdigit(): return False
val = int(val)
if setting in settings._wrapped.__dict__: if setting in settings._wrapped.__dict__:
return settings._wrapped.__dict__[setting] == val return settings._wrapped.__dict__[setting] == val
return False return False

View file

@ -541,6 +541,46 @@ class LockHandler(object):
return all(self._eval_access_type(accessing_obj, locks, access_type) for access_type in locks) return all(self._eval_access_type(accessing_obj, locks, access_type) for access_type in locks)
# convenience access function
# dummy to be able to call check_lockstring from the outside
class _ObjDummy:
lock_storage = ''
_LOCK_HANDLER = LockHandler(_ObjDummy())
def check_lockstring(self, accessing_obj, lockstring, no_superuser_bypass=False,
default=False, access_type=None):
"""
Do a direct check against a lockstring ('atype:func()..'),
without any intermediary storage on the accessed object.
Args:
accessing_obj (object or None): The object seeking access.
Importantly, this can be left unset if the lock functions
don't access it, no updating or storage of locks are made
against this object in this method.
lockstring (str): Lock string to check, on the form
`"access_type:lock_definition"` where the `access_type`
part can potentially be set to a dummy value to just check
a lock condition.
no_superuser_bypass (bool, optional): Force superusers to heed lock.
default (bool, optional): Fallback result to use if `access_type` is set
but no such `access_type` is found in the given `lockstring`.
access_type (str, bool): If set, only this access_type will be looked up
among the locks defined by `lockstring`.
Return:
access (bool): If check is passed or not.
"""
return _LOCK_HANDLER.check_lockstring(
accessing_obj, lockstring, no_superuser_bypass=no_superuser_bypass,
default=default, access_type=access_type)
def _test(): def _test():
# testing # testing

View file

@ -11,10 +11,11 @@ from evennia.utils.test_resources import EvenniaTest
try: try:
# this is a special optimized Django version, only available in current Django devel # this is a special optimized Django version, only available in current Django devel
from django.utils.unittest import TestCase from django.utils.unittest import TestCase, override_settings
except ImportError: except ImportError:
from django.test import TestCase from django.test import TestCase, override_settings
from evennia import settings_default
from evennia.locks import lockfuncs from evennia.locks import lockfuncs
# ------------------------------------------------------------ # ------------------------------------------------------------
@ -25,7 +26,8 @@ from evennia.locks import lockfuncs
class TestLockCheck(EvenniaTest): class TestLockCheck(EvenniaTest):
def testrun(self): def testrun(self):
dbref = self.obj2.dbref dbref = self.obj2.dbref
self.obj1.locks.add("owner:dbref(%s);edit:dbref(%s) or perm(Admin);examine:perm(Builder) and id(%s);delete:perm(Admin);get:all()" % (dbref, dbref, dbref)) self.obj1.locks.add("owner:dbref(%s);edit:dbref(%s) or perm(Admin);examine:perm(Builder) "
"and id(%s);delete:perm(Admin);get:all()" % (dbref, dbref, dbref))
self.obj2.permissions.add('Admin') self.obj2.permissions.add('Admin')
self.assertEquals(True, self.obj1.locks.check(self.obj2, 'owner')) self.assertEquals(True, self.obj1.locks.check(self.obj2, 'owner'))
self.assertEquals(True, self.obj1.locks.check(self.obj2, 'edit')) self.assertEquals(True, self.obj1.locks.check(self.obj2, 'edit'))
@ -36,20 +38,154 @@ class TestLockCheck(EvenniaTest):
self.assertEquals(False, self.obj1.locks.check(self.obj2, 'get')) self.assertEquals(False, self.obj1.locks.check(self.obj2, 'get'))
self.assertEquals(True, self.obj1.locks.check(self.obj2, 'not_exist', default=True)) self.assertEquals(True, self.obj1.locks.check(self.obj2, 'not_exist', default=True))
class TestLockfuncs(EvenniaTest): class TestLockfuncs(EvenniaTest):
def testrun(self): def setUp(self):
super(TestLockfuncs, self).setUp()
self.account2.permissions.add('Admin')
self.char2.permissions.add('Builder')
def test_booleans(self):
self.assertEquals(True, lockfuncs.true(self.account2, self.obj1))
self.assertEquals(True, lockfuncs.all(self.account2, self.obj1))
self.assertEquals(False, lockfuncs.false(self.account2, self.obj1))
self.assertEquals(False, lockfuncs.none(self.account2, self.obj1))
self.assertEquals(True, lockfuncs.self(self.obj1, self.obj1))
self.assertEquals(True, lockfuncs.self(self.account, self.account))
self.assertEquals(False, lockfuncs.superuser(self.account, None))
@override_settings(PERMISSION_HIERARCHY=settings_default.PERMISSION_HIERARCHY)
def test_account_perm(self):
self.assertEquals(False, lockfuncs.perm(self.account2, None, 'foo'))
self.assertEquals(False, lockfuncs.perm(self.account2, None, 'Developer'))
self.assertEquals(False, lockfuncs.perm(self.account2, None, 'Developers'))
self.assertEquals(True, lockfuncs.perm(self.account2, None, 'Admin'))
self.assertEquals(True, lockfuncs.perm(self.account2, None, 'Admins'))
self.assertEquals(True, lockfuncs.perm(self.account2, None, 'Player'))
self.assertEquals(True, lockfuncs.perm(self.account2, None, 'Players'))
self.assertEquals(True, lockfuncs.perm(self.account2, None, 'Builder'))
self.assertEquals(True, lockfuncs.perm(self.account2, None, 'Builders'))
self.assertEquals(True, lockfuncs.perm_above(self.account2, None, 'Builder'))
@override_settings(PERMISSION_HIERARCHY=settings_default.PERMISSION_HIERARCHY)
def test_puppet_perm(self):
self.assertEquals(False, lockfuncs.perm(self.char2, None, 'foo'))
self.assertEquals(False, lockfuncs.perm(self.char2, None, 'Developer'))
self.assertEquals(False, lockfuncs.perm(self.char2, None, 'Develoeprs'))
self.assertEquals(True, lockfuncs.perm(self.char2, None, 'Admin'))
self.assertEquals(True, lockfuncs.perm(self.char2, None, 'Admins'))
self.assertEquals(True, lockfuncs.perm(self.char2, None, 'Player'))
self.assertEquals(True, lockfuncs.perm(self.char2, None, 'Players'))
self.assertEquals(True, lockfuncs.perm(self.char2, None, 'Builder'))
self.assertEquals(True, lockfuncs.perm(self.char2, None, 'Builders'))
@override_settings(PERMISSION_HIERARCHY=settings_default.PERMISSION_HIERARCHY)
def test_account_perm_above(self):
self.assertEquals(True, lockfuncs.perm_above(self.char2, None, 'Builder'))
self.assertEquals(True, lockfuncs.perm_above(self.char2, None, 'Builders'))
self.assertEquals(True, lockfuncs.perm_above(self.char2, None, 'Player'))
self.assertEquals(False, lockfuncs.perm_above(self.char2, None, 'Admin'))
self.assertEquals(False, lockfuncs.perm_above(self.char2, None, 'Admins'))
self.assertEquals(False, lockfuncs.perm_above(self.char2, None, 'Developers'))
@override_settings(PERMISSION_HIERARCHY=settings_default.PERMISSION_HIERARCHY)
def test_quell_perm(self):
self.account2.db._quell = True
self.assertEquals(False, lockfuncs.false(self.char2, None))
self.assertEquals(False, lockfuncs.perm(self.char2, None, 'Developer'))
self.assertEquals(False, lockfuncs.perm(self.char2, None, 'Developers'))
self.assertEquals(False, lockfuncs.perm(self.char2, None, 'Admin'))
self.assertEquals(False, lockfuncs.perm(self.char2, None, 'Admins'))
self.assertEquals(True, lockfuncs.perm(self.char2, None, 'Player'))
self.assertEquals(True, lockfuncs.perm(self.char2, None, 'Players'))
self.assertEquals(True, lockfuncs.perm(self.char2, None, 'Builder'))
self.assertEquals(True, lockfuncs.perm(self.char2, None, 'Builders'))
@override_settings(PERMISSION_HIERARCHY=settings_default.PERMISSION_HIERARCHY)
def test_quell_above_perm(self):
self.assertEquals(True, lockfuncs.perm_above(self.char2, None, 'Player'))
self.assertEquals(True, lockfuncs.perm_above(self.char2, None, 'Builder'))
@override_settings(PERMISSION_HIERARCHY=settings_default.PERMISSION_HIERARCHY)
def test_object_perm(self):
self.obj2.permissions.add('Admin') self.obj2.permissions.add('Admin')
self.assertEquals(True, lockfuncs.true(self.obj2, self.obj1)) self.assertEquals(False, lockfuncs.perm(self.obj2, None, 'Developer'))
self.assertEquals(False, lockfuncs.false(self.obj2, self.obj1)) self.assertEquals(False, lockfuncs.perm(self.obj2, None, 'Developers'))
self.assertEquals(True, lockfuncs.perm(self.obj2, self.obj1, 'Admin')) self.assertEquals(True, lockfuncs.perm(self.obj2, None, 'Admin'))
self.assertEquals(True, lockfuncs.perm_above(self.obj2, self.obj1, 'Builder')) self.assertEquals(True, lockfuncs.perm(self.obj2, None, 'Admins'))
self.assertEquals(True, lockfuncs.perm(self.obj2, None, 'Player'))
self.assertEquals(True, lockfuncs.perm(self.obj2, None, 'Players'))
self.assertEquals(True, lockfuncs.perm(self.obj2, None, 'Builder'))
self.assertEquals(True, lockfuncs.perm(self.obj2, None, 'Builders'))
@override_settings(PERMISSION_HIERARCHY=settings_default.PERMISSION_HIERARCHY)
def test_object_above_perm(self):
self.obj2.permissions.add('Admin')
self.assertEquals(False, lockfuncs.perm_above(self.obj2, None, 'Admins'))
self.assertEquals(True, lockfuncs.perm_above(self.obj2, None, 'Builder'))
self.assertEquals(True, lockfuncs.perm_above(self.obj2, None, 'Builders'))
@override_settings(PERMISSION_HIERARCHY=settings_default.PERMISSION_HIERARCHY)
def test_pperm(self):
self.obj2.permissions.add('Developer')
self.char2.permissions.add('Developer')
self.assertEquals(False, lockfuncs.pperm(self.obj2, None, 'Players'))
self.assertEquals(True, lockfuncs.pperm(self.char2, None, 'Players'))
self.assertEquals(True, lockfuncs.pperm(self.account, None, 'Admins'))
self.assertEquals(True, lockfuncs.pperm_above(self.account, None, 'Builders'))
self.assertEquals(False, lockfuncs.pperm_above(self.account2, None, 'Admins'))
self.assertEquals(True, lockfuncs.pperm_above(self.char2, None, 'Players'))
def test_dbref(self):
dbref = self.obj2.dbref dbref = self.obj2.dbref
self.assertEquals(True, lockfuncs.dbref(self.obj2, self.obj1, '%s' % dbref)) self.assertEquals(True, lockfuncs.dbref(self.obj2, None, '%s' % dbref))
self.assertEquals(False, lockfuncs.id(self.obj2, None, '%s' % (dbref + '1')))
dbref = self.account2.dbref
self.assertEquals(True, lockfuncs.pdbref(self.account2, None, '%s' % dbref))
self.assertEquals(False, lockfuncs.pid(self.account2, None, '%s' % (dbref + '1')))
def test_attr(self):
self.obj2.db.testattr = 45 self.obj2.db.testattr = 45
self.assertEquals(True, lockfuncs.attr(self.obj2, self.obj1, 'testattr', '45')) self.assertEquals(True, lockfuncs.attr(self.obj2, None, 'testattr', '45'))
self.assertEquals(False, lockfuncs.attr_gt(self.obj2, self.obj1, 'testattr', '45')) self.assertEquals(False, lockfuncs.attr_gt(self.obj2, None, 'testattr', '45'))
self.assertEquals(True, lockfuncs.attr_ge(self.obj2, self.obj1, 'testattr', '45')) self.assertEquals(True, lockfuncs.attr_ge(self.obj2, None, 'testattr', '45'))
self.assertEquals(False, lockfuncs.attr_lt(self.obj2, self.obj1, 'testattr', '45')) self.assertEquals(False, lockfuncs.attr_lt(self.obj2, None, 'testattr', '45'))
self.assertEquals(True, lockfuncs.attr_le(self.obj2, self.obj1, 'testattr', '45')) self.assertEquals(True, lockfuncs.attr_le(self.obj2, None, 'testattr', '45'))
self.assertEquals(False, lockfuncs.attr_ne(self.obj2, self.obj1, 'testattr', '45'))
self.assertEquals(True, lockfuncs.objattr(None, self.obj2, 'testattr', '45'))
self.assertEquals(True, lockfuncs.objattr(None, self.obj2, 'testattr', '45'))
self.assertEquals(False, lockfuncs.objattr(None, self.obj2, 'testattr', '45', compare='lt'))
def test_locattr(self):
self.obj2.location.db.locattr = 'test'
self.assertEquals(True, lockfuncs.locattr(self.obj2, None, 'locattr', 'test'))
self.assertEquals(False, lockfuncs.locattr(self.obj2, None, 'fail', 'testfail'))
self.assertEquals(True, lockfuncs.objlocattr(None, self.obj2, 'locattr', 'test'))
def test_tag(self):
self.obj2.tags.add("test1")
self.obj2.tags.add("test2", "category1")
self.assertEquals(True, lockfuncs.tag(self.obj2, None, 'test1'))
self.assertEquals(True, lockfuncs.tag(self.obj2, None, 'test2', 'category1'))
self.assertEquals(False, lockfuncs.tag(self.obj2, None, 'test1', 'category1'))
self.assertEquals(False, lockfuncs.tag(self.obj2, None, 'test1', 'category2'))
self.assertEquals(True, lockfuncs.objtag(None, self.obj2, 'test2', 'category1'))
self.assertEquals(False, lockfuncs.objtag(None, self.obj2, 'test2'))
def test_inside_holds(self):
self.assertEquals(True, lockfuncs.inside(self.char1, self.room1))
self.assertEquals(False, lockfuncs.inside(self.char1, self.room2))
self.assertEquals(True, lockfuncs.holds(self.room1, self.char1))
self.assertEquals(False, lockfuncs.holds(self.room2, self.char1))
def test_has_account(self):
self.assertEquals(True, lockfuncs.has_account(self.char1, None))
self.assertEquals(False, lockfuncs.has_account(self.obj1, None))
@override_settings(IRC_ENABLED=True, TESTVAL=[1, 2, 3])
def test_serversetting(self):
# import pudb
# pudb.set_trace()
self.assertEquals(True, lockfuncs.serversetting(None, None, 'IRC_ENABLED', 'True'))
self.assertEquals(True, lockfuncs.serversetting(None, None, 'TESTVAL', '[1, 2, 3]'))
self.assertEquals(False, lockfuncs.serversetting(None, None, 'TESTVAL', '[1, 2, 4]'))
self.assertEquals(False, lockfuncs.serversetting(None, None, 'TESTVAL', '123'))

View file

@ -76,10 +76,14 @@ class ObjectDBManager(TypedObjectManager):
# simplest case - search by dbref # simplest case - search by dbref
dbref = self.dbref(ostring) dbref = self.dbref(ostring)
if dbref: if dbref:
return dbref try:
return self.get(id=dbref)
except self.model.DoesNotExist:
pass
# not a dbref. Search by name. # not a dbref. Search by name.
cand_restriction = candidates is not None and Q(pk__in=[_GA(obj, "id") for obj in make_iter(candidates) cand_restriction = candidates is not None and Q(
if obj]) or Q() pk__in=[_GA(obj, "id") for obj in make_iter(candidates) if obj]) or Q()
if exact: if exact:
return self.filter(cand_restriction & Q(db_account__username__iexact=ostring)) return self.filter(cand_restriction & Q(db_account__username__iexact=ostring))
else: # fuzzy matching else: # fuzzy matching

View file

@ -6,8 +6,10 @@ entities.
""" """
import time import time
import inflect
from builtins import object from builtins import object
from future.utils import with_metaclass from future.utils import with_metaclass
from collections import defaultdict
from django.conf import settings from django.conf import settings
@ -22,9 +24,11 @@ from evennia.commands import cmdhandler
from evennia.utils import search from evennia.utils import search
from evennia.utils import logger from evennia.utils import logger
from evennia.utils.utils import (variable_from_module, lazy_property, from evennia.utils.utils import (variable_from_module, lazy_property,
make_iter, to_unicode, is_iter) make_iter, to_unicode, is_iter, list_to_string,
to_str)
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
_INFLECT = inflect.engine()
_MULTISESSION_MODE = settings.MULTISESSION_MODE _MULTISESSION_MODE = settings.MULTISESSION_MODE
_ScriptDB = None _ScriptDB = None
@ -206,6 +210,14 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
def sessions(self): def sessions(self):
return ObjectSessionHandler(self) return ObjectSessionHandler(self)
@property
def is_connected(self):
# we get an error for objects subscribed to channels without this
if self.account: # seems sane to pass on the account
return self.account.is_connected
else:
return False
@property @property
def has_account(self): def has_account(self):
""" """
@ -281,9 +293,39 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
return "{}(#{})".format(self.name, self.id) return "{}(#{})".format(self.name, self.id)
return self.name 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, def search(self, searchdata,
global_search=False, global_search=False,
use_nicks=True, # should this default to off? use_nicks=True,
typeclass=None, typeclass=None,
location=None, location=None,
attribute_name=None, attribute_name=None,
@ -335,7 +377,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
below. below.
exact (bool): if unset (default) - prefers to match to beginning of exact (bool): if unset (default) - prefers to match to beginning of
string rather than not matching at all. If set, requires string rather than not matching at all. If set, requires
exact mathing of entire string. exact matching of entire string.
candidates (list of objects): this is an optional custom list of objects candidates (list of objects): this is an optional custom list of objects
to search (filter) between. It is ignored if `global_search` to search (filter) between. It is ignored if `global_search`
is given. If not set, this list will automatically be defined is given. If not set, this list will automatically be defined
@ -519,6 +561,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
obj.at_msg_send(text=text, to_obj=self, **kwargs) obj.at_msg_send(text=text, to_obj=self, **kwargs)
except Exception: except Exception:
logger.log_trace() logger.log_trace()
kwargs["options"] = options
try: try:
if not self.at_msg_receive(text=text, **kwargs): if not self.at_msg_receive(text=text, **kwargs):
# if at_msg_receive returns false, we abort message to this object # if at_msg_receive returns false, we abort message to this object
@ -526,12 +569,20 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
except Exception: except Exception:
logger.log_trace() logger.log_trace()
kwargs["options"] = options if text is not None:
if not (isinstance(text, basestring) or isinstance(text, tuple)):
# sanitize text before sending across the wire
try:
text = to_str(text, force_string=True)
except Exception:
text = repr(text)
kwargs['text'] = text
# relay to session(s) # relay to session(s)
sessions = make_iter(session) if session else self.sessions.all() sessions = make_iter(session) if session else self.sessions.all()
for session in sessions: for session in sessions:
session.data_out(text=text, **kwargs) session.data_out(**kwargs)
def for_contents(self, func, exclude=None, **kwargs): def for_contents(self, func, exclude=None, **kwargs):
""" """
@ -1433,7 +1484,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
# get and identify all objects # get and identify all objects
visible = (con for con in self.contents if con != looker and visible = (con for con in self.contents if con != looker and
con.access(looker, "view")) con.access(looker, "view"))
exits, users, things = [], [], [] exits, users, things = [], [], defaultdict(list)
for con in visible: for con in visible:
key = con.get_display_name(looker) key = con.get_display_name(looker)
if con.destination: if con.destination:
@ -1441,16 +1492,28 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
elif con.has_account: elif con.has_account:
users.append("|c%s|n" % key) users.append("|c%s|n" % key)
else: else:
things.append(key) # things can be pluralized
things[key].append(con)
# get description, build string # get description, build string
string = "|c%s|n\n" % self.get_display_name(looker) string = "|c%s|n\n" % self.get_display_name(looker)
desc = self.db.desc desc = self.db.desc
if desc: if desc:
string += "%s" % desc string += "%s" % desc
if exits: if exits:
string += "\n|wExits:|n " + ", ".join(exits) string += "\n|wExits:|n " + list_to_string(exits)
if users or things: 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 return string
def at_look(self, target, **kwargs): def at_look(self, target, **kwargs):
@ -1705,7 +1768,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
for recv in receivers) if receivers else None, for recv in receivers) if receivers else None,
"speech": message} "speech": message}
self_mapping.update(custom_mapping) self_mapping.update(custom_mapping)
self.msg(text=(msg_self.format(**self_mapping), {"type": msg_type})) self.msg(text=(msg_self.format(**self_mapping), {"type": msg_type}), from_obj=self)
if receivers and msg_receivers: if receivers and msg_receivers:
receiver_mapping = {"self": "You", receiver_mapping = {"self": "You",
@ -1723,7 +1786,8 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)):
for recv in receivers) if receivers else None} for recv in receivers) if receivers else None}
receiver_mapping.update(individual_mapping) receiver_mapping.update(individual_mapping)
receiver_mapping.update(custom_mapping) receiver_mapping.update(custom_mapping)
receiver.msg(text=(msg_receivers.format(**receiver_mapping), {"type": msg_type})) receiver.msg(text=(msg_receivers.format(**receiver_mapping),
{"type": msg_type}), from_obj=self)
if self.location and msg_location: if self.location and msg_location:
location_mapping = {"self": "You", location_mapping = {"self": "You",
@ -1811,7 +1875,7 @@ class DefaultCharacter(DefaultObject):
""" """
self.msg("\nYou become |c%s|n.\n" % self.name) self.msg("\nYou become |c%s|n.\n" % self.name)
self.msg(self.at_look(self.location)) self.msg((self.at_look(self.location), {'type':'look'}), options = None)
def message(obj, from_obj): def message(obj, from_obj):
obj.msg("%s has entered the game." % self.get_display_name(obj), from_obj=from_obj) obj.msg("%s has entered the game." % self.get_display_name(obj), from_obj=from_obj)

View file

@ -4,7 +4,8 @@ Module containing the task handler for Evennia deferred tasks, persistent or not
from datetime import datetime, timedelta from datetime import datetime, timedelta
from twisted.internet import reactor, task from twisted.internet import reactor
from twisted.internet.task import deferLater
from evennia.server.models import ServerConfig from evennia.server.models import ServerConfig
from evennia.utils.logger import log_err from evennia.utils.logger import log_err
from evennia.utils.dbserialize import dbserialize, dbunserialize from evennia.utils.dbserialize import dbserialize, dbunserialize
@ -143,7 +144,7 @@ class TaskHandler(object):
args = [task_id] args = [task_id]
kwargs = {} kwargs = {}
return task.deferLater(reactor, timedelay, callback, *args, **kwargs) return deferLater(reactor, timedelay, callback, *args, **kwargs)
def remove(self, task_id): def remove(self, task_id):
"""Remove a persistent task without executing it. """Remove a persistent task without executing it.
@ -189,7 +190,7 @@ class TaskHandler(object):
now = datetime.now() now = datetime.now()
for task_id, (date, callbac, args, kwargs) in self.tasks.items(): for task_id, (date, callbac, args, kwargs) in self.tasks.items():
seconds = max(0, (date - now).total_seconds()) seconds = max(0, (date - now).total_seconds())
task.deferLater(reactor, seconds, self.do_task, task_id) deferLater(reactor, seconds, self.do_task, task_id)
# Create the soft singleton # Create the soft singleton

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -160,10 +160,10 @@ def client_options(session, *args, **kwargs):
raw (bool): Turn off parsing raw (bool): Turn off parsing
""" """
flags = session.protocol_flags old_flags = session.protocol_flags
if not kwargs or kwargs.get("get", False): if not kwargs or kwargs.get("get", False):
# return current settings # return current settings
options = dict((key, flags[key]) for key in flags options = dict((key, old_flags[key]) for key in old_flags
if key.upper() in ("ANSI", "XTERM256", "MXP", if key.upper() in ("ANSI", "XTERM256", "MXP",
"UTF-8", "SCREENREADER", "ENCODING", "UTF-8", "SCREENREADER", "ENCODING",
"MCCP", "SCREENHEIGHT", "MCCP", "SCREENHEIGHT",
@ -189,6 +189,7 @@ def client_options(session, *args, **kwargs):
return True if val.lower() in ("true", "on", "1") else False return True if val.lower() in ("true", "on", "1") else False
return bool(val) return bool(val)
flags = {}
for key, value in kwargs.iteritems(): for key, value in kwargs.iteritems():
key = key.lower() key = key.lower()
if key == "client": if key == "client":
@ -230,9 +231,11 @@ def client_options(session, *args, **kwargs):
err = _ERROR_INPUT.format( err = _ERROR_INPUT.format(
name="client_settings", session=session, inp=key) name="client_settings", session=session, inp=key)
session.msg(text=err) session.msg(text=err)
session.protocol_flags = flags
# we must update the portal as well session.protocol_flags.update(flags)
session.sessionhandler.session_portal_sync(session) # we must update the protocol flags on the portal session copy as well
session.sessionhandler.session_portal_partial_sync(
{session.sessid: {"protocol_flags": flags}})
# GMCP alias # GMCP alias

View file

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

View file

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

View file

@ -7,17 +7,15 @@ sets up all the networking features. (this is done automatically
by game/evennia.py). by game/evennia.py).
""" """
from __future__ import print_function
from builtins import object from builtins import object
import time
import sys import sys
import os import os
from twisted.application import internet, service from twisted.application import internet, service
from twisted.internet import protocol, reactor from twisted.internet import protocol, reactor
from twisted.internet.task import LoopingCall from twisted.python.log import ILogObserver
from twisted.web import server
import django import django
django.setup() django.setup()
from django.conf import settings from django.conf import settings
@ -27,6 +25,7 @@ evennia._init()
from evennia.utils.utils import get_evennia_version, mod_import, make_iter from evennia.utils.utils import get_evennia_version, mod_import, make_iter
from evennia.server.portal.portalsessionhandler import PORTAL_SESSIONS from evennia.server.portal.portalsessionhandler import PORTAL_SESSIONS
from evennia.utils import logger
from evennia.server.webserver import EvenniaReverseProxyResource from evennia.server.webserver import EvenniaReverseProxyResource
from django.db import connection from django.db import connection
@ -40,11 +39,6 @@ except Exception:
PORTAL_SERVICES_PLUGIN_MODULES = [mod_import(module) for module in make_iter(settings.PORTAL_SERVICES_PLUGIN_MODULES)] PORTAL_SERVICES_PLUGIN_MODULES = [mod_import(module) for module in make_iter(settings.PORTAL_SERVICES_PLUGIN_MODULES)]
LOCKDOWN_MODE = settings.LOCKDOWN_MODE 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 # Evennia Portal settings
# ------------------------------------------------------------- # -------------------------------------------------------------
@ -80,10 +74,15 @@ AMP_PORT = settings.AMP_PORT
AMP_INTERFACE = settings.AMP_INTERFACE AMP_INTERFACE = settings.AMP_INTERFACE
AMP_ENABLED = AMP_HOST and AMP_PORT and 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 # Portal Service object
# ------------------------------------------------------------- # -------------------------------------------------------------
class Portal(object): class Portal(object):
""" """
@ -108,12 +107,19 @@ class Portal(object):
self.amp_protocol = None # set by amp factory self.amp_protocol = None # set by amp factory
self.sessions = PORTAL_SESSIONS self.sessions = PORTAL_SESSIONS
self.sessions.portal = self 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, # set a callback if the server is killed abruptly,
# by Ctrl-C, reboot etc. # by Ctrl-C, reboot etc.
reactor.addSystemEventTrigger('before', 'shutdown', self.shutdown, _reactor_stopping=True) 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): def set_restart_mode(self, mode=None):
""" """
@ -128,7 +134,6 @@ class Portal(object):
if mode is None: if mode is None:
return return
with open(PORTAL_RESTART, 'w') as f: 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)) f.write(str(mode))
def shutdown(self, restart=None, _reactor_stopping=False): def shutdown(self, restart=None, _reactor_stopping=False):
@ -155,9 +160,7 @@ class Portal(object):
return return
self.sessions.disconnect_all() self.sessions.disconnect_all()
self.set_restart_mode(restart) 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: if not _reactor_stopping:
# shutting down the reactor will trigger another signal. We set # shutting down the reactor will trigger another signal. We set
# a flag to avoid loops. # a flag to avoid loops.
@ -175,14 +178,18 @@ class Portal(object):
# what to execute from. # what to execute from.
application = service.Application('Portal') 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 # The main Portal server program. This sets up the database
# and is where we store all the other services. # and is where we store all the other services.
PORTAL = Portal(application) PORTAL = Portal(application)
print('-' * 50)
print(' %(servername)s Portal (%(version)s) started.' % {'servername': SERVERNAME, 'version': VERSION})
if LOCKDOWN_MODE: if LOCKDOWN_MODE:
print(' LOCKDOWN_MODE active: Only local connections.')
INFO_DICT["lockdown_mode"] = ' LOCKDOWN_MODE active: Only local connections.'
if AMP_ENABLED: if AMP_ENABLED:
@ -190,14 +197,14 @@ if AMP_ENABLED:
# the portal and the mud server. Only reason to ever deactivate # the portal and the mud server. Only reason to ever deactivate
# it would be during testing and debugging. # it would be during testing and debugging.
from evennia.server import amp from evennia.server.portal import amp_server
print(' amp (to Server): %s (internal)' % AMP_PORT) INFO_DICT["amp"] = 'amp: %s' % AMP_PORT
factory = amp.AmpClientFactory(PORTAL) factory = amp_server.AMPServerFactory(PORTAL)
amp_client = internet.TCPClient(AMP_HOST, AMP_PORT, factory) amp_service = internet.TCPServer(AMP_PORT, factory, interface=AMP_INTERFACE)
amp_client.setName('evennia_amp') amp_service.setName("PortalAMPServer")
PORTAL.services.addService(amp_client) PORTAL.services.addService(amp_service)
# We group all the various services under the same twisted app. # We group all the various services under the same twisted app.
@ -215,7 +222,7 @@ if TELNET_ENABLED:
ifacestr = "-%s" % interface ifacestr = "-%s" % interface
for port in TELNET_PORTS: for port in TELNET_PORTS:
pstring = "%s:%s" % (ifacestr, port) pstring = "%s:%s" % (ifacestr, port)
factory = protocol.ServerFactory() factory = telnet.TelnetServerFactory()
factory.noisy = False factory.noisy = False
factory.protocol = telnet.TelnetProtocol factory.protocol = telnet.TelnetProtocol
factory.sessionhandler = PORTAL_SESSIONS factory.sessionhandler = PORTAL_SESSIONS
@ -223,14 +230,14 @@ if TELNET_ENABLED:
telnet_service.setName('EvenniaTelnet%s' % pstring) telnet_service.setName('EvenniaTelnet%s' % pstring)
PORTAL.services.addService(telnet_service) PORTAL.services.addService(telnet_service)
print(' telnet%s: %s (external)' % (ifacestr, port)) INFO_DICT["telnet"].append('telnet%s: %s' % (ifacestr, port))
if SSL_ENABLED: if SSL_ENABLED:
# Start SSL game connection (requires PyOpenSSL). # Start Telnet+SSL game connection (requires PyOpenSSL).
from evennia.server.portal import ssl from evennia.server.portal import telnet_ssl
for interface in SSL_INTERFACES: for interface in SSL_INTERFACES:
ifacestr = "" ifacestr = ""
@ -241,15 +248,21 @@ if SSL_ENABLED:
factory = protocol.ServerFactory() factory = protocol.ServerFactory()
factory.noisy = False factory.noisy = False
factory.sessionhandler = PORTAL_SESSIONS factory.sessionhandler = PORTAL_SESSIONS
factory.protocol = ssl.SSLProtocol factory.protocol = telnet_ssl.SSLProtocol
ssl_service = internet.SSLServer(port,
factory,
ssl.getSSLContext(),
interface=interface)
ssl_service.setName('EvenniaSSL%s' % pstring)
PORTAL.services.addService(ssl_service)
print(" ssl%s: %s (external)" % (ifacestr, port)) ssl_context = telnet_ssl.getSSLContext()
if ssl_context:
ssl_service = internet.SSLServer(port,
factory,
telnet_ssl.getSSLContext(),
interface=interface)
ssl_service.setName('EvenniaSSL%s' % pstring)
PORTAL.services.addService(ssl_service)
INFO_DICT["telnet_ssl"].append("telnet+ssl%s: %s" % (ifacestr, port))
else:
INFO_DICT["telnet_ssl"].append(
"telnet+ssl%s: %s (deactivated - keys/cert unset)" % (ifacestr, port))
if SSH_ENABLED: if SSH_ENABLED:
@ -273,7 +286,7 @@ if SSH_ENABLED:
ssh_service.setName('EvenniaSSH%s' % pstring) ssh_service.setName('EvenniaSSH%s' % pstring)
PORTAL.services.addService(ssh_service) PORTAL.services.addService(ssh_service)
print(" ssh%s: %s (external)" % (ifacestr, port)) INFO_DICT["ssh"].append("ssh%s: %s" % (ifacestr, port))
if WEBSERVER_ENABLED: if WEBSERVER_ENABLED:
@ -296,7 +309,7 @@ if WEBSERVER_ENABLED:
ajax_webclient = webclient_ajax.AjaxWebClient() ajax_webclient = webclient_ajax.AjaxWebClient()
ajax_webclient.sessionhandler = PORTAL_SESSIONS ajax_webclient.sessionhandler = PORTAL_SESSIONS
web_root.putChild("webclientdata", ajax_webclient) web_root.putChild("webclientdata", ajax_webclient)
webclientstr = "\n + webclient (ajax only)" webclientstr = "webclient (ajax only)"
if WEBSOCKET_CLIENT_ENABLED and not websocket_started: if WEBSOCKET_CLIENT_ENABLED and not websocket_started:
# start websocket client port for the webclient # start websocket client port for the webclient
@ -309,32 +322,33 @@ if WEBSERVER_ENABLED:
if w_interface not in ('0.0.0.0', '::') or len(WEBSERVER_INTERFACES) > 1: if w_interface not in ('0.0.0.0', '::') or len(WEBSERVER_INTERFACES) > 1:
w_ifacestr = "-%s" % interface w_ifacestr = "-%s" % interface
port = WEBSOCKET_CLIENT_PORT port = WEBSOCKET_CLIENT_PORT
factory = protocol.ServerFactory()
class Websocket(protocol.ServerFactory):
"Only here for better naming in logs"
pass
factory = Websocket()
factory.noisy = False factory.noisy = False
factory.protocol = webclient.WebSocketClient factory.protocol = webclient.WebSocketClient
factory.sessionhandler = PORTAL_SESSIONS factory.sessionhandler = PORTAL_SESSIONS
websocket_service = internet.TCPServer(port, WebSocketFactory(factory), interface=w_interface) websocket_service = internet.TCPServer(port, WebSocketFactory(factory), interface=w_interface)
websocket_service.setName('EvenniaWebSocket%s:%s' % (w_ifacestr, proxyport)) websocket_service.setName('EvenniaWebSocket%s:%s' % (w_ifacestr, port))
PORTAL.services.addService(websocket_service) PORTAL.services.addService(websocket_service)
websocket_started = True websocket_started = True
webclientstr = "\n + webclient-websocket%s: %s (external)" % (w_ifacestr, proxyport) 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 = Website(web_root, logPath=settings.HTTP_LOG_FILE)
web_root.is_portal = True
proxy_service = internet.TCPServer(proxyport, proxy_service = internet.TCPServer(proxyport,
web_root, web_root,
interface=interface) interface=interface)
proxy_service.setName('EvenniaWebProxy%s' % pstring) proxy_service.setName('EvenniaWebProxy%s' % pstring)
PORTAL.services.addService(proxy_service) PORTAL.services.addService(proxy_service)
print(" website-proxy%s: %s (external) %s" % (ifacestr, proxyport, 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: for plugin_module in PORTAL_SERVICES_PLUGIN_MODULES:
# external plugin services to start # external plugin services to start
plugin_module.start_plugin_services(PORTAL) plugin_module.start_plugin_services(PORTAL)
print('-' * 50) # end of terminal output
if os.name == 'nt':
# Windows only: Set PID file manually
with open(PORTAL_PIDFILE, 'w') as f:
f.write(str(os.getpid()))

View file

@ -15,12 +15,15 @@ from evennia.utils.logger import log_trace
# module import # module import
_MOD_IMPORT = None _MOD_IMPORT = None
# throttles # global throttles
_MAX_CONNECTION_RATE = float(settings.MAX_CONNECTION_RATE) _MAX_CONNECTION_RATE = float(settings.MAX_CONNECTION_RATE)
# per-session throttles
_MAX_COMMAND_RATE = float(settings.MAX_COMMAND_RATE) _MAX_COMMAND_RATE = float(settings.MAX_COMMAND_RATE)
_MAX_CHAR_LIMIT = int(settings.MAX_CHAR_LIMIT) _MAX_CHAR_LIMIT = int(settings.MAX_CHAR_LIMIT)
_MIN_TIME_BETWEEN_CONNECTS = 1.0 / float(settings.MAX_CONNECTION_RATE) _MIN_TIME_BETWEEN_CONNECTS = 1.0 / float(_MAX_CONNECTION_RATE)
_MIN_TIME_BETWEEN_COMMANDS = 1.0 / float(_MAX_COMMAND_RATE)
_ERROR_COMMAND_OVERFLOW = settings.COMMAND_RATE_WARNING _ERROR_COMMAND_OVERFLOW = settings.COMMAND_RATE_WARNING
_ERROR_MAX_CHAR = settings.MAX_CHAR_LIMIT_WARNING _ERROR_MAX_CHAR = settings.MAX_CHAR_LIMIT_WARNING
@ -58,9 +61,6 @@ class PortalSessionHandler(SessionHandler):
self.connection_last = self.uptime self.connection_last = self.uptime
self.connection_task = None self.connection_task = None
self.command_counter = 0
self.command_counter_reset = self.uptime
self.command_overflow = False
def at_server_connection(self): def at_server_connection(self):
""" """
@ -354,8 +354,6 @@ class PortalSessionHandler(SessionHandler):
Data is serialized before passed on. Data is serialized before passed on.
""" """
# from evennia.server.profiling.timetrace import timetrace # DEBUG
# text = timetrace(text, "portalsessionhandler.data_in") # DEBUG
try: try:
text = kwargs['text'] text = kwargs['text']
if (_MAX_CHAR_LIMIT > 0) and len(text) > _MAX_CHAR_LIMIT: if (_MAX_CHAR_LIMIT > 0) and len(text) > _MAX_CHAR_LIMIT:
@ -367,30 +365,38 @@ class PortalSessionHandler(SessionHandler):
pass pass
if session: if session:
now = time.time() now = time.time()
if self.command_counter > _MAX_COMMAND_RATE > 0:
# data throttle (anti DoS measure) try:
delta_time = now - self.command_counter_reset command_counter_reset = session.command_counter_reset
self.command_counter = 0 except AttributeError:
self.command_counter_reset = now command_counter_reset = session.command_counter_reset = now
self.command_overflow = delta_time < 1.0 session.command_counter = 0
if self.command_overflow:
reactor.callLater(1.0, self.data_in, None) # global command-rate limit
if self.command_overflow: if max(0, now - command_counter_reset) > 1.0:
# more than a second since resetting the counter. Refresh.
session.command_counter_reset = now
session.command_counter = 0
session.command_counter += 1
if session.command_counter * _MIN_TIME_BETWEEN_COMMANDS > 1.0:
self.data_out(session, text=[[_ERROR_COMMAND_OVERFLOW], {}]) self.data_out(session, text=[[_ERROR_COMMAND_OVERFLOW], {}])
return return
if not self.portal.amp_protocol:
# this can happen if someone connects before AMP connection
# was established (usually on first start)
reactor.callLater(1.0, self.data_in, session, **kwargs)
return
# scrub data # scrub data
kwargs = self.clean_senddata(session, kwargs) kwargs = self.clean_senddata(session, kwargs)
# relay data to Server # relay data to Server
self.command_counter += 1
session.cmd_last = now session.cmd_last = now
self.portal.amp_protocol.send_MsgPortal2Server(session, self.portal.amp_protocol.send_MsgPortal2Server(session,
**kwargs) **kwargs)
else:
# called by the callLater callback
if self.command_overflow:
self.command_overflow = False
reactor.callLater(1.0, self.data_in, None)
def data_out(self, session, **kwargs): def data_out(self, session, **kwargs):
""" """

View file

@ -39,7 +39,7 @@ from twisted.conch.ssh import common
from twisted.conch.insults import insults from twisted.conch.insults import insults
from twisted.conch.manhole_ssh import TerminalRealm, _Glue, ConchFactory from twisted.conch.manhole_ssh import TerminalRealm, _Glue, ConchFactory
from twisted.conch.manhole import Manhole, recvline 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.conch import interfaces as iconch
from twisted.python import components from twisted.python import components
from django.conf import settings from django.conf import settings
@ -52,12 +52,34 @@ from evennia.utils.utils import to_str
_RE_N = re.compile(r"\|n$") _RE_N = re.compile(r"\|n$")
_RE_SCREENREADER_REGEX = re.compile(r"%s" % settings.SCREENREADER_REGEX_STRIP, re.DOTALL + re.MULTILINE) _RE_SCREENREADER_REGEX = re.compile(r"%s" % settings.SCREENREADER_REGEX_STRIP, re.DOTALL + re.MULTILINE)
_GAME_DIR = settings.GAME_DIR _GAME_DIR = settings.GAME_DIR
_PRIVATE_KEY_FILE = os.path.join(_GAME_DIR, "server", "ssh-private.key")
_PUBLIC_KEY_FILE = os.path.join(_GAME_DIR, "server", "ssh-public.key")
_KEY_LENGTH = 2048
CTRL_C = '\x03' CTRL_C = '\x03'
CTRL_D = '\x04' CTRL_D = '\x04'
CTRL_BACKSLASH = '\x1c' CTRL_BACKSLASH = '\x1c'
CTRL_L = '\x0c' CTRL_L = '\x0c'
_NO_AUTOGEN = """
Evennia could not generate SSH private- and public keys ({{err}})
Using conch default keys instead.
If this error persists, create the keys manually (using the tools for your OS)
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): class SshProtocol(Manhole, session.Session):
""" """
@ -66,6 +88,7 @@ class SshProtocol(Manhole, session.Session):
here. here.
""" """
noisy = False
def __init__(self, starttuple): def __init__(self, starttuple):
""" """
@ -76,6 +99,7 @@ class SshProtocol(Manhole, session.Session):
starttuple (tuple): A (account, factory) tuple. starttuple (tuple): A (account, factory) tuple.
""" """
self.protocol_key = "ssh"
self.authenticated_account = starttuple[0] self.authenticated_account = starttuple[0]
# obs must not be called self.factory, that gets overwritten! # obs must not be called self.factory, that gets overwritten!
self.cfactory = starttuple[1] self.cfactory = starttuple[1]
@ -104,7 +128,7 @@ class SshProtocol(Manhole, session.Session):
# since we might have authenticated already, we might set this here. # since we might have authenticated already, we might set this here.
if self.authenticated_account: if self.authenticated_account:
self.logged_in = True self.logged_in = True
self.uid = self.authenticated_account.user.id self.uid = self.authenticated_account.id
self.sessionhandler.connect(self) self.sessionhandler.connect(self)
def connectionMade(self): def connectionMade(self):
@ -228,7 +252,7 @@ class SshProtocol(Manhole, session.Session):
""" """
if reason: if reason:
self.data_out(text=reason) self.data_out(text=((reason, ), {}))
self.connectionLost(reason) self.connectionLost(reason)
def data_out(self, **kwargs): def data_out(self, **kwargs):
@ -302,6 +326,9 @@ class SshProtocol(Manhole, session.Session):
class ExtraInfoAuthServer(SSHUserAuthServer): class ExtraInfoAuthServer(SSHUserAuthServer):
noisy = False
def auth_password(self, packet): def auth_password(self, packet):
""" """
Password authentication. Password authentication.
@ -327,6 +354,7 @@ class AccountDBPasswordChecker(object):
useful for the Realm. useful for the Realm.
""" """
noisy = False
credentialInterfaces = (credentials.IUsernamePassword,) credentialInterfaces = (credentials.IUsernamePassword,)
def __init__(self, factory): def __init__(self, factory):
@ -362,6 +390,8 @@ class PassAvatarIdTerminalRealm(TerminalRealm):
""" """
noisy = False
def _getAvatar(self, avatarId): def _getAvatar(self, avatarId):
comp = components.Componentized() comp = components.Componentized()
user = self.userFactory(comp, avatarId) user = self.userFactory(comp, avatarId)
@ -383,6 +413,8 @@ class TerminalSessionTransport_getPeer(object):
""" """
noisy = False
def __init__(self, proto, chainedProtocol, avatar, width, height): def __init__(self, proto, chainedProtocol, avatar, width, height):
self.proto = proto self.proto = proto
self.avatar = avatar self.avatar = avatar
@ -417,33 +449,32 @@ def getKeyPair(pubkeyfile, privkeyfile):
if not (os.path.exists(pubkeyfile) and os.path.exists(privkeyfile)): if not (os.path.exists(pubkeyfile) and os.path.exists(privkeyfile)):
# No keypair exists. Generate a new RSA keypair # No keypair exists. Generate a new RSA keypair
print(" Generating SSH RSA keypair ...", end=' ')
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
KEY_LENGTH = 1024 rsa_key = Key(RSA.generate(_KEY_LENGTH))
rsaKey = Key(RSA.generate(KEY_LENGTH)) public_key_string = rsa_key.public().toString(type="OPENSSH")
publicKeyString = rsaKey.public().toString(type="OPENSSH") private_key_string = rsa_key.toString(type="OPENSSH")
privateKeyString = rsaKey.toString(type="OPENSSH")
# save keys for the future. # save keys for the future.
file(pubkeyfile, 'w+b').write(publicKeyString) with open(privkeyfile, 'wt') as pfile:
file(privkeyfile, 'w+b').write(privateKeyString) pfile.write(private_key_string)
print(" done.") print("Created SSH private key in '{}'".format(_PRIVATE_KEY_FILE))
with open(pubkeyfile, 'wt') as pfile:
pfile.write(public_key_string)
print("Created SSH public key in '{}'".format(_PUBLIC_KEY_FILE))
else: else:
publicKeyString = file(pubkeyfile).read() with open(pubkeyfile) as pfile:
privateKeyString = file(privkeyfile).read() public_key_string = pfile.read()
with open(privkeyfile) as pfile:
private_key_string = pfile.read()
return Key.fromString(publicKeyString), Key.fromString(privateKeyString) return Key.fromString(public_key_string), Key.fromString(private_key_string)
def makeFactory(configdict): def makeFactory(configdict):
""" """
Creates the ssh server factory. Creates the ssh server factory.
""" """
pubkeyfile = os.path.join(_GAME_DIR, "server", "ssh-public.key")
privkeyfile = os.path.join(_GAME_DIR, "server", "ssh-private.key")
def chainProtocolFactory(username=None): def chainProtocolFactory(username=None):
return insults.ServerProtocol( return insults.ServerProtocol(
configdict['protocolFactory'], configdict['protocolFactory'],
@ -458,14 +489,11 @@ def makeFactory(configdict):
try: try:
# create/get RSA keypair # create/get RSA keypair
publicKey, privateKey = getKeyPair(pubkeyfile, privkeyfile) publicKey, privateKey = getKeyPair(_PUBLIC_KEY_FILE, _PRIVATE_KEY_FILE)
factory.publicKeys = {'ssh-rsa': publicKey} factory.publicKeys = {'ssh-rsa': publicKey}
factory.privateKeys = {'ssh-rsa': privateKey} factory.privateKeys = {'ssh-rsa': privateKey}
except Exception as err: except Exception as err:
print("getKeyPair error: {err}\n WARNING: Evennia could not " print(_NO_AUTOGEN.format(err=err))
"auto-generate SSH keypair. Using conch default keys instead.\n"
"If this error persists, create {pub} and "
"{priv} yourself using third-party tools.".format(err=err, pub=pubkeyfile, priv=privkeyfile))
factory.services = factory.services.copy() factory.services = factory.services.copy()
factory.services['ssh-userauth'] = ExtraInfoAuthServer factory.services['ssh-userauth'] = ExtraInfoAuthServer

View file

@ -1,115 +0,0 @@
"""
This is a simple context factory for auto-creating
SSL keys and certificates.
"""
from __future__ import print_function
import os
import sys
try:
import OpenSSL
from twisted.internet import ssl as twisted_ssl
except ImportError as error:
errstr = """
{err}
SSL requires the PyOpenSSL library and dependencies:
pip install pyopenssl pycrypto enum pyasn1 service_identity
Stop and start Evennia again. If no certificate can be generated, you'll
get a suggestion for a (linux) command to generate this locally.
"""
raise ImportError(errstr.format(err=error))
from django.conf import settings
from evennia.server.portal.telnet import TelnetProtocol
_GAME_DIR = settings.GAME_DIR
# messages
NO_AUTOGEN = """
{err}
Evennia could not auto-generate the SSL private key. If this error
persists, create {keyfile} yourself using third-party tools.
"""
NO_AUTOCERT = """
{err}
Evennia's SSL context factory could not automatically, create an SSL
certificate {certfile}.
A private key {keyfile} was already created. Please create {certfile}
manually using the commands valid for your operating system, for
example (linux, using the openssl program):
{exestring}
"""
class SSLProtocol(TelnetProtocol):
"""
Communication is the same as telnet, except data transfer
is done with encryption.
"""
def __init__(self, *args, **kwargs):
super(SSLProtocol, self).__init__(*args, **kwargs)
self.protocol_name = "ssl"
def verify_SSL_key_and_cert(keyfile, certfile):
"""
This function looks for RSA key and certificate in the current
directory. If files ssl.key and ssl.cert does not exist, they
are created.
"""
if not (os.path.exists(keyfile) and os.path.exists(certfile)):
# key/cert does not exist. Create.
import subprocess
from Crypto.PublicKey import RSA
from twisted.conch.ssh.keys import Key
print(" Creating SSL key and certificate ... ", end=' ')
try:
# create the RSA key and store it.
KEY_LENGTH = 1024
rsaKey = Key(RSA.generate(KEY_LENGTH))
keyString = rsaKey.toString(type="OPENSSH")
file(keyfile, 'w+b').write(keyString)
except Exception as err:
print(NO_AUTOGEN.format(err=err, keyfile=keyfile))
sys.exit(5)
# try to create the certificate
CERT_EXPIRE = 365 * 20 # twenty years validity
# default:
# openssl req -new -x509 -key ssl.key -out ssl.cert -days 7300
exestring = "openssl req -new -x509 -key %s -out %s -days %s" % (keyfile, certfile, CERT_EXPIRE)
try:
subprocess.call(exestring)
except OSError as err:
raise OSError(NO_AUTOCERT.format(err=err, certfile=certfile, keyfile=keyfile, exestring=exestring))
print("done.")
def getSSLContext():
"""
This is called by the portal when creating the SSL context
server-side.
Returns:
ssl_context (tuple): A key and certificate that is either
existing previously or or created on the fly.
"""
keyfile = os.path.join(_GAME_DIR, "server", "ssl.key")
certfile = os.path.join(_GAME_DIR, "server", "ssl.cert")
verify_SSL_key_and_cert(keyfile, certfile)
return twisted_ssl.DefaultOpenSSLContextFactory(keyfile, certfile)

View file

@ -40,11 +40,9 @@ class SuppressGA(object):
self.protocol.protocol_flags["NOGOAHEAD"] = True self.protocol.protocol_flags["NOGOAHEAD"] = True
# tell the client that we prefer to suppress GA ... # tell the client that we prefer to suppress GA ...
self.protocol.will(SUPPRESS_GA).addCallbacks(self.do_suppress_ga, self.dont_suppress_ga) self.protocol.will(SUPPRESS_GA).addCallbacks(self.will_suppress_ga, self.wont_suppress_ga)
# ... but also accept if the client really wants not to.
self.protocol.do(SUPPRESS_GA).addCallbacks(self.do_suppress_ga, self.dont_suppress_ga)
def dont_suppress_ga(self, option): def wont_suppress_ga(self, option):
""" """
Called when client requests to not suppress GA. Called when client requests to not suppress GA.
@ -55,9 +53,9 @@ class SuppressGA(object):
self.protocol.protocol_flags["NOGOAHEAD"] = False self.protocol.protocol_flags["NOGOAHEAD"] = False
self.protocol.handshake_done() self.protocol.handshake_done()
def do_suppress_ga(self, option): def will_suppress_ga(self, option):
""" """
Client wants to suppress GA Client will suppress GA
Args: Args:
option (Option): Not used. option (Option): Not used.

View file

@ -8,6 +8,7 @@ sessions etc.
""" """
import re import re
from twisted.internet import protocol
from twisted.internet.task import LoopingCall from twisted.internet.task import LoopingCall
from twisted.conch.telnet import Telnet, StatefulTelnetProtocol from twisted.conch.telnet import Telnet, StatefulTelnetProtocol
from twisted.conch.telnet import IAC, NOP, LINEMODE, GA, WILL, WONT, ECHO, NULL from twisted.conch.telnet import IAC, NOP, LINEMODE, GA, WILL, WONT, ECHO, NULL
@ -25,6 +26,15 @@ _RE_LINEBREAK = re.compile(r"\n\r|\r\n|\n|\r", re.DOTALL + re.MULTILINE)
_RE_SCREENREADER_REGEX = re.compile(r"%s" % settings.SCREENREADER_REGEX_STRIP, re.DOTALL + re.MULTILINE) _RE_SCREENREADER_REGEX = re.compile(r"%s" % settings.SCREENREADER_REGEX_STRIP, re.DOTALL + re.MULTILINE)
_IDLE_COMMAND = settings.IDLE_COMMAND + "\n" _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): class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
""" """
Each player connecting over telnet (ie using most traditional mud Each player connecting over telnet (ie using most traditional mud
@ -33,8 +43,8 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.protocol_name = "telnet"
super(TelnetProtocol, self).__init__(*args, **kwargs) super(TelnetProtocol, self).__init__(*args, **kwargs)
self.protocol_key = "telnet"
def connectionMade(self): def connectionMade(self):
""" """
@ -48,9 +58,13 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
# this number is counted down for every handshake that completes. # this number is counted down for every handshake that completes.
# when it reaches 0 the portal/server syncs their data # when it reaches 0 the portal/server syncs their data
self.handshakes = 8 # suppress-go-ahead, naws, ttype, mccp, mssp, msdp, gmcp, mxp self.handshakes = 8 # suppress-go-ahead, naws, ttype, mccp, mssp, msdp, gmcp, mxp
self.init_session(self.protocol_name, client_address, self.factory.sessionhandler)
# change encoding to ENCODINGS[0] which reflects Telnet default encoding self.init_session(self.protocol_key, client_address, self.factory.sessionhandler)
self.protocol_flags["ENCODING"] = settings.ENCODINGS[0] if settings.ENCODINGS else 'utf-8' 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 # suppress go-ahead
self.sga = suppress_ga.SuppressGA(self) self.sga = suppress_ga.SuppressGA(self)
@ -67,13 +81,10 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
self.oob = telnet_oob.TelnetOOB(self) self.oob = telnet_oob.TelnetOOB(self)
# mxp support # mxp support
self.mxp = Mxp(self) self.mxp = Mxp(self)
# add this new connection to sessionhandler so
# the Server becomes aware of it.
self.sessionhandler.connect(self)
# timeout the handshakes in case the client doesn't reply at all
from evennia.utils.utils import delay from evennia.utils.utils import delay
delay(2, callback=self.handshake_done, force=True) # timeout the handshakes in case the client doesn't reply at all
delay(2, callback=self.handshake_done, timeout=True)
# TCP/IP keepalive watches for dead links # TCP/IP keepalive watches for dead links
self.transport.setTcpKeepAlive(1) self.transport.setTcpKeepAlive(1)
@ -101,17 +112,18 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
self.nop_keep_alive = LoopingCall(self._send_nop_keepalive) self.nop_keep_alive = LoopingCall(self._send_nop_keepalive)
self.nop_keep_alive.start(30, now=False) self.nop_keep_alive.start(30, now=False)
def handshake_done(self, force=False): def handshake_done(self, timeout=False):
""" """
This is called by all telnet extensions once they are finished. This is called by all telnet extensions once they are finished.
When all have reported, a sync with the server is performed. When all have reported, a sync with the server is performed.
The system will force-call this sync after a small time to handle The system will force-call this sync after a small time to handle
clients that don't reply to handshakes at all. clients that don't reply to handshakes at all.
""" """
if self.handshakes > 0: if timeout:
if force: if self.handshakes > 0:
self.handshakes = 0
self.sessionhandler.sync(self) self.sessionhandler.sync(self)
return else:
self.handshakes -= 1 self.handshakes -= 1
if self.handshakes <= 0: if self.handshakes <= 0:
# do the sync # do the sync
@ -231,9 +243,11 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
line (str): Line to send. line (str): Line to send.
""" """
# escape IAC in line mode, and correctly add \r\n # escape IAC in line mode, and correctly add \r\n (the TELNET end-of-line)
line += self.delimiter line = line.replace(IAC, IAC + IAC)
line = line.replace(IAC, IAC + IAC).replace('\n', '\r\n') line = line.replace('\n', '\r\n')
if not line.endswith("\r\n") and self.protocol_flags.get("FORCEDENDLINE", True):
line += "\r\n"
if not self.protocol_flags.get("NOGOAHEAD", True): if not self.protocol_flags.get("NOGOAHEAD", True):
line += IAC + GA line += IAC + GA
return self.transport.write(mccp_compress(self, line)) return self.transport.write(mccp_compress(self, line))
@ -306,8 +320,8 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session):
# handle arguments # handle arguments
options = kwargs.get("options", {}) options = kwargs.get("options", {})
flags = self.protocol_flags flags = self.protocol_flags
xterm256 = options.get("xterm256", flags.get('XTERM256', False) if flags["TTYPE"] else True) xterm256 = options.get("xterm256", flags.get('XTERM256', False) if flags.get("TTYPE", False) else True)
useansi = options.get("ansi", flags.get('ANSI', False) if flags["TTYPE"] else True) useansi = options.get("ansi", flags.get('ANSI', False) if flags.get("TTYPE", False) else True)
raw = options.get("raw", flags.get("RAW", False)) raw = options.get("raw", flags.get("RAW", False))
nocolor = options.get("nocolor", flags.get("NOCOLOR") or not (xterm256 or useansi)) nocolor = options.get("nocolor", flags.get("NOCOLOR") or not (xterm256 or useansi))
echo = options.get("echo", None) echo = options.get("echo", None)

View file

@ -227,26 +227,45 @@ class TelnetOOB(object):
GMCP messages will be outgoing on the following GMCP messages will be outgoing on the following
form (the non-JSON cmdname at the start is what form (the non-JSON cmdname at the start is what
IRE games use, supposedly, and what clients appear IRE games use, supposedly, and what clients appear
to have adopted): to have adopted). A cmdname without Package will end
up in the Core package, while Core package names will
be stripped on the Evennia side.
[cmdname, [], {}] -> cmdname [cmd.name, [], {}] -> Cmd.Name
[cmdname, [arg], {}] -> cmdname arg [cmd.name, [arg], {}] -> Cmd.Name arg
[cmdname, [args],{}] -> cmdname [args] [cmd.name, [args],{}] -> Cmd.Name [args]
[cmdname, [], {kwargs}] -> cmdname {kwargs} [cmd.name, [], {kwargs}] -> Cmd.Name {kwargs}
[cmdname, [args, {kwargs}] -> cmdname [[args],{kwargs}] [cmdname, [args, {kwargs}] -> Core.Cmdname [[args],{kwargs}]
Notes:
There are also a few default mappings between evennia outputcmds and
GMCP:
client_options -> Core.Supports.Get
get_inputfuncs -> Core.Commands.Get
get_value -> Char.Value.Get
repeat -> Char.Repeat.Update
monitor -> Char.Monitor.Update
""" """
if cmdname in EVENNIA_TO_GMCP:
gmcp_cmdname = EVENNIA_TO_GMCP[cmdname]
elif "_" in cmdname:
gmcp_cmdname = ".".join(word.capitalize() for word in cmdname.split("_"))
else:
gmcp_cmdname = "Core.%s" % cmdname.capitalize()
if not (args or kwargs): if not (args or kwargs):
gmcp_string = cmdname gmcp_string = gmcp_cmdname
elif args: elif args:
if len(args) == 1: if len(args) == 1:
args = args[0] args = args[0]
if kwargs: if kwargs:
gmcp_string = "%s %s" % (cmdname, json.dumps([args, kwargs])) gmcp_string = "%s %s" % (gmcp_cmdname, json.dumps([args, kwargs]))
else: else:
gmcp_string = "%s %s" % (cmdname, json.dumps(args)) gmcp_string = "%s %s" % (gmcp_cmdname, json.dumps(args))
else: # only kwargs else: # only kwargs
gmcp_string = "%s %s" % (cmdname, json.dumps(kwargs)) gmcp_string = "%s %s" % (gmcp_cmdname, json.dumps(kwargs))
# print("gmcp string", gmcp_string) # DEBUG # print("gmcp string", gmcp_string) # DEBUG
return gmcp_string return gmcp_string
@ -398,14 +417,9 @@ class TelnetOOB(object):
kwargs.pop("options", None) kwargs.pop("options", None)
if self.MSDP: if self.MSDP:
msdp_cmdname = cmdname encoded_oob = self.encode_msdp(cmdname, *args, **kwargs)
encoded_oob = self.encode_msdp(msdp_cmdname, *args, **kwargs)
self.protocol._write(IAC + SB + MSDP + encoded_oob + IAC + SE) self.protocol._write(IAC + SB + MSDP + encoded_oob + IAC + SE)
if self.GMCP: if self.GMCP:
if cmdname in EVENNIA_TO_GMCP: encoded_oob = self.encode_gmcp(cmdname, *args, **kwargs)
gmcp_cmdname = EVENNIA_TO_GMCP[cmdname]
else:
gmcp_cmdname = "Custom.Cmd"
encoded_oob = self.encode_gmcp(gmcp_cmdname, *args, **kwargs)
self.protocol._write(IAC + SB + GMCP + encoded_oob + IAC + SE) self.protocol._write(IAC + SB + GMCP + encoded_oob + IAC + SE)

View file

@ -0,0 +1,146 @@
"""
This allows for running the telnet communication over an encrypted SSL tunnel. To use it, requires a
client supporting Telnet SSL.
The protocol will try to automatically create the private key and certificate on the server side
when starting and will warn if this was not possible. These will appear as files ssl.key and
ssl.cert in mygame/server/.
"""
from __future__ import print_function
import os
try:
from OpenSSL import crypto
from twisted.internet import ssl as twisted_ssl
except ImportError as error:
errstr = """
{err}
Telnet-SSL requires the PyOpenSSL library and dependencies:
pip install pyopenssl pycrypto enum pyasn1 service_identity
Stop and start Evennia again. If no certificate can be generated, you'll
get a suggestion for a (linux) command to generate this locally.
"""
raise ImportError(errstr.format(err=error))
from django.conf import settings
from evennia.server.portal.telnet import TelnetProtocol
_GAME_DIR = settings.GAME_DIR
_PRIVATE_KEY_LENGTH = 2048
_PRIVATE_KEY_FILE = os.path.join(_GAME_DIR, "server", "ssl.key")
_PUBLIC_KEY_FILE = os.path.join(_GAME_DIR, "server", "ssl-public.key")
_CERTIFICATE_FILE = os.path.join(_GAME_DIR, "server", "ssl.cert")
_CERTIFICATE_EXPIRE = 365 * 24 * 60 * 60 * 20 # 20 years
_CERTIFICATE_ISSUER = {"C": "EV", "ST": "Evennia", "L": "Evennia", "O":
"Evennia Security", "OU": "Evennia Department", "CN": "evennia"}
# messages
NO_AUTOGEN = """
Evennia could not auto-generate the SSL private- and public keys ({{err}}).
If this error persists, create them manually (using the tools for your OS). The files
should be placed and named like this:
{}
{}
""".format(_PRIVATE_KEY_FILE, _PUBLIC_KEY_FILE)
NO_AUTOCERT = """
Evennia's could not auto-generate the SSL certificate ({{err}}).
The private key already exists here:
{}
If this error persists, create the certificate manually (using the private key and
the tools for your OS). The file should be placed and named like this:
{}
""".format(_PRIVATE_KEY_FILE, _CERTIFICATE_FILE)
class SSLProtocol(TelnetProtocol):
"""
Communication is the same as telnet, except data transfer
is done with encryption set up by the portal at start time.
"""
def __init__(self, *args, **kwargs):
super(SSLProtocol, self).__init__(*args, **kwargs)
self.protocol_key = "telnet/ssl"
def verify_or_create_SSL_key_and_cert(keyfile, certfile):
"""
Verify or create new key/certificate files.
Args:
keyfile (str): Path to ssl.key file.
certfile (str): Parth to ssl.cert file.
Notes:
If files don't already exist, they are created.
"""
if not (os.path.exists(keyfile) and os.path.exists(certfile)):
# key/cert does not exist. Create.
try:
# generate the keypair
keypair = crypto.PKey()
keypair.generate_key(crypto.TYPE_RSA, _PRIVATE_KEY_LENGTH)
with open(_PRIVATE_KEY_FILE, 'wt') as pfile:
pfile.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, keypair))
print("Created SSL private key in '{}'.".format(_PRIVATE_KEY_FILE))
with open(_PUBLIC_KEY_FILE, 'wt') as pfile:
pfile.write(crypto.dump_publickey(crypto.FILETYPE_PEM, keypair))
print("Created SSL public key in '{}'.".format(_PUBLIC_KEY_FILE))
except Exception as err:
print(NO_AUTOGEN.format(err=err))
return False
else:
try:
# create certificate
cert = crypto.X509()
subj = cert.get_subject()
for key, value in _CERTIFICATE_ISSUER.items():
setattr(subj, key, value)
cert.set_issuer(subj)
cert.set_serial_number(1000)
cert.gmtime_adj_notBefore(0)
cert.gmtime_adj_notAfter(_CERTIFICATE_EXPIRE)
cert.set_pubkey(keypair)
cert.sign(keypair, 'sha1')
with open(_CERTIFICATE_FILE, 'wt') as cfile:
cfile.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert))
print("Created SSL certificate in '{}'.".format(_CERTIFICATE_FILE))
except Exception as err:
print(NO_AUTOCERT.format(err=err))
return False
return True
def getSSLContext():
"""
This is called by the portal when creating the SSL context
server-side.
Returns:
ssl_context (tuple): A key and certificate that is either
existing previously or created on the fly.
"""
if verify_or_create_SSL_key_and_cert(_PRIVATE_KEY_FILE, _CERTIFICATE_FILE):
return twisted_ssl.DefaultOpenSSLContextFactory(_PRIVATE_KEY_FILE, _CERTIFICATE_FILE)
else:
return None

View file

@ -50,6 +50,8 @@ class Ttype(object):
""" """
self.ttype_step = 0 self.ttype_step = 0
self.protocol = protocol self.protocol = protocol
# we set FORCEDENDLINE for clients not supporting ttype
self.protocol.protocol_flags["FORCEDENDLINE"] = True
self.protocol.protocol_flags['TTYPE'] = False self.protocol.protocol_flags['TTYPE'] = False
# is it a safe bet to assume ANSI is always supported? # is it a safe bet to assume ANSI is always supported?
self.protocol.protocol_flags['ANSI'] = True self.protocol.protocol_flags['ANSI'] = True
@ -66,7 +68,7 @@ class Ttype(object):
option (Option): Not used. option (Option): Not used.
""" """
self.protocol.protocol_flags['TTYPE'] = True self.protocol.protocol_flags['TTYPE'] = False
self.protocol.handshake_done() self.protocol.handshake_done()
def will_ttype(self, option): def will_ttype(self, option):
@ -107,20 +109,28 @@ class Ttype(object):
# only support after a certain version, but all support # only support after a certain version, but all support
# it since at least 4 years. We assume recent client here for now. # it since at least 4 years. We assume recent client here for now.
cupper = clientname.upper() cupper = clientname.upper()
xterm256 = False
if cupper.startswith("MUDLET"): if cupper.startswith("MUDLET"):
# supports xterm256 stably since 1.1 (2010?) # supports xterm256 stably since 1.1 (2010?)
xterm256 = cupper.split("MUDLET", 1)[1].strip() >= "1.1" xterm256 = cupper.split("MUDLET", 1)[1].strip() >= "1.1"
else: self.protocol.protocol_flags["FORCEDENDLINE"] = False
xterm256 = (cupper.startswith("XTERM") or
cupper.endswith("-256COLOR") or if cupper.startswith("TINTIN++"):
cupper in ("ATLANTIS", # > 0.9.9.0 (aug 2009) self.protocol.protocol_flags["FORCEDENDLINE"] = False
"CMUD", # > 3.04 (mar 2009)
"KILDCLIENT", # > 2.2.0 (sep 2005) if (cupper.startswith("XTERM") or
"MUDLET", # > beta 15 (sep 2009) cupper.endswith("-256COLOR") or
"MUSHCLIENT", # > 4.02 (apr 2007) cupper in ("ATLANTIS", # > 0.9.9.0 (aug 2009)
"PUTTY", # > 0.58 (apr 2005) "CMUD", # > 3.04 (mar 2009)
"BEIP", # > 2.00.206 (late 2009) (BeipMu) "KILDCLIENT", # > 2.2.0 (sep 2005)
"POTATO")) # > 2.00 (maybe earlier) "MUDLET", # > beta 15 (sep 2009)
"MUSHCLIENT", # > 4.02 (apr 2007)
"PUTTY", # > 0.58 (apr 2005)
"BEIP", # > 2.00.206 (late 2009) (BeipMu)
"POTATO", # > 2.00 (maybe earlier)
"TINYFUGUE" # > 4.x (maybe earlier)
)):
xterm256 = True
# all clients supporting TTYPE at all seem to support ANSI # all clients supporting TTYPE at all seem to support ANSI
self.protocol.protocol_flags['ANSI'] = True self.protocol.protocol_flags['ANSI'] = True

View file

@ -31,6 +31,9 @@ class WebSocketClient(Protocol, Session):
""" """
Implements the server-side of the Websocket connection. Implements the server-side of the Websocket connection.
""" """
def __init__(self, *args, **kwargs):
super(WebSocketClient, self).__init__(*args, **kwargs)
self.protocol_key = "webclient/websocket"
def connectionMade(self): def connectionMade(self):
""" """

View file

@ -298,7 +298,7 @@ class AjaxWebClientSession(session.Session):
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.protocol_name = "ajax/comet" self.protocol_key = "webclient/ajax"
super(AjaxWebClientSession, self).__init__(*args, **kwargs) super(AjaxWebClientSession, self).__init__(*args, **kwargs)
def get_client_session(self): def get_client_session(self):

View file

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

View file

@ -7,7 +7,6 @@ sets up all the networking features. (this is done automatically
by evennia/server/server_runner.py). by evennia/server/server_runner.py).
""" """
from __future__ import print_function
from builtins import object from builtins import object
import time import time
import sys import sys
@ -17,6 +16,7 @@ from twisted.web import static
from twisted.application import internet, service from twisted.application import internet, service
from twisted.internet import reactor, defer from twisted.internet import reactor, defer
from twisted.internet.task import LoopingCall from twisted.internet.task import LoopingCall
from twisted.python.log import ILogObserver
import django import django
django.setup() django.setup()
@ -33,6 +33,7 @@ from evennia.server.models import ServerConfig
from evennia.server import initial_setup from evennia.server import initial_setup
from evennia.utils.utils import get_evennia_version, mod_import, make_iter from evennia.utils.utils import get_evennia_version, mod_import, make_iter
from evennia.utils import logger
from evennia.comms import channelhandler from evennia.comms import channelhandler
from evennia.server.sessionhandler import SESSIONS from evennia.server.sessionhandler import SESSIONS
@ -40,11 +41,6 @@ from django.utils.translation import ugettext as _
_SA = object.__setattr__ _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. # 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') 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 # modules containing plugin services
SERVER_SERVICES_PLUGIN_MODULES = [mod_import(module) for module in make_iter(settings.SERVER_SERVICES_PLUGIN_MODULES)] 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 # Evennia Server settings
#------------------------------------------------------------ # ------------------------------------------------------------
SERVERNAME = settings.SERVERNAME SERVERNAME = settings.SERVERNAME
VERSION = get_evennia_version() VERSION = get_evennia_version()
@ -83,6 +74,17 @@ IRC_ENABLED = settings.IRC_ENABLED
RSS_ENABLED = settings.RSS_ENABLED RSS_ENABLED = settings.RSS_ENABLED
WEBCLIENT_ENABLED = settings.WEBCLIENT_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 # Maintenance function - this is called repeatedly by the server
@ -127,6 +129,10 @@ def _server_maintenance():
if _MAINTENANCE_COUNT % 3700 == 0: if _MAINTENANCE_COUNT % 3700 == 0:
# validate channels off-sync with scripts # validate channels off-sync with scripts
evennia.CHANNEL_HANDLER.update() evennia.CHANNEL_HANDLER.update()
if _MAINTENANCE_COUNT % (3600 * 7) == 0:
# drop database connection every 7 hrs to avoid default timeouts on MySQL
# (see https://github.com/evennia/evennia/issues/1376)
connection.close()
# handle idle timeouts # handle idle timeouts
if _IDLE_TIMEOUT > 0: if _IDLE_TIMEOUT > 0:
@ -137,11 +143,6 @@ def _server_maintenance():
session.account.access(session.account, "noidletimeout", default=False): session.account.access(session.account, "noidletimeout", default=False):
SESSIONS.disconnect(session, reason=reason) SESSIONS.disconnect(session, reason=reason)
# Commenting this out, it is probably not needed
# with CONN_MAX_AGE set. Keeping it as a reminder
# if database-gone-away errors appears again /Griatch
# if _MAINTENANCE_COUNT % 18000 == 0:
# connection.close()
maintenance_task = LoopingCall(_server_maintenance) maintenance_task = LoopingCall(_server_maintenance)
maintenance_task.start(60, now=True) # call every minute maintenance_task.start(60, now=True) # call every minute
@ -173,6 +174,7 @@ class Evennia(object):
self.amp_protocol = None # set by amp factory self.amp_protocol = None # set by amp factory
self.sessions = SESSIONS self.sessions = SESSIONS
self.sessions.server = self self.sessions.server = self
self.process_id = os.getpid()
# Database-specific startup optimizations. # Database-specific startup optimizations.
self.sqlite3_prep() self.sqlite3_prep()
@ -193,18 +195,13 @@ class Evennia(object):
from twisted.internet.defer import Deferred from twisted.internet.defer import Deferred
if hasattr(self, "web_root"): if hasattr(self, "web_root"):
d = self.web_root.empty_threadpool() d = self.web_root.empty_threadpool()
d.addCallback(lambda _: self.shutdown(_reactor_stopping=True)) d.addCallback(lambda _: self.shutdown("shutdown", _reactor_stopping=True))
else: else:
d = Deferred(lambda _: self.shutdown(_reactor_stopping=True)) d = Deferred(lambda _: self.shutdown("shutdown", _reactor_stopping=True))
d.addCallback(lambda _: reactor.stop()) d.addCallback(lambda _: reactor.stop())
reactor.callLater(1, d.callback, None) reactor.callLater(1, d.callback, None)
reactor.sigInt = _wrap_sigint_handler reactor.sigInt = _wrap_sigint_handler
self.game_running = True
# track the server time
self.run_init_hooks()
# Server startup methods # Server startup methods
def sqlite3_prep(self): def sqlite3_prep(self):
@ -212,7 +209,8 @@ class Evennia(object):
Optimize some SQLite stuff at startup since we Optimize some SQLite stuff at startup since we
can't save it to the database. 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 (hasattr(settings, 'DATABASES') and
settings.DATABASES.get("default", {}).get('ENGINE', None) == settings.DATABASES.get("default", {}).get('ENGINE', None) ==
'django.db.backends.sqlite3')): 'django.db.backends.sqlite3')):
@ -230,6 +228,8 @@ class Evennia(object):
typeclasses in the settings file and have them auto-update all typeclasses in the settings file and have them auto-update all
already existing objects. already existing objects.
""" """
global INFO_DICT
# setting names # setting names
settings_names = ("CMDSET_CHARACTER", "CMDSET_ACCOUNT", settings_names = ("CMDSET_CHARACTER", "CMDSET_ACCOUNT",
"BASE_ACCOUNT_TYPECLASS", "BASE_OBJECT_TYPECLASS", "BASE_ACCOUNT_TYPECLASS", "BASE_OBJECT_TYPECLASS",
@ -248,7 +248,7 @@ class Evennia(object):
#from evennia.accounts.models import AccountDB #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): for i, prev, curr in ((i, tup[0], tup[1]) for i, tup in enumerate(settings_compare) if i in mismatches):
# update the database # 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: if i == 0:
ObjectDB.objects.filter(db_cmdset_storage__exact=prev).update(db_cmdset_storage=curr) ObjectDB.objects.filter(db_cmdset_storage__exact=prev).update(db_cmdset_storage=curr)
if i == 1: if i == 1:
@ -278,29 +278,31 @@ class Evennia(object):
It returns if this is not the first time the server starts. It returns if this is not the first time the server starts.
Once finished the last_initial_setup_step is set to -1. 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') last_initial_setup_step = ServerConfig.objects.conf('last_initial_setup_step')
if not last_initial_setup_step: if not last_initial_setup_step:
# None is only returned if the config does not exist, # None is only returned if the config does not exist,
# i.e. this is an empty DB that needs populating. # 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) initial_setup.handle_setup(0)
print('-' * 50)
elif int(last_initial_setup_step) >= 0: elif int(last_initial_setup_step) >= 0:
# a positive value means the setup crashed on one of its # a positive value means the setup crashed on one of its
# modules and setup will resume from this step, retrying # modules and setup will resume from this step, retrying
# the last failed module. When all are finished, the step # the last failed module. When all are finished, the step
# is set to -1 to show it does not need to be run again. # is set to -1 to show it does not need to be run again.
print(' Resuming initial setup from step %(last)s.' % INFO_DICT['info'] = ' Resuming initial setup from step {last}.'.format(
{'last': last_initial_setup_step}) last=last_initial_setup_step)
initial_setup.handle_setup(int(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.objects.models import ObjectDB
#from evennia.accounts.models import AccountDB
# update eventual changed defaults # update eventual changed defaults
self.update_defaults() self.update_defaults()
@ -308,47 +310,24 @@ class Evennia(object):
[o.at_init() for o in ObjectDB.get_all_cached_instances()] [o.at_init() for o in ObjectDB.get_all_cached_instances()]
[p.at_init() for p in AccountDB.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 # call correct server hook based on start file value
if mode == 'reload': if mode == 'reload':
# True was the old reload flag, kept for compatibilty logger.log_msg("Server successfully reloaded.")
self.at_server_reload_start() self.at_server_reload_start()
elif mode == 'reset': elif mode == 'reset':
# only run hook, don't purge sessions # only run hook, don't purge sessions
self.at_server_cold_start() 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() self.at_server_cold_start()
# clear eventual lingering session storages # clear eventual lingering session storages
ObjectDB.objects.clear_all_sessids() ObjectDB.objects.clear_all_sessids()
logger.log_msg("Evennia Server successfully started.")
# always call this regardless of start type # always call this regardless of start type
self.at_server_start() 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 @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. Shuts down the server from inside it.
@ -359,7 +338,6 @@ class Evennia(object):
at_shutdown hooks called but sessions will not at_shutdown hooks called but sessions will not
be disconnected. be disconnected.
'shutdown' - like reset, but server will not auto-restart. '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 _reactor_stopping - this is set if server is stopped by a kill
command OR this method was already called command OR this method was already called
once - in both cases the reactor is once - in both cases the reactor is
@ -370,10 +348,7 @@ class Evennia(object):
# once; we don't need to run the shutdown procedure again. # once; we don't need to run the shutdown procedure again.
defer.returnValue(None) defer.returnValue(None)
mode = self.getset_restart_mode(mode)
from evennia.objects.models import ObjectDB from evennia.objects.models import ObjectDB
#from evennia.accounts.models import AccountDB
from evennia.server.models import ServerConfig from evennia.server.models import ServerConfig
from evennia.utils import gametime as _GAMETIME_MODULE from evennia.utils import gametime as _GAMETIME_MODULE
@ -382,7 +357,8 @@ class Evennia(object):
ServerConfig.objects.conf("server_restart_mode", "reload") ServerConfig.objects.conf("server_restart_mode", "reload")
yield [o.at_server_reload() for o in ObjectDB.get_all_cached_instances()] 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 [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() yield self.sessions.all_sessions_portal_sync()
self.at_server_reload_stop() self.at_server_reload_stop()
# only save monitor state on reload, not on shutdown/reset # only save monitor state on reload, not on shutdown/reset
@ -412,10 +388,6 @@ class Evennia(object):
# always called, also for a reload # always called, also for a reload
self.at_server_stop() 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 if hasattr(self, "web_root"): # not set very first start
yield self.web_root.empty_threadpool() yield self.web_root.empty_threadpool()
@ -427,6 +399,10 @@ class Evennia(object):
# we make sure the proper gametime is saved as late as possible # we make sure the proper gametime is saved as late as possible
ServerConfig.objects.conf("runtime", _GAMETIME_MODULE.runtime()) 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 # server start/stop hooks
def at_server_start(self): def at_server_start(self):
@ -452,13 +428,15 @@ class Evennia(object):
if SERVER_STARTSTOP_MODULE: if SERVER_STARTSTOP_MODULE:
SERVER_STARTSTOP_MODULE.at_server_reload_start() 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 This is called just after the portal has finished syncing back data to the server
after reconnecting. 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 from evennia.scripts.monitorhandler import MONITOR_HANDLER
MONITOR_HANDLER.restore(mode == 'reload') MONITOR_HANDLER.restore(mode == 'reload')
@ -530,13 +508,15 @@ ServerConfig.objects.conf("server_starting_mode", True)
# what to execute from. # what to execute from.
application = service.Application('Evennia') 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 # The main evennia server program. This sets up the database
# and is where we store all the other services. # and is where we store all the other services.
EVENNIA = Evennia(application) EVENNIA = Evennia(application)
print('-' * 50)
print(' %(servername)s Server (%(version)s) started.' % {'servername': SERVERNAME, 'version': VERSION})
if AMP_ENABLED: if AMP_ENABLED:
# The AMP protocol handles the communication between # The AMP protocol handles the communication between
@ -546,20 +526,20 @@ if AMP_ENABLED:
ifacestr = "" ifacestr = ""
if AMP_INTERFACE != '127.0.0.1': if AMP_INTERFACE != '127.0.0.1':
ifacestr = "-%s" % AMP_INTERFACE ifacestr = "-%s" % AMP_INTERFACE
print(' amp (to Portal)%s: %s (internal)' % (ifacestr, AMP_PORT))
from evennia.server import amp INFO_DICT["amp"] = 'amp %s: %s' % (ifacestr, AMP_PORT)
factory = amp.AmpServerFactory(EVENNIA) from evennia.server import amp_client
amp_service = internet.TCPServer(AMP_PORT, factory, interface=AMP_INTERFACE)
amp_service.setName("EvenniaPortal") factory = amp_client.AMPClientFactory(EVENNIA)
amp_service = internet.TCPClient(AMP_HOST, AMP_PORT, factory)
amp_service.setName('ServerAMPClient')
EVENNIA.services.addService(amp_service) EVENNIA.services.addService(amp_service)
if WEBSERVER_ENABLED: if WEBSERVER_ENABLED:
# Start a django-compatible webserver. # Start a django-compatible webserver.
#from twisted.python import threadpool
from evennia.server.webserver import DjangoWebRoot, WSGIWebServer, Website, LockableThreadPool from evennia.server.webserver import DjangoWebRoot, WSGIWebServer, Website, LockableThreadPool
# start a thread pool and define the root url (/) as a wsgi resource # start a thread pool and define the root url (/) as a wsgi resource
@ -579,14 +559,16 @@ if WEBSERVER_ENABLED:
web_root = WEB_PLUGINS_MODULE.at_webserver_root_creation(web_root) web_root = WEB_PLUGINS_MODULE.at_webserver_root_creation(web_root)
web_site = Website(web_root, logPath=settings.HTTP_LOG_FILE) web_site = Website(web_root, logPath=settings.HTTP_LOG_FILE)
web_site.is_portal = False
INFO_DICT["webserver"] = ""
for proxyport, serverport in WEBSERVER_PORTS: for proxyport, serverport in WEBSERVER_PORTS:
# create the webserver (we only need the port for this) # create the webserver (we only need the port for this)
webserver = WSGIWebServer(threads, serverport, web_site, interface='127.0.0.1') webserver = WSGIWebServer(threads, serverport, web_site, interface='127.0.0.1')
webserver.setName('EvenniaWebServer%s' % serverport) webserver.setName('EvenniaWebServer%s' % serverport)
EVENNIA.services.addService(webserver) EVENNIA.services.addService(webserver)
print(" webserver: %s (internal)" % serverport) INFO_DICT["webserver"] += "webserver: %s" % serverport
ENABLED = [] ENABLED = []
if IRC_ENABLED: if IRC_ENABLED:
@ -598,18 +580,11 @@ if RSS_ENABLED:
ENABLED.append('rss') ENABLED.append('rss')
if ENABLED: if ENABLED:
print(" " + ", ".join(ENABLED) + " enabled.") INFO_DICT["irc_rss"] = ", ".join(ENABLED) + " enabled."
for plugin_module in SERVER_SERVICES_PLUGIN_MODULES: for plugin_module in SERVER_SERVICES_PLUGIN_MODULES:
# external plugin protocols # external plugin protocols
plugin_module.start_plugin_services(EVENNIA) plugin_module.start_plugin_services(EVENNIA)
print('-' * 50) # end of terminal output
# clear server startup mode # clear server startup mode
ServerConfig.objects.conf("server_starting_mode", delete=True) ServerConfig.objects.conf("server_starting_mode", delete=True)
if os.name == 'nt':
# Windows only: Set PID file manually
with open(SERVER_PIDFILE, 'w') as f:
f.write(str(os.getpid()))

View file

@ -188,6 +188,7 @@ class ServerSession(Session):
if not _ObjectDB: if not _ObjectDB:
from evennia.objects.models import ObjectDB as _ObjectDB from evennia.objects.models import ObjectDB as _ObjectDB
super(ServerSession, self).at_sync()
if not self.logged_in: if not self.logged_in:
# assign the unloggedin-command set. # assign the unloggedin-command set.
self.cmdset_storage = settings.CMDSET_UNLOGGEDIN self.cmdset_storage = settings.CMDSET_UNLOGGEDIN
@ -400,6 +401,7 @@ class ServerSession(Session):
# this can happen if this is triggered e.g. a command.msg # this can happen if this is triggered e.g. a command.msg
# that auto-adds the session, we'd get a kwarg collision. # that auto-adds the session, we'd get a kwarg collision.
kwargs.pop("session", None) kwargs.pop("session", None)
kwargs.pop("from_obj", None)
if text is not None: if text is not None:
self.data_out(text=text, **kwargs) self.data_out(text=text, **kwargs)
else: else:

View file

@ -46,8 +46,8 @@ class Session(object):
a new session is established. a new session is established.
Args: Args:
protocol_key (str): By default, one of 'telnet', 'ssh', protocol_key (str): By default, one of 'telnet', 'telnet/ssl', 'ssh',
'ssl' or 'web'. 'webclient/websocket' or 'webclient/ajax'.
address (str): Client address. address (str): Client address.
sessionhandler (SessionHandler): Reference to the sessionhandler (SessionHandler): Reference to the
main sessionhandler instance. main sessionhandler instance.
@ -117,7 +117,13 @@ class Session(object):
""" """
for propname, value in sessdata.items(): for propname, value in sessdata.items():
setattr(self, propname, value) if (propname == "protocol_flags" and isinstance(value, dict) and
hasattr(self, "protocol_flags") and
isinstance(self.protocol_flags, dict)):
# special handling to allow partial update of protocol flags
self.protocol_flags.update(value)
else:
setattr(self, propname, value)
def at_sync(self): def at_sync(self):
""" """
@ -126,7 +132,8 @@ class Session(object):
on uid etc). on uid etc).
""" """
self.protocol_flags.update(self.account.attributs.get("_saved_protocol_flags"), {}) if self.account:
self.protocol_flags.update(self.account.attributes.get("_saved_protocol_flags", {}))
# access hooks # access hooks

View file

@ -21,7 +21,7 @@ from evennia.commands.cmdhandler import CMD_LOGINSTART
from evennia.utils.logger import log_trace from evennia.utils.logger import log_trace
from evennia.utils.utils import (variable_from_module, is_iter, from evennia.utils.utils import (variable_from_module, is_iter,
to_str, to_unicode, to_str, to_unicode,
make_iter, make_iter, delay,
callables_from_module) callables_from_module)
from evennia.utils.inlinefuncs import parse_inlinefunc from evennia.utils.inlinefuncs import parse_inlinefunc
@ -58,6 +58,12 @@ SSYNC = chr(8) # server session sync
SCONN = chr(11) # server portal connection (for bots) SCONN = chr(11) # server portal connection (for bots)
PCONNSYNC = chr(12) # portal post-syncing session PCONNSYNC = chr(12) # portal post-syncing session
PDISCONNALL = chr(13) # portal session discnnect all 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 # i18n
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
@ -65,6 +71,7 @@ from django.utils.translation import ugettext as _
_SERVERNAME = settings.SERVERNAME _SERVERNAME = settings.SERVERNAME
_MULTISESSION_MODE = settings.MULTISESSION_MODE _MULTISESSION_MODE = settings.MULTISESSION_MODE
_IDLE_TIMEOUT = settings.IDLE_TIMEOUT _IDLE_TIMEOUT = settings.IDLE_TIMEOUT
_DELAY_CMD_LOGINSTART = settings.DELAY_CMD_LOGINSTART
_MAX_SERVER_COMMANDS_PER_SECOND = 100.0 _MAX_SERVER_COMMANDS_PER_SECOND = 100.0
_MAX_SESSION_COMMANDS_PER_SECOND = 5.0 _MAX_SESSION_COMMANDS_PER_SECOND = 5.0
_MODEL_MAP = None _MODEL_MAP = None
@ -274,6 +281,16 @@ class ServerSessionHandler(SessionHandler):
self.server = None self.server = None
self.server_data = {"servername": _SERVERNAME} self.server_data = {"servername": _SERVERNAME}
def _run_cmd_login(self, session):
"""
Launch the CMD_LOGINSTART command. This is wrapped
for delays.
"""
if not session.logged_in:
self.data_in(session, text=[[CMD_LOGINSTART], {}])
def portal_connect(self, portalsessiondata): def portal_connect(self, portalsessiondata):
""" """
Called by Portal when a new session has connected. Called by Portal when a new session has connected.
@ -309,8 +326,9 @@ class ServerSessionHandler(SessionHandler):
sess.logged_in = False sess.logged_in = False
sess.uid = None sess.uid = None
# show the first login command # show the first login command, may delay slightly to allow
self.data_in(sess, text=[[CMD_LOGINSTART], {}]) # the handshakes to finish.
delay(_DELAY_CMD_LOGINSTART, self._run_cmd_login, sess)
def portal_session_sync(self, portalsessiondata): def portal_session_sync(self, portalsessiondata):
""" """
@ -361,8 +379,10 @@ class ServerSessionHandler(SessionHandler):
self[sessid] = sess self[sessid] = sess
sess.at_sync() sess.at_sync()
mode = 'reload'
# tell the server hook we synced # tell the server hook we synced
self.server.at_post_portal_sync() self.server.at_post_portal_sync(mode)
# announce the reconnection # announce the reconnection
self.announce_all(_(" ... Server restarted.")) self.announce_all(_(" ... Server restarted."))
@ -420,13 +440,28 @@ class ServerSessionHandler(SessionHandler):
self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION, operation=SCONN, self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION, operation=SCONN,
protocol_path=protocol_path, config=configdict) 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): 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, self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION,
operation=SSHUTD) operation=PSHUTD)
def login(self, session, account, force=False, testmode=False): def login(self, session, account, force=False, testmode=False):
""" """
@ -538,6 +573,20 @@ class ServerSessionHandler(SessionHandler):
sessiondata=sessdata, sessiondata=sessdata,
clean=False) clean=False)
def session_portal_partial_sync(self, session_data):
"""
Call to make a partial update of the session, such as only a particular property.
Args:
session_data (dict): Store `{sessid: {property:value}, ...}` defining one or
more sessions in detail.
"""
return self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION,
operation=SSYNC,
sessiondata=session_data,
clean=False)
def disconnect_all_sessions(self, reason="You have been disconnected."): def disconnect_all_sessions(self, reason="You have been disconnected."):
""" """
Cleanly disconnect all of the connected sessions. Cleanly disconnect all of the connected sessions.
@ -565,10 +614,14 @@ class ServerSessionHandler(SessionHandler):
""" """
uid = curr_session.uid uid = curr_session.uid
# we can't compare sessions directly since this will compare addresses and
# mean connecting from the same host would not catch duplicates
sid = id(curr_session)
doublet_sessions = [sess for sess in self.values() doublet_sessions = [sess for sess in self.values()
if sess.logged_in and if sess.logged_in and
sess.uid == uid and sess.uid == uid and
sess != curr_session] id(sess) != sid]
for session in doublet_sessions: for session in doublet_sessions:
self.disconnect(session, reason) self.disconnect(session, reason)

View file

@ -212,6 +212,12 @@ class Website(server.Site):
""" """
noisy = False 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): def log(self, request):
"""Conditional logging""" """Conditional logging"""
if _DEBUG: if _DEBUG:

View file

@ -65,7 +65,7 @@ ALLOWED_HOSTS = ["*"]
# the Portal proxy presents to the world. The serverports are # the Portal proxy presents to the world. The serverports are
# the internal ports the proxy uses to forward data to the Server-side # the internal ports the proxy uses to forward data to the Server-side
# webserver (these should not be publicly open) # 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. # Interface addresses to listen to. If 0.0.0.0, listen to all. Use :: for IPv6.
WEBSERVER_INTERFACES = ['0.0.0.0'] WEBSERVER_INTERFACES = ['0.0.0.0']
# IP addresses that may talk to the server in a reverse proxy configuration, # IP addresses that may talk to the server in a reverse proxy configuration,
@ -89,12 +89,12 @@ WEBSOCKET_CLIENT_ENABLED = True
# working through a proxy or docker port-remapping, the environment variable # 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 # WEBCLIENT_CLIENT_PROXY_PORT can be used to override this port only for the
# front-facing client's sake. # front-facing client's sake.
WEBSOCKET_CLIENT_PORT = 4005 WEBSOCKET_CLIENT_PORT = 4002
# Interface addresses to listen to. If 0.0.0.0, listen to all. Use :: for IPv6. # Interface addresses to listen to. If 0.0.0.0, listen to all. Use :: for IPv6.
WEBSOCKET_CLIENT_INTERFACE = '0.0.0.0' WEBSOCKET_CLIENT_INTERFACE = '0.0.0.0'
# Actual URL for webclient component to reach the websocket. You only need # 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. # 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[s]://hostname[:port]". If left at None, # 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. # 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 # e.g. ws://external.example.com or wss://external.example.com:443
WEBSOCKET_CLIENT_URL = None WEBSOCKET_CLIENT_URL = None
@ -313,6 +313,14 @@ CMD_IGNORE_PREFIXES = "@&/+"
# This module should contain one or more variables # This module should contain one or more variables
# with strings defining the look of the screen. # with strings defining the look of the screen.
CONNECTION_SCREEN_MODULE = "server.conf.connection_screens" CONNECTION_SCREEN_MODULE = "server.conf.connection_screens"
# Delay to use before sending the evennia.syscmdkeys.CMD_LOGINSTART Command
# when a new session connects (this defaults the unloggedin-look for showing
# the connection screen). The delay is useful mainly for telnet, to allow
# client/server to establish client capabilities like color/mxp etc before
# sending any text. A value of 0.3 should be enough. While a good idea, it may
# cause issues with menu-logins and autoconnects since the menu will not have
# started when the autoconnects starts sending menu commands.
DELAY_CMD_LOGINSTART = 0.3
# An optional module that, if existing, must hold a function # An optional module that, if existing, must hold a function
# named at_initial_setup(). This hook method can be used to customize # named at_initial_setup(). This hook method can be used to customize
# the server's initial setup sequence (the very first startup of the system). # the server's initial setup sequence (the very first startup of the system).
@ -462,7 +470,7 @@ BASE_SCRIPT_TYPECLASS = "typeclasses.scripts.Script"
DEFAULT_HOME = "#2" DEFAULT_HOME = "#2"
# The start position for new characters. Default is Limbo (#2). # The start position for new characters. Default is Limbo (#2).
# MULTISESSION_MODE = 0, 1 - used by default unloggedin create command # 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" START_LOCATION = "#2"
# Lookups of Attributes, Tags, Nicks, Aliases can be aggressively # Lookups of Attributes, Tags, Nicks, Aliases can be aggressively
# cached to avoid repeated database hits. This often gives noticeable # cached to avoid repeated database hits. This often gives noticeable
@ -496,8 +504,13 @@ TIME_FACTOR = 2.0
# The starting point of your game time (the epoch), in seconds. # 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 # 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 # 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 TIME_GAME_EPOCH = None
# Normally, game time will only increase when the server runs. If this is True,
# game time will not pause when the server reloads or goes offline. This setting
# together with a time factor of 1 should keep the game in sync with
# the real time (add a different epoch to shift time)
TIME_IGNORE_DOWNTIMES = False
###################################################################### ######################################################################
# Inlinefunc # Inlinefunc
@ -532,8 +545,8 @@ INLINEFUNC_MODULES = ["evennia.utils.inlinefuncs",
# 3 - like mode 2, except multiple sessions can puppet one character, each # 3 - like mode 2, except multiple sessions can puppet one character, each
# session getting the same data. # session getting the same data.
MULTISESSION_MODE = 0 MULTISESSION_MODE = 0
# The maximum number of characters allowed for MULTISESSION_MODE 2,3. This is # The maximum number of characters allowed for MULTISESSION_MODE 2, 3.
# checked by the default ooc char-creation command. Forced to 1 for # This is checked by the default ooc char-creation command. Forced to 1 for
# MULTISESSION_MODE 0 and 1. # MULTISESSION_MODE 0 and 1.
MAX_NR_CHARACTERS = 1 MAX_NR_CHARACTERS = 1
# The access hierarchy, in climbing order. A higher permission in the # The access hierarchy, in climbing order. A higher permission in the

View file

@ -2,7 +2,7 @@ from django.contrib import admin
from evennia.typeclasses.models import Tag from evennia.typeclasses.models import Tag
from django import forms from django import forms
from evennia.utils.picklefield import PickledFormField from evennia.utils.picklefield import PickledFormField
from evennia.utils.dbserialize import from_pickle from evennia.utils.dbserialize import from_pickle, _SaverSet
import traceback import traceback
@ -164,12 +164,12 @@ class AttributeForm(forms.ModelForm):
attr_category = forms.CharField(label="Category", attr_category = forms.CharField(label="Category",
help_text="type of attribute, for sorting", help_text="type of attribute, for sorting",
required=False, required=False,
max_length=4) max_length=128)
attr_value = PickledFormField(label="Value", help_text="Value to pickle/save", required=False) attr_value = PickledFormField(label="Value", help_text="Value to pickle/save", required=False)
attr_type = forms.CharField(label="Type", attr_type = forms.CharField(label="Type",
help_text="Internal use. Either unset (normal Attribute) or \"nick\"", help_text="Internal use. Either unset (normal Attribute) or \"nick\"",
required=False, required=False,
max_length=4) max_length=16)
attr_strvalue = forms.CharField(label="String Value", attr_strvalue = forms.CharField(label="String Value",
help_text="Only set when using the Attribute as a string-only store", help_text="Only set when using the Attribute as a string-only store",
required=False, required=False,
@ -213,6 +213,9 @@ class AttributeForm(forms.ModelForm):
self.instance.attr_key = attr_key self.instance.attr_key = attr_key
self.instance.attr_category = attr_category self.instance.attr_category = attr_category
self.instance.attr_value = attr_value self.instance.attr_value = attr_value
# prevent set from being transformed to unicode
if isinstance(attr_value, set) or isinstance(attr_value, _SaverSet):
self.fields['attr_value'].disabled = True
self.instance.deserialized_value = from_pickle(attr_value) self.instance.deserialized_value = from_pickle(attr_value)
self.instance.attr_strvalue = attr_strvalue self.instance.attr_strvalue = attr_strvalue
self.instance.attr_type = attr_type self.instance.attr_type = attr_type
@ -237,6 +240,17 @@ class AttributeForm(forms.ModelForm):
instance.attr_lockstring = self.cleaned_data['attr_lockstring'] instance.attr_lockstring = self.cleaned_data['attr_lockstring']
return instance return instance
def clean_attr_value(self):
"""
Prevent Sets from being cleaned due to literal_eval failing on them. Otherwise they will be turned into
unicode.
"""
data = self.cleaned_data['attr_value']
initial = self.instance.attr_value
if isinstance(initial, set) or isinstance(initial, _SaverSet):
return initial
return data
class AttributeFormSet(forms.BaseInlineFormSet): class AttributeFormSet(forms.BaseInlineFormSet):
""" """

View file

@ -245,7 +245,7 @@ class AttributeHandler(object):
found from cache or database. found from cache or database.
Notes: Notes:
When given a category only, a search for all objects 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 stored. This tells the system on subsequent calls that the
list of cached attributes of this category is up-to-date list of cached attributes of this category is up-to-date
and that the cache can be queried for category matches and that the cache can be queried for category matches
@ -282,6 +282,8 @@ class AttributeHandler(object):
"attribute__db_attrtype": self._attrtype, "attribute__db_attrtype": self._attrtype,
"attribute__db_key__iexact": key.lower(), "attribute__db_key__iexact": key.lower(),
"attribute__db_category__iexact": category.lower() if category else None} "attribute__db_category__iexact": category.lower() if category else None}
if not self.obj.pk:
return []
conn = getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query) conn = getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query)
if conn: if conn:
attr = conn[0].attribute attr = conn[0].attribute

View file

@ -8,6 +8,8 @@ import shlex
from django.db.models import Q from django.db.models import Q
from evennia.utils import idmapper from evennia.utils import idmapper
from evennia.utils.utils import make_iter, variable_from_module, to_unicode from evennia.utils.utils import make_iter, variable_from_module, to_unicode
from evennia.typeclasses.attributes import Attribute
from evennia.typeclasses.tags import Tag
__all__ = ("TypedObjectManager", ) __all__ = ("TypedObjectManager", )
_GA = object.__getattribute__ _GA = object.__getattribute__
@ -56,17 +58,19 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
dbmodel = self.model.__dbclass__.__name__.lower() dbmodel = self.model.__dbclass__.__name__.lower()
query = [("attribute__db_attrtype", attrtype), ("attribute__db_model", dbmodel)] query = [("attribute__db_attrtype", attrtype), ("attribute__db_model", dbmodel)]
if obj: if obj:
query.append(("%s__id" % self.model.__name__.lower(), obj.id)) query.append(("%s__id" % self.model.__dbclass__.__name__.lower(), obj.id))
if key: if key:
query.append(("attribute__db_key", key)) query.append(("attribute__db_key", key))
if category: if category:
query.append(("attribute__db_category", category)) query.append(("attribute__db_category", category))
if strvalue: if strvalue:
query.append(("attribute__db_strvalue", strvalue)) query.append(("attribute__db_strvalue", strvalue))
elif value: if value:
# strvalue and value are mutually exclusive # no reason to make strvalue/value mutually exclusive at this level
query.append(("attribute__db_value", value)) query.append(("attribute__db_value", value))
return [th.attribute for th in self.model.db_attributes.through.objects.filter(**dict(query))] return Attribute.objects.filter(
pk__in=self.model.db_attributes.through.objects.filter(
**dict(query)).values_list("attribute_id", flat=True))
def get_nick(self, key=None, category=None, value=None, strvalue=None, obj=None): def get_nick(self, key=None, category=None, value=None, strvalue=None, obj=None):
""" """
@ -145,6 +149,7 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
# Tag manager methods # Tag manager methods
def get_tag(self, key=None, category=None, obj=None, tagtype=None, global_search=False): 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 Return Tag objects by key, by category, by object (it is
@ -188,7 +193,9 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
query.append(("tag__db_key", key)) query.append(("tag__db_key", key))
if category: if category:
query.append(("tag__db_category", category)) query.append(("tag__db_category", category))
return [th.tag for th in self.model.db_tags.through.objects.filter(**dict(query))] return Tag.objects.filter(
pk__in=self.model.db_tags.through.objects.filter(
**dict(query)).values_list("tag_id", flat=True))
def get_permission(self, key=None, category=None, obj=None): def get_permission(self, key=None, category=None, obj=None):
""" """
@ -222,25 +229,58 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager):
def get_by_tag(self, key=None, category=None, tagtype=None): def get_by_tag(self, key=None, category=None, tagtype=None):
""" """
Return objects having tags with a given key or category or Return objects having tags with a given key or category or combination of the two.
combination of the two. Also accepts multiple tags/category/tagtype
Args: Args:
key (str, optional): Tag key. Not case sensitive. key (str or list, optional): Tag key or list of keys. Not case sensitive.
category (str, optional): Tag category. Not case sensitive. category (str or list, optional): Tag category. Not case sensitive. If `key` is
tagtype (str or None, optional): 'type' of Tag, by default 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 this is either `None` (a normal Tag), `alias` or
`permission`. `permission`. This always apply to all queried tags.
Returns: Returns:
objects (list): Objects with matching tag. 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() dbmodel = self.model.__dbclass__.__name__.lower()
query = [("db_tags__db_tagtype", tagtype), ("db_tags__db_model", dbmodel)] query = self.filter(db_tags__db_tagtype__iexact=tagtype,
if key: db_tags__db_model__iexact=dbmodel).distinct()
query.append(("db_tags__db_key", key.lower()))
if category: if n_keys > 0:
query.append(("db_tags__db_category", category.lower())) # keys and/or categories given
return self.filter(**dict(query)) 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): def get_by_permission(self, key=None, category=None):
""" """

View file

@ -269,14 +269,15 @@ class TagHandler(object):
def get(self, key=None, default=None, category=None, return_tagobj=False, return_list=False): 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: 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. default (any, optional): The value to return in case of no match.
category (str, optional): The Tag category to limit the category (str, optional): The Tag category to limit the
request to. Note that `None` is the valid, default 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 return_tagobj (bool, optional): Return the Tag object itself
instead of a string representation of the Tag. instead of a string representation of the Tag.
return_list (bool, optional): Always return a list, regardless return_list (bool, optional): Always return a list, regardless

View file

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

View file

@ -43,13 +43,18 @@ command definition too) with function definitions:
def node_with_other_name(caller, input_string): def node_with_other_name(caller, input_string):
# code # code
return text, options return text, options
def another_node(caller, input_string, **kwargs):
# code
return text, options
``` ```
Where caller is the object using the menu and input_string is the Where caller is the object using the menu and input_string is the
command entered by the user on the *previous* node (the command command entered by the user on the *previous* node (the command
entered to get to this node). The node function code will only be entered to get to this node). The node function code will only be
executed once per node-visit and the system will accept nodes with executed once per node-visit and the system will accept nodes with
both one or two arguments interchangeably. both one or two arguments interchangeably. It also accepts nodes
that takes **kwargs.
The menu tree itself is available on the caller as The menu tree itself is available on the caller as
`caller.ndb._menutree`. This makes it a convenient place to store `caller.ndb._menutree`. This makes it a convenient place to store
@ -82,12 +87,14 @@ menu is immediately exited and the default "look" command is called.
the callable. Those kwargs will also be passed into the next node if possible. 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 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, string is the name of the next node to go to and the dict is the new,
(possibly modified) kwarg to pass into the next node. (possibly modified) kwarg to pass into the next node. If the callable returns
None or the empty string, the current node will be revisited.
- `exec` (str, callable or tuple, optional): This takes the same input as `goto` above - `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 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 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 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. next node name and the optional dict acting as the kwargs-input for the next node.
If an exec callable returns the empty string (only), the current node is re-run.
If key is not given, the option will automatically be identified by If key is not given, the option will automatically be identified by
its number 1..N. its number 1..N.
@ -167,7 +174,7 @@ from evennia import Command, CmdSet
from evennia.utils import logger from evennia.utils import logger
from evennia.utils.evtable import EvTable from evennia.utils.evtable import EvTable
from evennia.utils.ansi import strip_ansi from evennia.utils.ansi import strip_ansi
from evennia.utils.utils import mod_import, make_iter, pad, m_len from evennia.utils.utils import mod_import, make_iter, pad, m_len, is_iter
from evennia.commands import cmdhandler from evennia.commands import cmdhandler
# read from protocol NAWS later? # read from protocol NAWS later?
@ -182,7 +189,8 @@ _CMD_NOINPUT = cmdhandler.CMD_NOINPUT
# i18n # i18n
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
_ERR_NOT_IMPLEMENTED = _("Menu node '{nodename}' is not implemented. Make another choice.") _ERR_NOT_IMPLEMENTED = _("Menu node '{nodename}' is either not implemented or "
"caused an error. Make another choice.")
_ERR_GENERAL = _("Error in menu node '{nodename}'.") _ERR_GENERAL = _("Error in menu node '{nodename}'.")
_ERR_NO_OPTION_DESC = _("No description.") _ERR_NO_OPTION_DESC = _("No description.")
_HELP_FULL = _("Commands: <menu option>, help, quit") _HELP_FULL = _("Commands: <menu option>, help, quit")
@ -573,6 +581,7 @@ class EvMenu(object):
except EvMenuError: except EvMenuError:
errmsg = _ERR_GENERAL.format(nodename=callback) errmsg = _ERR_GENERAL.format(nodename=callback)
self.caller.msg(errmsg, self._session) self.caller.msg(errmsg, self._session)
logger.log_trace()
raise raise
return ret return ret
@ -606,9 +615,11 @@ class EvMenu(object):
nodetext, options = ret, None nodetext, options = ret, None
except KeyError: except KeyError:
self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session) self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session)
logger.log_trace()
raise EvMenuError raise EvMenuError
except Exception: except Exception:
self.caller.msg(_ERR_GENERAL.format(nodename=nodename), session=self._session) self.caller.msg(_ERR_GENERAL.format(nodename=nodename), session=self._session)
logger.log_trace()
raise raise
# store options to make them easier to test # store options to make them easier to test
@ -665,9 +676,49 @@ class EvMenu(object):
if isinstance(ret, basestring): if isinstance(ret, basestring):
# only return a value if a string (a goto target), ignore all other returns # only return a value if a string (a goto target), ignore all other returns
if not ret:
# an empty string - rerun the same node
return self.nodename
return ret, kwargs return ret, kwargs
return None return None
def extract_goto_exec(self, nodename, option_dict):
"""
Helper: Get callables and their eventual kwargs.
Args:
nodename (str): The current node name (used for error reporting).
option_dict (dict): The seleted option's dict.
Returns:
goto (str, callable or None): The goto directive in the option.
goto_kwargs (dict): Kwargs for `goto` if the former is callable, otherwise empty.
execute (callable or None): Executable given by the `exec` directive.
exec_kwargs (dict): Kwargs for `execute` if it's callable, otherwise empty.
"""
goto_kwargs, exec_kwargs = {}, {}
goto, execute = option_dict.get("goto", None), option_dict.get("exec", None)
if goto and isinstance(goto, (tuple, list)):
if len(goto) > 1:
goto, goto_kwargs = goto[:2] # ignore any extra arguments
if not hasattr(goto_kwargs, "__getitem__"):
# not a dict-like structure
raise EvMenuError("EvMenu node {}: goto kwargs is not a dict: {}".format(
nodename, goto_kwargs))
else:
goto = goto[0]
if execute and isinstance(execute, (tuple, list)):
if len(execute) > 1:
execute, exec_kwargs = execute[:2] # ignore any extra arguments
if not hasattr(exec_kwargs, "__getitem__"):
# not a dict-like structure
raise EvMenuError("EvMenu node {}: exec kwargs is not a dict: {}".format(
nodename, goto_kwargs))
else:
execute = execute[0]
return goto, goto_kwargs, execute, exec_kwargs
def goto(self, nodename, raw_string, **kwargs): def goto(self, nodename, raw_string, **kwargs):
""" """
Run a node by name, optionally dynamically generating that name first. Run a node by name, optionally dynamically generating that name first.
@ -681,29 +732,6 @@ class EvMenu(object):
argument) argument)
""" """
def _extract_goto_exec(option_dict):
"Helper: Get callables and their eventual kwargs"
goto_kwargs, exec_kwargs = {}, {}
goto, execute = option_dict.get("goto", None), option_dict.get("exec", None)
if goto and isinstance(goto, (tuple, list)):
if len(goto) > 1:
goto, goto_kwargs = goto[:2] # ignore any extra arguments
if not hasattr(goto_kwargs, "__getitem__"):
# not a dict-like structure
raise EvMenuError("EvMenu node {}: goto kwargs is not a dict: {}".format(
nodename, goto_kwargs))
else:
goto = goto[0]
if execute and isinstance(execute, (tuple, list)):
if len(execute) > 1:
execute, exec_kwargs = execute[:2] # ignore any extra arguments
if not hasattr(exec_kwargs, "__getitem__"):
# not a dict-like structure
raise EvMenuError("EvMenu node {}: exec kwargs is not a dict: {}".format(
nodename, goto_kwargs))
else:
execute = execute[0]
return goto, goto_kwargs, execute, exec_kwargs
if callable(nodename): if callable(nodename):
# run the "goto" callable, if possible # run the "goto" callable, if possible
@ -714,6 +742,9 @@ class EvMenu(object):
raise EvMenuError( raise EvMenuError(
"{}: goto callable must return str or (str, dict)".format(inp_nodename)) "{}: goto callable must return str or (str, dict)".format(inp_nodename))
nodename, kwargs = nodename[:2] nodename, kwargs = nodename[:2]
if not nodename:
# no nodename return. Re-run current node
nodename = self.nodename
try: try:
# execute the found node, make use of the returns. # execute the found node, make use of the returns.
nodetext, options = self._execute_node(nodename, raw_string, **kwargs) nodetext, options = self._execute_node(nodename, raw_string, **kwargs)
@ -746,12 +777,12 @@ class EvMenu(object):
desc = dic.get("desc", dic.get("text", None)) desc = dic.get("desc", dic.get("text", None))
if "_default" in keys: if "_default" in keys:
keys = [key for key in keys if key != "_default"] keys = [key for key in keys if key != "_default"]
goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic) goto, goto_kwargs, execute, exec_kwargs = self.extract_goto_exec(nodename, dic)
self.default = (goto, goto_kwargs, execute, exec_kwargs) self.default = (goto, goto_kwargs, execute, exec_kwargs)
else: else:
# use the key (only) if set, otherwise use the running number # use the key (only) if set, otherwise use the running number
keys = list(make_iter(dic.get("key", str(inum + 1).strip()))) keys = list(make_iter(dic.get("key", str(inum + 1).strip())))
goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic) goto, goto_kwargs, execute, exec_kwargs = self.extract_goto_exec(nodename, dic)
if keys: if keys:
display_options.append((keys[0], desc)) display_options.append((keys[0], desc))
for key in keys: for key in keys:
@ -846,10 +877,6 @@ class EvMenu(object):
else: else:
self.caller.msg(_HELP_NO_OPTION_MATCH, session=self._session) self.caller.msg(_HELP_NO_OPTION_MATCH, session=self._session)
if not (self.options or self.default):
# no options - we are at the end of the menu.
self.close_menu()
def display_nodetext(self): def display_nodetext(self):
self.caller.msg(self.nodetext, session=self._session) self.caller.msg(self.nodetext, session=self._session)
@ -949,14 +976,176 @@ class EvMenu(object):
node (str): The formatted node to display. node (str): The formatted node to display.
""" """
if self._session:
screen_width = self._session.protocol_flags.get(
"SCREENWIDTH", {0: _MAX_TEXT_WIDTH})[0]
else:
screen_width = _MAX_TEXT_WIDTH
nodetext_width_max = max(m_len(line) for line in nodetext.split("\n")) nodetext_width_max = max(m_len(line) for line in nodetext.split("\n"))
options_width_max = max(m_len(line) for line in optionstext.split("\n")) options_width_max = max(m_len(line) for line in optionstext.split("\n"))
total_width = max(options_width_max, nodetext_width_max) total_width = min(screen_width, max(options_width_max, nodetext_width_max))
separator1 = "_" * total_width + "\n\n" if nodetext_width_max else "" separator1 = "_" * total_width + "\n\n" if nodetext_width_max else ""
separator2 = "\n" + "_" * total_width + "\n\n" if total_width else "" separator2 = "\n" + "_" * total_width + "\n\n" if total_width else ""
return separator1 + "|n" + nodetext + "|n" + separator2 + "|n" + optionstext return separator1 + "|n" + nodetext + "|n" + separator2 + "|n" + optionstext
# -----------------------------------------------------------
#
# List node (decorator turning a node into a list with
# look/edit/add functionality for the elements)
#
# -----------------------------------------------------------
def list_node(option_generator, select=None, pagesize=10):
"""
Decorator for making an EvMenu node into a multi-page list node. Will add new options,
prepending those options added in the node.
Args:
option_generator (callable or list): A list of strings indicating the options, or a callable
that is called as option_generator(caller) to produce such a list.
select (callable or str, optional): Node to redirect a selection to. Its `**kwargs` will
contain the `available_choices` list and `selection` will hold one of the elements in
that list. If a callable, it will be called as select(caller, menuchoice) where
menuchoice is the chosen option as a string. Should return the target node to goto after
this selection (or None to repeat the list-node). Note that if this is not given, the
decorated node must itself provide a way to continue from the node!
pagesize (int): How many options to show per page.
Example:
@list_node(['foo', 'bar'], select)
def node_index(caller):
text = "describing the list"
return text, []
Notes:
All normal `goto` or `exec` callables returned from the decorated nodes will, if they accept
**kwargs, get a new kwarg 'available_choices' injected. These are the ordered list of named
options (descs) visible on the current node page.
"""
def decorator(func):
def _select_parser(caller, raw_string, **kwargs):
"""
Parse the select action
"""
available_choices = kwargs.get("available_choices", [])
try:
index = int(raw_string.strip()) - 1
selection = available_choices[index]
except Exception:
caller.msg("|rInvalid choice.|n")
else:
if callable(select):
try:
return select(caller, selection)
except Exception:
logger.log_trace()
elif select:
# we assume a string was given, we inject the result into the kwargs
# to pass on to the next node
kwargs['selection'] = selection
return str(select)
# this means the previous node will be re-run with these same kwargs
return None
def _list_node(caller, raw_string, **kwargs):
option_list = option_generator(caller) \
if callable(option_generator) else option_generator
npages = 0
page_index = 0
page = []
options = []
if option_list:
nall_options = len(option_list)
pages = [option_list[ind:ind + pagesize]
for ind in range(0, nall_options, pagesize)]
npages = len(pages)
page_index = max(0, min(npages - 1, kwargs.get("optionpage_index", 0)))
page = pages[page_index]
text = ""
extra_text = None
# dynamic, multi-page option list. Each selection leads to the `select`
# callback being called with a result from the available choices
options.extend([{"desc": opt,
"goto": (_select_parser,
{"available_choices": page})} for opt in page])
if npages > 1:
# if the goto callable returns None, the same node is rerun, and
# kwargs not used by the callable are passed on to the node. This
# allows us to call ourselves over and over, using different kwargs.
options.append({"key": ("|Wcurrent|n", "c"),
"desc": "|W({}/{})|n".format(page_index + 1, npages),
"goto": (lambda caller: None,
{"optionpage_index": page_index})})
if page_index > 0:
options.append({"key": ("|wp|Wrevious page|n", "p"),
"goto": (lambda caller: None,
{"optionpage_index": page_index - 1})})
if page_index < npages - 1:
options.append({"key": ("|wn|Wext page|n", "n"),
"goto": (lambda caller: None,
{"optionpage_index": page_index + 1})})
# add data from the decorated node
decorated_options = []
try:
text, decorated_options = func(caller, raw_string)
except TypeError:
try:
text, decorated_options = func(caller)
except Exception:
raise
except Exception:
logger.log_trace()
else:
if isinstance(decorated_options, {}):
decorated_options = [decorated_options]
else:
decorated_options = make_iter(decorated_options)
extra_options = []
for eopt in decorated_options:
cback = ("goto" in eopt and "goto") or ("exec" in eopt and "exec") or None
if cback:
signature = eopt[cback]
if callable(signature):
# callable with no kwargs defined
eopt[cback] = (signature, {"available_choices": page})
elif is_iter(signature):
if len(signature) > 1 and isinstance(signature[1], dict):
signature[1]["available_choices"] = page
eopt[cback] = signature
elif signature:
# a callable alone in a tuple (i.e. no previous kwargs)
eopt[cback] = (signature[0], {"available_choices": page})
else:
# malformed input.
logger.log_err("EvMenu @list_node decorator found "
"malformed option to decorate: {}".format(eopt))
extra_options.append(eopt)
options.extend(extra_options)
text = text + "\n\n" + extra_text if extra_text else text
return text, options
return _list_node
return decorator
# ------------------------------------------------------------------------------------------------- # -------------------------------------------------------------------------------------------------
# #
# Simple input shortcuts # Simple input shortcuts

View file

@ -893,6 +893,9 @@ class EvColumn(object):
""" """
col = self.column col = self.column
# fixed options for the column will override those requested in the call!
# this is particularly relevant to things like width/height, to avoid
# fixed-widths columns from being auto-balanced
kwargs.update(self.options) kwargs.update(self.options)
# use fixed width or adjust to the largest cell # use fixed width or adjust to the largest cell
if "width" not in kwargs: if "width" not in kwargs:
@ -1283,25 +1286,59 @@ class EvTable(object):
cwidths_min = [max(cell.get_min_width() for cell in col) for col in self.worktable] cwidths_min = [max(cell.get_min_width() for cell in col) for col in self.worktable]
cwmin = sum(cwidths_min) cwmin = sum(cwidths_min)
if cwmin > width: # get which cols have separately set widths - these should be locked
# we cannot shrink any more # note that we need to remove cwidths_min for each lock to avoid counting
raise Exception("Cannot shrink table width to %s. Minimum size is %s." % (self.width, cwmin)) # it twice (in cwmin and in locked_cols)
locked_cols = {icol: col.options['width'] - cwidths_min[icol]
for icol, col in enumerate(self.worktable) if 'width' in col.options}
locked_width = sum(locked_cols.values())
excess = width - cwmin - locked_width
if len(locked_cols) >= ncols and excess:
# we can't adjust the width at all - all columns are locked
raise Exception("Cannot balance table to width %s - "
"all columns have a set, fixed width summing to %s!" % (
self.width, sum(cwidths)))
if excess < 0:
# the locked cols makes it impossible
raise Exception("Cannot shrink table width to %s. "
"Minimum size (and/or fixed-width columns) "
"sets minimum at %s." % (self.width, cwmin + locked_width))
excess = width - cwmin
if self.evenwidth: if self.evenwidth:
# make each column of equal width # make each column of equal width
for _ in range(excess): # use cwidths as a work-array to track weights
cwidths = copy(cwidths_min)
correction = 0
while correction < excess:
# flood-fill the minimum table starting with the smallest columns # flood-fill the minimum table starting with the smallest columns
ci = cwidths_min.index(min(cwidths_min)) ci = cwidths.index(min(cwidths))
cwidths_min[ci] += 1 if ci in locked_cols:
# locked column, make sure it's not picked again
cwidths[ci] += 9999
cwidths_min[ci] = locked_cols[ci]
else:
cwidths_min[ci] += 1
correction += 1
cwidths = cwidths_min cwidths = cwidths_min
else: else:
# make each column expand more proportional to their data size # make each column expand more proportional to their data size
for _ in range(excess): # we use cwidth as a work-array to track weights
correction = 0
while correction < excess:
# fill wider columns first # fill wider columns first
ci = cwidths.index(max(cwidths)) ci = cwidths.index(max(cwidths))
cwidths_min[ci] += 1 if ci in locked_cols:
cwidths[ci] -= 3 # locked column, make sure it's not picked again
cwidths[ci] -= 9999
cwidths_min[ci] = locked_cols[ci]
else:
cwidths_min[ci] += 1
correction += 1
# give a just changed col less prio next run
cwidths[ci] -= 3
cwidths = cwidths_min cwidths = cwidths_min
# reformat worktable (for width align) # reformat worktable (for width align)
@ -1323,28 +1360,46 @@ class EvTable(object):
for cell in (col[iy] for col in self.worktable)) for iy in range(nrowmax)] for cell in (col[iy] for col in self.worktable)) for iy in range(nrowmax)]
chmin = sum(cheights_min) chmin = sum(cheights_min)
# get which cols have separately set heights - these should be locked
# note that we need to remove cheights_min for each lock to avoid counting
# it twice (in chmin and in locked_cols)
locked_cols = {icol: col.options['height'] - cheights_min[icol]
for icol, col in enumerate(self.worktable) if 'height' in col.options}
locked_height = sum(locked_cols.values())
excess = self.height - chmin - locked_height
if chmin > self.height: if chmin > self.height:
# we cannot shrink any more # we cannot shrink any more
raise Exception("Cannot shrink table height to %s. Minimum size is %s." % (self.height, chmin)) raise Exception("Cannot shrink table height to %s. Minimum "
"size (and/or fixed-height rows) sets minimum at %s." % (
self.height, chmin + locked_height))
# now we add all the extra height up to the desired table-height. # now we add all the extra height up to the desired table-height.
# We do this so that the tallest cells gets expanded first (and # We do this so that the tallest cells gets expanded first (and
# thus avoid getting cropped) # thus avoid getting cropped)
excess = self.height - chmin
even = self.height % 2 == 0 even = self.height % 2 == 0
for position in range(excess): correction = 0
while correction < excess:
# expand the cells with the most rows first # expand the cells with the most rows first
if 0 <= position < nrowmax and nrowmax > 1: if 0 <= correction < nrowmax and nrowmax > 1:
# avoid adding to header first round (looks bad on very small tables) # avoid adding to header first round (looks bad on very small tables)
ci = cheights[1:].index(max(cheights[1:])) + 1 ci = cheights[1:].index(max(cheights[1:])) + 1
else: else:
ci = cheights.index(max(cheights)) ci = cheights.index(max(cheights))
cheights_min[ci] += 1 if ci in locked_cols:
if ci == 0 and self.header: # locked row, make sure it's not picked again
# it doesn't look very good if header expands too fast cheights[ci] -= 9999
cheights[ci] -= 2 if even else 3 cheights_min[ci] = locked_cols[ci]
cheights[ci] -= 2 if even else 1 else:
cheights_min[ci] += 1
# change balance
if ci == 0 and self.header:
# it doesn't look very good if header expands too fast
cheights[ci] -= 2 if even else 3
cheights[ci] -= 2 if even else 1
correction += 1
cheights = cheights_min cheights = cheights_min
# we must tell cells to crop instead of expanding # we must tell cells to crop instead of expanding
@ -1554,6 +1609,8 @@ class EvTable(object):
""" """
if index > len(self.table): if index > len(self.table):
raise Exception("Not a valid column index") raise Exception("Not a valid column index")
# we update the columns' options which means eventual width/height
# will be 'locked in' and withstand auto-balancing width/height from the table later
self.table[index].options.update(kwargs) self.table[index].options.update(kwargs)
self.table[index].reformat(**kwargs) self.table[index].reformat(**kwargs)
@ -1569,6 +1626,7 @@ class EvTable(object):
def __str__(self): def __str__(self):
"""print table (this also balances it)""" """print table (this also balances it)"""
# h = "12345678901234567890123456789012345678901234567890123456789012345678901234567890"
return str(unicode(ANSIString("\n").join([line for line in self._generate_lines()]))) return str(unicode(ANSIString("\n").join([line for line in self._generate_lines()])))
def __unicode__(self): def __unicode__(self):

View file

@ -19,6 +19,8 @@ from evennia.utils.create import create_script
# to real time. # to real time.
TIMEFACTOR = settings.TIME_FACTOR TIMEFACTOR = settings.TIME_FACTOR
IGNORE_DOWNTIMES = settings.TIME_IGNORE_DOWNTIMES
# Only set if gametime_reset was called at some point. # Only set if gametime_reset was called at some point.
GAME_TIME_OFFSET = ServerConfig.objects.conf("gametime_offset", default=0) 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 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 return gtime

View file

@ -20,6 +20,7 @@ import time
from datetime import datetime from datetime import datetime
from traceback import format_exc from traceback import format_exc
from twisted.python import log, logfile from twisted.python import log, logfile
from twisted.python import util as twisted_util
from twisted.internet.threads import deferToThread from twisted.internet.threads import deferToThread
@ -29,10 +30,15 @@ _TIMEZONE = None
_CHANNEL_LOG_NUM_TAIL_LINES = None _CHANNEL_LOG_NUM_TAIL_LINES = None
# logging overrides
def timeformat(when=None): def timeformat(when=None):
""" """
This helper function will format the current time in the same 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: Args:
when (int, optional): This is a time in POSIX seconds on the form when (int, optional): This is a time in POSIX seconds on the form
@ -49,14 +55,86 @@ def timeformat(when=None):
tz_offset = tz_offset.days * 86400 + tz_offset.seconds tz_offset = tz_offset.days * 86400 + tz_offset.seconds
# correct given time to utc # correct given time to utc
when = datetime.utcfromtimestamp(when - tz_offset) 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' % ( if tz_offset == 0:
when.year, when.month, when.day, tz = ""
when.hour, when.minute, when.second, else:
tz_sign, tz_hour, tz_mins) 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):
"""
Wrapper around log.msg call to catch any exceptions that might
occur in logging. If an exception is raised, we'll print to
stdout instead.
Args:
msg: The message that was passed to log.msg
"""
try:
log.msg(msg)
except Exception:
print("Exception raised while writing message to log. Original message: %s" % msg)
def log_trace(errmsg=None): def log_trace(errmsg=None):
@ -80,9 +158,9 @@ def log_trace(errmsg=None):
except Exception as e: except Exception as e:
errmsg = str(e) errmsg = str(e)
for line in errmsg.splitlines(): for line in errmsg.splitlines():
log.msg('[EE] %s' % line) log_msg('[EE] %s' % line)
except Exception: except Exception:
log.msg('[EE] %s' % errmsg) log_msg('[EE] %s' % errmsg)
log_tracemsg = log_trace log_tracemsg = log_trace
@ -101,13 +179,27 @@ def log_err(errmsg):
except Exception as e: except Exception as e:
errmsg = str(e) errmsg = str(e)
for line in errmsg.splitlines(): for line in errmsg.splitlines():
log.msg('[EE] %s' % line) log_msg('[EE] %s' % line)
# log.err('ERROR: %s' % (errmsg,)) # log.err('ERROR: %s' % (errmsg,))
log_errmsg = log_err 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): def log_warn(warnmsg):
""" """
Prints/logs any warnings that aren't critical but should be noted. Prints/logs any warnings that aren't critical but should be noted.
@ -121,7 +213,7 @@ def log_warn(warnmsg):
except Exception as e: except Exception as e:
warnmsg = str(e) warnmsg = str(e)
for line in warnmsg.splitlines(): for line in warnmsg.splitlines():
log.msg('[WW] %s' % line) log_msg('[WW] %s' % line)
# log.msg('WARNING: %s' % (warnmsg,)) # log.msg('WARNING: %s' % (warnmsg,))
@ -139,7 +231,7 @@ def log_info(infomsg):
except Exception as e: except Exception as e:
infomsg = str(e) infomsg = str(e)
for line in infomsg.splitlines(): for line in infomsg.splitlines():
log.msg('[..] %s' % line) log_msg('[..] %s' % line)
log_infomsg = log_info log_infomsg = log_info
@ -157,7 +249,7 @@ def log_dep(depmsg):
except Exception as e: except Exception as e:
depmsg = str(e) depmsg = str(e)
for line in depmsg.splitlines(): for line in depmsg.splitlines():
log.msg('[DP] %s' % line) log_msg('[DP] %s' % line)
log_depmsg = log_dep log_depmsg = log_dep
@ -219,6 +311,8 @@ class EvenniaLogFile(logfile.LogFile):
_LOG_FILE_HANDLES = {} # holds open log handles _LOG_FILE_HANDLES = {} # holds open log handles
_LOG_FILE_HANDLE_COUNTS = {}
_LOG_FILE_HANDLE_RESET = 500
def _open_log_file(filename): def _open_log_file(filename):
@ -226,10 +320,15 @@ def _open_log_file(filename):
Helper to open the log file (always in the log dir) and cache its Helper to open the log file (always in the log dir) and cache its
handle. Will create a new file in the log dir if one didn't handle. Will create a new file in the log dir if one didn't
exist. exist.
To avoid keeping the filehandle open indefinitely we reset it every
_LOG_FILE_HANDLE_RESET accesses. This may help resolve issues for very
long uptimes and heavy log use.
""" """
# we delay import of settings to keep logger module as free # we delay import of settings to keep logger module as free
# from django as possible. # from django as possible.
global _LOG_FILE_HANDLES, _LOGDIR, _LOG_ROTATE_SIZE global _LOG_FILE_HANDLES, _LOG_FILE_HANDLE_COUNTS, _LOGDIR, _LOG_ROTATE_SIZE
if not _LOGDIR: if not _LOGDIR:
from django.conf import settings from django.conf import settings
_LOGDIR = settings.LOG_DIR _LOGDIR = settings.LOG_DIR
@ -237,16 +336,22 @@ def _open_log_file(filename):
filename = os.path.join(_LOGDIR, filename) filename = os.path.join(_LOGDIR, filename)
if filename in _LOG_FILE_HANDLES: if filename in _LOG_FILE_HANDLES:
# cache the handle _LOG_FILE_HANDLE_COUNTS[filename] += 1
return _LOG_FILE_HANDLES[filename] if _LOG_FILE_HANDLE_COUNTS[filename] > _LOG_FILE_HANDLE_RESET:
else: # close/refresh handle
try: _LOG_FILE_HANDLES[filename].close()
filehandle = EvenniaLogFile.fromFullPath(filename, rotateLength=_LOG_ROTATE_SIZE) del _LOG_FILE_HANDLES[filename]
# filehandle = open(filename, "a+") # append mode + reading else:
_LOG_FILE_HANDLES[filename] = filehandle # return cached handle
return filehandle return _LOG_FILE_HANDLES[filename]
except IOError: try:
log_trace() filehandle = EvenniaLogFile.fromFullPath(filename, rotateLength=_LOG_ROTATE_SIZE)
# filehandle = open(filename, "a+") # append mode + reading
_LOG_FILE_HANDLES[filename] = filehandle
_LOG_FILE_HANDLE_COUNTS[filename] = 0
return filehandle
except IOError:
log_trace()
return None return None

View file

@ -120,9 +120,11 @@ def dbsafe_decode(value, compress_object=False):
class PickledWidget(Textarea): class PickledWidget(Textarea):
def render(self, name, value, attrs=None): def render(self, name, value, attrs=None):
"""Display of the PickledField in django admin"""
value = repr(value) value = repr(value)
try: try:
literal_eval(value) # necessary to convert it back after repr(), otherwise validation errors will mutate it
value = literal_eval(value)
except ValueError: except ValueError:
return value return value

View file

@ -948,7 +948,7 @@ def delay(timedelay, callback, *args, **kwargs):
specified here. specified here.
Note: Note:
The task handler (`evennia.scripts.taskhandler.TASK_HANDLEr`) will The task handler (`evennia.scripts.taskhandler.TASK_HANDLER`) will
be called for persistent or non-persistent tasks. be called for persistent or non-persistent tasks.
If persistent is set to True, the callback, its arguments If persistent is set to True, the callback, its arguments
and other keyword arguments will be saved in the database, and other keyword arguments will be saved in the database,

View file

@ -8,10 +8,11 @@
--- */ --- */
/* Overall element look */ /* Overall element look */
html, body, #clientwrapper { height: 100% } html, body {
height: 100%;
width: 100%;
}
body { body {
margin: 0;
padding: 0;
background: #000; background: #000;
color: #ccc; color: #ccc;
font-size: .9em; font-size: .9em;
@ -19,6 +20,12 @@ body {
line-height: 1.6em; line-height: 1.6em;
overflow: hidden; overflow: hidden;
} }
@media screen and (max-width: 480px) {
body {
font-size: .5rem;
line-height: .7rem;
}
}
a:link, a:visited { color: inherit; } a:link, a:visited { color: inherit; }
@ -74,93 +81,109 @@ div {margin:0px;}
} }
/* Style specific classes corresponding to formatted, narative text. */ /* Style specific classes corresponding to formatted, narative text. */
.wrapper {
height: 100%;
}
/* Container surrounding entire client */ /* Container surrounding entire client */
#wrapper { #clientwrapper {
position: relative; height: 100%;
height: 100%
} }
/* Main scrolling message area */ /* Main scrolling message area */
#messagewindow { #messagewindow {
position: absolute; overflow-y: auto;
overflow: auto; overflow-x: hidden;
padding: 1em; overflow-wrap: break-word;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
top: 0;
left: 0;
right: 0;
bottom: 70px;
} }
/* Input area containing input field and button */ #messagewindow {
#inputform { overflow-y: auto;
position: absolute; overflow-x: hidden;
width: 100%; overflow-wrap: break-word;
padding: 0;
bottom: 0;
margin: 0;
padding-bottom: 10px;
border-top: 1px solid #555;
}
#inputcontrol {
width: 100%;
padding: 0;
} }
/* Input field */ /* Input field */
#inputfield, #inputsend, #inputsizer { #inputfield, #inputsizer {
display: block; height: 100%;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
height: 50px;
background: #000; background: #000;
color: #fff; color: #fff;
padding: 0 .45em; padding: 0 .45rem;
font-size: 1.1em; font-size: 1.1rem;
font-family: 'DejaVu Sans Mono', Consolas, Inconsolata, 'Lucida Console', monospace; font-family: 'DejaVu Sans Mono', Consolas, Inconsolata, 'Lucida Console', monospace;
}
#inputfield, #inputsizer {
float: left;
width: 95%;
border: 0;
resize: none; resize: none;
line-height: normal; }
#inputsend {
height: 100%;
}
#inputcontrol {
height: 100%;
} }
#inputfield:focus { #inputfield:focus {
outline: 0;
}
#inputsizer {
margin-left: -9999px;
}
/* Input 'send' button */
#inputsend {
float: right;
width: 3%;
max-width: 25px;
margin-right: 10px;
border: 0;
background: #555;
} }
/* prompt area above input field */ /* prompt area above input field */
#prompt { .prompt {
margin-top: 10px; max-height: 3rem;
padding: 0 .45em; }
#splitbutton {
width: 2rem;
font-size: 2rem;
color: #a6a6a6;
background-color: transparent;
border: 0px;
}
#splitbutton:hover {
color: white;
cursor: pointer;
}
#panebutton {
width: 2rem;
font-size: 2rem;
color: #a6a6a6;
background-color: transparent;
border: 0px;
}
#panebutton:hover {
color: white;
cursor: pointer;
}
#undobutton {
width: 2rem;
font-size: 2rem;
color: #a6a6a6;
background-color: transparent;
border: 0px;
}
#undobutton:hover {
color: white;
cursor: pointer;
}
.button {
width: fit-content;
padding: 1em;
color: black;
border: 1px solid black;
background-color: darkgray;
margin: 0 auto;
}
.splitbutton:hover {
cursor: pointer;
} }
#optionsbutton { #optionsbutton {
width: 40px; width: 2rem;
font-size: 20px; font-size: 2rem;
color: #a6a6a6; color: #a6a6a6;
background-color: transparent; background-color: transparent;
border: 0px; border: 0px;
@ -173,8 +196,8 @@ div {margin:0px;}
#toolbar { #toolbar {
position: fixed; position: fixed;
top: 0; top: .5rem;
right: 5px; right: .5rem;
z-index: 1; z-index: 1;
} }
@ -248,6 +271,52 @@ div {margin:0px;}
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
} }
.gutter.gutter-vertical {
cursor: row-resize;
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFAQMAAABo7865AAAABlBMVEVHcEzMzMzyAv2sAAAAAXRSTlMAQObYZgAAABBJREFUeF5jOAMEEAIEEFwAn3kMwcB6I2AAAAAASUVORK5CYII=')
}
.gutter.gutter-horizontal {
cursor: col-resize;
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==')
}
.split {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
overflow-y: auto;
overflow-x: hidden;
}
.split-sub {
padding: .5rem;
}
.content {
border: 1px solid #C0C0C0;
box-shadow: inset 0 1px 2px #e4e4e4;
background-color: black;
padding: 1rem;
}
@media screen and (max-width: 480px) {
.content {
padding: .5rem;
}
}
.gutter {
background-color: grey;
background-repeat: no-repeat;
background-position: 50%;
}
.split.split-horizontal, .gutter.gutter-horizontal {
height: 100%;
float: left;
}
/* XTERM256 colors */ /* XTERM256 colors */

View file

@ -0,0 +1,145 @@
// Use split.js to create a basic ui
var SplitHandler = (function () {
var split_panes = {};
var backout_list = new Array;
var set_pane_types = function(splitpane, types) {
split_panes[splitpane]['types'] = types;
}
var dynamic_split = function(splitpane, direction, pane_name1, pane_name2, update_method1, update_method2, sizes) {
// find the sub-div of the pane we are being asked to split
splitpanesub = splitpane + '-sub';
// create the new div stack to replace the sub-div with.
var first_div = $( '<div id="'+pane_name1+'" class="split split-'+direction+'" />' )
var first_sub = $( '<div id="'+pane_name1+'-sub" class="split-sub" />' )
var second_div = $( '<div id="'+pane_name2+'" class="split split-'+direction+'" />' )
var second_sub = $( '<div id="'+pane_name2+'-sub" class="split-sub" />' )
// check to see if this sub-pane contains anything
contents = $('#'+splitpanesub).contents();
if( contents ) {
// it does, so move it to the first new div-sub (TODO -- selectable between first/second?)
contents.appendTo(first_sub);
}
first_div.append( first_sub );
second_div.append( second_sub );
// update the split_panes array to remove this pane name, but store it for the backout stack
var backout_settings = split_panes[splitpane];
delete( split_panes[splitpane] );
// now vaporize the current split_N-sub placeholder and create two new panes.
$('#'+splitpane).append(first_div);
$('#'+splitpane).append(second_div);
$('#'+splitpane+'-sub').remove();
// And split
Split(['#'+pane_name1,'#'+pane_name2], {
direction: direction,
sizes: sizes,
gutterSize: 4,
minSize: [50,50],
});
// store our new split sub-divs for future splits/uses by the main UI.
split_panes[pane_name1] = { 'types': [], 'update_method': update_method1 };
split_panes[pane_name2] = { 'types': [], 'update_method': update_method2 };
// add our new split to the backout stack
backout_list.push( {'pane1': pane_name1, 'pane2': pane_name2, 'undo': backout_settings} );
}
var undo_split = function() {
// pop off the last split pair
var back = backout_list.pop();
if( !back ) {
return;
}
// Collect all the divs/subs in play
var pane1 = back['pane1'];
var pane2 = back['pane2'];
var pane1_sub = $('#'+pane1+'-sub');
var pane2_sub = $('#'+pane2+'-sub');
var pane1_parent = $('#'+pane1).parent();
var pane2_parent = $('#'+pane2).parent();
if( pane1_parent.attr('id') != pane2_parent.attr('id') ) {
// sanity check failed...somebody did something weird...bail out
console.log( pane1 );
console.log( pane2 );
console.log( pane1_parent );
console.log( pane2_parent );
return;
}
// create a new sub-pane in the panes parent
var parent_sub = $( '<div id="'+pane1_parent.attr('id')+'-sub" class="split-sub" />' )
// check to see if the special #messagewindow is in either of our sub-panes.
var msgwindow = pane1_sub.find('#messagewindow')
if( !msgwindow ) {
//didn't find it in pane 1, try pane 2
msgwindow = pane2_sub.find('#messagewindow')
}
if( msgwindow ) {
// It is, so collect all contents into it instead of our parent_sub div
// then move it to parent sub div, this allows future #messagewindow divs to flow properly
msgwindow.append( pane1_sub.contents() );
msgwindow.append( pane2_sub.contents() );
parent_sub.append( msgwindow );
} else {
//didn't find it, so move the contents of the two panes' sub-panes into the new sub-pane
parent_sub.append( pane1_sub.contents() );
parent_sub.append( pane2_sub.contents() );
}
// clear the parent
pane1_parent.empty();
// add the new sub-pane back to the parent div
pane1_parent.append(parent_sub);
// pull the sub-div's from split_panes
delete split_panes[pane1];
delete split_panes[pane2];
// add our parent pane back into the split_panes list for future splitting
split_panes[pane1_parent.attr('id')] = back['undo'];
}
var init = function(settings) {
//change Mustache tags to ruby-style (Django gets mad otherwise)
var customTags = [ '<%', '%>' ];
Mustache.tags = customTags;
var input_template = $('#input-template').html();
Mustache.parse(input_template);
Split(['#main','#input'], {
direction: 'vertical',
sizes: [90,10],
gutterSize: 4,
minSize: [50,50],
});
split_panes['main'] = { 'types': [], 'update_method': 'append' };
var input_render = Mustache.render(input_template);
$('[data-role-input]').html(input_render);
console.log("SplitHandler initialized");
}
return {
init: init,
set_pane_types: set_pane_types,
dynamic_split: dynamic_split,
split_panes: split_panes,
undo_split: undo_split,
}
})();

View file

@ -15,8 +15,13 @@
(function () { (function () {
"use strict" "use strict"
var num_splits = 0; //unique id counter for default split-panel names
var options = {}; var options = {};
var known_types = new Array();
known_types.push('help');
// //
// GUI Elements // GUI Elements
// //
@ -106,6 +111,7 @@ function togglePopup(dialogname, content) {
// Grab text from inputline and send to Evennia // Grab text from inputline and send to Evennia
function doSendText() { function doSendText() {
console.log("sending text");
if (!Evennia.isConnected()) { if (!Evennia.isConnected()) {
var reconnect = confirm("Not currently connected. Reconnect?"); var reconnect = confirm("Not currently connected. Reconnect?");
if (reconnect) { if (reconnect) {
@ -158,7 +164,11 @@ function onKeydown (event) {
var code = event.which; var code = event.which;
var history_entry = null; var history_entry = null;
var inputfield = $("#inputfield"); var inputfield = $("#inputfield");
inputfield.focus(); if (code === 9) {
return;
}
//inputfield.focus();
if (code === 13) { // Enter key sends text if (code === 13) { // Enter key sends text
doSendText(); doSendText();
@ -175,8 +185,11 @@ function onKeydown (event) {
} }
if (code === 27) { // Escape key if (code === 27) { // Escape key
closePopup("#optionsdialog"); if ($('#helpdialog').is(':visible')) {
closePopup("#helpdialog"); closePopup("#helpdialog");
} else {
closePopup("#optionsdialog");
}
} }
if (history_entry !== null) { if (history_entry !== null) {
@ -202,74 +215,68 @@ function onKeyPress (event) {
} }
var resizeInputField = function () { var resizeInputField = function () {
var min_height = 50; return function() {
var max_height = 300; var wrapper = $("#inputform")
var prev_text_len = 0; var input = $("#inputcontrol")
var prompt = $("#prompt")
// Check to see if we should change the height of the input area input.height(wrapper.height() - (input.offset().top - wrapper.offset().top));
return function () {
var inputfield = $("#inputfield");
var scrollh = inputfield.prop("scrollHeight");
var clienth = inputfield.prop("clientHeight");
var newh = 0;
var curr_text_len = inputfield.val().length;
if (scrollh > clienth && scrollh <= max_height) {
// Need to make it bigger
newh = scrollh;
}
else if (curr_text_len < prev_text_len) {
// There is less text in the field; try to make it smaller
// To avoid repaints, we draw the text in an offscreen element and
// determine its dimensions.
var sizer = $('#inputsizer')
.css("width", inputfield.prop("clientWidth"))
.text(inputfield.val());
newh = sizer.prop("scrollHeight");
}
if (newh != 0) {
newh = Math.min(newh, max_height);
if (clienth != newh) {
inputfield.css("height", newh + "px");
doWindowResize();
}
}
prev_text_len = curr_text_len;
} }
}(); }();
// Handle resizing of client // Handle resizing of client
function doWindowResize() { function doWindowResize() {
var formh = $('#inputform').outerHeight(true); resizeInputField();
var message_scrollh = $("#messagewindow").prop("scrollHeight"); var resizable = $("[data-update-append]");
$("#messagewindow") var parents = resizable.closest(".split")
.css({"bottom": formh}) // leave space for the input form parents.animate({
.scrollTop(message_scrollh); // keep the output window scrolled to the bottom scrollTop: parents.prop("scrollHeight")
}, 0);
} }
// Handle text coming from the server // Handle text coming from the server
function onText(args, kwargs) { function onText(args, kwargs) {
// append message to previous ones, then scroll so latest is at var use_default_pane = true;
// the bottom. Send 'cls' kwarg to modify the output class.
var renderto = "main"; if ( kwargs && 'type' in kwargs ) {
if (kwargs["type"] == "help") { var msgtype = kwargs['type'];
if (("helppopup" in options) && (options["helppopup"])) { if ( ! known_types.includes(msgtype) ) {
renderto = "#helpdialog"; // this is a new output type that can be mapped to panes
console.log('detected new output type: ' + msgtype)
known_types.push(msgtype);
}
// pass this message to each pane that has this msgtype mapped
if( SplitHandler ) {
for ( var key in SplitHandler.split_panes) {
var pane = SplitHandler.split_panes[key];
// is this message type mapped to this pane?
if ( (pane['types'].length > 0) && pane['types'].includes(msgtype) ) {
// yes, so append/replace this pane's inner div with this message
var text_div = $('#'+key+'-sub');
if ( pane['update_method'] == 'replace' ) {
text_div.html(args[0])
} else {
text_div.append(args[0]);
var scrollHeight = text_div.parent().prop("scrollHeight");
text_div.parent().animate({ scrollTop: scrollHeight }, 0);
}
// record sending this message to a pane, no need to update the default div
use_default_pane = false;
}
}
} }
} }
if (renderto == "main") { // append message to default pane, then scroll so latest is at the bottom.
if(use_default_pane) {
var mwin = $("#messagewindow"); var mwin = $("#messagewindow");
var cls = kwargs == null ? 'out' : kwargs['cls']; var cls = kwargs == null ? 'out' : kwargs['cls'];
mwin.append("<div class='" + cls + "'>" + args[0] + "</div>"); mwin.append("<div class='" + cls + "'>" + args[0] + "</div>");
mwin.animate({ var scrollHeight = mwin.parent().parent().prop("scrollHeight");
scrollTop: document.getElementById("messagewindow").scrollHeight mwin.parent().parent().animate({ scrollTop: scrollHeight }, 0);
}, 0);
onNewLine(args[0], null); onNewLine(args[0], null);
} else {
openPopup(renderto, args[0]);
} }
} }
@ -427,6 +434,105 @@ function doStartDragDialog(event) {
$(document).bind("mouseup", undrag); $(document).bind("mouseup", undrag);
} }
function onSplitDialogClose() {
var pane = $("input[name=pane]:checked").attr("value");
var direction = $("input[name=direction]:checked").attr("value");
var new_pane1 = $("input[name=new_pane1]").val();
var new_pane2 = $("input[name=new_pane2]").val();
var flow1 = $("input[name=flow1]:checked").attr("value");
var flow2 = $("input[name=flow2]:checked").attr("value");
if( new_pane1 == "" ) {
new_pane1 = 'pane_'+num_splits;
num_splits++;
}
if( new_pane2 == "" ) {
new_pane2 = 'pane_'+num_splits;
num_splits++;
}
if( document.getElementById(new_pane1) ) {
alert('An element: "' + new_pane1 + '" already exists');
return;
}
if( document.getElementById(new_pane2) ) {
alert('An element: "' + new_pane2 + '" already exists');
return;
}
SplitHandler.dynamic_split( pane, direction, new_pane1, new_pane2, flow1, flow2, [50,50] );
closePopup("#splitdialog");
}
function onSplitDialog() {
var dialog = $("#splitdialogcontent");
dialog.empty();
dialog.append("<h3>Split?</h3>");
dialog.append('<input type="radio" name="direction" value="vertical" checked> top/bottom<br />');
dialog.append('<input type="radio" name="direction" value="horizontal"> side-by-side<br />');
dialog.append("<h3>Split Which Pane?</h3>");
for ( var pane in SplitHandler.split_panes ) {
dialog.append('<input type="radio" name="pane" value="'+ pane +'">'+ pane +'<br />');
}
dialog.append("<h3>New Pane Names</h3>");
dialog.append('<input type="text" name="new_pane1" value="" />');
dialog.append('<input type="text" name="new_pane2" value="" />');
dialog.append("<h3>New First Pane</h3>");
dialog.append('<input type="radio" name="flow1" value="append" checked>append new incoming messages<br />');
dialog.append('<input type="radio" name="flow1" value="replace">replace old messages with new ones<br />');
dialog.append("<h3>New Second Pane</h3>");
dialog.append('<input type="radio" name="flow2" value="append" checked>append new incoming messages<br />');
dialog.append('<input type="radio" name="flow2" value="replace">replace old messages with new ones<br />');
dialog.append('<div id="splitclose" class="button">Split It</div>');
$("#splitclose").bind("click", onSplitDialogClose);
togglePopup("#splitdialog");
}
function onPaneControlDialogClose() {
var pane = $("input[name=pane]:checked").attr("value");
var types = new Array;
$('#splitdialogcontent input[type=checkbox]:checked').each(function() {
types.push( $(this).attr('value') );
});
SplitHandler.set_pane_types( pane, types );
closePopup("#splitdialog");
}
function onPaneControlDialog() {
var dialog = $("#splitdialogcontent");
dialog.empty();
dialog.append("<h3>Set Which Pane?</h3>");
for ( var pane in SplitHandler.split_panes ) {
dialog.append('<input type="radio" name="pane" value="'+ pane +'">'+ pane +'<br />');
}
dialog.append("<h3>Which content types?</h3>");
for ( var type in known_types ) {
dialog.append('<input type="checkbox" value="'+ known_types[type] +'">'+ known_types[type] +'<br />');
}
dialog.append('<div id="paneclose" class="button">Make It So</div>');
$("#paneclose").bind("click", onPaneControlDialogClose);
togglePopup("#splitdialog");
}
// //
// Register Events // Register Events
// //
@ -434,6 +540,18 @@ function doStartDragDialog(event) {
// Event when client finishes loading // Event when client finishes loading
$(document).ready(function() { $(document).ready(function() {
if( SplitHandler ) {
SplitHandler.init();
$("#splitbutton").bind("click", onSplitDialog);
$("#panebutton").bind("click", onPaneControlDialog);
$("#undobutton").bind("click", SplitHandler.undo_split);
$("#optionsbutton").hide();
} else {
$("#splitbutton").hide();
$("#panebutton").hide();
$("#undobutton").hide();
}
if ("Notification" in window) { if ("Notification" in window) {
Notification.requestPermission(); Notification.requestPermission();
} }
@ -450,7 +568,7 @@ $(document).ready(function() {
//$(document).on("visibilitychange", onVisibilityChange); //$(document).on("visibilitychange", onVisibilityChange);
$("#inputfield").bind("resize", doWindowResize) $("[data-role-input]").bind("resize", doWindowResize)
.keypress(onKeyPress) .keypress(onKeyPress)
.bind("paste", resizeInputField) .bind("paste", resizeInputField)
.bind("cut", resizeInputField); .bind("cut", resizeInputField);
@ -503,6 +621,7 @@ $(document).ready(function() {
}, },
60000*3 60000*3
); );
console.log("Completed GUI setup");
}); });

View file

@ -13,6 +13,10 @@ JQuery available.
<meta http-equiv="content-type", content="application/xhtml+xml; charset=UTF-8" /> <meta http-equiv="content-type", content="application/xhtml+xml; charset=UTF-8" />
<meta name="author" content="Evennia" /> <meta name="author" content="Evennia" />
<meta name="generator" content="Evennia" /> <meta name="generator" content="Evennia" />
<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">
<link rel='stylesheet' type="text/css" media="screen" href={% static "webclient/css/webclient.css" %}> <link rel='stylesheet' type="text/css" media="screen" href={% static "webclient/css/webclient.css" %}>
@ -20,15 +24,23 @@ JQuery available.
<!-- Import JQuery and warn if there is a problem --> <!-- Import JQuery and warn if there is a problem -->
{% block jquery_import %} {% block jquery_import %}
<script src="https://code.jquery.com/jquery-2.1.1.min.js" type="text/javascript" charset="utf-8"></script> <script src="https://code.jquery.com/jquery-3.2.1.min.js" type="text/javascript" charset="utf-8"></script>
{% endblock %} {% endblock %}
<script type="text/javascript" charset="utf-8"> <script type="text/javascript" charset="utf-8">
if(!window.jQuery) { if(!window.jQuery) {
document.write("<div class='err'>jQuery library not found or the online version could not be reached.</div>"); 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> </script>
<!-- This is will only fire if javascript is actually active -->
<script language="javascript" type="text/javascript">
$(document).ready(function() {
$('#noscript').remove();
$('#clientwrapper').removeClass('d-none');
})
</script>
<!-- Set up Websocket url and load the evennia.js library--> <!-- Set up Websocket url and load the evennia.js library-->
<script language="javascript" type="text/javascript"> <script language="javascript" type="text/javascript">
{% if websocket_enabled %} {% if websocket_enabled %}
@ -51,13 +63,29 @@ JQuery available.
</script> </script>
<script src={% static "webclient/js/evennia.js" %} language="javascript" type="text/javascript" charset="utf-8"/></script> <script src={% static "webclient/js/evennia.js" %} language="javascript" type="text/javascript" charset="utf-8"/></script>
<!-- set up splits before loading the GUI -->
<script src="https://unpkg.com/split.js/split.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mustache.js/2.3.0/mustache.min.js"></script>
<script src={% static "webclient/js/splithandler.js" %} language="javascript"></script>
<!-- Load gui library --> <!-- Load gui library -->
{% block guilib_import %} {% block guilib_import %}
<script src={% static "webclient/js/webclient_gui.js" %} language="javascript" type="text/javascript" charset="utf-8"></script> <script src={% static "webclient/js/webclient_gui.js" %} language="javascript" type="text/javascript" charset="utf-8"></script>
{% endblock %} {% endblock %}
<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> <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>
<script type="text/javascript" charset="utf-8">
if(!window.Favico) {
document.write("<div class='err'>Favico.js library not found or the online version could not be reached. Check so Javascript is not blocked in your browser.</div>");
}
</script>
<!-- jQuery first, then Tether, then Bootstrap JS. -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.11.0/umd/popper.min.js" integrity="sha384-b/U6ypiBEHpOf/4+1nzFpr53nxSS+GLCkfwBdFNTxtclqqenISfwAzpKaMNFNmj4" crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/js/bootstrap.min.js" integrity="sha384-h0AbiXch4ZDo7tp9hKZ4TsHbi047NrKGLO3SEJAg45jXxnGIfYzk4Si90RDIqNm1" crossorigin="anonymous"></script>
{% block scripts %}
{% endblock %}
</head> </head>
<body> <body>
@ -80,10 +108,9 @@ JQuery available.
</div> </div>
<!-- main client --> <!-- main client -->
<div id=clientwrapper> <div id=clientwrapper class="d-none">
{% block client %} {% block client %}
{% endblock %} {% endblock %}
</div> </div>
</body> </body>
</html> </html>

View file

@ -8,20 +8,29 @@
{% block client %} {% block client %}
<div id="toolbar">
<button id="optionsbutton" type="button" aria-haspopup="true" aria-owns="#optionsdialog">&#x2699;<span class="sr-only sr-only-focusable">Settings</span></button>
<button id="splitbutton" type="button" aria-haspopup="true" aria-owns="#optionsdialog">&#x21f9;<span class="sr-only sr-only-focusable">Splits</span></button>
<button id="panebutton" type="button" aria-haspopup="true" aria-owns="#optionsdialog">&#x2699;<span class="sr-only sr-only-focusable">Splits</span></button>
<button id="undobutton" type="button" aria-haspopup="true" aria-owns="#optionsdialog">&#x21B6;<span class="sr-only sr-only-focusable">Splits</span></button>
</div>
<div id="wrapper"> <!-- The "Main" Content -->
<div id="toolbar"> <div id="main" class="split split-vertical" data-role-default>
<button id="optionsbutton" type="button" class="hidden">&#x2699;</button> <div id="main-sub" class="split-sub">
</div> <div id="messagewindow"></div>
<div id="messagewindow" role="log"></div> </div>
<div id="inputform"> </div>
<div id="prompt"></div> <!-- The "Input" Pane -->
<div id="inputcontrol"> <div id="input" class="split split-vertical" data-role-input data-update-append></div>
<textarea id="inputfield" type="text"></textarea>
<input id="inputsend" type="button" value="&gt;"/> <!-- Basic UI Components -->
<div id="splitdialog" class="dialog">
<div class="dialogtitle">Split Pane<span class="dialogclose">&times;</span></div>
<div class="dialogcontentparent">
<div id="splitdialogcontent" class="dialogcontent">
</div> </div>
</div> </div>
<div id="inputsizer"></div>
</div> </div>
<div id="optionsdialog" class="dialog"> <div id="optionsdialog" class="dialog">
@ -47,4 +56,29 @@
</div> </div>
</div> </div>
<script type="text/html" id="split-template">
<div class="split content<%#horizontal%> split-horizontal<%/horizontal%>" id='<%id%>'>
</div>
</script>
<script type="text/html" id="output-template">
<div id="<%id%>" role="log" data-role-output data-update-append data-tags='[<%#tags%>"<%.%>", <%/tags%>]'></div>
</script>
<script type="text/html" id="input-template">
<div id="inputform" class="wrapper">
<div id="prompt" class="prompt">
</div>
<div id="inputcontrol" class="input-group">
<textarea id="inputfield" type="text" class="form-control"></textarea>
<span class="input-group-btn">
<button class="btn btn-large btn-outline-primary" id="inputsend" type="button" value="">&gt;</button>
</span>
</div>
</div>
</script>
{% endblock %}
{% block scripts %}
{% endblock %} {% endblock %}

View file

@ -34,7 +34,13 @@ def _shared_login(request):
if webclient_uid: if webclient_uid:
# The webclient has previously registered a login to this browser_session # The webclient has previously registered a login to this browser_session
if not account.is_authenticated() and not website_uid: if not account.is_authenticated() and not website_uid:
account = AccountDB.objects.get(id=webclient_uid) try:
account = AccountDB.objects.get(id=webclient_uid)
except AccountDB.DoesNotExist:
# this can happen e.g. for guest accounts or deletions
csession["website_authenticated_uid"] = False
csession["webclient_authenticated_uid"] = False
return
try: try:
# calls our custom authenticate in web/utils/backends.py # calls our custom authenticate in web/utils/backends.py
account = authenticate(autologin=account) account = authenticate(autologin=account)

View file

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

View file

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