From 280ffacc2d4b2eaa9834184fbe30024f76069d98 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 10 Aug 2017 23:36:56 +0200 Subject: [PATCH 001/189] First, non-working version of a get_by_tag manager method that accepts multiple tags --- evennia/typeclasses/managers.py | 80 ++++++++++++++++++++++++++++----- 1 file changed, 68 insertions(+), 12 deletions(-) diff --git a/evennia/typeclasses/managers.py b/evennia/typeclasses/managers.py index b6c486b166..7fff4f2a43 100644 --- a/evennia/typeclasses/managers.py +++ b/evennia/typeclasses/managers.py @@ -181,6 +181,7 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): # Tag manager methods + def get_tag(self, key=None, category=None, obj=None, tagtype=None, global_search=False): """ Return Tag objects by key, by category, by object (it is @@ -256,28 +257,83 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): """ return self.get_tag(key=key, category=category, obj=obj, tagtype="alias") +# @returns_typeclass_list +# def get_by_tag(self, key=None, category=None, tagtype=None): +# """ +# Return objects having tags with a given key or category or +# combination of the two. +# +# Args: +# key (str, optional): Tag key. Not case sensitive. +# category (str, optional): Tag category. Not case sensitive. +# tagtype (str or None, optional): 'type' of Tag, by default +# this is either `None` (a normal Tag), `alias` or +# `permission`. +# Returns: +# objects (list): Objects with matching tag. +# """ +# dbmodel = self.model.__dbclass__.__name__.lower() +# query = [("db_tags__db_tagtype", tagtype), ("db_tags__db_model", dbmodel)] +# if key: +# query.append(("db_tags__db_key", key.lower())) +# if category: +# query.append(("db_tags__db_category", category.lower())) +# return self.filter(**dict(query)) + @returns_typeclass_list def get_by_tag(self, key=None, category=None, tagtype=None): """ - Return objects having tags with a given key or category or - combination of the two. + Return objects having tags with a given key or category or combination of the two. + Also accepts multiple tags/category/tagtype Args: - key (str, optional): Tag key. Not case sensitive. - category (str, optional): Tag category. Not case sensitive. - tagtype (str or None, optional): 'type' of Tag, by default + key (str or list, optional): Tag key or list of keys. Not case sensitive. + category (str or list, optional): Tag category. Not case sensitive. If `key` is + a list, a single category can either apply to all keys in that list or this + must be a list matching the `key` list element by element. + tagtype (str, optional): 'type' of Tag, by default this is either `None` (a normal Tag), `alias` or - `permission`. + `permission`. This always apply to all queried tags. + Returns: objects (list): Objects with matching tag. + + Raises: + IndexError: If `key` and `category` are both lists and `category` is shorter + than `key`. + """ + keys = make_iter(key) + categories = make_iter(category) + n_keys = len(keys) + n_categories = len(categories) + dbmodel = self.model.__dbclass__.__name__.lower() - query = [("db_tags__db_tagtype", tagtype), ("db_tags__db_model", dbmodel)] - if key: - query.append(("db_tags__db_key", key.lower())) - if category: - query.append(("db_tags__db_category", category.lower())) - return self.filter(**dict(query)) + if n_keys > 1: + if n_categories == 1: + category = categories[0] + query = Q(db_tags__db_tagtype=tagtype.lower() if tagtype else tagtype, + db_tags__db_category=category.lower() if category else category, + db_tags__db_model=dbmodel) + for key in keys: + query = query & Q(db_tags__db_key=key.lower()) + print "Query:", query + else: + query = Q(db_tags__db_tagtype=tagtype.lower(), + db_tags__db_model=dbmodel) + for ikey, key in keys: + category = categories[ikey] + category = category.lower() if category else category + query = query & Q(db_tags__db_key=key.lower(), + db_tags__db_category=category) + return self.filter(query) + else: + query = [("db_tags__db_tagtype", tagtype), ("db_tags__db_model", dbmodel)] + if key: + query.append(("db_tags__db_key", keys[0].lower())) + if category: + query.append(("db_tags__db_category", categories[0].lower())) + return self.filter(**dict(query)) def get_by_permission(self, key=None, category=None): """ From 6f56ba71ce06f69ff947d08a2c4bbd4640f033ea Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 23 Sep 2017 13:55:01 +0200 Subject: [PATCH 002/189] Make internal/external ports clearer in wake of port changes --- evennia/server/portal/portal.py | 26 ++++++++++++-------------- evennia/server/server.py | 4 ++-- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index c5c9740ef9..fa4008a58a 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -192,7 +192,7 @@ if AMP_ENABLED: from evennia.server import amp - print(' amp (to Server): %s' % AMP_PORT) + print(' amp (to Server): %s (internal)' % AMP_PORT) factory = amp.AmpClientFactory(PORTAL) amp_client = internet.TCPClient(AMP_HOST, AMP_PORT, factory) @@ -223,7 +223,7 @@ if TELNET_ENABLED: telnet_service.setName('EvenniaTelnet%s' % pstring) PORTAL.services.addService(telnet_service) - print(' telnet%s: %s' % (ifacestr, port)) + print(' telnet%s: %s (external)' % (ifacestr, port)) if SSL_ENABLED: @@ -249,7 +249,7 @@ if SSL_ENABLED: ssl_service.setName('EvenniaSSL%s' % pstring) PORTAL.services.addService(ssl_service) - print(" ssl%s: %s" % (ifacestr, port)) + print(" ssl%s: %s (external)" % (ifacestr, port)) if SSH_ENABLED: @@ -273,7 +273,7 @@ if SSH_ENABLED: ssh_service.setName('EvenniaSSH%s' % pstring) PORTAL.services.addService(ssh_service) - print(" ssh%s: %s" % (ifacestr, port)) + print(" ssh%s: %s (external)" % (ifacestr, port)) if WEBSERVER_ENABLED: @@ -287,7 +287,6 @@ if WEBSERVER_ENABLED: if interface not in ('0.0.0.0', '::') or len(WEBSERVER_INTERFACES) > 1: ifacestr = "-%s" % interface for proxyport, serverport in WEBSERVER_PORTS: - pstring = "%s:%s<->%s" % (ifacestr, proxyport, serverport) web_root = EvenniaReverseProxyResource('127.0.0.1', serverport, '') webclientstr = "" if WEBCLIENT_ENABLED: @@ -305,21 +304,20 @@ if WEBSERVER_ENABLED: from evennia.server.portal import webclient from evennia.utils.txws import WebSocketFactory - interface = WEBSOCKET_CLIENT_INTERFACE + w_interface = WEBSOCKET_CLIENT_INTERFACE + w_ifacestr = '' + if w_interface not in ('0.0.0.0', '::') or len(WEBSERVER_INTERFACES) > 1: + w_ifacestr = "-%s" % interface port = WEBSOCKET_CLIENT_PORT - ifacestr = "" - if interface not in ('0.0.0.0', '::'): - ifacestr = "-%s" % interface - pstring = "%s:%s" % (ifacestr, port) factory = protocol.ServerFactory() factory.noisy = False factory.protocol = webclient.WebSocketClient factory.sessionhandler = PORTAL_SESSIONS - websocket_service = internet.TCPServer(port, WebSocketFactory(factory), interface=interface) - websocket_service.setName('EvenniaWebSocket%s' % pstring) + websocket_service = internet.TCPServer(port, WebSocketFactory(factory), interface=w_interface) + websocket_service.setName('EvenniaWebSocket%s:%s' % (w_ifacestr, proxyport)) PORTAL.services.addService(websocket_service) websocket_started = True - webclientstr = "\n + webclient%s" % pstring + webclientstr = "\n + webclient-websocket%s: %s (external)" % (w_ifacestr, proxyport) web_root = Website(web_root, logPath=settings.HTTP_LOG_FILE) proxy_service = internet.TCPServer(proxyport, @@ -327,7 +325,7 @@ if WEBSERVER_ENABLED: interface=interface) proxy_service.setName('EvenniaWebProxy%s' % pstring) PORTAL.services.addService(proxy_service) - print(" webproxy%s:%s (<-> %s)%s" % (ifacestr, proxyport, serverport, webclientstr)) + print(" website-proxy%s: %s (external) %s" % (ifacestr, proxyport, webclientstr)) for plugin_module in PORTAL_SERVICES_PLUGIN_MODULES: diff --git a/evennia/server/server.py b/evennia/server/server.py index 27ca1fd0f8..fb9f753b5c 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -546,7 +546,7 @@ if AMP_ENABLED: ifacestr = "" if AMP_INTERFACE != '127.0.0.1': ifacestr = "-%s" % AMP_INTERFACE - print(' amp (to Portal)%s: %s' % (ifacestr, AMP_PORT)) + print(' amp (to Portal)%s: %s (internal)' % (ifacestr, AMP_PORT)) from evennia.server import amp @@ -586,7 +586,7 @@ if WEBSERVER_ENABLED: webserver.setName('EvenniaWebServer%s' % serverport) EVENNIA.services.addService(webserver) - print(" webserver: %s" % serverport) + print(" webserver: %s (internal)" % serverport) ENABLED = [] if IRC_ENABLED: From f75860b1039de4b505dcf6cc78cb50883876bef6 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 23 Sep 2017 14:26:37 +0200 Subject: [PATCH 003/189] Update github contribute instructions for new branch layout --- .github/CONTRIBUTING.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 353140b4d7..a4d41c6bbf 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -19,6 +19,10 @@ happens to be named "command"! A PR allows you to request that your custom fixes/additions/changes be pulled into the main Evennia repository. To make a PR you must first [fork Evennia on GitHub][8]. Read the [Contribution][3] page for more help. +If you are working to solve an Issue in the issue tracker, note which branch you should make the PR +against (`master` or `develop`). If you are making a PR for a new feature or contrib, do so against +the `develop' branch. + - All contributions should abide by Evennia's [style guide](https://github.com/evennia/evennia/blob/master/CODING_STYLE.md). - For your own sanity and ours, separate unrelated contributions into their own branches and make a new PR for each. You can still update the branch after the PR is up - the PR will update automatically. From 18e55847050f97bee2c4649168d461705323c440 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 1 Oct 2017 09:43:33 +0200 Subject: [PATCH 004/189] Fix unittest for mail contrib update --- evennia/contrib/mail.py | 10 +++++----- evennia/contrib/tests.py | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/evennia/contrib/mail.py b/evennia/contrib/mail.py index 48369083d1..6e8585136d 100644 --- a/evennia/contrib/mail.py +++ b/evennia/contrib/mail.py @@ -133,7 +133,7 @@ class CmdMail(default_cmds.MuxCommand): return else: all_mail = self.get_all_mail() - mind_max = all_mail.count() - 1 + mind_max = max(0, all_mail.count() - 1) mind = max(0, min(mind_max, int(self.lhs) - 1)) if all_mail[mind]: all_mail[mind].delete() @@ -154,7 +154,7 @@ class CmdMail(default_cmds.MuxCommand): return else: all_mail = self.get_all_mail() - mind_max = all_mail.count() - 1 + mind_max = max(0, all_mail.count() - 1) if "/" in self.rhs: message_number, message = self.rhs.split("/", 1) mind = max(0, min(mind_max, int(message_number) - 1)) @@ -193,7 +193,7 @@ class CmdMail(default_cmds.MuxCommand): return else: all_mail = self.get_all_mail() - mind_max = all_mail.count() - 1 + mind_max = max(0, all_mail.count() - 1) mind = max(0, min(mind_max, int(self.lhs) - 1)) if all_mail[mind]: old_message = all_mail[mind] @@ -218,9 +218,9 @@ class CmdMail(default_cmds.MuxCommand): self.send_mail(self.search_targets(self.lhslist), subject, body, self.caller) else: all_mail = self.get_all_mail() - mind_max = all_mail.count() - 1 + mind_max = max(0, all_mail.count() - 1) try: - mind = max(0, min(mind_max, self.lhs - 1)) + mind = max(0, min(mind_max, int(self.lhs) - 1)) message = all_mail[mind] except (ValueError, IndexError): self.caller.msg("'%s' is not a valid mail id." % self.lhs) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 2342c4d17a..1678d06567 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -661,6 +661,7 @@ from evennia.contrib import mail class TestMail(CommandTest): def test_mail(self): self.call(mail.CmdMail(), "2", "'2' is not a valid mail id.", caller=self.account) + self.call(mail.CmdMail(), "test", "'test' is not a valid mail id.") self.call(mail.CmdMail(), "", "There are no messages in your inbox.", caller=self.account) self.call(mail.CmdMail(), "Char=Message 1", "You have received a new @mail from Char|You sent your message.", caller=self.char1) self.call(mail.CmdMail(), "Char=Message 2", "You sent your message.", caller=self.char2) From c3ce2ebcd7c134df9c02c202d90159777693d2cc Mon Sep 17 00:00:00 2001 From: Scyfris Talivinsky Date: Sat, 30 Sep 2017 18:18:54 -0700 Subject: [PATCH 005/189] Fix locationless spawned objects Spawned objects were not getting locations assigned to them. By default the locations should be assigned to the caller's location. --- evennia/commands/default/building.py | 2 +- evennia/utils/spawner.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 61a46bde30..9170f3c9ff 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2718,7 +2718,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): self.caller.msg("The prototype must be a prototype key or a Python dictionary.") return - if "noloc" in self.switches and not "location" not in prototype: + if "noloc" not in self.switches and "location" not in prototype: prototype["location"] = self.caller.location for obj in spawn(prototype): diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 423fc8225b..4a8ac946c8 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -100,7 +100,7 @@ _CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") def _handle_dbref(inp): - dbid_to_obj(inp, ObjectDB) + return dbid_to_obj(inp, ObjectDB) def _validate_prototype(key, prototype, protparents, visited): From dd8e136cfc7db7e6d24c5c84562152ec34fd539a Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Tue, 26 Sep 2017 15:01:52 -0700 Subject: [PATCH 006/189] Added at_before hooks to CmdGet, CmdGive, CmdDrop Added in references to at_before hooks for the get, give, and drop commands. If these hooks return 'False' or 'None', the action is canceled. --- evennia/commands/default/general.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index c5075b8744..481a1df993 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -266,6 +266,10 @@ class CmdGet(COMMAND_DEFAULT_CLASS): else: caller.msg("You can't get that.") return + + # calling at_before_get hook method + if not obj.at_before_get(caller): + return obj.move_to(caller, quiet=True) caller.msg("You pick up %s." % obj.name) @@ -273,7 +277,7 @@ class CmdGet(COMMAND_DEFAULT_CLASS): (caller.name, obj.name), exclude=caller) - # calling hook method + # calling at_get hook method obj.at_get(caller) @@ -307,6 +311,10 @@ class CmdDrop(COMMAND_DEFAULT_CLASS): multimatch_string="You carry more than one %s:" % self.args) if not obj: return + + # Call the object script's at_before_drop() method. + if not obj.at_before_drop(caller): + return obj.move_to(caller.location, quiet=True) caller.msg("You drop %s." % (obj.name,)) @@ -350,6 +358,11 @@ class CmdGive(COMMAND_DEFAULT_CLASS): if not to_give.location == caller: caller.msg("You are not holding %s." % to_give.key) return + + # calling at_before_give hook method + if not to_give.at_before_give(caller, target): + return + # give object caller.msg("You give %s to %s." % (to_give.key, target.key)) to_give.move_to(target, quiet=True) From 5ab670f3c8c0cac0dfd81d468ce6416b365e36ed Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Tue, 26 Sep 2017 15:04:04 -0700 Subject: [PATCH 007/189] Added at_before_give, at_before_get, and at_before_drop Added in new hooks to the default object - at_before_give, at_before_get, and at_before_drop. By default, these hooks do nothing but return True - however, if overloaded, they can be used to execute code before an object is given, gotten, or dropped, and even prevent the action if made to return None or False. --- evennia/objects/objects.py | 66 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 4479da8af7..29658cabab 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -1492,6 +1492,25 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): """ pass + def at_before_get(self, getter, **kwargs): + """ + Called by the default `get` command before this object has been + picked up. + + Args: + getter (Object): The object about to get this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + shouldget (bool): If the object should be gotten or not. + + Notes: + If this method returns False/None, the getting is cancelled + before it is even started. + """ + return True + def at_get(self, getter, **kwargs): """ Called by the default `get` command when this object has been @@ -1504,11 +1523,32 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): Notes: This hook cannot stop the pickup from happening. Use - permissions for that. + permissions or the at_before_get() hook for that. """ pass + def at_before_give(self, giver, getter, **kwargs): + """ + Called by the default `give` command before this object has been + given. + + Args: + giver (Object): The object about to give this object. + getter (Object): The object about to get this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + shouldgive (bool): If the object should be given or not. + + Notes: + If this method returns False/None, the giving is cancelled + before it is even started. + + """ + return True + def at_give(self, giver, getter, **kwargs): """ Called by the default `give` command when this object has been @@ -1522,11 +1562,31 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): Notes: This hook cannot stop the give from happening. Use - permissions for that. + permissions or the at_before_give() hook for that. """ pass + def at_before_drop(self, dropper, **kwargs): + """ + Called by the default `drop` command before this object has been + dropped. + + Args: + dropper (Object): The object which will drop this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + shoulddrop (bool): If the object should be dropped or not. + + Notes: + If this method returns False/None, the dropping is cancelled + before it is even started. + + """ + return True + def at_drop(self, dropper, **kwargs): """ Called by the default `drop` command when this object has been @@ -1539,7 +1599,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): Notes: This hook cannot stop the drop from happening. Use - permissions from that. + permissions or the at_before_drop() hook for that. """ pass From d2e8badf18c92c064d0d66d671b54109000d3b9a Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 1 Oct 2017 16:51:41 +0200 Subject: [PATCH 008/189] Stop #dbref searches for unprivileged. Other minor fixes. Resolves #1251. --- evennia/commands/default/account.py | 2 +- evennia/commands/default/general.py | 10 +++++----- evennia/objects/objects.py | 25 +++++++++++++++---------- evennia/typeclasses/models.py | 4 ++++ 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index 01b7066fe3..e45e2a9347 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -824,7 +824,7 @@ class CmdQuell(COMMAND_DEFAULT_CLASS): """Perform the command""" account = self.account permstr = account.is_superuser and " (superuser)" or "(%s)" % (", ".join(account.permissions.all())) - if self.cmdstring == '@unquell': + if self.cmdstring in ('unquell', '@unquell'): if not account.attributes.get('_quell'): self.msg("Already using normal Account permissions %s." % permstr) else: diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index 481a1df993..2f880f3f7f 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -67,7 +67,7 @@ class CmdLook(COMMAND_DEFAULT_CLASS): caller.msg("You have no location to look at!") return else: - target = caller.search(self.args, use_dbref=caller.check_permstring("Builders")) + target = caller.search(self.args) if not target: return self.msg(caller.at_look(target)) @@ -266,7 +266,7 @@ class CmdGet(COMMAND_DEFAULT_CLASS): else: caller.msg("You can't get that.") return - + # calling at_before_get hook method if not obj.at_before_get(caller): return @@ -311,7 +311,7 @@ class CmdDrop(COMMAND_DEFAULT_CLASS): multimatch_string="You carry more than one %s:" % self.args) if not obj: return - + # Call the object script's at_before_drop() method. if not obj.at_before_drop(caller): return @@ -358,11 +358,11 @@ class CmdGive(COMMAND_DEFAULT_CLASS): if not to_give.location == caller: caller.msg("You are not holding %s." % to_give.key) return - + # calling at_before_give hook method if not to_give.at_before_give(caller, target): return - + # give object caller.msg("You give %s to %s." % (to_give.key, target.key)) to_give.move_to(target, quiet=True) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 29658cabab..e0fd0dace4 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -292,7 +292,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): candidates=None, nofound_string=None, multimatch_string=None, - use_dbref=True): + use_dbref=None): """ Returns an Object matching a search string/condition @@ -343,8 +343,9 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): caller's contents (inventory). nofound_string (str): optional custom string for not-found error message. multimatch_string (str): optional custom string for multimatch error header. - use_dbref (bool, optional): if False, treat a given #dbref strings as a - normal string rather than database ids. + use_dbref (bool or None, optional): if True/False, active/deactivate the use of + #dbref as valid global search arguments. If None, check against a permission + ('Builder' by default). Returns: match (Object, None or list): will return an Object/None if `quiet=False`, @@ -360,6 +361,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): """ is_string = isinstance(searchdata, basestring) + if is_string: # searchdata is a string; wrap some common self-references if searchdata.lower() in ("here", ): @@ -367,6 +369,9 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): if searchdata.lower() in ("me", "self",): return [self] if quiet else self + if use_dbref is None: + use_dbref = self.locks.check_lockstring(self, "_dummy:perm(Builder)") + if use_nicks: # do nick-replacement on search searchdata = self.nicks.nickreplace(searchdata, categories=("object", "account"), include_account=True) @@ -1495,7 +1500,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): def at_before_get(self, getter, **kwargs): """ Called by the default `get` command before this object has been - picked up. + picked up. Args: getter (Object): The object about to get this object. @@ -1509,8 +1514,8 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): If this method returns False/None, the getting is cancelled before it is even started. """ - return True - + return True + def at_get(self, getter, **kwargs): """ Called by the default `get` command when this object has been @@ -1545,10 +1550,10 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): Notes: If this method returns False/None, the giving is cancelled before it is even started. - + """ - return True - + return True + def at_give(self, giver, getter, **kwargs): """ Called by the default `give` command when this object has been @@ -1586,7 +1591,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): """ return True - + def at_drop(self, dropper, **kwargs): """ Called by the default `drop` command when this object has been diff --git a/evennia/typeclasses/models.py b/evennia/typeclasses/models.py index 8a20ba9148..c156aa2ac6 100644 --- a/evennia/typeclasses/models.py +++ b/evennia/typeclasses/models.py @@ -575,6 +575,10 @@ class TypedObject(SharedMemoryModel): ppos = _PERMISSION_HIERARCHY.index(perm) return any(True for hpos, hperm in enumerate(_PERMISSION_HIERARCHY) if hperm in perms and hpos > ppos) + # we ignore pluralization (english only) + if perm.endswith("s"): + return self.check_permstring(perm[:-1]) + return False # From 9620ddb2e08975a5f55bc8f5bf25e43a67532b65 Mon Sep 17 00:00:00 2001 From: Edwin Sutanto Date: Sun, 8 Oct 2017 10:02:29 +0700 Subject: [PATCH 009/189] Fix ImportError for SSH client Added instruction to also install pyasn1. --- evennia/server/portal/ssh.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/server/portal/ssh.py b/evennia/server/portal/ssh.py index e993e74bc3..7dde511637 100644 --- a/evennia/server/portal/ssh.py +++ b/evennia/server/portal/ssh.py @@ -20,9 +20,9 @@ from twisted.conch.interfaces import IConchUser _SSH_IMPORT_ERROR = """ ERROR: Missing crypto library for SSH. Install it with - pip install cryptography + pip install cryptography pyasn1 -(On older Twisted versions you may have to do 'pip install pycrypto pyasn1 instead). +(On older Twisted versions you may have to do 'pip install pycrypto pyasn1' instead). If you get a compilation error you must install a C compiler and the SSL dev headers (On Debian-derived systems this is the gcc and libssl-dev From 5cb1ce5b6e60d967b94f0f87e479de73e1b4da1e Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 8 Oct 2017 20:04:00 -0700 Subject: [PATCH 010/189] Move turnbattle.py to turnbattle/tb_basic.py Moves the basic turnbattle module to a new module file in a new 'turnbattle' subfolder. Also fixes a minor bug where the first character in the turn order was not being initialized properly at the start of a fight. --- evennia/contrib/turnbattle/tb_basic.py | 738 +++++++++++++++++++++++++ 1 file changed, 738 insertions(+) create mode 100644 evennia/contrib/turnbattle/tb_basic.py diff --git a/evennia/contrib/turnbattle/tb_basic.py b/evennia/contrib/turnbattle/tb_basic.py new file mode 100644 index 0000000000..f1ff33a483 --- /dev/null +++ b/evennia/contrib/turnbattle/tb_basic.py @@ -0,0 +1,738 @@ +""" +Simple turn-based combat system + +Contrib - Tim Ashley Jenkins 2017 + +This is a framework for a simple turn-based combat system, similar +to those used in D&D-style tabletop role playing games. It allows +any character to start a fight in a room, at which point initiative +is rolled and a turn order is established. Each participant in combat +has a limited time to decide their action for that turn (30 seconds by +default), and combat progresses through the turn order, looping through +the participants until the fight ends. + +Only simple rolls for attacking are implemented here, but this system +is easily extensible and can be used as the foundation for implementing +the rules from your turn-based tabletop game of choice or making your +own battle system. + +To install and test, import this module's BattleCharacter object into +your game's character.py module: + + from evennia.contrib.turnbattle.tb_basic import BattleCharacter + +And change your game's character typeclass to inherit from BattleCharacter +instead of the default: + + class Character(BattleCharacter): + +Next, import this module into your default_cmdsets.py module: + + from evennia.contrib.turnbattle import tb_basic + +And add the battle command set to your default command set: + + # + # any commands you add below will overload the default ones. + # + self.add(tb_basic.BattleCmdSet()) + +This module is meant to be heavily expanded on, so you may want to copy it +to your game's 'world' folder and modify it there rather than importing it +in your game and using it as-is. +""" + +from random import randint +from evennia import DefaultCharacter, Command, default_cmds, DefaultScript +from evennia.commands.default.help import CmdHelp + +""" +---------------------------------------------------------------------------- +COMBAT FUNCTIONS START HERE +---------------------------------------------------------------------------- +""" + + +def roll_init(character): + """ + Rolls a number between 1-1000 to determine initiative. + + Args: + character (obj): The character to determine initiative for + + Returns: + initiative (int): The character's place in initiative - higher + numbers go first. + + Notes: + By default, does not reference the character and simply returns + a random integer from 1 to 1000. + + Since the character is passed to this function, you can easily reference + a character's stats to determine an initiative roll - for example, if your + character has a 'dexterity' attribute, you can use it to give that character + an advantage in turn order, like so: + + return (randint(1,20)) + character.db.dexterity + + This way, characters with a higher dexterity will go first more often. + """ + return randint(1, 1000) + + +def get_attack(attacker, defender): + """ + Returns a value for an attack roll. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Returns: + attack_value (int): Attack roll value, compared against a defense value + to determine whether an attack hits or misses. + + Notes: + By default, returns a random integer from 1 to 100 without using any + properties from either the attacker or defender. + + This can easily be expanded to return a value based on characters stats, + equipment, and abilities. This is why the attacker and defender are passed + to this function, even though nothing from either one are used in this example. + """ + # For this example, just return a random integer up to 100. + attack_value = randint(1, 100) + return attack_value + + +def get_defense(attacker, defender): + """ + Returns a value for defense, which an attack roll must equal or exceed in order + for an attack to hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Returns: + defense_value (int): Defense value, compared against an attack roll + to determine whether an attack hits or misses. + + Notes: + By default, returns 50, not taking any properties of the defender or + attacker into account. + + As above, this can be expanded upon based on character stats and equipment. + """ + # For this example, just return 50, for about a 50/50 chance of hit. + defense_value = 50 + return defense_value + + +def get_damage(attacker, defender): + """ + Returns a value for damage to be deducted from the defender's HP after abilities + successful hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being damaged + + Returns: + damage_value (int): Damage value, which is to be deducted from the defending + character's HP. + + Notes: + By default, returns a random integer from 15 to 25 without using any + properties from either the attacker or defender. + + Again, this can be expanded upon. + """ + # For this example, just generate a number between 15 and 25. + damage_value = randint(15, 25) + return damage_value + + +def apply_damage(defender, damage): + """ + Applies damage to a target, reducing their HP by the damage amount to a + minimum of 0. + + Args: + defender (obj): Character taking damage + damage (int): Amount of damage being taken + """ + defender.db.hp -= damage # Reduce defender's HP by the damage dealt. + # If this reduces it to 0 or less, set HP to 0. + if defender.db.hp <= 0: + defender.db.hp = 0 + + +def resolve_attack(attacker, defender, attack_value=None, defense_value=None): + """ + Resolves an attack and outputs the result. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Notes: + Even though the attack and defense values are calculated + extremely simply, they are separated out into their own functions + so that they are easier to expand upon. + """ + # Get an attack roll from the attacker. + if not attack_value: + attack_value = get_attack(attacker, defender) + # Get a defense value from the defender. + if not defense_value: + defense_value = get_defense(attacker, defender) + # If the attack value is lower than the defense value, miss. Otherwise, hit. + if attack_value < defense_value: + attacker.location.msg_contents("%s's attack misses %s!" % (attacker, defender)) + else: + damage_value = get_damage(attacker, defender) # Calculate damage value. + # Announce damage dealt and apply damage. + attacker.location.msg_contents("%s hits %s for %i damage!" % (attacker, defender, damage_value)) + apply_damage(defender, damage_value) + # If defender HP is reduced to 0 or less, announce defeat. + if defender.db.hp <= 0: + attacker.location.msg_contents("%s has been defeated!" % defender) + + +def combat_cleanup(character): + """ + Cleans up all the temporary combat-related attributes on a character. + + Args: + character (obj): Character to have their combat attributes removed + + Notes: + Any attribute whose key begins with 'combat_' is temporary and no + longer needed once a fight ends. + """ + for attr in character.attributes.all(): + if attr.key[:7] == "combat_": # If the attribute name starts with 'combat_'... + character.attributes.remove(key=attr.key) # ...then delete it! + + +def is_in_combat(character): + """ + Returns true if the given character is in combat. + + Args: + character (obj): Character to determine if is in combat or not + + Returns: + (bool): True if in combat or False if not in combat + """ + if character.db.Combat_TurnHandler: + return True + return False + + +def is_turn(character): + """ + Returns true if it's currently the given character's turn in combat. + + Args: + character (obj): Character to determine if it is their turn or not + + Returns: + (bool): True if it is their turn or False otherwise + """ + turnhandler = character.db.Combat_TurnHandler + currentchar = turnhandler.db.fighters[turnhandler.db.turn] + if character == currentchar: + return True + return False + + +def spend_action(character, actions, action_name=None): + """ + Spends a character's available combat actions and checks for end of turn. + + Args: + character (obj): Character spending the action + actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions + + Kwargs: + action_name (str or None): If a string is given, sets character's last action in + combat to provided string + """ + if action_name: + character.db.Combat_LastAction = action_name + if actions == 'all': # If spending all actions + character.db.Combat_ActionsLeft = 0 # Set actions to 0 + else: + character.db.Combat_ActionsLeft -= actions # Use up actions. + if character.db.Combat_ActionsLeft < 0: + character.db.Combat_ActionsLeft = 0 # Can't have fewer than 0 actions + character.db.Combat_TurnHandler.turn_end_check(character) # Signal potential end of turn. + + +""" +---------------------------------------------------------------------------- +CHARACTER TYPECLASS +---------------------------------------------------------------------------- +""" + + +class BattleCharacter(DefaultCharacter): + """ + A character able to participate in turn-based combat. Has attributes for current + and maximum HP, and access to combat commands. + """ + + def at_object_creation(self): + """ + Called once, when this object is first created. This is the + normal hook to overload for most object types. + """ + self.db.max_hp = 100 # Set maximum HP to 100 + self.db.hp = self.db.max_hp # Set current HP to maximum + """ + Adds attributes for a character's current and maximum HP. + We're just going to set this value at '100' by default. + + You may want to expand this to include various 'stats' that + can be changed at creation and factor into combat calculations. + """ + + def at_before_move(self, destination): + """ + Called just before starting to move this object to + destination. + + Args: + destination (Object): The object we are moving to + + Returns: + shouldmove (bool): If we should move or not. + + Notes: + If this method returns False/None, the move is cancelled + before it is even started. + + """ + # Keep the character from moving if at 0 HP or in combat. + if is_in_combat(self): + self.msg("You can't exit a room while in combat!") + return False # Returning false keeps the character from moving. + if self.db.HP <= 0: + self.msg("You can't move, you've been defeated!") + return False + return True + + +""" +---------------------------------------------------------------------------- +COMMANDS START HERE +---------------------------------------------------------------------------- +""" + + +class CmdFight(Command): + """ + Starts a fight with everyone in the same room as you. + + Usage: + fight + + When you start a fight, everyone in the room who is able to + fight is added to combat, and a turn order is randomly rolled. + When it's your turn, you can attack other characters. + """ + key = "fight" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + here = self.caller.location + fighters = [] + + if not self.caller.db.hp: # If you don't have any hp + self.caller.msg("You can't start a fight if you've been defeated!") + return + if is_in_combat(self.caller): # Already in a fight + self.caller.msg("You're already in a fight!") + return + for thing in here.contents: # Test everything in the room to add it to the fight. + if thing.db.HP: # If the object has HP... + fighters.append(thing) # ...then add it to the fight. + if len(fighters) <= 1: # If you're the only able fighter in the room + self.caller.msg("There's nobody here to fight!") + return + if here.db.Combat_TurnHandler: # If there's already a fight going on... + here.msg_contents("%s joins the fight!" % self.caller) + here.db.Combat_TurnHandler.join_fight(self.caller) # Join the fight! + return + here.msg_contents("%s starts a fight!" % self.caller) + # Add a turn handler script to the room, which starts combat. + here.scripts.add("contrib.turnbattle.tb_basic.TurnHandler") + # Remember you'll have to change the path to the script if you copy this code to your own modules! + + +class CmdAttack(Command): + """ + Attacks another character. + + Usage: + attack + + When in a fight, you may attack another character. The attack has + a chance to hit, and if successful, will deal damage. + """ + + key = "attack" + help_category = "combat" + + def func(self): + "This performs the actual command." + "Set the attacker to the caller and the defender to the target." + + if not is_in_combat(self.caller): # If not in combat, can't attack. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn, can't attack. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't attack if you have no HP. + self.caller.msg("You can't attack, you've been defeated.") + return + + attacker = self.caller + defender = self.caller.search(self.args) + + if not defender: # No valid target given. + return + + if not defender.db.hp: # Target object has no HP left or to begin with + self.caller.msg("You can't fight that!") + return + + if attacker == defender: # Target and attacker are the same + self.caller.msg("You can't attack yourself!") + return + + "If everything checks out, call the attack resolving function." + resolve_attack(attacker, defender) + spend_action(self.caller, 1, action_name="attack") # Use up one action. + + +class CmdPass(Command): + """ + Passes on your turn. + + Usage: + pass + + When in a fight, you can use this command to end your turn early, even + if there are still any actions you can take. + """ + + key = "pass" + aliases = ["wait", "hold"] + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + if not is_in_combat(self.caller): # Can only pass a turn in combat. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # Can only pass if it's your turn. + self.caller.msg("You can only do that on your turn.") + return + + self.caller.location.msg_contents("%s takes no further action, passing the turn." % self.caller) + spend_action(self.caller, 'all', action_name="pass") # Spend all remaining actions. + + +class CmdDisengage(Command): + """ + Passes your turn and attempts to end combat. + + Usage: + disengage + + Ends your turn early and signals that you're trying to end + the fight. If all participants in a fight disengage, the + fight ends. + """ + + key = "disengage" + aliases = ["spare"] + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + if not is_in_combat(self.caller): # If you're not in combat + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn + self.caller.msg("You can only do that on your turn.") + return + + self.caller.location.msg_contents("%s disengages, ready to stop fighting." % self.caller) + spend_action(self.caller, 'all', action_name="disengage") # Spend all remaining actions. + """ + The action_name kwarg sets the character's last action to "disengage", which is checked by + the turn handler script to see if all fighters have disengaged. + """ + + +class CmdRest(Command): + """ + Recovers damage. + + Usage: + rest + + Resting recovers your HP to its maximum, but you can only + rest if you're not in a fight. + """ + + key = "rest" + help_category = "combat" + + def func(self): + "This performs the actual command." + + if is_in_combat(self.caller): # If you're in combat + self.caller.msg("You can't rest while you're in combat.") + return + + self.caller.db.hp = self.caller.db.max_hp # Set current HP to maximum + self.caller.location.msg_contents("%s rests to recover HP." % self.caller) + """ + You'll probably want to replace this with your own system for recovering HP. + """ + + +class CmdCombatHelp(CmdHelp): + """ + View help or a list of topics + + Usage: + help + help list + help all + + This will search for help on commands and other + topics related to the game. + """ + # Just like the default help command, but will give quick + # tips on combat when used in a fight with no arguments. + + def func(self): + if is_in_combat(self.caller) and not self.args: # In combat and entered 'help' alone + self.caller.msg("Available combat commands:|/" + + "|wAttack:|n Attack a target, attempting to deal damage.|/" + + "|wPass:|n Pass your turn without further action.|/" + + "|wDisengage:|n End your turn and attempt to end combat.|/") + else: + super(CmdCombatHelp, self).func() # Call the default help command + + +class BattleCmdSet(default_cmds.CharacterCmdSet): + """ + This command set includes all the commmands used in the battle system. + """ + key = "DefaultCharacter" + + def at_cmdset_creation(self): + """ + Populates the cmdset + """ + self.add(CmdFight()) + self.add(CmdAttack()) + self.add(CmdRest()) + self.add(CmdPass()) + self.add(CmdDisengage()) + self.add(CmdCombatHelp()) + + +""" +---------------------------------------------------------------------------- +SCRIPTS START HERE +---------------------------------------------------------------------------- +""" + + +class TurnHandler(DefaultScript): + """ + This is the script that handles the progression of combat through turns. + On creation (when a fight is started) it adds all combat-ready characters + to its roster and then sorts them into a turn order. There can only be one + fight going on in a single room at a time, so the script is assigned to a + room as its object. + + Fights persist until only one participant is left with any HP or all + remaining participants choose to end the combat with the 'disengage' command. + """ + + def at_script_creation(self): + """ + Called once, when the script is created. + """ + self.key = "Combat Turn Handler" + self.interval = 5 # Once every 5 seconds + self.persistent = True + self.db.fighters = [] + + # Add all fighters in the room with at least 1 HP to the combat." + for object in self.obj.contents: + if object.db.hp: + self.db.fighters.append(object) + + # Initialize each fighter for combat + for fighter in self.db.fighters: + self.initialize_for_combat(fighter) + + # Add a reference to this script to the room + self.obj.db.Combat_TurnHandler = self + + # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order. + # The initiative roll is determined by the roll_init function and can be customized easily. + ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True) + self.db.fighters = ordered_by_roll + + # Announce the turn order. + self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters)) + + # Start first fighter's turn. + self.start_turn(self.db.fighters[0]) + + # Set up the current turn and turn timeout delay. + self.db.turn = 0 + self.db.timer = 30 # 30 seconds + + def at_stop(self): + """ + Called at script termination. + """ + for fighter in self.db.fighters: + combat_cleanup(fighter) # Clean up the combat attributes for every fighter. + self.obj.db.Combat_TurnHandler = None # Remove reference to turn handler in location + + def at_repeat(self): + """ + Called once every self.interval seconds. + """ + currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order. + self.db.timer -= self.interval # Count down the timer. + + if self.db.timer <= 0: + # Force current character to disengage if timer runs out. + self.obj.msg_contents("%s's turn timed out!" % currentchar) + spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions. + return + elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left + # Warn the current character if they're about to time out. + currentchar.msg("WARNING: About to time out!") + self.db.timeout_warning_given = True + + def initialize_for_combat(self, character): + """ + Prepares a character for combat when starting or entering a fight. + + Args: + character (obj): Character to initialize for combat. + """ + combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. + character.db.Combat_ActionsLeft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + character.db.Combat_TurnHandler = self # Add a reference to this turn handler script to the character + character.db.Combat_LastAction = "null" # Track last action taken in combat + + def start_turn(self, character): + """ + Readies a character for the start of their turn by replenishing their + available actions and notifying them that their turn has come up. + + Args: + character (obj): Character to be readied. + + Notes: + Here, you only get one action per turn, but you might want to allow more than + one per turn, or even grant a number of actions based on a character's + attributes. You can even add multiple different kinds of actions, I.E. actions + separated for movement, by adding "character.db.Combat_MovesLeft = 3" or + something similar. + """ + character.db.Combat_ActionsLeft = 1 # 1 action per turn. + # Prompt the character for their turn and give some information. + character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp) + + def next_turn(self): + """ + Advances to the next character in the turn order. + """ + + # Check to see if every character disengaged as their last action. If so, end combat. + disengage_check = True + for fighter in self.db.fighters: + if fighter.db.Combat_LastAction != "disengage": # If a character has done anything but disengage + disengage_check = False + if disengage_check: # All characters have disengaged + self.obj.msg_contents("All fighters have disengaged! Combat is over!") + self.stop() # Stop this script and end combat. + return + + # Check to see if only one character is left standing. If so, end combat. + defeated_characters = 0 + for fighter in self.db.fighters: + if fighter.db.HP == 0: + defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated) + if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated + for fighter in self.db.fighters: + if fighter.db.HP != 0: + LastStanding = fighter # Pick the one fighter left with HP remaining + self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding) + self.stop() # Stop this script and end combat. + return + + # Cycle to the next turn. + currentchar = self.db.fighters[self.db.turn] + self.db.turn += 1 # Go to the next in the turn order. + if self.db.turn > len(self.db.fighters) - 1: + self.db.turn = 0 # Go back to the first in the turn order once you reach the end. + newchar = self.db.fighters[self.db.turn] # Note the new character + self.db.timer = 30 + self.time_until_next_repeat() # Reset the timer. + self.db.timeout_warning_given = False # Reset the timeout warning. + self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) + self.start_turn(newchar) # Start the new character's turn. + + def turn_end_check(self, character): + """ + Tests to see if a character's turn is over, and cycles to the next turn if it is. + + Args: + character (obj): Character to test for end of turn + """ + if not character.db.Combat_ActionsLeft: # Character has no actions remaining + self.next_turn() + return + + def join_fight(self, character): + """ + Adds a new character to a fight already in progress. + + Args: + character (obj): Character to be added to the fight. + """ + # Inserts the fighter to the turn order, right behind whoever's turn it currently is. + self.db.fighters.insert(self.db.turn, character) + # Tick the turn counter forward one to compensate. + self.db.turn += 1 + # Initialize the character like you do at the start. + self.initialize_for_combat(character) From 83791e619a52f1fc2e6d8f90d6b2c4fa401d87dc Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 8 Oct 2017 20:05:53 -0700 Subject: [PATCH 011/189] Add 'tb_equip', weapon & armor system for 'turnbattle' Adds a new module, 'tb_equip', an implementation of the 'turnbattle' system that includes weapons and armor, which can be wielded and donned to modify attack damage and accuracy. --- evennia/contrib/turnbattle/tb_equip.py | 1067 ++++++++++++++++++++++++ 1 file changed, 1067 insertions(+) create mode 100644 evennia/contrib/turnbattle/tb_equip.py diff --git a/evennia/contrib/turnbattle/tb_equip.py b/evennia/contrib/turnbattle/tb_equip.py new file mode 100644 index 0000000000..702d02520e --- /dev/null +++ b/evennia/contrib/turnbattle/tb_equip.py @@ -0,0 +1,1067 @@ +""" +Simple turn-based combat system with equipment + +Contrib - Tim Ashley Jenkins 2017 + +This is a version of the 'turnbattle' contrib with a basic system for +weapons and armor implemented. Weapons can have unique damage ranges +and accuracy modifiers, while armor can reduce incoming damage and +change one's chance of getting hit. The 'wield' command is used to +equip weapons and the 'don' command is used to equip armor. + +Some prototypes are included at the end of this module - feel free to +copy them into your game's prototypes.py module in your 'world' folder +and create them with the @spawn command. (See the tutorial for using +the @spawn command for details.) + +For the example equipment given, heavier weapons deal more damage +but are less accurate, while light weapons are more accurate but +deal less damage. Similarly, heavy armor reduces incoming damage by +a lot but increases your chance of getting hit, while light armor is +easier to dodge in but reduces incoming damage less. Light weapons are +more effective against lightly armored opponents and heavy weapons are +more damaging against heavily armored foes, but heavy weapons and armor +are slightly better than light weapons and armor overall. + +This is a fairly bare implementation of equipment that is meant to be +expanded to fit your game - weapon and armor slots, damage types and +damage bonuses, etc. should be fairly simple to implement according to +the rules of your preferred system or the needs of your own game. + +To install and test, import this module's BattleCharacter object into +your game's character.py module: + + from evennia.contrib.turnbattle.tb_equip import BattleCharacter + +And change your game's character typeclass to inherit from BattleCharacter +instead of the default: + + class Character(BattleCharacter): + +Next, import this module into your default_cmdsets.py module: + + from evennia.contrib.turnbattle import tb_equip + +And add the battle command set to your default command set: + + # + # any commands you add below will overload the default ones. + # + self.add(tb_equip.BattleCmdSet()) + +This module is meant to be heavily expanded on, so you may want to copy it +to your game's 'world' folder and modify it there rather than importing it +in your game and using it as-is. +""" + +from random import randint +from evennia import DefaultCharacter, Command, default_cmds, DefaultScript, DefaultObject +from evennia.commands.default.help import CmdHelp + +""" +---------------------------------------------------------------------------- +COMBAT FUNCTIONS START HERE +---------------------------------------------------------------------------- +""" + + +def roll_init(character): + """ + Rolls a number between 1-1000 to determine initiative. + + Args: + character (obj): The character to determine initiative for + + Returns: + initiative (int): The character's place in initiative - higher + numbers go first. + + Notes: + By default, does not reference the character and simply returns + a random integer from 1 to 1000. + + Since the character is passed to this function, you can easily reference + a character's stats to determine an initiative roll - for example, if your + character has a 'dexterity' attribute, you can use it to give that character + an advantage in turn order, like so: + + return (randint(1,20)) + character.db.dexterity + + This way, characters with a higher dexterity will go first more often. + """ + return randint(1, 1000) + + +def get_attack(attacker, defender): + """ + Returns a value for an attack roll. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Returns: + attack_value (int): Attack roll value, compared against a defense value + to determine whether an attack hits or misses. + + Notes: + In this example, a weapon's accuracy bonus is factored into the attack + roll. Lighter weapons are more accurate but less damaging, and heavier + weapons are less accurate but deal more damage. Of course, you can + change this paradigm completely in your own game. + """ + # Start with a roll from 1 to 100. + attack_value = randint(1, 100) + accuracy_bonus = 0 + # If armed, add weapon's accuracy bonus. + if attacker.db.wielded_weapon: + weapon = attacker.db.wielded_weapon + accuracy_bonus += weapon.db.accuracy_bonus + # If unarmed, use character's unarmed accuracy bonus. + else: + accuracy_bonus += attacker.db.unarmed_accuracy + # Add the accuracy bonus to the attack roll. + attack_value += accuracy_bonus + return attack_value + + +def get_defense(attacker, defender): + """ + Returns a value for defense, which an attack roll must equal or exceed in order + for an attack to hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Returns: + defense_value (int): Defense value, compared against an attack roll + to determine whether an attack hits or misses. + + Notes: + Characters are given a default defense value of 50 which can be + modified up or down by armor. In this example, wearing armor actually + makes you a little easier to hit, but reduces incoming damage. + """ + # Start with a defense value of 50 for a 50/50 chance to hit. + defense_value = 50 + # Modify this value based on defender's armor. + if defender.db.worn_armor: + armor = defender.db.worn_armor + defense_value += armor.db.defense_modifier + return defense_value + + +def get_damage(attacker, defender): + """ + Returns a value for damage to be deducted from the defender's HP after abilities + successful hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being damaged + + Returns: + damage_value (int): Damage value, which is to be deducted from the defending + character's HP. + + Notes: + Damage is determined by the attacker's wielded weapon, or the attacker's + unarmed damage range if no weapon is wielded. Incoming damage is reduced + by the defender's armor. + """ + damage_value = 0 + # Generate a damage value from wielded weapon if armed + if attacker.db.wielded_weapon: + weapon = attacker.db.wielded_weapon + # Roll between minimum and maximum damage + damage_value = randint(weapon.db.damage_range[0], weapon.db.damage_range[1]) + # Use attacker's unarmed damage otherwise + else: + damage_value = randint(attacker.db.unarmed_damage_range[0], attacker.db.unarmed_damage_range[1]) + # If defender is armored, reduce incoming damage + if defender.db.worn_armor: + armor = defender.db.worn_armor + damage_value -= armor.db.damage_reduction + # Make sure minimum damage is 0 + if damage_value < 0: + damage_value = 0 + return damage_value + + +def apply_damage(defender, damage): + """ + Applies damage to a target, reducing their HP by the damage amount to a + minimum of 0. + + Args: + defender (obj): Character taking damage + damage (int): Amount of damage being taken + """ + defender.db.hp -= damage # Reduce defender's HP by the damage dealt. + # If this reduces it to 0 or less, set HP to 0. + if defender.db.hp <= 0: + defender.db.hp = 0 + + +def resolve_attack(attacker, defender, attack_value=None, defense_value=None): + """ + Resolves an attack and outputs the result. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Notes: + Even though the attack and defense values are calculated + extremely simply, they are separated out into their own functions + so that they are easier to expand upon. + """ + # Get the attacker's weapon type to reference in combat messages. + attackers_weapon = "attack" + if attacker.db.wielded_weapon: + weapon = attacker.db.wielded_weapon + attackers_weapon = weapon.db.weapon_type_name + # Get an attack roll from the attacker. + if not attack_value: + attack_value = get_attack(attacker, defender) + # Get a defense value from the defender. + if not defense_value: + defense_value = get_defense(attacker, defender) + # If the attack value is lower than the defense value, miss. Otherwise, hit. + if attack_value < defense_value: + attacker.location.msg_contents("%s's %s misses %s!" % (attacker, attackers_weapon, defender)) + else: + damage_value = get_damage(attacker, defender) # Calculate damage value. + # Announce damage dealt and apply damage. + if damage_value > 0: + attacker.location.msg_contents("%s's %s strikes %s for %i damage!" % (attacker, attackers_weapon, defender, damage_value)) + else: + attacker.location.msg_contents("%s's %s bounces harmlessly off %s!" % (attacker, attackers_weapon, defender)) + apply_damage(defender, damage_value) + # If defender HP is reduced to 0 or less, announce defeat. + if defender.db.hp <= 0: + attacker.location.msg_contents("%s has been defeated!" % defender) + + +def combat_cleanup(character): + """ + Cleans up all the temporary combat-related attributes on a character. + + Args: + character (obj): Character to have their combat attributes removed + + Notes: + Any attribute whose key begins with 'combat_' is temporary and no + longer needed once a fight ends. + """ + for attr in character.attributes.all(): + if attr.key[:7] == "combat_": # If the attribute name starts with 'combat_'... + character.attributes.remove(key=attr.key) # ...then delete it! + + +def is_in_combat(character): + """ + Returns true if the given character is in combat. + + Args: + character (obj): Character to determine if is in combat or not + + Returns: + (bool): True if in combat or False if not in combat + """ + if character.db.Combat_TurnHandler: + return True + return False + + +def is_turn(character): + """ + Returns true if it's currently the given character's turn in combat. + + Args: + character (obj): Character to determine if it is their turn or not + + Returns: + (bool): True if it is their turn or False otherwise + """ + turnhandler = character.db.Combat_TurnHandler + currentchar = turnhandler.db.fighters[turnhandler.db.turn] + if character == currentchar: + return True + return False + + +def spend_action(character, actions, action_name=None): + """ + Spends a character's available combat actions and checks for end of turn. + + Args: + character (obj): Character spending the action + actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions + + Kwargs: + action_name (str or None): If a string is given, sets character's last action in + combat to provided string + """ + if action_name: + character.db.Combat_LastAction = action_name + if actions == 'all': # If spending all actions + character.db.Combat_ActionsLeft = 0 # Set actions to 0 + else: + character.db.Combat_ActionsLeft -= actions # Use up actions. + if character.db.Combat_ActionsLeft < 0: + character.db.Combat_ActionsLeft = 0 # Can't have fewer than 0 actions + character.db.Combat_TurnHandler.turn_end_check(character) # Signal potential end of turn. + + +""" +---------------------------------------------------------------------------- +TYPECLASSES START HERE +---------------------------------------------------------------------------- +""" + +class TB_Weapon(DefaultObject): + """ + A weapon which can be wielded in combat with the 'wield' command. + """ + def at_object_creation(self): + """ + Called once, when this object is first created. This is the + normal hook to overload for most object types. + """ + self.db.damage_range = (15, 25) # Minimum and maximum damage on hit + self.db.accuracy_bonus = 0 # Bonus to attack rolls (or penalty if negative) + self.db.weapon_type_name = "weapon" # Single word for weapon - I.E. "dagger", "staff", "scimitar" + def at_drop(self, dropper): + """ + Stop being wielded if dropped. + """ + if dropper.db.wielded_weapon == self: + dropper.db.wielded_weapon = None + dropper.location.msg_contents("%s stops wielding %s." % (dropper, self)) + def at_give(self, giver, getter): + """ + Stop being wielded if given. + """ + if giver.db.wielded_weapon == self: + giver.db.wielded_weapon = None + giver.location.msg_contents("%s stops wielding %s." % (giver, self)) + +class TB_Armor(DefaultObject): + """ + A set of armor which can be worn with the 'don' command. + """ + def at_object_creation(self): + """ + Called once, when this object is first created. This is the + normal hook to overload for most object types. + """ + self.db.damage_reduction = 4 # Amount of incoming damage reduced by armor + self.db.defense_modifier = -4 # Amount to modify defense value (pos = harder to hit, neg = easier) + def at_before_drop(self, dropper): + """ + Can't drop in combat. + """ + if is_in_combat(dropper): + dropper.msg("You can't doff armor in a fight!") + return False + return True + def at_drop(self, dropper): + """ + Stop being wielded if dropped. + """ + if dropper.db.worn_armor == self: + dropper.db.worn_armor = None + dropper.location.msg_contents("%s removes %s." % (dropper, self)) + def at_before_give(self, giver, getter): + """ + Can't give away in combat. + """ + if is_in_combat(giver): + dropper.msg("You can't doff armor in a fight!") + return False + return True + def at_give(self, giver, getter): + """ + Stop being wielded if given. + """ + if giver.db.worn_armor == self: + giver.db.worn_armor = None + giver.location.msg_contents("%s removes %s." % (giver, self)) + +class BattleCharacter(DefaultCharacter): + """ + A character able to participate in turn-based combat. Has attributes for current + and maximum HP, and access to combat commands. + """ + + def at_object_creation(self): + """ + Called once, when this object is first created. This is the + normal hook to overload for most object types. + """ + self.db.max_hp = 100 # Set maximum HP to 100 + self.db.hp = self.db.max_hp # Set current HP to maximum + self.db.wielded_weapon = None # Currently used weapon + self.db.worn_armor = None # Currently worn armor + self.db.unarmed_damage_range = (5, 15) # Minimum and maximum unarmed damage + self.db.unarmed_accuracy = 30 # Accuracy bonus for unarmed attacks + + """ + Adds attributes for a character's current and maximum HP. + We're just going to set this value at '100' by default. + + You may want to expand this to include various 'stats' that + can be changed at creation and factor into combat calculations. + """ + + def at_before_move(self, destination): + """ + Called just before starting to move this object to + destination. + + Args: + destination (Object): The object we are moving to + + Returns: + shouldmove (bool): If we should move or not. + + Notes: + If this method returns False/None, the move is cancelled + before it is even started. + + """ + # Keep the character from moving if at 0 HP or in combat. + if is_in_combat(self): + self.msg("You can't exit a room while in combat!") + return False # Returning false keeps the character from moving. + if self.db.HP <= 0: + self.msg("You can't move, you've been defeated!") + return False + return True + + +""" +---------------------------------------------------------------------------- +COMMANDS START HERE +---------------------------------------------------------------------------- +""" + + +class CmdFight(Command): + """ + Starts a fight with everyone in the same room as you. + + Usage: + fight + + When you start a fight, everyone in the room who is able to + fight is added to combat, and a turn order is randomly rolled. + When it's your turn, you can attack other characters. + """ + key = "fight" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + here = self.caller.location + fighters = [] + + if not self.caller.db.hp: # If you don't have any hp + self.caller.msg("You can't start a fight if you've been defeated!") + return + if is_in_combat(self.caller): # Already in a fight + self.caller.msg("You're already in a fight!") + return + for thing in here.contents: # Test everything in the room to add it to the fight. + if thing.db.HP: # If the object has HP... + fighters.append(thing) # ...then add it to the fight. + if len(fighters) <= 1: # If you're the only able fighter in the room + self.caller.msg("There's nobody here to fight!") + return + if here.db.Combat_TurnHandler: # If there's already a fight going on... + here.msg_contents("%s joins the fight!" % self.caller) + here.db.Combat_TurnHandler.join_fight(self.caller) # Join the fight! + return + here.msg_contents("%s starts a fight!" % self.caller) + # Add a turn handler script to the room, which starts combat. + here.scripts.add("contrib.turnbattle_equip.TurnHandler") + # Remember you'll have to change the path to the script if you copy this code to your own modules! + + +class CmdAttack(Command): + """ + Attacks another character. + + Usage: + attack + + When in a fight, you may attack another character. The attack has + a chance to hit, and if successful, will deal damage. + """ + + key = "attack" + help_category = "combat" + + def func(self): + "This performs the actual command." + "Set the attacker to the caller and the defender to the target." + + if not is_in_combat(self.caller): # If not in combat, can't attack. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn, can't attack. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't attack if you have no HP. + self.caller.msg("You can't attack, you've been defeated.") + return + + attacker = self.caller + defender = self.caller.search(self.args) + + if not defender: # No valid target given. + return + + if not defender.db.hp: # Target object has no HP left or to begin with + self.caller.msg("You can't fight that!") + return + + if attacker == defender: # Target and attacker are the same + self.caller.msg("You can't attack yourself!") + return + + "If everything checks out, call the attack resolving function." + resolve_attack(attacker, defender) + spend_action(self.caller, 1, action_name="attack") # Use up one action. + + +class CmdPass(Command): + """ + Passes on your turn. + + Usage: + pass + + When in a fight, you can use this command to end your turn early, even + if there are still any actions you can take. + """ + + key = "pass" + aliases = ["wait", "hold"] + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + if not is_in_combat(self.caller): # Can only pass a turn in combat. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # Can only pass if it's your turn. + self.caller.msg("You can only do that on your turn.") + return + + self.caller.location.msg_contents("%s takes no further action, passing the turn." % self.caller) + spend_action(self.caller, 'all', action_name="pass") # Spend all remaining actions. + + +class CmdDisengage(Command): + """ + Passes your turn and attempts to end combat. + + Usage: + disengage + + Ends your turn early and signals that you're trying to end + the fight. If all participants in a fight disengage, the + fight ends. + """ + + key = "disengage" + aliases = ["spare"] + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + if not is_in_combat(self.caller): # If you're not in combat + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn + self.caller.msg("You can only do that on your turn.") + return + + self.caller.location.msg_contents("%s disengages, ready to stop fighting." % self.caller) + spend_action(self.caller, 'all', action_name="disengage") # Spend all remaining actions. + """ + The action_name kwarg sets the character's last action to "disengage", which is checked by + the turn handler script to see if all fighters have disengaged. + """ + + +class CmdRest(Command): + """ + Recovers damage. + + Usage: + rest + + Resting recovers your HP to its maximum, but you can only + rest if you're not in a fight. + """ + + key = "rest" + help_category = "combat" + + def func(self): + "This performs the actual command." + + if is_in_combat(self.caller): # If you're in combat + self.caller.msg("You can't rest while you're in combat.") + return + + self.caller.db.hp = self.caller.db.max_hp # Set current HP to maximum + self.caller.location.msg_contents("%s rests to recover HP." % self.caller) + """ + You'll probably want to replace this with your own system for recovering HP. + """ + + +class CmdCombatHelp(CmdHelp): + """ + View help or a list of topics + + Usage: + help + help list + help all + + This will search for help on commands and other + topics related to the game. + """ + # Just like the default help command, but will give quick + # tips on combat when used in a fight with no arguments. + + def func(self): + if is_in_combat(self.caller) and not self.args: # In combat and entered 'help' alone + self.caller.msg("Available combat commands:|/" + + "|wAttack:|n Attack a target, attempting to deal damage.|/" + + "|wPass:|n Pass your turn without further action.|/" + + "|wDisengage:|n End your turn and attempt to end combat.|/") + else: + super(CmdCombatHelp, self).func() # Call the default help command + +class CmdWield(Command): + """ + Wield a weapon you are carrying + + Usage: + wield + + Select a weapon you are carrying to wield in combat. If + you are already wielding another weapon, you will switch + to the weapon you specify instead. Using this command in + combat will spend your action for your turn. Use the + "unwield" command to stop wielding any weapon you are + currently wielding. + """ + + key = "wield" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + # If in combat, check to see if it's your turn. + if is_in_combat(self.caller): + if not is_turn(self.caller): + self.caller.msg("You can only do that on your turn.") + return + if not self.args: + self.caller.msg("Usage: wield ") + return + weapon = self.caller.search(self.args, candidates=self.caller.contents) + if not weapon: + return + if not weapon.is_typeclass("evennia.contrib.turnbattle.tb_equip.TB_Weapon"): + self.caller.msg("That's not a weapon!") + # Remember to update the path to the weapon typeclass if you move this module! + return + + if not self.caller.db.wielded_weapon: + self.caller.db.wielded_weapon = weapon + self.caller.location.msg_contents("%s wields %s." % (self.caller, weapon)) + else: + old_weapon = self.caller.db.wielded_weapon + self.caller.db.wielded_weapon = weapon + self.caller.location.msg_contents("%s lowers %s and wields %s." % (self.caller, old_weapon, weapon)) + # Spend an action if in combat. + if is_in_combat(self.caller): + spend_action(self.caller, 1, action_name="wield") # Use up one action. + +class CmdUnwield(Command): + """ + Stop wielding a weapon. + + Usage: + unwield + + After using this command, you will stop wielding any + weapon you are currently wielding and become unarmed. + """ + + key = "unwield" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + # If in combat, check to see if it's your turn. + if is_in_combat(self.caller): + if not is_turn(self.caller): + self.caller.msg("You can only do that on your turn.") + return + if not self.caller.db.wielded_weapon: + self.caller.msg("You aren't wielding a weapon!") + else: + old_weapon = self.caller.db.wielded_weapon + self.caller.db.wielded_weapon = None + self.caller.location.msg_contents("%s lowers %s." % (self.caller, old_weapon)) + +class CmdDon(Command): + """ + Don armor that you are carrying + + Usage: + don + + Select armor to wear in combat. You can't use this + command in the middle of a fight. Use the "doff" + command to remove any armor you are wearing. + """ + + key = "don" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + # Can't do this in combat + if is_in_combat(self.caller): + self.caller.msg("You can't don armor in a fight!") + return + if not self.args: + self.caller.msg("Usage: don ") + return + armor = self.caller.search(self.args, candidates=self.caller.contents) + if not armor: + return + if not armor.is_typeclass("evennia.contrib.turnbattle.tb_equip.TB_Armor"): + self.caller.msg("That's not armor!") + # Remember to update the path to the armor typeclass if you move this module! + return + + if not self.caller.db.worn_armor: + self.caller.db.worn_armor = armor + self.caller.location.msg_contents("%s dons %s." % (self.caller, armor)) + else: + old_armor = self.caller.db.worn_armor + self.caller.db.worn_armor = armor + self.caller.location.msg_contents("%s removes %s and dons %s." % (self.caller, old_armor, armor)) + +class CmdDoff(Command): + """ + Stop wearing armor. + + Usage: + doff + + After using this command, you will stop wearing any + armor you are currently using and become unarmored. + You can't use this command in combat. + """ + + key = "doff" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + # Can't do this in combat + if is_in_combat(self.caller): + self.caller.msg("You can't doff armor in a fight!") + return + if not self.caller.db.worn_armor: + self.caller.msg("You aren't wearing any armor!") + else: + old_armor = self.caller.db.worn_armor + self.caller.db.worn_armor = None + self.caller.location.msg_contents("%s removes %s." % (self.caller, old_armor)) + + + +class BattleCmdSet(default_cmds.CharacterCmdSet): + """ + This command set includes all the commmands used in the battle system. + """ + key = "DefaultCharacter" + + def at_cmdset_creation(self): + """ + Populates the cmdset + """ + self.add(CmdFight()) + self.add(CmdAttack()) + self.add(CmdRest()) + self.add(CmdPass()) + self.add(CmdDisengage()) + self.add(CmdCombatHelp()) + self.add(CmdWield()) + self.add(CmdUnwield()) + self.add(CmdDon()) + self.add(CmdDoff()) + + +""" +---------------------------------------------------------------------------- +SCRIPTS START HERE +---------------------------------------------------------------------------- +""" + + +class TurnHandler(DefaultScript): + """ + This is the script that handles the progression of combat through turns. + On creation (when a fight is started) it adds all combat-ready characters + to its roster and then sorts them into a turn order. There can only be one + fight going on in a single room at a time, so the script is assigned to a + room as its object. + + Fights persist until only one participant is left with any HP or all + remaining participants choose to end the combat with the 'disengage' command. + """ + + def at_script_creation(self): + """ + Called once, when the script is created. + """ + self.key = "Combat Turn Handler" + self.interval = 5 # Once every 5 seconds + self.persistent = True + self.db.fighters = [] + + # Add all fighters in the room with at least 1 HP to the combat." + for object in self.obj.contents: + if object.db.hp: + self.db.fighters.append(object) + + # Initialize each fighter for combat + for fighter in self.db.fighters: + self.initialize_for_combat(fighter) + + # Add a reference to this script to the room + self.obj.db.Combat_TurnHandler = self + + # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order. + # The initiative roll is determined by the roll_init function and can be customized easily. + ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True) + self.db.fighters = ordered_by_roll + + # Announce the turn order. + self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters)) + + # Set up the current turn and turn timeout delay. + self.db.turn = 0 + self.db.timer = 30 # 30 seconds + + def at_stop(self): + """ + Called at script termination. + """ + for fighter in self.db.fighters: + combat_cleanup(fighter) # Clean up the combat attributes for every fighter. + self.obj.db.Combat_TurnHandler = None # Remove reference to turn handler in location + + def at_repeat(self): + """ + Called once every self.interval seconds. + """ + currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order. + self.db.timer -= self.interval # Count down the timer. + + if self.db.timer <= 0: + # Force current character to disengage if timer runs out. + self.obj.msg_contents("%s's turn timed out!" % currentchar) + spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions. + return + elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left + # Warn the current character if they're about to time out. + currentchar.msg("WARNING: About to time out!") + self.db.timeout_warning_given = True + + def initialize_for_combat(self, character): + """ + Prepares a character for combat when starting or entering a fight. + + Args: + character (obj): Character to initialize for combat. + """ + combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. + character.db.Combat_ActionsLeft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + character.db.Combat_TurnHandler = self # Add a reference to this turn handler script to the character + character.db.Combat_LastAction = "null" # Track last action taken in combat + + def start_turn(self, character): + """ + Readies a character for the start of their turn by replenishing their + available actions and notifying them that their turn has come up. + + Args: + character (obj): Character to be readied. + + Notes: + Here, you only get one action per turn, but you might want to allow more than + one per turn, or even grant a number of actions based on a character's + attributes. You can even add multiple different kinds of actions, I.E. actions + separated for movement, by adding "character.db.Combat_MovesLeft = 3" or + something similar. + """ + character.db.Combat_ActionsLeft = 1 # 1 action per turn. + # Prompt the character for their turn and give some information. + character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp) + + def next_turn(self): + """ + Advances to the next character in the turn order. + """ + + # Check to see if every character disengaged as their last action. If so, end combat. + disengage_check = True + for fighter in self.db.fighters: + if fighter.db.Combat_LastAction != "disengage": # If a character has done anything but disengage + disengage_check = False + if disengage_check: # All characters have disengaged + self.obj.msg_contents("All fighters have disengaged! Combat is over!") + self.stop() # Stop this script and end combat. + return + + # Check to see if only one character is left standing. If so, end combat. + defeated_characters = 0 + for fighter in self.db.fighters: + if fighter.db.HP == 0: + defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated) + if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated + for fighter in self.db.fighters: + if fighter.db.HP != 0: + LastStanding = fighter # Pick the one fighter left with HP remaining + self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding) + self.stop() # Stop this script and end combat. + return + + # Cycle to the next turn. + currentchar = self.db.fighters[self.db.turn] + self.db.turn += 1 # Go to the next in the turn order. + if self.db.turn > len(self.db.fighters) - 1: + self.db.turn = 0 # Go back to the first in the turn order once you reach the end. + newchar = self.db.fighters[self.db.turn] # Note the new character + self.db.timer = 30 + self.time_until_next_repeat() # Reset the timer. + self.db.timeout_warning_given = False # Reset the timeout warning. + self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) + self.start_turn(newchar) # Start the new character's turn. + + def turn_end_check(self, character): + """ + Tests to see if a character's turn is over, and cycles to the next turn if it is. + + Args: + character (obj): Character to test for end of turn + """ + if not character.db.Combat_ActionsLeft: # Character has no actions remaining + self.next_turn() + return + + def join_fight(self, character): + """ + Adds a new character to a fight already in progress. + + Args: + character (obj): Character to be added to the fight. + """ + # Inserts the fighter to the turn order, right behind whoever's turn it currently is. + self.db.fighters.insert(self.db.turn, character) + # Tick the turn counter forward one to compensate. + self.db.turn += 1 + # Initialize the character like you do at the start. + self.initialize_for_combat(character) + +""" +---------------------------------------------------------------------------- +PROTOTYPES START HERE +---------------------------------------------------------------------------- +""" + +BASEWEAPON = { + "typeclass": "evennia.contrib.turnbattle.tb_equip.TB_Weapon", +} + +BASEARMOR = { + "typeclass": "evennia.contrib.turnbattle.tb_equip.TB_Armor", +} + +DAGGER = { + "prototype" : "BASEWEAPON", + "damage_range" : (10, 20), + "accuracy_bonus" : 30, + "key": "a thin steel dagger", + "weapon_type_name" : "dagger" +} + +BROADSWORD = { + "prototype" : "BASEWEAPON", + "damage_range" : (15, 30), + "accuracy_bonus" : 15, + "key": "an iron broadsword", + "weapon_type_name" : "broadsword" +} + +GREATSWORD = { + "prototype" : "BASEWEAPON", + "damage_range" : (20, 40), + "accuracy_bonus" : 0, + "key": "a rune-etched greatsword", + "weapon_type_name" : "greatsword" +} + +LEATHERARMOR = { + "prototype" : "BASEARMOR", + "damage_reduction" : 2, + "defense_modifier" : -2, + "key": "a suit of leather armor" +} + +SCALEMAIL = { + "prototype" : "BASEARMOR", + "damage_reduction" : 4, + "defense_modifier" : -4, + "key": "a suit of scale mail" +} + +PLATEMAIL = { + "prototype" : "BASEARMOR", + "damage_reduction" : 6, + "defense_modifier" : -6, + "key": "a suit of plate mail" +} From d4d8a9c1b8240c86311206202ed9dcbbdb76bada Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 8 Oct 2017 20:07:29 -0700 Subject: [PATCH 012/189] Readme for turnbattle folder --- evennia/contrib/turnbattle/README.md | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 evennia/contrib/turnbattle/README.md diff --git a/evennia/contrib/turnbattle/README.md b/evennia/contrib/turnbattle/README.md new file mode 100644 index 0000000000..8d709d1ba1 --- /dev/null +++ b/evennia/contrib/turnbattle/README.md @@ -0,0 +1,29 @@ +# Turn based battle system framework + +Contrib - Tim Ashley Jenkins 2017 + +This is a framework for a simple turn-based combat system, similar +to those used in D&D-style tabletop role playing games. It allows +any character to start a fight in a room, at which point initiative +is rolled and a turn order is established. Each participant in combat +has a limited time to decide their action for that turn (30 seconds by +default), and combat progresses through the turn order, looping through +the participants until the fight ends. + +This folder contains multiple examples of how such a system can be +implemented and customized: + + tb_basic.py - The simplest system, which implements initiative and turn + order, attack rolls against defense values, and damage to hit + points. Only very basic game mechanics are included. + + tb_equip.py - Adds weapons and armor to the basic implementation of + the battle system, including commands for wielding weapons and + donning armor, and modifiers to accuracy and damage based on + currently used equipment. + +This system is meant as a basic framework to start from, and is modeled +after the combat systems of popular tabletop role playing games rather than +the real-time battle systems that many MMOs and some MUDs use. As such, it +may be better suited to role-playing or more story-oriented games, or games +meant to closely emulate the experience of playing a tabletop RPG. From b50c7a1f3e3d87bc0c0d23faa2dc96eb7ce28d16 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 8 Oct 2017 20:08:08 -0700 Subject: [PATCH 013/189] __init__.py for Turnbattle folder --- evennia/contrib/turnbattle/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 evennia/contrib/turnbattle/__init__.py diff --git a/evennia/contrib/turnbattle/__init__.py b/evennia/contrib/turnbattle/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/evennia/contrib/turnbattle/__init__.py @@ -0,0 +1 @@ + From a3fd45bebbc13d373ef20fc42be23d9bfeca2ba0 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 8 Oct 2017 20:09:30 -0700 Subject: [PATCH 014/189] Update contrib unit tests for turnbattle Points the contrib unit tests to the turnbattle module's new location in its subfolder. --- evennia/contrib/tests.py | 42 +++++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 1678d06567..20a24eb177 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -907,7 +907,7 @@ class TestTutorialWorldRooms(CommandTest): # test turnbattle -from evennia.contrib import turnbattle +from evennia.contrib.turnbattle import tb_basic from evennia.objects.objects import DefaultRoom @@ -915,60 +915,59 @@ class TestTurnBattleCmd(CommandTest): # Test combat commands def test_turnbattlecmd(self): - self.call(turnbattle.CmdFight(), "", "You can't start a fight if you've been defeated!") - self.call(turnbattle.CmdAttack(), "", "You can only do that in combat. (see: help fight)") - self.call(turnbattle.CmdPass(), "", "You can only do that in combat. (see: help fight)") - self.call(turnbattle.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") - self.call(turnbattle.CmdRest(), "", "Char rests to recover HP.") - + self.call(tb_basic.CmdFight(), "", "You can't start a fight if you've been defeated!") + self.call(tb_basic.CmdAttack(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_basic.CmdPass(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_basic.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_basic.CmdRest(), "", "Char rests to recover HP.") class TestTurnBattleFunc(EvenniaTest): # Test combat functions def test_turnbattlefunc(self): - attacker = create_object(turnbattle.BattleCharacter, key="Attacker") - defender = create_object(turnbattle.BattleCharacter, key="Defender") + attacker = create_object(tb_basic.BattleCharacter, key="Attacker") + defender = create_object(tb_basic.BattleCharacter, key="Defender") testroom = create_object(DefaultRoom, key="Test Room") attacker.location = testroom defender.loaction = testroom # Initiative roll - initiative = turnbattle.roll_init(attacker) + initiative = tb_basic.roll_init(attacker) self.assertTrue(initiative >= 0 and initiative <= 1000) # Attack roll - attack_roll = turnbattle.get_attack(attacker, defender) + attack_roll = tb_basic.get_attack(attacker, defender) self.assertTrue(attack_roll >= 0 and attack_roll <= 100) # Defense roll - defense_roll = turnbattle.get_defense(attacker, defender) + defense_roll = tb_basic.get_defense(attacker, defender) self.assertTrue(defense_roll == 50) # Damage roll - damage_roll = turnbattle.get_damage(attacker, defender) + damage_roll = tb_basic.get_damage(attacker, defender) self.assertTrue(damage_roll >= 15 and damage_roll <= 25) # Apply damage defender.db.hp = 10 - turnbattle.apply_damage(defender, 3) + tb_basic.apply_damage(defender, 3) self.assertTrue(defender.db.hp == 7) # Resolve attack defender.db.hp = 40 - turnbattle.resolve_attack(attacker, defender, attack_value=20, defense_value=10) + tb_basic.resolve_attack(attacker, defender, attack_value=20, defense_value=10) self.assertTrue(defender.db.hp < 40) # Combat cleanup attacker.db.Combat_attribute = True - turnbattle.combat_cleanup(attacker) + tb_basic.combat_cleanup(attacker) self.assertFalse(attacker.db.combat_attribute) # Is in combat - self.assertFalse(turnbattle.is_in_combat(attacker)) + self.assertFalse(tb_basic.is_in_combat(attacker)) # Set up turn handler script for further tests - attacker.location.scripts.add(turnbattle.TurnHandler) + attacker.location.scripts.add(tb_basic.TurnHandler) turnhandler = attacker.db.combat_TurnHandler self.assertTrue(attacker.db.combat_TurnHandler) # Force turn order turnhandler.db.fighters = [attacker, defender] turnhandler.db.turn = 0 # Test is turn - self.assertTrue(turnbattle.is_turn(attacker)) + self.assertTrue(tb_basic.is_turn(attacker)) # Spend actions attacker.db.Combat_ActionsLeft = 1 - turnbattle.spend_action(attacker, 1, action_name="Test") + tb_basic.spend_action(attacker, 1, action_name="Test") self.assertTrue(attacker.db.Combat_ActionsLeft == 0) self.assertTrue(attacker.db.Combat_LastAction == "Test") # Initialize for combat @@ -992,7 +991,7 @@ class TestTurnBattleFunc(EvenniaTest): turnhandler.turn_end_check(attacker) self.assertTrue(turnhandler.db.turn == 1) # Join fight - joiner = create_object(turnbattle.BattleCharacter, key="Joiner") + joiner = create_object(tb_basic.BattleCharacter, key="Joiner") turnhandler.db.fighters = [attacker, defender] turnhandler.db.turn = 0 turnhandler.join_fight(joiner) @@ -1001,7 +1000,6 @@ class TestTurnBattleFunc(EvenniaTest): # Remove the script at the end turnhandler.stop() - # Test of the unixcommand module from evennia.contrib.unixcommand import UnixCommand From f031ba1b21721edb89b36ee115b49b185a8d8d1c Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Mon, 9 Oct 2017 00:13:03 -0700 Subject: [PATCH 015/189] Renamed typeclasses to avoid conflicts --- evennia/contrib/turnbattle/tb_basic.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_basic.py b/evennia/contrib/turnbattle/tb_basic.py index f1ff33a483..70c81debae 100644 --- a/evennia/contrib/turnbattle/tb_basic.py +++ b/evennia/contrib/turnbattle/tb_basic.py @@ -16,15 +16,15 @@ is easily extensible and can be used as the foundation for implementing the rules from your turn-based tabletop game of choice or making your own battle system. -To install and test, import this module's BattleCharacter object into +To install and test, import this module's TBBasicCharacter object into your game's character.py module: - from evennia.contrib.turnbattle.tb_basic import BattleCharacter + from evennia.contrib.turnbattle.tb_basic import TBBasicCharacter -And change your game's character typeclass to inherit from BattleCharacter +And change your game's character typeclass to inherit from TBBasicCharacter instead of the default: - class Character(BattleCharacter): + class Character(TBBasicCharacter): Next, import this module into your default_cmdsets.py module: @@ -278,7 +278,7 @@ CHARACTER TYPECLASS """ -class BattleCharacter(DefaultCharacter): +class TBBasicCharacter(DefaultCharacter): """ A character able to participate in turn-based combat. Has attributes for current and maximum HP, and access to combat commands. @@ -371,7 +371,7 @@ class CmdFight(Command): return here.msg_contents("%s starts a fight!" % self.caller) # Add a turn handler script to the room, which starts combat. - here.scripts.add("contrib.turnbattle.tb_basic.TurnHandler") + here.scripts.add("contrib.turnbattle.tb_basic.TBBasicTurnHandler") # Remember you'll have to change the path to the script if you copy this code to your own modules! @@ -569,7 +569,7 @@ SCRIPTS START HERE """ -class TurnHandler(DefaultScript): +class TBBasicTurnHandler(DefaultScript): """ This is the script that handles the progression of combat through turns. On creation (when a fight is started) it adds all combat-ready characters From da8b20e0b19cd46dd52cf66b69974eceb1fedfdb Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Mon, 9 Oct 2017 00:14:32 -0700 Subject: [PATCH 016/189] Renamed typeclasses to avoid conflicts --- evennia/contrib/turnbattle/tb_equip.py | 29 ++++++++++++++------------ 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_equip.py b/evennia/contrib/turnbattle/tb_equip.py index 702d02520e..7d9ea58442 100644 --- a/evennia/contrib/turnbattle/tb_equip.py +++ b/evennia/contrib/turnbattle/tb_equip.py @@ -28,15 +28,15 @@ expanded to fit your game - weapon and armor slots, damage types and damage bonuses, etc. should be fairly simple to implement according to the rules of your preferred system or the needs of your own game. -To install and test, import this module's BattleCharacter object into +To install and test, import this module's TBEquipCharacter object into your game's character.py module: - from evennia.contrib.turnbattle.tb_equip import BattleCharacter + from evennia.contrib.turnbattle.tb_equip import TBEquipCharacter -And change your game's character typeclass to inherit from BattleCharacter +And change your game's character typeclass to inherit from TBEquipCharacter instead of the default: - class Character(BattleCharacter): + class Character(TBEquipCharacter): Next, import this module into your default_cmdsets.py module: @@ -321,7 +321,7 @@ TYPECLASSES START HERE ---------------------------------------------------------------------------- """ -class TB_Weapon(DefaultObject): +class TBEWeapon(DefaultObject): """ A weapon which can be wielded in combat with the 'wield' command. """ @@ -348,7 +348,7 @@ class TB_Weapon(DefaultObject): giver.db.wielded_weapon = None giver.location.msg_contents("%s stops wielding %s." % (giver, self)) -class TB_Armor(DefaultObject): +class TBEArmor(DefaultObject): """ A set of armor which can be worn with the 'don' command. """ @@ -390,7 +390,7 @@ class TB_Armor(DefaultObject): giver.db.worn_armor = None giver.location.msg_contents("%s removes %s." % (giver, self)) -class BattleCharacter(DefaultCharacter): +class TBEquipCharacter(DefaultCharacter): """ A character able to participate in turn-based combat. Has attributes for current and maximum HP, and access to combat commands. @@ -488,7 +488,7 @@ class CmdFight(Command): return here.msg_contents("%s starts a fight!" % self.caller) # Add a turn handler script to the room, which starts combat. - here.scripts.add("contrib.turnbattle_equip.TurnHandler") + here.scripts.add("contrib.turnbattle.tb_equip.TBEquipTurnHandler") # Remember you'll have to change the path to the script if you copy this code to your own modules! @@ -693,7 +693,7 @@ class CmdWield(Command): weapon = self.caller.search(self.args, candidates=self.caller.contents) if not weapon: return - if not weapon.is_typeclass("evennia.contrib.turnbattle.tb_equip.TB_Weapon"): + if not weapon.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEWeapon"): self.caller.msg("That's not a weapon!") # Remember to update the path to the weapon typeclass if you move this module! return @@ -768,7 +768,7 @@ class CmdDon(Command): armor = self.caller.search(self.args, candidates=self.caller.contents) if not armor: return - if not armor.is_typeclass("evennia.contrib.turnbattle.tb_equip.TB_Armor"): + if not armor.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEArmor"): self.caller.msg("That's not armor!") # Remember to update the path to the armor typeclass if you move this module! return @@ -842,7 +842,7 @@ SCRIPTS START HERE """ -class TurnHandler(DefaultScript): +class TBEquipTurnHandler(DefaultScript): """ This is the script that handles the progression of combat through turns. On creation (when a fight is started) it adds all combat-ready characters @@ -882,6 +882,9 @@ class TurnHandler(DefaultScript): # Announce the turn order. self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters)) + + # Start first fighter's turn. + self.start_turn(self.db.fighters[0]) # Set up the current turn and turn timeout delay. self.db.turn = 0 @@ -1014,11 +1017,11 @@ PROTOTYPES START HERE """ BASEWEAPON = { - "typeclass": "evennia.contrib.turnbattle.tb_equip.TB_Weapon", + "typeclass": "evennia.contrib.turnbattle.tb_equip.TBEWeapon", } BASEARMOR = { - "typeclass": "evennia.contrib.turnbattle.tb_equip.TB_Armor", + "typeclass": "evennia.contrib.turnbattle.tb_equip.TBEArmor", } DAGGER = { From 576e2b4be6985f88082fe907c3053aed03492e4f Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Mon, 9 Oct 2017 00:16:14 -0700 Subject: [PATCH 017/189] Updated typeclass names & added tests for tb_equip commands --- evennia/contrib/tests.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 20a24eb177..f611471c0f 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -907,7 +907,7 @@ class TestTutorialWorldRooms(CommandTest): # test turnbattle -from evennia.contrib.turnbattle import tb_basic +from evennia.contrib.turnbattle import tb_basic, tb_equip from evennia.objects.objects import DefaultRoom @@ -920,13 +920,32 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_basic.CmdPass(), "", "You can only do that in combat. (see: help fight)") self.call(tb_basic.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_basic.CmdRest(), "", "Char rests to recover HP.") + + # Test equipment commands + def test_turnbattleequipcmd(self): + # Start with equip module specific commands. + testweapon = create_object(tb_equip.TBEWeapon, key="test weapon") + testarmor = create_object(tb_equip.TBEArmor, key="test armor") + testweapon.move_to(self.char1) + testarmor.move_to(self.char1) + self.call(tb_equip.CmdWield(), "weapon", "Char wields test weapon.") + self.call(tb_equip.CmdUnwield(), "", "Char lowers test weapon.") + self.call(tb_equip.CmdDon(), "armor", "Char dons test armor.") + self.call(tb_equip.CmdDoff(), "", "Char removes test armor.") + # Also test the commands that are the same in the basic module + self.call(tb_equip.CmdFight(), "", "You can't start a fight if you've been defeated!") + self.call(tb_equip.CmdAttack(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_equip.CmdPass(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_equip.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_equip.CmdRest(), "", "Char rests to recover HP.") + class TestTurnBattleFunc(EvenniaTest): # Test combat functions def test_turnbattlefunc(self): - attacker = create_object(tb_basic.BattleCharacter, key="Attacker") - defender = create_object(tb_basic.BattleCharacter, key="Defender") + attacker = create_object(tb_basic.TBBasicCharacter, key="Attacker") + defender = create_object(tb_basic.TBBasicCharacter, key="Defender") testroom = create_object(DefaultRoom, key="Test Room") attacker.location = testroom defender.loaction = testroom @@ -957,7 +976,7 @@ class TestTurnBattleFunc(EvenniaTest): # Is in combat self.assertFalse(tb_basic.is_in_combat(attacker)) # Set up turn handler script for further tests - attacker.location.scripts.add(tb_basic.TurnHandler) + attacker.location.scripts.add(tb_basic.TBBasicTurnHandler) turnhandler = attacker.db.combat_TurnHandler self.assertTrue(attacker.db.combat_TurnHandler) # Force turn order @@ -991,7 +1010,7 @@ class TestTurnBattleFunc(EvenniaTest): turnhandler.turn_end_check(attacker) self.assertTrue(turnhandler.db.turn == 1) # Join fight - joiner = create_object(tb_basic.BattleCharacter, key="Joiner") + joiner = create_object(tb_basic.TBBasicCharacter, key="Joiner") turnhandler.db.fighters = [attacker, defender] turnhandler.db.turn = 0 turnhandler.join_fight(joiner) @@ -1000,6 +1019,7 @@ class TestTurnBattleFunc(EvenniaTest): # Remove the script at the end turnhandler.stop() + # Test of the unixcommand module from evennia.contrib.unixcommand import UnixCommand From 8bdfa011fbe2e50f1c9c27105408430ce2e807f4 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Mon, 9 Oct 2017 17:44:47 -0700 Subject: [PATCH 018/189] Added more tb_equip tests The unit tests for tb_basic and tb_equip are almost the same, with a few minor differences created by the different default values for unarmed attack and damage rolls. --- evennia/contrib/tests.py | 77 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index f611471c0f..1fdd7dde75 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1018,6 +1018,83 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) # Remove the script at the end turnhandler.stop() + + # Test the combat functions in tb_equip too. They work mostly the same. + def test_turnbattlefunc(self): + attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker") + defender = create_object(tb_equip.TBEquipCharacter, key="Defender") + testroom = create_object(DefaultRoom, key="Test Room") + attacker.location = testroom + defender.loaction = testroom + # Initiative roll + initiative = tb_equip.roll_init(attacker) + self.assertTrue(initiative >= 0 and initiative <= 1000) + # Attack roll + attack_roll = tb_equip.get_attack(attacker, defender) + self.assertTrue(attack_roll >= -50 and attack_roll <= 150) + # Defense roll + defense_roll = tb_equip.get_defense(attacker, defender) + self.assertTrue(defense_roll == 50) + # Damage roll + damage_roll = tb_equip.get_damage(attacker, defender) + self.assertTrue(damage_roll >= 0 and damage_roll <= 50) + # Apply damage + defender.db.hp = 10 + tb_equip.apply_damage(defender, 3) + self.assertTrue(defender.db.hp == 7) + # Resolve attack + defender.db.hp = 40 + tb_equip.resolve_attack(attacker, defender, attack_value=20, defense_value=10) + self.assertTrue(defender.db.hp < 40) + # Combat cleanup + attacker.db.Combat_attribute = True + tb_equip.combat_cleanup(attacker) + self.assertFalse(attacker.db.combat_attribute) + # Is in combat + self.assertFalse(tb_equip.is_in_combat(attacker)) + # Set up turn handler script for further tests + attacker.location.scripts.add(tb_equip.TBEquipTurnHandler) + turnhandler = attacker.db.combat_TurnHandler + self.assertTrue(attacker.db.combat_TurnHandler) + # Force turn order + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + # Test is turn + self.assertTrue(tb_equip.is_turn(attacker)) + # Spend actions + attacker.db.Combat_ActionsLeft = 1 + tb_equip.spend_action(attacker, 1, action_name="Test") + self.assertTrue(attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(attacker.db.Combat_LastAction == "Test") + # Initialize for combat + attacker.db.Combat_ActionsLeft = 983 + turnhandler.initialize_for_combat(attacker) + self.assertTrue(attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(attacker.db.Combat_LastAction == "null") + # Start turn + defender.db.Combat_ActionsLeft = 0 + turnhandler.start_turn(defender) + self.assertTrue(defender.db.Combat_ActionsLeft == 1) + # Next turn + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + turnhandler.next_turn() + self.assertTrue(turnhandler.db.turn == 1) + # Turn end check + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + attacker.db.Combat_ActionsLeft = 0 + turnhandler.turn_end_check(attacker) + self.assertTrue(turnhandler.db.turn == 1) + # Join fight + joiner = create_object(tb_equip.TBEquipCharacter, key="Joiner") + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + turnhandler.join_fight(joiner) + self.assertTrue(turnhandler.db.turn == 1) + self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) + # Remove the script at the end + turnhandler.stop() # Test of the unixcommand module From 3e02f96566dab3aff233e23fb52b9ee1c04b5f42 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 14 Oct 2017 20:15:44 +0200 Subject: [PATCH 019/189] Add ability to search_script by typeclass --- evennia/scripts/manager.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/evennia/scripts/manager.py b/evennia/scripts/manager.py index 17a11d20b3..1b795b2c3e 100644 --- a/evennia/scripts/manager.py +++ b/evennia/scripts/manager.py @@ -214,7 +214,7 @@ class ScriptDBManager(TypedObjectManager): VALIDATE_ITERATION -= 1 return nr_started, nr_stopped - def search_script(self, ostring, obj=None, only_timed=False): + def search_script(self, ostring, obj=None, only_timed=False, typeclass=None): """ Search for a particular script. @@ -224,6 +224,7 @@ class ScriptDBManager(TypedObjectManager): this object only_timed (bool): Limit search only to scripts that run on a timer. + typeclass (class or str): Typeclass or path to typeclass. """ @@ -237,10 +238,17 @@ class ScriptDBManager(TypedObjectManager): (only_timed and dbref_match.interval)): return [dbref_match] + if typeclass: + if callable(typeclass): + typeclass = u"%s.%s" % (typeclass.__module__, typeclass.__name__) + else: + typeclass = u"%s" % typeclass + # not a dbref; normal search obj_restriction = obj and Q(db_obj=obj) or Q() - timed_restriction = only_timed and Q(interval__gt=0) or Q() - scripts = self.filter(timed_restriction & obj_restriction & Q(db_key__iexact=ostring)) + timed_restriction = only_timed and Q(db_interval__gt=0) or Q() + typeclass_restriction = typeclass and Q(db_typeclass_path=typeclass) or Q() + scripts = self.filter(timed_restriction & obj_restriction & typeclass_restriction & Q(db_key__iexact=ostring)) return scripts # back-compatibility alias script_search = search_script From 6cbd9984d71eb08a33340208857a6c2411144fc6 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sat, 14 Oct 2017 22:31:10 -0700 Subject: [PATCH 020/189] Delete turnbattle.py Now that this system has been moved to /turnbattle/, this file is obsolete. --- evennia/contrib/turnbattle.py | 735 ---------------------------------- 1 file changed, 735 deletions(-) delete mode 100644 evennia/contrib/turnbattle.py diff --git a/evennia/contrib/turnbattle.py b/evennia/contrib/turnbattle.py deleted file mode 100644 index 692656bc3a..0000000000 --- a/evennia/contrib/turnbattle.py +++ /dev/null @@ -1,735 +0,0 @@ -""" -Simple turn-based combat system - -Contrib - Tim Ashley Jenkins 2017 - -This is a framework for a simple turn-based combat system, similar -to those used in D&D-style tabletop role playing games. It allows -any character to start a fight in a room, at which point initiative -is rolled and a turn order is established. Each participant in combat -has a limited time to decide their action for that turn (30 seconds by -default), and combat progresses through the turn order, looping through -the participants until the fight ends. - -Only simple rolls for attacking are implemented here, but this system -is easily extensible and can be used as the foundation for implementing -the rules from your turn-based tabletop game of choice or making your -own battle system. - -To install and test, import this module's BattleCharacter object into -your game's character.py module: - - from evennia.contrib.turnbattle import BattleCharacter - -And change your game's character typeclass to inherit from BattleCharacter -instead of the default: - - class Character(BattleCharacter): - -Next, import this module into your default_cmdsets.py module: - - from evennia.contrib import turnbattle - -And add the battle command set to your default command set: - - # - # any commands you add below will overload the default ones. - # - self.add(turnbattle.BattleCmdSet()) - -This module is meant to be heavily expanded on, so you may want to copy it -to your game's 'world' folder and modify it there rather than importing it -in your game and using it as-is. -""" - -from random import randint -from evennia import DefaultCharacter, Command, default_cmds, DefaultScript -from evennia.commands.default.help import CmdHelp - -""" ----------------------------------------------------------------------------- -COMBAT FUNCTIONS START HERE ----------------------------------------------------------------------------- -""" - - -def roll_init(character): - """ - Rolls a number between 1-1000 to determine initiative. - - Args: - character (obj): The character to determine initiative for - - Returns: - initiative (int): The character's place in initiative - higher - numbers go first. - - Notes: - By default, does not reference the character and simply returns - a random integer from 1 to 1000. - - Since the character is passed to this function, you can easily reference - a character's stats to determine an initiative roll - for example, if your - character has a 'dexterity' attribute, you can use it to give that character - an advantage in turn order, like so: - - return (randint(1,20)) + character.db.dexterity - - This way, characters with a higher dexterity will go first more often. - """ - return randint(1, 1000) - - -def get_attack(attacker, defender): - """ - Returns a value for an attack roll. - - Args: - attacker (obj): Character doing the attacking - defender (obj): Character being attacked - - Returns: - attack_value (int): Attack roll value, compared against a defense value - to determine whether an attack hits or misses. - - Notes: - By default, returns a random integer from 1 to 100 without using any - properties from either the attacker or defender. - - This can easily be expanded to return a value based on characters stats, - equipment, and abilities. This is why the attacker and defender are passed - to this function, even though nothing from either one are used in this example. - """ - # For this example, just return a random integer up to 100. - attack_value = randint(1, 100) - return attack_value - - -def get_defense(attacker, defender): - """ - Returns a value for defense, which an attack roll must equal or exceed in order - for an attack to hit. - - Args: - attacker (obj): Character doing the attacking - defender (obj): Character being attacked - - Returns: - defense_value (int): Defense value, compared against an attack roll - to determine whether an attack hits or misses. - - Notes: - By default, returns 50, not taking any properties of the defender or - attacker into account. - - As above, this can be expanded upon based on character stats and equipment. - """ - # For this example, just return 50, for about a 50/50 chance of hit. - defense_value = 50 - return defense_value - - -def get_damage(attacker, defender): - """ - Returns a value for damage to be deducted from the defender's HP after abilities - successful hit. - - Args: - attacker (obj): Character doing the attacking - defender (obj): Character being damaged - - Returns: - damage_value (int): Damage value, which is to be deducted from the defending - character's HP. - - Notes: - By default, returns a random integer from 15 to 25 without using any - properties from either the attacker or defender. - - Again, this can be expanded upon. - """ - # For this example, just generate a number between 15 and 25. - damage_value = randint(15, 25) - return damage_value - - -def apply_damage(defender, damage): - """ - Applies damage to a target, reducing their HP by the damage amount to a - minimum of 0. - - Args: - defender (obj): Character taking damage - damage (int): Amount of damage being taken - """ - defender.db.hp -= damage # Reduce defender's HP by the damage dealt. - # If this reduces it to 0 or less, set HP to 0. - if defender.db.hp <= 0: - defender.db.hp = 0 - - -def resolve_attack(attacker, defender, attack_value=None, defense_value=None): - """ - Resolves an attack and outputs the result. - - Args: - attacker (obj): Character doing the attacking - defender (obj): Character being attacked - - Notes: - Even though the attack and defense values are calculated - extremely simply, they are separated out into their own functions - so that they are easier to expand upon. - """ - # Get an attack roll from the attacker. - if not attack_value: - attack_value = get_attack(attacker, defender) - # Get a defense value from the defender. - if not defense_value: - defense_value = get_defense(attacker, defender) - # If the attack value is lower than the defense value, miss. Otherwise, hit. - if attack_value < defense_value: - attacker.location.msg_contents("%s's attack misses %s!" % (attacker, defender)) - else: - damage_value = get_damage(attacker, defender) # Calculate damage value. - # Announce damage dealt and apply damage. - attacker.location.msg_contents("%s hits %s for %i damage!" % (attacker, defender, damage_value)) - apply_damage(defender, damage_value) - # If defender HP is reduced to 0 or less, announce defeat. - if defender.db.hp <= 0: - attacker.location.msg_contents("%s has been defeated!" % defender) - - -def combat_cleanup(character): - """ - Cleans up all the temporary combat-related attributes on a character. - - Args: - character (obj): Character to have their combat attributes removed - - Notes: - Any attribute whose key begins with 'combat_' is temporary and no - longer needed once a fight ends. - """ - for attr in character.attributes.all(): - if attr.key[:7] == "combat_": # If the attribute name starts with 'combat_'... - character.attributes.remove(key=attr.key) # ...then delete it! - - -def is_in_combat(character): - """ - Returns true if the given character is in combat. - - Args: - character (obj): Character to determine if is in combat or not - - Returns: - (bool): True if in combat or False if not in combat - """ - if character.db.Combat_TurnHandler: - return True - return False - - -def is_turn(character): - """ - Returns true if it's currently the given character's turn in combat. - - Args: - character (obj): Character to determine if it is their turn or not - - Returns: - (bool): True if it is their turn or False otherwise - """ - turnhandler = character.db.Combat_TurnHandler - currentchar = turnhandler.db.fighters[turnhandler.db.turn] - if character == currentchar: - return True - return False - - -def spend_action(character, actions, action_name=None): - """ - Spends a character's available combat actions and checks for end of turn. - - Args: - character (obj): Character spending the action - actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions - - Kwargs: - action_name (str or None): If a string is given, sets character's last action in - combat to provided string - """ - if action_name: - character.db.Combat_LastAction = action_name - if actions == 'all': # If spending all actions - character.db.Combat_ActionsLeft = 0 # Set actions to 0 - else: - character.db.Combat_ActionsLeft -= actions # Use up actions. - if character.db.Combat_ActionsLeft < 0: - character.db.Combat_ActionsLeft = 0 # Can't have fewer than 0 actions - character.db.Combat_TurnHandler.turn_end_check(character) # Signal potential end of turn. - - -""" ----------------------------------------------------------------------------- -CHARACTER TYPECLASS ----------------------------------------------------------------------------- -""" - - -class BattleCharacter(DefaultCharacter): - """ - A character able to participate in turn-based combat. Has attributes for current - and maximum HP, and access to combat commands. - """ - - def at_object_creation(self): - """ - Called once, when this object is first created. This is the - normal hook to overload for most object types. - """ - self.db.max_hp = 100 # Set maximum HP to 100 - self.db.hp = self.db.max_hp # Set current HP to maximum - """ - Adds attributes for a character's current and maximum HP. - We're just going to set this value at '100' by default. - - You may want to expand this to include various 'stats' that - can be changed at creation and factor into combat calculations. - """ - - def at_before_move(self, destination): - """ - Called just before starting to move this object to - destination. - - Args: - destination (Object): The object we are moving to - - Returns: - shouldmove (bool): If we should move or not. - - Notes: - If this method returns False/None, the move is cancelled - before it is even started. - - """ - # Keep the character from moving if at 0 HP or in combat. - if is_in_combat(self): - self.msg("You can't exit a room while in combat!") - return False # Returning false keeps the character from moving. - if self.db.HP <= 0: - self.msg("You can't move, you've been defeated!") - return False - return True - - -""" ----------------------------------------------------------------------------- -COMMANDS START HERE ----------------------------------------------------------------------------- -""" - - -class CmdFight(Command): - """ - Starts a fight with everyone in the same room as you. - - Usage: - fight - - When you start a fight, everyone in the room who is able to - fight is added to combat, and a turn order is randomly rolled. - When it's your turn, you can attack other characters. - """ - key = "fight" - help_category = "combat" - - def func(self): - """ - This performs the actual command. - """ - here = self.caller.location - fighters = [] - - if not self.caller.db.hp: # If you don't have any hp - self.caller.msg("You can't start a fight if you've been defeated!") - return - if is_in_combat(self.caller): # Already in a fight - self.caller.msg("You're already in a fight!") - return - for thing in here.contents: # Test everything in the room to add it to the fight. - if thing.db.HP: # If the object has HP... - fighters.append(thing) # ...then add it to the fight. - if len(fighters) <= 1: # If you're the only able fighter in the room - self.caller.msg("There's nobody here to fight!") - return - if here.db.Combat_TurnHandler: # If there's already a fight going on... - here.msg_contents("%s joins the fight!" % self.caller) - here.db.Combat_TurnHandler.join_fight(self.caller) # Join the fight! - return - here.msg_contents("%s starts a fight!" % self.caller) - # Add a turn handler script to the room, which starts combat. - here.scripts.add("contrib.turnbattle.TurnHandler") - # Remember you'll have to change the path to the script if you copy this code to your own modules! - - -class CmdAttack(Command): - """ - Attacks another character. - - Usage: - attack - - When in a fight, you may attack another character. The attack has - a chance to hit, and if successful, will deal damage. - """ - - key = "attack" - help_category = "combat" - - def func(self): - "This performs the actual command." - "Set the attacker to the caller and the defender to the target." - - if not is_in_combat(self.caller): # If not in combat, can't attack. - self.caller.msg("You can only do that in combat. (see: help fight)") - return - - if not is_turn(self.caller): # If it's not your turn, can't attack. - self.caller.msg("You can only do that on your turn.") - return - - if not self.caller.db.hp: # Can't attack if you have no HP. - self.caller.msg("You can't attack, you've been defeated.") - return - - attacker = self.caller - defender = self.caller.search(self.args) - - if not defender: # No valid target given. - return - - if not defender.db.hp: # Target object has no HP left or to begin with - self.caller.msg("You can't fight that!") - return - - if attacker == defender: # Target and attacker are the same - self.caller.msg("You can't attack yourself!") - return - - "If everything checks out, call the attack resolving function." - resolve_attack(attacker, defender) - spend_action(self.caller, 1, action_name="attack") # Use up one action. - - -class CmdPass(Command): - """ - Passes on your turn. - - Usage: - pass - - When in a fight, you can use this command to end your turn early, even - if there are still any actions you can take. - """ - - key = "pass" - aliases = ["wait", "hold"] - help_category = "combat" - - def func(self): - """ - This performs the actual command. - """ - if not is_in_combat(self.caller): # Can only pass a turn in combat. - self.caller.msg("You can only do that in combat. (see: help fight)") - return - - if not is_turn(self.caller): # Can only pass if it's your turn. - self.caller.msg("You can only do that on your turn.") - return - - self.caller.location.msg_contents("%s takes no further action, passing the turn." % self.caller) - spend_action(self.caller, 'all', action_name="pass") # Spend all remaining actions. - - -class CmdDisengage(Command): - """ - Passes your turn and attempts to end combat. - - Usage: - disengage - - Ends your turn early and signals that you're trying to end - the fight. If all participants in a fight disengage, the - fight ends. - """ - - key = "disengage" - aliases = ["spare"] - help_category = "combat" - - def func(self): - """ - This performs the actual command. - """ - if not is_in_combat(self.caller): # If you're not in combat - self.caller.msg("You can only do that in combat. (see: help fight)") - return - - if not is_turn(self.caller): # If it's not your turn - self.caller.msg("You can only do that on your turn.") - return - - self.caller.location.msg_contents("%s disengages, ready to stop fighting." % self.caller) - spend_action(self.caller, 'all', action_name="disengage") # Spend all remaining actions. - """ - The action_name kwarg sets the character's last action to "disengage", which is checked by - the turn handler script to see if all fighters have disengaged. - """ - - -class CmdRest(Command): - """ - Recovers damage. - - Usage: - rest - - Resting recovers your HP to its maximum, but you can only - rest if you're not in a fight. - """ - - key = "rest" - help_category = "combat" - - def func(self): - "This performs the actual command." - - if is_in_combat(self.caller): # If you're in combat - self.caller.msg("You can't rest while you're in combat.") - return - - self.caller.db.hp = self.caller.db.max_hp # Set current HP to maximum - self.caller.location.msg_contents("%s rests to recover HP." % self.caller) - """ - You'll probably want to replace this with your own system for recovering HP. - """ - - -class CmdCombatHelp(CmdHelp): - """ - View help or a list of topics - - Usage: - help - help list - help all - - This will search for help on commands and other - topics related to the game. - """ - # Just like the default help command, but will give quick - # tips on combat when used in a fight with no arguments. - - def func(self): - if is_in_combat(self.caller) and not self.args: # In combat and entered 'help' alone - self.caller.msg("Available combat commands:|/" + - "|wAttack:|n Attack a target, attempting to deal damage.|/" + - "|wPass:|n Pass your turn without further action.|/" + - "|wDisengage:|n End your turn and attempt to end combat.|/") - else: - super(CmdCombatHelp, self).func() # Call the default help command - - -class BattleCmdSet(default_cmds.CharacterCmdSet): - """ - This command set includes all the commmands used in the battle system. - """ - key = "DefaultCharacter" - - def at_cmdset_creation(self): - """ - Populates the cmdset - """ - self.add(CmdFight()) - self.add(CmdAttack()) - self.add(CmdRest()) - self.add(CmdPass()) - self.add(CmdDisengage()) - self.add(CmdCombatHelp()) - - -""" ----------------------------------------------------------------------------- -SCRIPTS START HERE ----------------------------------------------------------------------------- -""" - - -class TurnHandler(DefaultScript): - """ - This is the script that handles the progression of combat through turns. - On creation (when a fight is started) it adds all combat-ready characters - to its roster and then sorts them into a turn order. There can only be one - fight going on in a single room at a time, so the script is assigned to a - room as its object. - - Fights persist until only one participant is left with any HP or all - remaining participants choose to end the combat with the 'disengage' command. - """ - - def at_script_creation(self): - """ - Called once, when the script is created. - """ - self.key = "Combat Turn Handler" - self.interval = 5 # Once every 5 seconds - self.persistent = True - self.db.fighters = [] - - # Add all fighters in the room with at least 1 HP to the combat." - for object in self.obj.contents: - if object.db.hp: - self.db.fighters.append(object) - - # Initialize each fighter for combat - for fighter in self.db.fighters: - self.initialize_for_combat(fighter) - - # Add a reference to this script to the room - self.obj.db.Combat_TurnHandler = self - - # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order. - # The initiative roll is determined by the roll_init function and can be customized easily. - ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True) - self.db.fighters = ordered_by_roll - - # Announce the turn order. - self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters)) - - # Set up the current turn and turn timeout delay. - self.db.turn = 0 - self.db.timer = 30 # 30 seconds - - def at_stop(self): - """ - Called at script termination. - """ - for fighter in self.db.fighters: - combat_cleanup(fighter) # Clean up the combat attributes for every fighter. - self.obj.db.Combat_TurnHandler = None # Remove reference to turn handler in location - - def at_repeat(self): - """ - Called once every self.interval seconds. - """ - currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order. - self.db.timer -= self.interval # Count down the timer. - - if self.db.timer <= 0: - # Force current character to disengage if timer runs out. - self.obj.msg_contents("%s's turn timed out!" % currentchar) - spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions. - return - elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left - # Warn the current character if they're about to time out. - currentchar.msg("WARNING: About to time out!") - self.db.timeout_warning_given = True - - def initialize_for_combat(self, character): - """ - Prepares a character for combat when starting or entering a fight. - - Args: - character (obj): Character to initialize for combat. - """ - combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. - character.db.Combat_ActionsLeft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 - character.db.Combat_TurnHandler = self # Add a reference to this turn handler script to the character - character.db.Combat_LastAction = "null" # Track last action taken in combat - - def start_turn(self, character): - """ - Readies a character for the start of their turn by replenishing their - available actions and notifying them that their turn has come up. - - Args: - character (obj): Character to be readied. - - Notes: - Here, you only get one action per turn, but you might want to allow more than - one per turn, or even grant a number of actions based on a character's - attributes. You can even add multiple different kinds of actions, I.E. actions - separated for movement, by adding "character.db.Combat_MovesLeft = 3" or - something similar. - """ - character.db.Combat_ActionsLeft = 1 # 1 action per turn. - # Prompt the character for their turn and give some information. - character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp) - - def next_turn(self): - """ - Advances to the next character in the turn order. - """ - - # Check to see if every character disengaged as their last action. If so, end combat. - disengage_check = True - for fighter in self.db.fighters: - if fighter.db.Combat_LastAction != "disengage": # If a character has done anything but disengage - disengage_check = False - if disengage_check: # All characters have disengaged - self.obj.msg_contents("All fighters have disengaged! Combat is over!") - self.stop() # Stop this script and end combat. - return - - # Check to see if only one character is left standing. If so, end combat. - defeated_characters = 0 - for fighter in self.db.fighters: - if fighter.db.HP == 0: - defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated) - if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated - for fighter in self.db.fighters: - if fighter.db.HP != 0: - LastStanding = fighter # Pick the one fighter left with HP remaining - self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding) - self.stop() # Stop this script and end combat. - return - - # Cycle to the next turn. - currentchar = self.db.fighters[self.db.turn] - self.db.turn += 1 # Go to the next in the turn order. - if self.db.turn > len(self.db.fighters) - 1: - self.db.turn = 0 # Go back to the first in the turn order once you reach the end. - newchar = self.db.fighters[self.db.turn] # Note the new character - self.db.timer = 30 + self.time_until_next_repeat() # Reset the timer. - self.db.timeout_warning_given = False # Reset the timeout warning. - self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) - self.start_turn(newchar) # Start the new character's turn. - - def turn_end_check(self, character): - """ - Tests to see if a character's turn is over, and cycles to the next turn if it is. - - Args: - character (obj): Character to test for end of turn - """ - if not character.db.Combat_ActionsLeft: # Character has no actions remaining - self.next_turn() - return - - def join_fight(self, character): - """ - Adds a new character to a fight already in progress. - - Args: - character (obj): Character to be added to the fight. - """ - # Inserts the fighter to the turn order, right behind whoever's turn it currently is. - self.db.fighters.insert(self.db.turn, character) - # Tick the turn counter forward one to compensate. - self.db.turn += 1 - # Initialize the character like you do at the start. - self.initialize_for_combat(character) From d150d191e9b2a490e9d3124e21d4d7ccf3c55790 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sat, 14 Oct 2017 22:34:18 -0700 Subject: [PATCH 021/189] Add tb_range - movement & positioning for 'turnbattle' Adds a system for range and movement to the 'turnbattle' contrib. This is based on the abstract movement and positioning system I made for 'The World of Cool Battles', my learning project - fighters' absolute positions are not tracked, only their relative distance to each other and other objects. Commands for movement as well as distinction between melee and ranged attacks are included. --- evennia/contrib/turnbattle/tb_range.py | 1356 ++++++++++++++++++++++++ 1 file changed, 1356 insertions(+) create mode 100644 evennia/contrib/turnbattle/tb_range.py diff --git a/evennia/contrib/turnbattle/tb_range.py b/evennia/contrib/turnbattle/tb_range.py new file mode 100644 index 0000000000..3061d8c873 --- /dev/null +++ b/evennia/contrib/turnbattle/tb_range.py @@ -0,0 +1,1356 @@ +""" +Simple turn-based combat system with range and movement + +Contrib - Tim Ashley Jenkins 2017 + +This is a version of the 'turnbattle' contrib that includes a system +for abstract movement and positioning in combat, including distinction +between melee and ranged attacks. In this system, a fighter or object's +exact position is not recorded - only their relative distance to other +actors in combat. + +In this example, the distance between two objects in combat is expressed +as an integer value: 0 for "engaged" objects that are right next to each +other, 1 for "reach" which is for objects that are near each other but +not directly adjacent, and 2 for "range" for objects that are far apart. + +When combat starts, all fighters are at reach with each other and other +objects, and at range from any exits. On a fighter's turn, they can use +the "approach" command to move closer to an object, or the "withdraw" +command to move further away from an object, either of which takes an +action in combat. In this example, fighters are given two actions per +turn, allowing them to move and attack in the same round, or to attack +twice or move twice. + +When you move toward an object, you will also move toward anything else +that's close to your target - the same goes for moving away from a target, +which will also move you away from anything close to your target. Moving +toward one target may also move you away from anything you're already +close to, but withdrawing from a target will never inadvertently bring +you closer to anything else. + +In this example, there are two attack commands. 'Attack' can only hit +targets that are 'engaged' (range 0) with you. 'Shoot' can hit any target +on the field, but cannot be used if you are engaged with any other fighters. +In addition, strikes made with the 'attack' command are more accurate than +'shoot' attacks. This is only to provide an example of how melee and ranged +attacks can be made to work differently - you can, of course, modify this +to fit your rules system. + +When in combat, the ranges of objects are also accounted for - you can't +pick up an object unless you're engaged with it, and can't give an object +to another fighter without being engaged with them either. Dropped objects +are automatically assigned a range of 'engaged' with the fighter who dropped +them. Additionally, giving or getting an object will take an action in combat. +Dropping an object does not take an action, but can only be done on your turn. + +When combat ends, all range values are erased and all restrictions on getting +or getting objects are lifted - distances are no longer tracked and objects in +the same room can be considered to be in the same space, as is the default +behavior of Evennia and most MUDs. + +This system allows for strategies in combat involving movement and +positioning to be implemented in your battle system without the use of +a 'grid' of coordinates, which can be difficult and clunky to navigate +in text and disadvantageous to players who use screen readers. This loose, +narrative method of tracking position is based around how the matter is +handled in tabletop RPGs played without a grid - typically, a character's +exact position in a room isn't important, only their relative distance to +other actors. + +You may wish to expand this system with a method of distinguishing allies +from enemies (to prevent allied characters from blocking your ranged attacks) +as well as some method by which melee-focused characters can prevent enemies +from withdrawing or punish them from doing so, such as by granting "attacks of +opportunity" or something similar. If you wish, you can also expand the breadth +of values allowed for range - rather than just 0, 1, and 2, you can allow ranges +to go up to much higher values, and give attacks and movements more varying +values for distance for a more granular system. You may also want to implement +a system for fleeing or changing rooms in combat by approaching exits, which +are objects placed in the range field like any other. + +To install and test, import this module's TBRangeCharacter object into +your game's character.py module: + + from evennia.contrib.turnbattle.tb_range import TBRangeCharacter + +And change your game's character typeclass to inherit from TBRangeCharacter +instead of the default: + + class Character(TBRangeCharacter): + +Next, import this module into your default_cmdsets.py module: + + from evennia.contrib.turnbattle import tb_range + +And add the battle command set to your default command set: + + # + # any commands you add below will overload the default ones. + # + self.add(tb_range.BattleCmdSet()) + +This module is meant to be heavily expanded on, so you may want to copy it +to your game's 'world' folder and modify it there rather than importing it +in your game and using it as-is. +""" + +from random import randint +from evennia import DefaultCharacter, DefaultObject, Command, default_cmds, DefaultScript +from evennia.commands.default.help import CmdHelp + +""" +---------------------------------------------------------------------------- +COMBAT FUNCTIONS START HERE +---------------------------------------------------------------------------- +""" + + +def roll_init(character): + """ + Rolls a number between 1-1000 to determine initiative. + + Args: + character (obj): The character to determine initiative for + + Returns: + initiative (int): The character's place in initiative - higher + numbers go first. + + Notes: + By default, does not reference the character and simply returns + a random integer from 1 to 1000. + + Since the character is passed to this function, you can easily reference + a character's stats to determine an initiative roll - for example, if your + character has a 'dexterity' attribute, you can use it to give that character + an advantage in turn order, like so: + + return (randint(1,20)) + character.db.dexterity + + This way, characters with a higher dexterity will go first more often. + """ + return randint(1, 1000) + + +def get_attack(attacker, defender, attack_type): + """ + Returns a value for an attack roll. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + attack_type (str): Type of attack ('melee' or 'ranged') + + Returns: + attack_value (int): Attack roll value, compared against a defense value + to determine whether an attack hits or misses. + + Notes: + By default, generates a random integer from 1 to 100 without using any + properties from either the attacker or defender, and modifies the result + based on whether it's for a melee or ranged attack. + + This can easily be expanded to return a value based on characters stats, + equipment, and abilities. This is why the attacker and defender are passed + to this function, even though nothing from either one are used in this example. + """ + # For this example, just return a random integer up to 100. + attack_value = randint(1, 100) + # Make melee attacks more accurate, ranged attacks less accurate + if attack_type == "melee": + attack_value += 15 + if attack_type == "ranged": + attack_value -= 15 + return attack_value + + +def get_defense(attacker, defender, attack_type): + """ + Returns a value for defense, which an attack roll must equal or exceed in order + for an attack to hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + attack_type (str): Type of attack ('melee' or 'ranged') + + Returns: + defense_value (int): Defense value, compared against an attack roll + to determine whether an attack hits or misses. + + Notes: + By default, returns 50, not taking any properties of the defender or + attacker into account. + + As above, this can be expanded upon based on character stats and equipment. + """ + # For this example, just return 50, for about a 50/50 chance of hit. + defense_value = 50 + return defense_value + + +def get_damage(attacker, defender): + """ + Returns a value for damage to be deducted from the defender's HP after abilities + successful hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being damaged + + Returns: + damage_value (int): Damage value, which is to be deducted from the defending + character's HP. + + Notes: + By default, returns a random integer from 15 to 25 without using any + properties from either the attacker or defender. + + Again, this can be expanded upon. + """ + # For this example, just generate a number between 15 and 25. + damage_value = randint(15, 25) + return damage_value + + +def apply_damage(defender, damage): + """ + Applies damage to a target, reducing their HP by the damage amount to a + minimum of 0. + + Args: + defender (obj): Character taking damage + damage (int): Amount of damage being taken + """ + defender.db.hp -= damage # Reduce defender's HP by the damage dealt. + # If this reduces it to 0 or less, set HP to 0. + if defender.db.hp <= 0: + defender.db.hp = 0 + + +def resolve_attack(attacker, defender, attack_type, attack_value=None, defense_value=None): + """ + Resolves an attack and outputs the result. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + attack_type (str): Type of attack (melee or ranged) + + Notes: + Even though the attack and defense values are calculated + extremely simply, they are separated out into their own functions + so that they are easier to expand upon. + """ + # Get an attack roll from the attacker. + if not attack_value: + attack_value = get_attack(attacker, defender, attack_type) + # Get a defense value from the defender. + if not defense_value: + defense_value = get_defense(attacker, defender, attack_type) + # If the attack value is lower than the defense value, miss. Otherwise, hit. + if attack_value < defense_value: + attacker.location.msg_contents("%s's %s attack misses %s!" % (attacker, attack_type, defender)) + else: + damage_value = get_damage(attacker, defender) # Calculate damage value. + # Announce damage dealt and apply damage. + attacker.location.msg_contents("%s hits %s with a %s attack for %i damage!" % (attacker, defender, attack_type, damage_value)) + apply_damage(defender, damage_value) + # If defender HP is reduced to 0 or less, announce defeat. + if defender.db.hp <= 0: + attacker.location.msg_contents("%s has been defeated!" % defender) + +def distance_dec(mover, target): + """ + Decreases distance in range field between mover and target. + + Args: + mover (obj): The object moving + target (obj): The object to be moved toward + """ + mover.db.Combat_Range[target] -= 1 + target.db.Combat_Range[mover] = mover.db.Combat_Range[target] + # If this brings mover to range 0 (Engaged): + if mover.db.Combat_Range[target] <= 0: + # Reset range to each other to 0 and copy target's ranges to mover. + target.db.Combat_Range[mover] = 0 + mover.db.Combat_Range = target.db.Combat_Range + # Assure everything else has the same distance from the mover and target, now that they're together + for object in mover.location.contents: + if object != mover and object != target: + object.db.Combat_Range[mover] = object.db.Combat_Range[target] + +def distance_inc(mover, target): + """ + Increases distance in range field between mover and target. + + Args: + mover (obj): The object moving + target (obj): The object to be moved away from + """ + mover.db.Combat_Range[target] += 1 + target.db.Combat_Range[mover] = mover.db.Combat_Range[target] + # Set a cap of 2: + if mover.db.Combat_Range[target] > 2: + target.db.Combat_Range[mover] = 2 + mover.db.Combat_Range[target] = 2 + +def approach(mover, target): + """ + Manages a character's whole approach, including changes in ranges to other characters. + + Args: + mover (obj): The object moving + target (obj): The object to be moved toward + + Notes: + The mover will also automatically move toward any objects that are closer to the + target than the mover is. The mover will also move away from anything they started + out close to. + """ + objects = mover.location.contents + + for thing in objects: + if thing != mover and thing != target: + # Move closer to each object closer to the target than you. + if mover.db.Combat_Range[thing] > target.db.Combat_Range[thing]: + distance_dec(mover, thing) + # Move further from each object that's further from you than from the target. + if mover.db.Combat_Range[thing] < target.db.Combat_Range[thing]: + distance_inc(mover, thing) + # Lastly, move closer to your target. + distance_dec(mover, target) + return + +def withdraw(mover, target): + """ + Manages a character's whole withdrawal, including changes in ranges to other characters. + + Args: + mover (obj): The object moving + target (obj): The object to be moved away from + + Notes: + The mover will also automatically move away from objects that are close to the target + of their withdrawl. The mover will never inadvertently move toward anything else while + withdrawing - they can be considered to be moving to open space. + """ + objects = mover.location.contents + for thing in objects: + if thing != mover and thing != target: + # Move away from each object closer to the target than you, if it's also closer to you than you are to the target. + if mover.db.Combat_Range[thing] >= target.db.Combat_Range[thing] and mover.db.Combat_Range[thing] < mover.db.Combat_Range[thing]: + distance_inc(mover, thing) + # Move away from anything your target is engaged with + if target.db.Combat_Range[thing] == 0: + distance_inc(mover, thing) + # Move away from anything you're engaged with. + if mover.db.Combat_Range[thing] == 0: + distance_inc(mover, thing) + # Then, move away from your target. + distance_inc(mover, target) + return + +def get_range(obj1, obj2): + """ + Gets the combat range between two objects. + + Args: + obj1 (obj): First object + obj2 (obj): Second object + + Returns: + range (int or None): Distance between two objects or None if not applicable + """ + # Return None if not applicable. + if not obj1.db.Combat_Range: + return None + if not obj2.db.Combat_Range: + return None + if obj1 not in obj2.db.Combat_Range: + return None + if obj2 not in obj1.db.Combat_Range: + return None + # Return the range between the two objects. + return obj1.db.Combat_Range[obj2] + +def combat_cleanup(character): + """ + Cleans up all the temporary combat-related attributes on a character. + + Args: + character (obj): Character to have their combat attributes removed + + Notes: + Any attribute whose key begins with 'combat_' is temporary and no + longer needed once a fight ends. + """ + for attr in character.attributes.all(): + if attr.key[:7] == "combat_": # If the attribute name starts with 'combat_'... + character.attributes.remove(key=attr.key) # ...then delete it! + + +def is_in_combat(character): + """ + Returns true if the given character is in combat. + + Args: + character (obj): Character to determine if is in combat or not + + Returns: + (bool): True if in combat or False if not in combat + """ + if character.db.Combat_TurnHandler: + return True + return False + + +def is_turn(character): + """ + Returns true if it's currently the given character's turn in combat. + + Args: + character (obj): Character to determine if it is their turn or not + + Returns: + (bool): True if it is their turn or False otherwise + """ + turnhandler = character.db.Combat_TurnHandler + currentchar = turnhandler.db.fighters[turnhandler.db.turn] + if character == currentchar: + return True + return False + + +def spend_action(character, actions, action_name=None): + """ + Spends a character's available combat actions and checks for end of turn. + + Args: + character (obj): Character spending the action + actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions + + Kwargs: + action_name (str or None): If a string is given, sets character's last action in + combat to provided string + """ + if action_name: + character.db.Combat_LastAction = action_name + if actions == 'all': # If spending all actions + character.db.Combat_ActionsLeft = 0 # Set actions to 0 + else: + character.db.Combat_ActionsLeft -= actions # Use up actions. + if character.db.Combat_ActionsLeft < 0: + character.db.Combat_ActionsLeft = 0 # Can't have fewer than 0 actions + character.db.Combat_TurnHandler.turn_end_check(character) # Signal potential end of turn. + +def combat_status_message(fighter): + """ + Sends a message to a player with their current HP and + distances to other fighters and objects. Called at turn + start and by the 'status' command. + """ + + status_msg = ("HP Remaining: %i / %i" % (fighter.db.hp, fighter.db.max_hp)) + + if not is_in_combat(fighter): + fighter.msg(status_msg) + return + + engaged_obj = [] + reach_obj = [] + range_obj = [] + + for object in fighter.db.Combat_Range: + if object != fighter: + if fighter.db.Combat_Range[object] == 0: + engaged_obj.append(object) + if fighter.db.Combat_Range[object] == 1: + reach_obj.append(object) + if fighter.db.Combat_Range[object] > 1: + range_obj.append(object) + + if engaged_obj: + status_msg += "|/Engaged targets: %s" % ", ".join(obj.key for obj in engaged_obj) + if reach_obj: + status_msg += "|/Reach targets: %s" % ", ".join(obj.key for obj in reach_obj) + if range_obj: + status_msg += "|/Ranged targets: %s" % ", ".join(obj.key for obj in range_obj) + + fighter.msg(status_msg) + return + + + +""" +---------------------------------------------------------------------------- +TYPECLASSES START HERE +---------------------------------------------------------------------------- +""" + + +class TBRangeCharacter(DefaultCharacter): + """ + A character able to participate in turn-based combat. Has attributes for current + and maximum HP, and access to combat commands. + """ + + def at_object_creation(self): + """ + Called once, when this object is first created. This is the + normal hook to overload for most object types. + """ + self.db.max_hp = 100 # Set maximum HP to 100 + self.db.hp = self.db.max_hp # Set current HP to maximum + """ + Adds attributes for a character's current and maximum HP. + We're just going to set this value at '100' by default. + + You may want to expand this to include various 'stats' that + can be changed at creation and factor into combat calculations. + """ + + def at_before_move(self, destination): + """ + Called just before starting to move this object to + destination. + + Args: + destination (Object): The object we are moving to + + Returns: + shouldmove (bool): If we should move or not. + + Notes: + If this method returns False/None, the move is cancelled + before it is even started. + + """ + # Keep the character from moving if at 0 HP or in combat. + if is_in_combat(self): + self.msg("You can't exit a room while in combat!") + return False # Returning false keeps the character from moving. + if self.db.HP <= 0: + self.msg("You can't move, you've been defeated!") + return False + return True + +class TBRangeObject(DefaultObject): + """ + An object that is assigned range values in combat. Getting, giving, and dropping + the object has restrictions in combat - you must be next to an object to get it, + must be next to your target to give them something, and can only interact with + objects on your own turn. + """ + def at_before_drop(self, dropper): + """ + Called by the default `drop` command before this object has been + dropped. + + Args: + dropper (Object): The object which will drop this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + shoulddrop (bool): If the object should be dropped or not. + + Notes: + If this method returns False/None, the dropping is cancelled + before it is even started. + + """ + # Can't drop something if in combat and it's not your turn + if is_in_combat(dropper) and not is_turn(dropper): + dropper.msg("You can only drop things on your turn!") + return False + return True + def at_drop(self, dropper): + """ + Called by the default `drop` command when this object has been + dropped. + + Args: + dropper (Object): The object which just dropped this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Notes: + This hook cannot stop the drop from happening. Use + permissions or the at_before_drop() hook for that. + + """ + # If dropper is currently in combat + if dropper.location.db.Combat_TurnHandler: + # Object joins the range field + self.db.Combat_Range = {} + dropper.location.db.Combat_TurnHandler.join_rangefield(self, anchor_obj=dropper) + def at_before_get(self, getter): + """ + Called by the default `get` command before this object has been + picked up. + + Args: + getter (Object): The object about to get this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + shouldget (bool): If the object should be gotten or not. + + Notes: + If this method returns False/None, the getting is cancelled + before it is even started. + """ + # Restrictions for getting in combat + if is_in_combat(getter): + if not is_turn(getter): # Not your turn + getter.msg("You can only get things on your turn!") + return False + if get_range(self, getter) > 0: # Too far away + getter.msg("You aren't close enough to get that! (see: help approach)") + return False + return True + def at_get(self, getter): + """ + Called by the default `get` command when this object has been + picked up. + + Args: + getter (Object): The object getting this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Notes: + This hook cannot stop the pickup from happening. Use + permissions or the at_before_get() hook for that. + + """ + # If gotten, erase range values + if self.db.Combat_Range: + del self.db.Combat_Range + # Remove this object from everyone's range fields + for object in getter.location.contents: + if object.db.Combat_Range: + if self in object.db.Combat_Range: + object.db.Combat_Range.pop(self, None) + # If in combat, getter spends an action + if is_in_combat(getter): + spend_action(getter, 1, action_name="get") # Use up one action. + def at_before_give(self, giver, getter): + """ + Called by the default `give` command before this object has been + given. + + Args: + giver (Object): The object about to give this object. + getter (Object): The object about to get this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + shouldgive (bool): If the object should be given or not. + + Notes: + If this method returns False/None, the giving is cancelled + before it is even started. + + """ + # Restrictions for giving in combat + if is_in_combat(giver): + if not is_turn(giver): # Not your turn + giver.msg("You can only give things on your turn!") + return False + if get_range(giver, getter) > 0: # Too far away from target + giver.msg("You aren't close enough to give things to %s! (see: help approach)" % getter) + return False + return True + def at_give(self, giver, getter): + """ + Called by the default `give` command when this object has been + given. + + Args: + giver (Object): The object giving this object. + getter (Object): The object getting this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Notes: + This hook cannot stop the give from happening. Use + permissions or the at_before_give() hook for that. + + """ + # Spend an action if in combat + if is_in_combat(giver): + spend_action(giver, 1, action_name="give") # Use up one action. + +""" +---------------------------------------------------------------------------- +COMMANDS START HERE +---------------------------------------------------------------------------- +""" + + +class CmdFight(Command): + """ + Starts a fight with everyone in the same room as you. + + Usage: + fight + + When you start a fight, everyone in the room who is able to + fight is added to combat, and a turn order is randomly rolled. + When it's your turn, you can attack other characters. + """ + key = "fight" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + here = self.caller.location + fighters = [] + + if not self.caller.db.hp: # If you don't have any hp + self.caller.msg("You can't start a fight if you've been defeated!") + return + if is_in_combat(self.caller): # Already in a fight + self.caller.msg("You're already in a fight!") + return + for thing in here.contents: # Test everything in the room to add it to the fight. + if thing.db.HP: # If the object has HP... + fighters.append(thing) # ...then add it to the fight. + if len(fighters) <= 1: # If you're the only able fighter in the room + self.caller.msg("There's nobody here to fight!") + return + if here.db.Combat_TurnHandler: # If there's already a fight going on... + here.msg_contents("%s joins the fight!" % self.caller) + here.db.Combat_TurnHandler.join_fight(self.caller) # Join the fight! + return + here.msg_contents("%s starts a fight!" % self.caller) + # Add a turn handler script to the room, which starts combat. + here.scripts.add("contrib.turnbattle.tb_range.TBRangeTurnHandler") + # Remember you'll have to change the path to the script if you copy this code to your own modules! + + +class CmdAttack(Command): + """ + Attacks another character in melee. + + Usage: + attack + + When in a fight, you may attack another character. The attack has + a chance to hit, and if successful, will deal damage. You can only + attack engaged targets - that is, targets that are right next to + you. Use the 'approach' command to get closer to a target. + """ + + key = "attack" + help_category = "combat" + + def func(self): + "This performs the actual command." + "Set the attacker to the caller and the defender to the target." + + if not is_in_combat(self.caller): # If not in combat, can't attack. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn, can't attack. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't attack if you have no HP. + self.caller.msg("You can't attack, you've been defeated.") + return + + attacker = self.caller + defender = self.caller.search(self.args) + + if not defender: # No valid target given. + return + + if not defender.db.hp: # Target object has no HP left or to begin with + self.caller.msg("You can't fight that!") + return + + if attacker == defender: # Target and attacker are the same + self.caller.msg("You can't attack yourself!") + return + + if not get_range(attacker, defender) == 0: # Target isn't in melee + self.caller.msg("%s is too far away to attack - you need to get closer! (see: help approach)" % defender) + return + + "If everything checks out, call the attack resolving function." + resolve_attack(attacker, defender, "melee") + spend_action(self.caller, 1, action_name="attack") # Use up one action. + +class CmdShoot(Command): + """ + Attacks another character from range. + + Usage: + shoot + + When in a fight, you may shoot another character. The attack has + a chance to hit, and if successful, will deal damage. You can attack + any target in combat by shooting, but can't shoot if there are any + targets engaged with you. Use the 'withdraw' command to retreat from + nearby enemies. + """ + + key = "shoot" + help_category = "combat" + + def func(self): + "This performs the actual command." + "Set the attacker to the caller and the defender to the target." + + if not is_in_combat(self.caller): # If not in combat, can't attack. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn, can't attack. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't attack if you have no HP. + self.caller.msg("You can't attack, you've been defeated.") + return + + attacker = self.caller + defender = self.caller.search(self.args) + + if not defender: # No valid target given. + return + + if not defender.db.hp: # Target object has no HP left or to begin with + self.caller.msg("You can't fight that!") + return + + if attacker == defender: # Target and attacker are the same + self.caller.msg("You can't attack yourself!") + return + + # Test to see if there are any nearby enemy targets. + in_melee = [] + for target in attacker.db.Combat_Range: + # Object is engaged and has HP + if get_range(attacker, defender) == 0 and target.db.hp and target != self.caller: + in_melee.append(target) # Add to list of targets in melee + + if len(in_melee) > 0: + self.caller.msg("You can't shoot because there are fighters engaged with you (%s) - you need to retreat! (see: help withdraw)" % ", ".join(obj.key for obj in in_melee)) + return + + "If everything checks out, call the attack resolving function." + resolve_attack(attacker, defender, "ranged") + spend_action(self.caller, 1, action_name="attack") # Use up one action. + +class CmdApproach(Command): + """ + Approaches an object. + + Usage: + approach + + Move one space toward a character or object. You can only attack + characters you are 0 spaces away from. + """ + + key = "approach" + help_category = "combat" + + def func(self): + "This performs the actual command." + + if not is_in_combat(self.caller): # If not in combat, can't approach. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn, can't approach. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't approach if you have no HP. + self.caller.msg("You can't move, you've been defeated.") + return + + mover = self.caller + target = self.caller.search(self.args) + + if not target: # No valid target given. + return + + if not target.db.Combat_Range: # Target object is not on the range field + self.caller.msg("You can't move toward that!") + return + + if mover == target: # Target and mover are the same + self.caller.msg("You can't move toward yourself!") + return + + if get_range(mover, target) <= 0: # Already engaged with target + self.caller.msg("You're already next to that target!") + return + + # If everything checks out, call the approach resolving function. + approach(mover, target) + mover.location.msg_contents("%s moves toward %s." % (mover, target)) + spend_action(self.caller, 1, action_name="move") # Use up one action. + +class CmdWithdraw(Command): + """ + Moves away from an object. + + Usage: + withdraw + + Move one space away from a character or object. + """ + + key = "withdraw" + help_category = "combat" + + def func(self): + "This performs the actual command." + + if not is_in_combat(self.caller): # If not in combat, can't withdraw. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn, can't withdraw. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't withdraw if you have no HP. + self.caller.msg("You can't move, you've been defeated.") + return + + mover = self.caller + target = self.caller.search(self.args) + + if not target: # No valid target given. + return + + if not target.db.Combat_Range: # Target object is not on the range field + self.caller.msg("You can't move away from that!") + return + + if mover == target: # Target and mover are the same + self.caller.msg("You can't move away from yourself!") + return + + if mover.db.Combat_Range[target] >= 3: # Already at maximum distance + self.caller.msg("You're as far as you can get from that target!") + return + + # If everything checks out, call the approach resolving function. + withdraw(mover, target) + mover.location.msg_contents("%s moves away from %s." % (mover, target)) + spend_action(self.caller, 1, action_name="move") # Use up one action. + + +class CmdPass(Command): + """ + Passes on your turn. + + Usage: + pass + + When in a fight, you can use this command to end your turn early, even + if there are still any actions you can take. + """ + + key = "pass" + aliases = ["wait", "hold"] + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + if not is_in_combat(self.caller): # Can only pass a turn in combat. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # Can only pass if it's your turn. + self.caller.msg("You can only do that on your turn.") + return + + self.caller.location.msg_contents("%s takes no further action, passing the turn." % self.caller) + spend_action(self.caller, 'all', action_name="pass") # Spend all remaining actions. + + +class CmdDisengage(Command): + """ + Passes your turn and attempts to end combat. + + Usage: + disengage + + Ends your turn early and signals that you're trying to end + the fight. If all participants in a fight disengage, the + fight ends. + """ + + key = "disengage" + aliases = ["spare"] + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + if not is_in_combat(self.caller): # If you're not in combat + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn + self.caller.msg("You can only do that on your turn.") + return + + self.caller.location.msg_contents("%s disengages, ready to stop fighting." % self.caller) + spend_action(self.caller, 'all', action_name="disengage") # Spend all remaining actions. + """ + The action_name kwarg sets the character's last action to "disengage", which is checked by + the turn handler script to see if all fighters have disengaged. + """ + + +class CmdRest(Command): + """ + Recovers damage. + + Usage: + rest + + Resting recovers your HP to its maximum, but you can only + rest if you're not in a fight. + """ + + key = "rest" + help_category = "combat" + + def func(self): + "This performs the actual command." + + if is_in_combat(self.caller): # If you're in combat + self.caller.msg("You can't rest while you're in combat.") + return + + self.caller.db.hp = self.caller.db.max_hp # Set current HP to maximum + self.caller.location.msg_contents("%s rests to recover HP." % self.caller) + """ + You'll probably want to replace this with your own system for recovering HP. + """ + +class CmdStatus(Command): + """ + Gives combat information. + + Usage: + status + + Shows your current and maximum HP and your distance from + other targets in combat. + """ + + key = "status" + help_category = "combat" + + def func(self): + "This performs the actual command." + + combat_status_message(self.caller) + + +class CmdCombatHelp(CmdHelp): + """ + View help or a list of topics + + Usage: + help + help list + help all + + This will search for help on commands and other + topics related to the game. + """ + # Just like the default help command, but will give quick + # tips on combat when used in a fight with no arguments. + + def func(self): + if is_in_combat(self.caller) and not self.args: # In combat and entered 'help' alone + self.caller.msg("Available combat commands:|/" + + "|wAttack:|n Attack an engaged target, attempting to deal damage.|/" + + "|wShoot:|n Attack from a distance, if not engaged with other fighters.|/" + + "|wApproach:|n Move one step cloer to a target.|/" + + "|wWithdraw:|n Move one step away from a target.|/" + + "|wPass:|n Pass your turn without further action.|/" + + "|wStatus:|n View current HP and ranges to other targets.|/" + + "|wDisengage:|n End your turn and attempt to end combat.|/") + else: + super(CmdCombatHelp, self).func() # Call the default help command + + +class BattleCmdSet(default_cmds.CharacterCmdSet): + """ + This command set includes all the commmands used in the battle system. + """ + key = "DefaultCharacter" + + def at_cmdset_creation(self): + """ + Populates the cmdset + """ + self.add(CmdFight()) + self.add(CmdAttack()) + self.add(CmdShoot()) + self.add(CmdRest()) + self.add(CmdPass()) + self.add(CmdDisengage()) + self.add(CmdApproach()) + self.add(CmdWithdraw()) + self.add(CmdStatus()) + self.add(CmdCombatHelp()) + + +""" +---------------------------------------------------------------------------- +SCRIPTS START HERE +---------------------------------------------------------------------------- +""" + + +class TBRangeTurnHandler(DefaultScript): + """ + This is the script that handles the progression of combat through turns. + On creation (when a fight is started) it adds all combat-ready characters + to its roster and then sorts them into a turn order. There can only be one + fight going on in a single room at a time, so the script is assigned to a + room as its object. + + Fights persist until only one participant is left with any HP or all + remaining participants choose to end the combat with the 'disengage' command. + """ + + def at_script_creation(self): + """ + Called once, when the script is created. + """ + self.key = "Combat Turn Handler" + self.interval = 5 # Once every 5 seconds + self.persistent = True + self.db.fighters = [] + + # Add all fighters in the room with at least 1 HP to the combat." + for object in self.obj.contents: + if object.db.hp: + self.db.fighters.append(object) + + # Initialize each fighter for combat + for fighter in self.db.fighters: + self.initialize_for_combat(fighter) + + # Add a reference to this script to the room + self.obj.db.Combat_TurnHandler = self + + # Initialize range field for all objects in the room + for object in self.obj.contents: + self.init_range(object) + + # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order. + # The initiative roll is determined by the roll_init function and can be customized easily. + ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True) + self.db.fighters = ordered_by_roll + + # Announce the turn order. + self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters)) + + # Start first fighter's turn. + self.start_turn(self.db.fighters[0]) + + # Set up the current turn and turn timeout delay. + self.db.turn = 0 + self.db.timer = 30 # 30 seconds + + def at_stop(self): + """ + Called at script termination. + """ + for object in self.obj.contents: + combat_cleanup(object) # Clean up the combat attributes for every object in the room. + self.obj.db.Combat_TurnHandler = None # Remove reference to turn handler in location + + def at_repeat(self): + """ + Called once every self.interval seconds. + """ + currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order. + self.db.timer -= self.interval # Count down the timer. + + if self.db.timer <= 0: + # Force current character to disengage if timer runs out. + self.obj.msg_contents("%s's turn timed out!" % currentchar) + spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions. + return + elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left + # Warn the current character if they're about to time out. + currentchar.msg("WARNING: About to time out!") + self.db.timeout_warning_given = True + + def init_range(self, to_init): + """ + Initializes range values for an object at the start of a fight. + + Args: + to_init (object): Object to initialize range field for. + """ + rangedict = {} + # Get a list of objects in the room. + objectlist = self.obj.contents + for object in objectlist: + # Object always at distance 0 from itself + if object == to_init: + rangedict.update({object:0}) + else: + if object.destination or to_init.destination: + # Start exits at range 2 to put them at the 'edges' + rangedict.update({object:2}) + else: + # Start objects at range 1 from other objects + rangedict.update({object:1}) + to_init.db.Combat_Range = rangedict + + def join_rangefield(self, to_init, anchor_obj=None, add_distance=0): + """ + Adds a new object to the range field of a fight in progress. + + Args: + to_init (object): Object to initialize range field for. + + Kwargs: + anchor_obj (object): Object to copy range values from, or None for a random object. + add_distance (int): Distance to put between to_init object and anchor object. + + """ + # Get a list of room's contents without to_init object. + contents = self.obj.contents + contents.remove(to_init) + # If no anchor object given, pick one in the room at random. + if not anchor_obj: + anchor_obj = contents[randint(0, (len(contents)-1))] + # Copy the range values from the anchor object. + to_init.db.Combat_Range = anchor_obj.db.Combat_Range + # Add the new object to everyone else's ranges. + for object in contents: + new_objects_range = object.db.Combat_Range[anchor_obj] + object.db.Combat_Range.update({to_init:new_objects_range}) + # Set the new object's range to itself to 0. + to_init.db.Combat_Range.update({to_init:0}) + # Add additional distance from anchor object, if any. + for n in range(add_distance): + withdraw(to_init, anchor_obj) + + def initialize_for_combat(self, character): + """ + Prepares a character for combat when starting or entering a fight. + + Args: + character (obj): Character to initialize for combat. + """ + combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. + character.db.Combat_ActionsLeft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + character.db.Combat_TurnHandler = self # Add a reference to this turn handler script to the character + character.db.Combat_LastAction = "null" # Track last action taken in combat + + def start_turn(self, character): + """ + Readies a character for the start of their turn by replenishing their + available actions and notifying them that their turn has come up. + + Args: + character (obj): Character to be readied. + + Notes: + In this example, characters are given two actions per turn. This allows + characters to both move and attack in the same turn (or, alternately, + move twice or attack twice). + """ + character.db.Combat_ActionsLeft = 2 # 2 actions per turn. + # Prompt the character for their turn and give some information. + character.msg("|wIt's your turn!|n") + combat_status_message(character) + + def next_turn(self): + """ + Advances to the next character in the turn order. + """ + + # Check to see if every character disengaged as their last action. If so, end combat. + disengage_check = True + for fighter in self.db.fighters: + if fighter.db.Combat_LastAction != "disengage": # If a character has done anything but disengage + disengage_check = False + if disengage_check: # All characters have disengaged + self.obj.msg_contents("All fighters have disengaged! Combat is over!") + self.stop() # Stop this script and end combat. + return + + # Check to see if only one character is left standing. If so, end combat. + defeated_characters = 0 + for fighter in self.db.fighters: + if fighter.db.HP == 0: + defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated) + if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated + for fighter in self.db.fighters: + if fighter.db.HP != 0: + LastStanding = fighter # Pick the one fighter left with HP remaining + self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding) + self.stop() # Stop this script and end combat. + return + + # Cycle to the next turn. + currentchar = self.db.fighters[self.db.turn] + self.db.turn += 1 # Go to the next in the turn order. + if self.db.turn > len(self.db.fighters) - 1: + self.db.turn = 0 # Go back to the first in the turn order once you reach the end. + newchar = self.db.fighters[self.db.turn] # Note the new character + self.db.timer = 30 + self.time_until_next_repeat() # Reset the timer. + self.db.timeout_warning_given = False # Reset the timeout warning. + self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) + self.start_turn(newchar) # Start the new character's turn. + + def turn_end_check(self, character): + """ + Tests to see if a character's turn is over, and cycles to the next turn if it is. + + Args: + character (obj): Character to test for end of turn + """ + if not character.db.Combat_ActionsLeft: # Character has no actions remaining + self.next_turn() + return + + def join_fight(self, character): + """ + Adds a new character to a fight already in progress. + + Args: + character (obj): Character to be added to the fight. + """ + # Inserts the fighter to the turn order, right behind whoever's turn it currently is. + self.db.fighters.insert(self.db.turn, character) + # Tick the turn counter forward one to compensate. + self.db.turn += 1 + # Initialize the character like you do at the start. + self.initialize_for_combat(character) + # Add the character to the rangefield, at range from everyone, if they're not on it already. + if not character.db.Combat_Range: + self.join_rangefield(character, add_distance=2) From c762d3d7d173b200084cdd29692a47c43ef506d7 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sat, 14 Oct 2017 22:36:18 -0700 Subject: [PATCH 022/189] Update README.md Adds information about the tb_range module to the readme. --- evennia/contrib/turnbattle/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/evennia/contrib/turnbattle/README.md b/evennia/contrib/turnbattle/README.md index 8d709d1ba1..af04f80060 100644 --- a/evennia/contrib/turnbattle/README.md +++ b/evennia/contrib/turnbattle/README.md @@ -21,6 +21,11 @@ implemented and customized: the battle system, including commands for wielding weapons and donning armor, and modifiers to accuracy and damage based on currently used equipment. + + tb_range.py - Adds a system for abstract positioning and movement, which + tracks the distance between different characters and objects in + combat, as well as differentiates between melee and ranged + attacks. This system is meant as a basic framework to start from, and is modeled after the combat systems of popular tabletop role playing games rather than From fe6be5069a5a3aee9c9bfc36b250413080a4773a Mon Sep 17 00:00:00 2001 From: AmberFennek Date: Mon, 16 Oct 2017 15:13:53 -0400 Subject: [PATCH 023/189] Text corrections in comments and strings --- CHANGELOG.md | 2 +- evennia/accounts/accounts.py | 2 +- evennia/commands/default/account.py | 2 +- evennia/commands/default/unloggedin.py | 4 ++-- evennia/contrib/email_login.py | 2 +- evennia/utils/spawner.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cecd0de21..a05d65fdc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ # Sept 2017: Release of Evennia 0.7; upgrade to Django 1.11, change 'Player' to 'Account', rework the website template and a slew of other updates. -Info on what changed and how to migrat is found here: +Info on what changed and how to migrate is found here: https://groups.google.com/forum/#!msg/evennia/0JYYNGY-NfE/cDFaIwmPBAAJ ## Feb 2017: diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 43dfe71795..9feed46d2e 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -761,7 +761,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): elif _MULTISESSION_MODE in (2, 3): # In this mode we by default end up at a character selection # screen. We execute look on the account. - # we make sure to clean up the _playable_characers list in case + # we make sure to clean up the _playable_characters list in case # any was deleted in the interim. self.db._playable_characters = [char for char in self.db._playable_characters if char] self.msg(self.at_look(target=self.db._playable_characters, diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index e45e2a9347..90c2fdb954 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -168,7 +168,7 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS): if desc: new_character.db.desc = desc elif not new_character.db.desc: - new_character.db.desc = "This is an Account." + new_character.db.desc = "This is a character." self.msg("Created new character %s. Use |w@ic %s|n to enter the game as this character." % (new_character.key, new_character.key)) diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index 429948b0fb..67e15dfd38 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -293,7 +293,7 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS): session.msg(string) return if not re.findall(r"^[\w. @+\-']+$", password) or not (3 < len(password)): - string = "\n\r Password should be longer than 3 characers. Letters, spaces, digits and @/./+/-/_/' only." \ + string = "\n\r Password should be longer than 3 characters. Letters, spaces, digits and @/./+/-/_/' only." \ "\nFor best security, make it longer than 8 characters. You can also use a phrase of" \ "\nmany words if you enclose the password in double quotes." session.msg(string) @@ -557,7 +557,7 @@ def _create_character(session, new_account, typeclass, home, permissions): # If no description is set, set a default description if not new_character.db.desc: - new_character.db.desc = "This is an Account." + new_character.db.desc = "This is a character." # We need to set this to have @ic auto-connect to this character new_account.db._last_puppet = new_character except Exception as e: diff --git a/evennia/contrib/email_login.py b/evennia/contrib/email_login.py index 01d08abab4..c98bf4aa88 100644 --- a/evennia/contrib/email_login.py +++ b/evennia/contrib/email_login.py @@ -197,7 +197,7 @@ class CmdUnconnectedCreate(MuxCommand): session.msg(string) return if not re.findall(r"^[\w. @+\-']+$", password) or not (3 < len(password)): - string = "\n\r Password should be longer than 3 characers. Letters, spaces, digits and @/./+/-/_/' only." \ + string = "\n\r Password should be longer than 3 characters. Letters, spaces, digits and @/./+/-/_/' only." \ "\nFor best security, make it longer than 8 characters. You can also use a phrase of" \ "\nmany words if you enclose the password in double quotes." session.msg(string) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 4a8ac946c8..6df11985f1 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -174,7 +174,7 @@ def _batch_create_object(*objparams): objects (list): A list of created objects Notes: - The `exec` list will execute arbitrary python code so don't allow this to be availble to + The `exec` list will execute arbitrary python code so don't allow this to be available to unprivileged users! """ From 57f0abe37096e416062f2ff19b2fe013aa7547bb Mon Sep 17 00:00:00 2001 From: Jed Fox Date: Sun, 22 Oct 2017 10:50:36 -0400 Subject: [PATCH 024/189] Add notification support to a few older browsers and Safari --- evennia/web/webclient/static/webclient/js/webclient_gui.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/evennia/web/webclient/static/webclient/js/webclient_gui.js b/evennia/web/webclient/static/webclient/js/webclient_gui.js index ba657858d9..849993dd75 100644 --- a/evennia/web/webclient/static/webclient/js/webclient_gui.js +++ b/evennia/web/webclient/static/webclient/js/webclient_gui.js @@ -374,7 +374,10 @@ function onNewLine(text, originator) { document.title = "(" + unread + ") " + originalTitle; if ("Notification" in window){ if (("notification_popup" in options) && (options["notification_popup"])) { - Notification.requestPermission().then(function(result) { + // There is a Promise-based API for this, but it’s not supported + // in Safari and some older browsers: + // https://developer.mozilla.org/en-US/docs/Web/API/Notification/requestPermission#Browser_compatibility + Notification.requestPermission(function(result) { if(result === "granted") { var title = originalTitle === "" ? "Evennia" : originalTitle; var options = { From 5f516215eec6bfd29cbe4361f31003573c84ed08 Mon Sep 17 00:00:00 2001 From: Nicholas Matlaga Date: Sun, 22 Oct 2017 15:34:13 -0400 Subject: [PATCH 025/189] Change aliases of ExtendedRoom's desc command to reflect 0.7 hanges' --- evennia/contrib/extended_room.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/evennia/contrib/extended_room.py b/evennia/contrib/extended_room.py index 962578fa11..6823ede50e 100644 --- a/evennia/contrib/extended_room.py +++ b/evennia/contrib/extended_room.py @@ -4,7 +4,7 @@ Extended Room Evennia Contribution - Griatch 2012 This is an extended Room typeclass for Evennia. It is supported -by an extended `Look` command and an extended `@desc` command, also +by an extended `Look` command and an extended `desc` command, also in this module. @@ -21,7 +21,7 @@ There is also a general description which is used as fallback if one or more of the seasonal descriptions are not set when their time comes. -An updated `@desc` command allows for setting seasonal descriptions. +An updated `desc` command allows for setting seasonal descriptions. The room uses the `evennia.utils.gametime.GameTime` global script. This is started by default, but if you have deactivated it, you need to @@ -45,13 +45,13 @@ at, without there having to be a database object created for it. The Details are simply stored in a dictionary on the room and if the look command cannot find an object match for a `look ` command it will also look through the available details at the current location -if applicable. An extended `@desc` command is used to set details. +if applicable. An extended `desc` command is used to set details. 4) Extra commands CmdExtendedLook - look command supporting room details - CmdExtendedDesc - @desc command allowing to add seasonal descs and details, + CmdExtendedDesc - desc command allowing to add seasonal descs and details, as well as listing them CmdGameTime - A simple `time` command, displaying the current time and season. @@ -63,7 +63,7 @@ Installation/testing: (see Wiki for how to do this). 2) `@dig` a room of type `contrib.extended_room.ExtendedRoom` (or make it the default room type) -3) Use `@desc` and `@detail` to customize the room, then play around! +3) Use `desc` and `detail` to customize the room, then play around! """ from __future__ import division @@ -333,26 +333,26 @@ class CmdExtendedLook(default_cmds.CmdLook): class CmdExtendedDesc(default_cmds.CmdDesc): """ - `@desc` - describe an object or room. + `desc` - describe an object or room. Usage: - @desc[/switch] [ =] - @detail[/del] [ = ] + desc[/switch] [ =] + detail[/del] [ = ] - Switches for `@desc`: + Switches for `desc`: spring - set description for in current room. summer autumn winter - Switch for `@detail`: + Switch for `detail`: del - delete a named detail. Sets the "desc" attribute on an object. If an object is not given, describe the current room. - The alias `@detail` allows to assign a "detail" (a non-object + The alias `detail` allows to assign a "detail" (a non-object target for the `look` command) to the current room (only). You can also embed special time markers in your room description, like this: @@ -364,11 +364,11 @@ class CmdExtendedDesc(default_cmds.CmdDesc): Text marked this way will only display when the server is truly at the given timeslot. The available times are night, morning, afternoon and evening. - Note that `@detail`, seasons and time-of-day slots only work on rooms in this - version of the `@desc` command. + Note that `detail`, seasons and time-of-day slots only work on rooms in this + version of the `desc` command. """ - aliases = ["@describe", "@detail"] + aliases = ["describe", "detail"] def reset_times(self, obj): """By deleteting the caches we force a re-load.""" @@ -416,7 +416,7 @@ class CmdExtendedDesc(default_cmds.CmdDesc): self.reset_times(location) return else: - # we are doing a @desc call + # we are doing a desc call if not self.args: if location: string = "|wDescriptions on %s|n:\n" % location.key From f356b6b6d34e79ff438907a9aab3265b14f9e7fc Mon Sep 17 00:00:00 2001 From: bclack Date: Sat, 21 Oct 2017 14:46:18 -0500 Subject: [PATCH 026/189] Add newline after multimatch string error message Other utility functions, such as caller.msg(), don't require adding newline because newlines are added implicitly. This change modifies the multimatch string parameter to also not require newline, but add one implicitly. --- evennia/utils/utils.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 1a159991ba..667f6a209b 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -1786,8 +1786,12 @@ def at_search_result(matches, caller, query="", quiet=False, **kwargs): error = kwargs.get("nofound_string") or _("Could not find '%s'." % query) matches = None elif len(matches) > 1: - error = kwargs.get("multimatch_string") or \ - _("More than one match for '%s' (please narrow target):\n" % query) + multimatch_string = kwargs.get("multimatch_string") + if multimatch_string: + error = "%s\n" % multimatch_string + else: + error = _("More than one match for '%s' (please narrow target):\n" % query) + for num, result in enumerate(matches): # we need to consider Commands, where .aliases is a list aliases = result.aliases.all() if hasattr(result.aliases, "all") else result.aliases From 6e619d1949153e00175eff313451c63efeadb2c1 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Mon, 23 Oct 2017 21:02:42 -0700 Subject: [PATCH 027/189] Add at_defeat hook, PEP8 capitalization --- evennia/contrib/turnbattle/tb_basic.py | 57 ++++++---- evennia/contrib/turnbattle/tb_equip.py | 56 ++++++---- evennia/contrib/turnbattle/tb_range.py | 140 ++++++++++++++----------- 3 files changed, 147 insertions(+), 106 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_basic.py b/evennia/contrib/turnbattle/tb_basic.py index 70c81debae..22a96016c8 100644 --- a/evennia/contrib/turnbattle/tb_basic.py +++ b/evennia/contrib/turnbattle/tb_basic.py @@ -167,6 +167,20 @@ def apply_damage(defender, damage): if defender.db.hp <= 0: defender.db.hp = 0 +def at_defeat(defeated): + """ + Announces the defeat of a fighter in combat. + + Args: + defeated (obj): Fighter that's been defeated. + + Notes: + All this does is announce a defeat message by default, but if you + want anything else to happen to defeated fighters (like putting them + into a dying state or something similar) then this is the place to + do it. + """ + defeated.location.msg_contents("%s has been defeated!" % defeated) def resolve_attack(attacker, defender, attack_value=None, defense_value=None): """ @@ -195,10 +209,9 @@ def resolve_attack(attacker, defender, attack_value=None, defense_value=None): # Announce damage dealt and apply damage. attacker.location.msg_contents("%s hits %s for %i damage!" % (attacker, defender, damage_value)) apply_damage(defender, damage_value) - # If defender HP is reduced to 0 or less, announce defeat. + # If defender HP is reduced to 0 or less, call at_defeat. if defender.db.hp <= 0: - attacker.location.msg_contents("%s has been defeated!" % defender) - + at_defeat(defender) def combat_cleanup(character): """ @@ -226,7 +239,7 @@ def is_in_combat(character): Returns: (bool): True if in combat or False if not in combat """ - if character.db.Combat_TurnHandler: + if character.db.combat_turnhandler: return True return False @@ -241,7 +254,7 @@ def is_turn(character): Returns: (bool): True if it is their turn or False otherwise """ - turnhandler = character.db.Combat_TurnHandler + turnhandler = character.db.combat_turnhandler currentchar = turnhandler.db.fighters[turnhandler.db.turn] if character == currentchar: return True @@ -261,14 +274,14 @@ def spend_action(character, actions, action_name=None): combat to provided string """ if action_name: - character.db.Combat_LastAction = action_name + character.db.combat_lastaction = action_name if actions == 'all': # If spending all actions - character.db.Combat_ActionsLeft = 0 # Set actions to 0 + character.db.combat_actionsleft = 0 # Set actions to 0 else: - character.db.Combat_ActionsLeft -= actions # Use up actions. - if character.db.Combat_ActionsLeft < 0: - character.db.Combat_ActionsLeft = 0 # Can't have fewer than 0 actions - character.db.Combat_TurnHandler.turn_end_check(character) # Signal potential end of turn. + character.db.combat_actionsleft -= actions # Use up actions. + if character.db.combat_actionsleft < 0: + character.db.combat_actionsleft = 0 # Can't have fewer than 0 actions + character.db.combat_turnhandler.turn_end_check(character) # Signal potential end of turn. """ @@ -365,9 +378,9 @@ class CmdFight(Command): if len(fighters) <= 1: # If you're the only able fighter in the room self.caller.msg("There's nobody here to fight!") return - if here.db.Combat_TurnHandler: # If there's already a fight going on... + if here.db.combat_turnhandler: # If there's already a fight going on... here.msg_contents("%s joins the fight!" % self.caller) - here.db.Combat_TurnHandler.join_fight(self.caller) # Join the fight! + here.db.combat_turnhandler.join_fight(self.caller) # Join the fight! return here.msg_contents("%s starts a fight!" % self.caller) # Add a turn handler script to the room, which starts combat. @@ -600,7 +613,7 @@ class TBBasicTurnHandler(DefaultScript): self.initialize_for_combat(fighter) # Add a reference to this script to the room - self.obj.db.Combat_TurnHandler = self + self.obj.db.combat_turnhandler = self # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order. # The initiative roll is determined by the roll_init function and can be customized easily. @@ -623,7 +636,7 @@ class TBBasicTurnHandler(DefaultScript): """ for fighter in self.db.fighters: combat_cleanup(fighter) # Clean up the combat attributes for every fighter. - self.obj.db.Combat_TurnHandler = None # Remove reference to turn handler in location + self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location def at_repeat(self): """ @@ -650,9 +663,9 @@ class TBBasicTurnHandler(DefaultScript): character (obj): Character to initialize for combat. """ combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. - character.db.Combat_ActionsLeft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 - character.db.Combat_TurnHandler = self # Add a reference to this turn handler script to the character - character.db.Combat_LastAction = "null" # Track last action taken in combat + character.db.combat_actionsleft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + character.db.combat_turnhandler = self # Add a reference to this turn handler script to the character + character.db.combat_lastaction = "null" # Track last action taken in combat def start_turn(self, character): """ @@ -666,10 +679,10 @@ class TBBasicTurnHandler(DefaultScript): Here, you only get one action per turn, but you might want to allow more than one per turn, or even grant a number of actions based on a character's attributes. You can even add multiple different kinds of actions, I.E. actions - separated for movement, by adding "character.db.Combat_MovesLeft = 3" or + separated for movement, by adding "character.db.combat_movesleft = 3" or something similar. """ - character.db.Combat_ActionsLeft = 1 # 1 action per turn. + character.db.combat_actionsleft = 1 # 1 action per turn. # Prompt the character for their turn and give some information. character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp) @@ -681,7 +694,7 @@ class TBBasicTurnHandler(DefaultScript): # Check to see if every character disengaged as their last action. If so, end combat. disengage_check = True for fighter in self.db.fighters: - if fighter.db.Combat_LastAction != "disengage": # If a character has done anything but disengage + if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage disengage_check = False if disengage_check: # All characters have disengaged self.obj.msg_contents("All fighters have disengaged! Combat is over!") @@ -719,7 +732,7 @@ class TBBasicTurnHandler(DefaultScript): Args: character (obj): Character to test for end of turn """ - if not character.db.Combat_ActionsLeft: # Character has no actions remaining + if not character.db.combat_actionsleft: # Character has no actions remaining self.next_turn() return diff --git a/evennia/contrib/turnbattle/tb_equip.py b/evennia/contrib/turnbattle/tb_equip.py index 7d9ea58442..b415f82428 100644 --- a/evennia/contrib/turnbattle/tb_equip.py +++ b/evennia/contrib/turnbattle/tb_equip.py @@ -203,6 +203,20 @@ def apply_damage(defender, damage): if defender.db.hp <= 0: defender.db.hp = 0 +def at_defeat(defeated): + """ + Announces the defeat of a fighter in combat. + + Args: + defeated (obj): Fighter that's been defeated. + + Notes: + All this does is announce a defeat message by default, but if you + want anything else to happen to defeated fighters (like putting them + into a dying state or something similar) then this is the place to + do it. + """ + defeated.location.msg_contents("%s has been defeated!" % defeated) def resolve_attack(attacker, defender, attack_value=None, defense_value=None): """ @@ -239,9 +253,9 @@ def resolve_attack(attacker, defender, attack_value=None, defense_value=None): else: attacker.location.msg_contents("%s's %s bounces harmlessly off %s!" % (attacker, attackers_weapon, defender)) apply_damage(defender, damage_value) - # If defender HP is reduced to 0 or less, announce defeat. + # If defender HP is reduced to 0 or less, call at_defeat. if defender.db.hp <= 0: - attacker.location.msg_contents("%s has been defeated!" % defender) + at_defeat(defender) def combat_cleanup(character): @@ -270,7 +284,7 @@ def is_in_combat(character): Returns: (bool): True if in combat or False if not in combat """ - if character.db.Combat_TurnHandler: + if character.db.combat_turnhandler: return True return False @@ -285,7 +299,7 @@ def is_turn(character): Returns: (bool): True if it is their turn or False otherwise """ - turnhandler = character.db.Combat_TurnHandler + turnhandler = character.db.combat_turnhandler currentchar = turnhandler.db.fighters[turnhandler.db.turn] if character == currentchar: return True @@ -305,14 +319,14 @@ def spend_action(character, actions, action_name=None): combat to provided string """ if action_name: - character.db.Combat_LastAction = action_name + character.db.combat_lastaction = action_name if actions == 'all': # If spending all actions - character.db.Combat_ActionsLeft = 0 # Set actions to 0 + character.db.combat_actionsleft = 0 # Set actions to 0 else: - character.db.Combat_ActionsLeft -= actions # Use up actions. - if character.db.Combat_ActionsLeft < 0: - character.db.Combat_ActionsLeft = 0 # Can't have fewer than 0 actions - character.db.Combat_TurnHandler.turn_end_check(character) # Signal potential end of turn. + character.db.combat_actionsleft -= actions # Use up actions. + if character.db.combat_actionsleft < 0: + character.db.combat_actionsleft = 0 # Can't have fewer than 0 actions + character.db.combat_turnhandler.turn_end_check(character) # Signal potential end of turn. """ @@ -482,9 +496,9 @@ class CmdFight(Command): if len(fighters) <= 1: # If you're the only able fighter in the room self.caller.msg("There's nobody here to fight!") return - if here.db.Combat_TurnHandler: # If there's already a fight going on... + if here.db.combat_turnhandler: # If there's already a fight going on... here.msg_contents("%s joins the fight!" % self.caller) - here.db.Combat_TurnHandler.join_fight(self.caller) # Join the fight! + here.db.combat_turnhandler.join_fight(self.caller) # Join the fight! return here.msg_contents("%s starts a fight!" % self.caller) # Add a turn handler script to the room, which starts combat. @@ -873,7 +887,7 @@ class TBEquipTurnHandler(DefaultScript): self.initialize_for_combat(fighter) # Add a reference to this script to the room - self.obj.db.Combat_TurnHandler = self + self.obj.db.combat_turnhandler = self # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order. # The initiative roll is determined by the roll_init function and can be customized easily. @@ -896,7 +910,7 @@ class TBEquipTurnHandler(DefaultScript): """ for fighter in self.db.fighters: combat_cleanup(fighter) # Clean up the combat attributes for every fighter. - self.obj.db.Combat_TurnHandler = None # Remove reference to turn handler in location + self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location def at_repeat(self): """ @@ -923,9 +937,9 @@ class TBEquipTurnHandler(DefaultScript): character (obj): Character to initialize for combat. """ combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. - character.db.Combat_ActionsLeft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 - character.db.Combat_TurnHandler = self # Add a reference to this turn handler script to the character - character.db.Combat_LastAction = "null" # Track last action taken in combat + character.db.combat_actionsleft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + character.db.combat_turnhandler = self # Add a reference to this turn handler script to the character + character.db.combat_lastaction = "null" # Track last action taken in combat def start_turn(self, character): """ @@ -939,10 +953,10 @@ class TBEquipTurnHandler(DefaultScript): Here, you only get one action per turn, but you might want to allow more than one per turn, or even grant a number of actions based on a character's attributes. You can even add multiple different kinds of actions, I.E. actions - separated for movement, by adding "character.db.Combat_MovesLeft = 3" or + separated for movement, by adding "character.db.combat_movesleft = 3" or something similar. """ - character.db.Combat_ActionsLeft = 1 # 1 action per turn. + character.db.combat_actionsleft = 1 # 1 action per turn. # Prompt the character for their turn and give some information. character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp) @@ -954,7 +968,7 @@ class TBEquipTurnHandler(DefaultScript): # Check to see if every character disengaged as their last action. If so, end combat. disengage_check = True for fighter in self.db.fighters: - if fighter.db.Combat_LastAction != "disengage": # If a character has done anything but disengage + if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage disengage_check = False if disengage_check: # All characters have disengaged self.obj.msg_contents("All fighters have disengaged! Combat is over!") @@ -992,7 +1006,7 @@ class TBEquipTurnHandler(DefaultScript): Args: character (obj): Character to test for end of turn """ - if not character.db.Combat_ActionsLeft: # Character has no actions remaining + if not character.db.combat_actionsleft: # Character has no actions remaining self.next_turn() return diff --git a/evennia/contrib/turnbattle/tb_range.py b/evennia/contrib/turnbattle/tb_range.py index 3061d8c873..27566d4d27 100644 --- a/evennia/contrib/turnbattle/tb_range.py +++ b/evennia/contrib/turnbattle/tb_range.py @@ -228,6 +228,20 @@ def apply_damage(defender, damage): if defender.db.hp <= 0: defender.db.hp = 0 +def at_defeat(defeated): + """ + Announces the defeat of a fighter in combat. + + Args: + defeated (obj): Fighter that's been defeated. + + Notes: + All this does is announce a defeat message by default, but if you + want anything else to happen to defeated fighters (like putting them + into a dying state or something similar) then this is the place to + do it. + """ + defeated.location.msg_contents("%s has been defeated!" % defeated) def resolve_attack(attacker, defender, attack_type, attack_value=None, defense_value=None): """ @@ -257,9 +271,9 @@ def resolve_attack(attacker, defender, attack_type, attack_value=None, defense_v # Announce damage dealt and apply damage. attacker.location.msg_contents("%s hits %s with a %s attack for %i damage!" % (attacker, defender, attack_type, damage_value)) apply_damage(defender, damage_value) - # If defender HP is reduced to 0 or less, announce defeat. + # If defender HP is reduced to 0 or less, call at_defeat. if defender.db.hp <= 0: - attacker.location.msg_contents("%s has been defeated!" % defender) + at_defeat(defender) def distance_dec(mover, target): """ @@ -269,17 +283,17 @@ def distance_dec(mover, target): mover (obj): The object moving target (obj): The object to be moved toward """ - mover.db.Combat_Range[target] -= 1 - target.db.Combat_Range[mover] = mover.db.Combat_Range[target] + mover.db.combat_range[target] -= 1 + target.db.combat_range[mover] = mover.db.combat_range[target] # If this brings mover to range 0 (Engaged): - if mover.db.Combat_Range[target] <= 0: + if mover.db.combat_range[target] <= 0: # Reset range to each other to 0 and copy target's ranges to mover. - target.db.Combat_Range[mover] = 0 - mover.db.Combat_Range = target.db.Combat_Range + target.db.combat_range[mover] = 0 + mover.db.combat_range = target.db.combat_range # Assure everything else has the same distance from the mover and target, now that they're together for object in mover.location.contents: if object != mover and object != target: - object.db.Combat_Range[mover] = object.db.Combat_Range[target] + object.db.combat_range[mover] = object.db.combat_range[target] def distance_inc(mover, target): """ @@ -289,12 +303,12 @@ def distance_inc(mover, target): mover (obj): The object moving target (obj): The object to be moved away from """ - mover.db.Combat_Range[target] += 1 - target.db.Combat_Range[mover] = mover.db.Combat_Range[target] + mover.db.combat_range[target] += 1 + target.db.combat_range[mover] = mover.db.combat_range[target] # Set a cap of 2: - if mover.db.Combat_Range[target] > 2: - target.db.Combat_Range[mover] = 2 - mover.db.Combat_Range[target] = 2 + if mover.db.combat_range[target] > 2: + target.db.combat_range[mover] = 2 + mover.db.combat_range[target] = 2 def approach(mover, target): """ @@ -314,10 +328,10 @@ def approach(mover, target): for thing in objects: if thing != mover and thing != target: # Move closer to each object closer to the target than you. - if mover.db.Combat_Range[thing] > target.db.Combat_Range[thing]: + if mover.db.combat_range[thing] > target.db.combat_range[thing]: distance_dec(mover, thing) # Move further from each object that's further from you than from the target. - if mover.db.Combat_Range[thing] < target.db.Combat_Range[thing]: + if mover.db.combat_range[thing] < target.db.combat_range[thing]: distance_inc(mover, thing) # Lastly, move closer to your target. distance_dec(mover, target) @@ -340,13 +354,13 @@ def withdraw(mover, target): for thing in objects: if thing != mover and thing != target: # Move away from each object closer to the target than you, if it's also closer to you than you are to the target. - if mover.db.Combat_Range[thing] >= target.db.Combat_Range[thing] and mover.db.Combat_Range[thing] < mover.db.Combat_Range[thing]: + if mover.db.combat_range[thing] >= target.db.combat_range[thing] and mover.db.combat_range[thing] < mover.db.combat_range[thing]: distance_inc(mover, thing) # Move away from anything your target is engaged with - if target.db.Combat_Range[thing] == 0: + if target.db.combat_range[thing] == 0: distance_inc(mover, thing) # Move away from anything you're engaged with. - if mover.db.Combat_Range[thing] == 0: + if mover.db.combat_range[thing] == 0: distance_inc(mover, thing) # Then, move away from your target. distance_inc(mover, target) @@ -364,16 +378,16 @@ def get_range(obj1, obj2): range (int or None): Distance between two objects or None if not applicable """ # Return None if not applicable. - if not obj1.db.Combat_Range: + if not obj1.db.combat_range: return None - if not obj2.db.Combat_Range: + if not obj2.db.combat_range: return None - if obj1 not in obj2.db.Combat_Range: + if obj1 not in obj2.db.combat_range: return None - if obj2 not in obj1.db.Combat_Range: + if obj2 not in obj1.db.combat_range: return None # Return the range between the two objects. - return obj1.db.Combat_Range[obj2] + return obj1.db.combat_range[obj2] def combat_cleanup(character): """ @@ -401,7 +415,7 @@ def is_in_combat(character): Returns: (bool): True if in combat or False if not in combat """ - if character.db.Combat_TurnHandler: + if character.db.combat_turnhandler: return True return False @@ -416,7 +430,7 @@ def is_turn(character): Returns: (bool): True if it is their turn or False otherwise """ - turnhandler = character.db.Combat_TurnHandler + turnhandler = character.db.combat_turnhandler currentchar = turnhandler.db.fighters[turnhandler.db.turn] if character == currentchar: return True @@ -436,14 +450,14 @@ def spend_action(character, actions, action_name=None): combat to provided string """ if action_name: - character.db.Combat_LastAction = action_name + character.db.combat_lastaction = action_name if actions == 'all': # If spending all actions - character.db.Combat_ActionsLeft = 0 # Set actions to 0 + character.db.combat_actionsleft = 0 # Set actions to 0 else: - character.db.Combat_ActionsLeft -= actions # Use up actions. - if character.db.Combat_ActionsLeft < 0: - character.db.Combat_ActionsLeft = 0 # Can't have fewer than 0 actions - character.db.Combat_TurnHandler.turn_end_check(character) # Signal potential end of turn. + character.db.combat_actionsleft -= actions # Use up actions. + if character.db.combat_actionsleft < 0: + character.db.combat_actionsleft = 0 # Can't have fewer than 0 actions + character.db.combat_turnhandler.turn_end_check(character) # Signal potential end of turn. def combat_status_message(fighter): """ @@ -462,13 +476,13 @@ def combat_status_message(fighter): reach_obj = [] range_obj = [] - for object in fighter.db.Combat_Range: + for object in fighter.db.combat_range: if object != fighter: - if fighter.db.Combat_Range[object] == 0: + if fighter.db.combat_range[object] == 0: engaged_obj.append(object) - if fighter.db.Combat_Range[object] == 1: + if fighter.db.combat_range[object] == 1: reach_obj.append(object) - if fighter.db.Combat_Range[object] > 1: + if fighter.db.combat_range[object] > 1: range_obj.append(object) if engaged_obj: @@ -582,10 +596,10 @@ class TBRangeObject(DefaultObject): """ # If dropper is currently in combat - if dropper.location.db.Combat_TurnHandler: + if dropper.location.db.combat_turnhandler: # Object joins the range field - self.db.Combat_Range = {} - dropper.location.db.Combat_TurnHandler.join_rangefield(self, anchor_obj=dropper) + self.db.combat_range = {} + dropper.location.db.combat_turnhandler.join_rangefield(self, anchor_obj=dropper) def at_before_get(self, getter): """ Called by the default `get` command before this object has been @@ -628,13 +642,13 @@ class TBRangeObject(DefaultObject): """ # If gotten, erase range values - if self.db.Combat_Range: - del self.db.Combat_Range + if self.db.combat_range: + del self.db.combat_range # Remove this object from everyone's range fields for object in getter.location.contents: - if object.db.Combat_Range: - if self in object.db.Combat_Range: - object.db.Combat_Range.pop(self, None) + if object.db.combat_range: + if self in object.db.combat_range: + object.db.combat_range.pop(self, None) # If in combat, getter spends an action if is_in_combat(getter): spend_action(getter, 1, action_name="get") # Use up one action. @@ -726,9 +740,9 @@ class CmdFight(Command): if len(fighters) <= 1: # If you're the only able fighter in the room self.caller.msg("There's nobody here to fight!") return - if here.db.Combat_TurnHandler: # If there's already a fight going on... + if here.db.combat_turnhandler: # If there's already a fight going on... here.msg_contents("%s joins the fight!" % self.caller) - here.db.Combat_TurnHandler.join_fight(self.caller) # Join the fight! + here.db.combat_turnhandler.join_fight(self.caller) # Join the fight! return here.msg_contents("%s starts a fight!" % self.caller) # Add a turn handler script to the room, which starts combat. @@ -839,7 +853,7 @@ class CmdShoot(Command): # Test to see if there are any nearby enemy targets. in_melee = [] - for target in attacker.db.Combat_Range: + for target in attacker.db.combat_range: # Object is engaged and has HP if get_range(attacker, defender) == 0 and target.db.hp and target != self.caller: in_melee.append(target) # Add to list of targets in melee @@ -887,7 +901,7 @@ class CmdApproach(Command): if not target: # No valid target given. return - if not target.db.Combat_Range: # Target object is not on the range field + if not target.db.combat_range: # Target object is not on the range field self.caller.msg("You can't move toward that!") return @@ -938,7 +952,7 @@ class CmdWithdraw(Command): if not target: # No valid target given. return - if not target.db.Combat_Range: # Target object is not on the range field + if not target.db.combat_range: # Target object is not on the range field self.caller.msg("You can't move away from that!") return @@ -946,7 +960,7 @@ class CmdWithdraw(Command): self.caller.msg("You can't move away from yourself!") return - if mover.db.Combat_Range[target] >= 3: # Already at maximum distance + if mover.db.combat_range[target] >= 3: # Already at maximum distance self.caller.msg("You're as far as you can get from that target!") return @@ -1159,7 +1173,7 @@ class TBRangeTurnHandler(DefaultScript): self.initialize_for_combat(fighter) # Add a reference to this script to the room - self.obj.db.Combat_TurnHandler = self + self.obj.db.combat_turnhandler = self # Initialize range field for all objects in the room for object in self.obj.contents: @@ -1186,7 +1200,7 @@ class TBRangeTurnHandler(DefaultScript): """ for object in self.obj.contents: combat_cleanup(object) # Clean up the combat attributes for every object in the room. - self.obj.db.Combat_TurnHandler = None # Remove reference to turn handler in location + self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location def at_repeat(self): """ @@ -1226,7 +1240,7 @@ class TBRangeTurnHandler(DefaultScript): else: # Start objects at range 1 from other objects rangedict.update({object:1}) - to_init.db.Combat_Range = rangedict + to_init.db.combat_range = rangedict def join_rangefield(self, to_init, anchor_obj=None, add_distance=0): """ @@ -1247,13 +1261,13 @@ class TBRangeTurnHandler(DefaultScript): if not anchor_obj: anchor_obj = contents[randint(0, (len(contents)-1))] # Copy the range values from the anchor object. - to_init.db.Combat_Range = anchor_obj.db.Combat_Range + to_init.db.combat_range = anchor_obj.db.combat_range # Add the new object to everyone else's ranges. for object in contents: - new_objects_range = object.db.Combat_Range[anchor_obj] - object.db.Combat_Range.update({to_init:new_objects_range}) + new_objects_range = object.db.combat_range[anchor_obj] + object.db.combat_range.update({to_init:new_objects_range}) # Set the new object's range to itself to 0. - to_init.db.Combat_Range.update({to_init:0}) + to_init.db.combat_range.update({to_init:0}) # Add additional distance from anchor object, if any. for n in range(add_distance): withdraw(to_init, anchor_obj) @@ -1266,9 +1280,9 @@ class TBRangeTurnHandler(DefaultScript): character (obj): Character to initialize for combat. """ combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. - character.db.Combat_ActionsLeft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 - character.db.Combat_TurnHandler = self # Add a reference to this turn handler script to the character - character.db.Combat_LastAction = "null" # Track last action taken in combat + character.db.combat_actionsleft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + character.db.combat_turnhandler = self # Add a reference to this turn handler script to the character + character.db.combat_lastaction = "null" # Track last action taken in combat def start_turn(self, character): """ @@ -1283,7 +1297,7 @@ class TBRangeTurnHandler(DefaultScript): characters to both move and attack in the same turn (or, alternately, move twice or attack twice). """ - character.db.Combat_ActionsLeft = 2 # 2 actions per turn. + character.db.combat_actionsleft = 2 # 2 actions per turn. # Prompt the character for their turn and give some information. character.msg("|wIt's your turn!|n") combat_status_message(character) @@ -1296,7 +1310,7 @@ class TBRangeTurnHandler(DefaultScript): # Check to see if every character disengaged as their last action. If so, end combat. disengage_check = True for fighter in self.db.fighters: - if fighter.db.Combat_LastAction != "disengage": # If a character has done anything but disengage + if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage disengage_check = False if disengage_check: # All characters have disengaged self.obj.msg_contents("All fighters have disengaged! Combat is over!") @@ -1334,7 +1348,7 @@ class TBRangeTurnHandler(DefaultScript): Args: character (obj): Character to test for end of turn """ - if not character.db.Combat_ActionsLeft: # Character has no actions remaining + if not character.db.combat_actionsleft: # Character has no actions remaining self.next_turn() return @@ -1352,5 +1366,5 @@ class TBRangeTurnHandler(DefaultScript): # Initialize the character like you do at the start. self.initialize_for_combat(character) # Add the character to the rangefield, at range from everyone, if they're not on it already. - if not character.db.Combat_Range: + if not character.db.combat_range: self.join_rangefield(character, add_distance=2) From 347f161d94c420e5f1bbecd2512ee03289a34adf Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Mon, 23 Oct 2017 21:16:19 -0700 Subject: [PATCH 028/189] Simplified bool returns in is_in_combat, is_turn --- evennia/contrib/turnbattle/tb_basic.py | 8 ++------ evennia/contrib/turnbattle/tb_equip.py | 8 ++------ evennia/contrib/turnbattle/tb_range.py | 8 ++------ 3 files changed, 6 insertions(+), 18 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_basic.py b/evennia/contrib/turnbattle/tb_basic.py index 22a96016c8..e392715238 100644 --- a/evennia/contrib/turnbattle/tb_basic.py +++ b/evennia/contrib/turnbattle/tb_basic.py @@ -239,9 +239,7 @@ def is_in_combat(character): Returns: (bool): True if in combat or False if not in combat """ - if character.db.combat_turnhandler: - return True - return False + return bool(character.db.combat_turnhandler) def is_turn(character): @@ -256,9 +254,7 @@ def is_turn(character): """ turnhandler = character.db.combat_turnhandler currentchar = turnhandler.db.fighters[turnhandler.db.turn] - if character == currentchar: - return True - return False + return bool(character == currentchar) def spend_action(character, actions, action_name=None): diff --git a/evennia/contrib/turnbattle/tb_equip.py b/evennia/contrib/turnbattle/tb_equip.py index b415f82428..e3820393c4 100644 --- a/evennia/contrib/turnbattle/tb_equip.py +++ b/evennia/contrib/turnbattle/tb_equip.py @@ -284,9 +284,7 @@ def is_in_combat(character): Returns: (bool): True if in combat or False if not in combat """ - if character.db.combat_turnhandler: - return True - return False + return bool(character.db.combat_turnhandler) def is_turn(character): @@ -301,9 +299,7 @@ def is_turn(character): """ turnhandler = character.db.combat_turnhandler currentchar = turnhandler.db.fighters[turnhandler.db.turn] - if character == currentchar: - return True - return False + return bool(character == currentchar) def spend_action(character, actions, action_name=None): diff --git a/evennia/contrib/turnbattle/tb_range.py b/evennia/contrib/turnbattle/tb_range.py index 27566d4d27..91ccc3836e 100644 --- a/evennia/contrib/turnbattle/tb_range.py +++ b/evennia/contrib/turnbattle/tb_range.py @@ -415,9 +415,7 @@ def is_in_combat(character): Returns: (bool): True if in combat or False if not in combat """ - if character.db.combat_turnhandler: - return True - return False + return bool(character.db.combat_turnhandler) def is_turn(character): @@ -432,9 +430,7 @@ def is_turn(character): """ turnhandler = character.db.combat_turnhandler currentchar = turnhandler.db.fighters[turnhandler.db.turn] - if character == currentchar: - return True - return False + return bool(character == currentchar) def spend_action(character, actions, action_name=None): From f5760057722199c6823675cd4634e547f4088f23 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Mon, 23 Oct 2017 21:24:20 -0700 Subject: [PATCH 029/189] Moved turn handler script up higher in module --- evennia/contrib/turnbattle/tb_basic.py | 353 +++++++++---------- evennia/contrib/turnbattle/tb_equip.py | 350 +++++++++--------- evennia/contrib/turnbattle/tb_range.py | 470 ++++++++++++------------- 3 files changed, 584 insertions(+), 589 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_basic.py b/evennia/contrib/turnbattle/tb_basic.py index e392715238..d01ad7ed4a 100644 --- a/evennia/contrib/turnbattle/tb_basic.py +++ b/evennia/contrib/turnbattle/tb_basic.py @@ -333,7 +333,182 @@ class TBBasicCharacter(DefaultCharacter): return False return True +""" +---------------------------------------------------------------------------- +SCRIPTS START HERE +---------------------------------------------------------------------------- +""" + +class TBBasicTurnHandler(DefaultScript): + """ + This is the script that handles the progression of combat through turns. + On creation (when a fight is started) it adds all combat-ready characters + to its roster and then sorts them into a turn order. There can only be one + fight going on in a single room at a time, so the script is assigned to a + room as its object. + + Fights persist until only one participant is left with any HP or all + remaining participants choose to end the combat with the 'disengage' command. + """ + + def at_script_creation(self): + """ + Called once, when the script is created. + """ + self.key = "Combat Turn Handler" + self.interval = 5 # Once every 5 seconds + self.persistent = True + self.db.fighters = [] + + # Add all fighters in the room with at least 1 HP to the combat." + for object in self.obj.contents: + if object.db.hp: + self.db.fighters.append(object) + + # Initialize each fighter for combat + for fighter in self.db.fighters: + self.initialize_for_combat(fighter) + + # Add a reference to this script to the room + self.obj.db.combat_turnhandler = self + + # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order. + # The initiative roll is determined by the roll_init function and can be customized easily. + ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True) + self.db.fighters = ordered_by_roll + + # Announce the turn order. + self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters)) + + # Start first fighter's turn. + self.start_turn(self.db.fighters[0]) + + # Set up the current turn and turn timeout delay. + self.db.turn = 0 + self.db.timer = 30 # 30 seconds + + def at_stop(self): + """ + Called at script termination. + """ + for fighter in self.db.fighters: + combat_cleanup(fighter) # Clean up the combat attributes for every fighter. + self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location + + def at_repeat(self): + """ + Called once every self.interval seconds. + """ + currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order. + self.db.timer -= self.interval # Count down the timer. + + if self.db.timer <= 0: + # Force current character to disengage if timer runs out. + self.obj.msg_contents("%s's turn timed out!" % currentchar) + spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions. + return + elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left + # Warn the current character if they're about to time out. + currentchar.msg("WARNING: About to time out!") + self.db.timeout_warning_given = True + + def initialize_for_combat(self, character): + """ + Prepares a character for combat when starting or entering a fight. + + Args: + character (obj): Character to initialize for combat. + """ + combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. + character.db.combat_actionsleft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + character.db.combat_turnhandler = self # Add a reference to this turn handler script to the character + character.db.combat_lastaction = "null" # Track last action taken in combat + + def start_turn(self, character): + """ + Readies a character for the start of their turn by replenishing their + available actions and notifying them that their turn has come up. + + Args: + character (obj): Character to be readied. + + Notes: + Here, you only get one action per turn, but you might want to allow more than + one per turn, or even grant a number of actions based on a character's + attributes. You can even add multiple different kinds of actions, I.E. actions + separated for movement, by adding "character.db.combat_movesleft = 3" or + something similar. + """ + character.db.combat_actionsleft = 1 # 1 action per turn. + # Prompt the character for their turn and give some information. + character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp) + + def next_turn(self): + """ + Advances to the next character in the turn order. + """ + + # Check to see if every character disengaged as their last action. If so, end combat. + disengage_check = True + for fighter in self.db.fighters: + if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage + disengage_check = False + if disengage_check: # All characters have disengaged + self.obj.msg_contents("All fighters have disengaged! Combat is over!") + self.stop() # Stop this script and end combat. + return + + # Check to see if only one character is left standing. If so, end combat. + defeated_characters = 0 + for fighter in self.db.fighters: + if fighter.db.HP == 0: + defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated) + if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated + for fighter in self.db.fighters: + if fighter.db.HP != 0: + LastStanding = fighter # Pick the one fighter left with HP remaining + self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding) + self.stop() # Stop this script and end combat. + return + + # Cycle to the next turn. + currentchar = self.db.fighters[self.db.turn] + self.db.turn += 1 # Go to the next in the turn order. + if self.db.turn > len(self.db.fighters) - 1: + self.db.turn = 0 # Go back to the first in the turn order once you reach the end. + newchar = self.db.fighters[self.db.turn] # Note the new character + self.db.timer = 30 + self.time_until_next_repeat() # Reset the timer. + self.db.timeout_warning_given = False # Reset the timeout warning. + self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) + self.start_turn(newchar) # Start the new character's turn. + + def turn_end_check(self, character): + """ + Tests to see if a character's turn is over, and cycles to the next turn if it is. + + Args: + character (obj): Character to test for end of turn + """ + if not character.db.combat_actionsleft: # Character has no actions remaining + self.next_turn() + return + + def join_fight(self, character): + """ + Adds a new character to a fight already in progress. + + Args: + character (obj): Character to be added to the fight. + """ + # Inserts the fighter to the turn order, right behind whoever's turn it currently is. + self.db.fighters.insert(self.db.turn, character) + # Tick the turn counter forward one to compensate. + self.db.turn += 1 + # Initialize the character like you do at the start. + self.initialize_for_combat(character) + + """ ---------------------------------------------------------------------------- COMMANDS START HERE @@ -568,180 +743,4 @@ class BattleCmdSet(default_cmds.CharacterCmdSet): self.add(CmdRest()) self.add(CmdPass()) self.add(CmdDisengage()) - self.add(CmdCombatHelp()) - - -""" ----------------------------------------------------------------------------- -SCRIPTS START HERE ----------------------------------------------------------------------------- -""" - - -class TBBasicTurnHandler(DefaultScript): - """ - This is the script that handles the progression of combat through turns. - On creation (when a fight is started) it adds all combat-ready characters - to its roster and then sorts them into a turn order. There can only be one - fight going on in a single room at a time, so the script is assigned to a - room as its object. - - Fights persist until only one participant is left with any HP or all - remaining participants choose to end the combat with the 'disengage' command. - """ - - def at_script_creation(self): - """ - Called once, when the script is created. - """ - self.key = "Combat Turn Handler" - self.interval = 5 # Once every 5 seconds - self.persistent = True - self.db.fighters = [] - - # Add all fighters in the room with at least 1 HP to the combat." - for object in self.obj.contents: - if object.db.hp: - self.db.fighters.append(object) - - # Initialize each fighter for combat - for fighter in self.db.fighters: - self.initialize_for_combat(fighter) - - # Add a reference to this script to the room - self.obj.db.combat_turnhandler = self - - # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order. - # The initiative roll is determined by the roll_init function and can be customized easily. - ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True) - self.db.fighters = ordered_by_roll - - # Announce the turn order. - self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters)) - - # Start first fighter's turn. - self.start_turn(self.db.fighters[0]) - - # Set up the current turn and turn timeout delay. - self.db.turn = 0 - self.db.timer = 30 # 30 seconds - - def at_stop(self): - """ - Called at script termination. - """ - for fighter in self.db.fighters: - combat_cleanup(fighter) # Clean up the combat attributes for every fighter. - self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location - - def at_repeat(self): - """ - Called once every self.interval seconds. - """ - currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order. - self.db.timer -= self.interval # Count down the timer. - - if self.db.timer <= 0: - # Force current character to disengage if timer runs out. - self.obj.msg_contents("%s's turn timed out!" % currentchar) - spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions. - return - elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left - # Warn the current character if they're about to time out. - currentchar.msg("WARNING: About to time out!") - self.db.timeout_warning_given = True - - def initialize_for_combat(self, character): - """ - Prepares a character for combat when starting or entering a fight. - - Args: - character (obj): Character to initialize for combat. - """ - combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. - character.db.combat_actionsleft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 - character.db.combat_turnhandler = self # Add a reference to this turn handler script to the character - character.db.combat_lastaction = "null" # Track last action taken in combat - - def start_turn(self, character): - """ - Readies a character for the start of their turn by replenishing their - available actions and notifying them that their turn has come up. - - Args: - character (obj): Character to be readied. - - Notes: - Here, you only get one action per turn, but you might want to allow more than - one per turn, or even grant a number of actions based on a character's - attributes. You can even add multiple different kinds of actions, I.E. actions - separated for movement, by adding "character.db.combat_movesleft = 3" or - something similar. - """ - character.db.combat_actionsleft = 1 # 1 action per turn. - # Prompt the character for their turn and give some information. - character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp) - - def next_turn(self): - """ - Advances to the next character in the turn order. - """ - - # Check to see if every character disengaged as their last action. If so, end combat. - disengage_check = True - for fighter in self.db.fighters: - if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage - disengage_check = False - if disengage_check: # All characters have disengaged - self.obj.msg_contents("All fighters have disengaged! Combat is over!") - self.stop() # Stop this script and end combat. - return - - # Check to see if only one character is left standing. If so, end combat. - defeated_characters = 0 - for fighter in self.db.fighters: - if fighter.db.HP == 0: - defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated) - if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated - for fighter in self.db.fighters: - if fighter.db.HP != 0: - LastStanding = fighter # Pick the one fighter left with HP remaining - self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding) - self.stop() # Stop this script and end combat. - return - - # Cycle to the next turn. - currentchar = self.db.fighters[self.db.turn] - self.db.turn += 1 # Go to the next in the turn order. - if self.db.turn > len(self.db.fighters) - 1: - self.db.turn = 0 # Go back to the first in the turn order once you reach the end. - newchar = self.db.fighters[self.db.turn] # Note the new character - self.db.timer = 30 + self.time_until_next_repeat() # Reset the timer. - self.db.timeout_warning_given = False # Reset the timeout warning. - self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) - self.start_turn(newchar) # Start the new character's turn. - - def turn_end_check(self, character): - """ - Tests to see if a character's turn is over, and cycles to the next turn if it is. - - Args: - character (obj): Character to test for end of turn - """ - if not character.db.combat_actionsleft: # Character has no actions remaining - self.next_turn() - return - - def join_fight(self, character): - """ - Adds a new character to a fight already in progress. - - Args: - character (obj): Character to be added to the fight. - """ - # Inserts the fighter to the turn order, right behind whoever's turn it currently is. - self.db.fighters.insert(self.db.turn, character) - # Tick the turn counter forward one to compensate. - self.db.turn += 1 - # Initialize the character like you do at the start. - self.initialize_for_combat(character) + self.add(CmdCombatHelp()) \ No newline at end of file diff --git a/evennia/contrib/turnbattle/tb_equip.py b/evennia/contrib/turnbattle/tb_equip.py index e3820393c4..41fa93d4dd 100644 --- a/evennia/contrib/turnbattle/tb_equip.py +++ b/evennia/contrib/turnbattle/tb_equip.py @@ -324,6 +324,180 @@ def spend_action(character, actions, action_name=None): character.db.combat_actionsleft = 0 # Can't have fewer than 0 actions character.db.combat_turnhandler.turn_end_check(character) # Signal potential end of turn. +""" +---------------------------------------------------------------------------- +SCRIPTS START HERE +---------------------------------------------------------------------------- +""" + + +class TBEquipTurnHandler(DefaultScript): + """ + This is the script that handles the progression of combat through turns. + On creation (when a fight is started) it adds all combat-ready characters + to its roster and then sorts them into a turn order. There can only be one + fight going on in a single room at a time, so the script is assigned to a + room as its object. + + Fights persist until only one participant is left with any HP or all + remaining participants choose to end the combat with the 'disengage' command. + """ + + def at_script_creation(self): + """ + Called once, when the script is created. + """ + self.key = "Combat Turn Handler" + self.interval = 5 # Once every 5 seconds + self.persistent = True + self.db.fighters = [] + + # Add all fighters in the room with at least 1 HP to the combat." + for object in self.obj.contents: + if object.db.hp: + self.db.fighters.append(object) + + # Initialize each fighter for combat + for fighter in self.db.fighters: + self.initialize_for_combat(fighter) + + # Add a reference to this script to the room + self.obj.db.combat_turnhandler = self + + # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order. + # The initiative roll is determined by the roll_init function and can be customized easily. + ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True) + self.db.fighters = ordered_by_roll + + # Announce the turn order. + self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters)) + + # Start first fighter's turn. + self.start_turn(self.db.fighters[0]) + + # Set up the current turn and turn timeout delay. + self.db.turn = 0 + self.db.timer = 30 # 30 seconds + + def at_stop(self): + """ + Called at script termination. + """ + for fighter in self.db.fighters: + combat_cleanup(fighter) # Clean up the combat attributes for every fighter. + self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location + + def at_repeat(self): + """ + Called once every self.interval seconds. + """ + currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order. + self.db.timer -= self.interval # Count down the timer. + + if self.db.timer <= 0: + # Force current character to disengage if timer runs out. + self.obj.msg_contents("%s's turn timed out!" % currentchar) + spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions. + return + elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left + # Warn the current character if they're about to time out. + currentchar.msg("WARNING: About to time out!") + self.db.timeout_warning_given = True + + def initialize_for_combat(self, character): + """ + Prepares a character for combat when starting or entering a fight. + + Args: + character (obj): Character to initialize for combat. + """ + combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. + character.db.combat_actionsleft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + character.db.combat_turnhandler = self # Add a reference to this turn handler script to the character + character.db.combat_lastaction = "null" # Track last action taken in combat + + def start_turn(self, character): + """ + Readies a character for the start of their turn by replenishing their + available actions and notifying them that their turn has come up. + + Args: + character (obj): Character to be readied. + + Notes: + Here, you only get one action per turn, but you might want to allow more than + one per turn, or even grant a number of actions based on a character's + attributes. You can even add multiple different kinds of actions, I.E. actions + separated for movement, by adding "character.db.combat_movesleft = 3" or + something similar. + """ + character.db.combat_actionsleft = 1 # 1 action per turn. + # Prompt the character for their turn and give some information. + character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp) + + def next_turn(self): + """ + Advances to the next character in the turn order. + """ + + # Check to see if every character disengaged as their last action. If so, end combat. + disengage_check = True + for fighter in self.db.fighters: + if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage + disengage_check = False + if disengage_check: # All characters have disengaged + self.obj.msg_contents("All fighters have disengaged! Combat is over!") + self.stop() # Stop this script and end combat. + return + + # Check to see if only one character is left standing. If so, end combat. + defeated_characters = 0 + for fighter in self.db.fighters: + if fighter.db.HP == 0: + defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated) + if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated + for fighter in self.db.fighters: + if fighter.db.HP != 0: + LastStanding = fighter # Pick the one fighter left with HP remaining + self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding) + self.stop() # Stop this script and end combat. + return + + # Cycle to the next turn. + currentchar = self.db.fighters[self.db.turn] + self.db.turn += 1 # Go to the next in the turn order. + if self.db.turn > len(self.db.fighters) - 1: + self.db.turn = 0 # Go back to the first in the turn order once you reach the end. + newchar = self.db.fighters[self.db.turn] # Note the new character + self.db.timer = 30 + self.time_until_next_repeat() # Reset the timer. + self.db.timeout_warning_given = False # Reset the timeout warning. + self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) + self.start_turn(newchar) # Start the new character's turn. + + def turn_end_check(self, character): + """ + Tests to see if a character's turn is over, and cycles to the next turn if it is. + + Args: + character (obj): Character to test for end of turn + """ + if not character.db.combat_actionsleft: # Character has no actions remaining + self.next_turn() + return + + def join_fight(self, character): + """ + Adds a new character to a fight already in progress. + + Args: + character (obj): Character to be added to the fight. + """ + # Inserts the fighter to the turn order, right behind whoever's turn it currently is. + self.db.fighters.insert(self.db.turn, character) + # Tick the turn counter forward one to compensate. + self.db.turn += 1 + # Initialize the character like you do at the start. + self.initialize_for_combat(character) """ ---------------------------------------------------------------------------- @@ -844,182 +1018,6 @@ class BattleCmdSet(default_cmds.CharacterCmdSet): self.add(CmdDon()) self.add(CmdDoff()) - -""" ----------------------------------------------------------------------------- -SCRIPTS START HERE ----------------------------------------------------------------------------- -""" - - -class TBEquipTurnHandler(DefaultScript): - """ - This is the script that handles the progression of combat through turns. - On creation (when a fight is started) it adds all combat-ready characters - to its roster and then sorts them into a turn order. There can only be one - fight going on in a single room at a time, so the script is assigned to a - room as its object. - - Fights persist until only one participant is left with any HP or all - remaining participants choose to end the combat with the 'disengage' command. - """ - - def at_script_creation(self): - """ - Called once, when the script is created. - """ - self.key = "Combat Turn Handler" - self.interval = 5 # Once every 5 seconds - self.persistent = True - self.db.fighters = [] - - # Add all fighters in the room with at least 1 HP to the combat." - for object in self.obj.contents: - if object.db.hp: - self.db.fighters.append(object) - - # Initialize each fighter for combat - for fighter in self.db.fighters: - self.initialize_for_combat(fighter) - - # Add a reference to this script to the room - self.obj.db.combat_turnhandler = self - - # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order. - # The initiative roll is determined by the roll_init function and can be customized easily. - ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True) - self.db.fighters = ordered_by_roll - - # Announce the turn order. - self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters)) - - # Start first fighter's turn. - self.start_turn(self.db.fighters[0]) - - # Set up the current turn and turn timeout delay. - self.db.turn = 0 - self.db.timer = 30 # 30 seconds - - def at_stop(self): - """ - Called at script termination. - """ - for fighter in self.db.fighters: - combat_cleanup(fighter) # Clean up the combat attributes for every fighter. - self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location - - def at_repeat(self): - """ - Called once every self.interval seconds. - """ - currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order. - self.db.timer -= self.interval # Count down the timer. - - if self.db.timer <= 0: - # Force current character to disengage if timer runs out. - self.obj.msg_contents("%s's turn timed out!" % currentchar) - spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions. - return - elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left - # Warn the current character if they're about to time out. - currentchar.msg("WARNING: About to time out!") - self.db.timeout_warning_given = True - - def initialize_for_combat(self, character): - """ - Prepares a character for combat when starting or entering a fight. - - Args: - character (obj): Character to initialize for combat. - """ - combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. - character.db.combat_actionsleft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 - character.db.combat_turnhandler = self # Add a reference to this turn handler script to the character - character.db.combat_lastaction = "null" # Track last action taken in combat - - def start_turn(self, character): - """ - Readies a character for the start of their turn by replenishing their - available actions and notifying them that their turn has come up. - - Args: - character (obj): Character to be readied. - - Notes: - Here, you only get one action per turn, but you might want to allow more than - one per turn, or even grant a number of actions based on a character's - attributes. You can even add multiple different kinds of actions, I.E. actions - separated for movement, by adding "character.db.combat_movesleft = 3" or - something similar. - """ - character.db.combat_actionsleft = 1 # 1 action per turn. - # Prompt the character for their turn and give some information. - character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp) - - def next_turn(self): - """ - Advances to the next character in the turn order. - """ - - # Check to see if every character disengaged as their last action. If so, end combat. - disengage_check = True - for fighter in self.db.fighters: - if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage - disengage_check = False - if disengage_check: # All characters have disengaged - self.obj.msg_contents("All fighters have disengaged! Combat is over!") - self.stop() # Stop this script and end combat. - return - - # Check to see if only one character is left standing. If so, end combat. - defeated_characters = 0 - for fighter in self.db.fighters: - if fighter.db.HP == 0: - defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated) - if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated - for fighter in self.db.fighters: - if fighter.db.HP != 0: - LastStanding = fighter # Pick the one fighter left with HP remaining - self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding) - self.stop() # Stop this script and end combat. - return - - # Cycle to the next turn. - currentchar = self.db.fighters[self.db.turn] - self.db.turn += 1 # Go to the next in the turn order. - if self.db.turn > len(self.db.fighters) - 1: - self.db.turn = 0 # Go back to the first in the turn order once you reach the end. - newchar = self.db.fighters[self.db.turn] # Note the new character - self.db.timer = 30 + self.time_until_next_repeat() # Reset the timer. - self.db.timeout_warning_given = False # Reset the timeout warning. - self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) - self.start_turn(newchar) # Start the new character's turn. - - def turn_end_check(self, character): - """ - Tests to see if a character's turn is over, and cycles to the next turn if it is. - - Args: - character (obj): Character to test for end of turn - """ - if not character.db.combat_actionsleft: # Character has no actions remaining - self.next_turn() - return - - def join_fight(self, character): - """ - Adds a new character to a fight already in progress. - - Args: - character (obj): Character to be added to the fight. - """ - # Inserts the fighter to the turn order, right behind whoever's turn it currently is. - self.db.fighters.insert(self.db.turn, character) - # Tick the turn counter forward one to compensate. - self.db.turn += 1 - # Initialize the character like you do at the start. - self.initialize_for_combat(character) - """ ---------------------------------------------------------------------------- PROTOTYPES START HERE diff --git a/evennia/contrib/turnbattle/tb_range.py b/evennia/contrib/turnbattle/tb_range.py index 91ccc3836e..daeb4bad8f 100644 --- a/evennia/contrib/turnbattle/tb_range.py +++ b/evennia/contrib/turnbattle/tb_range.py @@ -491,7 +491,240 @@ def combat_status_message(fighter): fighter.msg(status_msg) return +""" +---------------------------------------------------------------------------- +SCRIPTS START HERE +---------------------------------------------------------------------------- +""" + + +class TBRangeTurnHandler(DefaultScript): + """ + This is the script that handles the progression of combat through turns. + On creation (when a fight is started) it adds all combat-ready characters + to its roster and then sorts them into a turn order. There can only be one + fight going on in a single room at a time, so the script is assigned to a + room as its object. + + Fights persist until only one participant is left with any HP or all + remaining participants choose to end the combat with the 'disengage' command. + """ + + def at_script_creation(self): + """ + Called once, when the script is created. + """ + self.key = "Combat Turn Handler" + self.interval = 5 # Once every 5 seconds + self.persistent = True + self.db.fighters = [] + + # Add all fighters in the room with at least 1 HP to the combat." + for object in self.obj.contents: + if object.db.hp: + self.db.fighters.append(object) + + # Initialize each fighter for combat + for fighter in self.db.fighters: + self.initialize_for_combat(fighter) + + # Add a reference to this script to the room + self.obj.db.combat_turnhandler = self + # Initialize range field for all objects in the room + for object in self.obj.contents: + self.init_range(object) + + # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order. + # The initiative roll is determined by the roll_init function and can be customized easily. + ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True) + self.db.fighters = ordered_by_roll + + # Announce the turn order. + self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters)) + + # Start first fighter's turn. + self.start_turn(self.db.fighters[0]) + + # Set up the current turn and turn timeout delay. + self.db.turn = 0 + self.db.timer = 30 # 30 seconds + + def at_stop(self): + """ + Called at script termination. + """ + for object in self.obj.contents: + combat_cleanup(object) # Clean up the combat attributes for every object in the room. + self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location + + def at_repeat(self): + """ + Called once every self.interval seconds. + """ + currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order. + self.db.timer -= self.interval # Count down the timer. + + if self.db.timer <= 0: + # Force current character to disengage if timer runs out. + self.obj.msg_contents("%s's turn timed out!" % currentchar) + spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions. + return + elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left + # Warn the current character if they're about to time out. + currentchar.msg("WARNING: About to time out!") + self.db.timeout_warning_given = True + + def init_range(self, to_init): + """ + Initializes range values for an object at the start of a fight. + + Args: + to_init (object): Object to initialize range field for. + """ + rangedict = {} + # Get a list of objects in the room. + objectlist = self.obj.contents + for object in objectlist: + # Object always at distance 0 from itself + if object == to_init: + rangedict.update({object:0}) + else: + if object.destination or to_init.destination: + # Start exits at range 2 to put them at the 'edges' + rangedict.update({object:2}) + else: + # Start objects at range 1 from other objects + rangedict.update({object:1}) + to_init.db.combat_range = rangedict + + def join_rangefield(self, to_init, anchor_obj=None, add_distance=0): + """ + Adds a new object to the range field of a fight in progress. + + Args: + to_init (object): Object to initialize range field for. + + Kwargs: + anchor_obj (object): Object to copy range values from, or None for a random object. + add_distance (int): Distance to put between to_init object and anchor object. + + """ + # Get a list of room's contents without to_init object. + contents = self.obj.contents + contents.remove(to_init) + # If no anchor object given, pick one in the room at random. + if not anchor_obj: + anchor_obj = contents[randint(0, (len(contents)-1))] + # Copy the range values from the anchor object. + to_init.db.combat_range = anchor_obj.db.combat_range + # Add the new object to everyone else's ranges. + for object in contents: + new_objects_range = object.db.combat_range[anchor_obj] + object.db.combat_range.update({to_init:new_objects_range}) + # Set the new object's range to itself to 0. + to_init.db.combat_range.update({to_init:0}) + # Add additional distance from anchor object, if any. + for n in range(add_distance): + withdraw(to_init, anchor_obj) + + def initialize_for_combat(self, character): + """ + Prepares a character for combat when starting or entering a fight. + + Args: + character (obj): Character to initialize for combat. + """ + combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. + character.db.combat_actionsleft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + character.db.combat_turnhandler = self # Add a reference to this turn handler script to the character + character.db.combat_lastaction = "null" # Track last action taken in combat + + def start_turn(self, character): + """ + Readies a character for the start of their turn by replenishing their + available actions and notifying them that their turn has come up. + + Args: + character (obj): Character to be readied. + + Notes: + In this example, characters are given two actions per turn. This allows + characters to both move and attack in the same turn (or, alternately, + move twice or attack twice). + """ + character.db.combat_actionsleft = 2 # 2 actions per turn. + # Prompt the character for their turn and give some information. + character.msg("|wIt's your turn!|n") + combat_status_message(character) + + def next_turn(self): + """ + Advances to the next character in the turn order. + """ + + # Check to see if every character disengaged as their last action. If so, end combat. + disengage_check = True + for fighter in self.db.fighters: + if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage + disengage_check = False + if disengage_check: # All characters have disengaged + self.obj.msg_contents("All fighters have disengaged! Combat is over!") + self.stop() # Stop this script and end combat. + return + + # Check to see if only one character is left standing. If so, end combat. + defeated_characters = 0 + for fighter in self.db.fighters: + if fighter.db.HP == 0: + defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated) + if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated + for fighter in self.db.fighters: + if fighter.db.HP != 0: + LastStanding = fighter # Pick the one fighter left with HP remaining + self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding) + self.stop() # Stop this script and end combat. + return + + # Cycle to the next turn. + currentchar = self.db.fighters[self.db.turn] + self.db.turn += 1 # Go to the next in the turn order. + if self.db.turn > len(self.db.fighters) - 1: + self.db.turn = 0 # Go back to the first in the turn order once you reach the end. + newchar = self.db.fighters[self.db.turn] # Note the new character + self.db.timer = 30 + self.time_until_next_repeat() # Reset the timer. + self.db.timeout_warning_given = False # Reset the timeout warning. + self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) + self.start_turn(newchar) # Start the new character's turn. + + def turn_end_check(self, character): + """ + Tests to see if a character's turn is over, and cycles to the next turn if it is. + + Args: + character (obj): Character to test for end of turn + """ + if not character.db.combat_actionsleft: # Character has no actions remaining + self.next_turn() + return + + def join_fight(self, character): + """ + Adds a new character to a fight already in progress. + + Args: + character (obj): Character to be added to the fight. + """ + # Inserts the fighter to the turn order, right behind whoever's turn it currently is. + self.db.fighters.insert(self.db.turn, character) + # Tick the turn counter forward one to compensate. + self.db.turn += 1 + # Initialize the character like you do at the start. + self.initialize_for_combat(character) + # Add the character to the rangefield, at range from everyone, if they're not on it already. + if not character.db.combat_range: + self.join_rangefield(character, add_distance=2) + """ ---------------------------------------------------------------------------- @@ -1128,239 +1361,4 @@ class BattleCmdSet(default_cmds.CharacterCmdSet): self.add(CmdApproach()) self.add(CmdWithdraw()) self.add(CmdStatus()) - self.add(CmdCombatHelp()) - - -""" ----------------------------------------------------------------------------- -SCRIPTS START HERE ----------------------------------------------------------------------------- -""" - - -class TBRangeTurnHandler(DefaultScript): - """ - This is the script that handles the progression of combat through turns. - On creation (when a fight is started) it adds all combat-ready characters - to its roster and then sorts them into a turn order. There can only be one - fight going on in a single room at a time, so the script is assigned to a - room as its object. - - Fights persist until only one participant is left with any HP or all - remaining participants choose to end the combat with the 'disengage' command. - """ - - def at_script_creation(self): - """ - Called once, when the script is created. - """ - self.key = "Combat Turn Handler" - self.interval = 5 # Once every 5 seconds - self.persistent = True - self.db.fighters = [] - - # Add all fighters in the room with at least 1 HP to the combat." - for object in self.obj.contents: - if object.db.hp: - self.db.fighters.append(object) - - # Initialize each fighter for combat - for fighter in self.db.fighters: - self.initialize_for_combat(fighter) - - # Add a reference to this script to the room - self.obj.db.combat_turnhandler = self - - # Initialize range field for all objects in the room - for object in self.obj.contents: - self.init_range(object) - - # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order. - # The initiative roll is determined by the roll_init function and can be customized easily. - ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True) - self.db.fighters = ordered_by_roll - - # Announce the turn order. - self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters)) - - # Start first fighter's turn. - self.start_turn(self.db.fighters[0]) - - # Set up the current turn and turn timeout delay. - self.db.turn = 0 - self.db.timer = 30 # 30 seconds - - def at_stop(self): - """ - Called at script termination. - """ - for object in self.obj.contents: - combat_cleanup(object) # Clean up the combat attributes for every object in the room. - self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location - - def at_repeat(self): - """ - Called once every self.interval seconds. - """ - currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order. - self.db.timer -= self.interval # Count down the timer. - - if self.db.timer <= 0: - # Force current character to disengage if timer runs out. - self.obj.msg_contents("%s's turn timed out!" % currentchar) - spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions. - return - elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left - # Warn the current character if they're about to time out. - currentchar.msg("WARNING: About to time out!") - self.db.timeout_warning_given = True - - def init_range(self, to_init): - """ - Initializes range values for an object at the start of a fight. - - Args: - to_init (object): Object to initialize range field for. - """ - rangedict = {} - # Get a list of objects in the room. - objectlist = self.obj.contents - for object in objectlist: - # Object always at distance 0 from itself - if object == to_init: - rangedict.update({object:0}) - else: - if object.destination or to_init.destination: - # Start exits at range 2 to put them at the 'edges' - rangedict.update({object:2}) - else: - # Start objects at range 1 from other objects - rangedict.update({object:1}) - to_init.db.combat_range = rangedict - - def join_rangefield(self, to_init, anchor_obj=None, add_distance=0): - """ - Adds a new object to the range field of a fight in progress. - - Args: - to_init (object): Object to initialize range field for. - - Kwargs: - anchor_obj (object): Object to copy range values from, or None for a random object. - add_distance (int): Distance to put between to_init object and anchor object. - - """ - # Get a list of room's contents without to_init object. - contents = self.obj.contents - contents.remove(to_init) - # If no anchor object given, pick one in the room at random. - if not anchor_obj: - anchor_obj = contents[randint(0, (len(contents)-1))] - # Copy the range values from the anchor object. - to_init.db.combat_range = anchor_obj.db.combat_range - # Add the new object to everyone else's ranges. - for object in contents: - new_objects_range = object.db.combat_range[anchor_obj] - object.db.combat_range.update({to_init:new_objects_range}) - # Set the new object's range to itself to 0. - to_init.db.combat_range.update({to_init:0}) - # Add additional distance from anchor object, if any. - for n in range(add_distance): - withdraw(to_init, anchor_obj) - - def initialize_for_combat(self, character): - """ - Prepares a character for combat when starting or entering a fight. - - Args: - character (obj): Character to initialize for combat. - """ - combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. - character.db.combat_actionsleft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 - character.db.combat_turnhandler = self # Add a reference to this turn handler script to the character - character.db.combat_lastaction = "null" # Track last action taken in combat - - def start_turn(self, character): - """ - Readies a character for the start of their turn by replenishing their - available actions and notifying them that their turn has come up. - - Args: - character (obj): Character to be readied. - - Notes: - In this example, characters are given two actions per turn. This allows - characters to both move and attack in the same turn (or, alternately, - move twice or attack twice). - """ - character.db.combat_actionsleft = 2 # 2 actions per turn. - # Prompt the character for their turn and give some information. - character.msg("|wIt's your turn!|n") - combat_status_message(character) - - def next_turn(self): - """ - Advances to the next character in the turn order. - """ - - # Check to see if every character disengaged as their last action. If so, end combat. - disengage_check = True - for fighter in self.db.fighters: - if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage - disengage_check = False - if disengage_check: # All characters have disengaged - self.obj.msg_contents("All fighters have disengaged! Combat is over!") - self.stop() # Stop this script and end combat. - return - - # Check to see if only one character is left standing. If so, end combat. - defeated_characters = 0 - for fighter in self.db.fighters: - if fighter.db.HP == 0: - defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated) - if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated - for fighter in self.db.fighters: - if fighter.db.HP != 0: - LastStanding = fighter # Pick the one fighter left with HP remaining - self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding) - self.stop() # Stop this script and end combat. - return - - # Cycle to the next turn. - currentchar = self.db.fighters[self.db.turn] - self.db.turn += 1 # Go to the next in the turn order. - if self.db.turn > len(self.db.fighters) - 1: - self.db.turn = 0 # Go back to the first in the turn order once you reach the end. - newchar = self.db.fighters[self.db.turn] # Note the new character - self.db.timer = 30 + self.time_until_next_repeat() # Reset the timer. - self.db.timeout_warning_given = False # Reset the timeout warning. - self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) - self.start_turn(newchar) # Start the new character's turn. - - def turn_end_check(self, character): - """ - Tests to see if a character's turn is over, and cycles to the next turn if it is. - - Args: - character (obj): Character to test for end of turn - """ - if not character.db.combat_actionsleft: # Character has no actions remaining - self.next_turn() - return - - def join_fight(self, character): - """ - Adds a new character to a fight already in progress. - - Args: - character (obj): Character to be added to the fight. - """ - # Inserts the fighter to the turn order, right behind whoever's turn it currently is. - self.db.fighters.insert(self.db.turn, character) - # Tick the turn counter forward one to compensate. - self.db.turn += 1 - # Initialize the character like you do at the start. - self.initialize_for_combat(character) - # Add the character to the rangefield, at range from everyone, if they're not on it already. - if not character.db.combat_range: - self.join_rangefield(character, add_distance=2) + self.add(CmdCombatHelp()) \ No newline at end of file From 30eea75ad72eff7ee857cbd307abe035cca16166 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Mon, 23 Oct 2017 21:36:42 -0700 Subject: [PATCH 030/189] Options for turn timeout and actions per turn --- evennia/contrib/turnbattle/tb_basic.py | 16 ++++++++++++---- evennia/contrib/turnbattle/tb_equip.py | 16 ++++++++++++---- evennia/contrib/turnbattle/tb_range.py | 16 ++++++++++++---- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_basic.py b/evennia/contrib/turnbattle/tb_basic.py index d01ad7ed4a..017094d0ec 100644 --- a/evennia/contrib/turnbattle/tb_basic.py +++ b/evennia/contrib/turnbattle/tb_basic.py @@ -48,10 +48,18 @@ from evennia.commands.default.help import CmdHelp """ ---------------------------------------------------------------------------- -COMBAT FUNCTIONS START HERE +OPTIONS ---------------------------------------------------------------------------- """ +TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds +ACTIONS_PER_TURN = 1 # Number of actions allowed per turn + +""" +---------------------------------------------------------------------------- +COMBAT FUNCTIONS START HERE +---------------------------------------------------------------------------- +""" def roll_init(character): """ @@ -386,7 +394,7 @@ class TBBasicTurnHandler(DefaultScript): # Set up the current turn and turn timeout delay. self.db.turn = 0 - self.db.timer = 30 # 30 seconds + self.db.timer = TURN_TIMEOUT # Set timer to turn timeout specified in options def at_stop(self): """ @@ -440,7 +448,7 @@ class TBBasicTurnHandler(DefaultScript): separated for movement, by adding "character.db.combat_movesleft = 3" or something similar. """ - character.db.combat_actionsleft = 1 # 1 action per turn. + character.db.combat_actionsleft = ACTIONS_PER_TURN # Replenish actions # Prompt the character for their turn and give some information. character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp) @@ -478,7 +486,7 @@ class TBBasicTurnHandler(DefaultScript): if self.db.turn > len(self.db.fighters) - 1: self.db.turn = 0 # Go back to the first in the turn order once you reach the end. newchar = self.db.fighters[self.db.turn] # Note the new character - self.db.timer = 30 + self.time_until_next_repeat() # Reset the timer. + self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer. self.db.timeout_warning_given = False # Reset the timeout warning. self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) self.start_turn(newchar) # Start the new character's turn. diff --git a/evennia/contrib/turnbattle/tb_equip.py b/evennia/contrib/turnbattle/tb_equip.py index 41fa93d4dd..087cc5b83f 100644 --- a/evennia/contrib/turnbattle/tb_equip.py +++ b/evennia/contrib/turnbattle/tb_equip.py @@ -60,10 +60,18 @@ from evennia.commands.default.help import CmdHelp """ ---------------------------------------------------------------------------- -COMBAT FUNCTIONS START HERE +OPTIONS ---------------------------------------------------------------------------- """ +TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds +ACTIONS_PER_TURN = 1 # Number of actions allowed per turn + +""" +---------------------------------------------------------------------------- +COMBAT FUNCTIONS START HERE +---------------------------------------------------------------------------- +""" def roll_init(character): """ @@ -377,7 +385,7 @@ class TBEquipTurnHandler(DefaultScript): # Set up the current turn and turn timeout delay. self.db.turn = 0 - self.db.timer = 30 # 30 seconds + self.db.timer = TURN_TIMEOUT # Set timer to turn timeout specified in options def at_stop(self): """ @@ -431,7 +439,7 @@ class TBEquipTurnHandler(DefaultScript): separated for movement, by adding "character.db.combat_movesleft = 3" or something similar. """ - character.db.combat_actionsleft = 1 # 1 action per turn. + character.db.combat_actionsleft = ACTIONS_PER_TURN # Replenish actions # Prompt the character for their turn and give some information. character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp) @@ -469,7 +477,7 @@ class TBEquipTurnHandler(DefaultScript): if self.db.turn > len(self.db.fighters) - 1: self.db.turn = 0 # Go back to the first in the turn order once you reach the end. newchar = self.db.fighters[self.db.turn] # Note the new character - self.db.timer = 30 + self.time_until_next_repeat() # Reset the timer. + self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer. self.db.timeout_warning_given = False # Reset the timeout warning. self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) self.start_turn(newchar) # Start the new character's turn. diff --git a/evennia/contrib/turnbattle/tb_range.py b/evennia/contrib/turnbattle/tb_range.py index daeb4bad8f..43d9492f24 100644 --- a/evennia/contrib/turnbattle/tb_range.py +++ b/evennia/contrib/turnbattle/tb_range.py @@ -101,10 +101,18 @@ from evennia.commands.default.help import CmdHelp """ ---------------------------------------------------------------------------- -COMBAT FUNCTIONS START HERE +OPTIONS ---------------------------------------------------------------------------- """ +TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds +ACTIONS_PER_TURN = 1 # Number of actions allowed per turn + +""" +---------------------------------------------------------------------------- +COMBAT FUNCTIONS START HERE +---------------------------------------------------------------------------- +""" def roll_init(character): """ @@ -548,7 +556,7 @@ class TBRangeTurnHandler(DefaultScript): # Set up the current turn and turn timeout delay. self.db.turn = 0 - self.db.timer = 30 # 30 seconds + self.db.timer = TURN_TIMEOUT # Set timer to turn timeout specified in options def at_stop(self): """ @@ -653,7 +661,7 @@ class TBRangeTurnHandler(DefaultScript): characters to both move and attack in the same turn (or, alternately, move twice or attack twice). """ - character.db.combat_actionsleft = 2 # 2 actions per turn. + character.db.combat_actionsleft = ACTIONS_PER_TURN # Replenish actions # Prompt the character for their turn and give some information. character.msg("|wIt's your turn!|n") combat_status_message(character) @@ -692,7 +700,7 @@ class TBRangeTurnHandler(DefaultScript): if self.db.turn > len(self.db.fighters) - 1: self.db.turn = 0 # Go back to the first in the turn order once you reach the end. newchar = self.db.fighters[self.db.turn] # Note the new character - self.db.timer = 30 + self.time_until_next_repeat() # Reset the timer. + self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer. self.db.timeout_warning_given = False # Reset the timeout warning. self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) self.start_turn(newchar) # Start the new character's turn. From b2ec29db81864994a24f92e2aae22c9fe930d4dc Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Mon, 23 Oct 2017 21:51:03 -0700 Subject: [PATCH 031/189] distance_inc & distance_dec changed to helper funcs --- evennia/contrib/turnbattle/tb_range.py | 78 ++++++++++++++------------ 1 file changed, 42 insertions(+), 36 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_range.py b/evennia/contrib/turnbattle/tb_range.py index 43d9492f24..ef2859c677 100644 --- a/evennia/contrib/turnbattle/tb_range.py +++ b/evennia/contrib/turnbattle/tb_range.py @@ -78,6 +78,11 @@ And change your game's character typeclass to inherit from TBRangeCharacter instead of the default: class Character(TBRangeCharacter): + +Do the same thing in your game's objects.py module for TBRangeObject: + + from evennia.contrib.turnbattle.tb_range import TBRangeObject + class Object(TBRangeObject): Next, import this module into your default_cmdsets.py module: @@ -106,7 +111,7 @@ OPTIONS """ TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds -ACTIONS_PER_TURN = 1 # Number of actions allowed per turn +ACTIONS_PER_TURN = 2 # Number of actions allowed per turn """ ---------------------------------------------------------------------------- @@ -282,41 +287,6 @@ def resolve_attack(attacker, defender, attack_type, attack_value=None, defense_v # If defender HP is reduced to 0 or less, call at_defeat. if defender.db.hp <= 0: at_defeat(defender) - -def distance_dec(mover, target): - """ - Decreases distance in range field between mover and target. - - Args: - mover (obj): The object moving - target (obj): The object to be moved toward - """ - mover.db.combat_range[target] -= 1 - target.db.combat_range[mover] = mover.db.combat_range[target] - # If this brings mover to range 0 (Engaged): - if mover.db.combat_range[target] <= 0: - # Reset range to each other to 0 and copy target's ranges to mover. - target.db.combat_range[mover] = 0 - mover.db.combat_range = target.db.combat_range - # Assure everything else has the same distance from the mover and target, now that they're together - for object in mover.location.contents: - if object != mover and object != target: - object.db.combat_range[mover] = object.db.combat_range[target] - -def distance_inc(mover, target): - """ - Increases distance in range field between mover and target. - - Args: - mover (obj): The object moving - target (obj): The object to be moved away from - """ - mover.db.combat_range[target] += 1 - target.db.combat_range[mover] = mover.db.combat_range[target] - # Set a cap of 2: - if mover.db.combat_range[target] > 2: - target.db.combat_range[mover] = 2 - mover.db.combat_range[target] = 2 def approach(mover, target): """ @@ -331,6 +301,26 @@ def approach(mover, target): target than the mover is. The mover will also move away from anything they started out close to. """ + def distance_dec(mover, target): + """ + Helper function that decreases distance in range field between mover and target. + + Args: + mover (obj): The object moving + target (obj): The object to be moved toward + """ + mover.db.combat_range[target] -= 1 + target.db.combat_range[mover] = mover.db.combat_range[target] + # If this brings mover to range 0 (Engaged): + if mover.db.combat_range[target] <= 0: + # Reset range to each other to 0 and copy target's ranges to mover. + target.db.combat_range[mover] = 0 + mover.db.combat_range = target.db.combat_range + # Assure everything else has the same distance from the mover and target, now that they're together + for object in mover.location.contents: + if object != mover and object != target: + object.db.combat_range[mover] = object.db.combat_range[target] + objects = mover.location.contents for thing in objects: @@ -358,7 +348,23 @@ def withdraw(mover, target): of their withdrawl. The mover will never inadvertently move toward anything else while withdrawing - they can be considered to be moving to open space. """ + def distance_inc(mover, target): + """ + Helper function that increases distance in range field between mover and target. + + Args: + mover (obj): The object moving + target (obj): The object to be moved away from + """ + mover.db.combat_range[target] += 1 + target.db.combat_range[mover] = mover.db.combat_range[target] + # Set a cap of 2: + if mover.db.combat_range[target] > 2: + target.db.combat_range[mover] = 2 + mover.db.combat_range[target] = 2 + objects = mover.location.contents + for thing in objects: if thing != mover and thing != target: # Move away from each object closer to the target than you, if it's also closer to you than you are to the target. From e742179310142ce8850273fb0b2ad5c0d2fdf9eb Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Mon, 23 Oct 2017 21:57:00 -0700 Subject: [PATCH 032/189] References to 'object' changed to 'thing' instead --- evennia/contrib/turnbattle/tb_basic.py | 6 +-- evennia/contrib/turnbattle/tb_equip.py | 6 +-- evennia/contrib/turnbattle/tb_range.py | 70 +++++++++++++------------- 3 files changed, 41 insertions(+), 41 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_basic.py b/evennia/contrib/turnbattle/tb_basic.py index 017094d0ec..88e5c176c8 100644 --- a/evennia/contrib/turnbattle/tb_basic.py +++ b/evennia/contrib/turnbattle/tb_basic.py @@ -370,9 +370,9 @@ class TBBasicTurnHandler(DefaultScript): self.db.fighters = [] # Add all fighters in the room with at least 1 HP to the combat." - for object in self.obj.contents: - if object.db.hp: - self.db.fighters.append(object) + for thing in self.obj.contents: + if thing.db.hp: + self.db.fighters.append(thing) # Initialize each fighter for combat for fighter in self.db.fighters: diff --git a/evennia/contrib/turnbattle/tb_equip.py b/evennia/contrib/turnbattle/tb_equip.py index 087cc5b83f..b6b7ae6035 100644 --- a/evennia/contrib/turnbattle/tb_equip.py +++ b/evennia/contrib/turnbattle/tb_equip.py @@ -361,9 +361,9 @@ class TBEquipTurnHandler(DefaultScript): self.db.fighters = [] # Add all fighters in the room with at least 1 HP to the combat." - for object in self.obj.contents: - if object.db.hp: - self.db.fighters.append(object) + for thing in self.obj.contents: + if thing.db.hp: + self.db.fighters.append(thing) # Initialize each fighter for combat for fighter in self.db.fighters: diff --git a/evennia/contrib/turnbattle/tb_range.py b/evennia/contrib/turnbattle/tb_range.py index ef2859c677..bfec0893f0 100644 --- a/evennia/contrib/turnbattle/tb_range.py +++ b/evennia/contrib/turnbattle/tb_range.py @@ -317,13 +317,13 @@ def approach(mover, target): target.db.combat_range[mover] = 0 mover.db.combat_range = target.db.combat_range # Assure everything else has the same distance from the mover and target, now that they're together - for object in mover.location.contents: - if object != mover and object != target: - object.db.combat_range[mover] = object.db.combat_range[target] + for thing in mover.location.contents: + if thing != mover and thing != target: + thing.db.combat_range[mover] = thing.db.combat_range[target] - objects = mover.location.contents + contents = mover.location.contents - for thing in objects: + for thing in contents: if thing != mover and thing != target: # Move closer to each object closer to the target than you. if mover.db.combat_range[thing] > target.db.combat_range[thing]: @@ -363,9 +363,9 @@ def withdraw(mover, target): target.db.combat_range[mover] = 2 mover.db.combat_range[target] = 2 - objects = mover.location.contents + contents = mover.location.contents - for thing in objects: + for thing in contents: if thing != mover and thing != target: # Move away from each object closer to the target than you, if it's also closer to you than you are to the target. if mover.db.combat_range[thing] >= target.db.combat_range[thing] and mover.db.combat_range[thing] < mover.db.combat_range[thing]: @@ -486,14 +486,14 @@ def combat_status_message(fighter): reach_obj = [] range_obj = [] - for object in fighter.db.combat_range: - if object != fighter: - if fighter.db.combat_range[object] == 0: - engaged_obj.append(object) - if fighter.db.combat_range[object] == 1: - reach_obj.append(object) - if fighter.db.combat_range[object] > 1: - range_obj.append(object) + for thing in fighter.db.combat_range: + if thing != fighter: + if fighter.db.combat_range[thing] == 0: + engaged_obj.append(thing) + if fighter.db.combat_range[thing] == 1: + reach_obj.append(thing) + if fighter.db.combat_range[thing] > 1: + range_obj.append(thing) if engaged_obj: status_msg += "|/Engaged targets: %s" % ", ".join(obj.key for obj in engaged_obj) @@ -534,9 +534,9 @@ class TBRangeTurnHandler(DefaultScript): self.db.fighters = [] # Add all fighters in the room with at least 1 HP to the combat." - for object in self.obj.contents: - if object.db.hp: - self.db.fighters.append(object) + for thing in self.obj.contents: + if thing.db.hp: + self.db.fighters.append(thing) # Initialize each fighter for combat for fighter in self.db.fighters: @@ -546,8 +546,8 @@ class TBRangeTurnHandler(DefaultScript): self.obj.db.combat_turnhandler = self # Initialize range field for all objects in the room - for object in self.obj.contents: - self.init_range(object) + for thing in self.obj.contents: + self.init_range(thing) # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order. # The initiative roll is determined by the roll_init function and can be customized easily. @@ -568,8 +568,8 @@ class TBRangeTurnHandler(DefaultScript): """ Called at script termination. """ - for object in self.obj.contents: - combat_cleanup(object) # Clean up the combat attributes for every object in the room. + for thing in self.obj.contents: + combat_cleanup(thing) # Clean up the combat attributes for every object in the room. self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location def at_repeat(self): @@ -599,17 +599,17 @@ class TBRangeTurnHandler(DefaultScript): rangedict = {} # Get a list of objects in the room. objectlist = self.obj.contents - for object in objectlist: + for thing in objectlist: # Object always at distance 0 from itself - if object == to_init: - rangedict.update({object:0}) + if thing == to_init: + rangedict.update({thing:0}) else: - if object.destination or to_init.destination: + if thing.destination or to_init.destination: # Start exits at range 2 to put them at the 'edges' - rangedict.update({object:2}) + rangedict.update({thing:2}) else: # Start objects at range 1 from other objects - rangedict.update({object:1}) + rangedict.update({thing:1}) to_init.db.combat_range = rangedict def join_rangefield(self, to_init, anchor_obj=None, add_distance=0): @@ -633,9 +633,9 @@ class TBRangeTurnHandler(DefaultScript): # Copy the range values from the anchor object. to_init.db.combat_range = anchor_obj.db.combat_range # Add the new object to everyone else's ranges. - for object in contents: - new_objects_range = object.db.combat_range[anchor_obj] - object.db.combat_range.update({to_init:new_objects_range}) + for thing in contents: + new_objects_range = thing.db.combat_range[anchor_obj] + thing.db.combat_range.update({to_init:new_objects_range}) # Set the new object's range to itself to 0. to_init.db.combat_range.update({to_init:0}) # Add additional distance from anchor object, if any. @@ -888,10 +888,10 @@ class TBRangeObject(DefaultObject): if self.db.combat_range: del self.db.combat_range # Remove this object from everyone's range fields - for object in getter.location.contents: - if object.db.combat_range: - if self in object.db.combat_range: - object.db.combat_range.pop(self, None) + for thing in getter.location.contents: + if thing.db.combat_range: + if self in thing.db.combat_range: + thing.db.combat_range.pop(self, None) # If in combat, getter spends an action if is_in_combat(getter): spend_action(getter, 1, action_name="get") # Use up one action. From 66bb313c34e528aeb5a7991e89b3d3c63bd570a8 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Mon, 23 Oct 2017 21:59:01 -0700 Subject: [PATCH 033/189] Empty returns deleted, methods properly spaced --- evennia/contrib/turnbattle/tb_range.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_range.py b/evennia/contrib/turnbattle/tb_range.py index bfec0893f0..143134beec 100644 --- a/evennia/contrib/turnbattle/tb_range.py +++ b/evennia/contrib/turnbattle/tb_range.py @@ -333,7 +333,6 @@ def approach(mover, target): distance_inc(mover, thing) # Lastly, move closer to your target. distance_dec(mover, target) - return def withdraw(mover, target): """ @@ -378,7 +377,6 @@ def withdraw(mover, target): distance_inc(mover, thing) # Then, move away from your target. distance_inc(mover, target) - return def get_range(obj1, obj2): """ @@ -503,7 +501,6 @@ def combat_status_message(fighter): status_msg += "|/Ranged targets: %s" % ", ".join(obj.key for obj in range_obj) fighter.msg(status_msg) - return """ ---------------------------------------------------------------------------- @@ -823,6 +820,7 @@ class TBRangeObject(DefaultObject): dropper.msg("You can only drop things on your turn!") return False return True + def at_drop(self, dropper): """ Called by the default `drop` command when this object has been @@ -843,6 +841,7 @@ class TBRangeObject(DefaultObject): # Object joins the range field self.db.combat_range = {} dropper.location.db.combat_turnhandler.join_rangefield(self, anchor_obj=dropper) + def at_before_get(self, getter): """ Called by the default `get` command before this object has been @@ -869,6 +868,7 @@ class TBRangeObject(DefaultObject): getter.msg("You aren't close enough to get that! (see: help approach)") return False return True + def at_get(self, getter): """ Called by the default `get` command when this object has been @@ -895,6 +895,7 @@ class TBRangeObject(DefaultObject): # If in combat, getter spends an action if is_in_combat(getter): spend_action(getter, 1, action_name="get") # Use up one action. + def at_before_give(self, giver, getter): """ Called by the default `give` command before this object has been @@ -923,6 +924,7 @@ class TBRangeObject(DefaultObject): giver.msg("You aren't close enough to give things to %s! (see: help approach)" % getter) return False return True + def at_give(self, giver, getter): """ Called by the default `give` command when this object has been From 95f840ac7abc6bbfad8eb37bbbc72f654c5db783 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Mon, 23 Oct 2017 22:05:56 -0700 Subject: [PATCH 034/189] get_range integrated into movement functions This makes the code a bit more readable and fixes a bug in withdrawing that didn't take other objects into account properly. --- evennia/contrib/turnbattle/tb_range.py | 60 +++++++++++++------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_range.py b/evennia/contrib/turnbattle/tb_range.py index 143134beec..d848d445b7 100644 --- a/evennia/contrib/turnbattle/tb_range.py +++ b/evennia/contrib/turnbattle/tb_range.py @@ -287,6 +287,29 @@ def resolve_attack(attacker, defender, attack_type, attack_value=None, defense_v # If defender HP is reduced to 0 or less, call at_defeat. if defender.db.hp <= 0: at_defeat(defender) + +def get_range(obj1, obj2): + """ + Gets the combat range between two objects. + + Args: + obj1 (obj): First object + obj2 (obj): Second object + + Returns: + range (int or None): Distance between two objects or None if not applicable + """ + # Return None if not applicable. + if not obj1.db.combat_range: + return None + if not obj2.db.combat_range: + return None + if obj1 not in obj2.db.combat_range: + return None + if obj2 not in obj1.db.combat_range: + return None + # Return the range between the two objects. + return obj1.db.combat_range[obj2] def approach(mover, target): """ @@ -312,7 +335,7 @@ def approach(mover, target): mover.db.combat_range[target] -= 1 target.db.combat_range[mover] = mover.db.combat_range[target] # If this brings mover to range 0 (Engaged): - if mover.db.combat_range[target] <= 0: + if get_range(mover, target) <= 0: # Reset range to each other to 0 and copy target's ranges to mover. target.db.combat_range[mover] = 0 mover.db.combat_range = target.db.combat_range @@ -326,10 +349,10 @@ def approach(mover, target): for thing in contents: if thing != mover and thing != target: # Move closer to each object closer to the target than you. - if mover.db.combat_range[thing] > target.db.combat_range[thing]: + if get_range(mover, thing) > get_range(target, thing): distance_dec(mover, thing) # Move further from each object that's further from you than from the target. - if mover.db.combat_range[thing] < target.db.combat_range[thing]: + if get_range(mover, thing) < get_range(target, thing): distance_inc(mover, thing) # Lastly, move closer to your target. distance_dec(mover, target) @@ -358,7 +381,7 @@ def withdraw(mover, target): mover.db.combat_range[target] += 1 target.db.combat_range[mover] = mover.db.combat_range[target] # Set a cap of 2: - if mover.db.combat_range[target] > 2: + if get_range(mover, target) > 2: target.db.combat_range[mover] = 2 mover.db.combat_range[target] = 2 @@ -367,39 +390,16 @@ def withdraw(mover, target): for thing in contents: if thing != mover and thing != target: # Move away from each object closer to the target than you, if it's also closer to you than you are to the target. - if mover.db.combat_range[thing] >= target.db.combat_range[thing] and mover.db.combat_range[thing] < mover.db.combat_range[thing]: + if get_range(mover, thing) >= get_range(target, thing) and get_range(mover, thing) < get_range(mover, target): distance_inc(mover, thing) # Move away from anything your target is engaged with - if target.db.combat_range[thing] == 0: + if get_range(target, thing) == 0: distance_inc(mover, thing) # Move away from anything you're engaged with. - if mover.db.combat_range[thing] == 0: + if get_range(mover, thing) == 0: distance_inc(mover, thing) # Then, move away from your target. distance_inc(mover, target) - -def get_range(obj1, obj2): - """ - Gets the combat range between two objects. - - Args: - obj1 (obj): First object - obj2 (obj): Second object - - Returns: - range (int or None): Distance between two objects or None if not applicable - """ - # Return None if not applicable. - if not obj1.db.combat_range: - return None - if not obj2.db.combat_range: - return None - if obj1 not in obj2.db.combat_range: - return None - if obj2 not in obj1.db.combat_range: - return None - # Return the range between the two objects. - return obj1.db.combat_range[obj2] def combat_cleanup(character): """ From 1fe9bf3dceb09658bd25c3a9a6283003a8506f22 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Mon, 23 Oct 2017 22:29:45 -0700 Subject: [PATCH 035/189] Moved distance_inc back to being its own function I almost forgot - distance_inc is actually used by both 'approach' and 'withdraw', since approaching an object might put you farther away from others. So, I moved it back to its own function. --- evennia/contrib/turnbattle/tb_range.py | 29 +++++++++++++------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_range.py b/evennia/contrib/turnbattle/tb_range.py index d848d445b7..0eb617c48b 100644 --- a/evennia/contrib/turnbattle/tb_range.py +++ b/evennia/contrib/turnbattle/tb_range.py @@ -310,6 +310,21 @@ def get_range(obj1, obj2): return None # Return the range between the two objects. return obj1.db.combat_range[obj2] + +def distance_inc(mover, target): + """ + Function that increases distance in range field between mover and target. + + Args: + mover (obj): The object moving + target (obj): The object to be moved away from + """ + mover.db.combat_range[target] += 1 + target.db.combat_range[mover] = mover.db.combat_range[target] + # Set a cap of 2: + if get_range(mover, target) > 2: + target.db.combat_range[mover] = 2 + mover.db.combat_range[target] = 2 def approach(mover, target): """ @@ -370,20 +385,6 @@ def withdraw(mover, target): of their withdrawl. The mover will never inadvertently move toward anything else while withdrawing - they can be considered to be moving to open space. """ - def distance_inc(mover, target): - """ - Helper function that increases distance in range field between mover and target. - - Args: - mover (obj): The object moving - target (obj): The object to be moved away from - """ - mover.db.combat_range[target] += 1 - target.db.combat_range[mover] = mover.db.combat_range[target] - # Set a cap of 2: - if get_range(mover, target) > 2: - target.db.combat_range[mover] = 2 - mover.db.combat_range[target] = 2 contents = mover.location.contents From 2475d14691baf707a051283932e8c93bcd85536f Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 28 Oct 2017 00:13:40 +0200 Subject: [PATCH 036/189] Almost finished with kwargs-support for evmenu --- evennia/utils/evmenu.py | 250 ++++++++++++++++++++++++++-------------- 1 file changed, 166 insertions(+), 84 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index d248910e76..cde028cf50 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -63,23 +63,35 @@ menu is immediately exited and the default "look" command is called. text (str, tuple or None): Text shown at this node. If a tuple, the second element in the tuple is a help text to display at this node when the user enters the menu help command there. - options (tuple, dict or None): ( - {'key': name, # can also be a list of aliases. A special key is - # "_default", which marks this option as the default - # fallback when no other option matches the user input. - 'desc': description, # optional description - 'goto': nodekey, # node to go to when chosen. This can also be a callable with - # caller and/or raw_string args. It must return a string - # with the key pointing to the node to go to. - 'exec': nodekey}, # node or callback to trigger as callback when chosen. This - # will execute *before* going to the next node. Both node - # and the explicit callback will be called as normal nodes - # (with caller and/or raw_string args). If the callable/node - # returns a single string (only), this will replace the current - # goto location string in-place (if a goto callback, it will never fire). - # Note that relying to much on letting exec assign the goto - # location can make it hard to debug your menu logic. - {...}, ...) + options (tuple, dict or None): If `None`, this exits the menu. + If a single dict, this is a single-option node. If a tuple, + it should be a tuple of option dictionaries. Option dicts have + the following keys: + - `key` (str or tuple, optional): What to enter to choose this option. + If a tuple, it must be a tuple of strings, where the first string is the + key which will be shown to the user and the others are aliases. + If unset, the options' number will be used. The special key `_default` + marks this option as the default fallback when no other option matches + the user input. There can only be one `_default` option per node. It + will not be displayed in the list. + - `desc` (str, optional): This describes what choosing the option will do. + - `goto` (str, tuple or callable): If string, should be the name of node to go to + when this option is selected. If a callable, it has the signature + `callable(caller[,raw_input][,**kwargs]). If a tuple, the first element + is the callable and the second is a dict with the **kwargs to pass to + the callable. Those kwargs will also be passed into the next node if possible. + Such a callable should return either a str or a (str, dict), where the + string is the name of the next node to go to and the dict is the new, + (possibly modified) kwarg to pass into the next node. + - `exec` (str, callable or tuple, optional): This specified either the name of + a menu node to execute as a callback or a regular callable. If a tuple, the + first element is either the menu-node name or the callback, while the second + is a dict for the **kwargs to pass into the node/callback. This callback/node + will execute *before* going any `goto` function and before going to the next + node. The callback should look like a node, so `callback(caller[,raw_input][,**kwargs])`. + If this callable returns a single string (only) then that will replace the + current goto location (if a `goto` callback is set, it will never fire). Returning + anything else has no effect. If key is not given, the option will automatically be identified by its number 1..N. @@ -519,7 +531,43 @@ class EvMenu(object): # format the entire node return self.node_formatter(nodetext, optionstext) - def _execute_node(self, nodename, raw_string): + def _safe_call(self, callback, raw_string, **kwargs): + """ + Call a node-like callable, with a variable number of raw_string, *args, **kwargs, all of + which should work also if not present (only `caller` is always required). Return its result. + + """ + try: + nspec = getargspec(callback).args + kspec = getargspec(callback).defaults + try: + # this counts both args and kwargs + nspec = len(nspec) + except TypeError: + raise EvMenuError("Callable {} doesn't accept any arguments!".format(callback)) + nkwargs = len(kspec) if kspec else 0 + nargs = nspec - nkwargs + if nargs <= 0: + raise EvMenuError("Callable {} doesn't accept any arguments!".format(callback)) + + if nkwargs: + if nargs > 1: + return callback(self.caller, raw_string, **kwargs) + # callback accepting raw_string, **kwargs + else: + # callback accepting **kwargs + return callback(self.caller, **kwargs) + elif nargs > 1: + # callback accepting raw_string + return callback(self.caller, raw_string) + else: + # normal callback, only the caller as arg + return callback(self.caller) + except Exception: + self.caller.msg(_ERR_GENERAL.format(nodename=callback), self._session) + raise + + def _execute_node(self, nodename, raw_string, **kwargs): """ Execute a node. @@ -528,6 +576,7 @@ class EvMenu(object): raw_string (str): The raw default string entered on the previous node (only used if the node accepts it as an argument) + kwargs (any, optional): Optional kwargs for the node. Returns: nodetext, options (tuple): The node text (a string or a @@ -540,13 +589,7 @@ class EvMenu(object): self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session) raise EvMenuError try: - # the node should return data as (text, options) - if len(getargspec(node).args) > 1: - # a node accepting raw_string - nodetext, options = node(self.caller, raw_string) - else: - # a normal node, only accepting caller - nodetext, options = node(self.caller) + nodetext, options = self._safe_call(node, raw_string, **kwargs) except KeyError: self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session) raise EvMenuError @@ -555,32 +598,7 @@ class EvMenu(object): raise return nodetext, options - def display_nodetext(self): - self.caller.msg(self.nodetext, session=self._session) - - def display_helptext(self): - self.caller.msg(self.helptext, session=self._session) - - def callback_goto(self, callback, goto, raw_string): - """ - Call callback and goto in sequence. - - Args: - callback (callable or str): Callback to run before goto. If - the callback returns a string, this is used to replace - the `goto` string before going to the next node. - goto (str): The target node to go to next (unless replaced - by `callable`).. - raw_string (str): The original user input. - - """ - if callback: - # replace goto only if callback returns - goto = self.callback(callback, raw_string) or goto - if goto: - self.goto(goto, raw_string) - - def callback(self, nodename, raw_string): + def run_exec(self, nodename, raw_string, **kwargs): """ Run a function or node as a callback (with the 'exec' option key). @@ -592,6 +610,8 @@ class EvMenu(object): raw_string (str): The raw default string entered on the previous node (only used if the node accepts it as an argument) + kwargs (any): These are optional kwargs passed into goto + Returns: new_goto (str or None): A replacement goto location string or None (no replacement). @@ -604,34 +624,30 @@ class EvMenu(object): """ if callable(nodename): # this is a direct callable - execute it directly - try: - if len(getargspec(nodename).args) > 1: - # callable accepting raw_string - ret = nodename(self.caller, raw_string) - else: - # normal callable, only the caller as arg - ret = nodename(self.caller) - except Exception: - self.caller.msg(_ERR_GENERAL.format(nodename=nodename), self._session) - raise + ret = self._safe_call(nodename, raw_string, **kwargs) else: - # nodename is a string; lookup as node + # nodename is a string; lookup as node and run as node (but don't + # care about options) try: # execute the node - ret = self._execute_node(nodename, raw_string) + ret = self._execute_node(nodename, raw_string, **kwargs) + if isinstance(ret, (tuple, list)) and len(ret) == 2: + # a (text, options) tuple. We only want the text. + ret = ret[0] except EvMenuError as err: errmsg = "Error in exec '%s' (input: '%s'): %s" % (nodename, raw_string, err) self.caller.msg("|r%s|n" % errmsg) logger.log_trace(errmsg) return + if isinstance(ret, basestring): # only return a value if a string (a goto target), ignore all other returns return ret return None - def goto(self, nodename, raw_string): + def goto(self, nodename, raw_string, **kwargs): """ - Run a node by name + Run a node by name, optionally dynamically generating that name first. Args: nodename (str or callable): Name of node or a callable @@ -642,19 +658,40 @@ class EvMenu(object): argument) """ - if callable(nodename): - try: - if len(getargspec(nodename).args) > 1: - # callable accepting raw_string - nodename = nodename(self.caller, raw_string) + def _extract_goto_exec(option_dict): + "Helper: Get callables and their eventual kwargs" + goto_kwargs, exec_kwargs = {}, {} + goto, execute = option_dict.get("goto", None), option_dict.get("exec", None) + if goto and isinstance(goto, (tuple, list)): + if len(goto) > 1: + goto, goto_kwargs = goto[:2] # ignore any extra arguments + if not hasattr(goto_kwargs, "__getitem__"): + # not a dict-like structure + raise EvMenuError("EvMenu node {}: goto kwargs is not a dict: {}".format( + nodename, goto_kwargs)) else: - nodename = nodename(self.caller) - except Exception: - self.caller.msg(_ERR_GENERAL.format(nodename=nodename), self._session) - raise + goto = goto[0] + if execute and isinstance(execute, (tuple, list)): + if len(execute) > 1: + execute, exec_kwargs = execute[:2] # ignore any extra arguments + if not hasattr(exec_kwargs, "__getitem__"): + # not a dict-like structure + raise EvMenuError("EvMenu node {}: exec kwargs is not a dict: {}".format( + nodename, goto_kwargs)) + else: + execute = execute[0] + return goto, goto_kwargs, execute, exec_kwargs + + if callable(nodename): + # run the "goto" callable, if possible + nodename = self._safe_call(nodename, raw_string, **kwargs) + if isinstance(nodename, (tuple, list)): + if not len(nodename) > 1 or not isinstance(nodename[1], dict): + raise EvMenuError("{}: goto callable must return str or (str, dict)") + nodename, kwargs = nodename[:2] try: - # execute the node, make use of the returns. - nodetext, options = self._execute_node(nodename, raw_string) + # execute the found node, make use of the returns. + nodetext, options = self._execute_node(nodename, raw_string, **kwargs) except EvMenuError: return @@ -683,17 +720,19 @@ class EvMenu(object): if "_default" in keys: keys = [key for key in keys if key != "_default"] desc = dic.get("desc", dic.get("text", _ERR_NO_OPTION_DESC).strip()) - goto, execute = dic.get("goto", None), dic.get("exec", None) - self.default = (goto, execute) + goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic) + self.default = (goto, goto_kwargs, execute, exec_kwargs) else: + # use the key (only) if set, otherwise use the running number keys = list(make_iter(dic.get("key", str(inum + 1).strip()))) desc = dic.get("desc", dic.get("text", _ERR_NO_OPTION_DESC).strip()) - goto, execute = dic.get("goto", None), dic.get("exec", None) + goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic) if keys: display_options.append((keys[0], desc)) for key in keys: if goto or execute: - self.options[strip_ansi(key).strip().lower()] = (goto, execute) + self.options[strip_ansi(key).strip().lower()] = \ + (goto, goto_kwargs, execute, exec_kwargs) self.nodetext = self._format_node(nodetext, display_options) @@ -709,6 +748,28 @@ class EvMenu(object): if not options: self.close_menu() + def run_exec_then_goto(self, runexec, goto, raw_string, runexec_kwargs=None, goto_kwargs=None): + """ + Call 'exec' callback and goto (which may also be a callable) in sequence. + + Args: + runexec (callable or str): Callback to run before goto. If + the callback returns a string, this is used to replace + the `goto` string/callable before being passed into the goto handler. + goto (str): The target node to go to next (may be replaced + by `runexec`).. + raw_string (str): The original user input. + runexec_kwargs (dict, optional): Optional kwargs for runexec. + goto_kwargs (dict, optional): Optional kwargs for goto. + + """ + if runexec: + # replace goto only if callback returns + goto = self.run_exec(runexec, raw_string, + **(runexec_kwargs if runexec_kwargs else {})) or goto + if goto: + self.goto(goto, raw_string, **(goto_kwargs if goto_kwargs else {})) + def close_menu(self): """ Shutdown menu; occurs when reaching the end node or using the quit command. @@ -739,8 +800,8 @@ class EvMenu(object): if cmd in self.options: # this will take precedence over the default commands # below - goto, callback = self.options[cmd] - self.callback_goto(callback, goto, raw_string) + goto, goto_kwargs, execfunc, exec_kwargs = self.options[cmd] + self.run_exec_then_goto(execfunc, goto, raw_string, exec_kwargs, goto_kwargs) elif self.auto_look and cmd in ("look", "l"): self.display_nodetext() elif self.auto_help and cmd in ("help", "h"): @@ -748,8 +809,8 @@ class EvMenu(object): elif self.auto_quit and cmd in ("quit", "q", "exit"): self.close_menu() elif self.default: - goto, callback = self.default - self.callback_goto(callback, goto, raw_string) + goto, goto_kwargs, execfunc, exec_kwargs = self.default + self.run_exec_then_goto(execfunc, goto, raw_string, exec_kwargs, goto_kwargs) else: self.caller.msg(_HELP_NO_OPTION_MATCH, session=self._session) @@ -757,6 +818,12 @@ class EvMenu(object): # no options - we are at the end of the menu. self.close_menu() + def display_nodetext(self): + self.caller.msg(self.nodetext, session=self._session) + + def display_helptext(self): + self.caller.msg(self.helptext, session=self._session) + # formatters - override in a child class def nodetext_formatter(self, nodetext): @@ -996,6 +1063,10 @@ def get_input(caller, prompt, callback, session=None, *args, **kwargs): # # ------------------------------------------------------------- +def _generate_goto(caller, **kwargs): + return kwargs.get("name", "text_start_node"), {"name": "replaced!"} + + def test_start_node(caller): menu = caller.ndb._menutree text = """ @@ -1020,6 +1091,9 @@ def test_start_node(caller): {"key": ("|yV|niew", "v"), "desc": "View your own name", "goto": "test_view_node"}, + {"key": ("|yD|nynamic", "d"), + "desc": "Dynamic node", + "goto": (_generate_goto, {"name": "test_dynamic_node"})}, {"key": ("|yQ|nuit", "quit", "q", "Q"), "desc": "Quit this menu example.", "goto": "test_end_node"}, @@ -1095,6 +1169,14 @@ def test_displayinput_node(caller, raw_string): return text, options +def test_dynamic_node(caller, **kwargs): + text = """ + This is a dynamic node, whose name was + generated by the goto function. + """ + options = {} + return text, options + def test_end_node(caller): text = """ This is the end of the menu and since it has no options the menu From 7b295fa98b37a3812b533d77d76999c4e7b3a9b9 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 28 Oct 2017 12:05:32 +0200 Subject: [PATCH 037/189] Add working **kwargs support to nodes/callbacks in evmenu --- evennia/utils/evmenu.py | 172 +++++++++++++++++++++++++--------------- 1 file changed, 109 insertions(+), 63 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index cde028cf50..6f31822bf3 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -83,15 +83,11 @@ menu is immediately exited and the default "look" command is called. Such a callable should return either a str or a (str, dict), where the string is the name of the next node to go to and the dict is the new, (possibly modified) kwarg to pass into the next node. - - `exec` (str, callable or tuple, optional): This specified either the name of - a menu node to execute as a callback or a regular callable. If a tuple, the - first element is either the menu-node name or the callback, while the second - is a dict for the **kwargs to pass into the node/callback. This callback/node - will execute *before* going any `goto` function and before going to the next - node. The callback should look like a node, so `callback(caller[,raw_input][,**kwargs])`. - If this callable returns a single string (only) then that will replace the - current goto location (if a `goto` callback is set, it will never fire). Returning - anything else has no effect. + - `exec` (str, callable or tuple, optional): This takes the same input as `goto` above + and runs before it. If given a node name, the node will be executed but will not + be considered the next node. If node/callback returns str or (str, dict), these will + replace the `goto` step (`goto` callbacks will not fire), with the string being the + next node name and the optional dict acting as the kwargs-input for the next node. If key is not given, the option will automatically be identified by its number 1..N. @@ -107,7 +103,7 @@ Example: "This is help text for this node") options = ({"key": "testing", "desc": "Select this to go to node 2", - "goto": "node2", + "goto": ("node2", {"foo": "bar"}), "exec": "callback1"}, {"desc": "Go to node 3.", "goto": "node3"}) @@ -120,12 +116,13 @@ Example: # by the normal 'goto' option key above. caller.msg("Callback called!") - def node2(caller): + def node2(caller, **kwargs): text = ''' This is node 2. It only allows you to go back to the original node1. This extra indent will - be stripped. We don't include a help text. - ''' + be stripped. We don't include a help text but + here are the variables passed to us: {} + '''.format(kwargs) options = {"goto": "node1"} return text, options @@ -160,6 +157,7 @@ evennia.utils.evmenu`. """ from __future__ import print_function +import random from builtins import object, range from textwrap import dedent @@ -403,6 +401,7 @@ class EvMenu(object): self._startnode = startnode self._menutree = self._parse_menudata(menudata) self._persistent = persistent + self._quitting = False if startnode not in self._menutree: raise EvMenuError("Start node '%s' not in menu tree!" % startnode) @@ -538,35 +537,34 @@ class EvMenu(object): """ try: - nspec = getargspec(callback).args - kspec = getargspec(callback).defaults try: - # this counts both args and kwargs - nspec = len(nspec) + nargs = len(getargspec(callback).args) except TypeError: raise EvMenuError("Callable {} doesn't accept any arguments!".format(callback)) - nkwargs = len(kspec) if kspec else 0 - nargs = nspec - nkwargs + supports_kwargs = bool(getargspec(callback).keywords) if nargs <= 0: raise EvMenuError("Callable {} doesn't accept any arguments!".format(callback)) - if nkwargs: + if supports_kwargs: if nargs > 1: - return callback(self.caller, raw_string, **kwargs) + ret = callback(self.caller, raw_string, **kwargs) # callback accepting raw_string, **kwargs else: # callback accepting **kwargs - return callback(self.caller, **kwargs) + ret = callback(self.caller, **kwargs) elif nargs > 1: # callback accepting raw_string - return callback(self.caller, raw_string) + ret = callback(self.caller, raw_string) else: # normal callback, only the caller as arg - return callback(self.caller) - except Exception: - self.caller.msg(_ERR_GENERAL.format(nodename=callback), self._session) + ret = callback(self.caller) + except EvMenuError: + errmsg = _ERR_GENERAL.format(nodename=callback) + self.caller.msg(errmsg, self._session) raise + return ret + def _execute_node(self, nodename, raw_string, **kwargs): """ Execute a node. @@ -589,7 +587,11 @@ class EvMenu(object): self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session) raise EvMenuError try: - nodetext, options = self._safe_call(node, raw_string, **kwargs) + ret = self._safe_call(node, raw_string, **kwargs) + if isinstance(ret, (tuple, list)) and len(ret) > 1: + nodetext, options = ret[:2] + else: + nodetext, options = ret, None except KeyError: self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session) raise EvMenuError @@ -622,27 +624,31 @@ class EvMenu(object): relying on this. """ - if callable(nodename): - # this is a direct callable - execute it directly - ret = self._safe_call(nodename, raw_string, **kwargs) - else: - # nodename is a string; lookup as node and run as node (but don't - # care about options) - try: + try: + if callable(nodename): + # this is a direct callable - execute it directly + ret = self._safe_call(nodename, raw_string, **kwargs) + if isinstance(ret, (tuple, list)): + if not len(ret) > 1 or not isinstance(ret[1], dict): + raise EvMenuError("exec callable must return either None, str or (str, dict)") + ret, kwargs = ret[:2] + else: + # nodename is a string; lookup as node and run as node in-place (don't goto it) # execute the node ret = self._execute_node(nodename, raw_string, **kwargs) - if isinstance(ret, (tuple, list)) and len(ret) == 2: - # a (text, options) tuple. We only want the text. - ret = ret[0] - except EvMenuError as err: - errmsg = "Error in exec '%s' (input: '%s'): %s" % (nodename, raw_string, err) - self.caller.msg("|r%s|n" % errmsg) - logger.log_trace(errmsg) - return + if isinstance(ret, (tuple, list)): + if not len(ret) > 1 and ret[1] and not isinstance(ret[1], dict): + raise EvMenuError("exec node must return either None, str or (str, dict)") + ret, kwargs = ret[:2] + except EvMenuError as err: + errmsg = "Error in exec '%s' (input: '%s'): %s" % (nodename, raw_string.rstrip(), err) + self.caller.msg("|r%s|n" % errmsg) + logger.log_trace(errmsg) + return if isinstance(ret, basestring): # only return a value if a string (a goto target), ignore all other returns - return ret + return ret, kwargs return None def goto(self, nodename, raw_string, **kwargs): @@ -684,10 +690,12 @@ class EvMenu(object): if callable(nodename): # run the "goto" callable, if possible + inp_nodename = nodename nodename = self._safe_call(nodename, raw_string, **kwargs) if isinstance(nodename, (tuple, list)): if not len(nodename) > 1 or not isinstance(nodename[1], dict): - raise EvMenuError("{}: goto callable must return str or (str, dict)") + raise EvMenuError( + "{}: goto callable must return str or (str, dict)".format(inp_nodename)) nodename, kwargs = nodename[:2] try: # execute the found node, make use of the returns. @@ -765,8 +773,10 @@ class EvMenu(object): """ if runexec: # replace goto only if callback returns - goto = self.run_exec(runexec, raw_string, - **(runexec_kwargs if runexec_kwargs else {})) or goto + goto, goto_kwargs = ( + self.run_exec(runexec, raw_string, + **(runexec_kwargs if runexec_kwargs else {})) or + (goto, goto_kwargs)) if goto: self.goto(goto, raw_string, **(goto_kwargs if goto_kwargs else {})) @@ -774,13 +784,16 @@ class EvMenu(object): """ Shutdown menu; occurs when reaching the end node or using the quit command. """ - self.caller.cmdset.remove(EvMenuCmdSet) - del self.caller.ndb._menutree - if self._persistent: - self.caller.attributes.remove("_menutree_saved") - self.caller.attributes.remove("_menutree_saved_startnode") - if self.cmd_on_exit is not None: - self.cmd_on_exit(self.caller, self) + if not self._quitting: + # avoid multiple calls from different sources + self._quitting = True + self.caller.cmdset.remove(EvMenuCmdSet) + del self.caller.ndb._menutree + if self._persistent: + self.caller.attributes.remove("_menutree_saved") + self.caller.attributes.remove("_menutree_saved_startnode") + if self.cmd_on_exit is not None: + self.cmd_on_exit(self.caller, self) def parse_input(self, raw_string): """ @@ -1064,7 +1077,7 @@ def get_input(caller, prompt, callback, session=None, *args, **kwargs): # ------------------------------------------------------------- def _generate_goto(caller, **kwargs): - return kwargs.get("name", "text_start_node"), {"name": "replaced!"} + return kwargs.get("name", "test_dynamic_node"), {"name": "replaced!"} def test_start_node(caller): @@ -1135,7 +1148,7 @@ def test_set_node(caller): return text, options -def test_view_node(caller): +def test_view_node(caller, **kwargs): text = """ Your name is |g%s|n! @@ -1145,9 +1158,14 @@ def test_view_node(caller): -always- use numbers (1...N) to refer to listed options also if you don't see a string option key (try it!). """ % caller.key - options = {"desc": "back to main", - "goto": "test_start_node"} - return text, options + if kwargs.get("executed_from_dynamic_node", False): + # we are calling this node as a exec, skip return values + caller.msg("|gCalled from dynamic node:|n \n {}".format(text)) + return + else: + options = {"desc": "back to main", + "goto": "test_start_node"} + return text, options def test_displayinput_node(caller, raw_string): @@ -1163,20 +1181,48 @@ def test_displayinput_node(caller, raw_string): makes it hidden from view. It catches all input (except the in-menu help/quit commands) and will, in this case, bring you back to the start node. - """ % raw_string + """ % raw_string.rstrip() options = {"key": "_default", "goto": "test_start_node"} return text, options +def _test_call(caller, raw_input, **kwargs): + mode = kwargs.get("mode", "exec") + + caller.msg("\n|y'{}' |n_test_call|y function called with\n " + "caller: |n{}\n |yraw_input: \"|n{}|y\" \n kwargs: |n{}\n".format( + mode, caller, raw_input.rstrip(), kwargs)) + + if mode == "exec": + kwargs = {"random": random.random()} + caller.msg("function modify kwargs to {}".format(kwargs)) + else: + caller.msg("|ypassing function kwargs without modification.|n") + + return "test_dynamic_node", kwargs + + def test_dynamic_node(caller, **kwargs): text = """ - This is a dynamic node, whose name was - generated by the goto function. - """ - options = {} + This is a dynamic node with input: + {} + """.format(kwargs) + options = ({"desc": "pass a new random number to this node", + "goto": ("test_dynamic_node", {"random": random.random()})}, + {"desc": "execute a func with kwargs", + "exec": (_test_call, {"mode": "exec", "test_random": random.random()})}, + {"desc": "dynamic_goto", + "goto": (_test_call, {"mode": "goto", "goto_input": "test"})}, + {"desc": "exec test_view_node with kwargs", + "exec": ("test_view_node", {"executed_from_dynamic_node": True}), + "goto": "test_dynamic_node"}, + {"desc": "back to main", + "goto": "test_start_node"}) + return text, options + def test_end_node(caller): text = """ This is the end of the menu and since it has no options the menu From 931e42082cb7ea2282051838ef57e7b176842a07 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 28 Oct 2017 13:29:51 +0200 Subject: [PATCH 038/189] Make persistent evmenu's store node kwargs correctly --- evennia/utils/evmenu.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 6f31822bf3..509e2cec5f 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -270,7 +270,7 @@ class CmdEvMenuNode(Command): err = "Menu object not found as %s.ndb._menutree!" % orig_caller orig_caller.msg(err) # don't give the session as a kwarg here, direct to original raise EvMenuError(err) - # we must do this after the caller with the menui has been correctly identified since it + # we must do this after the caller with the menu has been correctly identified since it # can be either Account, Object or Session (in the latter case this info will be superfluous). caller.ndb._menutree._session = self.session # we have a menu, use it. @@ -369,9 +369,10 @@ class EvMenu(object): re-run with the same input arguments - so be careful if you are counting up some persistent counter or similar - the counter may be run twice if reload happens on the node that does that. - startnode_input (str, optional): Send an input text to `startnode` as if - a user input text from a fictional previous node. When the server reloads, - the latest visited node will be re-run using this kwarg. + startnode_input (str or (str, dict), optional): Send an input text to `startnode` as if + a user input text from a fictional previous node. If including the dict, this will + be passed as **kwargs to that node. When the server reloads, + the latest visited node will be re-run as `node(caller, raw_string, **kwargs)`. session (Session, optional): This is useful when calling EvMenu from an account in multisession mode > 2. Note that this session only really relevant for the very first display of the first node - after that, EvMenu itself @@ -428,6 +429,7 @@ class EvMenu(object): self.nodetext = None self.helptext = None self.options = None + self.node_kwargs = {} # assign kwargs as initialization vars on ourselves. if set(("_startnode", "_menutree", "_session", "_persistent", @@ -474,8 +476,13 @@ class EvMenu(object): menu_cmdset.priority = int(cmdset_priority) self.caller.cmdset.add(menu_cmdset, permanent=persistent) + startnode_kwargs = {} + if isinstance(startnode_input, (tuple, list)) and len(startnode_input) > 1: + startnode_input, startnode_kwargs = startnode_input[:2] + if not isinstance(startnode_kwargs, dict): + raise EvMenuError("startnode_input must be either a str or a tuple (str, dict).") # start the menu - self.goto(self._startnode, startnode_input) + self.goto(self._startnode, startnode_input, **startnode_kwargs) def _parse_menudata(self, menudata): """ @@ -704,7 +711,8 @@ class EvMenu(object): return if self._persistent: - self.caller.attributes.add("_menutree_saved_startnode", (nodename, raw_string)) + self.caller.attributes.add("_menutree_saved_startnode", + (nodename, (raw_string, kwargs))) # validation of the node return values helptext = "" From b6b112b70a42c467e8b6b2f2fedb820c6eb711bb Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 28 Oct 2017 14:31:09 +0200 Subject: [PATCH 039/189] Make an empty evmenu desc option just show the key --- evennia/utils/evmenu.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 509e2cec5f..d6ccd2240b 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -733,15 +733,14 @@ class EvMenu(object): for inum, dic in enumerate(options): # fix up the option dicts keys = make_iter(dic.get("key")) + desc = dic.get("desc", dic.get("text", None)) if "_default" in keys: keys = [key for key in keys if key != "_default"] - desc = dic.get("desc", dic.get("text", _ERR_NO_OPTION_DESC).strip()) goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic) self.default = (goto, goto_kwargs, execute, exec_kwargs) else: # use the key (only) if set, otherwise use the running number keys = list(make_iter(dic.get("key", str(inum + 1).strip()))) - desc = dic.get("desc", dic.get("text", _ERR_NO_OPTION_DESC).strip()) goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic) if keys: display_options.append((keys[0], desc)) @@ -887,16 +886,17 @@ class EvMenu(object): for key, desc in optionlist: if not (key or desc): continue + desc_string = ": %s" % desc if desc else "" table_width_max = max(table_width_max, max(m_len(p) for p in key.split("\n")) + - max(m_len(p) for p in desc.split("\n")) + colsep) + max(m_len(p) for p in desc_string.split("\n")) + colsep) raw_key = strip_ansi(key) if raw_key != key: # already decorations in key definition - table.append(" |lc%s|lt%s|le: %s" % (raw_key, key, desc)) + table.append(" |lc%s|lt%s|le%s" % (raw_key, key, desc_string)) else: # add a default white color to key - table.append(" |lc%s|lt|w%s|n|le: %s" % (raw_key, raw_key, desc)) + table.append(" |lc%s|lt|w%s|n|le%s" % (raw_key, raw_key, desc_string)) ncols = (_MAX_TEXT_WIDTH // table_width_max) + 1 # number of ncols @@ -1151,7 +1151,6 @@ def test_set_node(caller): """) options = {"key": ("back (default)", "_default"), - "desc": "back to main", "goto": "test_start_node"} return text, options From d05495cc52b4ae21b675c258046278756610a10d Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 28 Oct 2017 22:33:58 +0200 Subject: [PATCH 040/189] Add testing framework for EvMenu. Implements #1484 --- evennia/utils/evmenu.py | 16 ++- evennia/utils/tests/test_evmenu.py | 197 ++++++++++++++++++++++++++++- 2 files changed, 205 insertions(+), 8 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index d6ccd2240b..a595366f5c 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -429,8 +429,13 @@ class EvMenu(object): self.nodetext = None self.helptext = None self.options = None + self.nodename = None self.node_kwargs = {} + # used for testing + self.test_options = {} + self.test_nodetext = "" + # assign kwargs as initialization vars on ourselves. if set(("_startnode", "_menutree", "_session", "_persistent", "cmd_on_exit", "default", "nodetext", "helptext", @@ -605,6 +610,11 @@ class EvMenu(object): except Exception: self.caller.msg(_ERR_GENERAL.format(nodename=nodename), session=self._session) raise + + # store options to make them easier to test + self.test_options = options + self.test_nodetext = nodetext + return nodetext, options def run_exec(self, nodename, raw_string, **kwargs): @@ -750,6 +760,8 @@ class EvMenu(object): (goto, goto_kwargs, execute, exec_kwargs) self.nodetext = self._format_node(nodetext, display_options) + self.node_kwargs = kwargs + self.nodename = nodename # handle the helptext if helptext: @@ -815,7 +827,7 @@ class EvMenu(object): should also report errors directly to the user. """ - cmd = raw_string.strip().lower() + cmd = strip_ansi(raw_string.strip().lower()) if cmd in self.options: # this will take precedence over the default commands @@ -1124,7 +1136,7 @@ def test_start_node(caller): def test_look_node(caller): - text = "" + text = "This is a custom look location!" options = {"key": ("|yL|nook", "l"), "desc": "Go back to the previous menu.", "goto": "test_start_node"} diff --git a/evennia/utils/tests/test_evmenu.py b/evennia/utils/tests/test_evmenu.py index 4436fdccd6..a3517c35ac 100644 --- a/evennia/utils/tests/test_evmenu.py +++ b/evennia/utils/tests/test_evmenu.py @@ -5,20 +5,205 @@ TODO: This need expansion. """ +import copy from django.test import TestCase from evennia.utils import evmenu -from mock import Mock +from evennia.utils import ansi +from mock import MagicMock class TestEvMenu(TestCase): "Run the EvMenu testing." + menutree = {} # can also be the path to the menu tree + startnode = "start" + cmdset_mergetype = "Replace" + cmdset_priority = 1 + auto_quit = True + auto_look = True + auto_help = True + cmd_on_exit = "look" + persistent = False + startnode_input = "" + kwargs = {} + + # this is compared against the full tree structure generated + expected_tree = [] + # this allows for verifying that a given node returns a given text. The + # text is compared with .startswith, so the entire text need not be matched. + expected_node_texts = {} + # just check the number of options from each node + expected_node_options_count = {} + # check the actual options + expected_node_options = {} + + # set this to print the traversal as it happens (debugging) + debug_output = False + + def _debug_output(self, indent, msg): + if self.debug_output: + print(" " * indent + msg) + + def _test_menutree(self, menu): + """ + This is a automatic tester of the menu tree by recursively progressing through the + structure. + """ + + def _depth_first(menu, tree, visited, indent): + + # we are in a given node here + nodename = menu.nodename + options = menu.test_options + if isinstance(options, dict): + options = (options, ) + + # run validation tests for this node + compare_text = self.expected_node_texts.get(nodename, None) + if compare_text is not None: + compare_text = ansi.strip_ansi(compare_text.strip()) + node_text = menu.test_nodetext + self.assertIsNotNone( + bool(node_text), + "node: {}: node-text is None, which was not expected.".format(nodename)) + node_text = ansi.strip_ansi(node_text.strip()) + self.assertTrue( + node_text.startswith(compare_text), + "\nnode \"{}\':\nOutput:\n{}\n\nExpected (startswith):\n{}".format( + nodename, node_text, compare_text)) + compare_options_count = self.expected_node_options_count.get(nodename, None) + if compare_options_count is not None: + self.assertEqual( + len(options), compare_options_count, + "Not the right number of options returned from node {}.".format(nodename)) + compare_options = self.expected_node_options.get(nodename, None) + if compare_options: + self.assertEqual( + options, compare_options, + "Options returned from node {} does not match.".format(nodename)) + + self._debug_output(indent, "*{}".format(nodename)) + subtree = [] + + if not options: + # an end node + if nodename not in visited: + visited.append(nodename) + subtree = nodename + else: + for inum, optdict in enumerate(options): + + key, desc, execute, goto = optdict.get("key", ""), optdict.get("desc", None),\ + optdict.get("exec", None), optdict.get("goto", None) + + # prepare the key to pass to the menu + if isinstance(key, (tuple, list)) and len(key) > 1: + key = key[0] + if key == "_default": + key = "test raw input" + if not key: + key = str(inum + 1) + + backup_menu = copy.copy(menu) + + # step the menu + menu.parse_input(key) + + # from here on we are likely in a different node + nodename = menu.nodename + + if menu.close_menu.called: + # this was an end node + self._debug_output(indent, " .. menu exited! Back to previous node.") + menu = backup_menu + menu.close_menu = MagicMock() + visited.append(nodename) + subtree.append(nodename) + elif nodename not in visited: + visited.append(nodename) + subtree.append(nodename) + _depth_first(menu, subtree, visited, indent + 2) + #self._debug_output(indent, " -> arrived at {}".format(nodename)) + else: + subtree.append(nodename) + #self._debug_output( indent, " -> arrived at {} (circular call)".format(nodename)) + self._debug_output(indent, "-- {} ({}) -> {}".format(key, desc, goto)) + + if subtree: + tree.append(subtree) + + # the start node has already fired at this point + visited_nodes = [menu.nodename] + traversal_tree = [menu.nodename] + _depth_first(menu, traversal_tree, visited_nodes, 1) + + self.assertGreaterEqual(len(menu._menutree), len(visited_nodes)) + self.assertEqual(traversal_tree, self.expected_tree) def setUp(self): - self.caller = Mock() - self.caller.msg = Mock() - self.menu = evmenu.EvMenu(self.caller, "evennia.utils.evmenu", startnode="test_start_node", - persistent=True, cmdset_mergetype="Replace", testval="val", - testval2="val2") + self.menu = None + if self.menutree: + self.caller = MagicMock() + self.caller.key = "Test" + self.caller2 = MagicMock() + self.caller2.key = "Test" + self.caller.msg = MagicMock() + self.caller2.msg = MagicMock() + self.session = MagicMock() + self.session2 = MagicMock() + self.menu = evmenu.EvMenu(self.caller, self.menutree, startnode=self.startnode, + cmdset_mergetype=self.cmdset_mergetype, + cmdset_priority=self.cmdset_priority, + auto_quit=self.auto_quit, auto_look=self.auto_look, + auto_help=self.auto_help, + cmd_on_exit=self.cmd_on_exit, persistent=False, + startnode_input=self.startnode_input, session=self.session, + **self.kwargs) + # persistent version + self.pmenu = evmenu.EvMenu(self.caller2, self.menutree, startnode=self.startnode, + cmdset_mergetype=self.cmdset_mergetype, + cmdset_priority=self.cmdset_priority, + auto_quit=self.auto_quit, auto_look=self.auto_look, + auto_help=self.auto_help, + cmd_on_exit=self.cmd_on_exit, persistent=True, + startnode_input=self.startnode_input, session=self.session2, + **self.kwargs) + + self.menu.close_menu = MagicMock() + self.pmenu.close_menu = MagicMock() + + def test_menu_structure(self): + if self.menu: + self._test_menutree(self.menu) + self._test_menutree(self.pmenu) + + +class TestEvMenuExample(TestEvMenu): + + menutree = "evennia.utils.evmenu" + startnode = "test_start_node" + kwargs = {"testval": "val", "testval2": "val2"} + debug_output = False + + expected_node_texts = { + "test_view_node": "Your name is"} + + expected_tree = \ + ['test_start_node', + ['test_set_node', + ['test_start_node'], + 'test_look_node', + ['test_start_node'], + 'test_view_node', + ['test_start_node'], + 'test_dynamic_node', + ['test_dynamic_node', + 'test_dynamic_node', + 'test_dynamic_node', + 'test_dynamic_node', + 'test_start_node'], + 'test_end_node', + 'test_displayinput_node', + ['test_start_node']]] def test_kwargsave(self): self.assertTrue(hasattr(self.menu, "testval")) From 65664bf523996e7c7b07d67c9c5d72d45973a3d8 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 28 Oct 2017 22:48:06 +0200 Subject: [PATCH 041/189] Add documentation to EvMenu test class --- evennia/utils/tests/test_evmenu.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/evennia/utils/tests/test_evmenu.py b/evennia/utils/tests/test_evmenu.py index a3517c35ac..7f01db63f6 100644 --- a/evennia/utils/tests/test_evmenu.py +++ b/evennia/utils/tests/test_evmenu.py @@ -1,7 +1,17 @@ """ Unit tests for the EvMenu system -TODO: This need expansion. +This sets up a testing parent for testing EvMenu trees. It is configured by subclassing the +`TestEvMenu` class from this module and setting the class variables to point to the menu that should +be tested and how it should be called. + +Without adding any further test methods, the tester will process all nodes of the menu, width first, +by stepping through all options for every node. It will check to make sure all are visited. It will +create a hierarchical list of node names that describes the tree structure. Easiest way to use this +is to run the test once to see how the structure looks. + +The system also allows for testing the returns of each node as part of the parsing. To help debug +the menu, turn on `debug_output`, which will print the traversal process in detail. """ @@ -26,6 +36,11 @@ class TestEvMenu(TestCase): startnode_input = "" kwargs = {} + # if all nodes must be visited for the test to pass. This is not on + # by default since there may be exec-nodes that are made to not be + # visited. + expect_all_nodes = False + # this is compared against the full tree structure generated expected_tree = [] # this allows for verifying that a given node returns a given text. The @@ -136,7 +151,8 @@ class TestEvMenu(TestCase): traversal_tree = [menu.nodename] _depth_first(menu, traversal_tree, visited_nodes, 1) - self.assertGreaterEqual(len(menu._menutree), len(visited_nodes)) + if self.expect_all_nodes: + self.assertGreaterEqual(len(menu._menutree), len(visited_nodes)) self.assertEqual(traversal_tree, self.expected_tree) def setUp(self): From a5a8d9dd5735e4beca76714c8e93cf4fcfbe6f0c Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 28 Oct 2017 22:51:47 +0200 Subject: [PATCH 042/189] Some doc updates --- evennia/utils/tests/test_evmenu.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/evennia/utils/tests/test_evmenu.py b/evennia/utils/tests/test_evmenu.py index 7f01db63f6..04310c90ed 100644 --- a/evennia/utils/tests/test_evmenu.py +++ b/evennia/utils/tests/test_evmenu.py @@ -5,13 +5,15 @@ This sets up a testing parent for testing EvMenu trees. It is configured by subc `TestEvMenu` class from this module and setting the class variables to point to the menu that should be tested and how it should be called. -Without adding any further test methods, the tester will process all nodes of the menu, width first, -by stepping through all options for every node. It will check to make sure all are visited. It will -create a hierarchical list of node names that describes the tree structure. Easiest way to use this -is to run the test once to see how the structure looks. +Without adding any further test methods, the tester will process all nodes of the menu, depth first, +by stepping through all options for every node. Optionally, it can check that all nodes are visited. +It will create a hierarchical list of node names that describes the tree structure. This can then be +compared against a template to make sure the menu structure is sound. Easiest way to use this is to +run the test once to see how the structure looks. -The system also allows for testing the returns of each node as part of the parsing. To help debug -the menu, turn on `debug_output`, which will print the traversal process in detail. +The system also allows for testing the returns of each node as part of the parsing. + +To help debug the menu, turn on `debug_output`, which will print the traversal process in detail. """ From 9bc3fcf4860a3a1477940ce345e02c6b870758ff Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 29 Oct 2017 21:39:11 -0700 Subject: [PATCH 043/189] Unit tests for tb_range added --- evennia/contrib/tests.py | 110 ++++++++++++++++++++++++- evennia/contrib/turnbattle/tb_range.py | 6 +- 2 files changed, 111 insertions(+), 5 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 1fdd7dde75..4b2c1a696f 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -907,7 +907,7 @@ class TestTutorialWorldRooms(CommandTest): # test turnbattle -from evennia.contrib.turnbattle import tb_basic, tb_equip +from evennia.contrib.turnbattle import tb_basic, tb_equip, tb_range from evennia.objects.objects import DefaultRoom @@ -939,11 +939,25 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_equip.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_equip.CmdRest(), "", "Char rests to recover HP.") + # Test range commands + def test_turnbattlerangecmd(self): + # Start with range module specific commands. + self.call(tb_range.CmdShoot(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_range.CmdApproach(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_range.CmdWithdraw(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_range.CmdStatus(), "", "HP Remaining: 100 / 100") + # Also test the commands that are the same in the basic module + self.call(tb_range.CmdFight(), "", "There's nobody here to fight!") + self.call(tb_range.CmdAttack(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_range.CmdPass(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_range.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_range.CmdRest(), "", "Char rests to recover HP.") + class TestTurnBattleFunc(EvenniaTest): # Test combat functions - def test_turnbattlefunc(self): + def test_tbbasicfunc(self): attacker = create_object(tb_basic.TBBasicCharacter, key="Attacker") defender = create_object(tb_basic.TBBasicCharacter, key="Defender") testroom = create_object(DefaultRoom, key="Test Room") @@ -1020,7 +1034,7 @@ class TestTurnBattleFunc(EvenniaTest): turnhandler.stop() # Test the combat functions in tb_equip too. They work mostly the same. - def test_turnbattlefunc(self): + def test_tbequipfunc(self): attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker") defender = create_object(tb_equip.TBEquipCharacter, key="Defender") testroom = create_object(DefaultRoom, key="Test Room") @@ -1095,6 +1109,96 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) # Remove the script at the end turnhandler.stop() + + # Test combat functions in tb_range too. + def test_tbrangefunc(self): + testroom = create_object(DefaultRoom, key="Test Room") + attacker = create_object(tb_range.TBRangeCharacter, key="Attacker", location=testroom) + defender = create_object(tb_range.TBRangeCharacter, key="Defender", location=testroom) + # Initiative roll + initiative = tb_range.roll_init(attacker) + self.assertTrue(initiative >= 0 and initiative <= 1000) + # Attack roll + attack_roll = tb_range.get_attack(attacker, defender, "test") + self.assertTrue(attack_roll >= 0 and attack_roll <= 100) + # Defense roll + defense_roll = tb_range.get_defense(attacker, defender, "test") + self.assertTrue(defense_roll == 50) + # Damage roll + damage_roll = tb_range.get_damage(attacker, defender) + self.assertTrue(damage_roll >= 15 and damage_roll <= 25) + # Apply damage + defender.db.hp = 10 + tb_range.apply_damage(defender, 3) + self.assertTrue(defender.db.hp == 7) + # Resolve attack + defender.db.hp = 40 + tb_range.resolve_attack(attacker, defender, "test", attack_value=20, defense_value=10) + self.assertTrue(defender.db.hp < 40) + # Combat cleanup + attacker.db.Combat_attribute = True + tb_range.combat_cleanup(attacker) + self.assertFalse(attacker.db.combat_attribute) + # Is in combat + self.assertFalse(tb_range.is_in_combat(attacker)) + # Set up turn handler script for further tests + attacker.location.scripts.add(tb_range.TBRangeTurnHandler) + turnhandler = attacker.db.combat_TurnHandler + self.assertTrue(attacker.db.combat_TurnHandler) + # Force turn order + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + # Test is turn + self.assertTrue(tb_range.is_turn(attacker)) + # Spend actions + attacker.db.Combat_ActionsLeft = 1 + tb_range.spend_action(attacker, 1, action_name="Test") + self.assertTrue(attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(attacker.db.Combat_LastAction == "Test") + # Initialize for combat + attacker.db.Combat_ActionsLeft = 983 + turnhandler.initialize_for_combat(attacker) + self.assertTrue(attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(attacker.db.Combat_LastAction == "null") + # Set up ranges again, since initialize_for_combat clears them + attacker.db.combat_range = {} + attacker.db.combat_range[attacker] = 0 + attacker.db.combat_range[defender] = 1 + defender.db.combat_range = {} + defender.db.combat_range[defender] = 0 + defender.db.combat_range[attacker] = 1 + # Start turn + defender.db.Combat_ActionsLeft = 0 + turnhandler.start_turn(defender) + self.assertTrue(defender.db.Combat_ActionsLeft == 2) + # Next turn + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + turnhandler.next_turn() + self.assertTrue(turnhandler.db.turn == 1) + # Turn end check + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + attacker.db.Combat_ActionsLeft = 0 + turnhandler.turn_end_check(attacker) + self.assertTrue(turnhandler.db.turn == 1) + # Join fight + joiner = create_object(tb_range.TBRangeCharacter, key="Joiner", location=testroom) + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + turnhandler.join_fight(joiner) + self.assertTrue(turnhandler.db.turn == 1) + self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) + # Now, test for approach/withdraw functions + self.assertTrue(tb_range.get_range(attacker, defender) == 1) + # Approach + tb_range.approach(attacker, defender) + self.assertTrue(tb_range.get_range(attacker, defender) == 0) + # Withdraw + tb_range.withdraw(attacker, defender) + self.assertTrue(tb_range.get_range(attacker, defender) == 1) + # Remove the script at the end + turnhandler.stop() # Test of the unixcommand module diff --git a/evennia/contrib/turnbattle/tb_range.py b/evennia/contrib/turnbattle/tb_range.py index 0eb617c48b..5596573a2d 100644 --- a/evennia/contrib/turnbattle/tb_range.py +++ b/evennia/contrib/turnbattle/tb_range.py @@ -474,7 +474,10 @@ def combat_status_message(fighter): distances to other fighters and objects. Called at turn start and by the 'status' command. """ - + if not fighter.db.max_hp: + fighter.db.hp = 100 + fighter.db.max_hp = 100 + status_msg = ("HP Remaining: %i / %i" % (fighter.db.hp, fighter.db.max_hp)) if not is_in_combat(fighter): @@ -1326,7 +1329,6 @@ class CmdStatus(Command): def func(self): "This performs the actual command." - combat_status_message(self.caller) From df9072253f9a99986689e397b66dcee90c0b2b6e Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 29 Oct 2017 23:00:22 -0700 Subject: [PATCH 044/189] Set turn handler's intervals higher during tests This was an attempt to try to fix some strange 'unhandled error in deffered' results while unit testing the contrib folder. It didn't work, but it's probably good to do anyway. --- evennia/contrib/tests.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 4b2c1a696f..79a61ce65d 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -993,6 +993,8 @@ class TestTurnBattleFunc(EvenniaTest): attacker.location.scripts.add(tb_basic.TBBasicTurnHandler) turnhandler = attacker.db.combat_TurnHandler self.assertTrue(attacker.db.combat_TurnHandler) + # Set the turn handler's interval very high to keep it from repeating during tests. + turnhandler.interval = 10000 # Force turn order turnhandler.db.fighters = [attacker, defender] turnhandler.db.turn = 0 @@ -1070,6 +1072,8 @@ class TestTurnBattleFunc(EvenniaTest): attacker.location.scripts.add(tb_equip.TBEquipTurnHandler) turnhandler = attacker.db.combat_TurnHandler self.assertTrue(attacker.db.combat_TurnHandler) + # Set the turn handler's interval very high to keep it from repeating during tests. + turnhandler.interval = 10000 # Force turn order turnhandler.db.fighters = [attacker, defender] turnhandler.db.turn = 0 @@ -1145,6 +1149,8 @@ class TestTurnBattleFunc(EvenniaTest): attacker.location.scripts.add(tb_range.TBRangeTurnHandler) turnhandler = attacker.db.combat_TurnHandler self.assertTrue(attacker.db.combat_TurnHandler) + # Set the turn handler's interval very high to keep it from repeating during tests. + turnhandler.interval = 10000 # Force turn order turnhandler.db.fighters = [attacker, defender] turnhandler.db.turn = 0 From c6f422d44b1bfe81e0cb7c7cdb1eafb9737d4c8a Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Mon, 30 Oct 2017 05:03:26 -0700 Subject: [PATCH 045/189] Updated username in contrib readme --- evennia/contrib/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/README.md b/evennia/contrib/README.md index ed3c048c32..e37b1ee294 100644 --- a/evennia/contrib/README.md +++ b/evennia/contrib/README.md @@ -19,7 +19,7 @@ things you want from here into your game folder and change them there. for any game. Allows safe trading of any godds (including coin) * CharGen (Griatch 2011) - A simple Character creator for OOC mode. Meant as a starting point for a more fleshed-out system. -* Clothing (BattleJenkins 2017) - A layered clothing system with +* Clothing (FlutterSprite 2017) - A layered clothing system with slots for different types of garments auto-showing in description. * Color-markups (Griatch, 2017) - Alternative in-game color markups. * Custom gametime (Griatch, vlgeoff 2017) - Implements Evennia's @@ -50,7 +50,7 @@ things you want from here into your game folder and change them there. time to pass depending on if you are walking/running etc. * Talking NPC (Griatch 2011) - A talking NPC object that offers a menu-driven conversation tree. -* Turnbattle (BattleJenkins 2017) - A turn-based combat engine meant +* Turnbattle (FlutterSprite 2017) - A turn-based combat engine meant as a start to build from. Has attack/disengage and turn timeouts. * Wilderness (titeuf87 2017) - Make infinitely large wilderness areas with dynamically created locations. From 89773e58608660f49b56d0c0a73169fdc52f76c6 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Mon, 30 Oct 2017 05:11:53 -0700 Subject: [PATCH 046/189] Moved 'turnbattle' to packages section in readme --- evennia/contrib/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/README.md b/evennia/contrib/README.md index e37b1ee294..63abb2f713 100644 --- a/evennia/contrib/README.md +++ b/evennia/contrib/README.md @@ -50,8 +50,6 @@ things you want from here into your game folder and change them there. time to pass depending on if you are walking/running etc. * Talking NPC (Griatch 2011) - A talking NPC object that offers a menu-driven conversation tree. -* Turnbattle (FlutterSprite 2017) - A turn-based combat engine meant - as a start to build from. Has attack/disengage and turn timeouts. * Wilderness (titeuf87 2017) - Make infinitely large wilderness areas with dynamically created locations. * UnixCommand (Vincent Le Geoff 2017) - Add commands with UNIX-style syntax. @@ -62,6 +60,9 @@ things you want from here into your game folder and change them there. to the Evennia game index (games.evennia.com) * In-game Python (Vincent Le Goff 2017) - Allow trusted builders to script objects and events using Python from in-game. +* Turnbattle (FlutterSprite 2017) - A turn-based combat engine meant + as a start to build from. Has attack/disengage and turn timeouts, + and includes optional expansions for equipment and combat movement. * Tutorial examples (Griatch 2011, 2015) - A folder of basic example objects, commands and scripts. * Tutorial world (Griatch 2011, 2015) - A folder containing the From e48763a02e4c732968037ca579986e9a85727822 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Mon, 30 Oct 2017 13:23:06 -0700 Subject: [PATCH 047/189] Clarify the nature of the different modules --- evennia/contrib/turnbattle/README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/evennia/contrib/turnbattle/README.md b/evennia/contrib/turnbattle/README.md index af04f80060..729c42a099 100644 --- a/evennia/contrib/turnbattle/README.md +++ b/evennia/contrib/turnbattle/README.md @@ -26,9 +26,17 @@ implemented and customized: tracks the distance between different characters and objects in combat, as well as differentiates between melee and ranged attacks. - + This system is meant as a basic framework to start from, and is modeled after the combat systems of popular tabletop role playing games rather than the real-time battle systems that many MMOs and some MUDs use. As such, it may be better suited to role-playing or more story-oriented games, or games meant to closely emulate the experience of playing a tabletop RPG. + +Each of these modules contains the full functionality of the battle system +with different customizations added in - the instructions to install each +one is contained in the module itself. It's recommended that you install +and test tb_basic first, so you can better understand how the other +modules expand on it and get a better idea of how you can customize the +system to your liking and integrate the subsystems presented here into +your own combat system. From 94e9b4370ec007ad014929f9f79d212629400b99 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Mon, 30 Oct 2017 15:01:51 -0700 Subject: [PATCH 048/189] Add simple menu tree selection contrib This contrib module allows developers to generate an EvMenu instance with options sourced from a multi-line string, which supports categories, back and forth menu navigation, option descriptions, and passing selections to custom callbacks. This allows for easier dynamic menus and much faster deployment of simple menu trees which does not require the manual definition of menu nodes and option dictionary-lists. --- evennia/contrib/tree_select.py | 522 +++++++++++++++++++++++++++++++++ 1 file changed, 522 insertions(+) create mode 100644 evennia/contrib/tree_select.py diff --git a/evennia/contrib/tree_select.py b/evennia/contrib/tree_select.py new file mode 100644 index 0000000000..56305340b3 --- /dev/null +++ b/evennia/contrib/tree_select.py @@ -0,0 +1,522 @@ +""" +Easy menu selection tree + +Contrib - Tim Ashley Jenkins 2017 + +This module allows you to create and initialize an entire branching EvMenu +instance with nothing but a multi-line string passed to one function. + +EvMenu is incredibly powerful and flexible, but using it for simple menus +can often be fairly cumbersome - a simple menu that can branch into five +categories would require six nodes, each with options represented as a list +of dictionaries. + +This module provides a function, init_tree_selection, which acts as a frontend +for EvMenu, dynamically sourcing the options from a multi-line string you provide. +For example, if you define a string as such: + + TEST_MENU = '''Foo + Bar + Baz + Qux''' + +And then use TEST_MENU as the 'treestr' source when you call init_tree_selection +on a player: + + init_tree_selection(TEST_MENU, caller, callback) + +The player will be presented with an EvMenu, like so: + + ___________________________ + + Make your selection: + ___________________________ + + Foo + Bar + Baz + Qux + +Making a selection will pass the selection's key to the specified callback as a +string along with the caller, as well as the index of the selection (the line number +on the source string) along with the source string for the tree itself. + +In addition to specifying selections on the menu, you can also specify categories. +Categories are indicated by putting options below it preceded with a '-' character. +If a selection is a category, then choosing it will bring up a new menu node, prompting +the player to select between those options, or to go back to the previous menu. In +addition, categories are marked by default with a '[+]' at the end of their key. Both +this marker and the option to go back can be disabled. + +Categories can be nested in other categories as well - just go another '-' deeper. You +can do this as many times as you like. There's no hard limit to the number of +categories you can go down. + +For example, let's add some more options to our menu, turning 'Foo' into a category. + + TEST_MENU = '''Foo + Bar + -You've got to know + --When to hold em + --When to fold em + --When to walk away + Baz + Qux''' + +Now when we call the menu, we can see that 'Foo' has become a category instead of a +selectable option. + + _______________________________ + + Make your selection: + _______________________________ + + Foo + Bar [+] + Baz + Qux + +Note the [+] next to 'Bar'. If we select 'Bar', it'll show us the option listed under it. + + ________________________________________________________________ + + Bar + ________________________________________________________________ + + You've got to know [+] + << Go Back: Return to the previous menu. + +Just the one option, which is a category itself, and the option to go back, which will +take us back to the previous menu. Let's select 'You've got to know'. + + ________________________________________________________________ + + You've got to know + ________________________________________________________________ + + When to hold em + When to fold em + When to walk away + << Go Back: Return to the previous menu. + +Now we see the three options listed under it, too. We can select one of them or use 'Go +Back' to return to the 'Bar' menu we were just at before. It's very simple to make a +branching tree of selections! + +One last thing - you can set the descriptions for the various options simply by adding a +':' character followed by the description to the option's line. For example, let's add a +description to 'Baz' in our menu: + + TEST_MENU = '''Foo + Bar + -You've got to know + --When to hold em + --When to fold em + --When to walk away + Baz: Look at this one: the best option. + Qux''' + +Now we see that the Baz option has a description attached that's separate from its key: + + _______________________________________________________________ + + Make your selection: + _______________________________________________________________ + + Foo + Bar [+] + Baz: Look at this one: the best option. + Qux + +And that's all there is to it! For simple branching-tree selections, using this system is +much easier than manually creating EvMenu nodes. It also makes generating menus with dynamic +options much easier - since the source of the menu tree is just a string, you could easily +generate that string procedurally before passing it to the init_tree_selection function. +For example, if a player casts a spell or does an attack without specifying a target, instead +of giving them an error, you could present them with a list of valid targets to select by +generating a multi-line string of targets and passing it to init_tree_selection, with the +callable performing the maneuver once a selection is made. + +This selection system only works for simple branching trees - doing anything really complicated +like jumping between categories or prompting for arbitrary input would still require a full +EvMenu implementation. For simple selections, however, I'm sure you will find using this function +to be much easier! + +Included in this module is a sample menu and function which will let a player change the color +of their name - feel free to mess with it to get a feel for how this system works by importing +this module in your game's default_cmdsets.py module and adding CmdNameColor to your default +character's command set. +""" + +from evennia.utils import evmenu +from evennia import Command + +def init_tree_selection(treestr, caller, callback, + index=None, mark_category=True, go_back=True, + cmd_on_exit="look", + start_text="Make your selection:"): + """ + Prompts a player to select an option from a menu tree given as a multi-line string. + + Args: + treestr (str): Multi-lne string representing menu options + caller (obj): Player to initialize the menu for + callback (callable): Function to run when a selection is made. Must take 4 args: + treestr (str): Menu tree string given above + caller (obj): Caller given above + index (int): Index of final selection + selection (str): Key of final selection + + Options: + index (int or None): Index to start the menu at, or None for top level + mark_category (bool): If True, marks categories with a [+] symbol in the menu + go_back (bool): If True, present an option to go back to previous categories + start_text (str): Text to display at the top level of the menu + cmd_on_exit(str): Command to enter when the menu exits - 'look' by default + + + Notes: + This function will initialize an instance of EvMenu with options generated + dynamically from the source string, and passes the menu user's selection to + a function of your choosing. The EvMenu is made of a single, repeating node, + which will call itself over and over at different levels of the menu tree as + categories are selected. + + Once a non-category selection is made, the user's selection will be passed to + the given callable, both as a string and as an index number. The index is given + to ensure every selection has a unique identifier, so that selections with the + same key in different categories can be distinguished between. + + The menus called by this function are not persistent and cannot perform + complicated tasks like prompt for arbitrary input or jump multiple category + levels at once - you'll have to use EvMenu itself if you want to take full + advantage of its features. + """ + + # Pass kwargs to store data needed in the menu + kwargs = { + "index":index, + "mark_category":mark_category, + "go_back":go_back, + "treestr":treestr, + "callback":callback, + "start_text":start_text + } + + # Initialize menu of selections + evmenu.EvMenu(caller, "evennia.contrib.tree_select", startnode="menunode_treeselect", + startnode_input=None, cmd_on_exit=cmd_on_exit, **kwargs) + +def dashcount(entry): + """ + Counts the number of dashes at the beginning of a string. This + is needed to determine the depth of options in categories. + + Args: + entry (str): String to count the dashes at the start of + + Returns: + dashes (int): Number of dashes at the start + """ + dashes = 0 + for char in entry: + if char == "-": + dashes += 1 + else: + return dashes + return dashes + +def is_category(treestr, index): + """ + Determines whether an option in a tree string is a category by + whether or not there are additional options below it. + + Args: + treestr (str): Multi-line string representing menu options + index (int): Which line of the string to test + + Returns: + is_category (bool): Whether the option is a category + """ + opt_list = treestr.split('\n') + # Not a category if it's the last one in the list + if index == len(opt_list) - 1: + return False + # Not a category if next option is not one level deeper + return not bool(dashcount(opt_list[index+1]) != dashcount(opt_list[index]) + 1) + +def parse_opts(treestr, category_index=None): + """ + Parses a tree string and given index into a list of options. If + category_index is none, returns all the options at the top level of + the menu. If category_index corresponds to a category, returns a list + of options under that category. If category_index corresponds to + an option that is not a category, it's a selection and returns True. + + Args: + treestr (str): Multi-line string representing menu options + category_index (int): Index of category or None for top level + + Returns: + kept_opts (list or True): Either a list of options in the selected + category or True if a selection was made + """ + dash_depth = 0 + opt_list = treestr.split('\n') + kept_opts = [] + + # If a category index is given + if category_index != None: + # If given index is not a category, it's a selection - return True. + if not is_category(treestr, category_index): + return True + # Otherwise, change the dash depth to match the new category. + dash_depth = dashcount(opt_list[category_index]) + 1 + # Delete everything before the category index + opt_list = opt_list [category_index+1:] + + # Keep every option (referenced by index) at the appropriate depth + cur_index = 0 + for option in opt_list: + if dashcount(option) == dash_depth: + if category_index == None: + kept_opts.append((cur_index, option[dash_depth:])) + else: + kept_opts.append((cur_index + category_index + 1, option[dash_depth:])) + # Exits the loop if leaving a category + if dashcount(option) < dash_depth: + return kept_opts + cur_index += 1 + return kept_opts + +def index_to_selection(treestr, index, desc=False): + """ + Given a menu tree string and an index, returns the corresponding selection's + name as a string. If 'desc' is set to True, will return the selection's + description as a string instead. + + Args: + treestr (str): Multi-line string representing menu options + index (int): Index to convert to selection key or description + + Options: + desc (bool): If true, returns description instead of key + + Returns: + selection (str): Selection key or description if 'desc' is set + """ + opt_list = treestr.split('\n') + # Fetch the given line + selection = opt_list[index] + # Strip out the dashes at the start + selection = selection[dashcount(selection):] + # Separate out description, if any + if ":" in selection: + # Split string into key and description + selection = selection.split(':', 1) + selection[1] = selection[1].strip(" ") + else: + # If no description given, set description to None + selection = [selection, None] + if not desc: + return selection[0] + else: + return selection[1] + +def go_up_one_category(treestr, index): + """ + Given a menu tree string and an index, returns the category that the given option + belongs to. Used for the 'go back' option. + + Args: + treestr (str): Multi-line string representing menu options + index (int): Index to determine the parent category of + + Returns: + parent_category (int): Index of parent category + """ + opt_list = treestr.split('\n') + # Get the number of dashes deep the given index is + dash_level = dashcount(opt_list[index]) + # Delete everything after the current index + opt_list = opt_list[:index+1] + + + # If there's no dash, return 'None' to return to base menu + if dash_level == 0: + return None + current_index = index + # Go up through each option until we find one that's one category above + for selection in reversed(opt_list): + if dashcount(selection) == dash_level - 1: + return current_index + current_index -= 1 + +def optlist_to_menuoptions(treestr, optlist, index, mark_category, go_back): + """ + Takes a list of options processed by parse_opts and turns it into + a list/dictionary of menu options for use in menunode_treeselect. + + Args: + treestr (str): Multi-line string representing menu options + optlist (list): List of options to convert to EvMenu's option format + index (int): Index of current category + mark_category (bool): Whether or not to mark categories with [+] + go_back (bool): Whether or not to add an option to go back in the menu + + Returns: + menuoptions (list of dicts): List of menu options formatted for use + in EvMenu, each passing a different "newindex" kwarg that changes + the menu level or makes a selection + """ + + menuoptions = [] + cur_index = 0 + for option in optlist: + index_to_add = optlist[cur_index][0] + menuitem = {} + keystr = index_to_selection(treestr, index_to_add) + if mark_category and is_category(treestr, index_to_add): + # Add the [+] to the key if marking categories, and the key by itself as an alias + menuitem["key"] = [keystr + " [+]", keystr] + else: + menuitem["key"] = keystr + # Get the option's description + desc = index_to_selection(treestr, index_to_add, desc=True) + if desc: + menuitem["desc"] = desc + # Passing 'newindex' as a kwarg to the node is how we move through the menu! + menuitem["goto"] = ["menunode_treeselect", {"newindex":index_to_add}] + menuoptions.append(menuitem) + cur_index += 1 + # Add option to go back, if needed + if index != None and go_back == True: + gobackitem = {"key":["<< Go Back", "go back", "back"], + "desc":"Return to the previous menu.", + "goto":["menunode_treeselect", {"newindex":go_up_one_category(treestr, index)}]} + menuoptions.append(gobackitem) + return menuoptions + +def menunode_treeselect(caller, raw_string, **kwargs): + """ + This is the repeating menu node that handles the tree selection. + """ + + # If 'newindex' is in the kwargs, change the stored index. + if "newindex" in kwargs: + caller.ndb._menutree.index = kwargs["newindex"] + + # Retrieve menu info + index = caller.ndb._menutree.index + mark_category = caller.ndb._menutree.mark_category + go_back = caller.ndb._menutree.go_back + treestr = caller.ndb._menutree.treestr + callback = caller.ndb._menutree.callback + start_text = caller.ndb._menutree.start_text + + # List of options if index is 'None' or category, or 'True' if a selection + optlist = parse_opts(treestr, category_index=index) + + # If given index returns optlist as 'True', it's a selection. Pass to callback and end the menu. + if optlist == True: + selection = index_to_selection(treestr, index) + callback(caller, treestr, index, selection) + + # Returning None, None ends the menu. + return None, None + + # Otherwise, convert optlist to a list of menu options. + else: + options = optlist_to_menuoptions(treestr, optlist, index, mark_category, go_back) + if index == None: + # Use start_text for the menu text on the top level + text = start_text + else: + # Use the category name and description (if any) as the menu text + if index_to_selection(treestr, index, desc=True) != None: + text = "|w" + index_to_selection(treestr, index) + "|n: " + index_to_selection(treestr, index, desc=True) + else: + text = "|w" + index_to_selection(treestr, index) + "|n" + return text, options + +# The rest of this module is for the example menu and command! It'll change the color of your name. + +""" +Here's an example string that you can initialize a menu from. Note the dashes at +the beginning of each line - that's how menu option depth and hierarchy is determined. +""" + +NAMECOLOR_MENU = """Set name color: Choose a color for your name! +-Red shades: Various shades of |511red|n +--Red: |511Set your name to Red|n +--Pink: |533Set your name to Pink|n +--Maroon: |301Set your name to Maroon|n +-Orange shades: Various shades of |531orange|n +--Orange: |531Set your name to Orange|n +--Brown: |321Set your name to Brown|n +--Sienna: |420Set your name to Sienna|n +-Yellow shades: Various shades of |551yellow|n +--Yellow: |551Set your name to Yellow|n +--Gold: |540Set your name to Gold|n +--Dandelion: |553Set your name to Dandelion|n +-Green shades: Various shades of |141green|n +--Green: |141Set your name to Green|n +--Lime: |350Set your name to Lime|n +--Forest: |032Set your name to Forest|n +-Blue shades: Various shades of |115blue|n +--Blue: |115Set your name to Blue|n +--Cyan: |155Set your name to Cyan|n +--Navy: |113Set your name to Navy|n +-Purple shades: Various shades of |415purple|n +--Purple: |415Set your name to Purple|n +--Lavender: |535Set your name to Lavender|n +--Fuchsia: |503Set your name to Fuchsia|n +Remove name color: Remove your name color, if any""" + +class CmdNameColor(Command): + """ + Set or remove a special color on your name. Just an example for the + easy menu selection tree contrib. + """ + + key = "namecolor" + + def func(self): + # This is all you have to do to initialize a menu! + init_tree_selection(TEST_MENU, self.caller, + change_name_color, + start_text="Name color options:") + +def change_name_color(caller, treestr, index, selection): + """ + Changes a player's name color. + + Args: + caller (obj): Character whose name to color. + treestr (str): String for the color change menu - unused + index (int): Index of menu selection - unused + selection (str): Selection made from the name color menu - used + to determine the color the player chose. + """ + + # Store the caller's uncolored name + if not caller.db.uncolored_name: + caller.db.uncolored_name = caller.key + + # Dictionary matching color selection names to color codes + colordict = { "Red":"|511", "Pink":"|533", "Maroon":"|301", + "Orange":"|531", "Brown":"|321", "Sienna":"|420", + "Yellow":"|551", "Gold":"|540", "Dandelion":"|553", + "Green":"|141", "Lime":"|350", "Forest":"|032", + "Blue":"|115", "Cyan":"|155", "Navy":"|113", + "Purple":"|415", "Lavender":"|535", "Fuchsia":"|503"} + + # I know this probably isn't the best way to do this. It's just an example! + if selection == "Remove name color": # Player chose to remove their name color + caller.key = caller.db.uncolored_name + caller.msg("Name color removed.") + elif selection in colordict: + newcolor = colordict[selection] # Retrieve color code based on menu selection + caller.key = newcolor + caller.db.uncolored_name + "|n" # Add color code to caller's name + caller.msg(newcolor + ("Name color changed to %s!" % selection) + "|n") + From fc16898db318bdec2d0cb0c7d4531371590423d8 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Mon, 30 Oct 2017 16:21:32 -0700 Subject: [PATCH 049/189] Added unit tests for tree_select contrib --- evennia/contrib/tests.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 79a61ce65d..e2148fa0da 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1206,6 +1206,36 @@ class TestTurnBattleFunc(EvenniaTest): # Remove the script at the end turnhandler.stop() +# Test tree select + +from evennia.contrib import tree_select + +TREE_MENU_TESTSTR = """Foo +Bar +-Baz +--Baz 1 +--Baz 2 +-Qux""" + +class TestTreeSelectFunc(EvenniaTest): + + def test_tree_functions(self): + # Dash counter + self.assertTrue(tree_select.dashcount("--test") == 2) + # Is category + self.assertTrue(tree_select.is_category(TREE_MENU_TESTSTR, 1) == True) + # Parse options + self.assertTrue(tree_select.parse_opts(TREE_MENU_TESTSTR, category_index=2) == [(3, "Baz 1"), (4, "Baz 2")]) + # Index to selection + self.assertTrue(tree_select.index_to_selection(TREE_MENU_TESTSTR, 2) == "Baz") + # Go up one category + self.assertTrue(tree_select.go_up_one_category(TREE_MENU_TESTSTR, 4) == 2) + # Option list to menu options + test_optlist = tree_select.parse_opts(TREE_MENU_TESTSTR, category_index=2) + optlist_to_menu_expected_result = [{'goto': ['menunode_treeselect', {'newindex': 3}], 'key': 'Baz 1'}, + {'goto': ['menunode_treeselect', {'newindex': 4}], 'key': 'Baz 2'}, + {'goto': ['menunode_treeselect', {'newindex': 1}], 'key': ['<< Go Back', 'go back', 'back'], 'desc': 'Return to the previous menu.'}] + self.assertTrue(tree_select.optlist_to_menuoptions(TREE_MENU_TESTSTR, test_optlist, 2, True, True) == optlist_to_menu_expected_result) # Test of the unixcommand module From 9a047a6362b016de64fe22322f86ec5d73cfcee7 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Mon, 30 Oct 2017 16:24:11 -0700 Subject: [PATCH 050/189] Add tree select to README.md --- evennia/contrib/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/evennia/contrib/README.md b/evennia/contrib/README.md index 63abb2f713..52e63b7b1a 100644 --- a/evennia/contrib/README.md +++ b/evennia/contrib/README.md @@ -50,6 +50,9 @@ things you want from here into your game folder and change them there. time to pass depending on if you are walking/running etc. * Talking NPC (Griatch 2011) - A talking NPC object that offers a menu-driven conversation tree. +* Tree Select (FlutterSprite 2017) - A simple system for creating a + branching EvMenu with selection options sourced from a single + multi-line string. * Wilderness (titeuf87 2017) - Make infinitely large wilderness areas with dynamically created locations. * UnixCommand (Vincent Le Geoff 2017) - Add commands with UNIX-style syntax. From 5ea86d86fa498396c74c9b0bf4027871b1c7d98a Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Mon, 30 Oct 2017 19:16:43 -0700 Subject: [PATCH 051/189] Fix typo in documentation --- evennia/contrib/tree_select.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/tree_select.py b/evennia/contrib/tree_select.py index 56305340b3..a92cd6c2a7 100644 --- a/evennia/contrib/tree_select.py +++ b/evennia/contrib/tree_select.py @@ -52,7 +52,7 @@ Categories can be nested in other categories as well - just go another '-' deepe can do this as many times as you like. There's no hard limit to the number of categories you can go down. -For example, let's add some more options to our menu, turning 'Foo' into a category. +For example, let's add some more options to our menu, turning 'Bar' into a category. TEST_MENU = '''Foo Bar @@ -63,7 +63,7 @@ For example, let's add some more options to our menu, turning 'Foo' into a categ Baz Qux''' -Now when we call the menu, we can see that 'Foo' has become a category instead of a +Now when we call the menu, we can see that 'Bar' has become a category instead of a selectable option. _______________________________ From 9ab3d278755492877d68c2b0e3e7d273cfa78772 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 5 Nov 2017 18:36:08 -0800 Subject: [PATCH 052/189] Adds health bar module Adds a versatile function that will return a given current and maximum value as a "health bar" rendered with ANSI or xterm256 background color codes. This function has many options, such as being able to specify the length of the bar, its colors (including changing color depending on how full the bar is), what text is included inside the bar and how the text is justified within it. --- evennia/contrib/health_bar.py | 103 ++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 evennia/contrib/health_bar.py diff --git a/evennia/contrib/health_bar.py b/evennia/contrib/health_bar.py new file mode 100644 index 0000000000..c3d4af1c52 --- /dev/null +++ b/evennia/contrib/health_bar.py @@ -0,0 +1,103 @@ +""" +Health Bar + +Contrib - Tim Ashley Jenkins 2017 + +The function provided in this module lets you easily display visual +bars or meters - "health bar" is merely the most obvious use for this, +though these bars are highly customizable and can be used for any sort +of appropriate data besides player health. + +Today's players may be more used to seeing statistics like health, +stamina, magic, and etc. displayed as bars rather than bare numerical +values, so using this module to present this data this way may make it +more accessible. Keep in mind, however, that players may also be using +a screen reader to connect to your game, which will not be able to +represent the colors of the bar in any way. By default, the values +represented are rendered as text inside the bar which can be read by +screen readers. + +The health bar will account for current values above the maximum or +below 0, rendering them as a completely full or empty bar with the +values displayed within. +""" + +def display_meter(cur_value, max_value, + length=30, fill_color=["R", "Y", "G"], + empty_color="B", text_color="w", + align="left", pre_text="", post_text="", + show_values=True): + """ + Represents a current and maximum value given as a "bar" rendered with + ANSI or xterm256 background colors. + + Args: + cur_value (int): Current value to display + max_value (int): Maximum value to display + + Options: + length (int): Length of meter returned, in characters + fill_color (list): List of color codes for the full portion + of the bar, sans any sort of prefix - both ANSI and xterm256 + colors are usable. When the bar is empty, colors toward the + start of the list will be chosen - when the bar is full, colors + towards the end are picked. You can adjust the 'weights' of + the changing colors by adding multiple entries of the same + color - for example, if you only want the bar to change when + it's close to empty, you could supply ['R','Y','G','G','G'] + empty_color (str): Color code for the empty portion of the bar. + text_color (str): Color code for text inside the bar. + align (str): "left", "right", or "center" - alignment of text in the bar + pre_text (str): Text to put before the numbers in the bar + post_text (str): Text to put after the numbers in the bar + show_values (bool): If true, shows the numerical values represented by + the bar. It's highly recommended you keep this on, especially if + there's no info given in pre_text or post_text, as players on screen + readers will be unable to read the graphical aspect of the bar. + """ + # Start by building the base string. + num_text = "" + if show_values: + num_text = "%i / %i" % (cur_value, max_value) + bar_base_str = pre_text + num_text + post_text + # Cut down the length of the base string if needed + if len(bar_base_str) > length: + bar_base_str = bar_base_str[:length] + # Pad and align the bar base string + if align == "right": + bar_base_str = bar_base_str.rjust(length, " ") + elif align == "center": + bar_base_str = bar_base_str.center(length, " ") + else: + bar_base_str = bar_base_str.ljust(length, " ") + + if max_value < 1: # Prevent divide by zero + max_value = 1 + if cur_value < 0: # Prevent weirdly formatted 'negative bars' + cur_value = 0 + if cur_value > max_value: # Display overfull bars correctly + cur_value = max_value + + # Now it's time to determine where to put the color codes. + percent_full = float(cur_value) / float(max_value) + split_index = round(float(length) * percent_full) + # Determine point at which to split the bar + split_index = int(split_index) + + # Separate the bar string into full and empty portions + full_portion = bar_base_str[:split_index] + empty_portion = bar_base_str[split_index:] + + # Pick which fill color to use based on how full the bar is + fillcolor_index = (float(len(fill_color)) * percent_full) + fillcolor_index = int(round(fillcolor_index)) - 1 + fillcolor_code = "|[" + fill_color[fillcolor_index] + + # Make color codes for empty bar portion and text_color + emptycolor_code = "|[" + empty_color + textcolor_code = "|" + text_color + + # Assemble the final bar + final_bar = fillcolor_code + textcolor_code + full_portion + "|n" + emptycolor_code + textcolor_code + empty_portion + "|n" + + return final_bar From 8d5e167a8dff7a2362ceb8d41b37475e3b06753f Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 5 Nov 2017 18:42:55 -0800 Subject: [PATCH 053/189] Add unit tests for health_bar contrib --- evennia/contrib/tests.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 79a61ce65d..4f4ac8c4bf 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -651,6 +651,15 @@ class TestGenderSub(CommandTest): char = create_object(gendersub.GenderCharacter, key="Gendered", location=self.room1) txt = "Test |p gender" self.assertEqual(gendersub._RE_GENDER_PRONOUN.sub(char._get_pronoun, txt), "Test their gender") + +# test health bar contrib + +from evennia.contrib import health_bar + +class TestHealthBar(EvenniaTest): + def test_healthbar(self): + expected_bar_str = "|[G|w |n|[B|w test24 / 200test |n" + self.assertTrue(health_bar.display_meter(24, 200, length=40, pre_text="test", post_text="test", align="center") == expected_bar_str) # test mail contrib From 4a554a44094ba31c1b545f761213ccb56efbf30e Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 12 Nov 2017 01:56:35 -0800 Subject: [PATCH 054/189] Add mention of how the callback is used --- evennia/contrib/tree_select.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/evennia/contrib/tree_select.py b/evennia/contrib/tree_select.py index a92cd6c2a7..2eab9450d7 100644 --- a/evennia/contrib/tree_select.py +++ b/evennia/contrib/tree_select.py @@ -127,7 +127,16 @@ Now we see that the Baz option has a description attached that's separate from i Bar [+] Baz: Look at this one: the best option. Qux + +Once the player makes a selection - let's say, 'Foo' - the menu will terminate and call +your specified callback with the selection, like so: + + callback(TEST_MENU, caller, 0, "Foo") +The index of the selection is given along with a string containing the selection's key. +That way, if you have two selections in the menu with the same key, you can still +differentiate between them. + And that's all there is to it! For simple branching-tree selections, using this system is much easier than manually creating EvMenu nodes. It also makes generating menus with dynamic options much easier - since the source of the menu tree is just a string, you could easily From ebe7c6f4b3df77bcb75aee77b3556ace365a9b8b Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 12 Nov 2017 02:21:49 -0800 Subject: [PATCH 055/189] Fix order of args for the callback in documentation --- evennia/contrib/tree_select.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/tree_select.py b/evennia/contrib/tree_select.py index 2eab9450d7..d3befd9614 100644 --- a/evennia/contrib/tree_select.py +++ b/evennia/contrib/tree_select.py @@ -131,7 +131,7 @@ Now we see that the Baz option has a description attached that's separate from i Once the player makes a selection - let's say, 'Foo' - the menu will terminate and call your specified callback with the selection, like so: - callback(TEST_MENU, caller, 0, "Foo") + callback(caller, TEST_MENU, 0, "Foo") The index of the selection is given along with a string containing the selection's key. That way, if you have two selections in the menu with the same key, you can still @@ -171,8 +171,8 @@ def init_tree_selection(treestr, caller, callback, treestr (str): Multi-lne string representing menu options caller (obj): Player to initialize the menu for callback (callable): Function to run when a selection is made. Must take 4 args: - treestr (str): Menu tree string given above caller (obj): Caller given above + treestr (str): Menu tree string given above index (int): Index of final selection selection (str): Key of final selection From 7d10570424b8e8818b52de32bbafdd58a733ac02 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 12 Nov 2017 11:46:59 -0800 Subject: [PATCH 056/189] Catch callback errors with logger --- evennia/contrib/tree_select.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/evennia/contrib/tree_select.py b/evennia/contrib/tree_select.py index d3befd9614..85970f58b4 100644 --- a/evennia/contrib/tree_select.py +++ b/evennia/contrib/tree_select.py @@ -158,6 +158,7 @@ character's command set. """ from evennia.utils import evmenu +from evennia.utils.logger import log_trace from evennia import Command def init_tree_selection(treestr, caller, callback, @@ -429,7 +430,10 @@ def menunode_treeselect(caller, raw_string, **kwargs): # If given index returns optlist as 'True', it's a selection. Pass to callback and end the menu. if optlist == True: selection = index_to_selection(treestr, index) - callback(caller, treestr, index, selection) + try: + callback(caller, treestr, index, selection) + except: + log_trace("Error in tree selection callback.") # Returning None, None ends the menu. return None, None From 53d8536744f5a24587d9b4878c3893f97ad807e9 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 12 Nov 2017 11:51:53 -0800 Subject: [PATCH 057/189] Update tree_select.py --- evennia/contrib/tree_select.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/contrib/tree_select.py b/evennia/contrib/tree_select.py index 85970f58b4..8aca99ec9e 100644 --- a/evennia/contrib/tree_select.py +++ b/evennia/contrib/tree_select.py @@ -432,7 +432,7 @@ def menunode_treeselect(caller, raw_string, **kwargs): selection = index_to_selection(treestr, index) try: callback(caller, treestr, index, selection) - except: + except Exception: log_trace("Error in tree selection callback.") # Returning None, None ends the menu. From f0630535e0dbc9f04342d22857661213b2143236 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 12 Nov 2017 11:58:23 -0800 Subject: [PATCH 058/189] Fix variable in example menu function I changed this while making unit tests and forgot to change it back. Whoops! --- evennia/contrib/tree_select.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/contrib/tree_select.py b/evennia/contrib/tree_select.py index 8aca99ec9e..5b8f038e33 100644 --- a/evennia/contrib/tree_select.py +++ b/evennia/contrib/tree_select.py @@ -496,7 +496,7 @@ class CmdNameColor(Command): def func(self): # This is all you have to do to initialize a menu! - init_tree_selection(TEST_MENU, self.caller, + init_tree_selection(NAMECOLOR_MENU, self.caller, change_name_color, start_text="Name color options:") From cc398f985117f6f8d2f81429b43c77b539444588 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 29 Nov 2017 19:32:50 +0100 Subject: [PATCH 059/189] Remove some spurious spaces --- evennia/contrib/tree_select.py | 140 ++++++++++++++++----------------- 1 file changed, 70 insertions(+), 70 deletions(-) diff --git a/evennia/contrib/tree_select.py b/evennia/contrib/tree_select.py index 5b8f038e33..d2854fc83f 100644 --- a/evennia/contrib/tree_select.py +++ b/evennia/contrib/tree_select.py @@ -28,14 +28,14 @@ on a player: The player will be presented with an EvMenu, like so: ___________________________ - + Make your selection: ___________________________ - - Foo - Bar - Baz - Qux + + Foo + Bar + Baz + Qux Making a selection will pass the selection's key to the specified callback as a string along with the caller, as well as the index of the selection (the line number @@ -62,7 +62,7 @@ For example, let's add some more options to our menu, turning 'Bar' into a categ --When to walk away Baz Qux''' - + Now when we call the menu, we can see that 'Bar' has become a category instead of a selectable option. @@ -71,34 +71,34 @@ selectable option. Make your selection: _______________________________ - Foo - Bar [+] - Baz - Qux - + Foo + Bar [+] + Baz + Qux + Note the [+] next to 'Bar'. If we select 'Bar', it'll show us the option listed under it. ________________________________________________________________ Bar ________________________________________________________________ - - You've got to know [+] - << Go Back: Return to the previous menu. - + + You've got to know [+] + << Go Back: Return to the previous menu. + Just the one option, which is a category itself, and the option to go back, which will take us back to the previous menu. Let's select 'You've got to know'. ________________________________________________________________ - + You've got to know ________________________________________________________________ - - When to hold em - When to fold em - When to walk away + + When to hold em + When to fold em + When to walk away << Go Back: Return to the previous menu. - + Now we see the three options listed under it, too. We can select one of them or use 'Go Back' to return to the 'Bar' menu we were just at before. It's very simple to make a branching tree of selections! @@ -115,24 +115,24 @@ description to 'Baz' in our menu: --When to walk away Baz: Look at this one: the best option. Qux''' - + Now we see that the Baz option has a description attached that's separate from its key: _______________________________________________________________ Make your selection: _______________________________________________________________ - - Foo - Bar [+] - Baz: Look at this one: the best option. - Qux + + Foo + Bar [+] + Baz: Look at this one: the best option. + Qux Once the player makes a selection - let's say, 'Foo' - the menu will terminate and call your specified callback with the selection, like so: callback(caller, TEST_MENU, 0, "Foo") - + The index of the selection is given along with a string containing the selection's key. That way, if you have two selections in the menu with the same key, you can still differentiate between them. @@ -167,7 +167,7 @@ def init_tree_selection(treestr, caller, callback, start_text="Make your selection:"): """ Prompts a player to select an option from a menu tree given as a multi-line string. - + Args: treestr (str): Multi-lne string representing menu options caller (obj): Player to initialize the menu for @@ -176,33 +176,33 @@ def init_tree_selection(treestr, caller, callback, treestr (str): Menu tree string given above index (int): Index of final selection selection (str): Key of final selection - + Options: index (int or None): Index to start the menu at, or None for top level mark_category (bool): If True, marks categories with a [+] symbol in the menu go_back (bool): If True, present an option to go back to previous categories start_text (str): Text to display at the top level of the menu cmd_on_exit(str): Command to enter when the menu exits - 'look' by default - - + + Notes: This function will initialize an instance of EvMenu with options generated dynamically from the source string, and passes the menu user's selection to a function of your choosing. The EvMenu is made of a single, repeating node, which will call itself over and over at different levels of the menu tree as categories are selected. - + Once a non-category selection is made, the user's selection will be passed to the given callable, both as a string and as an index number. The index is given to ensure every selection has a unique identifier, so that selections with the same key in different categories can be distinguished between. - + The menus called by this function are not persistent and cannot perform complicated tasks like prompt for arbitrary input or jump multiple category levels at once - you'll have to use EvMenu itself if you want to take full advantage of its features. - """ - + """ + # Pass kwargs to store data needed in the menu kwargs = { "index":index, @@ -212,7 +212,7 @@ def init_tree_selection(treestr, caller, callback, "callback":callback, "start_text":start_text } - + # Initialize menu of selections evmenu.EvMenu(caller, "evennia.contrib.tree_select", startnode="menunode_treeselect", startnode_input=None, cmd_on_exit=cmd_on_exit, **kwargs) @@ -221,10 +221,10 @@ def dashcount(entry): """ Counts the number of dashes at the beginning of a string. This is needed to determine the depth of options in categories. - + Args: entry (str): String to count the dashes at the start of - + Returns: dashes (int): Number of dashes at the start """ @@ -240,11 +240,11 @@ def is_category(treestr, index): """ Determines whether an option in a tree string is a category by whether or not there are additional options below it. - + Args: treestr (str): Multi-line string representing menu options index (int): Which line of the string to test - + Returns: is_category (bool): Whether the option is a category """ @@ -262,11 +262,11 @@ def parse_opts(treestr, category_index=None): the menu. If category_index corresponds to a category, returns a list of options under that category. If category_index corresponds to an option that is not a category, it's a selection and returns True. - + Args: treestr (str): Multi-line string representing menu options category_index (int): Index of category or None for top level - + Returns: kept_opts (list or True): Either a list of options in the selected category or True if a selection was made @@ -274,7 +274,7 @@ def parse_opts(treestr, category_index=None): dash_depth = 0 opt_list = treestr.split('\n') kept_opts = [] - + # If a category index is given if category_index != None: # If given index is not a category, it's a selection - return True. @@ -284,7 +284,7 @@ def parse_opts(treestr, category_index=None): dash_depth = dashcount(opt_list[category_index]) + 1 # Delete everything before the category index opt_list = opt_list [category_index+1:] - + # Keep every option (referenced by index) at the appropriate depth cur_index = 0 for option in opt_list: @@ -298,20 +298,20 @@ def parse_opts(treestr, category_index=None): return kept_opts cur_index += 1 return kept_opts - + def index_to_selection(treestr, index, desc=False): """ Given a menu tree string and an index, returns the corresponding selection's name as a string. If 'desc' is set to True, will return the selection's description as a string instead. - + Args: treestr (str): Multi-line string representing menu options index (int): Index to convert to selection key or description - + Options: desc (bool): If true, returns description instead of key - + Returns: selection (str): Selection key or description if 'desc' is set """ @@ -332,16 +332,16 @@ def index_to_selection(treestr, index, desc=False): return selection[0] else: return selection[1] - + def go_up_one_category(treestr, index): """ Given a menu tree string and an index, returns the category that the given option belongs to. Used for the 'go back' option. - + Args: treestr (str): Multi-line string representing menu options index (int): Index to determine the parent category of - + Returns: parent_category (int): Index of parent category """ @@ -350,7 +350,7 @@ def go_up_one_category(treestr, index): dash_level = dashcount(opt_list[index]) # Delete everything after the current index opt_list = opt_list[:index+1] - + # If there's no dash, return 'None' to return to base menu if dash_level == 0: @@ -361,25 +361,25 @@ def go_up_one_category(treestr, index): if dashcount(selection) == dash_level - 1: return current_index current_index -= 1 - + def optlist_to_menuoptions(treestr, optlist, index, mark_category, go_back): """ Takes a list of options processed by parse_opts and turns it into a list/dictionary of menu options for use in menunode_treeselect. - + Args: treestr (str): Multi-line string representing menu options optlist (list): List of options to convert to EvMenu's option format index (int): Index of current category mark_category (bool): Whether or not to mark categories with [+] go_back (bool): Whether or not to add an option to go back in the menu - + Returns: menuoptions (list of dicts): List of menu options formatted for use in EvMenu, each passing a different "newindex" kwarg that changes the menu level or makes a selection """ - + menuoptions = [] cur_index = 0 for option in optlist: @@ -410,12 +410,12 @@ def optlist_to_menuoptions(treestr, optlist, index, mark_category, go_back): def menunode_treeselect(caller, raw_string, **kwargs): """ This is the repeating menu node that handles the tree selection. - """ - + """ + # If 'newindex' is in the kwargs, change the stored index. if "newindex" in kwargs: caller.ndb._menutree.index = kwargs["newindex"] - + # Retrieve menu info index = caller.ndb._menutree.index mark_category = caller.ndb._menutree.mark_category @@ -423,10 +423,10 @@ def menunode_treeselect(caller, raw_string, **kwargs): treestr = caller.ndb._menutree.treestr callback = caller.ndb._menutree.callback start_text = caller.ndb._menutree.start_text - + # List of options if index is 'None' or category, or 'True' if a selection optlist = parse_opts(treestr, category_index=index) - + # If given index returns optlist as 'True', it's a selection. Pass to callback and end the menu. if optlist == True: selection = index_to_selection(treestr, index) @@ -434,10 +434,10 @@ def menunode_treeselect(caller, raw_string, **kwargs): callback(caller, treestr, index, selection) except Exception: log_trace("Error in tree selection callback.") - + # Returning None, None ends the menu. return None, None - + # Otherwise, convert optlist to a list of menu options. else: options = optlist_to_menuoptions(treestr, optlist, index, mark_category, go_back) @@ -485,7 +485,7 @@ NAMECOLOR_MENU = """Set name color: Choose a color for your name! --Lavender: |535Set your name to Lavender|n --Fuchsia: |503Set your name to Fuchsia|n Remove name color: Remove your name color, if any""" - + class CmdNameColor(Command): """ Set or remove a special color on your name. Just an example for the @@ -503,7 +503,7 @@ class CmdNameColor(Command): def change_name_color(caller, treestr, index, selection): """ Changes a player's name color. - + Args: caller (obj): Character whose name to color. treestr (str): String for the color change menu - unused @@ -511,11 +511,11 @@ def change_name_color(caller, treestr, index, selection): selection (str): Selection made from the name color menu - used to determine the color the player chose. """ - + # Store the caller's uncolored name if not caller.db.uncolored_name: caller.db.uncolored_name = caller.key - + # Dictionary matching color selection names to color codes colordict = { "Red":"|511", "Pink":"|533", "Maroon":"|301", "Orange":"|531", "Brown":"|321", "Sienna":"|420", @@ -523,7 +523,7 @@ def change_name_color(caller, treestr, index, selection): "Green":"|141", "Lime":"|350", "Forest":"|032", "Blue":"|115", "Cyan":"|155", "Navy":"|113", "Purple":"|415", "Lavender":"|535", "Fuchsia":"|503"} - + # I know this probably isn't the best way to do this. It's just an example! if selection == "Remove name color": # Player chose to remove their name color caller.key = caller.db.uncolored_name From 90dc745d73f6b21a5c769801a2fe8fec3802101f Mon Sep 17 00:00:00 2001 From: Tehom Date: Fri, 8 Dec 2017 02:59:29 -0500 Subject: [PATCH 060/189] Make at_say more flexible by not ignoring parameters passed --- evennia/objects/objects.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index e0fd0dace4..e0f337d3ee 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -1685,10 +1685,8 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): msg_type = 'whisper' msg_self = '{self} whisper to {all_receivers}, "{speech}"' if msg_self is True else msg_self msg_receivers = '{object} whispers: "{speech}"' - msg_location = None else: msg_self = '{self} say, "{speech}"' if msg_self is True else msg_self - msg_receivers = None msg_location = msg_location or '{object} says, "{speech}"' custom_mapping = kwargs.get('mapping', {}) @@ -1733,9 +1731,14 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): "receiver": None, "speech": message} location_mapping.update(custom_mapping) + exclude = [] + if msg_self: + exclude.append(self) + if receivers: + exclude.extend(receivers) self.location.msg_contents(text=(msg_location, {"type": msg_type}), from_obj=self, - exclude=(self, ) if msg_self else None, + exclude=exclude, mapping=location_mapping) From 5ff1da8e09c6d729ce6fcf5cf8e3d3ff06c6e060 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 9 Dec 2017 01:13:02 +0100 Subject: [PATCH 061/189] Change dockerfile entrypoint to launch evennia server, more suitable for docker-compose setups. Add websocket proxy envvar --- Dockerfile | 12 ++++++------ bin/unix/evennia-docker-start.sh | 13 +++++++++++++ evennia/settings_default.py | 7 ++++++- evennia/web/utils/general_context.py | 7 ++++++- 4 files changed, 31 insertions(+), 8 deletions(-) create mode 100644 bin/unix/evennia-docker-start.sh diff --git a/Dockerfile b/Dockerfile index 27af6b2a3a..4ca00d254b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,11 +5,11 @@ # install `docker` (http://docker.com) # # Usage: -# cd to a folder where you want your game data to be (or where it already is). +# cd to a folder where you want your game data to be (or where it already is). # # docker run -it -p 4000:4000 -p 4001:4001 -p 4005:4005 -v $PWD:/usr/src/game evennia/evennia -# -# (If your OS does not support $PWD, replace it with the full path to your current +# +# (If your OS does not support $PWD, replace it with the full path to your current # folder). # # You will end up in a shell where the `evennia` command is available. From here you @@ -30,10 +30,10 @@ RUN apk update && apk add python py-pip python-dev py-setuptools gcc musl-dev jp ADD . /usr/src/evennia # install dependencies -RUN pip install -e /usr/src/evennia --trusted-host pypi.python.org +RUN pip install --upgrade pip && pip install /usr/src/evennia --trusted-host pypi.python.org # add the game source when rebuilding a new docker image from inside -# a game dir +# a game dir ONBUILD ADD . /usr/src/game # make the game source hierarchy persistent with a named volume. @@ -48,7 +48,7 @@ WORKDIR /usr/src/game ENV PS1 "evennia|docker \w $ " # startup a shell when we start the container -ENTRYPOINT ["bash"] +ENTRYPOINT bash -c "source /usr/src/evennia/bin/unix/evennia-docker-start.sh" # expose the telnet, webserver and websocket client ports EXPOSE 4000 4001 4005 diff --git a/bin/unix/evennia-docker-start.sh b/bin/unix/evennia-docker-start.sh new file mode 100644 index 0000000000..270f6ec627 --- /dev/null +++ b/bin/unix/evennia-docker-start.sh @@ -0,0 +1,13 @@ +#! /bin/bash + +# called by the Dockerfile to start the server in docker mode + +# remove leftover .pid files (such as from when dropping the container) +rm /usr/src/game/server/*.pid >& /dev/null || true + +# start evennia server; log to server.log but also output to stdout so it can +# be viewed with docker-compose logs +exec 3>&1; evennia start 2>&1 1>&3 | tee /usr/src/game/server/logs/server.log; exec 3>&- + +# start a shell to keep the container running +bash diff --git a/evennia/settings_default.py b/evennia/settings_default.py index b1e2a7a79e..fd213d333d 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -83,7 +83,12 @@ WEBCLIENT_ENABLED = True # default webclient will use this and only use the ajax version if the browser # is too old to support websockets. Requires WEBCLIENT_ENABLED. WEBSOCKET_CLIENT_ENABLED = True -# Server-side websocket port to open for the webclient. +# Server-side websocket port to open for the webclient. Note that this value will +# be dynamically encoded in the webclient html page to allow the webclient to call +# home. If the external encoded value needs to be different than this, due to +# working through a proxy or docker port-remapping, the environment variable +# WEBCLIENT_CLIENT_PROXY_PORT can be used to override this port only for the +# front-facing client's sake. WEBSOCKET_CLIENT_PORT = 4005 # Interface addresses to listen to. If 0.0.0.0, listen to all. Use :: for IPv6. WEBSOCKET_CLIENT_INTERFACE = '0.0.0.0' diff --git a/evennia/web/utils/general_context.py b/evennia/web/utils/general_context.py index 27edd79c86..44bc8b3cb3 100644 --- a/evennia/web/utils/general_context.py +++ b/evennia/web/utils/general_context.py @@ -6,6 +6,7 @@ # tuple. # +import os from django.conf import settings from evennia.utils.utils import get_evennia_version @@ -52,7 +53,11 @@ def set_webclient_settings(): global WEBCLIENT_ENABLED, WEBSOCKET_CLIENT_ENABLED, WEBSOCKET_PORT, WEBSOCKET_URL WEBCLIENT_ENABLED = settings.WEBCLIENT_ENABLED WEBSOCKET_CLIENT_ENABLED = settings.WEBSOCKET_CLIENT_ENABLED - WEBSOCKET_PORT = settings.WEBSOCKET_CLIENT_PORT + # if we are working through a proxy or uses docker port-remapping, the webclient port encoded + # in the webclient should be different than the one the server expects. Use the environment + # variable WEBSOCKET_CLIENT_PROXY_PORT if this is the case. + WEBSOCKET_PORT = int(os.environ.get("WEBSOCKET_CLIENT_PROXY_PORT", settings.WEBSOCKET_CLIENT_PORT)) + # this is determined dynamically by the client and is less of an issue WEBSOCKET_URL = settings.WEBSOCKET_CLIENT_URL set_webclient_settings() From 2dae4c4c6f724e91dc096d66cf42f67fbf8b1452 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 10 Dec 2017 10:26:20 +0100 Subject: [PATCH 062/189] fix unittests --- evennia/web/utils/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/web/utils/tests.py b/evennia/web/utils/tests.py index b2d42891ae..e2b28c3510 100644 --- a/evennia/web/utils/tests.py +++ b/evennia/web/utils/tests.py @@ -51,9 +51,9 @@ class TestGeneralContext(TestCase): mock_settings.WEBCLIENT_ENABLED = "webclient" mock_settings.WEBSOCKET_CLIENT_URL = "websocket_url" mock_settings.WEBSOCKET_CLIENT_ENABLED = "websocket_client" - mock_settings.WEBSOCKET_CLIENT_PORT = "websocket_port" + mock_settings.WEBSOCKET_CLIENT_PORT = 5000 general_context.set_webclient_settings() self.assertEqual(general_context.WEBCLIENT_ENABLED, "webclient") self.assertEqual(general_context.WEBSOCKET_URL, "websocket_url") self.assertEqual(general_context.WEBSOCKET_CLIENT_ENABLED, "websocket_client") - self.assertEqual(general_context.WEBSOCKET_PORT, "websocket_port") + self.assertEqual(general_context.WEBSOCKET_PORT, 5000) From abaf8d0a197b6a026c5d9d399f7a91cdcd163ea1 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Tue, 12 Dec 2017 18:56:36 +0100 Subject: [PATCH 063/189] Add a setting to change telnet default encoding --- evennia/server/portal/telnet.py | 5 +++-- evennia/server/session.py | 7 ++++++- evennia/settings_default.py | 6 ++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/evennia/server/portal/telnet.py b/evennia/server/portal/telnet.py index 4112d85e2a..86199ae4ac 100644 --- a/evennia/server/portal/telnet.py +++ b/evennia/server/portal/telnet.py @@ -24,7 +24,7 @@ _RE_LEND = re.compile(r"\n$|\r$|\r\n$|\r\x00$|", re.MULTILINE) _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) _IDLE_COMMAND = settings.IDLE_COMMAND + "\n" - +_TELNET_ENCODING = settings.TELNET_ENCODING class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session): """ @@ -49,7 +49,8 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session): # this number is counted down for every handshake that completes. # when it reaches 0 the portal/server syncs their data self.handshakes = 8 # suppress-go-ahead, naws, ttype, mccp, mssp, msdp, gmcp, mxp - self.init_session(self.protocol_name, client_address, self.factory.sessionhandler) + self.init_session(self.protocol_name, client_address, self.factory.sessionhandler, + override_flags={"ENCODING": _TELNET_ENCODING}) # suppress go-ahead self.sga = suppress_ga.SuppressGA(self) diff --git a/evennia/server/session.py b/evennia/server/session.py index 70be0708d7..dc816ee59b 100644 --- a/evennia/server/session.py +++ b/evennia/server/session.py @@ -41,7 +41,7 @@ class Session(object): 'conn_time', 'cmd_last', 'cmd_last_visible', 'cmd_total', 'protocol_flags', 'server_data', "cmdset_storage_string") - def init_session(self, protocol_key, address, sessionhandler): + def init_session(self, protocol_key, address, sessionhandler, override_flags=None): """ Initialize the Session. This should be called by the protocol when a new session is established. @@ -52,6 +52,7 @@ class Session(object): address (str): Client address. sessionhandler (SessionHandler): Reference to the main sessionhandler instance. + override_flags (optional, dict): a dictionary of protocol flags to override. """ # This is currently 'telnet', 'ssh', 'ssl' or 'web' @@ -87,6 +88,10 @@ class Session(object): "INPUTDEBUG": False, "RAW": False, "NOCOLOR": False} + + if override_flags: + self.protocol_flags.update(override_flags) + self.server_data = {} # map of input data to session methods diff --git a/evennia/settings_default.py b/evennia/settings_default.py index fd213d333d..bb8e07fe44 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -166,6 +166,12 @@ IDLE_TIMEOUT = -1 # command-name is given here; this is because the webclient needs a default # to send to avoid proxy timeouts. IDLE_COMMAND = "idle" +# The encoding (character set) specific to Telnet. This will not influence +# other encoding settings: namely, the webclient, the website, the +# database encoding will remain (utf-8 by default). This setting only +# affects the telnet encoding and will be overridden by user settings +# (through one of their client's supported protocol or their account options). +TELNET_ENCODING = "utf-8" # The set of encodings tried. An Account object may set an attribute "encoding" on # itself to match the client used. If not set, or wrong encoding is # given, this list is tried, in order, aborting on the first match. From 8f5a28455e073c323eb799134c121166eac54777 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Tue, 12 Dec 2017 19:46:28 +0100 Subject: [PATCH 064/189] Remove TELNET_ENCODING and set ENCODINGS[0] --- evennia/server/portal/telnet.py | 2 +- evennia/settings_default.py | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/evennia/server/portal/telnet.py b/evennia/server/portal/telnet.py index 86199ae4ac..9d7b31929d 100644 --- a/evennia/server/portal/telnet.py +++ b/evennia/server/portal/telnet.py @@ -24,7 +24,7 @@ _RE_LEND = re.compile(r"\n$|\r$|\r\n$|\r\x00$|", re.MULTILINE) _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) _IDLE_COMMAND = settings.IDLE_COMMAND + "\n" -_TELNET_ENCODING = settings.TELNET_ENCODING +_TELNET_ENCODING = settings.ENCODINGS[0] class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session): """ diff --git a/evennia/settings_default.py b/evennia/settings_default.py index bb8e07fe44..5469fd46db 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -166,17 +166,12 @@ IDLE_TIMEOUT = -1 # command-name is given here; this is because the webclient needs a default # to send to avoid proxy timeouts. IDLE_COMMAND = "idle" -# The encoding (character set) specific to Telnet. This will not influence -# other encoding settings: namely, the webclient, the website, the -# database encoding will remain (utf-8 by default). This setting only -# affects the telnet encoding and will be overridden by user settings -# (through one of their client's supported protocol or their account options). -TELNET_ENCODING = "utf-8" # The set of encodings tried. An Account object may set an attribute "encoding" on # itself to match the client used. If not set, or wrong encoding is # given, this list is tried, in order, aborting on the first match. # Add sets for languages/regions your accounts are likely to use. # (see http://en.wikipedia.org/wiki/Character_encoding) +# Telnet default encoding, unless specified by the client, will be ENCODINGS[0]. ENCODINGS = ["utf-8", "latin-1", "ISO-8859-1"] # Regular expression applied to all output to a given session in order # to strip away characters (usually various forms of decorations) for the benefit From 0acf0246c799346aa4cd3938719c3fd194f314cb Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Tue, 12 Dec 2017 20:51:16 +0100 Subject: [PATCH 065/189] Simplify telnet edefault encoding --- evennia/server/portal/telnet.py | 6 +++--- evennia/server/session.py | 8 +------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/evennia/server/portal/telnet.py b/evennia/server/portal/telnet.py index 9d7b31929d..08267f0ee9 100644 --- a/evennia/server/portal/telnet.py +++ b/evennia/server/portal/telnet.py @@ -24,7 +24,6 @@ _RE_LEND = re.compile(r"\n$|\r$|\r\n$|\r\x00$|", re.MULTILINE) _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) _IDLE_COMMAND = settings.IDLE_COMMAND + "\n" -_TELNET_ENCODING = settings.ENCODINGS[0] class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session): """ @@ -49,8 +48,9 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session): # this number is counted down for every handshake that completes. # when it reaches 0 the portal/server syncs their data self.handshakes = 8 # suppress-go-ahead, naws, ttype, mccp, mssp, msdp, gmcp, mxp - self.init_session(self.protocol_name, client_address, self.factory.sessionhandler, - override_flags={"ENCODING": _TELNET_ENCODING}) + self.init_session(self.protocol_name, client_address, self.factory.sessionhandler) + # change encoding to ENCODINGS[0] which reflects Telnet default encoding + self.protocol_flags["ENCODING"] = settings.ENCODINGS[0] # suppress go-ahead self.sga = suppress_ga.SuppressGA(self) diff --git a/evennia/server/session.py b/evennia/server/session.py index dc816ee59b..96b68662a5 100644 --- a/evennia/server/session.py +++ b/evennia/server/session.py @@ -7,7 +7,6 @@ from builtins import object import time - #------------------------------------------------------------ # Server Session #------------------------------------------------------------ @@ -41,7 +40,7 @@ class Session(object): 'conn_time', 'cmd_last', 'cmd_last_visible', 'cmd_total', 'protocol_flags', 'server_data', "cmdset_storage_string") - def init_session(self, protocol_key, address, sessionhandler, override_flags=None): + def init_session(self, protocol_key, address, sessionhandler): """ Initialize the Session. This should be called by the protocol when a new session is established. @@ -52,7 +51,6 @@ class Session(object): address (str): Client address. sessionhandler (SessionHandler): Reference to the main sessionhandler instance. - override_flags (optional, dict): a dictionary of protocol flags to override. """ # This is currently 'telnet', 'ssh', 'ssl' or 'web' @@ -88,10 +86,6 @@ class Session(object): "INPUTDEBUG": False, "RAW": False, "NOCOLOR": False} - - if override_flags: - self.protocol_flags.update(override_flags) - self.server_data = {} # map of input data to session methods From 9605d66646376a73dbe6ceed1a6231223d8f88cb Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Tue, 12 Dec 2017 20:59:55 +0100 Subject: [PATCH 066/189] Add a little check in case ENCODINGS is empty --- evennia/server/portal/telnet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/server/portal/telnet.py b/evennia/server/portal/telnet.py index 08267f0ee9..dd07512b70 100644 --- a/evennia/server/portal/telnet.py +++ b/evennia/server/portal/telnet.py @@ -50,7 +50,7 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session): 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.protocol_flags["ENCODING"] = settings.ENCODINGS[0] + self.protocol_flags["ENCODING"] = settings.ENCODINGS[0] if settings.ENCODINGS else 'utf-8' # suppress go-ahead self.sga = suppress_ga.SuppressGA(self) From 916246120a5da8dafaa4ba6dcfa21c9d378d3a06 Mon Sep 17 00:00:00 2001 From: Robert Bost Date: Tue, 12 Dec 2017 19:29:16 -0500 Subject: [PATCH 067/189] Update websocket URL so proxy port can be utilized. Resolves #1421. --- evennia/settings_default.py | 6 +++--- evennia/web/webclient/templates/webclient/base.html | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index b1e2a7a79e..9a0ec3ffa3 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -89,9 +89,9 @@ WEBSOCKET_CLIENT_PORT = 4005 WEBSOCKET_CLIENT_INTERFACE = '0.0.0.0' # Actual URL for webclient component to reach the websocket. You only need # to set this if you know you need it, like using some sort of proxy setup. -# If given it must be on the form "ws://hostname" (WEBSOCKET_CLIENT_PORT will -# be automatically appended). If left at None, the client will itself -# figure out this url based on the server's hostname. +# If given it must be on the form "ws[s]://hostname[:port]". If left at None, +# the client will itself figure out this url based on the server's hostname. +# e.g. ws://external.example.com or wss://external.example.com:443 WEBSOCKET_CLIENT_URL = None # This determine's whether Evennia's custom admin page is used, or if the # standard Django admin is used. diff --git a/evennia/web/webclient/templates/webclient/base.html b/evennia/web/webclient/templates/webclient/base.html index 373ff0f357..30a68c498f 100644 --- a/evennia/web/webclient/templates/webclient/base.html +++ b/evennia/web/webclient/templates/webclient/base.html @@ -44,7 +44,7 @@ JQuery available. {% endif %} {% if websocket_url %} - var wsurl = "{{websocket_url}}:{{websocket_port}}"; + var wsurl = "{{websocket_url}}"; {% else %} var wsurl = "ws://" + this.location.hostname + ":{{websocket_port}}"; {% endif %} From dd3e9ccbbe2bc679d286f8b7228ed3dbd8a9d36b Mon Sep 17 00:00:00 2001 From: Tehom Date: Sun, 17 Dec 2017 18:53:41 -0500 Subject: [PATCH 068/189] Allow other typeclasses to have their Attributes set via command. --- evennia/commands/default/building.py | 42 +++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index f4a001e6d2..1e544748ff 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -1455,6 +1455,11 @@ class CmdSetAttribute(ObjManipCommand): Switch: edit: Open the line editor (string values only) + script: If we're trying to set an attribute on a script + channel: If we're trying to set an attribute on a channel + account: If we're trying to set an attribute on an account + (room, exit, char/character may all be used as well for global + searches) Sets attributes on objects. The second form clears a previously set attribute while the last form @@ -1555,6 +1560,38 @@ class CmdSetAttribute(ObjManipCommand): # start the editor EvEditor(self.caller, load, save, key="%s/%s" % (obj, attr)) + def search_for_obj(self, objname): + """ + Searches for an object matching objname. The object may be of different typeclasses. + Args: + objname: Name of the object we're looking for + + Returns: + A typeclassed object, or None if nothing is found. + """ + from evennia.utils.utils import variable_from_module + _AT_SEARCH_RESULT = variable_from_module(*settings.SEARCH_AT_RESULT.rsplit('.', 1)) + caller = self.caller + if objname.startswith('*') or "account" in self.switches: + found_obj = caller.search_account(objname.lstrip('*')) + elif "script" in self.switches: + found_obj = _AT_SEARCH_RESULT(search.search_script(objname), caller) + elif "channel" in self.switches: + found_obj = _AT_SEARCH_RESULT(search.search_channel(objname), caller) + else: + global_search = True + if "char" in self.switches or "character" in self.switches: + typeclass = settings.BASE_CHARACTER_TYPECLASS + elif "room" in self.switches: + typeclass = settings.BASE_ROOM_TYPECLASS + elif "exit" in self.switches: + typeclass = settings.BASE_EXIT_TYPECLASS + else: + global_search = False + typeclass = None + found_obj = caller.search(objname, global_search=global_search, typeclass=typeclass) + return found_obj + def func(self): """Implement the set attribute - a limited form of @py.""" @@ -1568,10 +1605,7 @@ class CmdSetAttribute(ObjManipCommand): objname = self.lhs_objattr[0]['name'] attrs = self.lhs_objattr[0]['attrs'] - if objname.startswith('*'): - obj = caller.search_account(objname.lstrip('*')) - else: - obj = caller.search(objname) + obj = self.search_for_obj(objname) if not obj: return From 0adb346555b4b69bfb58f736d77a169ce3b3fd30 Mon Sep 17 00:00:00 2001 From: Tehom Date: Tue, 19 Dec 2017 00:42:23 -0500 Subject: [PATCH 069/189] Try to clarify help file. --- evennia/commands/default/building.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 1e544748ff..5076170957 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -1458,8 +1458,10 @@ class CmdSetAttribute(ObjManipCommand): 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, exit, char/character may all be used as well for global - searches) + room: Setting an attribute on a room (global search) + exit: Setting an attribute on an exit (global search) + char: Setting an attribute on a character (global search) + character: Alias for char, as above. Sets attributes on objects. The second form clears a previously set attribute while the last form From 1b9a083b19cd2c5c8dea88d1eba19a1617e5cf4b Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 1 Jan 2018 21:03:11 +0100 Subject: [PATCH 070/189] Add TIME_IGNORE_DOWNTIMES to allow for true 1:1 time ratios. Implements #1545 --- evennia/settings_default.py | 7 ++++++- evennia/utils/gametime.py | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index b1e2a7a79e..7948868ed9 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -490,8 +490,13 @@ TIME_FACTOR = 2.0 # The starting point of your game time (the epoch), in seconds. # In Python a value of 0 means Jan 1 1970 (use negatives for earlier # start date). This will affect the returns from the utils.gametime -# module. +# module. If None, the server's first start-time is used as the epoch. TIME_GAME_EPOCH = None +# Normally, game time will only increase when the server runs. If this is True, +# game time will not pause when the server reloads or goes offline. This setting +# together with a time factor of 1 should keep the game in sync with +# the real time (add a different epoch to shift time) +TIME_IGNORE_DOWNTIMES = False ###################################################################### # Inlinefunc diff --git a/evennia/utils/gametime.py b/evennia/utils/gametime.py index 793e348143..3736128819 100644 --- a/evennia/utils/gametime.py +++ b/evennia/utils/gametime.py @@ -19,6 +19,8 @@ from evennia.utils.create import create_script # to real time. TIMEFACTOR = settings.TIME_FACTOR +IGNORE_DOWNTIMES = settings.TIME_IGNORE_DOWNTIMES + # Only set if gametime_reset was called at some point. GAME_TIME_OFFSET = ServerConfig.objects.conf("gametime_offset", default=0) @@ -133,7 +135,10 @@ def gametime(absolute=False): """ epoch = game_epoch() if absolute else 0 - gtime = epoch + (runtime() - GAME_TIME_OFFSET) * TIMEFACTOR + if IGNORE_DOWNTIMES: + gtime = epoch + (time.time() - server_epoch()) * TIMEFACTOR + else: + gtime = epoch + (runtime() - GAME_TIME_OFFSET) * TIMEFACTOR return gtime From e4321783a59f510e040bd612550f9f4fe7f19a5d Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Sat, 6 Jan 2018 13:32:30 +0100 Subject: [PATCH 071/189] [IGPS] Fix mistakes in the say event --- evennia/contrib/ingame_python/typeclasses.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/ingame_python/typeclasses.py b/evennia/contrib/ingame_python/typeclasses.py index 33729bef66..3f52b982bf 100644 --- a/evennia/contrib/ingame_python/typeclasses.py +++ b/evennia/contrib/ingame_python/typeclasses.py @@ -430,7 +430,7 @@ class EventCharacter(DefaultCharacter): # Browse all the room's other characters for obj in location.contents: - if obj is self or not inherits_from(obj, "objects.objects.DefaultCharacter"): + if obj is self or not inherits_from(obj, "evennia.objects.objects.DefaultCharacter"): continue allow = obj.callbacks.call("can_say", self, obj, message, parameters=message) @@ -491,7 +491,7 @@ class EventCharacter(DefaultCharacter): parameters=message) # Call the other characters' "say" event - presents = [obj for obj in location.contents if obj is not self and inherits_from(obj, "objects.objects.DefaultCharacter")] + presents = [obj for obj in location.contents if obj is not self and inherits_from(obj, "evennia.objects.objects.DefaultCharacter")] for present in presents: present.callbacks.call("say", self, present, message, parameters=message) From b8f9154df9536bf3eaa573a4609c64a8a3827577 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 6 Jan 2018 19:19:20 +0100 Subject: [PATCH 072/189] Make DELAY_CMD_LOGINSTART configurable in settings --- evennia/server/sessionhandler.py | 10 ++++------ evennia/settings_default.py | 8 ++++++++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/evennia/server/sessionhandler.py b/evennia/server/sessionhandler.py index 49d9b46498..423bc87f1c 100644 --- a/evennia/server/sessionhandler.py +++ b/evennia/server/sessionhandler.py @@ -65,6 +65,7 @@ from django.utils.translation import ugettext as _ _SERVERNAME = settings.SERVERNAME _MULTISESSION_MODE = settings.MULTISESSION_MODE _IDLE_TIMEOUT = settings.IDLE_TIMEOUT +_DELAY_CMD_LOGINSTART = settings.DELAY_CMD_LOGINSTART _MAX_SERVER_COMMANDS_PER_SECOND = 100.0 _MAX_SESSION_COMMANDS_PER_SECOND = 5.0 _MODEL_MAP = None @@ -319,12 +320,9 @@ class ServerSessionHandler(SessionHandler): sess.logged_in = False sess.uid = None - # show the first login command - - # this delay is necessary notably for Mudlet, which will fail on the connection screen - # unless the MXP protocol has been negotiated. Unfortunately this may be too short for some - # networks, the symptom is that < and > are not parsed by mudlet on first connection. - delay(0.3, self._run_cmd_login, sess) + # show the first login command, may delay slightly to allow + # the handshakes to finish. + delay(_DELAY_CMD_LOGINSTART, self._run_cmd_login, sess) def portal_session_sync(self, portalsessiondata): """ diff --git a/evennia/settings_default.py b/evennia/settings_default.py index b1e2a7a79e..7ae45c4be2 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -307,6 +307,14 @@ CMD_IGNORE_PREFIXES = "@&/+" # This module should contain one or more variables # with strings defining the look of the screen. 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 # 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). From de79a033ba1faa64ce22e8371323233470b9d19f Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 6 Jan 2018 19:22:34 +0100 Subject: [PATCH 073/189] Edit version info --- evennia/VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/VERSION.txt b/evennia/VERSION.txt index faef31a435..f8d71478f5 100644 --- a/evennia/VERSION.txt +++ b/evennia/VERSION.txt @@ -1 +1 @@ -0.7.0 +0.8.0-dev From 217bc2782614af0483f8f7390bdc26d88a5d73df Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 7 Jan 2018 16:20:56 +0100 Subject: [PATCH 074/189] Make alias command handle categories, remove 'alias' alias from nick cmd --- evennia/commands/default/building.py | 36 +++++++++++++++++++++------- evennia/commands/default/general.py | 2 +- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 10bfd0f099..445ec082a9 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -106,9 +106,15 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS): Usage: @alias [= [alias[,alias,alias,...]]] @alias = + @alias/category = [alias[,alias,...]: + + Switches: + category - requires ending input with :category, to store the + given aliases with the given category. Assigns aliases to an object so it can be referenced by more - than one name. Assign empty to remove all aliases from object. + than one name. Assign empty to remove all aliases from object. If + assigning a category, all aliases given will be using this category. Observe that this is not the same thing as personal aliases created with the 'nick' command! Aliases set with @alias are @@ -138,9 +144,12 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS): return if self.rhs is None: # no =, so we just list aliases on object. - aliases = obj.aliases.all() + aliases = obj.aliases.all(return_key_and_category=True) if aliases: - caller.msg("Aliases for '%s': %s" % (obj.get_display_name(caller), ", ".join(aliases))) + caller.msg("Aliases for %s: %s" % ( + obj.get_display_name(caller), + ", ".join("'%s'%s" % (alias, "" if category is None else "[category:'%s']" % category) + for (alias, category) in aliases))) else: caller.msg("No aliases exist for '%s'." % obj.get_display_name(caller)) return @@ -159,17 +168,27 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS): caller.msg("No aliases to clear.") return + category = None + if "category" in self.switches: + if ":" in self.rhs: + rhs, category = self.rhs.rsplit(':', 1) + category = category.strip() + else: + caller.msg("If specifying the /category switch, the category must be given " + "as :category at the end.") + else: + rhs = self.rhs + # merge the old and new aliases (if any) - old_aliases = obj.aliases.all() - new_aliases = [alias.strip().lower() for alias in self.rhs.split(',') - if alias.strip()] + old_aliases = obj.aliases.get(category=category, return_list=True) + new_aliases = [alias.strip().lower() for alias in rhs.split(',') if alias.strip()] # make the aliases only appear once old_aliases.extend(new_aliases) aliases = list(set(old_aliases)) # save back to object. - obj.aliases.add(aliases) + obj.aliases.add(aliases, category=category) # we need to trigger this here, since this will force # (default) Exits to rebuild their Exit commands with the new @@ -177,7 +196,8 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS): obj.at_cmdset_get(force_init=True) # report all aliases on the object - caller.msg("Alias(es) for '%s' set to %s." % (obj.get_display_name(caller), str(obj.aliases))) + caller.msg("Alias(es) for '%s' set to '%s'%s." % (obj.get_display_name(caller), + str(obj.aliases), " (category: '%s')" % category if category else "")) class CmdCopy(ObjManipCommand): diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index 2f880f3f7f..612a798167 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -113,7 +113,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS): """ key = "nick" - aliases = ["nickname", "nicks", "alias"] + aliases = ["nickname", "nicks"] locks = "cmd:all()" def func(self): From e8cffa62a6a609e7bf9ecd49db9b01cdca31ca3e Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 7 Jan 2018 16:21:59 +0100 Subject: [PATCH 075/189] Clarify taghandler.get docstring --- evennia/typeclasses/tags.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/evennia/typeclasses/tags.py b/evennia/typeclasses/tags.py index e7c3bfdbb1..488dce0f85 100644 --- a/evennia/typeclasses/tags.py +++ b/evennia/typeclasses/tags.py @@ -269,14 +269,15 @@ class TagHandler(object): def get(self, key=None, default=None, category=None, return_tagobj=False, return_list=False): """ - Get the tag for the given key or list of tags. + Get the tag for the given key, category or combination of the two. Args: - key (str or list): The tag or tags to retrieve. + key (str or list, optional): The tag or tags to retrieve. default (any, optional): The value to return in case of no match. category (str, optional): The Tag category to limit the request to. Note that `None` is the valid, default - category. + category. If no `key` is given, all tags of this category will be + returned. return_tagobj (bool, optional): Return the Tag object itself instead of a string representation of the Tag. return_list (bool, optional): Always return a list, regardless From 6182ce9c1019e5b86775e0e504cc0c8d7df8c325 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 7 Jan 2018 19:31:01 +0100 Subject: [PATCH 076/189] Add inflect dependency. Add get_plural_name and mechanisms discussed in #1385. --- evennia/objects/objects.py | 45 +++++++++++++++++++++++++++++++++----- requirements.txt | 1 + win_requirements.txt | 1 + 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index af7f520817..7c230a8c47 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -6,8 +6,10 @@ entities. """ import time +import inflect from builtins import object from future.utils import with_metaclass +from collections import Counter from django.conf import settings @@ -22,9 +24,10 @@ from evennia.commands import cmdhandler from evennia.utils import search from evennia.utils import logger from evennia.utils.utils import (variable_from_module, lazy_property, - make_iter, to_unicode, is_iter) + make_iter, to_unicode, is_iter, list_to_string) from django.utils.translation import ugettext as _ +_INFLECT = inflect.engine() _MULTISESSION_MODE = settings.MULTISESSION_MODE _ScriptDB = None @@ -281,9 +284,34 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): return "{}(#{})".format(self.name, self.id) return self.name + def get_plural_name(self, looker, **kwargs): + """ + Return the plural form of this object's key. This is used for grouping multiple same-named + versions of this object. + + Args: + looker (Object): Onlooker. Not used by default. + Kwargs: + key (str): Optional key to pluralize, use this instead of the object's key. + count (int): How many entities of this type are being counted (not used by default). + Returns: + plural (str): The determined plural form of the key. + + """ + key = kwargs.get("key", self.key) + plural = _INFLECT.plural(key, 2) + 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(_INFLECT.an(key), category="plural_key") + return plural + def search(self, searchdata, global_search=False, - use_nicks=True, # should this default to off? + use_nicks=True, typeclass=None, location=None, attribute_name=None, @@ -1441,16 +1469,23 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): elif con.has_account: users.append("|c%s|n" % key) else: - things.append(key) + # things can be pluralized + things.append((key, con.get_plural_name(looker))) # get description, build string string = "|c%s|n\n" % self.get_display_name(looker) desc = self.db.desc if desc: string += "%s" % desc if exits: - string += "\n|wExits:|n " + ", ".join(exits) + string += "\n|wExits:|n " + list_to_string(exits) if users or things: - string += "\n|wYou see:|n " + ", ".join(users + things) + # handle pluralization + things = [("%s %s" % (_INFLECT.number_to_words(count, one=_INFLECT.an(key), threshold=12), + plural if count > 1 else "")).strip() + for ikey, ((key, plural), count) in enumerate(Counter(things).iteritems())] + + string += "\n|wYou see:|n " + list_to_string(users + things) + return string def at_look(self, target, **kwargs): diff --git a/requirements.txt b/requirements.txt index be3cf558e5..7f4b94726f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ pillow == 2.9.0 pytz future >= 0.15.2 django-sekizai +inflect diff --git a/win_requirements.txt b/win_requirements.txt index 7012643657..8a130b3268 100644 --- a/win_requirements.txt +++ b/win_requirements.txt @@ -10,3 +10,4 @@ pillow == 2.9.0 pytz future >= 0.15.2 django-sekizai +inflect From 785eb528e80b74a5e650af008d4d59b376d67113 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 7 Jan 2018 20:34:03 +0100 Subject: [PATCH 077/189] Rename to get_numbered_name. Handle 'two boxes' through aliasing. Fix unittests --- evennia/commands/default/tests.py | 3 ++- evennia/objects/objects.py | 42 +++++++++++++++++++------------ 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index f437779662..013baec4cc 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -239,7 +239,8 @@ class TestBuilding(CommandTest): self.call(building.CmdExamine(), "Obj", "Name/key: Obj") def test_set_obj_alias(self): - self.call(building.CmdSetObjAlias(), "Obj = TestObj1b", "Alias(es) for 'Obj(#4)' set to testobj1b.") + self.call(building.CmdSetObjAlias(), "Obj =", "Cleared aliases from Obj(#4)") + self.call(building.CmdSetObjAlias(), "Obj = TestObj1b", "Alias(es) for 'Obj(#4)' set to 'testobj1b'.") def test_copy(self): self.call(building.CmdCopy(), "Obj = TestObj2;TestObj2b, TestObj3;TestObj3b", "Copied Obj to 'TestObj3' (aliases: ['TestObj3b']") diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 7c230a8c47..de4e887b32 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -9,7 +9,7 @@ import time import inflect from builtins import object from future.utils import with_metaclass -from collections import Counter +from collections import defaultdict from django.conf import settings @@ -284,30 +284,35 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): return "{}(#{})".format(self.name, self.id) return self.name - def get_plural_name(self, looker, **kwargs): + def get_numbered_name(self, count, looker, **kwargs): """ - Return the plural form of this object's key. This is used for grouping multiple same-named - versions of this object. + 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. - count (int): How many entities of this type are being counted (not used by default). Returns: - plural (str): The determined plural form of the key. - + 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(_INFLECT.an(key), category="plural_key") - return plural + self.aliases.add(singular, category="plural_key") + return singular, plural def search(self, searchdata, global_search=False, @@ -1461,7 +1466,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): # get and identify all objects visible = (con for con in self.contents if con != looker and con.access(looker, "view")) - exits, users, things = [], [], [] + exits, users, things = [], [], defaultdict(list) for con in visible: key = con.get_display_name(looker) if con.destination: @@ -1470,7 +1475,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): users.append("|c%s|n" % key) else: # things can be pluralized - things.append((key, con.get_plural_name(looker))) + things[key].append(con) # get description, build string string = "|c%s|n\n" % self.get_display_name(looker) desc = self.db.desc @@ -1479,12 +1484,17 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): if exits: string += "\n|wExits:|n " + list_to_string(exits) if users or things: - # handle pluralization - things = [("%s %s" % (_INFLECT.number_to_words(count, one=_INFLECT.an(key), threshold=12), - plural if count > 1 else "")).strip() - for ikey, ((key, plural), count) in enumerate(Counter(things).iteritems())] + # 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 + things) + string += "\n|wYou see:|n " + list_to_string(users + thing_strings) return string From 9add566a89d7851e165246785e4fedf25021f7de Mon Sep 17 00:00:00 2001 From: Tehom Date: Mon, 8 Jan 2018 05:31:20 -0500 Subject: [PATCH 078/189] Refactor return_appearance to extract desc update into its own method. --- evennia/contrib/extended_room.py | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/evennia/contrib/extended_room.py b/evennia/contrib/extended_room.py index 0530bd796c..755c2ac22e 100644 --- a/evennia/contrib/extended_room.py +++ b/evennia/contrib/extended_room.py @@ -213,37 +213,39 @@ class ExtendedRoom(DefaultRoom): return detail return None - def return_appearance(self, looker): + def return_appearance(self, looker, **kwargs): """ This is called when e.g. the look command wants to retrieve the description of this object. Args: looker (Object): The object looking at us. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). Returns: description (str): Our description. """ - update = False + # ensures that our description is current based on time/season + self.update_current_description() + # run the normal return_appearance method, now that desc is updated. + return super(ExtendedRoom, self).return_appearance(looker, **kwargs) + def update_current_description(self): + """ + This will update the description of the room if the time or season + has changed since last checked. + """ + update = False # get current time and season curr_season, curr_timeslot = self.get_time_and_season() - # compare with previously stored slots last_season = self.ndb.last_season last_timeslot = self.ndb.last_timeslot - if curr_season != last_season: # season changed. Load new desc, or a fallback. - if curr_season == 'spring': - new_raw_desc = self.db.spring_desc - elif curr_season == 'summer': - new_raw_desc = self.db.summer_desc - elif curr_season == 'autumn': - new_raw_desc = self.db.autumn_desc - else: - new_raw_desc = self.db.winter_desc + new_raw_desc = self.attributes.get("%s_desc" % curr_season) if new_raw_desc: raw_desc = new_raw_desc else: @@ -252,19 +254,15 @@ class ExtendedRoom(DefaultRoom): self.db.raw_desc = raw_desc self.ndb.last_season = curr_season update = True - if curr_timeslot != last_timeslot: # timeslot changed. Set update flag. self.ndb.last_timeslot = curr_timeslot update = True - if update: # if anything changed we have to re-parse # the raw_desc for time markers # and re-save the description again. self.db.desc = self.replace_timeslots(self.db.raw_desc, curr_timeslot) - # run the normal return_appearance method, now that desc is updated. - return super(ExtendedRoom, self).return_appearance(looker) # Custom Look command supporting Room details. Add this to From 9813a9d346254b5d235699748cbae2fb6effe8f8 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 10 Jan 2018 22:47:53 +0100 Subject: [PATCH 079/189] Allow AMP to handle multiple connections gracefully (more stable). --- evennia/server/amp.py | 53 ++++++++++++++++++++++++++------ evennia/server/portal/portal.py | 3 -- evennia/server/sessionhandler.py | 10 +++++- 3 files changed, 53 insertions(+), 13 deletions(-) diff --git a/evennia/server/amp.py b/evennia/server/amp.py index 9694abd034..a62adb51d4 100644 --- a/evennia/server/amp.py +++ b/evennia/server/amp.py @@ -29,7 +29,7 @@ except ImportError: import pickle from twisted.protocols import amp from twisted.internet import protocol -from twisted.internet.defer import Deferred +from twisted.internet.defer import Deferred, DeferredList from evennia.utils import logger from evennia.utils.utils import to_str, variable_from_module import zlib # Used in Compressed class @@ -45,11 +45,13 @@ 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 +SSHUTD = chr(7) # server shutdown (shutdown portal too) 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 reloading (have portal start a new server) + 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 @@ -97,6 +99,7 @@ class AmpServerFactory(protocol.ServerFactory): """ self.server = server self.protocol = AMPProtocol + self.connections = [] def buildProtocol(self, addr): """ @@ -114,6 +117,9 @@ class AmpServerFactory(protocol.ServerFactory): return self.server.amp_protocol +_AMP_TRANSPORTS = [] + + class AmpClientFactory(protocol.ReconnectingClientFactory): """ This factory creates an instance of the Portal, an AMPProtocol @@ -327,6 +333,9 @@ def loads(data): return pickle.loads(to_str(data)) +def cmdline_input(data): + print("cmdline_input received:\n %s" % data) + # ------------------------------------------------------------- # Core AMP protocol for communication Server <-> Portal # ------------------------------------------------------------- @@ -356,6 +365,22 @@ class AMPProtocol(amp.AMP): self.send_mode = True self.send_task = None + def dataReceived(self, data): + if data[0] != b'\0': + cmdline_input(data) + else: + super(AMPProtocol, self).dataReceived(data) + + def makeConnection(self, transport): + """ + Copied from parent AMP protocol + """ + global _AMP_TRANSPORTS + # this makes for a factor x10 faster sends across the wire + transport.setTcpNoDelay(True) + super(AMPProtocol, self).makeConnection(transport) + _AMP_TRANSPORTS.append(transport) + def connectionMade(self): """ This is called when an AMP connection is (re-)established @@ -363,9 +388,6 @@ class AMPProtocol(amp.AMP): 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. @@ -387,7 +409,8 @@ class AMPProtocol(amp.AMP): portal will continuously try to reconnect, showing the problem that way. """ - pass + global _AMP_TRANSPORTS + _AMP_TRANSPORTS = [transport for transport in _AMP_TRANSPORTS if transport.connected == 1] # Error handling @@ -421,9 +444,18 @@ class AMPProtocol(amp.AMP): (sessid, kwargs). """ - return self.callRemote(command, - packed_data=dumps((sessid, kwargs)) - ).addErrback(self.errback, command.key) + if hasattr(self.factory, "portal") or len(_AMP_TRANSPORTS) == 1: + return self.callRemote(command, + packed_data=dumps((sessid, kwargs)) + ).addErrback(self.errback, command.key) + + else: + deferreds = [] + for transport in _AMP_TRANSPORTS: + self.transport = transport + deferreds.append(self.callRemote(command, + packed_data=dumps((sessid, kwargs)))) + return DeferredList(deferreds, fireOnOneErrback=1).addErrback(self.errback, command.key) # Message definition + helper methods to call/create each message type @@ -584,6 +616,9 @@ class AMPProtocol(amp.AMP): # the server orders the portal to shut down self.factory.portal.shutdown(restart=False) + elif operation == SRELOAD: # server reload + self.factory.portal.server_reload(**kwargs) + elif operation == SSYNC: # server_session_sync # server wants to save session data to the portal, # maybe because it's about to shut down. diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index 79002c2a0e..400fd91b7b 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -10,14 +10,11 @@ by game/evennia.py). from __future__ import print_function from builtins import object -import time import sys import os from twisted.application import internet, service from twisted.internet import protocol, reactor -from twisted.internet.task import LoopingCall -from twisted.web import server import django django.setup() from django.conf import settings diff --git a/evennia/server/sessionhandler.py b/evennia/server/sessionhandler.py index 423bc87f1c..9862f03421 100644 --- a/evennia/server/sessionhandler.py +++ b/evennia/server/sessionhandler.py @@ -58,6 +58,8 @@ SSYNC = chr(8) # server session sync SCONN = chr(11) # server portal connection (for bots) PCONNSYNC = chr(12) # portal post-syncing session PDISCONNALL = chr(13) # portal session discnnect all +SRELOAD = chr(14) # server reloading (have portal start a new server) + # i18n from django.utils.translation import ugettext as _ @@ -432,9 +434,15 @@ class ServerSessionHandler(SessionHandler): self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION, operation=SCONN, protocol_path=protocol_path, config=configdict) + def portal_restart_server(self): + """ + Called by server when reloading. We tell the portal to start a new server instance. + + """ + def portal_shutdown(self): """ - Called by server when shutting down the portal. + Called by server when shutting down the portal (usually because server is going down too). """ self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION, From 3971a08412fccba99519de2a14a9957d5b38d36e Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 11 Jan 2018 21:37:40 +0100 Subject: [PATCH 080/189] Make AMP protocol multi-sending and able to handle HTTP input --- evennia/server/amp.py | 59 ++++++++++++++++++++++++------------------- 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/evennia/server/amp.py b/evennia/server/amp.py index a62adb51d4..cb2fec8c65 100644 --- a/evennia/server/amp.py +++ b/evennia/server/amp.py @@ -62,6 +62,17 @@ _SENDBATCH = defaultdict(list) _MSGBUFFER = defaultdict(list) +_HTTP_WARNING = """ +HTTP/1.1 200 OK +Content-Type: text/html + + +This is Evennia's interal AMP port. It handles communication +between Evennia's different processes.

This port should NOT be +publicly visible.

+""".strip() + + def get_restart_mode(restart_file): """ Parse the server/portal restart status @@ -99,7 +110,7 @@ class AmpServerFactory(protocol.ServerFactory): """ self.server = server self.protocol = AMPProtocol - self.connections = [] + self.broadcasts = [] def buildProtocol(self, addr): """ @@ -142,6 +153,9 @@ class AmpClientFactory(protocol.ReconnectingClientFactory): """ self.portal = portal self.protocol = AMPProtocol + # 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): """ @@ -336,6 +350,7 @@ def loads(data): def cmdline_input(data): print("cmdline_input received:\n %s" % data) + # ------------------------------------------------------------- # Core AMP protocol for communication Server <-> Portal # ------------------------------------------------------------- @@ -350,6 +365,11 @@ class AMPProtocol(amp.AMP): amp.Command subclasses that specify the datatypes of the input/output of these methods. + This version of the protocol is a broadcast-version: it can + accept multiple connections and will broadcast to all of them. + IT will also correctly intercept non-AMP messages to avoid them + interrupting the connection. + """ # helper methods @@ -366,21 +386,15 @@ class AMPProtocol(amp.AMP): self.send_task = None def dataReceived(self, data): + """ + Handle non-AMP messages, such as HTTP communication. + """ if data[0] != b'\0': - cmdline_input(data) + self.transport.write(_HTTP_WARNING) + self.transport.loseConnection() else: super(AMPProtocol, self).dataReceived(data) - def makeConnection(self, transport): - """ - Copied from parent AMP protocol - """ - global _AMP_TRANSPORTS - # this makes for a factor x10 faster sends across the wire - transport.setTcpNoDelay(True) - super(AMPProtocol, self).makeConnection(transport) - _AMP_TRANSPORTS.append(transport) - def connectionMade(self): """ This is called when an AMP connection is (re-)established @@ -388,6 +402,7 @@ class AMPProtocol(amp.AMP): need to make sure to only trigger resync from the portal side. """ + self.factory.broadcasts.append(self) 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. @@ -409,8 +424,7 @@ class AMPProtocol(amp.AMP): portal will continuously try to reconnect, showing the problem that way. """ - global _AMP_TRANSPORTS - _AMP_TRANSPORTS = [transport for transport in _AMP_TRANSPORTS if transport.connected == 1] + self.factory.broadcasts.remove(self) # Error handling @@ -444,18 +458,11 @@ class AMPProtocol(amp.AMP): (sessid, kwargs). """ - if hasattr(self.factory, "portal") or len(_AMP_TRANSPORTS) == 1: - return self.callRemote(command, - packed_data=dumps((sessid, kwargs)) - ).addErrback(self.errback, command.key) - - else: - deferreds = [] - for transport in _AMP_TRANSPORTS: - self.transport = transport - deferreds.append(self.callRemote(command, - packed_data=dumps((sessid, kwargs)))) - return DeferredList(deferreds, fireOnOneErrback=1).addErrback(self.errback, command.key) + deferreds = [] + for prot in self.factory.broadcasts: + deferreds.append(prot.callRemote(command, + packed_data=dumps((sessid, kwargs)))) + return DeferredList(deferreds, fireOnOneErrback=1).addErrback(self.errback, command.key) # Message definition + helper methods to call/create each message type From 82fd9ff698547ca7076606dbea2a1b0bea7bb678 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 11 Jan 2018 22:09:01 +0100 Subject: [PATCH 081/189] Swap so Server is the AMP-client and Portal the AMP-server --- evennia/server/amp.py | 49 +++++++++++++-------------------- evennia/server/portal/portal.py | 8 +++--- evennia/server/server.py | 6 ++-- 3 files changed, 26 insertions(+), 37 deletions(-) diff --git a/evennia/server/amp.py b/evennia/server/amp.py index cb2fec8c65..2774958a5a 100644 --- a/evennia/server/amp.py +++ b/evennia/server/amp.py @@ -93,22 +93,22 @@ def get_restart_mode(restart_file): class AmpServerFactory(protocol.ServerFactory): """ - This factory creates the Server as a new AMPProtocol instance for accepting - connections from the Portal. + This factory creates AMP Server instance. This + is meant to sit on the Evennia Portal service. """ noisy = False - def __init__(self, server): + def __init__(self, portal): """ Initialize the factory. Args: - server (Server): The Evennia server service instance. + portal (Portal): The Evennia Portal service instance. protocol (Protocol): The protocol the factory creates instances of. """ - self.server = server + self.portal = portal self.protocol = AMPProtocol self.broadcasts = [] @@ -123,9 +123,9 @@ class AmpServerFactory(protocol.ServerFactory): protocol (Protocol): The created protocol. """ - self.server.amp_protocol = AMPProtocol() - self.server.amp_protocol.factory = self - return self.server.amp_protocol + self.portal.amp_protocol = AMPProtocol() + self.portal.amp_protocol.factory = self + return self.portal.amp_protocol _AMP_TRANSPORTS = [] @@ -133,8 +133,8 @@ _AMP_TRANSPORTS = [] class AmpClientFactory(protocol.ReconnectingClientFactory): """ - This factory creates an instance of the Portal, an AMPProtocol - instances to use to connect + This factory creates an instance of an AMP client. This + is intended to be the Evennia 'Server' service. """ # Initial reconnect delay in seconds. @@ -143,16 +143,17 @@ class AmpClientFactory(protocol.ReconnectingClientFactory): maxDelay = 1 noisy = False - def __init__(self, portal): + def __init__(self, server): """ Initializes the client factory. Args: - portal (Portal): Portal instance. + server (server): server instance. """ - self.portal = portal + self.server = server self.protocol = AMPProtocol + self.maxDelay = 10 # not really used unless connecting to multiple servers, but # avoids having to check for its existence on the protocol self.broadcasts = [] @@ -177,9 +178,9 @@ class AmpClientFactory(protocol.ReconnectingClientFactory): """ self.resetDelay() - self.portal.amp_protocol = AMPProtocol() - self.portal.amp_protocol.factory = self - return self.portal.amp_protocol + self.server.amp_protocol = AMPProtocol() + self.server.amp_protocol.factory = self + return self.server.amp_protocol def clientConnectionLost(self, connector, reason): """ @@ -191,13 +192,7 @@ class AmpClientFactory(protocol.ReconnectingClientFactory): 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.") + logger.log_info("Server lost connection to the Portal. Reconnecting ...") protocol.ReconnectingClientFactory.clientConnectionLost(self, connector, reason) def clientConnectionFailed(self, connector, reason): @@ -210,11 +205,7 @@ class AmpClientFactory(protocol.ReconnectingClientFactory): 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(" ...") + logger.log_info("Attempting to reconnect to Portal ...") protocol.ReconnectingClientFactory.clientConnectionFailed(self, connector, reason) @@ -411,8 +402,6 @@ class AMPProtocol(amp.AMP): 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): """ diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index 400fd91b7b..eda7aeada4 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -191,10 +191,10 @@ if AMP_ENABLED: print(' amp (to Server): %s (internal)' % AMP_PORT) - factory = amp.AmpClientFactory(PORTAL) - amp_client = internet.TCPClient(AMP_HOST, AMP_PORT, factory) - amp_client.setName('evennia_amp') - PORTAL.services.addService(amp_client) + factory = amp.AmpServerFactory(PORTAL) + amp_service = internet.TCPServer(AMP_PORT, factory, interface=AMP_INTERFACE) + amp_service.setName("PortalAMPService") + PORTAL.services.addService(amp_service) # We group all the various services under the same twisted app. diff --git a/evennia/server/server.py b/evennia/server/server.py index fb9f753b5c..643b3161f7 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -550,9 +550,9 @@ if AMP_ENABLED: from evennia.server import amp - factory = amp.AmpServerFactory(EVENNIA) - amp_service = internet.TCPServer(AMP_PORT, factory, interface=AMP_INTERFACE) - amp_service.setName("EvenniaPortal") + factory = amp.AmpClientFactory(EVENNIA) + amp_service = internet.TCPClient(AMP_HOST, AMP_PORT, factory) + amp_service.setName('ServerAMPClient') EVENNIA.services.addService(amp_service) if WEBSERVER_ENABLED: From b0d545a086743518a56f3b991fde10f2b2d903d0 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 11 Jan 2018 23:43:58 +0100 Subject: [PATCH 082/189] Refactor amp into three modules, separating clients/server better --- evennia/server/amp.py | 701 ---------------------------- evennia/server/amp_client.py | 185 ++++++++ evennia/server/portal/amp.py | 353 ++++++++++++++ evennia/server/portal/amp_server.py | 168 +++++++ evennia/server/portal/portal.py | 6 +- evennia/server/server.py | 4 +- 6 files changed, 711 insertions(+), 706 deletions(-) delete mode 100644 evennia/server/amp.py create mode 100644 evennia/server/amp_client.py create mode 100644 evennia/server/portal/amp.py create mode 100644 evennia/server/portal/amp_server.py diff --git a/evennia/server/amp.py b/evennia/server/amp.py deleted file mode 100644 index 2774958a5a..0000000000 --- a/evennia/server/amp.py +++ /dev/null @@ -1,701 +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, DeferredList -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 (shutdown portal too) -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 reloading (have portal start a new server) - -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) - - -_HTTP_WARNING = """ -HTTP/1.1 200 OK -Content-Type: text/html - - -This is Evennia's interal AMP port. It handles communication -between Evennia's different processes.

This port should NOT be -publicly visible.

-""".strip() - - -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 AMP Server instance. This - is meant to sit on the Evennia Portal service. - """ - noisy = False - - def __init__(self, portal): - """ - Initialize the factory. - - Args: - portal (Portal): The Evennia Portal service instance. - protocol (Protocol): The protocol the factory creates - instances of. - - """ - self.portal = portal - self.protocol = AMPProtocol - self.broadcasts = [] - - 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 = AMPProtocol() - self.portal.amp_protocol.factory = self - return self.portal.amp_protocol - - -_AMP_TRANSPORTS = [] - - -class AmpClientFactory(protocol.ReconnectingClientFactory): - """ - This factory creates an instance of an AMP client. This - is intended to be the Evennia 'Server' service. - - """ - # 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 = AMPProtocol - 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 = AMPProtocol() - 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 lost connection to the Portal. Reconnecting ...") - 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_info("Attempting to reconnect to Portal ...") - 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)) - - -def cmdline_input(data): - print("cmdline_input received:\n %s" % 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. - - This version of the protocol is a broadcast-version: it can - accept multiple connections and will broadcast to all of them. - IT will also correctly intercept non-AMP messages to avoid them - interrupting the connection. - - """ - - # 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(AMPProtocol, self).dataReceived(data) - - 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. - - """ - self.factory.broadcasts.append(self) - 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() - - 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. - """ - self.factory.broadcasts.remove(self) - - # 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). - - """ - deferreds = [] - for prot in self.factory.broadcasts: - deferreds.append(prot.callRemote(command, - packed_data=dumps((sessid, kwargs)))) - return DeferredList(deferreds, fireOnOneErrback=1).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 == SRELOAD: # server reload - self.factory.portal.server_reload(**kwargs) - - elif operation == SSYNC: # server_session_sync - # server wants to save session data to the portal, - # maybe because it's about to shut down. - portal_sessionhandler.server_session_sync(kwargs.get("sessiondata"), - kwargs.get("clean", True)) - # set a flag in case we are about to shut down soon - self.factory.server_restart_mode = True - - elif operation == SCONN: # server_force_connection (for irc/etc) - portal_sessionhandler.server_connect(**kwargs) - - else: - raise Exception("operation %(op)s not recognized." % {'op': operation}) - return {} - - def send_AdminServer2Portal(self, session, operation="", **kwargs): - """ - Administrative access method called by the Server to send an - instruction to the Portal. - - Args: - session (Session): Session. - operation (char, optional): Identifier for the server - operation, as defined by the global variables in - `evennia/server/amp.py`. - data (str or dict, optional): Data going into the adminstrative. - - """ - return self.send_data(AdminServer2Portal, session.sessid, operation=operation, **kwargs) - - # Extra functions - - @FunctionCall.responder - def receive_functioncall(self, module, function, func_args, func_kwargs): - """ - This allows Portal- and Server-process to call an arbitrary - function in the other process. It is intended for use by - plugin modules. - - Args: - module (str or module): The module containing the - `function` to call. - function (str): The name of the function to call in - `module`. - func_args (str): Pickled args tuple for use in `function` call. - func_kwargs (str): Pickled kwargs dict for use in `function` call. - - """ - args = loads(func_args) - kwargs = loads(func_kwargs) - - # call the function (don't catch tracebacks here) - result = variable_from_module(module, function)(*args, **kwargs) - - if isinstance(result, Deferred): - # if result is a deferred, attach handler to properly - # wrap the return value - result.addCallback(lambda r: {"result": dumps(r)}) - return result - else: - return {'result': dumps(result)} - - def send_FunctionCall(self, modulepath, functionname, *args, **kwargs): - """ - Access method called by either process. This will call an arbitrary - function on the other process (On Portal if calling from Server and - vice versa). - - Inputs: - modulepath (str) - python path to module holding function to call - functionname (str) - name of function in given module - *args, **kwargs will be used as arguments/keyword args for the - remote function call - Returns: - A deferred that fires with the return value of the remote - function call - - """ - return self.callRemote(FunctionCall, - module=modulepath, - function=functionname, - args=dumps(args), - kwargs=dumps(kwargs)).addCallback( - lambda r: loads(r["result"])).addErrback(self.errback, "FunctionCall") diff --git a/evennia/server/amp_client.py b/evennia/server/amp_client.py new file mode 100644 index 0000000000..9552ad2122 --- /dev/null +++ b/evennia/server/amp_client.py @@ -0,0 +1,185 @@ +""" +The Evennia Server service acts as an AMP-client when talking to the +Portal. This module sets up the Client-side communication. + +""" + +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 lost connection to the Portal. Reconnecting ...") + 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_info("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 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_out(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`. + data (str or dict, optional): Data going into the adminstrative. + + """ + return self.data_out(amp.AdminServer2Portal, session.sessid, operation=operation, **kwargs) + + # receiving AMP data + + @amp.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 = 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 + 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 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 {} diff --git a/evennia/server/portal/amp.py b/evennia/server/portal/amp.py new file mode 100644 index 0000000000..9968c22a9e --- /dev/null +++ b/evennia/server/portal/amp.py @@ -0,0 +1,353 @@ +""" +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 +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 import logger +from evennia.utils.utils import to_str, variable_from_module + + +# 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 (shutdown portal too) +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 reloading (have portal start a new server) + +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 + + +This is Evennia's interal AMP port. It handles communication +between Evennia's different processes.

This port should NOT be +publicly visible.

+""".strip() + + +# Helper functions for pickling. + +def dumps(data): + return to_str(pickle.dumps(to_str(data), pickle.HIGHEST_PROTOCOL)) + + +def loads(data): + return pickle.loads(to_str(data)) + + +# 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 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())] + + +# ------------------------------------------------------------- +# 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 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. + + """ + 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. + """ + self.factory.broadcasts.remove(self) + + # 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 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 data_out(self, command, sessid, **kwargs): + """ + Send data across the wire. Always use this to send. + + 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 prot in self.factory.broadcasts: + deferreds.append(prot.callRemote(command, + packed_data=dumps((sessid, kwargs)))) + return DeferredList(deferreds, fireOnOneErrback=1).addErrback(self.errback, command.key) + + # 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 + def receive_functioncall(self, module, function, func_args, func_kwargs): + """ + This allows Portal- and Server-process to call an arbitrary + function in the other process. It is intended for use by + plugin modules. + + Args: + module (str or module): The module containing the + `function` to call. + function (str): The name of the function to call in + `module`. + func_args (str): Pickled args tuple for use in `function` call. + func_kwargs (str): Pickled kwargs dict for use in `function` call. + + """ + args = loads(func_args) + kwargs = loads(func_kwargs) + + # call the function (don't catch tracebacks here) + result = variable_from_module(module, function)(*args, **kwargs) + + if isinstance(result, Deferred): + # if result is a deferred, attach handler to properly + # wrap the return value + result.addCallback(lambda r: {"result": dumps(r)}) + return result + else: + return {'result': dumps(result)} diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py new file mode 100644 index 0000000000..3c5bf175d2 --- /dev/null +++ b/evennia/server/portal/amp_server.py @@ -0,0 +1,168 @@ +""" +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). + +""" +from twisted.internet import protocol +from evennia.server.portal import amp + + +class AMPServerFactory(protocol.ServerFactory): + + """ + This factory creates AMP Server connection. This acts as the 'Portal'-side communication to the + 'Server' process. + + """ + noisy = False + + 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 = [] + + 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 connectionMade(self): + """ + Called when a new connection is established. + + """ + super(AMPServerProtocol, self).connectionMade() + + sessdata = self.factory.portal.sessions.get_all_sync_data() + self.send_AdminPortal2Server(amp.DUMMYSESSION, + amp.PSYNC, + sessiondata=sessdata) + self.factory.portal.sessions.at_server_connection() + + # sending amp data + + 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_out(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_out(amp.AdminPortal2Server, session.sessid, operation=operation, **kwargs) + + # receive amp data + + @amp.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 = 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 + 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 = 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.SSHUTD: # server_shutdown + # the server orders the portal to shut down + self.factory.portal.shutdown(restart=False) + + elif operation == amp.SRELOAD: # server reload + self.factory.portal.server_reload(**kwargs) + + elif operation == amp.SSYNC: # server_session_sync + # server wants to save session data to the portal, + # maybe because it's about to shut down. + portal_sessionhandler.server_session_sync(kwargs.get("sessiondata"), + kwargs.get("clean", True)) + # set a flag in case we are about to shut down soon + self.factory.server_restart_mode = True + + elif operation == amp.SCONN: # server_force_connection (for irc/etc) + portal_sessionhandler.server_connect(**kwargs) + + else: + raise Exception("operation %(op)s not recognized." % {'op': operation}) + return {} diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index eda7aeada4..293d1d6db5 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -187,13 +187,13 @@ if AMP_ENABLED: # the portal and the mud server. Only reason to ever deactivate # it would be during testing and debugging. - from evennia.server import amp + from evennia.server.portal import amp_server print(' amp (to Server): %s (internal)' % AMP_PORT) - factory = amp.AmpServerFactory(PORTAL) + factory = amp_server.AMPServerFactory(PORTAL) amp_service = internet.TCPServer(AMP_PORT, factory, interface=AMP_INTERFACE) - amp_service.setName("PortalAMPService") + amp_service.setName("PortalAMPServer") PORTAL.services.addService(amp_service) diff --git a/evennia/server/server.py b/evennia/server/server.py index 643b3161f7..be282527f6 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -548,9 +548,9 @@ if AMP_ENABLED: ifacestr = "-%s" % AMP_INTERFACE print(' amp (to Portal)%s: %s (internal)' % (ifacestr, AMP_PORT)) - from evennia.server import amp + from evennia.server import amp_client - factory = amp.AmpClientFactory(EVENNIA) + factory = amp_client.AMPClientFactory(EVENNIA) amp_service = internet.TCPClient(AMP_HOST, AMP_PORT, factory) amp_service.setName('ServerAMPClient') EVENNIA.services.addService(amp_service) From e0d8d8293da5192191f37368fe9c8192b69fdf7b Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 13 Jan 2018 13:11:48 +0100 Subject: [PATCH 083/189] Add better error-handling for AMP, start design launcer API --- evennia/server/amp_client.py | 2 + evennia/server/portal/amp.py | 81 ++++++++++++++++++++++++++--- evennia/server/portal/amp_server.py | 40 ++++++++++++++ 3 files changed, 115 insertions(+), 8 deletions(-) diff --git a/evennia/server/amp_client.py b/evennia/server/amp_client.py index 9552ad2122..fd36499561 100644 --- a/evennia/server/amp_client.py +++ b/evennia/server/amp_client.py @@ -126,6 +126,7 @@ class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol): # receiving AMP data @amp.MsgPortal2Server.responder + @amp.catch_traceback def server_receive_msgportal2server(self, packed_data): """ Receives message arriving to server. This method is executed @@ -142,6 +143,7 @@ class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol): return {} @amp.AdminPortal2Server.responder + @amp.catch_traceback def server_receive_adminportal2server(self, packed_data): """ Receives admin data from the Portal (allows the portal to diff --git a/evennia/server/portal/amp.py b/evennia/server/portal/amp.py index 9968c22a9e..0d3537cd85 100644 --- a/evennia/server/portal/amp.py +++ b/evennia/server/portal/amp.py @@ -5,6 +5,7 @@ This module acts as a central place for AMP-servers and -clients to get commands """ from __future__ import print_function +from functools import wraps import time from twisted.protocols import amp from collections import defaultdict, namedtuple @@ -17,9 +18,10 @@ except ImportError: import pickle from twisted.internet.defer import DeferredList, Deferred -from evennia.utils import logger 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) @@ -30,12 +32,15 @@ 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 (shutdown portal too) +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 reloading (have portal start a new server) +PSTART = chr(15) # server+portal start +PSHUTD = chr(16) # portal (+server) shutdown +PPING = chr(17) # server or portal status 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 @@ -71,6 +76,21 @@ 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: + 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 + return decorator + + # AMP Communication Command types class Compressed(amp.String): @@ -123,6 +143,18 @@ class Compressed(amp.String): return zlib.decompress(inString) +class MsgLauncher2Portal(amp.Command): + """ + Message Launcher -> Portal + + """ + key = "MsgLauncher2Portal" + arguments = [('operation', amp.String()), + ('argument', amp.String())] + errors = {Exception: 'EXCEPTION'} + response = [('result', amp.String())] + + class MsgPortal2Server(amp.Command): """ Message Portal -> Server @@ -173,6 +205,17 @@ class AdminServer2Portal(amp.Command): response = [] +class MsgPing(amp.Command): + """ + Ping between AMP services + + """ + key = "AMPPing" + arguments = [('ping', amp.Boolean())] + errors = {Exception: 'EXCEPTION'} + response = [('pong', amp.Boolean())] + + class FunctionCall(amp.Command): """ Bidirectional Server <-> Portal @@ -259,9 +302,12 @@ class AMPMultiConnectionProtocol(amp.AMP): 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()}) + _LOGGER.log_err("AMP Error for %(info)s: %(e)s" % {'info': info, + 'e': e.getErrorMessage()}) def data_in(self, packed_data): """ @@ -292,10 +338,28 @@ class AMPMultiConnectionProtocol(amp.AMP): """ deferreds = [] - for prot in self.factory.broadcasts: - deferreds.append(prot.callRemote(command, - packed_data=dumps((sessid, kwargs)))) - return DeferredList(deferreds, fireOnOneErrback=1).addErrback(self.errback, command.key) + for protcl in self.factory.broadcasts: + deferreds.append(protcl.callRemote(command, + packed_data=dumps((sessid, kwargs))).addErrback( + self.errback, command.key)) + return DeferredList(deferreds) + + def send_ping(self, port, callback, errback): + """ + Ping to the given AMP port. + + Args: + port (int): The port to ping + callback (callable): This will be called with the port that replied to the ping. + errback (callable0: This will be called with the port that failed to reply. + + """ + targets = [(protcl, protcl.getHost()[1]) for protcl in self.factory.broadcasts] + deferreds = [] + for protcl, port in ((protcl, prt) for protcl, prt in targets if prt == port): + deferreds.append(protcl.callRemote(MsgPing, ping=True).addCallback( + callback, port).addErrback(errback, port)) + return DeferredList(deferreds) # generic function send/recvs @@ -323,6 +387,7 @@ class AMPMultiConnectionProtocol(amp.AMP): 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 diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index 3c5bf175d2..8915feef8a 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -96,9 +96,48 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): """ return self.data_out(amp.AdminPortal2Server, session.sessid, operation=operation, **kwargs) + def sendPingPortal2Server(self, callback): + """ + Send ping to check if Server is alive. + + """ + # receive amp data + @amp.MsgLauncher2Portal.responder + @amp.catch_traceback + def portal_receive_launcher2portal(self, operation, argument): + """ + Receives message arriving from evennia_launcher. + This method is executed on the Portal. + + Args: + operation (str): The action to perform. + argument (str): A 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. + + """ + if operation == amp.PPING: # check portal and server status + pass + elif operation == amp.PSTART: # portal start (server start or reload) + pass + elif operation == amp.SRELOAD: # reload server + pass + elif operation == amp.PSHUTD: # portal + server shutdown + pass + else: + raise Exception("operation %(op)s not recognized." % {'op': operation}) + # fallback + return {"result": ""} + @amp.MsgServer2Portal.responder + @amp.catch_traceback def portal_receive_server2portal(self, packed_data): """ Receives message arriving to Portal from Server. @@ -115,6 +154,7 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): return {} @amp.AdminServer2Portal.responder + @amp.catch_traceback def portal_receive_adminserver2portal(self, packed_data): """ From 84e0f463a557ae90e7b23ef9054950dd90358258 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 13 Jan 2018 15:22:51 +0100 Subject: [PATCH 084/189] Add ability to ping Portal from launcher over AMP --- evennia/server/evennia_launcher.py | 127 +++++++++++++++++++++++++++- evennia/server/portal/amp.py | 18 ++-- evennia/server/portal/amp_server.py | 8 +- 3 files changed, 137 insertions(+), 16 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 9ccf664410..94395f09c4 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -20,6 +20,8 @@ import importlib from distutils.version import LooseVersion from argparse import ArgumentParser from subprocess import Popen, check_output, call, CalledProcessError, STDOUT +from twisted.protocols import amp +from twisted.internet import reactor, endpoints import django # Signal processing @@ -49,18 +51,32 @@ CURRENT_DIR = os.getcwd() GAMEDIR = CURRENT_DIR # Operational setup +AMP_PORT = None +AMP_HOST = None +AMP_INTERFACE = None + SERVER_LOGFILE = None PORTAL_LOGFILE = None HTTP_LOGFILE = None + SERVER_PIDFILE = None PORTAL_PIDFILE = None SERVER_RESTART = None PORTAL_RESTART = None + SERVER_PY_FILE = None PORTAL_PY_FILE = None + TEST_MODE = False ENFORCED_SETTING = False +# communication constants + +SRELOAD = chr(14) # server reloading (have portal start a new server) +PSTART = chr(15) # server+portal start +PSHUTD = chr(16) # portal (+server) shutdown +PSTATUS = chr(17) # ping server or portal status + # requirements PYTHON_MIN = '2.7' TWISTED_MIN = '16.0.0' @@ -303,6 +319,15 @@ MENU = \ +---------------------------------------------------------------+ """ +ERROR_AMP_UNCONFIGURED = \ + """ + Can't find server info for connecting. Either run this command from + the game dir (it will then use the game's settings file) or specify + the path to your game's settings file manually with the --settings + option. + + """ + ERROR_LOGDIR_MISSING = \ """ ERROR: One or more log-file directory locations could not be @@ -391,11 +416,94 @@ NOTE_TEST_CUSTOM = \ on the game dir.) """ -#------------------------------------------------------------ + +# ------------------------------------------------------------ # -# Functions +# Protocol Evennia launcher - Portal/Server communication # -#------------------------------------------------------------ +# ------------------------------------------------------------ + + +class MsgStatus(amp.Command): + """ + Ping between AMP services + + """ + key = "AMPPing" + arguments = [('question', amp.String())] + errors = {Exception: 'EXCEPTION'} + response = [('status', amp.String())] + + +class MsgLauncher2Portal(amp.Command): + """ + Message Launcher -> Portal + + """ + key = "MsgLauncher2Portal" + arguments = [('operation', amp.String()), + ('argument', amp.String())] + errors = {Exception: 'EXCEPTION'} + response = [('result', amp.String())] + + +def send_instruction(instruction, argument, callback, errback): + """ + Send instruction and handle the response. + + """ + if None in (AMP_HOST, AMP_PORT, AMP_INTERFACE): + print(ERROR_AMP_UNCONFIGURED) + sys.exit() + + def _on_connect(prot): + """ + This fires with the protocol when connection is established. We + immediately send off the instruction then shut down. + + """ + def _callback(result): + callback(result) + prot.transport.loseConnection() + reactor.stop() + + def _errback(fail): + errback(fail) + prot.transport.loseConnection() + reactor.stop() + + if instruction == PSTATUS: + prot.callRemote(MsgStatus, question="").addCallbacks(_callback, _errback) + else: + prot.callRemote(MsgLauncher2Portal, instruction, argument).addCallbacks( + _callback, _errback) + + point = endpoints.TCP4ClientEndpoint(reactor, AMP_HOST, AMP_PORT) + deferred = endpoints.connectProtocol(point, amp.AMP()) + deferred.addCallbacks(_on_connect, errback) + reactor.run() + + +def send_status(): + """ + Send ping to portal + + """ + import time + t0 = time.time() + def _callback(status): + print("STATUS returned: %s (%gms)" % (status, (time.time()-t0) * 1000)) + + def _errback(err): + print("STATUS returned: %s" % err) + + send_instruction(PSTATUS, None, _callback, _errback) + +# ------------------------------------------------------------ +# +# Helper functions +# +# ------------------------------------------------------------ def evennia_version(): @@ -869,12 +977,17 @@ def init_game_directory(path, check_db=True): check_database() # set up the Evennia executables and log file locations + global AMP_PORT, AMP_HOST, AMP_INTERFACE global SERVER_PY_FILE, PORTAL_PY_FILE global SERVER_LOGFILE, PORTAL_LOGFILE, HTTP_LOGFILE global SERVER_PIDFILE, PORTAL_PIDFILE global SERVER_RESTART, PORTAL_RESTART global EVENNIA_VERSION + AMP_PORT = settings.AMP_PORT + AMP_HOST = settings.AMP_HOST + AMP_INTERFACE = settings.AMP_INTERFACE + SERVER_PY_FILE = os.path.join(EVENNIA_LIB, "server", "server.py") PORTAL_PY_FILE = os.path.join(EVENNIA_LIB, "portal", "portal", "portal.py") @@ -1239,6 +1352,9 @@ def main(): "service", metavar="component", nargs='?', default="all", help=("Which component to operate on: " "'server', 'portal' or 'all' (default if not set).")) + parser.add_argument( + "--status", action='store_true', dest='get_status', + default=None, help='Get current server status.') parser.epilog = ( "Common usage: evennia start|stop|reload. Django-admin database commands:" "evennia migration|flush|shell|dbshell (see the django documentation for more django-admin commands.)") @@ -1289,6 +1405,11 @@ def main(): print(ERROR_INITSETTINGS) sys.exit() + if args.get_status: + init_game_directory(CURRENT_DIR, check_db=True) + send_status() + sys.exit() + if args.dummyrunner: # launch the dummy runner init_game_directory(CURRENT_DIR, check_db=True) diff --git a/evennia/server/portal/amp.py b/evennia/server/portal/amp.py index 0d3537cd85..1578b8f1f4 100644 --- a/evennia/server/portal/amp.py +++ b/evennia/server/portal/amp.py @@ -40,7 +40,7 @@ PDISCONNALL = chr(13) # portal session disconnect all SRELOAD = chr(14) # server reloading (have portal start a new server) PSTART = chr(15) # server+portal start PSHUTD = chr(16) # portal (+server) shutdown -PPING = chr(17) # server or portal status +PSTATUS = chr(17) # ping server or portal status 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 @@ -205,15 +205,15 @@ class AdminServer2Portal(amp.Command): response = [] -class MsgPing(amp.Command): +class MsgStatus(amp.Command): """ - Ping between AMP services + Check Status between AMP services """ key = "AMPPing" - arguments = [('ping', amp.Boolean())] + arguments = [('question', amp.String())] errors = {Exception: 'EXCEPTION'} - response = [('pong', amp.Boolean())] + response = [('status', amp.String())] class FunctionCall(amp.Command): @@ -271,9 +271,7 @@ class AMPMultiConnectionProtocol(amp.AMP): 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 is called when an AMP connection is (re-)established AMP calls it on both sides. """ self.factory.broadcasts.append(self) @@ -344,7 +342,7 @@ class AMPMultiConnectionProtocol(amp.AMP): self.errback, command.key)) return DeferredList(deferreds) - def send_ping(self, port, callback, errback): + def send_status(self, port, callback, errback): """ Ping to the given AMP port. @@ -357,7 +355,7 @@ class AMPMultiConnectionProtocol(amp.AMP): targets = [(protcl, protcl.getHost()[1]) for protcl in self.factory.broadcasts] deferreds = [] for protcl, port in ((protcl, prt) for protcl, prt in targets if prt == port): - deferreds.append(protcl.callRemote(MsgPing, ping=True).addCallback( + deferreds.append(protcl.callRemote(MsgStatus, status=True).addCallback( callback, port).addErrback(errback, port)) return DeferredList(deferreds) diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index 8915feef8a..343680aa89 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -104,6 +104,10 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): # receive amp data + @amp.MsgStatus.responder + def portal_receive_status(self, question): + return {"status": "All well"} + @amp.MsgLauncher2Portal.responder @amp.catch_traceback def portal_receive_launcher2portal(self, operation, argument): @@ -123,9 +127,7 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): evennia launcher. """ - if operation == amp.PPING: # check portal and server status - pass - elif operation == amp.PSTART: # portal start (server start or reload) + if operation == amp.PSTART: # portal start (server start or reload) pass elif operation == amp.SRELOAD: # reload server pass From b4d2fe728464be7d96f269aa173b20ececedd34f Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 13 Jan 2018 20:11:02 +0100 Subject: [PATCH 085/189] Start reworking launcher for sending instructions --- evennia/server/amp_client.py | 4 + evennia/server/evennia_launcher.py | 212 ++++++++++++++++++++++++---- evennia/server/evennia_runner.py | 4 - evennia/server/portal/amp.py | 28 +--- evennia/server/portal/amp_server.py | 50 ++++--- 5 files changed, 231 insertions(+), 67 deletions(-) diff --git a/evennia/server/amp_client.py b/evennia/server/amp_client.py index fd36499561..a76245912b 100644 --- a/evennia/server/amp_client.py +++ b/evennia/server/amp_client.py @@ -125,6 +125,10 @@ class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol): # 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): diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 94395f09c4..a9452ae785 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -20,6 +20,12 @@ import importlib from distutils.version import LooseVersion from argparse import ArgumentParser from subprocess import Popen, check_output, call, CalledProcessError, STDOUT + +try: + import cPickle as pickle +except ImportError: + import pickle + from twisted.protocols import amp from twisted.internet import reactor, endpoints import django @@ -67,15 +73,19 @@ PORTAL_RESTART = None SERVER_PY_FILE = None PORTAL_PY_FILE = None +SPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "server.prof") +PPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "portal.prof") + TEST_MODE = False ENFORCED_SETTING = False # communication constants SRELOAD = chr(14) # server reloading (have portal start a new server) -PSTART = chr(15) # server+portal start +SSTART = chr(15) # server start PSHUTD = chr(16) # portal (+server) shutdown -PSTATUS = chr(17) # ping server or portal status +SSHUTD = chr(17) # server-only shutdown +PSTATUS = chr(18) # ping server or portal status # requirements PYTHON_MIN = '2.7' @@ -85,11 +95,11 @@ DJANGO_REC = '1.11' sys.path[1] = EVENNIA_ROOT -#------------------------------------------------------------ +# ------------------------------------------------------------ # # Messages # -#------------------------------------------------------------ +# ------------------------------------------------------------ CREATED_NEW_GAMEDIR = \ """ @@ -416,6 +426,10 @@ NOTE_TEST_CUSTOM = \ on the game dir.) """ +PROCESS_ERROR = \ + """ + {component} process error: {traceback}. + """ # ------------------------------------------------------------ # @@ -429,8 +443,8 @@ class MsgStatus(amp.Command): Ping between AMP services """ - key = "AMPPing" - arguments = [('question', amp.String())] + key = "MsgStatus" + arguments = [('status', amp.String())] errors = {Exception: 'EXCEPTION'} response = [('status', amp.String())] @@ -442,12 +456,12 @@ class MsgLauncher2Portal(amp.Command): """ key = "MsgLauncher2Portal" arguments = [('operation', amp.String()), - ('argument', amp.String())] + ('arguments', amp.String())] errors = {Exception: 'EXCEPTION'} response = [('result', amp.String())] -def send_instruction(instruction, argument, callback, errback): +def send_instruction(instruction, arguments, callback, errback): """ Send instruction and handle the response. @@ -473,14 +487,22 @@ def send_instruction(instruction, argument, callback, errback): reactor.stop() if instruction == PSTATUS: - prot.callRemote(MsgStatus, question="").addCallbacks(_callback, _errback) + prot.callRemote(MsgStatus, status="").addCallbacks(_callback, _errback) else: - prot.callRemote(MsgLauncher2Portal, instruction, argument).addCallbacks( - _callback, _errback) + prot.callRemote( + MsgLauncher2Portal, + instruction=instruction, + arguments=pickle.dumps(arguments, pickle.HIGHEST_PROTOCOL).addCallbacks( + _callback, _errback)) + + def _on_connect_fail(fail): + "This is called if portal is not reachable." + errback(fail) + reactor.stop() point = endpoints.TCP4ClientEndpoint(reactor, AMP_HOST, AMP_PORT) deferred = endpoints.connectProtocol(point, amp.AMP()) - deferred.addCallbacks(_on_connect, errback) + deferred.addCallbacks(_on_connect, _on_connect_fail) reactor.run() @@ -489,16 +511,33 @@ def send_status(): Send ping to portal """ - import time - t0 = time.time() - def _callback(status): - print("STATUS returned: %s (%gms)" % (status, (time.time()-t0) * 1000)) + def _callback(response): + pstatus, sstatus = response['status'].split("|") + print("Portal: {}\nServer: {}".format(pstatus, sstatus)) - def _errback(err): - print("STATUS returned: %s" % err) + def _errback(fail): + pstatus, sstatus = "NOT RUNNING", "NOT RUNNING" + print("Portal: {}\nServer: {}".format(pstatus, sstatus)) send_instruction(PSTATUS, None, _callback, _errback) + +def send_repeating_status(callback=None): + """ + Repeat the status ping until a reply is returned or timeout is reached. + + Args: + callback (callable): Takes the response on a successful status-reply + """ + def _callback(response): + pstatus, sstatus = response['status'].split("|") + print("Portal: {}\nServer: {}".format(pstatus, sstatus)) + + def _errback(fail): + send_instruction(PSTATUS, None, _callback, _errback) + + send_instruction(PSTATUS, None, callback or _callback, _errback) + # ------------------------------------------------------------ # # Helper functions @@ -506,6 +545,118 @@ def send_status(): # ------------------------------------------------------------ +def get_twistd_cmdline(pprofiler, sprofiler): + + portal_cmd = [TWISTED_BINARY, + "--logfile={}".format(PORTAL_LOGFILE), + "--python={}".format(PORTAL_PY_FILE)] + server_cmd = [TWISTED_BINARY, + "--logfile={}".format(PORTAL_LOGFILE), + "--python={}".format(PORTAL_PY_FILE)] + if pprofiler: + portal_cmd.extend(["--savestats", + "--profiler=cprofiler", + "--profile={}".format(PPROFILER_LOGFILE)]) + if sprofiler: + server_cmd.extend(["--savestats", + "--profiler=cprofiler", + "--profile={}".format(SPROFILER_LOGFILE)]) + return portal_cmd, server_cmd + + +def start_evennia(pprofiler=False, sprofiler=False): + """ + This will start Evennia anew by launching the Evennia Portal (which in turn + will start the Server) + + """ + portal_cmd, server_cmd = get_twistd_cmdline(pprofiler, sprofiler) + + def _portal_running(response): + _, server_running = [stat == 'RUNNING' for stat in response['status'].split("|")] + print("Portal is already running as process {pid}. Not restarted.".format(pid=0)) # TODO PID + if server_running: + print("Server is already running as process {pid}. Not restarted.".format(pid=0)) # TODO PID + else: + print("Server starting {}...".format("(under cProfile)" if pprofiler else "")) + send_instruction(SSTART, server_cmd, lambda x: 0, lambda e: 0) # TODO + + def _portal_cold_started(response): + "Called once the portal is up after a cold boot. It needs to know how to start the Server." + send_instruction(SSTART, server_cmd, lambda x: 0, lambda e: 0) # TODO + + def _portal_not_running(fail): + print("Portal starting {}...".format("(under cProfile)" if sprofiler else "")) + try: + Popen(portal_cmd) + except Exception as e: + print(PROCESS_ERROR.format(component="Portal", traceback=e)) + send_repeating_status(_portal_cold_started) + + # first, check if the portal/server is running already + send_instruction(PSTATUS, None, _portal_running, _portal_not_running) + + +def reload_evennia(sprofiler=False): + """ + This will instruct the Portal to reboot the Server component. + + """ + _, server_cmd = get_twistd_cmdline(False, sprofiler) + + def _portal_running(response): + _, server_running = [stat == 'RUNNING' for stat in response['status'].split("|")] + if server_running: + print("Server reloading ...") + else: + print("Server down. Starting anew.") + send_instruction(SRELOAD, server_cmd, lambda x: 0, lambda e: 0) # TODO + + def _portal_not_running(fail): + print("Evennia not running. Starting from scratch ...") + start_evennia() + + # get portal status + send_instruction(PSTATUS, None, _portal_running, _portal_not_running) + + +def stop_evennia(): + """ + This instructs the Portal to stop the Server and then itself. + + """ + def _portal_running(response): + _, server_running = [stat == 'RUNNING' for stat in response['status'].split("|")] + print("Portal stopping ...") + if server_running: + print("Server stopping ...") + send_instruction(PSHUTD, {}, lambda x: 0, lambda e: 0) # TODO + + def _portal_not_running(fail): + print("Evennia is not running.") + + send_instruction(PSTATUS, None, _portal_running, _portal_not_running) + + +def stop_server_only(): + """ + Only stop the Server-component of Evennia (this is not useful except for debug) + + """ + def _portal_running(response): + _, server_running = [stat == 'RUNNING' for stat in response['status'].split("|")] + if server_running: + print("Server stopping ...") + send_instruction(SSHUTD, {}, lambda x: 0, lambda e: 0) # TODO + else: + print("Server is not running.") + + def _portal_not_running(fail): + print("Evennia is not running.") + + send_instruction(PSTATUS, None, _portal_running, _portal_not_running) + + def evennia_version(): """ Get the Evennia version info from the main package. @@ -645,10 +796,10 @@ def create_settings_file(init=True, secret_settings=False): if os.path.exists(settings_path): inp = input("%s already exists. Do you want to reset it? y/[N]> " % settings_path) if not inp.lower() == 'y': - print ("Aborted.") + print("Aborted.") return else: - print ("Reset the settings file.") + print("Reset the settings file.") default_settings_path = os.path.join(EVENNIA_TEMPLATE, "server", "conf", "settings.py") shutil.copy(default_settings_path, settings_path) @@ -912,7 +1063,7 @@ def error_check_python_modules(): _imp(settings.COMMAND_PARSER) _imp(settings.SEARCH_AT_RESULT) _imp(settings.CONNECTION_SCREEN_MODULE) - #imp(settings.AT_INITIAL_SETUP_HOOK_MODULE, split=False) + # imp(settings.AT_INITIAL_SETUP_HOOK_MODULE, split=False) for path in settings.LOCK_FUNC_MODULES: _imp(path, split=False) @@ -1280,7 +1431,7 @@ def server_operation(mode, service, interactive, profiler, logserver=False, doex elif mode == 'stop': if os.name == "nt": - print ( + print( "(Obs: You can use a single Ctrl-C to skip " "Windows' annoying 'Terminate batch job (Y/N)?' prompts.)") # stop processes, avoiding reload @@ -1357,7 +1508,8 @@ def main(): default=None, help='Get current server status.') parser.epilog = ( "Common usage: evennia start|stop|reload. Django-admin database commands:" - "evennia migration|flush|shell|dbshell (see the django documentation for more django-admin commands.)") + "evennia migration|flush|shell|dbshell (see the django documentation for more " + "django-admin commands.)") args, unknown_args = parser.parse_known_args() @@ -1422,10 +1574,20 @@ def main(): # launch menu for operation init_game_directory(CURRENT_DIR, check_db=True) run_menu() - elif option in ('start', 'reload', 'stop'): + elif option in ('sstart', 'sreload', 'sstop', 'ssstop', 'start', 'reload', 'stop'): # operate the server directly init_game_directory(CURRENT_DIR, check_db=True) - server_operation(option, service, args.interactive, args.profiler, args.logserver, doexit=args.doexit) + if option == "sstart": + start_evennia(False, args.profiler) + elif option == 'sreload': + reload_evennia(args.profiler) + elif option == 'sstop': + stop_evennia() + elif option == 'ssstop': + stop_server_only() + else: + server_operation(option, service, args.interactive, + args.profiler, args.logserver, doexit=args.doexit) elif option != "noop": # pass-through to django manager check_db = False diff --git a/evennia/server/evennia_runner.py b/evennia/server/evennia_runner.py index 83e7bf4093..27fc187211 100644 --- a/evennia/server/evennia_runner.py +++ b/evennia/server/evennia_runner.py @@ -63,10 +63,6 @@ CMDLINE_HELP = \ are stored in the game's server/ directory. """ -PROCESS_ERROR = \ - """ - {component} process error: {traceback}. - """ PROCESS_IOERROR = \ """ diff --git a/evennia/server/portal/amp.py b/evennia/server/portal/amp.py index 1578b8f1f4..fc6848a577 100644 --- a/evennia/server/portal/amp.py +++ b/evennia/server/portal/amp.py @@ -38,9 +38,10 @@ 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 reloading (have portal start a new server) -PSTART = chr(15) # server+portal start +SSTART = chr(15) # server start (portal must already be running anyway) PSHUTD = chr(16) # portal (+server) shutdown -PSTATUS = chr(17) # ping server or portal status +SSHUTD = chr(17) # server-only shutdown +PSTATUS = chr(18) # ping server or portal status 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 @@ -150,7 +151,7 @@ class MsgLauncher2Portal(amp.Command): """ key = "MsgLauncher2Portal" arguments = [('operation', amp.String()), - ('argument', amp.String())] + ('arguments', amp.String())] errors = {Exception: 'EXCEPTION'} response = [('result', amp.String())] @@ -210,8 +211,8 @@ class MsgStatus(amp.Command): Check Status between AMP services """ - key = "AMPPing" - arguments = [('question', amp.String())] + key = "MsgStatus" + arguments = [('status', amp.String())] errors = {Exception: 'EXCEPTION'} response = [('status', amp.String())] @@ -342,23 +343,6 @@ class AMPMultiConnectionProtocol(amp.AMP): self.errback, command.key)) return DeferredList(deferreds) - def send_status(self, port, callback, errback): - """ - Ping to the given AMP port. - - Args: - port (int): The port to ping - callback (callable): This will be called with the port that replied to the ping. - errback (callable0: This will be called with the port that failed to reply. - - """ - targets = [(protcl, protcl.getHost()[1]) for protcl in self.factory.broadcasts] - deferreds = [] - for protcl, port in ((protcl, prt) for protcl, prt in targets if prt == port): - deferreds.append(protcl.callRemote(MsgStatus, status=True).addCallback( - callback, port).addErrback(errback, port)) - return DeferredList(deferreds) - # generic function send/recvs def send_FunctionCall(self, modulepath, functionname, *args, **kwargs): diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index 343680aa89..4b18e91c27 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -96,43 +96,61 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): """ return self.data_out(amp.AdminPortal2Server, session.sessid, operation=operation, **kwargs) - def sendPingPortal2Server(self, callback): - """ - Send ping to check if Server is alive. - - """ - # receive amp data @amp.MsgStatus.responder - def portal_receive_status(self, question): - return {"status": "All well"} + @amp.catch_traceback + def portal_receive_status(self, status): + """ + Check if Server is running + """ + # check if the server is connected + server_connected = any(1 for prtcl in self.factory.broadcasts + if prtcl is not self and prtcl.transport.connected) + # return portal|server RUNNING/NOT RUNNING + if server_connected: + return {"status": "RUNNING|RUNNING"} + else: + return {"status": "RUNNING|NOT RUNNING"} @amp.MsgLauncher2Portal.responder @amp.catch_traceback - def portal_receive_launcher2portal(self, operation, argument): + 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. - argument (str): A possible argument to the instruction, or the empty string. + 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. + 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. """ - if operation == amp.PSTART: # portal start (server start or reload) - pass + server_connected = any(1 for prtcl in self.factory.broadcasts + if prtcl is not self and prtcl.transport.connected) + + if operation == amp.SSTART: # portal start (server start or reload) + # first, check if server is already running + if server_connected: + return {"result": "Server already running (PID {}).".format(0)} # TODO store and send PID + else: + self.start_server(amp.loads(arguments)) + return {"result": "Server started with PID {}.".format(0)} # TODO elif operation == amp.SRELOAD: # reload server - pass + if server_connected: + self.reload_server(amp.loads(arguments)) + else: + self.start_server(amp.loads(arguments)) elif operation == amp.PSHUTD: # portal + server shutdown - pass + if server_connected: + self.stop_server(amp.loads(arguments)) + self.factory.portal.shutdown(restart=False) else: raise Exception("operation %(op)s not recognized." % {'op': operation}) # fallback From c46d181566f06b088ec1be8b187dd5e92672561a Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 13 Jan 2018 23:01:23 +0100 Subject: [PATCH 086/189] Working launcher sends, no reception on server side yet --- evennia/server/evennia_launcher.py | 152 ++++++++++++++++++++++------ evennia/server/portal/amp_server.py | 15 ++- 2 files changed, 130 insertions(+), 37 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index a9452ae785..99eab5bb84 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -81,6 +81,8 @@ ENFORCED_SETTING = False # communication constants +AMP_CONNECTION = None + SRELOAD = chr(14) # server reloading (have portal start a new server) SSTART = chr(15) # server start PSHUTD = chr(16) # portal (+server) shutdown @@ -461,11 +463,13 @@ class MsgLauncher2Portal(amp.Command): response = [('result', amp.String())] -def send_instruction(instruction, arguments, callback, errback): +def send_instruction(operation, arguments, callback=None, errback=None, autostop=False): """ Send instruction and handle the response. """ + global AMP_CONNECTION + if None in (AMP_HOST, AMP_PORT, AMP_INTERFACE): print(ERROR_AMP_UNCONFIGURED) sys.exit() @@ -477,36 +481,42 @@ def send_instruction(instruction, arguments, callback, errback): """ def _callback(result): - callback(result) + if callback: + callback(result) prot.transport.loseConnection() - reactor.stop() + if autostop: + reactor.stop() def _errback(fail): - errback(fail) + if errback: + errback(fail) prot.transport.loseConnection() - reactor.stop() + if autostop: + reactor.stop() - if instruction == PSTATUS: + if operation == PSTATUS: prot.callRemote(MsgStatus, status="").addCallbacks(_callback, _errback) else: + print("callRemote MsgLauncher %s %s" % (ord(operation), arguments)) prot.callRemote( MsgLauncher2Portal, - instruction=instruction, - arguments=pickle.dumps(arguments, pickle.HIGHEST_PROTOCOL).addCallbacks( - _callback, _errback)) + operation=operation, + arguments=pickle.dumps(arguments, pickle.HIGHEST_PROTOCOL)).addCallbacks( + _callback, _errback) def _on_connect_fail(fail): "This is called if portal is not reachable." errback(fail) - reactor.stop() + if autostop: + reactor.stop() point = endpoints.TCP4ClientEndpoint(reactor, AMP_HOST, AMP_PORT) deferred = endpoints.connectProtocol(point, amp.AMP()) deferred.addCallbacks(_on_connect, _on_connect_fail) - reactor.run() + return deferred -def send_status(): +def send_status(repeat=False): """ Send ping to portal @@ -519,24 +529,70 @@ def send_status(): pstatus, sstatus = "NOT RUNNING", "NOT RUNNING" print("Portal: {}\nServer: {}".format(pstatus, sstatus)) - send_instruction(PSTATUS, None, _callback, _errback) + send_instruction(PSTATUS, None, _callback, _errback, autostop=True) + reactor.run() -def send_repeating_status(callback=None): +def wait_for_status(portal_running=True, server_running=True, callback=None, errback=None, + rate=0.5, retries=20): """ - Repeat the status ping until a reply is returned or timeout is reached. + Repeat the status ping until the desired state combination is achieved. Args: - callback (callable): Takes the response on a successful status-reply + portal_running (bool or None): Desired portal run-state. If None, any state is accepted. + server_running (bool or None): Desired server run-state. If None, any state is accepted. + the portal must be running. + callback (callable): Will be called with portal_state, server_state when + condition is fulfilled. + errback (callable): Will be called with portal_state, server_state if the + request is timed out. + rate (float): How often to retry. + retries (int): How many times to retry before timing out and calling `errback`. """ def _callback(response): - pstatus, sstatus = response['status'].split("|") - print("Portal: {}\nServer: {}".format(pstatus, sstatus)) + prun, srun = [stat == 'RUNNING' for stat in response['status'].split("|")] + if ((portal_running is None or prun == portal_running) and + (server_running is None or srun == server_running)): + # the correct state was achieved + if callback: + callback(prun, srun) + else: + reactor.stop() + else: + if retries <= 0: + if errback: + errback(prun, srun) + else: + print("Timeout.") + reactor.stop() + else: + reactor.callLater(rate, wait_for_status, + portal_running, server_running, + callback, errback, rate, retries - 1) def _errback(fail): - send_instruction(PSTATUS, None, _callback, _errback) + """ + Portal not running + """ + if not portal_running: + # this is what we want + if callback: + callback(portal_running, server_running) + else: + reactor.stop() + else: + if retries <= 0: + if errback: + errback(portal_running, server_running) + else: + print("Timeout.") + reactor.stop() + else: + reactor.callLater(rate, wait_for_status, + portal_running, server_running, + callback, errback, rate, retries - 1) - send_instruction(PSTATUS, None, callback or _callback, _errback) + return send_instruction(PSTATUS, None, _callback, _errback) # ------------------------------------------------------------ # @@ -572,18 +628,22 @@ def start_evennia(pprofiler=False, sprofiler=False): """ portal_cmd, server_cmd = get_twistd_cmdline(pprofiler, sprofiler) + def _server_started(*args): + print("... Server started.\nEvennia running.") + reactor.stop() + + def _portal_started(*args): + send_instruction(SSTART, server_cmd, _server_started) + def _portal_running(response): _, server_running = [stat == 'RUNNING' for stat in response['status'].split("|")] print("Portal is already running as process {pid}. Not restarted.".format(pid=0)) # TODO PID if server_running: print("Server is already running as process {pid}. Not restarted.".format(pid=0)) # TODO PID + reactor.stop() else: print("Server starting {}...".format("(under cProfile)" if pprofiler else "")) - send_instruction(SSTART, server_cmd, lambda x: 0, lambda e: 0) # TODO - - def _portal_cold_started(response): - "Called once the portal is up after a cold boot. It needs to know how to start the Server." - send_instruction(SSTART, server_cmd, lambda x: 0, lambda e: 0) # TODO + send_instruction(SSTART, server_cmd, _server_started) def _portal_not_running(fail): print("Portal starting {}...".format("(under cProfile)" if sprofiler else "")) @@ -591,10 +651,10 @@ def start_evennia(pprofiler=False, sprofiler=False): Popen(portal_cmd) except Exception as e: print(PROCESS_ERROR.format(component="Portal", traceback=e)) - send_repeating_status(_portal_cold_started) + wait_for_status(True, None, _portal_started) - # first, check if the portal/server is running already send_instruction(PSTATUS, None, _portal_running, _portal_not_running) + reactor.run() def reload_evennia(sprofiler=False): @@ -604,13 +664,22 @@ def reload_evennia(sprofiler=False): """ _, server_cmd = get_twistd_cmdline(False, sprofiler) + def _server_restarted(*args): + print("... Server re-started.", args) + reactor.stop() + + def _server_reloaded(*args): + print("... Server reloaded.", args) + reactor.stop() + def _portal_running(response): _, server_running = [stat == 'RUNNING' for stat in response['status'].split("|")] if server_running: print("Server reloading ...") + send_instruction(SRELOAD, server_cmd, _server_reloaded) else: - print("Server down. Starting anew.") - send_instruction(SRELOAD, server_cmd, lambda x: 0, lambda e: 0) # TODO + print("Server down. Re-starting ...") + send_instruction(SSTART, server_cmd, _server_restarted) def _portal_not_running(fail): print("Evennia not running. Starting from scratch ...") @@ -618,6 +687,7 @@ def reload_evennia(sprofiler=False): # get portal status send_instruction(PSTATUS, None, _portal_running, _portal_not_running) + reactor.run() def stop_evennia(): @@ -625,17 +695,29 @@ def stop_evennia(): This instructs the Portal to stop the Server and then itself. """ + def _portal_stopped(*args): + print("... Portal stopped.\nEvennia shut down.") + reactor.stop() + + def _server_stopped(*args): + print("... Server stopped.", args) + send_instruction(PSHUTD, {}) + wait_for_status(False, None, _portal_stopped) + def _portal_running(response): _, server_running = [stat == 'RUNNING' for stat in response['status'].split("|")] - print("Portal stopping ...") if server_running: print("Server stopping ...") - send_instruction(PSHUTD, {}, lambda x: 0, lambda e: 0) # TODO + send_instruction(SSHUTD, {}, _server_stopped) + else: + send_instruction(PSHUTD, {}) + wait_for_status(False, False, _portal_stopped) def _portal_not_running(fail): print("Evennia is not running.") send_instruction(PSTATUS, None, _portal_running, _portal_not_running) + reactor.run() def stop_server_only(): @@ -643,11 +725,16 @@ def stop_server_only(): Only stop the Server-component of Evennia (this is not useful except for debug) """ + def _server_stopped(*args): + print("... Server stopped.") + reactor.stop() + def _portal_running(response): _, server_running = [stat == 'RUNNING' for stat in response['status'].split("|")] if server_running: print("Server stopping ...") - send_instruction(SSHUTD, {}, lambda x: 0, lambda e: 0) # TODO + send_instruction(SSHUTD, {}) + wait_for_status(True, False, _server_stopped) else: print("Server is not running.") @@ -655,6 +742,7 @@ def stop_server_only(): print("Evennia is not running.") send_instruction(PSTATUS, None, _portal_running, _portal_not_running) + reactor.run() def evennia_version(): diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index 4b18e91c27..a9b27f639c 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -59,11 +59,12 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): """ super(AMPServerProtocol, self).connectionMade() - sessdata = self.factory.portal.sessions.get_all_sync_data() - self.send_AdminPortal2Server(amp.DUMMYSESSION, - amp.PSYNC, - sessiondata=sessdata) - self.factory.portal.sessions.at_server_connection() + if len(self.factory.broadcasts) < 2: + sessdata = self.factory.portal.sessions.get_all_sync_data() + self.send_AdminPortal2Server(amp.DUMMYSESSION, + amp.PSYNC, + sessiondata=sessdata) + self.factory.portal.sessions.at_server_connection() # sending amp data @@ -135,6 +136,10 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): server_connected = any(1 for prtcl in self.factory.broadcasts if prtcl is not self and prtcl.transport.connected) + print("AMP SERVER operation == %s received" % (ord(operation))) + print("AMP SERVER arguments: %s" % (amp.loads(arguments))) + return {"result": "Received."} + if operation == amp.SSTART: # portal start (server start or reload) # first, check if server is already running if server_connected: From ff887a07ab2bb14e9489434a2a654f77c2868927 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 14 Jan 2018 00:30:13 +0100 Subject: [PATCH 087/189] Further work on controlling the portal with AMP --- evennia/server/evennia_launcher.py | 113 +++++++++++++++------------- evennia/server/portal/amp.py | 3 +- evennia/server/portal/amp_server.py | 52 +++++++++++-- evennia/server/portal/portal.py | 2 + evennia/server/server.py | 1 + 5 files changed, 113 insertions(+), 58 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 99eab5bb84..806f51b621 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -463,7 +463,7 @@ class MsgLauncher2Portal(amp.Command): response = [('result', amp.String())] -def send_instruction(operation, arguments, callback=None, errback=None, autostop=False): +def send_instruction(operation, arguments, callback=None, errback=None): """ Send instruction and handle the response. @@ -484,20 +484,15 @@ def send_instruction(operation, arguments, callback=None, errback=None, autostop if callback: callback(result) prot.transport.loseConnection() - if autostop: - reactor.stop() def _errback(fail): if errback: errback(fail) prot.transport.loseConnection() - if autostop: - reactor.stop() if operation == PSTATUS: prot.callRemote(MsgStatus, status="").addCallbacks(_callback, _errback) else: - print("callRemote MsgLauncher %s %s" % (ord(operation), arguments)) prot.callRemote( MsgLauncher2Portal, operation=operation, @@ -507,8 +502,6 @@ def send_instruction(operation, arguments, callback=None, errback=None, autostop def _on_connect_fail(fail): "This is called if portal is not reachable." errback(fail) - if autostop: - reactor.stop() point = endpoints.TCP4ClientEndpoint(reactor, AMP_HOST, AMP_PORT) deferred = endpoints.connectProtocol(point, amp.AMP()) @@ -516,20 +509,55 @@ def send_instruction(operation, arguments, callback=None, errback=None, autostop return deferred -def send_status(repeat=False): +def _parse_status(response): + "Unpack the status information" + return pickle.loads(response['status']) + + +def _get_twistd_cmdline(pprofiler, sprofiler): """ - Send ping to portal + Compile the command line for starting a Twisted application using the 'twistd' executable. """ + + portal_cmd = [TWISTED_BINARY, + "--logfile={}".format(PORTAL_LOGFILE), + "--python={}".format(PORTAL_PY_FILE)] + server_cmd = [TWISTED_BINARY, + "--logfile={}".format(PORTAL_LOGFILE), + "--python={}".format(PORTAL_PY_FILE)] + if pprofiler: + portal_cmd.extend(["--savestats", + "--profiler=cprofiler", + "--profile={}".format(PPROFILER_LOGFILE)]) + if sprofiler: + server_cmd.extend(["--savestats", + "--profiler=cprofiler", + "--profile={}".format(SPROFILER_LOGFILE)]) + return portal_cmd, server_cmd + + +def query_status(repeat=False): + """ + Send status ping to portal + + """ + wmap = {True: "RUNNING", + False: "NOT RUNNING"} + def _callback(response): - pstatus, sstatus = response['status'].split("|") - print("Portal: {}\nServer: {}".format(pstatus, sstatus)) + pstatus, sstatus, ppid, spid = _parse_status(response) + print("Portal: {} (pid {})\nServer: {} (pid {})".format( + wmap[pstatus], ppid, wmap[sstatus], spid)) + reactor.stop() def _errback(fail): - pstatus, sstatus = "NOT RUNNING", "NOT RUNNING" - print("Portal: {}\nServer: {}".format(pstatus, sstatus)) + print("status fail: %s", fail) + pstatus, sstatus = False, False + print("Portal: {}\nServer: {}".format(wmap[pstatus], wmap[sstatus])) + reactor.stop() - send_instruction(PSTATUS, None, _callback, _errback, autostop=True) + send_instruction(PSTATUS, None, _callback, _errback) reactor.run() @@ -550,7 +578,7 @@ def wait_for_status(portal_running=True, server_running=True, callback=None, err retries (int): How many times to retry before timing out and calling `errback`. """ def _callback(response): - prun, srun = [stat == 'RUNNING' for stat in response['status'].split("|")] + prun, srun, _, _ = _parse_status(response) if ((portal_running is None or prun == portal_running) and (server_running is None or srun == server_running)): # the correct state was achieved @@ -594,52 +622,34 @@ def wait_for_status(portal_running=True, server_running=True, callback=None, err return send_instruction(PSTATUS, None, _callback, _errback) + + # ------------------------------------------------------------ # -# Helper functions +# Operational functions # # ------------------------------------------------------------ - -def get_twistd_cmdline(pprofiler, sprofiler): - - portal_cmd = [TWISTED_BINARY, - "--logfile={}".format(PORTAL_LOGFILE), - "--python={}".format(PORTAL_PY_FILE)] - server_cmd = [TWISTED_BINARY, - "--logfile={}".format(PORTAL_LOGFILE), - "--python={}".format(PORTAL_PY_FILE)] - if pprofiler: - portal_cmd.extend(["--savestats", - "--profiler=cprofiler", - "--profile={}".format(PPROFILER_LOGFILE)]) - if sprofiler: - server_cmd.extend(["--savestats", - "--profiler=cprofiler", - "--profile={}".format(SPROFILER_LOGFILE)]) - return portal_cmd, server_cmd - - def start_evennia(pprofiler=False, sprofiler=False): """ This will start Evennia anew by launching the Evennia Portal (which in turn will start the Server) """ - portal_cmd, server_cmd = get_twistd_cmdline(pprofiler, sprofiler) + portal_cmd, server_cmd = _get_twistd_cmdline(pprofiler, sprofiler) def _server_started(*args): - print("... Server started.\nEvennia running.") + print("... Server started.\nEvennia running.", args) reactor.stop() def _portal_started(*args): send_instruction(SSTART, server_cmd, _server_started) def _portal_running(response): - _, server_running = [stat == 'RUNNING' for stat in response['status'].split("|")] - print("Portal is already running as process {pid}. Not restarted.".format(pid=0)) # TODO PID - if server_running: - print("Server is already running as process {pid}. Not restarted.".format(pid=0)) # TODO PID + prun, srun, ppid, spid = _parse_status(response) + print("Portal is already running as process {pid}. Not restarted.".format(pid=ppid)) + if srun: + print("Server is already running as process {pid}. Not restarted.".format(pid=spid)) reactor.stop() else: print("Server starting {}...".format("(under cProfile)" if pprofiler else "")) @@ -648,7 +658,7 @@ def start_evennia(pprofiler=False, sprofiler=False): def _portal_not_running(fail): print("Portal starting {}...".format("(under cProfile)" if sprofiler else "")) try: - Popen(portal_cmd) + Popen(portal_cmd, env=getenv()) except Exception as e: print(PROCESS_ERROR.format(component="Portal", traceback=e)) wait_for_status(True, None, _portal_started) @@ -662,7 +672,7 @@ def reload_evennia(sprofiler=False): This will instruct the Portal to reboot the Server component. """ - _, server_cmd = get_twistd_cmdline(False, sprofiler) + _, server_cmd = _get_twistd_cmdline(False, sprofiler) def _server_restarted(*args): print("... Server re-started.", args) @@ -673,8 +683,8 @@ def reload_evennia(sprofiler=False): reactor.stop() def _portal_running(response): - _, server_running = [stat == 'RUNNING' for stat in response['status'].split("|")] - if server_running: + _, srun, _, _ = _parse_status(response) + if srun: print("Server reloading ...") send_instruction(SRELOAD, server_cmd, _server_reloaded) else: @@ -700,16 +710,17 @@ def stop_evennia(): reactor.stop() def _server_stopped(*args): - print("... Server stopped.", args) + print("... Server stopped.\nStopping Portal ...", args) send_instruction(PSHUTD, {}) wait_for_status(False, None, _portal_stopped) def _portal_running(response): - _, server_running = [stat == 'RUNNING' for stat in response['status'].split("|")] - if server_running: + prun, srun, ppid, spid = _parse_status(response) + if srun: print("Server stopping ...") send_instruction(SSHUTD, {}, _server_stopped) else: + print("Server already stopped.\nStopping Portal ...") send_instruction(PSHUTD, {}) wait_for_status(False, False, _portal_stopped) @@ -1647,7 +1658,7 @@ def main(): if args.get_status: init_game_directory(CURRENT_DIR, check_db=True) - send_status() + query_status() sys.exit() if args.dummyrunner: diff --git a/evennia/server/portal/amp.py b/evennia/server/portal/amp.py index fc6848a577..2887aab8ec 100644 --- a/evennia/server/portal/amp.py +++ b/evennia/server/portal/amp.py @@ -83,11 +83,12 @@ def catch_traceback(func): def decorator(*args, **kwargs): try: func(*args, **kwargs) - except Exception: + except Exception as err: global _LOGGER if not _LOGGER: from evennia.utils import logger as _LOGGER _LOGGER.log_trace() + print("error", err) raise # make sure the error is visible on the other side of the connection too return decorator diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index a9b27f639c..20a73a70a7 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -4,8 +4,25 @@ 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 subprocess import Popen + + +def getenv(): + """ + Get current environment and add PYTHONPATH. + + Returns: + env (dict): Environment global dict. + + """ + sep = ";" if os.name == 'nt' else ":" + env = os.environ.copy() + env['PYTHONPATH'] = sep.join(sys.path) + return env class AMPServerFactory(protocol.ServerFactory): @@ -66,6 +83,20 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): sessiondata=sessdata) self.factory.portal.sessions.at_server_connection() + 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 = Popen(server_twistd_cmd, env=getenv()) + # store the pid for future reference + self.portal.server_process_id = process.pid + # sending amp data def send_MsgPortal2Server(self, session, **kwargs): @@ -103,16 +134,25 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): @amp.catch_traceback def portal_receive_status(self, status): """ - Check if Server is running + 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). + """ # check if the server is connected server_connected = any(1 for prtcl in self.factory.broadcasts if prtcl is not self and prtcl.transport.connected) - # return portal|server RUNNING/NOT RUNNING + server_pid = self.factory.portal.server_process_id + portal_pid = os.getpid() + if server_connected: - return {"status": "RUNNING|RUNNING"} + return {"status": amp.dumps((True, True, portal_pid, server_pid))} else: - return {"status": "RUNNING|NOT RUNNING"} + return {"status": amp.dumps((True, False, portal_pid, server_pid))} @amp.MsgLauncher2Portal.responder @amp.catch_traceback @@ -140,10 +180,10 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): print("AMP SERVER arguments: %s" % (amp.loads(arguments))) return {"result": "Received."} - if operation == amp.SSTART: # portal start (server start or reload) + if operation == amp.SSTART: # portal start # first, check if server is already running if server_connected: - return {"result": "Server already running (PID {}).".format(0)} # TODO store and send PID + return {"result": "Server already running at PID={}"} else: self.start_server(amp.loads(arguments)) return {"result": "Server started with PID {}.".format(0)} # TODO diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index 293d1d6db5..5d4c9aae19 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -105,6 +105,8 @@ class Portal(object): self.amp_protocol = None # set by amp factory self.sessions = PORTAL_SESSIONS self.sessions.portal = self + self.process_id = os.getpid() + self.server_process_id = None # set a callback if the server is killed abruptly, # by Ctrl-C, reboot etc. diff --git a/evennia/server/server.py b/evennia/server/server.py index be282527f6..fab6d9675f 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -173,6 +173,7 @@ class Evennia(object): self.amp_protocol = None # set by amp factory self.sessions = SESSIONS self.sessions.server = self + self.process_id = os.getpid() # Database-specific startup optimizations. self.sqlite3_prep() From 5741eef9bc044d2fb41d85ba70e0913b2809a7a7 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 14 Jan 2018 14:02:34 +0100 Subject: [PATCH 088/189] Parallel start/stop/reload systems, for testing --- evennia/commands/default/system.py | 4 +- evennia/server/amp_client.py | 27 +++++++--- evennia/server/portal/amp.py | 20 ++++++-- evennia/server/portal/amp_server.py | 78 ++++++++++++++++++++--------- evennia/server/sessionhandler.py | 19 +++++-- 5 files changed, 109 insertions(+), 39 deletions(-) diff --git a/evennia/commands/default/system.py b/evennia/commands/default/system.py index fe5efa337d..7933245ae2 100644 --- a/evennia/commands/default/system.py +++ b/evennia/commands/default/system.py @@ -58,7 +58,7 @@ class CmdReload(COMMAND_DEFAULT_CLASS): if self.args: reason = "(Reason: %s) " % self.args.rstrip(".") SESSIONS.announce_all(" Server restart initiated %s..." % reason) - SESSIONS.server.shutdown(mode='reload') + SESSIONS.portal_restart_server() class CmdReset(COMMAND_DEFAULT_CLASS): @@ -91,7 +91,7 @@ class CmdReset(COMMAND_DEFAULT_CLASS): Reload the system. """ SESSIONS.announce_all(" Server resetting/restarting ...") - SESSIONS.server.shutdown(mode='reset') + SESSIONS.portal_reset_server() class CmdShutdown(COMMAND_DEFAULT_CLASS): diff --git a/evennia/server/amp_client.py b/evennia/server/amp_client.py index a76245912b..bf6b3d9fc8 100644 --- a/evennia/server/amp_client.py +++ b/evennia/server/amp_client.py @@ -96,6 +96,16 @@ class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol): """ # sending AMP data + def connectionMade(self): + """ + Called when a new connection is established. + + """ + super(AMPServerClientProtocol, self).connectionMade() + # first thing we do is to request the Portal to sync all sessions + # back with the Server side + self.send_AdminServer2Portal(amp.DUMMYSESSION, operation=amp.PSYNC) + def send_MsgServer2Portal(self, session, **kwargs): """ Access method - executed on the Server for sending data @@ -118,7 +128,7 @@ class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol): 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. + kwargs (dict, optional): Data going into the adminstrative. """ return self.data_out(amp.AdminServer2Portal, session.sessid, operation=operation, **kwargs) @@ -180,12 +190,17 @@ class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol): server_sessionhandler.portal_disconnect_all() elif operation == amp.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. + # force a resync of sessions from the portal side server_sessionhandler.portal_sessions_sync(kwargs.get("sessiondata")) + elif operation == amp.SRELOAD: # server reload + # shut down in reload mode + server_sessionhandler.server.shutdown(mode='reload') + elif operation == amp.SRESET: + # shut down in reset mode + server_sessionhandler.server.shutdown(mode='reset') + elif operation == amp.SSHUTD: # server shutdown + # shutdown in stop mode + server_sessionhandler.server.shutdown(mode='shutdown') else: raise Exception("operation %(op)s not recognized." % {'op': operation}) return {} diff --git a/evennia/server/portal/amp.py b/evennia/server/portal/amp.py index 2887aab8ec..8f08b46b0f 100644 --- a/evennia/server/portal/amp.py +++ b/evennia/server/portal/amp.py @@ -37,11 +37,12 @@ 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 reloading (have portal start a new server) +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-only 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 @@ -271,9 +272,22 @@ class AMPMultiConnectionProtocol(amp.AMP): 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. + This is called when an AMP connection is (re-)established. AMP calls it on both sides. """ self.factory.broadcasts.append(self) diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index 20a73a70a7..8193ac0663 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -9,6 +9,7 @@ import sys from twisted.internet import protocol from evennia.server.portal import amp from subprocess import Popen +from evennia.utils import logger def getenv(): @@ -69,20 +70,6 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): Protocol subclass for the AMP-server run by the Portal. """ - def connectionMade(self): - """ - Called when a new connection is established. - - """ - super(AMPServerProtocol, self).connectionMade() - - if len(self.factory.broadcasts) < 2: - sessdata = self.factory.portal.sessions.get_all_sync_data() - self.send_AdminPortal2Server(amp.DUMMYSESSION, - amp.PSYNC, - sessiondata=sessdata) - self.factory.portal.sessions.at_server_connection() - def start_server(self, server_twistd_cmd): """ (Re-)Launch the Evennia server. @@ -92,10 +79,29 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): to pass to POpen to start the server. """ - # start the server + # start the Server process = Popen(server_twistd_cmd, env=getenv()) # store the pid for future reference - self.portal.server_process_id = process.pid + self.factory.portal.server_process_id = process.pid + self.factory.portal.server_twistd_cmd = server_twistd_cmd + return process.pid + + def stop_server(self, mode='reload'): + """ + 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, amp.SRELOAD) + return self.start_server(self.factory.portal.server_twistd_cmd) + if mode == 'reset': + self.send_AdminPortal2Server(amp.DUMMYSESSION, amp.SRESET) + return self.start_server(self.factory.portal.server_twistd_cmd) + if mode == 'shutdown': + self.send_AdminPortal2Server(amp.DUMMYSESSION, amp.SSHUTD) # sending amp data @@ -173,28 +179,44 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): launcher. It can obviously only accessed when the Portal is already up and running. """ + def _retval(success, txt): + return {"result": amp.dumps((success, txt))} + server_connected = any(1 for prtcl in self.factory.broadcasts if prtcl is not self and prtcl.transport.connected) + server_pid = self.factory.portal.server_process_id - print("AMP SERVER operation == %s received" % (ord(operation))) - print("AMP SERVER arguments: %s" % (amp.loads(arguments))) - return {"result": "Received."} + logger.log_msg("AMP SERVER operation == %s received" % (ord(operation))) + logger.log_msg("AMP SERVER arguments: %s" % (amp.loads(arguments))) if operation == amp.SSTART: # portal start # first, check if server is already running if server_connected: - return {"result": "Server already running at PID={}"} + return _retval(False, + "Server already running at PID={spid}".format(spid=server_pid)) else: - self.start_server(amp.loads(arguments)) - return {"result": "Server started with PID {}.".format(0)} # TODO + spid = self.start_server(amp.loads(arguments)) + return _retval(True, "Server started with PID {spid}.".format(spid=spid)) elif operation == amp.SRELOAD: # reload server if server_connected: - self.reload_server(amp.loads(arguments)) + spid = self.reload_server(amp.loads(arguments)) + return _retval(True, "Server started with PID {spid}.".format(spid=spid)) else: self.start_server(amp.loads(arguments)) + spid = self.start_server(amp.loads(arguments)) + return _retval(True, "Server started with PID {spid}.".format(spid=spid)) + elif operation == amp.SRESET: # reload server + if server_connected: + spid = self.reload_server(amp.loads(arguments)) + return _retval(True, "Server started with PID {spid}.".format(spid=spid)) + else: + self.start_server(amp.loads(arguments)) + spid = self.start_server(amp.loads(arguments)) + return _retval(True, "Server started with PID {spid}.".format(spid=spid)) elif operation == amp.PSHUTD: # portal + server shutdown if server_connected: - self.stop_server(amp.loads(arguments)) + self.stop_server() + return _retval(True, "Server stopped.") self.factory.portal.shutdown(restart=False) else: raise Exception("operation %(op)s not recognized." % {'op': operation}) @@ -257,6 +279,14 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): elif operation == amp.SRELOAD: # server reload self.factory.portal.server_reload(**kwargs) + elif operation == amp.PSYNC: # portal sync + # Server has (re-)connected and wants the session data from portal + sessdata = self.factory.portal.sessions.get_all_sync_data() + self.send_AdminPortal2Server(amp.DUMMYSESSION, + amp.PSYNC, + sessiondata=sessdata) + self.factory.portal.sessions.at_server_connection() + elif operation == amp.SSYNC: # server_session_sync # server wants to save session data to the portal, # maybe because it's about to shut down. diff --git a/evennia/server/sessionhandler.py b/evennia/server/sessionhandler.py index 9862f03421..20e4cd677e 100644 --- a/evennia/server/sessionhandler.py +++ b/evennia/server/sessionhandler.py @@ -59,7 +59,11 @@ SCONN = chr(11) # server portal connection (for bots) PCONNSYNC = chr(12) # portal post-syncing session PDISCONNALL = chr(13) # portal session discnnect all SRELOAD = chr(14) # server reloading (have portal start a new server) - +SSTART = chr(15) # server start (portal must already be running anyway) +PSHUTD = chr(16) # portal (+server) shutdown +SSHUTD = chr(17) # server shutdown +PSTATUS = chr(18) # ping server or portal status +SRESET = chr(19) # server shutdown in reset mode # i18n from django.utils.translation import ugettext as _ @@ -439,10 +443,19 @@ class ServerSessionHandler(SessionHandler): Called by server when reloading. We tell the portal to start a new server instance. """ + self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION, operation=SRELOAD) + + def portal_reset_server(self): + """ + Called by server when reloading. We tell the portal to start a new server instance. + + """ + self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION, operation=SRESET) def portal_shutdown(self): """ - Called by server when shutting down the portal (usually because server is going down too). + 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, @@ -572,8 +585,6 @@ class ServerSessionHandler(SessionHandler): sessiondata=session_data, clean=False) - - def disconnect_all_sessions(self, reason="You have been disconnected."): """ Cleanly disconnect all of the connected sessions. From ef999362c7d1a6b0eca0a3839770b097ece96b86 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 14 Jan 2018 14:17:36 +0100 Subject: [PATCH 089/189] Cleanup of call signatures for reloading --- evennia/server/portal/amp_server.py | 36 +++++++++++++++++------------ 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index 8193ac0663..1163012371 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -86,7 +86,7 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): self.factory.portal.server_twistd_cmd = server_twistd_cmd return process.pid - def stop_server(self, mode='reload'): + def stop_server(self, mode='shutdown'): """ Shut down server in one or more modes. @@ -96,10 +96,8 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): """ if mode == 'reload': self.send_AdminPortal2Server(amp.DUMMYSESSION, amp.SRELOAD) - return self.start_server(self.factory.portal.server_twistd_cmd) if mode == 'reset': self.send_AdminPortal2Server(amp.DUMMYSESSION, amp.SRESET) - return self.start_server(self.factory.portal.server_twistd_cmd) if mode == 'shutdown': self.send_AdminPortal2Server(amp.DUMMYSESSION, amp.SSHUTD) @@ -199,23 +197,23 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): return _retval(True, "Server started with PID {spid}.".format(spid=spid)) elif operation == amp.SRELOAD: # reload server if server_connected: - spid = self.reload_server(amp.loads(arguments)) - return _retval(True, "Server started with PID {spid}.".format(spid=spid)) + self.stop(mode='reload') + spid = self.start_server(amp.loads(arguments)) + return _retval(True, "Server restarted with PID {spid}.".format(spid=spid)) else: - self.start_server(amp.loads(arguments)) spid = self.start_server(amp.loads(arguments)) return _retval(True, "Server started with PID {spid}.".format(spid=spid)) elif operation == amp.SRESET: # reload server if server_connected: - spid = self.reload_server(amp.loads(arguments)) - return _retval(True, "Server started with PID {spid}.".format(spid=spid)) + self.stop_server(mode='reset') + spid = self.start_server(amp.loads(arguments)) + return _retval(True, "Server restarted with PID {spid}.".format(spid=spid)) else: - self.start_server(amp.loads(arguments)) spid = self.start_server(amp.loads(arguments)) return _retval(True, "Server started with PID {spid}.".format(spid=spid)) elif operation == amp.PSHUTD: # portal + server shutdown if server_connected: - self.stop_server() + self.stop_server(mode='shutdown') return _retval(True, "Server stopped.") self.factory.portal.shutdown(restart=False) else: @@ -272,12 +270,20 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): # server orders all sessions to disconnect portal_sessionhandler.server_disconnect_all(reason=kwargs.get("reason")) - elif operation == amp.SSHUTD: # server_shutdown - # the server orders the portal to shut down - self.factory.portal.shutdown(restart=False) - elif operation == amp.SRELOAD: # server reload - self.factory.portal.server_reload(**kwargs) + self.stop_server(mode='reload') + self.start(self.factory.portal.server_twisted_cmd) + + elif operation == amp.SRESET: # server reset + self.stop_server(mode='reset') + self.start(self.factory.portal.server_twisted_cmd) + + elif operation == amp.SSHUTD: # server-only shutdown + self.stop_server(mode='shutdown') + + elif operation == amp.PSHUTD: # full server+server shutdown + self.stop_server(mode='shutdown') + self.factory.portal.shutdown() elif operation == amp.PSYNC: # portal sync # Server has (re-)connected and wants the session data from portal From 5133034b4b5fa78fc3e62554d707da9b16a15382 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 14 Jan 2018 22:53:36 +0100 Subject: [PATCH 090/189] Working start/reload/reset/stop from launcher --- evennia/server/amp_client.py | 6 ++ evennia/server/evennia_launcher.py | 76 +++++++++++++--------- evennia/server/evennia_runner.py | 1 + evennia/server/portal/amp.py | 2 +- evennia/server/portal/amp_server.py | 97 ++++++++++++++++++++++------- evennia/server/server.py | 1 + evennia/utils/logger.py | 9 +++ 7 files changed, 136 insertions(+), 56 deletions(-) diff --git a/evennia/server/amp_client.py b/evennia/server/amp_client.py index bf6b3d9fc8..5c29825d23 100644 --- a/evennia/server/amp_client.py +++ b/evennia/server/amp_client.py @@ -192,15 +192,21 @@ class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol): elif operation == amp.PSYNC: # portal_session_sync # force a resync of sessions from the portal side server_sessionhandler.portal_sessions_sync(kwargs.get("sessiondata")) + elif operation == amp.SRELOAD: # server reload # shut down in reload mode + server_sessionhandler.all_sessions_portal_sync() server_sessionhandler.server.shutdown(mode='reload') + elif operation == amp.SRESET: # shut down in reset mode + server_sessionhandler.all_sessions_portal_sync() server_sessionhandler.server.shutdown(mode='reset') + elif operation == amp.SSHUTD: # server shutdown # shutdown in stop mode server_sessionhandler.server.shutdown(mode='shutdown') + else: raise Exception("operation %(op)s not recognized." % {'op': operation}) return {} diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 806f51b621..c2d29ad961 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -19,7 +19,7 @@ import shutil import importlib from distutils.version import LooseVersion from argparse import ArgumentParser -from subprocess import Popen, check_output, call, CalledProcessError, STDOUT +from subprocess import Popen, check_output, call, CalledProcessError, STDOUT, PIPE try: import cPickle as pickle @@ -88,6 +88,7 @@ SSTART = chr(15) # server start PSHUTD = chr(16) # portal (+server) shutdown SSHUTD = chr(17) # server-only shutdown PSTATUS = chr(18) # ping server or portal status +SRESET = chr(19) # shutdown server in reset mode # requirements PYTHON_MIN = '2.7' @@ -519,13 +520,18 @@ def _get_twistd_cmdline(pprofiler, sprofiler): Compile the command line for starting a Twisted application using the 'twistd' executable. """ - portal_cmd = [TWISTED_BINARY, "--logfile={}".format(PORTAL_LOGFILE), "--python={}".format(PORTAL_PY_FILE)] server_cmd = [TWISTED_BINARY, - "--logfile={}".format(PORTAL_LOGFILE), - "--python={}".format(PORTAL_PY_FILE)] + "--logfile={}".format(SERVER_LOGFILE), + "--python={}".format(SERVER_PY_FILE)] + + if os.name != 'nt': + # PID files only for UNIX + portal_cmd.append("--pidfile={}".format(PORTAL_PIDFILE)) + server_cmd.append("--pidfile={}".format(SERVER_PIDFILE)) + if pprofiler: portal_cmd.extend(["--savestats", "--profiler=cprofiler", @@ -534,6 +540,8 @@ def _get_twistd_cmdline(pprofiler, sprofiler): server_cmd.extend(["--savestats", "--profiler=cprofiler", "--profile={}".format(SPROFILER_LOGFILE)]) + + return portal_cmd, server_cmd @@ -552,7 +560,6 @@ def query_status(repeat=False): reactor.stop() def _errback(fail): - print("status fail: %s", fail) pstatus, sstatus = False, False print("Portal: {}\nServer: {}".format(wmap[pstatus], wmap[sstatus])) reactor.stop() @@ -591,7 +598,7 @@ def wait_for_status(portal_running=True, server_running=True, callback=None, err if errback: errback(prun, srun) else: - print("Timeout.") + print("Connection to Evennia timed out. Try again.") reactor.stop() else: reactor.callLater(rate, wait_for_status, @@ -613,7 +620,7 @@ def wait_for_status(portal_running=True, server_running=True, callback=None, err if errback: errback(portal_running, server_running) else: - print("Timeout.") + print("Connection to Evennia timed out. Try again.") reactor.stop() else: reactor.callLater(rate, wait_for_status, @@ -622,14 +629,13 @@ def wait_for_status(portal_running=True, server_running=True, callback=None, err return send_instruction(PSTATUS, None, _callback, _errback) - - # ------------------------------------------------------------ # # Operational functions # # ------------------------------------------------------------ + def start_evennia(pprofiler=False, sprofiler=False): """ This will start Evennia anew by launching the Evennia Portal (which in turn @@ -638,8 +644,12 @@ def start_evennia(pprofiler=False, sprofiler=False): """ portal_cmd, server_cmd = _get_twistd_cmdline(pprofiler, sprofiler) + def _fail(fail): + print(fail) + reactor.stop() + def _server_started(*args): - print("... Server started.\nEvennia running.", args) + print("... Server started.\nEvennia running.") reactor.stop() def _portal_started(*args): @@ -653,21 +663,22 @@ def start_evennia(pprofiler=False, sprofiler=False): reactor.stop() else: print("Server starting {}...".format("(under cProfile)" if pprofiler else "")) - send_instruction(SSTART, server_cmd, _server_started) + send_instruction(SSTART, server_cmd, _server_started, _fail) def _portal_not_running(fail): print("Portal starting {}...".format("(under cProfile)" if sprofiler else "")) try: - Popen(portal_cmd, env=getenv()) + Popen(portal_cmd, env=getenv(), bufsize=-1) except Exception as e: print(PROCESS_ERROR.format(component="Portal", traceback=e)) + reactor.stop() wait_for_status(True, None, _portal_started) send_instruction(PSTATUS, None, _portal_running, _portal_not_running) reactor.run() -def reload_evennia(sprofiler=False): +def reload_evennia(sprofiler=False, reset=False): """ This will instruct the Portal to reboot the Server component. @@ -675,18 +686,23 @@ def reload_evennia(sprofiler=False): _, server_cmd = _get_twistd_cmdline(False, sprofiler) def _server_restarted(*args): - print("... Server re-started.", args) + print("... Server re-started.") reactor.stop() def _server_reloaded(*args): - print("... Server reloaded.", args) + print("... Server {}.".format("reset" if reset else "reloaded")) reactor.stop() + def _server_not_running(*args): + send_instruction(SSTART, server_cmd) + wait_for_status(True, True, _server_reloaded) + def _portal_running(response): _, srun, _, _ = _parse_status(response) if srun: - print("Server reloading ...") - send_instruction(SRELOAD, server_cmd, _server_reloaded) + print("Server {}...".format("resetting" if reset else "reloading")) + send_instruction(SRESET if reset else SRELOAD, server_cmd) + wait_for_status(True, False, _server_not_running) else: print("Server down. Re-starting ...") send_instruction(SSTART, server_cmd, _server_restarted) @@ -710,7 +726,7 @@ def stop_evennia(): reactor.stop() def _server_stopped(*args): - print("... Server stopped.\nStopping Portal ...", args) + print("... Server stopped.\nStopping Portal ...") send_instruction(PSHUTD, {}) wait_for_status(False, None, _portal_stopped) @@ -718,7 +734,8 @@ def stop_evennia(): prun, srun, ppid, spid = _parse_status(response) if srun: print("Server stopping ...") - send_instruction(SSHUTD, {}, _server_stopped) + send_instruction(SSHUTD, {}) + wait_for_status(True, False, _server_stopped) else: print("Server already stopped.\nStopping Portal ...") send_instruction(PSHUTD, {}) @@ -726,6 +743,7 @@ def stop_evennia(): def _portal_not_running(fail): print("Evennia is not running.") + reactor.stop() send_instruction(PSTATUS, None, _portal_running, _portal_not_running) reactor.run() @@ -741,8 +759,8 @@ def stop_server_only(): reactor.stop() def _portal_running(response): - _, server_running = [stat == 'RUNNING' for stat in response['status'].split("|")] - if server_running: + _, srun, _, _ = _parse_status(response) + if srun: print("Server stopping ...") send_instruction(SSHUTD, {}) wait_for_status(True, False, _server_stopped) @@ -1239,7 +1257,7 @@ def init_game_directory(path, check_db=True): AMP_INTERFACE = settings.AMP_INTERFACE SERVER_PY_FILE = os.path.join(EVENNIA_LIB, "server", "server.py") - PORTAL_PY_FILE = os.path.join(EVENNIA_LIB, "portal", "portal", "portal.py") + PORTAL_PY_FILE = os.path.join(EVENNIA_LIB, "server", "portal", "portal.py") SERVER_PIDFILE = os.path.join(GAMEDIR, SERVERDIR, "server.pid") PORTAL_PIDFILE = os.path.join(GAMEDIR, SERVERDIR, "portal.pid") @@ -1602,9 +1620,6 @@ def main(): "service", metavar="component", nargs='?', default="all", help=("Which component to operate on: " "'server', 'portal' or 'all' (default if not set).")) - parser.add_argument( - "--status", action='store_true', dest='get_status', - default=None, help='Get current server status.') parser.epilog = ( "Common usage: evennia start|stop|reload. Django-admin database commands:" "evennia migration|flush|shell|dbshell (see the django documentation for more " @@ -1656,11 +1671,6 @@ def main(): print(ERROR_INITSETTINGS) sys.exit() - if args.get_status: - init_game_directory(CURRENT_DIR, check_db=True) - query_status() - sys.exit() - if args.dummyrunner: # launch the dummy runner init_game_directory(CURRENT_DIR, check_db=True) @@ -1673,13 +1683,17 @@ def main(): # launch menu for operation init_game_directory(CURRENT_DIR, check_db=True) run_menu() - elif option in ('sstart', 'sreload', 'sstop', 'ssstop', 'start', 'reload', 'stop'): + elif option in ('status', 'sstart', 'sreload', 'sreset', 'sstop', 'ssstop', 'start', 'reload', 'stop'): # operate the server directly init_game_directory(CURRENT_DIR, check_db=True) + if option == "status": + query_status() if option == "sstart": start_evennia(False, args.profiler) elif option == 'sreload': reload_evennia(args.profiler) + elif option == 'sreset': + reload_evennia(args.profiler, reset=True) elif option == 'sstop': stop_evennia() elif option == 'ssstop': diff --git a/evennia/server/evennia_runner.py b/evennia/server/evennia_runner.py index 27fc187211..d920c181e8 100644 --- a/evennia/server/evennia_runner.py +++ b/evennia/server/evennia_runner.py @@ -346,6 +346,7 @@ def main(): del portal_argv[-2] # Start processes + print("server_argv:", server_argv, portal_argv) start_services(server_argv, portal_argv, doexit=args.doexit) diff --git a/evennia/server/portal/amp.py b/evennia/server/portal/amp.py index 8f08b46b0f..6da13905c2 100644 --- a/evennia/server/portal/amp.py +++ b/evennia/server/portal/amp.py @@ -89,8 +89,8 @@ def catch_traceback(func): if not _LOGGER: from evennia.utils import logger as _LOGGER _LOGGER.log_trace() - print("error", err) raise # make sure the error is visible on the other side of the connection too + print(err) return decorator diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index 1163012371..6b746c10a8 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -8,7 +8,7 @@ import os import sys from twisted.internet import protocol from evennia.server.portal import amp -from subprocess import Popen +from subprocess import Popen, STDOUT, PIPE from evennia.utils import logger @@ -48,6 +48,8 @@ class AMPServerFactory(protocol.ServerFactory): self.portal = portal self.protocol = AMPServerProtocol self.broadcasts = [] + self.server_connection = None + self.disconnect_callbacks = {} def buildProtocol(self, addr): """ @@ -80,12 +82,45 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): """ # start the Server - process = Popen(server_twistd_cmd, env=getenv()) - # store the pid for future reference + try: + process = Popen(server_twistd_cmd, env=getenv(), bufsize=-1, stdout=PIPE, stderr=STDOUT) + except Exception: + self.factory.portal.server_process_id = None + logger.log_trace() + return 0 + # there is a short window before the server logger is up where we must + # catch the stdout of the Server or eventual tracebacks will be lost. + with process.stdout as out: + logger.log_server(out.read()) + + # store the pid and launch argument for future reference self.factory.portal.server_process_id = process.pid self.factory.portal.server_twistd_cmd = server_twistd_cmd return process.pid + def connectionLost(self, reason): + """ + Set up a simple callback mechanism to let the amp-server wait for a connection to close. + + """ + callback, args, kwargs = self.factory.disconnect_callbacks.pop(self, (None, None, None)) + if callback: + try: + callback(*args, **kwargs) + except Exception: + logger.log_trace() + + 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 stop_server(self, mode='shutdown'): """ Shut down server in one or more modes. @@ -95,11 +130,11 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): """ if mode == 'reload': - self.send_AdminPortal2Server(amp.DUMMYSESSION, amp.SRELOAD) - if mode == 'reset': - self.send_AdminPortal2Server(amp.DUMMYSESSION, amp.SRESET) - if mode == 'shutdown': - self.send_AdminPortal2Server(amp.DUMMYSESSION, amp.SSHUTD) + 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) # sending amp data @@ -148,8 +183,8 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): """ # check if the server is connected - server_connected = any(1 for prtcl in self.factory.broadcasts - if prtcl is not self and prtcl.transport.connected) + server_connected = (self.factory.server_connection and + self.factory.server_connection.transport.connected) server_pid = self.factory.portal.server_process_id portal_pid = os.getpid() @@ -180,14 +215,14 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): def _retval(success, txt): return {"result": amp.dumps((success, txt))} - server_connected = any(1 for prtcl in self.factory.broadcasts - if prtcl is not self and prtcl.transport.connected) + server_connected = (self.factory.server_connection and + self.factory.server_connection.transport.connected) server_pid = self.factory.portal.server_process_id logger.log_msg("AMP SERVER operation == %s received" % (ord(operation))) logger.log_msg("AMP SERVER arguments: %s" % (amp.loads(arguments))) - if operation == amp.SSTART: # portal start + if operation == amp.SSTART: # portal start #15 # first, check if server is already running if server_connected: return _retval(False, @@ -195,27 +230,36 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): else: spid = self.start_server(amp.loads(arguments)) return _retval(True, "Server started with PID {spid}.".format(spid=spid)) - elif operation == amp.SRELOAD: # reload server + + elif operation == amp.SRELOAD: # reload server #14 if server_connected: - self.stop(mode='reload') - spid = self.start_server(amp.loads(arguments)) - return _retval(True, "Server restarted with PID {spid}.".format(spid=spid)) + # don't restart until the server connection goes down + self.stop_server(mode='reload') else: spid = self.start_server(amp.loads(arguments)) return _retval(True, "Server started with PID {spid}.".format(spid=spid)) - elif operation == amp.SRESET: # reload server + + elif operation == amp.SRESET: # reload server #19 if server_connected: self.stop_server(mode='reset') - spid = self.start_server(amp.loads(arguments)) return _retval(True, "Server restarted with PID {spid}.".format(spid=spid)) else: spid = self.start_server(amp.loads(arguments)) return _retval(True, "Server started with PID {spid}.".format(spid=spid)) - elif operation == amp.PSHUTD: # portal + server shutdown + + elif operation == amp.SSHUTD: # server-only shutdown #17 + if server_connected: + self.stop_server(mode='shutdown') + return _retval(True, "Server stopped.") + else: + return _retval(False, "Server not running") + + elif operation == amp.PSHUTD: # portal + server shutdown #16 if server_connected: self.stop_server(mode='shutdown') return _retval(True, "Server stopped.") self.factory.portal.shutdown(restart=False) + else: raise Exception("operation %(op)s not recognized." % {'op': operation}) # fallback @@ -254,6 +298,9 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): operation = kwargs.pop("operation") portal_sessionhandler = self.factory.portal.sessions + # store this transport since we know it comes from the Server + self.factory.server_connection = self + if operation == amp.SLOGIN: # server_session_login # a session has authenticated; sync it. session = portal_sessionhandler.get(sessid) @@ -271,12 +318,14 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): portal_sessionhandler.server_disconnect_all(reason=kwargs.get("reason")) elif operation == amp.SRELOAD: # server reload - self.stop_server(mode='reload') - self.start(self.factory.portal.server_twisted_cmd) + self.factory.server_connection.wait_for_disconnect( + self.start_server, self.factory.portal.server_twisted_cmd) + self.stop_server(mode='reload') elif operation == amp.SRESET: # server reset - self.stop_server(mode='reset') - self.start(self.factory.portal.server_twisted_cmd) + self.factory.server_connection.wait_for_disconnect( + self.start_server, self.factory.portal.server_twisted_cmd) + self.stop_server(mode='reset') elif operation == amp.SSHUTD: # server-only shutdown self.stop_server(mode='shutdown') diff --git a/evennia/server/server.py b/evennia/server/server.py index fab6d9675f..070ddd4695 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -366,6 +366,7 @@ class Evennia(object): once - in both cases the reactor is dead/stopping already. """ + print("server.shutdown mode=", mode) if _reactor_stopping and hasattr(self, "shutdown_complete"): # this means we have already passed through this method # once; we don't need to run the shutdown procedure again. diff --git a/evennia/utils/logger.py b/evennia/utils/logger.py index b248278ce1..a8bcfb00e7 100644 --- a/evennia/utils/logger.py +++ b/evennia/utils/logger.py @@ -124,6 +124,15 @@ def log_err(errmsg): log_errmsg = log_err +def log_server(servermsg): + try: + servermsg = str(servermsg) + except Exception as e: + servermsg = str(e) + for line in servermsg.splitlines(): + log_msg('[SRV] %s' % line) + + def log_warn(warnmsg): """ Prints/logs any warnings that aren't critical but should be noted. From 3bec3a3512718b010d27f0871d2d1e74f493df68 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 14 Jan 2018 23:11:59 +0100 Subject: [PATCH 091/189] Support in-game server-control commands --- evennia/commands/default/system.py | 1 - evennia/server/amp_client.py | 2 +- evennia/server/portal/amp_server.py | 32 +++++++++++++++-------------- evennia/server/portal/portal.py | 1 + evennia/server/sessionhandler.py | 2 +- 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/evennia/commands/default/system.py b/evennia/commands/default/system.py index 7933245ae2..7bd092bad5 100644 --- a/evennia/commands/default/system.py +++ b/evennia/commands/default/system.py @@ -119,7 +119,6 @@ class CmdShutdown(COMMAND_DEFAULT_CLASS): announcement += "%s\n" % self.args logger.log_info('Server shutdown by %s.' % self.caller.name) SESSIONS.announce_all(announcement) - SESSIONS.server.shutdown(mode='shutdown') SESSIONS.portal_shutdown() diff --git a/evennia/server/amp_client.py b/evennia/server/amp_client.py index 5c29825d23..b490d8393b 100644 --- a/evennia/server/amp_client.py +++ b/evennia/server/amp_client.py @@ -71,7 +71,7 @@ class AMPClientFactory(protocol.ReconnectingClientFactory): reason (str): Eventual text describing why connection was lost. """ - logger.log_info("Server lost connection to the Portal. Reconnecting ...") + logger.log_info("Server disconnected from the portal.") protocol.ReconnectingClientFactory.clientConnectionLost(self, connector, reason) def clientConnectionFailed(self, connector, reason): diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index 6b746c10a8..024454fc7a 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -72,6 +72,19 @@ 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. + + """ + callback, args, kwargs = self.factory.disconnect_callbacks.pop(self, (None, None, None)) + if callback: + try: + callback(*args, **kwargs) + except Exception: + logger.log_trace() + def start_server(self, server_twistd_cmd): """ (Re-)Launch the Evennia server. @@ -98,18 +111,6 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): self.factory.portal.server_twistd_cmd = server_twistd_cmd return process.pid - def connectionLost(self, reason): - """ - Set up a simple callback mechanism to let the amp-server wait for a connection to close. - - """ - callback, args, kwargs = self.factory.disconnect_callbacks.pop(self, (None, None, None)) - if callback: - try: - callback(*args, **kwargs) - except Exception: - logger.log_trace() - def wait_for_disconnect(self, callback, *args, **kwargs): """ Add a callback for when this connection is lost. @@ -319,20 +320,21 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): elif operation == amp.SRELOAD: # server reload self.factory.server_connection.wait_for_disconnect( - self.start_server, self.factory.portal.server_twisted_cmd) + 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_twisted_cmd) + 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') - self.factory.portal.shutdown() elif operation == amp.PSYNC: # portal sync # Server has (re-)connected and wants the session data from portal diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index 5d4c9aae19..5bdd65d39e 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -148,6 +148,7 @@ class Portal(object): case it always needs to be restarted manually. """ + print("portal.shutdown: restart=", restart) if _reactor_stopping and hasattr(self, "shutdown_complete"): # we get here due to us calling reactor.stop below. No need # to do the shutdown procedure again. diff --git a/evennia/server/sessionhandler.py b/evennia/server/sessionhandler.py index 20e4cd677e..e76efd7405 100644 --- a/evennia/server/sessionhandler.py +++ b/evennia/server/sessionhandler.py @@ -459,7 +459,7 @@ class ServerSessionHandler(SessionHandler): """ self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION, - operation=SSHUTD) + operation=PSHUTD) def login(self, session, account, force=False, testmode=False): """ From 44590627c592c06130c54f57ff90b67e60022693 Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Mon, 15 Jan 2018 13:50:08 -0500 Subject: [PATCH 092/189] `@locate` alias of CmdFind shows location of find If using `@locate` alias and only one object is found and the found object has a location, display that information. --- evennia/commands/default/building.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 445ec082a9..9a7caf8567 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2315,6 +2315,8 @@ class CmdFind(COMMAND_DEFAULT_CLASS): else: result = result[0] string += "\n|g %s - %s|n" % (result.get_display_name(caller), result.path) + if self.cmdstring == "@locate" and not is_account and result.location: + string += " Location: {}".format(result.location.get_display_name(caller)) else: # Not an account/dbref search but a wider search; build a queryset. # Searchs for key and aliases @@ -2350,6 +2352,8 @@ class CmdFind(COMMAND_DEFAULT_CLASS): else: string = "|wOne Match|n(#%i-#%i%s):" % (low, high, restrictions) string += "\n |g%s - %s|n" % (results[0].get_display_name(caller), results[0].path) + if self.cmdstring == "@locate" and nresults == 1 and results[0].location: + string += " Location: {}".format(results[0].location.get_display_name(caller)) else: string = "|wMatch|n(#%i-#%i%s):" % (low, high, restrictions) string += "\n |RNo matches found for '%s'|n" % searchstring From 96508d8a2555aa5cfb2928116184fb46f886e520 Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Mon, 15 Jan 2018 13:53:19 -0500 Subject: [PATCH 093/189] Typo fix in CmdWhisper help docstring --- evennia/commands/default/general.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index 612a798167..195ad9a7d1 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -439,7 +439,7 @@ class CmdWhisper(COMMAND_DEFAULT_CLASS): Usage: whisper = - whisper , = , = Talk privately to one or more characters in your current location, without others in the room being informed. From 27afb3240d10fce830beb41ac6f3847929bf8250 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 16 Jan 2018 00:11:46 +0100 Subject: [PATCH 094/189] Rework launcher with persistent connection for better server status reporting. Still some errors. --- evennia/server/amp_client.py | 25 ++++- evennia/server/evennia_launcher.py | 146 ++++++++++++++++++--------- evennia/server/portal/amp.py | 12 +-- evennia/server/portal/amp_server.py | 151 ++++++++++++++++++++-------- 4 files changed, 238 insertions(+), 96 deletions(-) diff --git a/evennia/server/amp_client.py b/evennia/server/amp_client.py index b490d8393b..70ec00a3a7 100644 --- a/evennia/server/amp_client.py +++ b/evennia/server/amp_client.py @@ -106,6 +106,26 @@ class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol): # back with the Server side self.send_AdminServer2Portal(amp.DUMMYSESSION, operation=amp.PSYNC) + 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 @@ -116,7 +136,7 @@ class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol): kwargs (any, optiona): Extra data. """ - return self.data_out(amp.MsgServer2Portal, session.sessid, **kwargs) + return self.data_to_portal(amp.MsgServer2Portal, session.sessid, **kwargs) def send_AdminServer2Portal(self, session, operation="", **kwargs): """ @@ -131,7 +151,8 @@ class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol): kwargs (dict, optional): Data going into the adminstrative. """ - return self.data_out(amp.AdminServer2Portal, session.sessid, operation=operation, **kwargs) + return self.data_to_portal(amp.AdminServer2Portal, session.sessid, + operation=operation, **kwargs) # receiving AMP data diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index c2d29ad961..b90c43b1f0 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -19,7 +19,7 @@ import shutil import importlib from distutils.version import LooseVersion from argparse import ArgumentParser -from subprocess import Popen, check_output, call, CalledProcessError, STDOUT, PIPE +from subprocess import Popen, check_output, call, CalledProcessError, STDOUT try: import cPickle as pickle @@ -57,9 +57,6 @@ CURRENT_DIR = os.getcwd() GAMEDIR = CURRENT_DIR # Operational setup -AMP_PORT = None -AMP_HOST = None -AMP_INTERFACE = None SERVER_LOGFILE = None PORTAL_LOGFILE = None @@ -81,6 +78,9 @@ ENFORCED_SETTING = False # communication constants +AMP_PORT = None +AMP_HOST = None +AMP_INTERFACE = None AMP_CONNECTION = None SRELOAD = chr(14) # server reloading (have portal start a new server) @@ -461,7 +461,35 @@ class MsgLauncher2Portal(amp.Command): arguments = [('operation', amp.String()), ('arguments', amp.String())] errors = {Exception: 'EXCEPTION'} - response = [('result', amp.String())] + response = [] + + +class AMPLauncherProtocol(amp.AMP): + """ + Defines callbacks to the launcher + + """ + def __init__(self): + self.on_status = [] + + def wait_for_status(self, callback): + """ + Register a waiter for a status return. + + """ + self.on_status.append(callback) + + @MsgStatus.responder + def receive_status_from_portal(self, status): + """ + Get a status signal from portal - fire callbacks + + """ + status = pickle.loads(status) + for callback in self.on_status: + callback(status) + self.on_status = [] + return {"status": ""} def send_instruction(operation, arguments, callback=None, errback=None): @@ -475,39 +503,54 @@ def send_instruction(operation, arguments, callback=None, errback=None): print(ERROR_AMP_UNCONFIGURED) sys.exit() + def _timeout(*args): + print("Client timed out.") + reactor.stop() + + def _callback(result): + if callback: + callback(result) + # prot.transport.loseConnection() + + def _errback(fail): + if errback: + errback(fail) + # prot.transport.loseConnection() + def _on_connect(prot): """ This fires with the protocol when connection is established. We - immediately send off the instruction then shut down. + immediately send off the instruction """ - def _callback(result): - if callback: - callback(result) - prot.transport.loseConnection() - - def _errback(fail): - if errback: - errback(fail) - prot.transport.loseConnection() - - if operation == PSTATUS: - prot.callRemote(MsgStatus, status="").addCallbacks(_callback, _errback) - else: - prot.callRemote( - MsgLauncher2Portal, - operation=operation, - arguments=pickle.dumps(arguments, pickle.HIGHEST_PROTOCOL)).addCallbacks( - _callback, _errback) + global AMP_CONNECTION + AMP_CONNECTION = prot + _send() def _on_connect_fail(fail): "This is called if portal is not reachable." errback(fail) - point = endpoints.TCP4ClientEndpoint(reactor, AMP_HOST, AMP_PORT) - deferred = endpoints.connectProtocol(point, amp.AMP()) - deferred.addCallbacks(_on_connect, _on_connect_fail) - return deferred + def _send(): + if operation == PSTATUS: + return AMP_CONNECTION.callRemote(MsgStatus, status="").addCallbacks(_callback, _errback) + else: + return AMP_CONNECTION.callRemote( + MsgLauncher2Portal, + operation=operation, + arguments=pickle.dumps(arguments, pickle.HIGHEST_PROTOCOL)).addCallbacks( + _callback, _errback) + + if AMP_CONNECTION: + # already connected - send right away + _send() + else: + # we must connect first, send once connected + point = endpoints.TCP4ClientEndpoint(reactor, AMP_HOST, AMP_PORT) + deferred = endpoints.connectProtocol(point, AMPLauncherProtocol()) + deferred.addCallbacks(_on_connect, _on_connect_fail) + if not reactor.running: + reactor.run() def _parse_status(response): @@ -541,7 +584,6 @@ def _get_twistd_cmdline(pprofiler, sprofiler): "--profiler=cprofiler", "--profile={}".format(SPROFILER_LOGFILE)]) - return portal_cmd, server_cmd @@ -565,7 +607,16 @@ def query_status(repeat=False): reactor.stop() send_instruction(PSTATUS, None, _callback, _errback) - reactor.run() + + +def wait_for_status_reply(callback): + """ + Wait for an explicit STATUS signal to be sent back from Evennia. + """ + if AMP_CONNECTION: + AMP_CONNECTION.wait_for_status(callback) + else: + print("No Evennia connection established.") def wait_for_status(portal_running=True, server_running=True, callback=None, errback=None, @@ -587,7 +638,7 @@ def wait_for_status(portal_running=True, server_running=True, callback=None, err def _callback(response): prun, srun, _, _ = _parse_status(response) if ((portal_running is None or prun == portal_running) and - (server_running is None or srun == server_running)): + (server_running is None or srun == server_running)): # the correct state was achieved if callback: callback(prun, srun) @@ -653,7 +704,8 @@ def start_evennia(pprofiler=False, sprofiler=False): reactor.stop() def _portal_started(*args): - send_instruction(SSTART, server_cmd, _server_started) + wait_for_status_reply(_server_started) + send_instruction(SSTART, server_cmd) def _portal_running(response): prun, srun, ppid, spid = _parse_status(response) @@ -675,12 +727,14 @@ def start_evennia(pprofiler=False, sprofiler=False): wait_for_status(True, None, _portal_started) send_instruction(PSTATUS, None, _portal_running, _portal_not_running) - reactor.run() def reload_evennia(sprofiler=False, reset=False): """ - This will instruct the Portal to reboot the Server component. + This will instruct the Portal to reboot the Server component. We + do this manually by telling the server to shutdown (in reload mode) + and wait for the portal to report back, at which point we start the + server again. This way we control the process exactly. """ _, server_cmd = _get_twistd_cmdline(False, sprofiler) @@ -689,23 +743,24 @@ def reload_evennia(sprofiler=False, reset=False): print("... Server re-started.") reactor.stop() - def _server_reloaded(*args): - print("... Server {}.".format("reset" if reset else "reloaded")) + def _server_reloaded(status): + print("{} ... Server {}.".format(status, "reset" if reset else "reloaded")) reactor.stop() - def _server_not_running(*args): + def _server_stopped(status): + wait_for_status_reply(_server_reloaded) send_instruction(SSTART, server_cmd) - wait_for_status(True, True, _server_reloaded) def _portal_running(response): _, srun, _, _ = _parse_status(response) if srun: print("Server {}...".format("resetting" if reset else "reloading")) - send_instruction(SRESET if reset else SRELOAD, server_cmd) - wait_for_status(True, False, _server_not_running) + wait_for_status_reply(_server_stopped) + send_instruction(SRESET if reset else SRELOAD, {}) else: print("Server down. Re-starting ...") - send_instruction(SSTART, server_cmd, _server_restarted) + wait_for_status_reply(_server_restarted) + send_instruction(SSTART, server_cmd) def _portal_not_running(fail): print("Evennia not running. Starting from scratch ...") @@ -713,7 +768,6 @@ def reload_evennia(sprofiler=False, reset=False): # get portal status send_instruction(PSTATUS, None, _portal_running, _portal_not_running) - reactor.run() def stop_evennia(): @@ -735,18 +789,17 @@ def stop_evennia(): if srun: print("Server stopping ...") send_instruction(SSHUTD, {}) - wait_for_status(True, False, _server_stopped) + wait_for_status_reply(_server_stopped) else: print("Server already stopped.\nStopping Portal ...") send_instruction(PSHUTD, {}) - wait_for_status(False, False, _portal_stopped) + wait_for_status(False, None, _portal_stopped) def _portal_not_running(fail): print("Evennia is not running.") reactor.stop() send_instruction(PSTATUS, None, _portal_running, _portal_not_running) - reactor.run() def stop_server_only(): @@ -762,8 +815,8 @@ def stop_server_only(): _, srun, _, _ = _parse_status(response) if srun: print("Server stopping ...") + wait_for_status_reply(_server_stopped) send_instruction(SSHUTD, {}) - wait_for_status(True, False, _server_stopped) else: print("Server is not running.") @@ -771,7 +824,6 @@ def stop_server_only(): print("Evennia is not running.") send_instruction(PSTATUS, None, _portal_running, _portal_not_running) - reactor.run() def evennia_version(): diff --git a/evennia/server/portal/amp.py b/evennia/server/portal/amp.py index 6da13905c2..2c1d28bc1f 100644 --- a/evennia/server/portal/amp.py +++ b/evennia/server/portal/amp.py @@ -155,7 +155,7 @@ class MsgLauncher2Portal(amp.Command): arguments = [('operation', amp.String()), ('arguments', amp.String())] errors = {Exception: 'EXCEPTION'} - response = [('result', amp.String())] + response = [] class MsgPortal2Server(amp.Command): @@ -335,9 +335,9 @@ class AMPMultiConnectionProtocol(amp.AMP): """ return loads(packed_data) - def data_out(self, command, sessid, **kwargs): + def broadcast(self, command, sessid, **kwargs): """ - Send data across the wire. Always use this to send. + Send data across the wire to all connections. Args: command (AMP Command): A protocol send command. @@ -353,9 +353,9 @@ class AMPMultiConnectionProtocol(amp.AMP): """ deferreds = [] for protcl in self.factory.broadcasts: - deferreds.append(protcl.callRemote(command, - packed_data=dumps((sessid, kwargs))).addErrback( - self.errback, command.key)) + deferreds.append(protcl.callRemote(command, **kwargs).addErrback( + self.errback, command.key)) + return DeferredList(deferreds) # generic function send/recvs diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index 024454fc7a..8e49bca263 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -49,7 +49,9 @@ class AMPServerFactory(protocol.ServerFactory): self.protocol = AMPServerProtocol self.broadcasts = [] self.server_connection = None + self.launcher_connection = None self.disconnect_callbacks = {} + self.server_connect_callbacks = [] def buildProtocol(self, addr): """ @@ -72,12 +74,18 @@ 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 + 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: @@ -85,6 +93,45 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): 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) + server_pid = self.factory.portal.server_process_id + portal_pid = os.getpid() + return (True, server_connected, portal_pid, server_pid) + + 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. @@ -122,6 +169,17 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): """ 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. @@ -139,6 +197,17 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): # 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. @@ -151,7 +220,7 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): deferred (Deferred): Asynchronous return. """ - return self.data_out(amp.MsgPortal2Server, session.sessid, **kwargs) + return self.data_to_server(amp.MsgPortal2Server, session.sessid, **kwargs) def send_AdminPortal2Server(self, session, operation="", **kwargs): """ @@ -166,7 +235,8 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): data (str or dict, optional): Data used in the administrative operation. """ - return self.data_out(amp.AdminPortal2Server, session.sessid, operation=operation, **kwargs) + return self.data_to_server(amp.AdminPortal2Server, session.sessid, + operation=operation, **kwargs) # receive amp data @@ -183,16 +253,7 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): (portal_running, server_running, portal_pid, server_pid). """ - # check if the server is connected - server_connected = (self.factory.server_connection and - self.factory.server_connection.transport.connected) - server_pid = self.factory.portal.server_process_id - portal_pid = os.getpid() - - if server_connected: - return {"status": amp.dumps((True, True, portal_pid, server_pid))} - else: - return {"status": amp.dumps((True, False, portal_pid, server_pid))} + return {"status": amp.dumps(self.get_status())} @amp.MsgLauncher2Portal.responder @amp.catch_traceback @@ -213,58 +274,55 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): launcher. It can obviously only accessed when the Portal is already up and running. """ - def _retval(success, txt): - return {"result": amp.dumps((success, txt))} + self.factory.launcher_connection = self - server_connected = (self.factory.server_connection and - self.factory.server_connection.transport.connected) - server_pid = self.factory.portal.server_process_id + _, server_connected, _, _ = self.get_status() logger.log_msg("AMP SERVER operation == %s received" % (ord(operation))) logger.log_msg("AMP SERVER arguments: %s" % (amp.loads(arguments))) if operation == amp.SSTART: # portal start #15 # first, check if server is already running - if server_connected: - return _retval(False, - "Server already running at PID={spid}".format(spid=server_pid)) - else: - spid = self.start_server(amp.loads(arguments)) - return _retval(True, "Server started with PID {spid}.".format(spid=spid)) + 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: - # don't restart until the server connection goes down + # 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: - spid = self.start_server(amp.loads(arguments)) - return _retval(True, "Server started with PID {spid}.".format(spid=spid)) + 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') - return _retval(True, "Server restarted with PID {spid}.".format(spid=spid)) else: - spid = self.start_server(amp.loads(arguments)) - return _retval(True, "Server started with PID {spid}.".format(spid=spid)) + 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') - return _retval(True, "Server stopped.") - else: - return _retval(False, "Server not running") elif operation == amp.PSHUTD: # portal + server shutdown #16 if server_connected: - self.stop_server(mode='shutdown') - return _retval(True, "Server stopped.") - self.factory.portal.shutdown(restart=False) + 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}) - # fallback - return {"result": ""} + + return {} @amp.MsgServer2Portal.responder @amp.catch_traceback @@ -295,13 +353,12 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): 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 - # store this transport since we know it comes from the Server - self.factory.server_connection = self - if operation == amp.SLOGIN: # server_session_login # a session has authenticated; sync it. session = portal_sessionhandler.get(sessid) @@ -344,11 +401,23 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): sessiondata=sessdata) self.factory.portal.sessions.at_server_connection() + print("Portal PSYNC: %s" % self.factory.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 From 76f27f9bc2e7387478890eeac41b2c9cf653e8ee Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 17 Jan 2018 00:42:40 +0100 Subject: [PATCH 095/189] Add proper status reporting and stability fixes --- evennia/server/amp_client.py | 3 +- evennia/server/evennia_launcher.py | 131 +++++++++++++++++++++++----- evennia/server/portal/amp.py | 5 +- evennia/server/portal/amp_server.py | 13 +-- evennia/server/portal/portal.py | 43 +++++---- evennia/server/server.py | 66 ++++++-------- 6 files changed, 172 insertions(+), 89 deletions(-) diff --git a/evennia/server/amp_client.py b/evennia/server/amp_client.py index 70ec00a3a7..603924190a 100644 --- a/evennia/server/amp_client.py +++ b/evennia/server/amp_client.py @@ -101,10 +101,11 @@ class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol): 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 - self.send_AdminServer2Portal(amp.DUMMYSESSION, operation=amp.PSYNC) + self.send_AdminServer2Portal(amp.DUMMYSESSION, operation=amp.PSYNC, info_dict=info_dict) def data_to_portal(self, command, sessid, **kwargs): """ diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index b90c43b1f0..8056d83887 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -434,6 +434,66 @@ PROCESS_ERROR = \ {component} process error: {traceback}. """ +PORTAL_INFO = \ + """{servername} Portal {version} + external ports: + {telnet} + {telnet_ssl} + {ssh} + {webserver_proxy} + {webclient} + internal_ports (to Server): + {amp} + {webserver_internal} +""" + + +SERVER_INFO = \ + """{servername} Server {version} + internal ports (to Portal): + {amp} + {webserver} + {irc_rss} + {info} + {errors}""" + + +# Info formatting + +def print_info(portal_info_dict, server_info_dict): + """ + Format info dicts from the Portal/Server for display + + """ + ind = " " * 7 + + def _prepare_dict(dct): + out = {} + for key, value in dct.iteritems(): + if isinstance(value, list): + value = "\n{}".format(ind).join(value) + out[key] = value + return out + + def _strip_empty_lines(string): + return "\n".join(line for line in string.split("\n") if line.strip()) + + pstr, sstr = "", "" + if portal_info_dict: + pdict = _prepare_dict(portal_info_dict) + pstr = _strip_empty_lines(PORTAL_INFO.format(**pdict)) + + if server_info_dict: + sdict = _prepare_dict(server_info_dict) + sstr = _strip_empty_lines(SERVER_INFO.format(**sdict)) + + info = pstr + ("\n\n" + sstr if sstr else "") + maxwidth = max(len(line) for line in info.split("\n")) + top_border = "-" * (maxwidth - 11) + " Evennia " + "--" + border = "-" * (maxwidth + 1) + print(top_border + "\n" + info + '\n' + border) + + # ------------------------------------------------------------ # # Protocol Evennia launcher - Portal/Server communication @@ -482,13 +542,17 @@ class AMPLauncherProtocol(amp.AMP): @MsgStatus.responder def receive_status_from_portal(self, status): """ - Get a status signal from portal - fire callbacks + Get a status signal from portal - fire next queued + callback """ - status = pickle.loads(status) - for callback in self.on_status: + try: + callback = self.on_status.pop() + except IndexError: + pass + else: + status = pickle.loads(status) callback(status) - self.on_status = [] return {"status": ""} @@ -503,10 +567,6 @@ def send_instruction(operation, arguments, callback=None, errback=None): print(ERROR_AMP_UNCONFIGURED) sys.exit() - def _timeout(*args): - print("Client timed out.") - reactor.stop() - def _callback(result): if callback: callback(result) @@ -587,7 +647,7 @@ def _get_twistd_cmdline(pprofiler, sprofiler): return portal_cmd, server_cmd -def query_status(repeat=False): +def query_status(callback=None): """ Send status ping to portal @@ -596,10 +656,13 @@ def query_status(repeat=False): False: "NOT RUNNING"} def _callback(response): - pstatus, sstatus, ppid, spid = _parse_status(response) - print("Portal: {} (pid {})\nServer: {} (pid {})".format( - wmap[pstatus], ppid, wmap[sstatus], spid)) - reactor.stop() + if callback: + callback(response) + else: + pstatus, sstatus, ppid, spid, pinfo, sinfo = _parse_status(response) + print("Portal: {} (pid {})\nServer: {} (pid {})".format( + wmap[pstatus], ppid, wmap[sstatus], spid)) + reactor.stop() def _errback(fail): pstatus, sstatus = False, False @@ -636,7 +699,7 @@ def wait_for_status(portal_running=True, server_running=True, callback=None, err retries (int): How many times to retry before timing out and calling `errback`. """ def _callback(response): - prun, srun, _, _ = _parse_status(response) + prun, srun, _, _, _, _ = _parse_status(response) if ((portal_running is None or prun == portal_running) and (server_running is None or srun == server_running)): # the correct state was achieved @@ -699,8 +762,11 @@ def start_evennia(pprofiler=False, sprofiler=False): print(fail) reactor.stop() - def _server_started(*args): + def _server_started(response): print("... Server started.\nEvennia running.") + if response: + _, _, _, _, pinfo, sinfo = response + print_info(pinfo, sinfo) reactor.stop() def _portal_started(*args): @@ -708,7 +774,7 @@ def start_evennia(pprofiler=False, sprofiler=False): send_instruction(SSTART, server_cmd) def _portal_running(response): - prun, srun, ppid, spid = _parse_status(response) + prun, srun, ppid, spid, _, _ = _parse_status(response) print("Portal is already running as process {pid}. Not restarted.".format(pid=ppid)) if srun: print("Server is already running as process {pid}. Not restarted.".format(pid=spid)) @@ -744,7 +810,7 @@ def reload_evennia(sprofiler=False, reset=False): reactor.stop() def _server_reloaded(status): - print("{} ... Server {}.".format(status, "reset" if reset else "reloaded")) + print("... Server {}.".format("reset" if reset else "reloaded")) reactor.stop() def _server_stopped(status): @@ -752,7 +818,7 @@ def reload_evennia(sprofiler=False, reset=False): send_instruction(SSTART, server_cmd) def _portal_running(response): - _, srun, _, _ = _parse_status(response) + _, srun, _, _, _, _ = _parse_status(response) if srun: print("Server {}...".format("resetting" if reset else "reloading")) wait_for_status_reply(_server_stopped) @@ -785,7 +851,7 @@ def stop_evennia(): wait_for_status(False, None, _portal_stopped) def _portal_running(response): - prun, srun, ppid, spid = _parse_status(response) + prun, srun, ppid, spid, _, _ = _parse_status(response) if srun: print("Server stopping ...") send_instruction(SSHUTD, {}) @@ -812,7 +878,7 @@ def stop_server_only(): reactor.stop() def _portal_running(response): - _, srun, _, _ = _parse_status(response) + _, srun, _, _, _, _ = _parse_status(response) if srun: print("Server stopping ...") wait_for_status_reply(_server_stopped) @@ -826,6 +892,25 @@ def stop_server_only(): send_instruction(PSTATUS, None, _portal_running, _portal_not_running) +def query_info(): + """ + Display the info strings from the running Evennia + + """ + def _got_status(status): + _, _, _, _, pinfo, sinfo = _parse_status(status) + print_info(pinfo, sinfo) + reactor.stop() + + def _portal_running(response): + query_status(_got_status) + + def _portal_not_running(fail): + print("Evennia is not running.") + + send_instruction(PSTATUS, None, _portal_running, _portal_not_running) + + def evennia_version(): """ Get the Evennia version info from the main package. @@ -1735,12 +1820,14 @@ def main(): # launch menu for operation init_game_directory(CURRENT_DIR, check_db=True) run_menu() - elif option in ('status', 'sstart', 'sreload', 'sreset', 'sstop', 'ssstop', 'start', 'reload', 'stop'): + elif option in ('status', 'info', 'sstart', 'sreload', 'sreset', 'sstop', 'ssstop', 'start', 'reload', 'stop'): # operate the server directly init_game_directory(CURRENT_DIR, check_db=True) if option == "status": query_status() - if option == "sstart": + elif option == "info": + query_info() + elif option == "sstart": start_evennia(False, args.profiler) elif option == 'sreload': reload_evennia(args.profiler) diff --git a/evennia/server/portal/amp.py b/evennia/server/portal/amp.py index 2c1d28bc1f..4ff4732708 100644 --- a/evennia/server/portal/amp.py +++ b/evennia/server/portal/amp.py @@ -302,7 +302,10 @@ class AMPMultiConnectionProtocol(amp.AMP): portal will continuously try to reconnect, showing the problem that way. """ - self.factory.broadcasts.remove(self) + try: + self.factory.broadcasts.remove(self) + except ValueError: + pass # Error handling diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index 8e49bca263..98e93e7a24 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -49,6 +49,7 @@ class AMPServerFactory(protocol.ServerFactory): self.protocol = AMPServerProtocol self.broadcasts = [] self.server_connection = None + self.server_info_dict = None self.launcher_connection = None self.disconnect_callbacks = {} self.server_connect_callbacks = [] @@ -83,6 +84,7 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): super(AMPServerProtocol, self).connectionLost(reason) if self.factory.server_connection == self: self.factory.server_connection = None + self.factory.server_info_dict = None if self.factory.launcher_connection == self: self.factory.launcher_connection = None @@ -104,9 +106,11 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): """ 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.server_info_dict server_pid = self.factory.portal.server_process_id portal_pid = os.getpid() - return (True, server_connected, portal_pid, server_pid) + return (True, server_connected, portal_pid, server_pid, portal_info_dict, server_info_dict) def data_to_server(self, command, sessid, **kwargs): """ @@ -276,10 +280,9 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): """ self.factory.launcher_connection = self - _, server_connected, _, _ = self.get_status() + _, server_connected, _, _, _, _ = self.get_status() - logger.log_msg("AMP SERVER operation == %s received" % (ord(operation))) - logger.log_msg("AMP SERVER arguments: %s" % (amp.loads(arguments))) + 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 @@ -395,13 +398,13 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): elif operation == amp.PSYNC: # portal sync # Server has (re-)connected and wants the session data from portal + self.factory.server_info_dict = kwargs.get("info_dict", {}) sessdata = self.factory.portal.sessions.get_all_sync_data() self.send_AdminPortal2Server(amp.DUMMYSESSION, amp.PSYNC, sessiondata=sessdata) self.factory.portal.sessions.at_server_connection() - print("Portal PSYNC: %s" % self.factory.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) diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index 5bdd65d39e..2cffbc8f55 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -7,7 +7,6 @@ sets up all the networking features. (this is done automatically by game/evennia.py). """ -from __future__ import print_function from builtins import object import sys @@ -77,9 +76,13 @@ AMP_PORT = settings.AMP_PORT AMP_INTERFACE = settings.AMP_INTERFACE AMP_ENABLED = AMP_HOST and AMP_PORT and AMP_INTERFACE +INFO_DICT = {"servername": SERVERNAME, "version": VERSION, "errors": "", "info": "", + "lockdown_mode": "", "amp": "", "telnet": [], "telnet_ssl": [], "ssh": [], + "webclient": [], "webserver_proxy": [], "webserver_internal": []} # ------------------------------------------------------------- # Portal Service object + # ------------------------------------------------------------- class Portal(object): @@ -114,6 +117,10 @@ class Portal(object): 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): """ This manages the flag file that tells the runner if the server @@ -127,7 +134,6 @@ class Portal(object): if mode is None: return with open(PORTAL_RESTART, 'w') as f: - print("writing mode=%(mode)s to %(portal_restart)s" % {'mode': mode, 'portal_restart': PORTAL_RESTART}) f.write(str(mode)) def shutdown(self, restart=None, _reactor_stopping=False): @@ -148,7 +154,6 @@ class Portal(object): case it always needs to be restarted manually. """ - print("portal.shutdown: restart=", restart) if _reactor_stopping and hasattr(self, "shutdown_complete"): # we get here due to us calling reactor.stop below. No need # to do the shutdown procedure again. @@ -179,10 +184,9 @@ application = service.Application('Portal') # and is where we store all the other services. PORTAL = Portal(application) -print('-' * 50) -print(' %(servername)s Portal (%(version)s) started.' % {'servername': SERVERNAME, 'version': VERSION}) if LOCKDOWN_MODE: - print(' LOCKDOWN_MODE active: Only local connections.') + + INFO_DICT["lockdown_mode"] = ' LOCKDOWN_MODE active: Only local connections.' if AMP_ENABLED: @@ -192,7 +196,7 @@ if AMP_ENABLED: from evennia.server.portal import amp_server - print(' amp (to Server): %s (internal)' % AMP_PORT) + INFO_DICT["amp"] = 'amp: %s)' % AMP_PORT factory = amp_server.AMPServerFactory(PORTAL) amp_service = internet.TCPServer(AMP_PORT, factory, interface=AMP_INTERFACE) @@ -223,12 +227,12 @@ if TELNET_ENABLED: telnet_service.setName('EvenniaTelnet%s' % pstring) PORTAL.services.addService(telnet_service) - print(' telnet%s: %s (external)' % (ifacestr, port)) + INFO_DICT["telnet"].append('telnet%s: %s' % (ifacestr, port)) if SSL_ENABLED: - # Start SSL game connection (requires PyOpenSSL). + # Start Telnet+SSL game connection (requires PyOpenSSL). from evennia.server.portal import ssl @@ -249,7 +253,7 @@ if SSL_ENABLED: ssl_service.setName('EvenniaSSL%s' % pstring) PORTAL.services.addService(ssl_service) - print(" ssl%s: %s (external)" % (ifacestr, port)) + INFO_DICT["telnet_ssl"].append("telnet+ssl%s: %s" % (ifacestr, port)) if SSH_ENABLED: @@ -273,7 +277,7 @@ if SSH_ENABLED: ssh_service.setName('EvenniaSSH%s' % pstring) PORTAL.services.addService(ssh_service) - print(" ssh%s: %s (external)" % (ifacestr, port)) + INFO_DICT["ssh"].append("ssh%s: %s" % (ifacestr, port)) if WEBSERVER_ENABLED: @@ -296,7 +300,7 @@ if WEBSERVER_ENABLED: ajax_webclient = webclient_ajax.AjaxWebClient() ajax_webclient.sessionhandler = PORTAL_SESSIONS web_root.putChild("webclientdata", ajax_webclient) - webclientstr = "\n + webclient (ajax only)" + webclientstr = "webclient (ajax only)" if WEBSOCKET_CLIENT_ENABLED and not websocket_started: # start websocket client port for the webclient @@ -314,10 +318,11 @@ if WEBSERVER_ENABLED: factory.protocol = webclient.WebSocketClient factory.sessionhandler = PORTAL_SESSIONS 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) 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) proxy_service = internet.TCPServer(proxyport, @@ -325,16 +330,10 @@ if WEBSERVER_ENABLED: interface=interface) proxy_service.setName('EvenniaWebProxy%s' % pstring) PORTAL.services.addService(proxy_service) - print(" website-proxy%s: %s (external) %s" % (ifacestr, proxyport, webclientstr)) + INFO_DICT["webserver_proxy"].append("website%s: %s" % (ifacestr, proxyport)) + INFO_DICT["webserver_internal"].append("webserver: %s" % serverport) for plugin_module in PORTAL_SERVICES_PLUGIN_MODULES: # external plugin services to start plugin_module.start_plugin_services(PORTAL) - -print('-' * 50) # end of terminal output - -if os.name == 'nt': - # Windows only: Set PID file manually - with open(PORTAL_PIDFILE, 'w') as f: - f.write(str(os.getpid())) diff --git a/evennia/server/server.py b/evennia/server/server.py index 070ddd4695..3830f3c84a 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -7,7 +7,6 @@ sets up all the networking features. (this is done automatically by evennia/server/server_runner.py). """ -from __future__ import print_function from builtins import object import time import sys @@ -40,11 +39,6 @@ from django.utils.translation import ugettext as _ _SA = object.__setattr__ -SERVER_PIDFILE = "" -if os.name == 'nt': - # For Windows we need to handle pid files manually. - SERVER_PIDFILE = os.path.join(settings.GAME_DIR, "server", 'server.pid') - # a file with a flag telling the server to restart after shutdown or not. SERVER_RESTART = os.path.join(settings.GAME_DIR, "server", 'server.restart') @@ -53,12 +47,7 @@ SERVER_STARTSTOP_MODULE = mod_import(settings.AT_SERVER_STARTSTOP_MODULE) # modules containing plugin services SERVER_SERVICES_PLUGIN_MODULES = [mod_import(module) for module in make_iter(settings.SERVER_SERVICES_PLUGIN_MODULES)] -try: - WEB_PLUGINS_MODULE = mod_import(settings.WEB_PLUGINS_MODULE) -except ImportError: - WEB_PLUGINS_MODULE = None - print ("WARNING: settings.WEB_PLUGINS_MODULE not found - " - "copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf.") + #------------------------------------------------------------ # Evennia Server settings @@ -83,6 +72,17 @@ IRC_ENABLED = settings.IRC_ENABLED RSS_ENABLED = settings.RSS_ENABLED WEBCLIENT_ENABLED = settings.WEBCLIENT_ENABLED +INFO_DICT = {"servername": SERVERNAME, "version": VERSION, + "amp": "", "errors": "", "info": "", "webserver": "", "irc_rss": ""} + +try: + WEB_PLUGINS_MODULE = mod_import(settings.WEB_PLUGINS_MODULE) +except ImportError: + WEB_PLUGINS_MODULE = None + INFO_DICT["errors"] = ( + "WARNING: settings.WEB_PLUGINS_MODULE not found - " + "copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf.") + # Maintenance function - this is called repeatedly by the server @@ -231,6 +231,8 @@ class Evennia(object): typeclasses in the settings file and have them auto-update all already existing objects. """ + global INFO_DICT + # setting names settings_names = ("CMDSET_CHARACTER", "CMDSET_ACCOUNT", "BASE_ACCOUNT_TYPECLASS", "BASE_OBJECT_TYPECLASS", @@ -249,7 +251,7 @@ class Evennia(object): #from evennia.accounts.models import AccountDB for i, prev, curr in ((i, tup[0], tup[1]) for i, tup in enumerate(settings_compare) if i in mismatches): # update the database - print(" %s:\n '%s' changed to '%s'. Updating unchanged entries in database ..." % (settings_names[i], prev, curr)) + INFO_DICT['info'] = " %s:\n '%s' changed to '%s'. Updating unchanged entries in database ..." % (settings_names[i], prev, curr) if i == 0: ObjectDB.objects.filter(db_cmdset_storage__exact=prev).update(db_cmdset_storage=curr) if i == 1: @@ -279,29 +281,27 @@ class Evennia(object): It returns if this is not the first time the server starts. Once finished the last_initial_setup_step is set to -1. """ + global INFO_DICT last_initial_setup_step = ServerConfig.objects.conf('last_initial_setup_step') if not last_initial_setup_step: # None is only returned if the config does not exist, # i.e. this is an empty DB that needs populating. - print(' Server started for the first time. Setting defaults.') + INFO_DICT['info'] = ' Server started for the first time. Setting defaults.' initial_setup.handle_setup(0) - print('-' * 50) elif int(last_initial_setup_step) >= 0: # a positive value means the setup crashed on one of its # modules and setup will resume from this step, retrying # the last failed module. When all are finished, the step # is set to -1 to show it does not need to be run again. - print(' Resuming initial setup from step %(last)s.' % - {'last': last_initial_setup_step}) + INFO_DICT['info'] = ' Resuming initial setup from step {last}.'.format( + last=last_initial_setup_step) initial_setup.handle_setup(int(last_initial_setup_step)) - print('-' * 50) def run_init_hooks(self): """ Called every server start """ from evennia.objects.models import ObjectDB - #from evennia.accounts.models import AccountDB # update eventual changed defaults self.update_defaults() @@ -366,7 +366,6 @@ class Evennia(object): once - in both cases the reactor is dead/stopping already. """ - print("server.shutdown mode=", mode) if _reactor_stopping and hasattr(self, "shutdown_complete"): # this means we have already passed through this method # once; we don't need to run the shutdown procedure again. @@ -414,10 +413,6 @@ class Evennia(object): # always called, also for a reload self.at_server_stop() - if os.name == 'nt' and os.path.exists(SERVER_PIDFILE): - # for Windows we need to remove pid files manually - os.remove(SERVER_PIDFILE) - if hasattr(self, "web_root"): # not set very first start yield self.web_root.empty_threadpool() @@ -429,6 +424,10 @@ class Evennia(object): # we make sure the proper gametime is saved as late as possible ServerConfig.objects.conf("runtime", _GAMETIME_MODULE.runtime()) + def get_info_dict(self): + "Return the server info, for display." + return INFO_DICT + # server start/stop hooks def at_server_start(self): @@ -536,9 +535,6 @@ application = service.Application('Evennia') # and is where we store all the other services. EVENNIA = Evennia(application) -print('-' * 50) -print(' %(servername)s Server (%(version)s) started.' % {'servername': SERVERNAME, 'version': VERSION}) - if AMP_ENABLED: # The AMP protocol handles the communication between @@ -548,7 +544,8 @@ if AMP_ENABLED: ifacestr = "" if AMP_INTERFACE != '127.0.0.1': ifacestr = "-%s" % AMP_INTERFACE - print(' amp (to Portal)%s: %s (internal)' % (ifacestr, AMP_PORT)) + + INFO_DICT["amp"] = 'amp %s: %s' % (ifacestr, AMP_PORT) from evennia.server import amp_client @@ -561,7 +558,6 @@ if WEBSERVER_ENABLED: # Start a django-compatible webserver. - #from twisted.python import threadpool from evennia.server.webserver import DjangoWebRoot, WSGIWebServer, Website, LockableThreadPool # start a thread pool and define the root url (/) as a wsgi resource @@ -582,13 +578,14 @@ if WEBSERVER_ENABLED: web_site = Website(web_root, logPath=settings.HTTP_LOG_FILE) + INFO_DICT["webserver"] = "" for proxyport, serverport in WEBSERVER_PORTS: # create the webserver (we only need the port for this) webserver = WSGIWebServer(threads, serverport, web_site, interface='127.0.0.1') webserver.setName('EvenniaWebServer%s' % serverport) EVENNIA.services.addService(webserver) - print(" webserver: %s (internal)" % serverport) + INFO_DICT["webserver"] += "webserver: %s" % serverport ENABLED = [] if IRC_ENABLED: @@ -600,18 +597,11 @@ if RSS_ENABLED: ENABLED.append('rss') if ENABLED: - print(" " + ", ".join(ENABLED) + " enabled.") + INFO_DICT["irc_rss"] = ", ".join(ENABLED) + " enabled." for plugin_module in SERVER_SERVICES_PLUGIN_MODULES: # external plugin protocols plugin_module.start_plugin_services(EVENNIA) -print('-' * 50) # end of terminal output - # clear server startup mode ServerConfig.objects.conf("server_starting_mode", delete=True) - -if os.name == 'nt': - # Windows only: Set PID file manually - with open(SERVER_PIDFILE, 'w') as f: - f.write(str(os.getpid())) From 3bdb0cd2ca6ee6c1924f8baa990aa0afb1315b44 Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Wed, 17 Jan 2018 15:36:27 -0500 Subject: [PATCH 096/189] Docstring typo fix in access method of Command --- evennia/commands/command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/commands/command.py b/evennia/commands/command.py index 48a4b132da..17902b3602 100644 --- a/evennia/commands/command.py +++ b/evennia/commands/command.py @@ -297,7 +297,7 @@ class Command(with_metaclass(CommandMeta, object)): Args: srcobj (Object): Object trying to gain permission access_type (str, optional): The lock type to check. - default (bool, optional): The fallbacl result if no lock + default (bool, optional): The fallback result if no lock of matching `access_type` is found on this Command. """ From 4126ec83185c04e05eec413204e481e478529f2e Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 17 Jan 2018 22:11:25 +0100 Subject: [PATCH 097/189] Clean up the launcher for the new functionality. --- evennia/server/evennia_launcher.py | 281 ++++++----------------------- 1 file changed, 59 insertions(+), 222 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 8056d83887..4eba70e839 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -64,14 +64,12 @@ HTTP_LOGFILE = None SERVER_PIDFILE = None PORTAL_PIDFILE = None -SERVER_RESTART = None -PORTAL_RESTART = None SERVER_PY_FILE = None PORTAL_PY_FILE = None -SPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "server.prof") -PPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "portal.prof") +SPROFILER_LOGFILE = None +PPROFILER_LOGFILE = None TEST_MODE = False ENFORCED_SETTING = False @@ -136,6 +134,12 @@ ERROR_INPUT = \ raised an error: '{traceback}'. """ +ERROR_NO_ALT_GAMEDIR = \ + """ + The path {gamedir} doesn't seem to exist. + +""" + ERROR_NO_GAMEDIR = \ """ ERROR: No Evennia settings file was found. Evennia looks for the @@ -292,40 +296,26 @@ ABOUT_INFO = \ HELP_ENTRY = \ """ - Enter 'evennia -h' for command-line options. + This is a convenience launcher for the most common actions. For + more advanced ways to operate and manage Evennia, see 'evennia -h'. - Use option (1) in a production environment. During development (2) is - usually enough, portal debugging is usually only useful if you are - adding new protocols or are debugging Evennia itself. - - Reload with (5) to update the server with your changes without - disconnecting any accounts. - - Note: Reload and stop are sometimes poorly supported in Windows. If you - have issues, log into the game to stop or restart the server instead. + Evennia's manual is found here: https://github.com/evennia/evennia/wiki """ MENU = \ """ +----Evennia Launcher-------------------------------------------+ | | - +--- Starting --------------------------------------------------+ + +--- Common operations -----------------------------------------+ | | - | 1) (normal): All output to logfiles | - | 2) (server devel): Server logs to terminal (-i option) | - | 3) (portal devel): Portal logs to terminal | - | 4) (full devel): Both Server and Portal logs to terminal | + | 1) Start Portal and Server (also restart downed Server) | + | 2) Reload Server (update on code changes) | + | 3) Stop Portal and Server (full shutdown) | | | - +--- Restarting ------------------------------------------------+ + +--- Other -----------------------------------------------------+ | | - | 5) Reload the Server | - | 6) Reload the Portal (only works with portal/full debug) | - | | - +--- Stopping --------------------------------------------------+ - | | - | 7) Stopping both Portal and Server | - | 8) Stopping only Server | - | 9) Stopping only Portal | + | 4) Reset Server (Server shutdown with restart) | + | 5) Stop Server only | | | +---------------------------------------------------------------+ | h) Help i) About info q) Abort | @@ -885,9 +875,11 @@ def stop_server_only(): send_instruction(SSHUTD, {}) else: print("Server is not running.") + reactor.stop() def _portal_not_running(fail): print("Evennia is not running.") + reactor.stop() send_instruction(PSTATUS, None, _portal_running, _portal_not_running) @@ -990,7 +982,8 @@ def check_main_evennia_dependencies(): def set_gamedir(path): """ Set GAMEDIR based on path, by figuring out where the setting file - is inside the directory tree. + is inside the directory tree. This allows for running the launcher + from elsewhere than the top of the gamedir folder. """ global GAMEDIR @@ -998,7 +991,7 @@ def set_gamedir(path): Ndepth = 10 settings_path = os.path.join("server", "conf", "settings.py") for i in range(Ndepth): - gpath = os.getcwd() + gpath = GAMEDIR if "server" in os.listdir(gpath): if os.path.isfile(settings_path): GAMEDIR = gpath @@ -1208,8 +1201,7 @@ def del_pid(pidfile): os.remove(pidfile) -def kill(pidfile, killsignal=SIG, succmsg="", errmsg="", - restart_file=SERVER_RESTART, restart=False): +def kill(pidfile, killsignal=SIG, succmsg="", errmsg="", restart=False): """ Send a kill signal to a process based on PID. A customized success/error message will be returned. If clean=True, the system @@ -1220,23 +1212,12 @@ def kill(pidfile, killsignal=SIG, succmsg="", errmsg="", killsignal (int, optional): Signal identifier for signal to send. succmsg (str, optional): Message to log on success. errmsg (str, optional): Message to log on failure. - restart_file (str, optional): Restart file location. - restart (bool, optional): Are we in restart mode or not. """ pid = get_pid(pidfile) if pid: if os.name == 'nt': os.remove(pidfile) - # set restart/norestart flag - if restart: - django.core.management.call_command( - 'collectstatic', interactive=False, verbosity=0) - with open(restart_file, 'w') as f: - f.write("reload") - else: - with open(restart_file, 'w') as f: - f.write("shutdown") try: if os.name == 'nt': from win32api import GenerateConsoleCtrlEvent, SetConsoleCtrlHandler @@ -1386,7 +1367,7 @@ def init_game_directory(path, check_db=True): global SERVER_PY_FILE, PORTAL_PY_FILE global SERVER_LOGFILE, PORTAL_LOGFILE, HTTP_LOGFILE global SERVER_PIDFILE, PORTAL_PIDFILE - global SERVER_RESTART, PORTAL_RESTART + global SPROFILER_LOGFILE, PPROFILER_LOGFILE global EVENNIA_VERSION AMP_PORT = settings.AMP_PORT @@ -1399,8 +1380,8 @@ def init_game_directory(path, check_db=True): SERVER_PIDFILE = os.path.join(GAMEDIR, SERVERDIR, "server.pid") PORTAL_PIDFILE = os.path.join(GAMEDIR, SERVERDIR, "portal.pid") - SERVER_RESTART = os.path.join(GAMEDIR, SERVERDIR, "server.restart") - PORTAL_RESTART = os.path.join(GAMEDIR, SERVERDIR, "portal.restart") + SPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "server.prof") + PPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "portal.prof") SERVER_LOGFILE = settings.SERVER_LOG_FILE PORTAL_LOGFILE = settings.PORTAL_LOG_FILE @@ -1548,160 +1529,21 @@ def run_menu(): print("Not a valid option.") continue if inp == 1: - # start everything, log to log files - server_operation("start", "all", False, False) + start_evennia(False, False) elif inp == 2: - # start everything, server interactive start - server_operation("start", "all", True, False) + reload_evennia(False, False) elif inp == 3: - # start everything, portal interactive start - server_operation("start", "server", False, False) - server_operation("start", "portal", True, False) + stop_evennia() elif inp == 4: - # start both server and portal interactively - server_operation("start", "server", True, False) - server_operation("start", "portal", True, False) + reload_evennia(False, True) elif inp == 5: - # reload the server - server_operation("reload", "server", None, None) - elif inp == 6: - # reload the portal - server_operation("reload", "portal", None, None) - elif inp == 7: - # stop server and portal - server_operation("stop", "all", None, None) - elif inp == 8: - # stop server - server_operation("stop", "server", None, None) - elif inp == 9: - # stop portal - server_operation("stop", "portal", None, None) + stop_server_only() else: print("Not a valid option.") continue return -def server_operation(mode, service, interactive, profiler, logserver=False, doexit=False): - """ - Handle argument options given on the command line. - - Args: - mode (str): Start/stop/restart and so on. - service (str): "server", "portal" or "all". - interactive (bool). Use interactive mode or daemon. - profiler (bool): Run the service under the profiler. - logserver (bool, optional): Log Server data to logfile - specified by settings.SERVER_LOG_FILE. - doexit (bool, optional): If True, immediately exit the runner after - starting the relevant processes. If the runner exits, Evennia - cannot be reloaded. This is meant to be used with an external - process manager like Linux' start-stop-daemon. - - """ - - cmdstr = [sys.executable, EVENNIA_RUNNER] - errmsg = "The %s does not seem to be running." - - if mode == 'start': - - # launch the error checker. Best to catch the errors already here. - error_check_python_modules() - - # starting one or many services - if service == 'server': - if profiler: - cmdstr.append('--pserver') - if interactive: - cmdstr.append('--iserver') - if logserver: - cmdstr.append('--logserver') - cmdstr.append('--noportal') - elif service == 'portal': - if profiler: - cmdstr.append('--pportal') - if interactive: - cmdstr.append('--iportal') - cmdstr.append('--noserver') - django.core.management.call_command( - 'collectstatic', verbosity=1, interactive=False) - else: - # all - # for convenience we don't start logging of - # portal, only of server with this command. - if profiler: - # this is the common case - cmdstr.append('--pserver') - if interactive: - cmdstr.append('--iserver') - if logserver: - cmdstr.append('--logserver') - django.core.management.call_command( - 'collectstatic', verbosity=1, interactive=False) - if doexit: - cmdstr.append('--doexit') - cmdstr.extend([ - GAMEDIR, TWISTED_BINARY, SERVER_LOGFILE, - PORTAL_LOGFILE, HTTP_LOGFILE]) - # start the server - process = Popen(cmdstr, env=getenv()) - - if interactive: - try: - process.wait() - except KeyboardInterrupt: - server_operation("stop", "portal", False, False) - return - finally: - print(NOTE_KEYBOARDINTERRUPT) - - elif mode == 'reload': - # restarting services - if os.name == 'nt': - print( - "Restarting from command line is not supported under Windows. " - "Use the in-game command (@reload by default) " - "or use 'evennia stop && evennia start' for a cold reboot.") - return - if service == 'server': - kill(SERVER_PIDFILE, SIG, "Server reloaded.", - errmsg % 'Server', SERVER_RESTART, restart=True) - elif service == 'portal': - print( - "Note: Portal usually doesnt't need to be reloaded unless you " - "are debugging in interactive mode. If Portal was running in " - "default Daemon mode, it cannot be restarted. In that case " - "you have to restart it manually with 'evennia.py " - "start portal'") - kill(PORTAL_PIDFILE, SIG, - "Portal reloaded (or stopped, if it was in daemon mode).", - errmsg % 'Portal', PORTAL_RESTART, restart=True) - else: - # all - # default mode, only restart server - kill(SERVER_PIDFILE, SIG, - "Server reload.", - errmsg % 'Server', SERVER_RESTART, restart=True) - - elif mode == 'stop': - if os.name == "nt": - print( - "(Obs: You can use a single Ctrl-C to skip " - "Windows' annoying 'Terminate batch job (Y/N)?' prompts.)") - # stop processes, avoiding reload - if service == 'server': - kill(SERVER_PIDFILE, SIG, - "Server stopped.", errmsg % 'Server', SERVER_RESTART) - elif service == 'portal': - kill(PORTAL_PIDFILE, SIG, - "Portal stopped.", errmsg % 'Portal', PORTAL_RESTART) - else: - kill(PORTAL_PIDFILE, SIG, - "Portal stopped.", errmsg % 'Portal', PORTAL_RESTART) - kill(SERVER_PIDFILE, SIG, - "Server stopped.", errmsg % 'Server', SERVER_RESTART) - - def main(): """ Run the evennia launcher main program. @@ -1715,14 +1557,6 @@ def main(): '-v', '--version', action='store_true', dest='show_version', default=False, help="Show version info.") - parser.add_argument( - '-i', '--interactive', action='store_true', - dest='interactive', default=False, - help="Start given processes in interactive mode.") - parser.add_argument( - '-l', '--log', action='store_true', - dest="logserver", default=False, - help="Log Server data to log file.") parser.add_argument( '--init', action='store', dest="init", metavar="name", help="Creates a new game directory 'name' at the current location.") @@ -1737,6 +1571,10 @@ def main(): '--dummyrunner', nargs=1, action='store', dest='dummyrunner', metavar="N", help="Test a running server by connecting N dummy accounts to it.") + parser.add_argument( + '--gamedir', nargs=1, action='store', dest='altgamedir', + default=None, metavar="path/to/gamedir", + help="Supply path to gamedir, if not current location") parser.add_argument( '--settings', nargs=1, action='store', dest='altsettings', default=None, metavar="filename.py", @@ -1746,17 +1584,10 @@ def main(): '--initsettings', action='store_true', dest="initsettings", default=False, help="Create a new, empty settings file as gamedir/server/conf/settings.py.") - parser.add_argument( - '--external-runner', action='store_true', dest="doexit", - default=False, - help="Handle server restart with an external process manager.") parser.add_argument( "operation", nargs='?', default="noop", - help="Operation to perform: 'start', 'stop', 'reload' or 'menu'.") - parser.add_argument( - "service", metavar="component", nargs='?', default="all", - help=("Which component to operate on: " - "'server', 'portal' or 'all' (default if not set).")) + metavar="start|stop|reload|reset|sstart|info|status|menu", + help="Operation to perform. Unregognized actions are passed on to Django.") parser.epilog = ( "Common usage: evennia start|stop|reload. Django-admin database commands:" "evennia migration|flush|shell|dbshell (see the django documentation for more " @@ -1765,7 +1596,7 @@ def main(): args, unknown_args = parser.parse_known_args() # handle arguments - option, service = args.operation, args.service + option = args.operation # make sure we have everything check_main_evennia_dependencies() @@ -1774,7 +1605,16 @@ def main(): # show help pane print(CMDLINE_HELP) sys.exit() - elif args.init: + + if args.altgamedir: + # use alternative gamedir path + global GAMEDIR + if not os.path.isdir(args.altgamedir) and not args.init: + print(ERROR_NO_ALT_GAMEDIR.format(args.altgamedir)) + sys.exit() + GAMEDIR = args.altgamedir + + if args.init: # initialization of game directory create_game_directory(args.init) print(CREATED_NEW_GAMEDIR.format( @@ -1799,8 +1639,6 @@ def main(): if args.initsettings: # create new settings file - global GAMEDIR - GAMEDIR = os.getcwd() try: create_settings_file(init=False) print(RECREATED_SETTINGS) @@ -1820,29 +1658,28 @@ def main(): # launch menu for operation init_game_directory(CURRENT_DIR, check_db=True) run_menu() - elif option in ('status', 'info', 'sstart', 'sreload', 'sreset', 'sstop', 'ssstop', 'start', 'reload', 'stop'): + elif option in ('status', 'info', 'start', 'reload', 'reset', 'stop', 'sstop'): # operate the server directly init_game_directory(CURRENT_DIR, check_db=True) if option == "status": query_status() elif option == "info": query_info() - elif option == "sstart": + elif option == "start": start_evennia(False, args.profiler) - elif option == 'sreload': + elif option == 'reload': reload_evennia(args.profiler) - elif option == 'sreset': + elif option == 'reset': reload_evennia(args.profiler, reset=True) - elif option == 'sstop': + elif option == 'stop': stop_evennia() - elif option == 'ssstop': + elif option == 'sstop': stop_server_only() - else: - server_operation(option, service, args.interactive, - args.profiler, args.logserver, doexit=args.doexit) elif option != "noop": # pass-through to django manager check_db = False + + # handle special django commands if option in ('runserver', 'testserver'): print(WARNING_RUNSERVER) if option in ("shell", "check"): @@ -1852,12 +1689,12 @@ def main(): if option == "test": global TEST_MODE TEST_MODE = True + init_game_directory(CURRENT_DIR, check_db=check_db) + # pass on to the manager args = [option] kwargs = {} - if service not in ("all", "server", "portal"): - args.append(service) if unknown_args: for arg in unknown_args: if arg.startswith("--"): From 54c6ae4aec6d1a81c17d02ca2651f8a545f31aad Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 18 Jan 2018 00:44:36 +0100 Subject: [PATCH 098/189] Add --gamedir option for remote-running Evennia --- evennia/server/evennia_launcher.py | 49 +++++++++++++++--------------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 4eba70e839..594dff7438 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -136,8 +136,7 @@ ERROR_INPUT = \ ERROR_NO_ALT_GAMEDIR = \ """ - The path {gamedir} doesn't seem to exist. - + The path '{gamedir}' could not be found. """ ERROR_NO_GAMEDIR = \ @@ -990,8 +989,9 @@ def set_gamedir(path): Ndepth = 10 settings_path = os.path.join("server", "conf", "settings.py") + os.chdir(GAMEDIR) for i in range(Ndepth): - gpath = GAMEDIR + gpath = os.getcwd() if "server" in os.listdir(gpath): if os.path.isfile(settings_path): GAMEDIR = gpath @@ -1554,36 +1554,36 @@ def main(): parser = ArgumentParser(description=CMDLINE_HELP) parser.add_argument( - '-v', '--version', action='store_true', - dest='show_version', default=False, - help="Show version info.") + '--gamedir', nargs=1, action='store', dest='altgamedir', + metavar="", + help="Location of gamedir (default: current location)") parser.add_argument( - '--init', action='store', dest="init", metavar="name", - help="Creates a new game directory 'name' at the current location.") + '--init', action='store', dest="init", metavar="", + help="Creates a new gamedir 'name' at current location.") parser.add_argument( - '--list', nargs='+', action='store', dest='listsetting', metavar="key", + '--list', nargs='+', action='store', dest='listsetting', metavar="all|", help=("List values for server settings. Use 'all' to list all " "available keys.")) - parser.add_argument( - '--profiler', action='store_true', dest='profiler', default=False, - help="Start given server component under the Python profiler.") - parser.add_argument( - '--dummyrunner', nargs=1, action='store', dest='dummyrunner', - metavar="N", - help="Test a running server by connecting N dummy accounts to it.") - parser.add_argument( - '--gamedir', nargs=1, action='store', dest='altgamedir', - default=None, metavar="path/to/gamedir", - help="Supply path to gamedir, if not current location") parser.add_argument( '--settings', nargs=1, action='store', dest='altsettings', - default=None, metavar="filename.py", + default=None, metavar="", help=("Start evennia with alternative settings file from " "gamedir/server/conf/. (default is settings.py)")) parser.add_argument( '--initsettings', action='store_true', dest="initsettings", default=False, help="Create a new, empty settings file as gamedir/server/conf/settings.py.") + parser.add_argument( + '--profiler', action='store_true', dest='profiler', default=False, + help="Start given server component under the Python profiler.") + parser.add_argument( + '--dummyrunner', nargs=1, action='store', dest='dummyrunner', + metavar="", + help="Test a server by connecting dummy accounts to it.") + parser.add_argument( + '-v', '--version', action='store_true', + dest='show_version', default=False, + help="Show version info.") parser.add_argument( "operation", nargs='?', default="noop", metavar="start|stop|reload|reset|sstart|info|status|menu", @@ -1609,10 +1609,11 @@ def main(): if args.altgamedir: # use alternative gamedir path global GAMEDIR - if not os.path.isdir(args.altgamedir) and not args.init: - print(ERROR_NO_ALT_GAMEDIR.format(args.altgamedir)) + altgamedir = args.altgamedir[0] + if not os.path.isdir(altgamedir) and not args.init: + print(ERROR_NO_ALT_GAMEDIR.format(gamedir=altgamedir)) sys.exit() - GAMEDIR = args.altgamedir + GAMEDIR = altgamedir if args.init: # initialization of game directory From fabcc87ecd20647f1d66ae87e01e710b2c79e983 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 18 Jan 2018 18:59:46 +0100 Subject: [PATCH 099/189] Remove evennia_runner.py (now managed by Portal) --- evennia/server/evennia_runner.py | 354 ------------------------------- 1 file changed, 354 deletions(-) delete mode 100644 evennia/server/evennia_runner.py diff --git a/evennia/server/evennia_runner.py b/evennia/server/evennia_runner.py deleted file mode 100644 index d920c181e8..0000000000 --- a/evennia/server/evennia_runner.py +++ /dev/null @@ -1,354 +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_IOERROR = \ - """ - {component} IOError: {traceback} - One possible explanation is that 'twistd' was not found. - """ - -PROCESS_RESTART = "{component} restarting ..." - -PROCESS_DOEXIT = "Deferring to external runner." - -# Functions - - -def set_restart_mode(restart_file, flag="reload"): - """ - This sets a flag file for the restart mode. - """ - with open(restart_file, 'w') as f: - f.write(str(flag)) - - -def getenv(): - """ - Get current environment and add PYTHONPATH - """ - sep = ";" if os.name == "nt" else ":" - env = os.environ.copy() - sys.path.insert(0, GAMEDIR) - env['PYTHONPATH'] = sep.join(sys.path) - return env - - -def get_restart_mode(restart_file): - """ - Parse the server/portal restart status - """ - if os.path.exists(restart_file): - with open(restart_file, 'r') as f: - return f.read() - return "shutdown" - - -def get_pid(pidfile): - """ - Get the PID (Process ID) by trying to access - an PID file. - """ - pid = None - if os.path.exists(pidfile): - with open(pidfile, 'r') as f: - pid = f.read() - return pid - - -def cycle_logfile(logfile): - """ - Rotate the old log files to .old - """ - logfile_old = logfile + '.old' - if os.path.exists(logfile): - # Cycle the old logfiles to *.old - if os.path.exists(logfile_old): - # E.g. Windows don't support rename-replace - os.remove(logfile_old) - os.rename(logfile, logfile_old) - -# Start program management - - -def start_services(server_argv, portal_argv, doexit=False): - """ - This calls a threaded loop that launches the Portal and Server - and then restarts them when they finish. - """ - global SERVER, PORTAL - processes = Queue.Queue() - - def server_waiter(queue): - try: - rc = Popen(server_argv, env=getenv()).wait() - except Exception as e: - print(PROCESS_ERROR.format(component="Server", traceback=e)) - return - # this signals the controller that the program finished - queue.put(("server_stopped", rc)) - - def portal_waiter(queue): - try: - rc = Popen(portal_argv, env=getenv()).wait() - except Exception as e: - print(PROCESS_ERROR.format(component="Portal", traceback=e)) - return - # this signals the controller that the program finished - queue.put(("portal_stopped", rc)) - - if portal_argv: - try: - if not doexit and get_restart_mode(PORTAL_RESTART) == "True": - # start portal as interactive, reloadable thread - PORTAL = thread.start_new_thread(portal_waiter, (processes, )) - else: - # normal operation: start portal as a daemon; - # we don't care to monitor it for restart - PORTAL = Popen(portal_argv, env=getenv()) - except IOError as e: - print(PROCESS_IOERROR.format(component="Portal", traceback=e)) - return - - try: - if server_argv: - if doexit: - SERVER = Popen(server_argv, env=getenv()) - else: - # start server as a reloadable thread - SERVER = thread.start_new_thread(server_waiter, (processes, )) - except IOError as e: - print(PROCESS_IOERROR.format(component="Server", traceback=e)) - return - - if doexit: - # Exit immediately - return - - # Reload loop - while True: - - # this blocks until something is actually returned. - from twisted.internet.error import ReactorNotRunning - try: - try: - message, rc = processes.get() - except KeyboardInterrupt: - # this only matters in interactive mode - break - - # restart only if process stopped cleanly - if (message == "server_stopped" and int(rc) == 0 and - get_restart_mode(SERVER_RESTART) in ("True", "reload", "reset")): - print(PROCESS_RESTART.format(component="Server")) - SERVER = thread.start_new_thread(server_waiter, (processes, )) - continue - - # normally the portal is not reloaded since it's run as a daemon. - if (message == "portal_stopped" and int(rc) == 0 and - get_restart_mode(PORTAL_RESTART) == "True"): - print(PROCESS_RESTART.format(component="Portal")) - PORTAL = thread.start_new_thread(portal_waiter, (processes, )) - continue - break - except ReactorNotRunning: - break - - -def main(): - """ - This handles the command line input of the runner, usually created by - the evennia launcher - """ - - parser = ArgumentParser(description=CMDLINE_HELP) - parser.add_argument('--noserver', action='store_true', dest='noserver', - default=False, help='Do not start Server process') - parser.add_argument('--noportal', action='store_true', dest='noportal', - default=False, help='Do not start Portal process') - parser.add_argument('--logserver', action='store_true', dest='logserver', - default=False, help='Log Server output to logfile') - parser.add_argument('--iserver', action='store_true', dest='iserver', - default=False, help='Server in interactive mode') - parser.add_argument('--iportal', action='store_true', dest='iportal', - default=False, help='Portal in interactive mode') - parser.add_argument('--pserver', action='store_true', dest='pserver', - default=False, help='Profile Server') - parser.add_argument('--pportal', action='store_true', dest='pportal', - default=False, help='Profile Portal') - parser.add_argument('--nologcycle', action='store_false', dest='nologcycle', - default=True, help='Do not cycle log files') - parser.add_argument('--doexit', action='store_true', dest='doexit', - default=False, help='Immediately exit after processes have started.') - parser.add_argument('gamedir', help="path to game dir") - parser.add_argument('twistdbinary', help="path to twistd binary") - parser.add_argument('slogfile', help="path to server log file") - parser.add_argument('plogfile', help="path to portal log file") - parser.add_argument('hlogfile', help="path to http log file") - - args = parser.parse_args() - - global GAMEDIR - global SERVER_LOGFILE, PORTAL_LOGFILE, HTTP_LOGFILE - global SERVER_PIDFILE, PORTAL_PIDFILE - global SERVER_RESTART, PORTAL_RESTART - global SPROFILER_LOGFILE, PPROFILER_LOGFILE - - GAMEDIR = args.gamedir - sys.path.insert(1, os.path.join(GAMEDIR, SERVERDIR)) - - SERVER_PIDFILE = os.path.join(GAMEDIR, SERVERDIR, "server.pid") - PORTAL_PIDFILE = os.path.join(GAMEDIR, SERVERDIR, "portal.pid") - SERVER_RESTART = os.path.join(GAMEDIR, SERVERDIR, "server.restart") - PORTAL_RESTART = os.path.join(GAMEDIR, SERVERDIR, "portal.restart") - SERVER_LOGFILE = args.slogfile - PORTAL_LOGFILE = args.plogfile - HTTP_LOGFILE = args.hlogfile - TWISTED_BINARY = args.twistdbinary - SPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "server.prof") - PPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "portal.prof") - - # set up default project calls - server_argv = [TWISTED_BINARY, - '--nodaemon', - '--logfile=%s' % SERVER_LOGFILE, - '--pidfile=%s' % SERVER_PIDFILE, - '--python=%s' % SERVER_PY_FILE] - portal_argv = [TWISTED_BINARY, - '--logfile=%s' % PORTAL_LOGFILE, - '--pidfile=%s' % PORTAL_PIDFILE, - '--python=%s' % PORTAL_PY_FILE] - - # Profiling settings (read file from python shell e.g with - # p = pstats.Stats('server.prof') - pserver_argv = ['--savestats', - '--profiler=cprofile', - '--profile=%s' % SPROFILER_LOGFILE] - pportal_argv = ['--savestats', - '--profiler=cprofile', - '--profile=%s' % PPROFILER_LOGFILE] - - # Server - - pid = get_pid(SERVER_PIDFILE) - if pid and not args.noserver: - print("\nEvennia Server is already running as process %(pid)s. Not restarted." % {'pid': pid}) - args.noserver = True - if args.noserver: - server_argv = None - else: - set_restart_mode(SERVER_RESTART, "shutdown") - if not args.logserver: - # don't log to server logfile - del server_argv[2] - print("\nStarting Evennia Server (output to stdout).") - else: - if not args.nologcycle: - cycle_logfile(SERVER_LOGFILE) - print("\nStarting Evennia Server (output to server logfile).") - if args.pserver: - server_argv.extend(pserver_argv) - print("\nRunning Evennia Server under cProfile.") - - # Portal - - pid = get_pid(PORTAL_PIDFILE) - if pid and not args.noportal: - print("\nEvennia Portal is already running as process %(pid)s. Not restarted." % {'pid': pid}) - args.noportal = True - if args.noportal: - portal_argv = None - else: - if args.iportal: - # make portal interactive - portal_argv[1] = '--nodaemon' - set_restart_mode(PORTAL_RESTART, True) - print("\nStarting Evennia Portal in non-Daemon mode (output to stdout).") - else: - if not args.nologcycle: - cycle_logfile(PORTAL_LOGFILE) - cycle_logfile(HTTP_LOGFILE) - set_restart_mode(PORTAL_RESTART, False) - print("\nStarting Evennia Portal in Daemon mode (output to portal logfile).") - if args.pportal: - portal_argv.extend(pportal_argv) - print("\nRunning Evennia Portal under cProfile.") - if args.doexit: - print(PROCESS_DOEXIT) - - # Windows fixes (Windows don't support pidfiles natively) - if os.name == 'nt': - if server_argv: - del server_argv[-2] - if portal_argv: - del portal_argv[-2] - - # Start processes - print("server_argv:", server_argv, portal_argv) - start_services(server_argv, portal_argv, doexit=args.doexit) - - -if __name__ == '__main__': - main() From 815ef198f54b548402d25bc158ac0d3f33059e0c Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 18 Jan 2018 20:07:38 +0100 Subject: [PATCH 100/189] Clean up launcher help strings --- evennia/server/evennia_launcher.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 594dff7438..3fcfd71aa2 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -1,12 +1,13 @@ -#!/usr/bin/env python +#!/usr/bin/python """ -EVENNIA SERVER LAUNCHER SCRIPT +Evennia launcher program This is the start point for running Evennia. -Sets the appropriate environmental variables and launches the server -and portal through the evennia_runner. Run without arguments to get a -menu. Run the script with the -h flag to see usage information. +Sets the appropriate environmental variables for managing an Evennia game. It will start and connect +to the Portal, through which the Server is also controlled. This pprogram + +Run the script with the -h flag to see usage information. """ from __future__ import print_function @@ -19,6 +20,7 @@ import shutil import importlib from distutils.version import LooseVersion from argparse import ArgumentParser +import argparse from subprocess import Popen, check_output, call, CalledProcessError, STDOUT try: @@ -1201,7 +1203,7 @@ def del_pid(pidfile): os.remove(pidfile) -def kill(pidfile, killsignal=SIG, succmsg="", errmsg="", restart=False): +def kill(pidfile, killsignal=SIG, succmsg="", errmsg=""): """ Send a kill signal to a process based on PID. A customized success/error message will be returned. If clean=True, the system @@ -1562,7 +1564,7 @@ def main(): help="Creates a new gamedir 'name' at current location.") parser.add_argument( '--list', nargs='+', action='store', dest='listsetting', metavar="all|", - help=("List values for server settings. Use 'all' to list all " + help=("List values for one or more server settings. Use 'all' to list all " "available keys.")) parser.add_argument( '--settings', nargs=1, action='store', dest='altsettings', @@ -1584,17 +1586,18 @@ def main(): '-v', '--version', action='store_true', dest='show_version', default=False, help="Show version info.") + parser.add_argument( "operation", nargs='?', default="noop", - metavar="start|stop|reload|reset|sstart|info|status|menu", - help="Operation to perform. Unregognized actions are passed on to Django.") + help=("One of start, stop, reload, reset, sstart, info, status and menu. " + "Unregognized input is passed on to Django.")) parser.epilog = ( - "Common usage: evennia start|stop|reload. Django-admin database commands:" - "evennia migration|flush|shell|dbshell (see the django documentation for more " - "django-admin commands.)") + "Common Django-admin commands are shell, dbshell, migrate and flush. " + "See the django documentation for more django-admin commands.") args, unknown_args = parser.parse_known_args() + print(args.rest) # handle arguments option = args.operation From 55d21500deeab0a9018272327a7bc58812aa3a9b Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 18 Jan 2018 20:39:08 +0100 Subject: [PATCH 101/189] Better formatting in evennia launcher help --- evennia/server/evennia_launcher.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 3fcfd71aa2..8e3645f82b 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -449,6 +449,19 @@ SERVER_INFO = \ {errors}""" +ARG_OPTIONS = \ + """Actions on installed server. One of: + start - launch server+portal if not running + stop - shutdown server+portal + reload - restart server (code refresh) + reset - mimic server shutdown but with auto-restart + sstart - start only server (requires portal) + status - server and portal run state + info - get server and portal port info + menu - show a menu of options +Unregognized input is passed on to Django.""" + + # Info formatting def print_info(portal_info_dict, server_info_dict): @@ -1554,7 +1567,7 @@ def main(): # set up argument parser - parser = ArgumentParser(description=CMDLINE_HELP) + parser = ArgumentParser(description=CMDLINE_HELP, formatter_class=argparse.RawTextHelpFormatter) parser.add_argument( '--gamedir', nargs=1, action='store', dest='altgamedir', metavar="", @@ -1564,17 +1577,17 @@ def main(): help="Creates a new gamedir 'name' at current location.") parser.add_argument( '--list', nargs='+', action='store', dest='listsetting', metavar="all|", - help=("List values for one or more server settings. Use 'all' to list all " + help=("List values for one or more server settings. Use 'all' to \n list all " "available keys.")) parser.add_argument( '--settings', nargs=1, action='store', dest='altsettings', default=None, metavar="", - help=("Start evennia with alternative settings file from " - "gamedir/server/conf/. (default is settings.py)")) + help=("Start evennia with alternative settings file from\n" + " gamedir/server/conf/. (default is settings.py)")) parser.add_argument( '--initsettings', action='store_true', dest="initsettings", default=False, - help="Create a new, empty settings file as gamedir/server/conf/settings.py.") + help="Create a new, empty settings file as\n gamedir/server/conf/settings.py.") parser.add_argument( '--profiler', action='store_true', dest='profiler', default=False, help="Start given server component under the Python profiler.") @@ -1589,10 +1602,9 @@ def main(): parser.add_argument( "operation", nargs='?', default="noop", - help=("One of start, stop, reload, reset, sstart, info, status and menu. " - "Unregognized input is passed on to Django.")) + help=ARG_OPTIONS) parser.epilog = ( - "Common Django-admin commands are shell, dbshell, migrate and flush. " + "Common Django-admin commands are shell, dbshell, migrate and flush.\n" "See the django documentation for more django-admin commands.") args, unknown_args = parser.parse_known_args() From c147d86118dd60db9e36adab178e62bbb23c93c4 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 18 Jan 2018 20:43:11 +0100 Subject: [PATCH 102/189] Further cleanup of launcher docs --- evennia/server/evennia_launcher.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 8e3645f82b..3088b7dbd7 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -264,12 +264,8 @@ INFO_WINDOWS_BATFILE = \ """ CMDLINE_HELP = \ - """ - Starts or operates the Evennia MU* server. Allows for - initializing a new game directory and manages the game's database. - Most standard django-admin arguments and options can also be - passed. - """ + """Starts, initializes, manages and operates the Evennia MU* server. +Most standard django management commands are also accepted.""" VERSION_INFO = \ @@ -1605,7 +1601,7 @@ def main(): help=ARG_OPTIONS) parser.epilog = ( "Common Django-admin commands are shell, dbshell, migrate and flush.\n" - "See the django documentation for more django-admin commands.") + "See the Django documentation for more management commands.") args, unknown_args = parser.parse_known_args() From 958071d3cc43953219f94b2aae32dfa609f922d7 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 18 Jan 2018 20:44:16 +0100 Subject: [PATCH 103/189] fix leftover debug causing error --- evennia/server/evennia_launcher.py | 1 - 1 file changed, 1 deletion(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 3088b7dbd7..7b109d049c 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -1605,7 +1605,6 @@ def main(): args, unknown_args = parser.parse_known_args() - print(args.rest) # handle arguments option = args.operation From 3a7b7ab4c0de00f68b0694dc195b1a3e8e75f278 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 18 Jan 2018 21:02:24 +0100 Subject: [PATCH 104/189] Add kill signal handling to launcher --- evennia/server/evennia_launcher.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 7b109d049c..3dc58be73b 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -1212,7 +1212,7 @@ def del_pid(pidfile): os.remove(pidfile) -def kill(pidfile, killsignal=SIG, succmsg="", errmsg=""): +def kill(pidfile, component='Server', killsignal=SIG): """ Send a kill signal to a process based on PID. A customized success/error message will be returned. If clean=True, the system @@ -1220,9 +1220,8 @@ def kill(pidfile, killsignal=SIG, succmsg="", errmsg=""): Args: pidfile (str): The path of the pidfile to get the PID from. + component (str, optional): Usually one of 'Server' or 'Portal'. killsignal (int, optional): Signal identifier for signal to send. - succmsg (str, optional): Message to log on success. - errmsg (str, optional): Message to log on failure. """ pid = get_pid(pidfile) @@ -1247,13 +1246,15 @@ def kill(pidfile, killsignal=SIG, succmsg="", errmsg=""): os.kill(int(pid), killsignal) except OSError: - print("Process %(pid)s cannot be stopped. " - "The PID file 'server/%(pidfile)s' seems stale. " - "Try removing it." % {'pid': pid, 'pidfile': pidfile}) + print("{component} ({pid}) cannot be stopped. " + "The PID file '{pidfile}' seems stale. " + "Try removing it manually.".format( + component=component, pid=pid, pidfile=pidfile)) return - print("Evennia:", succmsg) + print("Sent kill signal to {component}.".format(component=component)) return - print("Evennia:", errmsg) + print("Could not send kill signal - {component} does " + "not appear to be running.".format(component=component)) def show_version_info(about=False): @@ -1669,7 +1670,7 @@ def main(): # launch menu for operation init_game_directory(CURRENT_DIR, check_db=True) run_menu() - elif option in ('status', 'info', 'start', 'reload', 'reset', 'stop', 'sstop'): + elif option in ('status', 'info', 'start', 'reload', 'reset', 'stop', 'sstop', 'kill', 'skill'): # operate the server directly init_game_directory(CURRENT_DIR, check_db=True) if option == "status": @@ -1686,6 +1687,11 @@ def main(): stop_evennia() elif option == 'sstop': stop_server_only() + elif option == 'kill': + kill(PORTAL_PIDFILE, 'Portal') + kill(SERVER_PIDFILE, 'Server') + elif option == 'skill': + kill(SERVER_PIDFILE, 'Server') elif option != "noop": # pass-through to django manager check_db = False From 03472e0d09b03ee3fcaf472b2bba27a928ec126e Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 18 Jan 2018 21:18:34 +0100 Subject: [PATCH 105/189] Expand menu with new functionality --- evennia/server/evennia_launcher.py | 33 +++++++++++++++++++----------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 3dc58be73b..b753c79e93 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -304,20 +304,20 @@ MENU = \ +----Evennia Launcher-------------------------------------------+ | | +--- Common operations -----------------------------------------+ - | | | 1) Start Portal and Server (also restart downed Server) | | 2) Reload Server (update on code changes) | | 3) Stop Portal and Server (full shutdown) | - | | +--- Other -----------------------------------------------------+ - | | | 4) Reset Server (Server shutdown with restart) | | 5) Stop Server only | - | | + | 6) Kill Portal + Server (send kill signal to process) | + | 7) Kill Server only | + +--- Information -----------------------------------------------+ + | 8) Run status | + | 9) Port info | +---------------------------------------------------------------+ | h) Help i) About info q) Abort | - +---------------------------------------------------------------+ - """ + +---------------------------------------------------------------+""" ERROR_AMP_UNCONFIGURED = \ """ @@ -325,7 +325,6 @@ ERROR_AMP_UNCONFIGURED = \ the game dir (it will then use the game's settings file) or specify the path to your game's settings file manually with the --settings option. - """ ERROR_LOGDIR_MISSING = \ @@ -344,7 +343,6 @@ ERROR_LOGDIR_MISSING = \ you used git to clone a pre-created game directory - since log files are in .gitignore they will not be cloned, which leads to the log directory also not being created.) - """ ERROR_PYTHON_VERSION = \ @@ -448,14 +446,16 @@ SERVER_INFO = \ ARG_OPTIONS = \ """Actions on installed server. One of: start - launch server+portal if not running - stop - shutdown server+portal reload - restart server (code refresh) + stop - shutdown server+portal reset - mimic server shutdown but with auto-restart sstart - start only server (requires portal) - status - server and portal run state - info - get server and portal port info + kill - send kill signal to portal+server (force) + skill = send kill signal only to server + status - show server and portal run state + info - show server and portal port info menu - show a menu of options -Unregognized input is passed on to Django.""" +Other input, like migrate and shell is passed on to Django.""" # Info formatting @@ -1550,6 +1550,15 @@ def run_menu(): reload_evennia(False, True) elif inp == 5: stop_server_only() + elif inp == 6: + kill(PORTAL_PIDFILE, 'Portal') + kill(SERVER_PIDFILE, 'Server') + elif inp == 7: + kill(SERVER_PIDFILE, 'Server') + elif inp == 8: + query_status() + elif inp == 9: + query_info() else: print("Not a valid option.") continue From 4233298345fdce99a8d961c51526515c6bf5fbbd Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 18 Jan 2018 21:55:06 +0100 Subject: [PATCH 106/189] Enabled profile, change default websocket port --- evennia/server/evennia_launcher.py | 16 +++++++++------- evennia/server/portal/portal.py | 4 ++-- evennia/settings_default.py | 6 +++--- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index b753c79e93..5e2dc065c2 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -428,16 +428,16 @@ PORTAL_INFO = \ {webserver_proxy} {webclient} internal_ports (to Server): - {amp} {webserver_internal} + {amp} """ SERVER_INFO = \ """{servername} Server {version} internal ports (to Portal): - {amp} {webserver} + {amp} {irc_rss} {info} {errors}""" @@ -637,11 +637,11 @@ def _get_twistd_cmdline(pprofiler, sprofiler): if pprofiler: portal_cmd.extend(["--savestats", - "--profiler=cprofiler", + "--profiler=cprofile", "--profile={}".format(PPROFILER_LOGFILE)]) if sprofiler: server_cmd.extend(["--savestats", - "--profiler=cprofiler", + "--profiler=cprofile", "--profile={}".format(SPROFILER_LOGFILE)]) return portal_cmd, server_cmd @@ -770,6 +770,8 @@ def start_evennia(pprofiler=False, sprofiler=False): reactor.stop() def _portal_started(*args): + print("... Portal started.\nServer starting {} ...".format( + "(under cProfile)" if sprofiler else "")) wait_for_status_reply(_server_started) send_instruction(SSTART, server_cmd) @@ -780,11 +782,11 @@ def start_evennia(pprofiler=False, sprofiler=False): print("Server is already running as process {pid}. Not restarted.".format(pid=spid)) reactor.stop() else: - print("Server starting {}...".format("(under cProfile)" if pprofiler else "")) + print("Server starting {}...".format("(under cProfile)" if sprofiler else "")) send_instruction(SSTART, server_cmd, _server_started, _fail) def _portal_not_running(fail): - print("Portal starting {}...".format("(under cProfile)" if sprofiler else "")) + print("Portal starting {}...".format("(under cProfile)" if pprofiler else "")) try: Popen(portal_cmd, env=getenv(), bufsize=-1) except Exception as e: @@ -1687,7 +1689,7 @@ def main(): elif option == "info": query_info() elif option == "start": - start_evennia(False, args.profiler) + start_evennia(args.profiler, args.profiler) elif option == 'reload': reload_evennia(args.profiler) elif option == 'reset': diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index 2cffbc8f55..edd71e163c 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -196,7 +196,7 @@ if AMP_ENABLED: from evennia.server.portal import amp_server - INFO_DICT["amp"] = 'amp: %s)' % AMP_PORT + INFO_DICT["amp"] = 'amp: %s' % AMP_PORT factory = amp_server.AMPServerFactory(PORTAL) amp_service = internet.TCPServer(AMP_PORT, factory, interface=AMP_INTERFACE) @@ -330,7 +330,7 @@ if WEBSERVER_ENABLED: interface=interface) proxy_service.setName('EvenniaWebProxy%s' % pstring) PORTAL.services.addService(proxy_service) - INFO_DICT["webserver_proxy"].append("website%s: %s" % (ifacestr, proxyport)) + INFO_DICT["webserver_proxy"].append("webserver-proxy%s: %s" % (ifacestr, proxyport)) INFO_DICT["webserver_internal"].append("webserver: %s" % serverport) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 19666b8bf1..2b6bb55584 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -65,7 +65,7 @@ ALLOWED_HOSTS = ["*"] # the Portal proxy presents to the world. The serverports are # the internal ports the proxy uses to forward data to the Server-side # webserver (these should not be publicly open) -WEBSERVER_PORTS = [(4001, 4002)] +WEBSERVER_PORTS = [(4001, 4005)] # Interface addresses to listen to. If 0.0.0.0, listen to all. Use :: for IPv6. WEBSERVER_INTERFACES = ['0.0.0.0'] # IP addresses that may talk to the server in a reverse proxy configuration, @@ -89,12 +89,12 @@ WEBSOCKET_CLIENT_ENABLED = True # working through a proxy or docker port-remapping, the environment variable # WEBCLIENT_CLIENT_PROXY_PORT can be used to override this port only for the # front-facing client's sake. -WEBSOCKET_CLIENT_PORT = 4005 +WEBSOCKET_CLIENT_PORT = 4002 # Interface addresses to listen to. If 0.0.0.0, listen to all. Use :: for IPv6. WEBSOCKET_CLIENT_INTERFACE = '0.0.0.0' # Actual URL for webclient component to reach the websocket. You only need # to set this if you know you need it, like using some sort of proxy setup. -# If given it must be on the form "ws[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. # e.g. ws://external.example.com or wss://external.example.com:443 WEBSOCKET_CLIENT_URL = None From 713766b33a7c2d56a73b62d7c8d9a449fa43d906 Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 19 Jan 2018 01:27:52 +0100 Subject: [PATCH 107/189] Change log format, rename, join server/portal logs --- evennia/server/evennia_launcher.py | 2 +- evennia/server/portal/amp_server.py | 6 +++- evennia/server/portal/portal.py | 20 +++++++++-- evennia/server/portal/ssh.py | 11 +++++- evennia/server/portal/ssl.py | 9 +++++ evennia/server/portal/telnet.py | 9 +++++ evennia/server/server.py | 8 +++++ evennia/server/webserver.py | 6 ++++ evennia/utils/logger.py | 53 +++++++++++++++++++++++++++++ 9 files changed, 118 insertions(+), 6 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 5e2dc065c2..a9fc76e767 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -489,7 +489,7 @@ def print_info(portal_info_dict, server_info_dict): info = pstr + ("\n\n" + sstr if sstr else "") maxwidth = max(len(line) for line in info.split("\n")) - top_border = "-" * (maxwidth - 11) + " Evennia " + "--" + top_border = "-" * (maxwidth - 11) + " Evennia " + "---" border = "-" * (maxwidth + 1) print(top_border + "\n" + info + '\n' + border) diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index 98e93e7a24..ed51b8952a 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -35,6 +35,10 @@ class AMPServerFactory(protocol.ServerFactory): """ 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. @@ -282,7 +286,7 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): _, server_connected, _, _, _, _ = self.get_status() - logger.log_msg("Evennia Launcher->Portal operation %s received" % (ord(operation))) + # 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 diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index edd71e163c..db054c0e36 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -14,6 +14,8 @@ import os from twisted.application import internet, service from twisted.internet import protocol, reactor +from twisted.python.log import ILogObserver + import django django.setup() from django.conf import settings @@ -23,6 +25,7 @@ evennia._init() from evennia.utils.utils import get_evennia_version, mod_import, make_iter from evennia.server.portal.portalsessionhandler import PORTAL_SESSIONS +from evennia.utils import logger from evennia.server.webserver import EvenniaReverseProxyResource from django.db import connection @@ -180,6 +183,11 @@ class Portal(object): # what to execute from. application = service.Application('Portal') +# custom logging +logfile = logger.WeeklyLogFile(os.path.basename(settings.PORTAL_LOG_FILE), + os.path.dirname(settings.PORTAL_LOG_FILE)) +application.setComponent(ILogObserver, logger.PortalLogObserver(logfile).emit) + # The main Portal server program. This sets up the database # and is where we store all the other services. PORTAL = Portal(application) @@ -219,7 +227,7 @@ if TELNET_ENABLED: ifacestr = "-%s" % interface for port in TELNET_PORTS: pstring = "%s:%s" % (ifacestr, port) - factory = protocol.ServerFactory() + factory = telnet.TelnetServerFactory() factory.noisy = False factory.protocol = telnet.TelnetProtocol factory.sessionhandler = PORTAL_SESSIONS @@ -242,7 +250,7 @@ if SSL_ENABLED: ifacestr = "-%s" % interface for port in SSL_PORTS: pstring = "%s:%s" % (ifacestr, port) - factory = protocol.ServerFactory() + factory = ssl.SSLServerFactory() factory.noisy = False factory.sessionhandler = PORTAL_SESSIONS factory.protocol = ssl.SSLProtocol @@ -313,7 +321,12 @@ if WEBSERVER_ENABLED: if w_interface not in ('0.0.0.0', '::') or len(WEBSERVER_INTERFACES) > 1: w_ifacestr = "-%s" % interface 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.protocol = webclient.WebSocketClient factory.sessionhandler = PORTAL_SESSIONS @@ -325,6 +338,7 @@ if WEBSERVER_ENABLED: INFO_DICT["webclient"].append(webclientstr) web_root = Website(web_root, logPath=settings.HTTP_LOG_FILE) + web_root.is_portal = True proxy_service = internet.TCPServer(proxyport, web_root, interface=interface) diff --git a/evennia/server/portal/ssh.py b/evennia/server/portal/ssh.py index 7dde511637..3920c795d9 100644 --- a/evennia/server/portal/ssh.py +++ b/evennia/server/portal/ssh.py @@ -39,7 +39,7 @@ from twisted.conch.ssh import common from twisted.conch.insults import insults from twisted.conch.manhole_ssh import TerminalRealm, _Glue, ConchFactory from twisted.conch.manhole import Manhole, recvline -from twisted.internet import defer +from twisted.internet import defer, protocol from twisted.conch import interfaces as iconch from twisted.python import components from django.conf import settings @@ -59,6 +59,15 @@ CTRL_BACKSLASH = '\x1c' CTRL_L = '\x0c' +# not used atm +class SSHServerFactory(protocol.ServerFactory): + "This is only to name this better in logs" + noisy = False + + def logPrefix(self): + return "SSH" + + class SshProtocol(Manhole, session.Session): """ Each account connecting over ssh gets this protocol assigned to diff --git a/evennia/server/portal/ssl.py b/evennia/server/portal/ssl.py index 8b638ed23d..c2c6284c93 100644 --- a/evennia/server/portal/ssl.py +++ b/evennia/server/portal/ssl.py @@ -7,6 +7,7 @@ from __future__ import print_function import os import sys +from twisted.internet import protocol try: import OpenSSL from twisted.internet import ssl as twisted_ssl @@ -50,6 +51,14 @@ example (linux, using the openssl program): """ +class SSLServerFactory(protocol.ServerFactory): + "This is only to name this better in logs" + noisy = False + + def logPrefix(self): + return "SSL" + + class SSLProtocol(TelnetProtocol): """ Communication is the same as telnet, except data transfer diff --git a/evennia/server/portal/telnet.py b/evennia/server/portal/telnet.py index a1877d15ee..357b20c81b 100644 --- a/evennia/server/portal/telnet.py +++ b/evennia/server/portal/telnet.py @@ -8,6 +8,7 @@ sessions etc. """ import re +from twisted.internet import protocol from twisted.internet.task import LoopingCall from twisted.conch.telnet import Telnet, StatefulTelnetProtocol from twisted.conch.telnet import IAC, NOP, LINEMODE, GA, WILL, WONT, ECHO, NULL @@ -26,6 +27,14 @@ _RE_SCREENREADER_REGEX = re.compile(r"%s" % settings.SCREENREADER_REGEX_STRIP, r _IDLE_COMMAND = settings.IDLE_COMMAND + "\n" +class TelnetServerFactory(protocol.ServerFactory): + "This is only to name this better in logs" + noisy = False + + def logPrefix(self): + return "Telnet" + + class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session): """ Each player connecting over telnet (ie using most traditional mud diff --git a/evennia/server/server.py b/evennia/server/server.py index 3830f3c84a..1ce422d19b 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -16,6 +16,7 @@ from twisted.web import static from twisted.application import internet, service from twisted.internet import reactor, defer from twisted.internet.task import LoopingCall +from twisted.python.log import ILogObserver import django django.setup() @@ -32,6 +33,7 @@ from evennia.server.models import ServerConfig from evennia.server import initial_setup from evennia.utils.utils import get_evennia_version, mod_import, make_iter +from evennia.utils import logger from evennia.comms import channelhandler from evennia.server.sessionhandler import SESSIONS @@ -531,6 +533,11 @@ ServerConfig.objects.conf("server_starting_mode", True) # what to execute from. application = service.Application('Evennia') +# custom logging +logfile = logger.WeeklyLogFile(os.path.basename(settings.PORTAL_LOG_FILE), + os.path.dirname(settings.PORTAL_LOG_FILE)) +application.setComponent(ILogObserver, logger.ServerLogObserver(logfile).emit) + # The main evennia server program. This sets up the database # and is where we store all the other services. EVENNIA = Evennia(application) @@ -577,6 +584,7 @@ if WEBSERVER_ENABLED: web_root = WEB_PLUGINS_MODULE.at_webserver_root_creation(web_root) web_site = Website(web_root, logPath=settings.HTTP_LOG_FILE) + web_site.is_portal = False INFO_DICT["webserver"] = "" for proxyport, serverport in WEBSERVER_PORTS: diff --git a/evennia/server/webserver.py b/evennia/server/webserver.py index 260b38071d..b1912bf120 100644 --- a/evennia/server/webserver.py +++ b/evennia/server/webserver.py @@ -212,6 +212,12 @@ class Website(server.Site): """ noisy = False + def logPrefix(self): + "How to be named in logs" + if hasattr(self, "is_portal") and self.is_portal: + return "Webserver-proxy" + return "Webserver" + def log(self, request): """Conditional logging""" if _DEBUG: diff --git a/evennia/utils/logger.py b/evennia/utils/logger.py index a8bcfb00e7..f288f533d6 100644 --- a/evennia/utils/logger.py +++ b/evennia/utils/logger.py @@ -20,6 +20,7 @@ import time from datetime import datetime from traceback import format_exc from twisted.python import log, logfile +from twisted.python import util as twisted_util from twisted.internet.threads import deferToThread @@ -29,6 +30,58 @@ _TIMEZONE = None _CHANNEL_LOG_NUM_TAIL_LINES = None +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 = '[P]' + + 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"]) + if timeStr.startswith("20"): + timeStr = timeStr[2:] + 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 = "|S|" + + def timeformat(when=None): """ This helper function will format the current time in the same From fbbf920587c3844f539c8ae1e0ac5b0c2b42d0b5 Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 19 Jan 2018 01:58:24 +0100 Subject: [PATCH 108/189] Homogenize log time format in logger module --- evennia/utils/logger.py | 76 ++++++++++++++++++++++------------------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/evennia/utils/logger.py b/evennia/utils/logger.py index f288f533d6..fe1d31bb7d 100644 --- a/evennia/utils/logger.py +++ b/evennia/utils/logger.py @@ -30,6 +30,41 @@ _TIMEZONE = None _CHANNEL_LOG_NUM_TAIL_LINES = None +# logging overrides + + +def timeformat(when=None): + """ + This helper function will format the current time in the same + way as the twisted logger does, including time zone info. Only + difference from official logger is that we only use two digits + for the year. + + Args: + when (int, optional): This is a time in POSIX seconds on the form + given by time.time(). If not given, this function will + use the current time. + + Returns: + timestring (str): A formatted string of the given time. + """ + when = when if when else time.time() + + # time zone offset: UTC - the actual offset + tz_offset = datetime.utcfromtimestamp(when) - datetime.fromtimestamp(when) + tz_offset = tz_offset.days * 86400 + tz_offset.seconds + # correct given time to utc + when = datetime.utcfromtimestamp(when - tz_offset) + tz_hour = abs(int(tz_offset // 3600)) + tz_mins = abs(int(tz_offset // 60 % 60)) + tz_sign = "-" if tz_offset >= 0 else "+" + + return '%d-%02d-%02d %02d:%02d:%02d%s%02d%02d' % ( + when.year - 2000, when.month, when.day, + when.hour, when.minute, when.second, + tz_sign, tz_hour, tz_mins) + + class WeeklyLogFile(logfile.DailyLogFile): """ Log file that rotates once per week @@ -55,7 +90,7 @@ class PortalLogObserver(log.FileLogObserver): Reformat logging """ timeFormat = None - prefix = '[P]' + prefix = " |Portal| " def emit(self, eventDict): """ @@ -66,50 +101,19 @@ class PortalLogObserver(log.FileLogObserver): if text is None: return - timeStr = self.formatTime(eventDict["time"]) - if timeStr.startswith("20"): - timeStr = timeStr[2:] + # 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.write, timeStr + "%s" % self.prefix + msgStr) twisted_util.untilConcludes(self.flush) class ServerLogObserver(PortalLogObserver): - prefix = "|S|" - - -def timeformat(when=None): - """ - This helper function will format the current time in the same - way as twisted's logger does, including time zone info. - - Args: - when (int, optional): This is a time in POSIX seconds on the form - given by time.time(). If not given, this function will - use the current time. - - Returns: - timestring (str): A formatted string of the given time. - """ - when = when if when else time.time() - - # time zone offset: UTC - the actual offset - tz_offset = datetime.utcfromtimestamp(when) - datetime.fromtimestamp(when) - tz_offset = tz_offset.days * 86400 + tz_offset.seconds - # correct given time to utc - when = datetime.utcfromtimestamp(when - tz_offset) - tz_hour = abs(int(tz_offset // 3600)) - tz_mins = abs(int(tz_offset // 60 % 60)) - tz_sign = "-" if tz_offset >= 0 else "+" - - return '%d-%02d-%02d %02d:%02d:%02d%s%02d%02d' % ( - when.year, when.month, when.day, - when.hour, when.minute, when.second, - tz_sign, tz_hour, tz_mins) + prefix = " " def log_msg(msg): From 7f9fad5594923f04b6d338c24314571a28bd755e Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Fri, 19 Jan 2018 01:57:48 -0500 Subject: [PATCH 109/189] Whitespace formatting MULTISESSION_MODE comments --- evennia/settings_default.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 19666b8bf1..dc2e064633 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -470,7 +470,7 @@ BASE_SCRIPT_TYPECLASS = "typeclasses.scripts.Script" DEFAULT_HOME = "#2" # The start position for new characters. Default is Limbo (#2). # MULTISESSION_MODE = 0, 1 - used by default unloggedin create command -# MULTISESSION_MODE = 2,3 - used by default character_create command +# MULTISESSION_MODE = 2, 3 - used by default character_create command START_LOCATION = "#2" # Lookups of Attributes, Tags, Nicks, Aliases can be aggressively # cached to avoid repeated database hits. This often gives noticeable @@ -545,8 +545,8 @@ INLINEFUNC_MODULES = ["evennia.utils.inlinefuncs", # 3 - like mode 2, except multiple sessions can puppet one character, each # session getting the same data. MULTISESSION_MODE = 0 -# The maximum number of characters allowed for MULTISESSION_MODE 2,3. This is -# checked by the default ooc char-creation command. Forced to 1 for +# The maximum number of characters allowed for MULTISESSION_MODE 2, 3. +# This is checked by the default ooc char-creation command. Forced to 1 for # MULTISESSION_MODE 0 and 1. MAX_NR_CHARACTERS = 1 # The access hierarchy, in climbing order. A higher permission in the From 4769599cf9f81c63aea3ec7e05169ed275c42b81 Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 19 Jan 2018 09:50:26 +0100 Subject: [PATCH 110/189] Clean tz from log if in CET time --- evennia/utils/logger.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/evennia/utils/logger.py b/evennia/utils/logger.py index fe1d31bb7d..907dfe9053 100644 --- a/evennia/utils/logger.py +++ b/evennia/utils/logger.py @@ -38,7 +38,7 @@ def timeformat(when=None): This helper function will format the current time in the same 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. + for the year and don't show timezone for CET times. Args: when (int, optional): This is a time in POSIX seconds on the form @@ -55,14 +55,19 @@ def timeformat(when=None): tz_offset = tz_offset.days * 86400 + tz_offset.seconds # correct given time to utc when = datetime.utcfromtimestamp(when - tz_offset) - tz_hour = abs(int(tz_offset // 3600)) - tz_mins = abs(int(tz_offset // 60 % 60)) - tz_sign = "-" if tz_offset >= 0 else "+" - return '%d-%02d-%02d %02d:%02d:%02d%s%02d%02d' % ( + if tz_offset == 0: + tz = "" + else: + tz_hour = abs(int(tz_offset // 3600)) + tz_mins = abs(int(tz_offset // 60 % 60)) + tz_sign = "-" if tz_offset >= 0 else "+" + tz = "%s%02d%s" % (tz_sign, tz_hour, + (":%02d" % tz_mins if tz_mins else "")) + + return '%d-%02d-%02d %02d:%02d:%02d%s' % ( when.year - 2000, when.month, when.day, - when.hour, when.minute, when.second, - tz_sign, tz_hour, tz_mins) + when.hour, when.minute, when.second, tz) class WeeklyLogFile(logfile.DailyLogFile): From a9648cf2bbc7b3e0bce17ab283f885c7023ba81a Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Fri, 19 Jan 2018 05:28:03 -0500 Subject: [PATCH 111/189] Fix to catch non `@` version of command --- evennia/commands/default/building.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 9a7caf8567..de263fa4ff 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2315,7 +2315,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS): else: result = result[0] string += "\n|g %s - %s|n" % (result.get_display_name(caller), result.path) - if self.cmdstring == "@locate" and not is_account and result.location: + if "locate" in self.cmdstring and not is_account and result.location: string += " Location: {}".format(result.location.get_display_name(caller)) else: # Not an account/dbref search but a wider search; build a queryset. @@ -2352,7 +2352,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS): else: string = "|wOne Match|n(#%i-#%i%s):" % (low, high, restrictions) string += "\n |g%s - %s|n" % (results[0].get_display_name(caller), results[0].path) - if self.cmdstring == "@locate" and nresults == 1 and results[0].location: + if "locate" in self.cmdstring and nresults == 1 and results[0].location: string += " Location: {}".format(results[0].location.get_display_name(caller)) else: string = "|wMatch|n(#%i-#%i%s):" % (low, high, restrictions) From a2d63c9582c684dd69c80f27df50625388729fb2 Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 19 Jan 2018 23:47:14 +0100 Subject: [PATCH 112/189] Add log-tailing to launcher options --- evennia/server/evennia_launcher.py | 120 ++++++++++++++++++++++++++++- evennia/server/portal/portal.py | 4 +- evennia/server/server.py | 4 +- evennia/utils/logger.py | 5 ++ 4 files changed, 125 insertions(+), 8 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index a9fc76e767..de4ee0591a 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -75,6 +75,7 @@ PPROFILER_LOGFILE = None TEST_MODE = False ENFORCED_SETTING = False +TAIL_LOG_MODE = False # communication constants @@ -88,7 +89,7 @@ SSTART = chr(15) # server start PSHUTD = chr(16) # portal (+server) shutdown SSHUTD = chr(17) # server-only shutdown PSTATUS = chr(18) # ping server or portal status -SRESET = chr(19) # shutdown server in reset mode +SRESET = chr(19) # shutdown server in reset mode # requirements PYTHON_MIN = '2.7' @@ -624,10 +625,8 @@ def _get_twistd_cmdline(pprofiler, sprofiler): """ portal_cmd = [TWISTED_BINARY, - "--logfile={}".format(PORTAL_LOGFILE), "--python={}".format(PORTAL_PY_FILE)] server_cmd = [TWISTED_BINARY, - "--logfile={}".format(SERVER_LOGFILE), "--python={}".format(SERVER_PY_FILE)] if os.name != 'nt': @@ -915,6 +914,104 @@ def query_info(): send_instruction(PSTATUS, None, _portal_running, _portal_not_running) +def tail_server_log(filename, rate=1): + """ + Tail the server logfile interactively, printing to stdout + + When first starting, this will display the tail of the log file. After + that it will poll the log file repeatedly and display changes. + + Args: + filename (str): Path to log file. + rate (int, optional): How often to poll the log file. + + """ + def _file_changed(filename, prev_size): + "Get size of file in bytes, get diff compared with previous size" + new_size = os.path.getsize(filename) + return new_size != prev_size, new_size + + + def _get_new_lines(filehandle, old_linecount): + "count lines, get the ones not counted before" + + def _block(filehandle, size=65536): + "File block generator for quick traversal" + while True: + dat = filehandle.read(size) + if not dat: + break + yield dat + + # count number of lines in file + new_linecount = sum(blck.count("\n") for blck in _block(filehandle)) + + if new_linecount < old_linecount: + # this could happen if the file was manually deleted or edited + print("Log file has shrunk. Restart log reader.") + sys.exit() + + lines_to_get = max(0, new_linecount - old_linecount) + + if not lines_to_get: + return [], old_linecount + + lines_found = [] + buffer_size = 4098 + block_count = -1 + + while len(lines_found) < lines_to_get: + try: + # scan backwards in file, starting from the end + filehandle.seek(block_count * buffer_size, os.SEEK_END) + except IOError: + # file too small for current seek, include entire file + filehandle.seek(0) + lines_found = filehandle.readlines() + break + lines_found = filehandle.readlines() + block_count -= 1 + + # only actually return the new lines + return lines_found[-lines_to_get:], new_linecount + + def _tail_file(filename, file_size, line_count, max_lines=None): + """This will cycle repeatedly, printing new lines""" + + # poll for changes + has_changed, file_size = _file_changed(filename, file_size) + + if has_changed: + try: + with open(filename, 'r') as filehandle: + new_lines, line_count = _get_new_lines(filehandle, line_count) + except IOError: + # the log file might not exist yet. Wait a little, then try again ... + pass + else: + if max_lines: + # first startup + print(" Tailing logfile {} ...".format(filename)) + new_lines = new_lines[-max_lines:] + + # print to stdout without line break (log has its own line feeds) + sys.stdout.write("".join(new_lines)) + sys.stdout.flush() + + # set up the next poll + reactor.callLater(rate, _tail_file, filename, file_size, line_count, max_lines=100) + + reactor.callLater(0, _tail_file, filename, 0, 0, max_lines=20) + reactor.run() + + +# ------------------------------------------------------------ +# +# Environment setup +# +# ------------------------------------------------------------ + + def evennia_version(): """ Get the Evennia version info from the main package. @@ -1572,6 +1669,7 @@ def main(): Run the evennia launcher main program. """ + global TAIL_LOG_MODE # set up argument parser @@ -1583,6 +1681,9 @@ def main(): parser.add_argument( '--init', action='store', dest="init", metavar="", help="Creates a new gamedir 'name' at current location.") + parser.add_argument( + '--log', '-l', action='store_true', dest='tail_log', default=False, + help="Tail the server logfile to standard out.") parser.add_argument( '--list', nargs='+', action='store', dest='listsetting', metavar="all|", help=("List values for one or more server settings. Use 'all' to \n list all " @@ -1623,6 +1724,8 @@ def main(): # make sure we have everything check_main_evennia_dependencies() + TAIL_LOG_MODE = args.tail_log + if not args: # show help pane print(CMDLINE_HELP) @@ -1740,10 +1843,19 @@ def main(): args = ", ".join(args) kwargs = ", ".join(["--%s" % kw for kw in kwargs]) print(ERROR_INPUT.format(traceback=exc, args=args, kwargs=kwargs)) - else: + + elif not TAIL_LOG_MODE: # no input; print evennia info print(ABOUT_INFO) + if TAIL_LOG_MODE: + # start the log-tail last + if not SERVER_LOGFILE: + init_game_directory(CURRENT_DIR, check_db=False) + tail_server_log(SERVER_LOGFILE) + + + if __name__ == '__main__': # start Evennia from the command line diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index db054c0e36..d522975999 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -184,8 +184,8 @@ class Portal(object): application = service.Application('Portal') # custom logging -logfile = logger.WeeklyLogFile(os.path.basename(settings.PORTAL_LOG_FILE), - os.path.dirname(settings.PORTAL_LOG_FILE)) +logfile = logger.WeeklyLogFile(os.path.basename(settings.SERVER_LOG_FILE), + os.path.dirname(settings.SERVER_LOG_FILE)) application.setComponent(ILogObserver, logger.PortalLogObserver(logfile).emit) # The main Portal server program. This sets up the database diff --git a/evennia/server/server.py b/evennia/server/server.py index 1ce422d19b..c665fc143e 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -534,8 +534,8 @@ ServerConfig.objects.conf("server_starting_mode", True) application = service.Application('Evennia') # custom logging -logfile = logger.WeeklyLogFile(os.path.basename(settings.PORTAL_LOG_FILE), - os.path.dirname(settings.PORTAL_LOG_FILE)) +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 diff --git a/evennia/utils/logger.py b/evennia/utils/logger.py index 907dfe9053..30af5d2226 100644 --- a/evennia/utils/logger.py +++ b/evennia/utils/logger.py @@ -187,6 +187,11 @@ 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: From 94606141504be2eafa7661c720323a6198ab1fff Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 20 Jan 2018 00:06:34 +0100 Subject: [PATCH 113/189] Make --log operate simultaneously to other commands --- evennia/server/evennia_launcher.py | 87 ++++++++++++++++-------------- 1 file changed, 47 insertions(+), 40 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index de4ee0591a..42bdd8cf84 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -75,7 +75,9 @@ PPROFILER_LOGFILE = None TEST_MODE = False ENFORCED_SETTING = False -TAIL_LOG_MODE = False + +REACTOR_RUN = False +NO_REACTOR_STOP = False # communication constants @@ -557,12 +559,17 @@ class AMPLauncherProtocol(amp.AMP): return {"status": ""} +def _reactor_stop(): + if not NO_REACTOR_STOP: + reactor.stop() + + def send_instruction(operation, arguments, callback=None, errback=None): """ Send instruction and handle the response. """ - global AMP_CONNECTION + global AMP_CONNECTION, REACTOR_RUN if None in (AMP_HOST, AMP_PORT, AMP_INTERFACE): print(ERROR_AMP_UNCONFIGURED) @@ -610,9 +617,7 @@ def send_instruction(operation, arguments, callback=None, errback=None): point = endpoints.TCP4ClientEndpoint(reactor, AMP_HOST, AMP_PORT) deferred = endpoints.connectProtocol(point, AMPLauncherProtocol()) deferred.addCallbacks(_on_connect, _on_connect_fail) - if not reactor.running: - reactor.run() - + REACTOR_RUN = True def _parse_status(response): "Unpack the status information" @@ -661,12 +666,12 @@ def query_status(callback=None): pstatus, sstatus, ppid, spid, pinfo, sinfo = _parse_status(response) print("Portal: {} (pid {})\nServer: {} (pid {})".format( wmap[pstatus], ppid, wmap[sstatus], spid)) - reactor.stop() + _reactor_stop() def _errback(fail): pstatus, sstatus = False, False print("Portal: {}\nServer: {}".format(wmap[pstatus], wmap[sstatus])) - reactor.stop() + _reactor_stop() send_instruction(PSTATUS, None, _callback, _errback) @@ -705,14 +710,14 @@ def wait_for_status(portal_running=True, server_running=True, callback=None, err if callback: callback(prun, srun) else: - reactor.stop() + _reactor_stop() else: if retries <= 0: if errback: errback(prun, srun) else: print("Connection to Evennia timed out. Try again.") - reactor.stop() + _reactor_stop() else: reactor.callLater(rate, wait_for_status, portal_running, server_running, @@ -727,14 +732,14 @@ def wait_for_status(portal_running=True, server_running=True, callback=None, err if callback: callback(portal_running, server_running) else: - reactor.stop() + _reactor_stop() else: if retries <= 0: if errback: errback(portal_running, server_running) else: print("Connection to Evennia timed out. Try again.") - reactor.stop() + _reactor_stop() else: reactor.callLater(rate, wait_for_status, portal_running, server_running, @@ -759,14 +764,14 @@ def start_evennia(pprofiler=False, sprofiler=False): def _fail(fail): print(fail) - reactor.stop() + _reactor_stop() def _server_started(response): print("... Server started.\nEvennia running.") if response: _, _, _, _, pinfo, sinfo = response print_info(pinfo, sinfo) - reactor.stop() + _reactor_stop() def _portal_started(*args): print("... Portal started.\nServer starting {} ...".format( @@ -779,7 +784,7 @@ def start_evennia(pprofiler=False, sprofiler=False): print("Portal is already running as process {pid}. Not restarted.".format(pid=ppid)) if srun: print("Server is already running as process {pid}. Not restarted.".format(pid=spid)) - reactor.stop() + _reactor_stop() else: print("Server starting {}...".format("(under cProfile)" if sprofiler else "")) send_instruction(SSTART, server_cmd, _server_started, _fail) @@ -790,7 +795,7 @@ def start_evennia(pprofiler=False, sprofiler=False): Popen(portal_cmd, env=getenv(), bufsize=-1) except Exception as e: print(PROCESS_ERROR.format(component="Portal", traceback=e)) - reactor.stop() + _reactor_stop() wait_for_status(True, None, _portal_started) send_instruction(PSTATUS, None, _portal_running, _portal_not_running) @@ -808,11 +813,11 @@ def reload_evennia(sprofiler=False, reset=False): def _server_restarted(*args): print("... Server re-started.") - reactor.stop() + _reactor_stop() def _server_reloaded(status): print("... Server {}.".format("reset" if reset else "reloaded")) - reactor.stop() + _reactor_stop() def _server_stopped(status): wait_for_status_reply(_server_reloaded) @@ -844,7 +849,7 @@ def stop_evennia(): """ def _portal_stopped(*args): print("... Portal stopped.\nEvennia shut down.") - reactor.stop() + _reactor_stop() def _server_stopped(*args): print("... Server stopped.\nStopping Portal ...") @@ -864,7 +869,7 @@ def stop_evennia(): def _portal_not_running(fail): print("Evennia is not running.") - reactor.stop() + _reactor_stop() send_instruction(PSTATUS, None, _portal_running, _portal_not_running) @@ -876,7 +881,7 @@ def stop_server_only(): """ def _server_stopped(*args): print("... Server stopped.") - reactor.stop() + _reactor_stop() def _portal_running(response): _, srun, _, _, _, _ = _parse_status(response) @@ -886,11 +891,11 @@ def stop_server_only(): send_instruction(SSHUTD, {}) else: print("Server is not running.") - reactor.stop() + _reactor_stop() def _portal_not_running(fail): print("Evennia is not running.") - reactor.stop() + _reactor_stop() send_instruction(PSTATUS, None, _portal_running, _portal_not_running) @@ -903,7 +908,7 @@ def query_info(): def _got_status(status): _, _, _, _, pinfo, sinfo = _parse_status(status) print_info(pinfo, sinfo) - reactor.stop() + _reactor_stop() def _portal_running(response): query_status(_got_status) @@ -926,6 +931,8 @@ def tail_server_log(filename, rate=1): rate (int, optional): How often to poll the log file. """ + global REACTOR_RUN + def _file_changed(filename, prev_size): "Get size of file in bytes, get diff compared with previous size" new_size = os.path.getsize(filename) @@ -991,7 +998,6 @@ def tail_server_log(filename, rate=1): else: if max_lines: # first startup - print(" Tailing logfile {} ...".format(filename)) new_lines = new_lines[-max_lines:] # print to stdout without line break (log has its own line feeds) @@ -1002,7 +1008,7 @@ def tail_server_log(filename, rate=1): reactor.callLater(rate, _tail_file, filename, file_size, line_count, max_lines=100) reactor.callLater(0, _tail_file, filename, 0, 0, max_lines=20) - reactor.run() + REACTOR_RUN = True # ------------------------------------------------------------ @@ -1669,8 +1675,6 @@ def main(): Run the evennia launcher main program. """ - global TAIL_LOG_MODE - # set up argument parser parser = ArgumentParser(description=CMDLINE_HELP, formatter_class=argparse.RawTextHelpFormatter) @@ -1724,8 +1728,6 @@ def main(): # make sure we have everything check_main_evennia_dependencies() - TAIL_LOG_MODE = args.tail_log - if not args: # show help pane print(CMDLINE_HELP) @@ -1755,8 +1757,8 @@ def main(): if args.altsettings: # use alternative settings file - sfile = args.altsettings[0] global SETTINGSFILE, SETTINGS_DOTPATH, ENFORCED_SETTING + sfile = args.altsettings[0] SETTINGSFILE = sfile ENFORCED_SETTING = True SETTINGS_DOTPATH = "server.conf.%s" % sfile.rstrip(".py") @@ -1772,6 +1774,15 @@ def main(): print(ERROR_INITSETTINGS) sys.exit() + if args.tail_log: + # set up for tailing the server log file + global NO_REACTOR_STOP + NO_REACTOR_STOP = True + if not SERVER_LOGFILE: + init_game_directory(CURRENT_DIR, check_db=False) + tail_server_log(SERVER_LOGFILE) + print(" Tailing logfile {} ...".format(SERVER_LOGFILE)) + if args.dummyrunner: # launch the dummy runner init_game_directory(CURRENT_DIR, check_db=True) @@ -1786,7 +1797,8 @@ def main(): run_menu() elif option in ('status', 'info', 'start', 'reload', 'reset', 'stop', 'sstop', 'kill', 'skill'): # operate the server directly - init_game_directory(CURRENT_DIR, check_db=True) + if not SERVER_LOGFILE: + init_game_directory(CURRENT_DIR, check_db=True) if option == "status": query_status() elif option == "info": @@ -1844,17 +1856,12 @@ def main(): kwargs = ", ".join(["--%s" % kw for kw in kwargs]) print(ERROR_INPUT.format(traceback=exc, args=args, kwargs=kwargs)) - elif not TAIL_LOG_MODE: - # no input; print evennia info + elif not args.tail_log: + # no input; print evennia info (don't pring if we're tailing log) print(ABOUT_INFO) - if TAIL_LOG_MODE: - # start the log-tail last - if not SERVER_LOGFILE: - init_game_directory(CURRENT_DIR, check_db=False) - tail_server_log(SERVER_LOGFILE) - - + if REACTOR_RUN: + reactor.run() if __name__ == '__main__': From 4cb3fc70125044d19ecb9da95fc311764e53f37d Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 20 Jan 2018 00:14:04 +0100 Subject: [PATCH 114/189] Add MERGE_LOGS for those not wanting to merge server/portal logs --- evennia/server/portal/portal.py | 4 ++-- evennia/settings_default.py | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index d522975999..d7fcadcbcb 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -184,8 +184,8 @@ class Portal(object): application = service.Application('Portal') # custom logging -logfile = logger.WeeklyLogFile(os.path.basename(settings.SERVER_LOG_FILE), - os.path.dirname(settings.SERVER_LOG_FILE)) +logfile = settings.SERVER_LOG_FILE if settings.MERGE_LOGS else settings.PORTAL_LOG_FILE +logfile = logger.WeeklyLogFile(os.path.basename(logfile), os.path.dirname(logfile)) application.setComponent(ILogObserver, logger.PortalLogObserver(logfile).emit) # The main Portal server program. This sets up the database diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 2b6bb55584..3bb2b504aa 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -134,6 +134,8 @@ LOG_DIR = os.path.join(GAME_DIR, 'server', 'logs') SERVER_LOG_FILE = os.path.join(LOG_DIR, 'server.log') PORTAL_LOG_FILE = os.path.join(LOG_DIR, 'portal.log') HTTP_LOG_FILE = os.path.join(LOG_DIR, 'http_requests.log') +# if this is true, merge logs into only the SERVER_LOG_FILE location. +MERGE_LOGS = True # if this is set to the empty string, lockwarnings will be turned off. LOCKWARNING_LOG_FILE = os.path.join(LOG_DIR, 'lockwarnings.log') # Rotate log files when server and/or portal stops. This will keep log From f9c35eb23aa30752d2311a7b3bf70ee656f444c2 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 20 Jan 2018 00:34:13 +0100 Subject: [PATCH 115/189] Add support for tail-log in launcher menu --- evennia/server/evennia_launcher.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 42bdd8cf84..e33f13a4b2 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -316,8 +316,9 @@ MENU = \ | 6) Kill Portal + Server (send kill signal to process) | | 7) Kill Server only | +--- Information -----------------------------------------------+ - | 8) Run status | - | 9) Port info | + | 8) Tail log file + | 9) Run status | + | 10) Port info | +---------------------------------------------------------------+ | h) Help i) About info q) Abort | +---------------------------------------------------------------+""" @@ -938,7 +939,6 @@ def tail_server_log(filename, rate=1): new_size = os.path.getsize(filename) return new_size != prev_size, new_size - def _get_new_lines(filehandle, old_linecount): "count lines, get the ones not counted before" @@ -1661,8 +1661,13 @@ def run_menu(): elif inp == 7: kill(SERVER_PIDFILE, 'Server') elif inp == 8: - query_status() + if not SERVER_LOGFILE: + init_game_directory(CURRENT_DIR, check_db=False) + tail_server_log(SERVER_LOGFILE) + print(" Tailing logfile {} ...".format(SERVER_LOGFILE)) elif inp == 9: + query_status() + elif inp == 10: query_info() else: print("Not a valid option.") From 9dfd1f5ea8811313f3963d4d10e53908e2925cc8 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 20 Jan 2018 00:51:41 +0100 Subject: [PATCH 116/189] Add missing border line in menu --- evennia/server/evennia_launcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index e33f13a4b2..58442d2678 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -316,7 +316,7 @@ MENU = \ | 6) Kill Portal + Server (send kill signal to process) | | 7) Kill Server only | +--- Information -----------------------------------------------+ - | 8) Tail log file + | 8) Tail log file | | 9) Run status | | 10) Port info | +---------------------------------------------------------------+ From d6105f6d6cc3a1fc870c60c32c0ff526f85da339 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 20 Jan 2018 12:41:27 +0100 Subject: [PATCH 117/189] Remove dependence on .restart file, add reboot option to launcher --- evennia/server/amp_client.py | 7 +- evennia/server/evennia_launcher.py | 219 ++++++++++++++++++---------- evennia/server/portal/amp_server.py | 8 +- evennia/server/portal/portal.py | 6 +- evennia/server/server.py | 58 ++------ evennia/server/sessionhandler.py | 4 +- evennia/utils/logger.py | 2 +- 7 files changed, 173 insertions(+), 131 deletions(-) diff --git a/evennia/server/amp_client.py b/evennia/server/amp_client.py index 603924190a..294250b9e7 100644 --- a/evennia/server/amp_client.py +++ b/evennia/server/amp_client.py @@ -104,7 +104,7 @@ class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol): 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 + # back with the Server side. We also need the startup mode (reload, reset, shutdown) self.send_AdminServer2Portal(amp.DUMMYSESSION, operation=amp.PSYNC, info_dict=info_dict) def data_to_portal(self, command, sessid, **kwargs): @@ -212,7 +212,10 @@ class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol): server_sessionhandler.portal_disconnect_all() elif operation == amp.PSYNC: # portal_session_sync - # force a resync of sessions from the portal side + # 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 diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 58442d2678..296cf61b12 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -305,20 +305,24 @@ HELP_ENTRY = \ MENU = \ """ +----Evennia Launcher-------------------------------------------+ - | | + {gameinfo} +--- Common operations -----------------------------------------+ - | 1) Start Portal and Server (also restart downed Server) | - | 2) Reload Server (update on code changes) | - | 3) Stop Portal and Server (full shutdown) | + | 1) Start (also restart stopped Server) | + | 2) Reload (stop/start Server in 'reload' mode) | + | 3) Stop (shutdown Portal and Server) | + | 4) Reboot (shutdown then restart) | +--- Other -----------------------------------------------------+ - | 4) Reset Server (Server shutdown with restart) | - | 5) Stop Server only | - | 6) Kill Portal + Server (send kill signal to process) | - | 7) Kill Server only | + | 5) Reset (stop/start Server in 'shutdown' mode) | + | 6) Stop Server only | + | 7) Kill Server only (send kill signal to process) | + | 8) Kill Portal + Server | +--- Information -----------------------------------------------+ - | 8) Tail log file | - | 9) Run status | - | 10) Port info | + | 9) Tail log file | + | 10) Run status | + | 11) Port info | + +--- Testing ---------------------------------------------------+ + | 12) Test gamedir (run gamedir test suite, if any) | + | 13) Test Evennia (run evennia test suite) | +---------------------------------------------------------------+ | h) Help i) About info q) Abort | +---------------------------------------------------------------+""" @@ -452,7 +456,8 @@ ARG_OPTIONS = \ start - launch server+portal if not running reload - restart server (code refresh) stop - shutdown server+portal - reset - mimic server shutdown but with auto-restart + reboot - shutdown server+portal, then start again + reset - restart server in shutdown-mode (not reload mode) sstart - start only server (requires portal) kill - send kill signal to portal+server (force) skill = send kill signal only to server @@ -461,10 +466,13 @@ ARG_OPTIONS = \ menu - show a menu of options Other input, like migrate and shell is passed on to Django.""" +# ------------------------------------------------------------ +# +# Private helper functions +# +# ------------------------------------------------------------ -# Info formatting - -def print_info(portal_info_dict, server_info_dict): +def _print_info(portal_info_dict, server_info_dict): """ Format info dicts from the Portal/Server for display @@ -498,6 +506,43 @@ def print_info(portal_info_dict, server_info_dict): print(top_border + "\n" + info + '\n' + border) +def _parse_status(response): + "Unpack the status information" + return pickle.loads(response['status']) + + +def _get_twistd_cmdline(pprofiler, sprofiler): + """ + Compile the command line for starting a Twisted application using the 'twistd' executable. + + """ + portal_cmd = [TWISTED_BINARY, + "--python={}".format(PORTAL_PY_FILE)] + server_cmd = [TWISTED_BINARY, + "--python={}".format(SERVER_PY_FILE)] + + if os.name != 'nt': + # PID files only for UNIX + portal_cmd.append("--pidfile={}".format(PORTAL_PIDFILE)) + server_cmd.append("--pidfile={}".format(SERVER_PIDFILE)) + + if pprofiler: + portal_cmd.extend(["--savestats", + "--profiler=cprofile", + "--profile={}".format(PPROFILER_LOGFILE)]) + if sprofiler: + server_cmd.extend(["--savestats", + "--profiler=cprofile", + "--profile={}".format(SPROFILER_LOGFILE)]) + + return portal_cmd, server_cmd + + +def _reactor_stop(): + if not NO_REACTOR_STOP: + reactor.stop() + + # ------------------------------------------------------------ # # Protocol Evennia launcher - Portal/Server communication @@ -560,11 +605,6 @@ class AMPLauncherProtocol(amp.AMP): return {"status": ""} -def _reactor_stop(): - if not NO_REACTOR_STOP: - reactor.stop() - - def send_instruction(operation, arguments, callback=None, errback=None): """ Send instruction and handle the response. @@ -579,12 +619,10 @@ def send_instruction(operation, arguments, callback=None, errback=None): def _callback(result): if callback: callback(result) - # prot.transport.loseConnection() def _errback(fail): if errback: errback(fail) - # prot.transport.loseConnection() def _on_connect(prot): """ @@ -620,37 +658,6 @@ def send_instruction(operation, arguments, callback=None, errback=None): deferred.addCallbacks(_on_connect, _on_connect_fail) REACTOR_RUN = True -def _parse_status(response): - "Unpack the status information" - return pickle.loads(response['status']) - - -def _get_twistd_cmdline(pprofiler, sprofiler): - """ - Compile the command line for starting a Twisted application using the 'twistd' executable. - - """ - portal_cmd = [TWISTED_BINARY, - "--python={}".format(PORTAL_PY_FILE)] - server_cmd = [TWISTED_BINARY, - "--python={}".format(SERVER_PY_FILE)] - - if os.name != 'nt': - # PID files only for UNIX - portal_cmd.append("--pidfile={}".format(PORTAL_PIDFILE)) - server_cmd.append("--pidfile={}".format(SERVER_PIDFILE)) - - if pprofiler: - portal_cmd.extend(["--savestats", - "--profiler=cprofile", - "--profile={}".format(PPROFILER_LOGFILE)]) - if sprofiler: - server_cmd.extend(["--savestats", - "--profiler=cprofile", - "--profile={}".format(SPROFILER_LOGFILE)]) - - return portal_cmd, server_cmd - def query_status(callback=None): """ @@ -693,9 +700,10 @@ def wait_for_status(portal_running=True, server_running=True, callback=None, err Repeat the status ping until the desired state combination is achieved. Args: - portal_running (bool or None): Desired portal run-state. If None, any state is accepted. - server_running (bool or None): Desired server run-state. If None, any state is accepted. - the portal must be running. + portal_running (bool or None): Desired portal run-state. If None, any state + is accepted. + server_running (bool or None): Desired server run-state. If None, any state + is accepted. The portal must be running. callback (callable): Will be called with portal_state, server_state when condition is fulfilled. errback (callable): Will be called with portal_state, server_state if the @@ -748,13 +756,13 @@ def wait_for_status(portal_running=True, server_running=True, callback=None, err return send_instruction(PSTATUS, None, _callback, _errback) + # ------------------------------------------------------------ # # Operational functions # # ------------------------------------------------------------ - def start_evennia(pprofiler=False, sprofiler=False): """ This will start Evennia anew by launching the Evennia Portal (which in turn @@ -771,7 +779,7 @@ def start_evennia(pprofiler=False, sprofiler=False): print("... Server started.\nEvennia running.") if response: _, _, _, _, pinfo, sinfo = response - print_info(pinfo, sinfo) + _print_info(pinfo, sinfo) _reactor_stop() def _portal_started(*args): @@ -836,10 +844,9 @@ def reload_evennia(sprofiler=False, reset=False): send_instruction(SSTART, server_cmd) def _portal_not_running(fail): - print("Evennia not running. Starting from scratch ...") + print("Evennia not running. Starting up ...") start_evennia() - # get portal status send_instruction(PSTATUS, None, _portal_running, _portal_not_running) @@ -869,12 +876,51 @@ def stop_evennia(): wait_for_status(False, None, _portal_stopped) def _portal_not_running(fail): - print("Evennia is not running.") + print("Evennia not running.") _reactor_stop() send_instruction(PSTATUS, None, _portal_running, _portal_not_running) +def reboot_evennia(pprofiler=False, sprofiler=False): + """ + This is essentially an evennia stop && evennia start except we make sure + the system has successfully shut down before starting it again. + + If evennia was not running, start it. + + """ + global AMP_CONNECTION + + def _portal_stopped(*args): + print("... Portal stopped. Evennia shut down. Rebooting ...") + global AMP_CONNECTION + AMP_CONNECTION = None + start_evennia(pprofiler, sprofiler) + + def _server_stopped(*args): + print("... Server stopped.\nStopping Portal ...") + send_instruction(PSHUTD, {}) + wait_for_status(False, None, _portal_stopped) + + def _portal_running(response): + prun, srun, ppid, spid, _, _ = _parse_status(response) + if srun: + print("Server stopping ...") + send_instruction(SSHUTD, {}) + wait_for_status_reply(_server_stopped) + else: + print("Server already stopped.\nStopping Portal ...") + send_instruction(PSHUTD, {}) + wait_for_status(False, None, _portal_stopped) + + def _portal_not_running(fail): + print("Evennia not running. Starting up ...") + start_evennia() + + send_instruction(PSTATUS, None, _portal_running, _portal_not_running) + + def stop_server_only(): """ Only stop the Server-component of Evennia (this is not useful except for debug) @@ -908,7 +954,7 @@ def query_info(): """ def _got_status(status): _, _, _, _, pinfo, sinfo = _parse_status(status) - print_info(pinfo, sinfo) + _print_info(pinfo, sinfo) _reactor_stop() def _portal_running(response): @@ -1435,6 +1481,12 @@ def error_check_python_modules(): _imp(settings.BASE_SCRIPT_TYPECLASS) +# ------------------------------------------------------------ +# +# Options +# +# ------------------------------------------------------------ + def init_game_directory(path, check_db=True): """ Try to analyze the given path to find settings.py - this defines @@ -1623,8 +1675,11 @@ def run_menu(): """ while True: # menu loop + gamedir = "/{}".format(os.path.basename(GAMEDIR)) + leninfo = len(gamedir) + line = "|" + " " * (60 - leninfo) + gamedir + " " * 3 + "|" - print(MENU) + print(MENU.format(gameinfo=line)) inp = input(" option > ") # quitting and help @@ -1652,22 +1707,24 @@ def run_menu(): elif inp == 3: stop_evennia() elif inp == 4: - reload_evennia(False, True) + reboot_evennia(False, False) elif inp == 5: - stop_server_only() + reload_evennia(False, True) elif inp == 6: - kill(PORTAL_PIDFILE, 'Portal') - kill(SERVER_PIDFILE, 'Server') + stop_server_only() elif inp == 7: kill(SERVER_PIDFILE, 'Server') elif inp == 8: + kill(PORTAL_PIDFILE, 'Portal') + kill(SERVER_PIDFILE, 'Server') + elif inp == 9: if not SERVER_LOGFILE: init_game_directory(CURRENT_DIR, check_db=False) tail_server_log(SERVER_LOGFILE) print(" Tailing logfile {} ...".format(SERVER_LOGFILE)) - elif inp == 9: - query_status() elif inp == 10: + query_status() + elif inp == 11: query_info() else: print("Not a valid option.") @@ -1686,37 +1743,36 @@ def main(): parser.add_argument( '--gamedir', nargs=1, action='store', dest='altgamedir', metavar="", - help="Location of gamedir (default: current location)") + help="location of gamedir (default: current location)") parser.add_argument( '--init', action='store', dest="init", metavar="", - help="Creates a new gamedir 'name' at current location.") + help="creates a new gamedir 'name' at current location") parser.add_argument( '--log', '-l', action='store_true', dest='tail_log', default=False, - help="Tail the server logfile to standard out.") + help="tail the server logfile to console") parser.add_argument( '--list', nargs='+', action='store', dest='listsetting', metavar="all|", - help=("List values for one or more server settings. Use 'all' to \n list all " - "available keys.")) + help=("list settings, use 'all' to list all available keys")) parser.add_argument( '--settings', nargs=1, action='store', dest='altsettings', default=None, metavar="", - help=("Start evennia with alternative settings file from\n" + help=("start evennia with alternative settings file from\n" " gamedir/server/conf/. (default is settings.py)")) parser.add_argument( '--initsettings', action='store_true', dest="initsettings", default=False, - help="Create a new, empty settings file as\n gamedir/server/conf/settings.py.") + help="create a new, empty settings file as\n gamedir/server/conf/settings.py") parser.add_argument( '--profiler', action='store_true', dest='profiler', default=False, - help="Start given server component under the Python profiler.") + help="start given server component under the Python profiler") parser.add_argument( '--dummyrunner', nargs=1, action='store', dest='dummyrunner', metavar="", - help="Test a server by connecting dummy accounts to it.") + help="test a server by connecting dummy accounts to it") parser.add_argument( '-v', '--version', action='store_true', dest='show_version', default=False, - help="Show version info.") + help="show version info") parser.add_argument( "operation", nargs='?', default="noop", @@ -1800,7 +1856,8 @@ def main(): # launch menu for operation init_game_directory(CURRENT_DIR, check_db=True) run_menu() - elif option in ('status', 'info', 'start', 'reload', 'reset', 'stop', 'sstop', 'kill', 'skill'): + elif option in ('status', 'info', 'start', 'reload', 'reboot', + 'reset', 'stop', 'sstop', 'kill', 'skill'): # operate the server directly if not SERVER_LOGFILE: init_game_directory(CURRENT_DIR, check_db=True) @@ -1812,6 +1869,8 @@ def main(): start_evennia(args.profiler, args.profiler) elif option == 'reload': reload_evennia(args.profiler) + elif option == 'reboot': + reboot_evennia(args.profiler, args.profiler) elif option == 'reset': reload_evennia(args.profiler, reset=True) elif option == 'stop': diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index ed51b8952a..6fa671e886 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -202,6 +202,7 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): 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 @@ -233,8 +234,7 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): def send_AdminPortal2Server(self, session, operation="", **kwargs): """ Send Admin instructions from the Portal to the Server. - Executed - on the Portal. + Executed on the Portal. Args: session (Session): Session. @@ -403,9 +403,13 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): elif operation == amp.PSYNC: # portal sync # Server has (re-)connected and wants the session data from portal self.factory.server_info_dict = kwargs.get("info_dict", {}) + # 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() diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index d7fcadcbcb..27034f5785 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -85,8 +85,9 @@ INFO_DICT = {"servername": SERVERNAME, "version": VERSION, "errors": "", "info": # ------------------------------------------------------------- # Portal Service object - # ------------------------------------------------------------- + + class Portal(object): """ @@ -113,13 +114,12 @@ class Portal(object): self.sessions.portal = self self.process_id = os.getpid() self.server_process_id = None + self.server_restart_mode = "shutdown" # set a callback if the server is killed abruptly, # by Ctrl-C, reboot etc. reactor.addSystemEventTrigger('before', 'shutdown', self.shutdown, _reactor_stopping=True) - self.game_running = False - def get_info_dict(self): "Return the Portal info, for display." return INFO_DICT diff --git a/evennia/server/server.py b/evennia/server/server.py index c665fc143e..6ba5f32ef8 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -203,11 +203,6 @@ class Evennia(object): reactor.callLater(1, d.callback, None) reactor.sigInt = _wrap_sigint_handler - self.game_running = True - - # track the server time - self.run_init_hooks() - # Server startup methods def sqlite3_prep(self): @@ -299,9 +294,13 @@ class Evennia(object): last=last_initial_setup_step) initial_setup.handle_setup(int(last_initial_setup_step)) - 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 @@ -311,47 +310,24 @@ class Evennia(object): [o.at_init() for o in ObjectDB.get_all_cached_instances()] [p.at_init() for p in AccountDB.get_all_cached_instances()] - mode = self.getset_restart_mode() - # call correct server hook based on start file value if mode == 'reload': - # True was the old reload flag, kept for compatibilty + logger.log_msg("Server successfully reloaded.") self.at_server_reload_start() elif mode == 'reset': # only run hook, don't purge sessions self.at_server_cold_start() - elif mode in ('reset', 'shutdown'): + logger.log_msg("Evennia Server successfully restarted in 'reset' mode.") + elif mode == 'shutdown': self.at_server_cold_start() # clear eventual lingering session storages ObjectDB.objects.clear_all_sessids() + logger.log_msg("Evennia Server successfully started.") # always call this regardless of start type self.at_server_start() - def getset_restart_mode(self, mode=None): - """ - This manages the flag file that tells the runner if the server is - reloading, resetting or shutting down. - - Args: - mode (string or None, optional): Valid values are - 'reload', 'reset', 'shutdown' and `None`. If mode is `None`, - no change will be done to the flag file. - Returns: - mode (str): The currently active restart mode, either just - set or previously set. - - """ - if mode is None: - with open(SERVER_RESTART, 'r') as f: - # mode is either shutdown, reset or reload - mode = f.read() - else: - with open(SERVER_RESTART, 'w') as f: - f.write(str(mode)) - return mode - @defer.inlineCallbacks - def shutdown(self, mode=None, _reactor_stopping=False): + def shutdown(self, mode='reload', _reactor_stopping=False): """ Shuts down the server from inside it. @@ -362,7 +338,6 @@ class Evennia(object): at_shutdown hooks called but sessions will not be disconnected. 'shutdown' - like reset, but server will not auto-restart. - None - keep currently set flag from flag file. _reactor_stopping - this is set if server is stopped by a kill command OR this method was already called once - in both cases the reactor is @@ -373,10 +348,7 @@ class Evennia(object): # once; we don't need to run the shutdown procedure again. defer.returnValue(None) - mode = self.getset_restart_mode(mode) - from evennia.objects.models import ObjectDB - #from evennia.accounts.models import AccountDB from evennia.server.models import ServerConfig from evennia.utils import gametime as _GAMETIME_MODULE @@ -455,13 +427,15 @@ class Evennia(object): if SERVER_STARTSTOP_MODULE: SERVER_STARTSTOP_MODULE.at_server_reload_start() - def at_post_portal_sync(self): + def at_post_portal_sync(self, mode): """ This is called just after the portal has finished syncing back data to the server after reconnecting. + + Args: + mode (str): One of reload, reset or shutdown. + """ - # one of reload, reset or shutdown - mode = self.getset_restart_mode() from evennia.scripts.monitorhandler import MONITOR_HANDLER MONITOR_HANDLER.restore(mode == 'reload') diff --git a/evennia/server/sessionhandler.py b/evennia/server/sessionhandler.py index e76efd7405..8c6f6193b8 100644 --- a/evennia/server/sessionhandler.py +++ b/evennia/server/sessionhandler.py @@ -379,8 +379,10 @@ class ServerSessionHandler(SessionHandler): self[sessid] = sess sess.at_sync() + mode = 'reload' + # tell the server hook we synced - self.server.at_post_portal_sync() + self.server.at_post_portal_sync(mode) # announce the reconnection self.announce_all(_(" ... Server restarted.")) diff --git a/evennia/utils/logger.py b/evennia/utils/logger.py index 30af5d2226..4b9307819c 100644 --- a/evennia/utils/logger.py +++ b/evennia/utils/logger.py @@ -197,7 +197,7 @@ def log_server(servermsg): except Exception as e: servermsg = str(e) for line in servermsg.splitlines(): - log_msg('[SRV] %s' % line) + log_msg('[Server] %s' % line) def log_warn(warnmsg): From 9422c6314da768a1ee87ff42f48fcc3077ed066f Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 20 Jan 2018 13:19:06 +0100 Subject: [PATCH 118/189] Prevent server from reconnecting to Portal mid-shutdown --- evennia/server/amp_client.py | 7 ++++--- evennia/server/evennia_launcher.py | 24 +++++++++++++++++------- evennia/server/server.py | 3 ++- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/evennia/server/amp_client.py b/evennia/server/amp_client.py index 294250b9e7..24fc7a91f5 100644 --- a/evennia/server/amp_client.py +++ b/evennia/server/amp_client.py @@ -5,7 +5,7 @@ Portal. This module sets up the Client-side communication. """ from evennia.server.portal import amp -from twisted.internet import protocol +from twisted.internet import protocol, reactor from evennia.utils import logger @@ -84,8 +84,9 @@ class AMPClientFactory(protocol.ReconnectingClientFactory): reason (str): Eventual text describing why connection failed. """ - logger.log_info("Attempting to reconnect to Portal ...") - protocol.ReconnectingClientFactory.clientConnectionFailed(self, connector, reason) + if reactor.running: + logger.log_info("Attempting to reconnect to Portal ...") + protocol.ReconnectingClientFactory.clientConnectionFailed(self, connector, reason) class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol): diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 296cf61b12..db14037ae3 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -1363,7 +1363,7 @@ def del_pid(pidfile): os.remove(pidfile) -def kill(pidfile, component='Server', killsignal=SIG): +def kill(pidfile, component='Server', callback=None, errback=None, killsignal=SIG): """ Send a kill signal to a process based on PID. A customized success/error message will be returned. If clean=True, the system @@ -1372,6 +1372,8 @@ def kill(pidfile, component='Server', killsignal=SIG): Args: pidfile (str): The path of the pidfile to get the PID from. component (str, optional): Usually one of 'Server' or 'Portal'. + errback (callable, optional): Called if signal failed to send. + callback (callable, optional): Called if kill signal was sent successfully. killsignal (int, optional): Signal identifier for signal to send. """ @@ -1402,10 +1404,16 @@ def kill(pidfile, component='Server', killsignal=SIG): "Try removing it manually.".format( component=component, pid=pid, pidfile=pidfile)) return - print("Sent kill signal to {component}.".format(component=component)) - return - print("Could not send kill signal - {component} does " - "not appear to be running.".format(component=component)) + if callback: + callback() + else: + print("Sent kill signal to {component}.".format(component=component)) + return + if errback: + errback() + else: + print("Could not send kill signal - {component} does " + "not appear to be running.".format(component=component)) def show_version_info(about=False): @@ -1715,8 +1723,10 @@ def run_menu(): elif inp == 7: kill(SERVER_PIDFILE, 'Server') elif inp == 8: - kill(PORTAL_PIDFILE, 'Portal') + global REACTOR_RUN kill(SERVER_PIDFILE, 'Server') + reactor.callLater(5, kill, PORTAL_PIDFILE, 'Portal') + REACTOR_RUN = True elif inp == 9: if not SERVER_LOGFILE: init_game_directory(CURRENT_DIR, check_db=False) @@ -1878,7 +1888,7 @@ def main(): elif option == 'sstop': stop_server_only() elif option == 'kill': - kill(PORTAL_PIDFILE, 'Portal') + kill(SERVER_PIDFILE, 'Server') kill(SERVER_PIDFILE, 'Server') elif option == 'skill': kill(SERVER_PIDFILE, 'Server') diff --git a/evennia/server/server.py b/evennia/server/server.py index 6ba5f32ef8..2cabb58f37 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -210,7 +210,8 @@ class Evennia(object): Optimize some SQLite stuff at startup since we can't save it to the database. """ - if ((".".join(str(i) for i in django.VERSION) < "1.2" and settings.DATABASES.get('default', {}).get('ENGINE') == "sqlite3") or + if ((".".join(str(i) for i in django.VERSION) < "1.2" and + settings.DATABASES.get('default', {}).get('ENGINE') == "sqlite3") or (hasattr(settings, 'DATABASES') and settings.DATABASES.get("default", {}).get('ENGINE', None) == 'django.db.backends.sqlite3')): From b1e68b913830d51d63f9333e23d369ac069ab17b Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 20 Jan 2018 15:50:24 +0100 Subject: [PATCH 119/189] Add support for running tests from menu, fix bug in kill --- evennia/server/amp_client.py | 5 ++- evennia/server/evennia_launcher.py | 52 ++++++++++++++++++------------ evennia/server/server.py | 7 ++-- 3 files changed, 38 insertions(+), 26 deletions(-) diff --git a/evennia/server/amp_client.py b/evennia/server/amp_client.py index 24fc7a91f5..ea43402eb3 100644 --- a/evennia/server/amp_client.py +++ b/evennia/server/amp_client.py @@ -84,9 +84,8 @@ class AMPClientFactory(protocol.ReconnectingClientFactory): reason (str): Eventual text describing why connection failed. """ - if reactor.running: - logger.log_info("Attempting to reconnect to Portal ...") - protocol.ReconnectingClientFactory.clientConnectionFailed(self, connector, reason) + logger.log_msg("Attempting to reconnect to Portal ...") + protocol.ReconnectingClientFactory.clientConnectionFailed(self, connector, reason) class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol): diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index db14037ae3..a2c255ec19 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -296,10 +296,17 @@ ABOUT_INFO = \ HELP_ENTRY = \ """ - This is a convenience launcher for the most common actions. For - more advanced ways to operate and manage Evennia, see 'evennia -h'. + Evennia has two processes, the 'Server' and the 'Portal'. + External users connect to the Portal while the Server runs the + game/database. Restarting the Server will refresh code but not + disconnect users. - Evennia's manual is found here: https://github.com/evennia/evennia/wiki + For more ways to operate and manage Evennia, use 'evennia -h'. + + If you want to add unit tests to your game, see + https://github.com/evennia/evennia/wiki/Unit-Testing + Evennia's manual is found here: + https://github.com/evennia/evennia/wiki """ MENU = \ @@ -307,24 +314,24 @@ MENU = \ +----Evennia Launcher-------------------------------------------+ {gameinfo} +--- Common operations -----------------------------------------+ - | 1) Start (also restart stopped Server) | - | 2) Reload (stop/start Server in 'reload' mode) | - | 3) Stop (shutdown Portal and Server) | - | 4) Reboot (shutdown then restart) | + | 1) Start (also restart stopped Server) | + | 2) Reload (stop/start Server in 'reload' mode) | + | 3) Stop (shutdown Portal and Server) | + | 4) Reboot (shutdown then restart) | +--- Other -----------------------------------------------------+ - | 5) Reset (stop/start Server in 'shutdown' mode) | + | 5) Reset (stop/start Server in 'shutdown' mode) | | 6) Stop Server only | - | 7) Kill Server only (send kill signal to process) | + | 7) Kill Server only (send kill signal to process) | | 8) Kill Portal + Server | +--- Information -----------------------------------------------+ - | 9) Tail log file | - | 10) Run status | + | 9) Tail log file (quickly see errors) | + | 10) Status | | 11) Port info | +--- Testing ---------------------------------------------------+ - | 12) Test gamedir (run gamedir test suite, if any) | - | 13) Test Evennia (run evennia test suite) | + | 12) Test gamedir (run gamedir test suite, if any) | + | 13) Test Evennia (run evennia test suite) | +---------------------------------------------------------------+ - | h) Help i) About info q) Abort | + | h) Help i) About info q) Abort | +---------------------------------------------------------------+""" ERROR_AMP_UNCONFIGURED = \ @@ -672,8 +679,11 @@ def query_status(callback=None): callback(response) else: pstatus, sstatus, ppid, spid, pinfo, sinfo = _parse_status(response) + # note - the server reports its pid, but this is likely to be a + # thread and can't be relied on, so we use the pid file instead print("Portal: {} (pid {})\nServer: {} (pid {})".format( - wmap[pstatus], ppid, wmap[sstatus], spid)) + wmap[pstatus], get_pid(PORTAL_PIDFILE), + wmap[sstatus], get_pid(SERVER_PIDFILE))) _reactor_stop() def _errback(fail): @@ -1685,7 +1695,7 @@ def run_menu(): # menu loop gamedir = "/{}".format(os.path.basename(GAMEDIR)) leninfo = len(gamedir) - line = "|" + " " * (60 - leninfo) + gamedir + " " * 3 + "|" + line = "|" + " " * (61 - leninfo) + gamedir + " " * 2 + "|" print(MENU.format(gameinfo=line)) inp = input(" option > ") @@ -1723,10 +1733,8 @@ def run_menu(): elif inp == 7: kill(SERVER_PIDFILE, 'Server') elif inp == 8: - global REACTOR_RUN kill(SERVER_PIDFILE, 'Server') - reactor.callLater(5, kill, PORTAL_PIDFILE, 'Portal') - REACTOR_RUN = True + kill(PORTAL_PIDFILE, 'Portal') elif inp == 9: if not SERVER_LOGFILE: init_game_directory(CURRENT_DIR, check_db=False) @@ -1736,6 +1744,10 @@ def run_menu(): query_status() elif inp == 11: query_info() + elif inp == 12: + Popen(['evennia', '--settings', 'settings.py', 'test', '.'], env=getenv()).wait() + elif inp == 13: + Popen(['evennia', 'test', 'evennia'], env=getenv()).wait() else: print("Not a valid option.") continue @@ -1889,7 +1901,7 @@ def main(): stop_server_only() elif option == 'kill': kill(SERVER_PIDFILE, 'Server') - kill(SERVER_PIDFILE, 'Server') + kill(PORTAL_PIDFILE, 'Portal') elif option == 'skill': kill(SERVER_PIDFILE, 'Server') elif option != "noop": diff --git a/evennia/server/server.py b/evennia/server/server.py index 2cabb58f37..ba17015022 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -196,9 +196,9 @@ class Evennia(object): from twisted.internet.defer import Deferred if hasattr(self, "web_root"): d = self.web_root.empty_threadpool() - d.addCallback(lambda _: self.shutdown(_reactor_stopping=True)) + d.addCallback(lambda _: self.shutdown("shutdown", _reactor_stopping=True)) else: - d = Deferred(lambda _: self.shutdown(_reactor_stopping=True)) + d = Deferred(lambda _: self.shutdown("shutdown", _reactor_stopping=True)) d.addCallback(lambda _: reactor.stop()) reactor.callLater(1, d.callback, None) reactor.sigInt = _wrap_sigint_handler @@ -358,7 +358,8 @@ class Evennia(object): ServerConfig.objects.conf("server_restart_mode", "reload") yield [o.at_server_reload() for o in ObjectDB.get_all_cached_instances()] yield [p.at_server_reload() for p in AccountDB.get_all_cached_instances()] - yield [(s.pause(manual_pause=False), s.at_server_reload()) for s in ScriptDB.get_all_cached_instances() if s.is_active] + yield [(s.pause(manual_pause=False), s.at_server_reload()) + for s in ScriptDB.get_all_cached_instances() if s.is_active] yield self.sessions.all_sessions_portal_sync() self.at_server_reload_stop() # only save monitor state on reload, not on shutdown/reset From 30ae4c3be8a0d76e23cb03a3d255189e20215d6a Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 20 Jan 2018 15:53:53 +0100 Subject: [PATCH 120/189] Wording fixes --- evennia/server/evennia_launcher.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index a2c255ec19..d00388a1f0 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -288,8 +288,8 @@ ABOUT_INFO = \ Web: http://www.evennia.com Irc: #evennia on FreeNode Forum: http://www.evennia.com/discussions - Maintainer (2010-): Griatch (griatch AT gmail DOT com) Maintainer (2006-10): Greg Taylor + Maintainer (2010-): Griatch (griatch AT gmail DOT com) Use -h for command line options. """ @@ -329,7 +329,7 @@ MENU = \ | 11) Port info | +--- Testing ---------------------------------------------------+ | 12) Test gamedir (run gamedir test suite, if any) | - | 13) Test Evennia (run evennia test suite) | + | 13) Test Evennia (run Evennia test suite) | +---------------------------------------------------------------+ | h) Help i) About info q) Abort | +---------------------------------------------------------------+""" From e9d66066597da032847e29b2e168130a73fa6025 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 20 Jan 2018 16:00:28 +0100 Subject: [PATCH 121/189] Minor rewordings --- evennia/server/evennia_launcher.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index d00388a1f0..5a6c770995 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -461,17 +461,17 @@ SERVER_INFO = \ ARG_OPTIONS = \ """Actions on installed server. One of: start - launch server+portal if not running - reload - restart server (code refresh) + reload - restart server in 'reload' mode stop - shutdown server+portal reboot - shutdown server+portal, then start again - reset - restart server in shutdown-mode (not reload mode) + reset - restart server in 'shutdown' mode sstart - start only server (requires portal) kill - send kill signal to portal+server (force) skill = send kill signal only to server status - show server and portal run state info - show server and portal port info menu - show a menu of options -Other input, like migrate and shell is passed on to Django.""" +Others, like migrate, test and shell is passed on to Django.""" # ------------------------------------------------------------ # @@ -1800,7 +1800,7 @@ def main(): "operation", nargs='?', default="noop", help=ARG_OPTIONS) parser.epilog = ( - "Common Django-admin commands are shell, dbshell, migrate and flush.\n" + "Common Django-admin commands are shell, dbshell, test and migrate.\n" "See the Django documentation for more management commands.") args, unknown_args = parser.parse_known_args() From 3be493d990bbc9cd115e78d4933944343415aa57 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 20 Jan 2018 23:08:22 +0100 Subject: [PATCH 122/189] Fix minor indentation error in launcher info cmd --- evennia/server/amp_client.py | 2 +- evennia/server/evennia_launcher.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/server/amp_client.py b/evennia/server/amp_client.py index ea43402eb3..474f028e93 100644 --- a/evennia/server/amp_client.py +++ b/evennia/server/amp_client.py @@ -5,7 +5,7 @@ Portal. This module sets up the Client-side communication. """ from evennia.server.portal import amp -from twisted.internet import protocol, reactor +from twisted.internet import protocol from evennia.utils import logger diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 5a6c770995..66b5311bbf 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -484,7 +484,7 @@ def _print_info(portal_info_dict, server_info_dict): Format info dicts from the Portal/Server for display """ - ind = " " * 7 + ind = " " * 8 def _prepare_dict(dct): out = {} From 1befcb1699d73cf73fd3faf5beecf5de9ab55404 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 23 Jan 2018 19:46:55 +0100 Subject: [PATCH 123/189] Stop hard-coding examples of port settings in game template, it just confuses things. --- evennia/game_template/server/conf/settings.py | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/evennia/game_template/server/conf/settings.py b/evennia/game_template/server/conf/settings.py index 7fe163b833..a8f9776805 100644 --- a/evennia/game_template/server/conf/settings.py +++ b/evennia/game_template/server/conf/settings.py @@ -34,27 +34,6 @@ from evennia.settings_default import * # This is the name of your game. Make it catchy! SERVERNAME = {servername} -# Server ports. If enabled and marked as "visible", the port -# should be visible to the outside world on a production server. -# Note that there are many more options available beyond these. - -# Telnet ports. Visible. -TELNET_ENABLED = True -TELNET_PORTS = [4000] -# (proxy, internal). Only proxy should be visible. -WEBSERVER_ENABLED = True -WEBSERVER_PORTS = [(4001, 4002)] -# Telnet+SSL ports, for supporting clients. Visible. -SSL_ENABLED = False -SSL_PORTS = [4003] -# SSH client ports. Requires crypto lib. Visible. -SSH_ENABLED = False -SSH_PORTS = [4004] -# Websocket-client port. Visible. -WEBSOCKET_CLIENT_ENABLED = True -WEBSOCKET_CLIENT_PORT = 4005 -# Internal Server-Portal port. Not visible. -AMP_PORT = 4006 ###################################################################### # Settings given in secret_settings.py override those in this file. @@ -62,4 +41,4 @@ AMP_PORT = 4006 try: from server.conf.secret_settings import * except ImportError: - print "secret_settings.py file not found or failed to import." + print("secret_settings.py file not found or failed to import.") From 6e5f9e8d2e9df12ce6b78982a7dd48d36aba0b2d Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 26 Jan 2018 22:12:57 +0100 Subject: [PATCH 124/189] Combining log files does not actually work, removing functionality --- evennia/server/evennia_launcher.py | 8 +++++- evennia/server/portal/amp_server.py | 40 +++++++++++++++++++---------- evennia/server/portal/portal.py | 4 +-- evennia/settings_default.py | 2 -- 4 files changed, 35 insertions(+), 19 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 66b5311bbf..1e006483af 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -811,7 +811,13 @@ def start_evennia(pprofiler=False, sprofiler=False): def _portal_not_running(fail): print("Portal starting {}...".format("(under cProfile)" if pprofiler else "")) try: - Popen(portal_cmd, env=getenv(), bufsize=-1) + if os.name == 'nt': + # Windows requires special care + create_no_window = 0x08000000 + Popen(portal_cmd, env=getenv(), bufsize=-1, + createflags=create_no_window) + else: + Popen(portal_cmd, env=getenv(), bufsize=-1) except Exception as e: print(PROCESS_ERROR.format(component="Portal", traceback=e)) _reactor_stop() diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index 6fa671e886..a11b52d58b 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -8,6 +8,7 @@ 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, PIPE from evennia.utils import logger @@ -150,21 +151,32 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): """ # start the Server - try: - process = Popen(server_twistd_cmd, env=getenv(), bufsize=-1, stdout=PIPE, stderr=STDOUT) - except Exception: - self.factory.portal.server_process_id = None - logger.log_trace() - return 0 - # there is a short window before the server logger is up where we must - # catch the stdout of the Server or eventual tracebacks will be lost. - with process.stdout as out: - logger.log_server(out.read()) + with open(settings.SERVER_LOG_FILE, 'a') as logfile: + try: + if os.name == 'nt': + # 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: + self.factory.portal.server_process_id = None + logger.log_trace() + logfile.flush() + return 0 + # there is a short window before the server logger is up where we must + # catch the stdout of the Server or eventual tracebacks will be lost. + # with process.stdout as out: + # logger.log_server(out.readlines()) - # store the pid and launch argument for future reference - self.factory.portal.server_process_id = process.pid - self.factory.portal.server_twistd_cmd = server_twistd_cmd - return process.pid + # store the pid and launch argument for future reference + self.factory.portal.server_process_id = process.pid + self.factory.portal.server_twistd_cmd = server_twistd_cmd + logfile.flush() + return process.pid def wait_for_disconnect(self, callback, *args, **kwargs): """ diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index 27034f5785..6645f8964d 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -184,8 +184,8 @@ class Portal(object): application = service.Application('Portal') # custom logging -logfile = settings.SERVER_LOG_FILE if settings.MERGE_LOGS else settings.PORTAL_LOG_FILE -logfile = logger.WeeklyLogFile(os.path.basename(logfile), os.path.dirname(logfile)) +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 diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 3bb2b504aa..2b6bb55584 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -134,8 +134,6 @@ LOG_DIR = os.path.join(GAME_DIR, 'server', 'logs') SERVER_LOG_FILE = os.path.join(LOG_DIR, 'server.log') PORTAL_LOG_FILE = os.path.join(LOG_DIR, 'portal.log') HTTP_LOG_FILE = os.path.join(LOG_DIR, 'http_requests.log') -# if this is true, merge logs into only the SERVER_LOG_FILE location. -MERGE_LOGS = True # if this is set to the empty string, lockwarnings will be turned off. LOCKWARNING_LOG_FILE = os.path.join(LOG_DIR, 'lockwarnings.log') # Rotate log files when server and/or portal stops. This will keep log From c6eca6bf03545ab11c1b5e40d67f33acf4801274 Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 26 Jan 2018 22:30:39 +0100 Subject: [PATCH 125/189] Let -l option merge log outputs from both log files --- evennia/server/evennia_launcher.py | 36 +++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 1e006483af..635b504071 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -982,15 +982,18 @@ def query_info(): send_instruction(PSTATUS, None, _portal_running, _portal_not_running) -def tail_server_log(filename, rate=1): +def tail_log_files(filename1, filename2, start_lines1=20, start_lines2=20, rate=1): """ - Tail the server logfile interactively, printing to stdout + Tail two logfiles interactively, combining their output to stdout - When first starting, this will display the tail of the log file. After - that it will poll the log file repeatedly and display changes. + When first starting, this will display the tail of the log files. After + that it will poll the log files repeatedly and display changes. Args: - filename (str): Path to log file. + filename1 (str): Path to first log file. + filename2 (str): Path to second log file. + start_lines1 (int): How many lines to show from existing first log. + start_lines2 (int): How many lines to show from existing second log. rate (int, optional): How often to poll the log file. """ @@ -1058,8 +1061,11 @@ def tail_server_log(filename, rate=1): # the log file might not exist yet. Wait a little, then try again ... pass else: - if max_lines: - # first startup + if max_lines == 0: + # don't show any lines from old file + new_lines = [] + elif max_lines: + # show some lines from first startup new_lines = new_lines[-max_lines:] # print to stdout without line break (log has its own line feeds) @@ -1069,7 +1075,9 @@ def tail_server_log(filename, rate=1): # set up the next poll reactor.callLater(rate, _tail_file, filename, file_size, line_count, max_lines=100) - reactor.callLater(0, _tail_file, filename, 0, 0, max_lines=20) + reactor.callLater(0, _tail_file, filename1, 0, 0, max_lines=start_lines1) + reactor.callLater(0, _tail_file, filename2, 0, 0, max_lines=start_lines2) + REACTOR_RUN = True @@ -1744,7 +1752,7 @@ def run_menu(): elif inp == 9: if not SERVER_LOGFILE: init_game_directory(CURRENT_DIR, check_db=False) - tail_server_log(SERVER_LOGFILE) + tail_log_files(PORTAL_LOGFILE, SERVER_LOGFILE, 20, 20) print(" Tailing logfile {} ...".format(SERVER_LOGFILE)) elif inp == 10: query_status() @@ -1864,12 +1872,18 @@ def main(): sys.exit() if args.tail_log: - # set up for tailing the server log file + # set up for tailing the log files global NO_REACTOR_STOP NO_REACTOR_STOP = True if not SERVER_LOGFILE: init_game_directory(CURRENT_DIR, check_db=False) - tail_server_log(SERVER_LOGFILE) + + # adjust how many lines we show from existing logs + start_lines1, start_lines2 = 20, 20 + if option == 'start': + start_lines1, start_lines2 = 0, 0 + + tail_log_files(PORTAL_LOGFILE, SERVER_LOGFILE, start_lines1, start_lines2) print(" Tailing logfile {} ...".format(SERVER_LOGFILE)) if args.dummyrunner: From c0fe8a92eeed381b9fe760313116e2bfd2dbc584 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 27 Jan 2018 00:33:09 +0100 Subject: [PATCH 126/189] Make pid handling more stable, also internally --- evennia/server/amp_client.py | 4 +++- evennia/server/evennia_launcher.py | 19 +++++++++---------- evennia/server/portal/amp_server.py | 26 +++++++++++++------------- evennia/server/portal/portal.py | 11 +++-------- evennia/server/server.py | 4 ++-- 5 files changed, 30 insertions(+), 34 deletions(-) diff --git a/evennia/server/amp_client.py b/evennia/server/amp_client.py index 474f028e93..8b9f9d4e8e 100644 --- a/evennia/server/amp_client.py +++ b/evennia/server/amp_client.py @@ -4,6 +4,7 @@ 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 @@ -105,7 +106,8 @@ class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol): 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, info_dict=info_dict) + self.send_AdminServer2Portal( + amp.DUMMYSESSION, operation=amp.PSYNC, spid=os.getpid(), info_dict=info_dict) def data_to_portal(self, command, sessid, **kwargs): """ diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 635b504071..b07e3bb562 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -679,16 +679,14 @@ def query_status(callback=None): callback(response) else: pstatus, sstatus, ppid, spid, pinfo, sinfo = _parse_status(response) - # note - the server reports its pid, but this is likely to be a - # thread and can't be relied on, so we use the pid file instead - print("Portal: {} (pid {})\nServer: {} (pid {})".format( - wmap[pstatus], get_pid(PORTAL_PIDFILE), - wmap[sstatus], get_pid(SERVER_PIDFILE))) + print("Evennia Portal: {}{}\n Server: {}{}".format( + wmap[pstatus], " (pid {})".format(get_pid(PORTAL_PIDFILE, ppid)) if pstatus else "", + wmap[sstatus], " (pid {})".format(get_pid(SERVER_PIDFILE, spid)) if sstatus else "")) _reactor_stop() def _errback(fail): pstatus, sstatus = False, False - print("Portal: {}\nServer: {}".format(wmap[pstatus], wmap[sstatus])) + print("Portal: {}\nServer: {}".format(wmap[pstatus], wmap[sstatus])) _reactor_stop() send_instruction(PSTATUS, None, _callback, _errback) @@ -1355,22 +1353,23 @@ def getenv(): return env -def get_pid(pidfile): +def get_pid(pidfile, default=None): """ Get the PID (Process ID) by trying to access an PID file. Args: pidfile (str): The path of the pid file. + default (int, optional): What to return if file does not exist. Returns: - pid (str or None): The process id. + pid (str): The process id or `default`. """ if os.path.exists(pidfile): with open(pidfile, 'r') as f: pid = f.read() return pid - return None + return default def del_pid(pidfile): @@ -1418,7 +1417,7 @@ def kill(pidfile, component='Server', callback=None, errback=None, killsignal=SI # We must catch and ignore the interrupt sent. pass else: - # Linux can send the SIGINT signal directly + # Linux/Unix can send the SIGINT signal directly # to the specified PID. os.kill(int(pid), killsignal) diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index a11b52d58b..9baa7ba86f 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -54,7 +54,6 @@ class AMPServerFactory(protocol.ServerFactory): self.protocol = AMPServerProtocol self.broadcasts = [] self.server_connection = None - self.server_info_dict = None self.launcher_connection = None self.disconnect_callbacks = {} self.server_connect_callbacks = [] @@ -89,7 +88,7 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): super(AMPServerProtocol, self).connectionLost(reason) if self.factory.server_connection == self: self.factory.server_connection = None - self.factory.server_info_dict = None + self.factory.portal.server_info_dict = {} if self.factory.launcher_connection == self: self.factory.launcher_connection = None @@ -112,7 +111,7 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): 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.server_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) @@ -151,7 +150,11 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): """ # 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 os.name == 'nt': # Windows requires special care @@ -159,24 +162,20 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): 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: - self.factory.portal.server_process_id = None logger.log_trace() - logfile.flush() - return 0 - # there is a short window before the server logger is up where we must - # catch the stdout of the Server or eventual tracebacks will be lost. - # with process.stdout as out: - # logger.log_server(out.readlines()) - # store the pid and launch argument for future reference - self.factory.portal.server_process_id = process.pid self.factory.portal.server_twistd_cmd = server_twistd_cmd logfile.flush() + if process: + # avoid zombie-process + process.wait() return process.pid + return 0 def wait_for_disconnect(self, callback, *args, **kwargs): """ @@ -414,7 +413,8 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): elif operation == amp.PSYNC: # portal sync # Server has (re-)connected and wants the session data from portal - self.factory.server_info_dict = kwargs.get("info_dict", {}) + 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 diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index 6645f8964d..bbeec30fc5 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -39,11 +39,6 @@ except Exception: PORTAL_SERVICES_PLUGIN_MODULES = [mod_import(module) for module in make_iter(settings.PORTAL_SERVICES_PLUGIN_MODULES)] LOCKDOWN_MODE = settings.LOCKDOWN_MODE -PORTAL_PIDFILE = "" -if os.name == 'nt': - # For Windows we need to handle pid files manually. - PORTAL_PIDFILE = os.path.join(settings.GAME_DIR, "server", 'portal.pid') - # ------------------------------------------------------------- # Evennia Portal settings # ------------------------------------------------------------- @@ -113,8 +108,10 @@ class Portal(object): self.sessions = PORTAL_SESSIONS self.sessions.portal = self self.process_id = os.getpid() + self.server_process_id = None self.server_restart_mode = "shutdown" + self.server_info_dict = {} # set a callback if the server is killed abruptly, # by Ctrl-C, reboot etc. @@ -163,9 +160,7 @@ class Portal(object): return self.sessions.disconnect_all() self.set_restart_mode(restart) - if os.name == 'nt' and os.path.exists(PORTAL_PIDFILE): - # for Windows we need to remove pid files manually - os.remove(PORTAL_PIDFILE) + if not _reactor_stopping: # shutting down the reactor will trigger another signal. We set # a flag to avoid loops. diff --git a/evennia/server/server.py b/evennia/server/server.py index ba17015022..3006245843 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -51,9 +51,9 @@ SERVER_STARTSTOP_MODULE = mod_import(settings.AT_SERVER_STARTSTOP_MODULE) SERVER_SERVICES_PLUGIN_MODULES = [mod_import(module) for module in make_iter(settings.SERVER_SERVICES_PLUGIN_MODULES)] -#------------------------------------------------------------ +# ------------------------------------------------------------ # Evennia Server settings -#------------------------------------------------------------ +# ------------------------------------------------------------ SERVERNAME = settings.SERVERNAME VERSION = get_evennia_version() From 0d6ff46238f860fbfc43006067390b200959215e Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 27 Jan 2018 09:43:11 +0100 Subject: [PATCH 127/189] Dumb down launcher kill for Windows' sake --- evennia/server/evennia_launcher.py | 112 +++++++++++++++++------------ 1 file changed, 65 insertions(+), 47 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index b07e3bb562..a4fa6ba7ff 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -301,10 +301,12 @@ HELP_ENTRY = \ game/database. Restarting the Server will refresh code but not disconnect users. - For more ways to operate and manage Evennia, use 'evennia -h'. + To start a new game, use 'evennia --init mygame'. + For more ways to operate and manage Evennia, see 'evennia -h'. If you want to add unit tests to your game, see https://github.com/evennia/evennia/wiki/Unit-Testing + Evennia's manual is found here: https://github.com/evennia/evennia/wiki """ @@ -318,7 +320,7 @@ MENU = \ | 2) Reload (stop/start Server in 'reload' mode) | | 3) Stop (shutdown Portal and Server) | | 4) Reboot (shutdown then restart) | - +--- Other -----------------------------------------------------+ + +--- Other operations ------------------------------------------+ | 5) Reset (stop/start Server in 'shutdown' mode) | | 6) Stop Server only | | 7) Kill Server only (send kill signal to process) | @@ -679,7 +681,7 @@ def query_status(callback=None): callback(response) else: pstatus, sstatus, ppid, spid, pinfo, sinfo = _parse_status(response) - print("Evennia Portal: {}{}\n Server: {}{}".format( + print("Portal: {}{}\nServer: {}{}".format( wmap[pstatus], " (pid {})".format(get_pid(PORTAL_PIDFILE, ppid)) if pstatus else "", wmap[sstatus], " (pid {})".format(get_pid(SERVER_PIDFILE, spid)) if sstatus else "")) _reactor_stop() @@ -1390,53 +1392,61 @@ def kill(pidfile, component='Server', callback=None, errback=None, killsignal=SI """ Send a kill signal to a process based on PID. A customized success/error message will be returned. If clean=True, the system - will attempt to manually remove the pid file. + will attempt to manually remove the pid file. On Windows, no arguments + are useful since Windows has no ability to direct signals except to all + children of a console. Args: - pidfile (str): The path of the pidfile to get the PID from. - component (str, optional): Usually one of 'Server' or 'Portal'. - errback (callable, optional): Called if signal failed to send. + pidfile (str): The path of the pidfile to get the PID from. This is ignored + on Windows. + component (str, optional): Usually one of 'Server' or 'Portal'. This is + ignored on Windows. + errback (callable, optional): Called if signal failed to send. This + is ignored on Windows. callback (callable, optional): Called if kill signal was sent successfully. - killsignal (int, optional): Signal identifier for signal to send. + This is ignored on Windows. + killsignal (int, optional): Signal identifier for signal to send. This is + Ignored on Windows. """ - pid = get_pid(pidfile) - if pid: - if os.name == 'nt': - os.remove(pidfile) + if os.name == 'nt': + # Windows signal sending is very limited. + from win32api import GenerateConsoleCtrlEvent, SetConsoleCtrlHandler try: - if os.name == 'nt': - from win32api import GenerateConsoleCtrlEvent, SetConsoleCtrlHandler - try: - # Windows can only send a SIGINT-like signal to - # *every* process spawned off the same console, so we must - # avoid killing ourselves here. - SetConsoleCtrlHandler(None, True) - GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0) - except KeyboardInterrupt: - # We must catch and ignore the interrupt sent. - pass - else: - # Linux/Unix can send the SIGINT signal directly - # to the specified PID. - os.kill(int(pid), killsignal) + # Windows can only send a SIGINT-like signal to + # *every* process spawned off the same console, so we must + # avoid killing ourselves here. + SetConsoleCtrlHandler(None, True) + GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0) + except KeyboardInterrupt: + # We must catch and ignore the interrupt sent. + pass + print("Sent kill signal to all spawned processes") - except OSError: - print("{component} ({pid}) cannot be stopped. " - "The PID file '{pidfile}' seems stale. " - "Try removing it manually.".format( - component=component, pid=pid, pidfile=pidfile)) - return - if callback: - callback() - else: - print("Sent kill signal to {component}.".format(component=component)) - return - if errback: - errback() else: - print("Could not send kill signal - {component} does " - "not appear to be running.".format(component=component)) + # Linux/Unix/Mac can send kill signal directly to specific PIDs. + pid = get_pid(pidfile) + if pid: + if os.name == 'nt': + os.remove(pidfile) + try: + os.kill(int(pid), killsignal) + except OSError: + print("{component} ({pid}) cannot be stopped. " + "The PID file '{pidfile}' seems stale. " + "Try removing it manually.".format( + component=component, pid=pid, pidfile=pidfile)) + return + if callback: + callback() + else: + print("Sent kill signal to {component}.".format(component=component)) + return + if errback: + errback() + else: + print("Could not send kill signal - {component} does " + "not appear to be running.".format(component=component)) def show_version_info(about=False): @@ -1744,10 +1754,16 @@ def run_menu(): elif inp == 6: stop_server_only() elif inp == 7: - kill(SERVER_PIDFILE, 'Server') + if os.name == 'nt': + print("Windows can't send kill signals by PID. Use option 8 instead.") + else: + kill(SERVER_PIDFILE, 'Server') elif inp == 8: - kill(SERVER_PIDFILE, 'Server') - kill(PORTAL_PIDFILE, 'Portal') + if os.name == 'nt': + kill(None) + else: + kill(SERVER_PIDFILE, 'Server') + kill(PORTAL_PIDFILE, 'Portal') elif inp == 9: if not SERVER_LOGFILE: init_game_directory(CURRENT_DIR, check_db=False) @@ -1758,8 +1774,10 @@ def run_menu(): elif inp == 11: query_info() elif inp == 12: + print("Running 'evennia --settings settings.py test .' ...") Popen(['evennia', '--settings', 'settings.py', 'test', '.'], env=getenv()).wait() elif inp == 13: + print("Running 'evennia test evennia' ...") Popen(['evennia', 'test', 'evennia'], env=getenv()).wait() else: print("Not a valid option.") @@ -1879,11 +1897,11 @@ def main(): # adjust how many lines we show from existing logs start_lines1, start_lines2 = 20, 20 - if option == 'start': + if option not in ('reload', 'reset', 'noop'): start_lines1, start_lines2 = 0, 0 tail_log_files(PORTAL_LOGFILE, SERVER_LOGFILE, start_lines1, start_lines2) - print(" Tailing logfile {} ...".format(SERVER_LOGFILE)) + print(" Tailing logfile {} (Ctrl-C to exit) ...".format(SERVER_LOGFILE)) if args.dummyrunner: # launch the dummy runner From fa4c59a66259c105d5efb43ff8996df771bcf892 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 27 Jan 2018 10:14:26 +0100 Subject: [PATCH 128/189] Rename Windows argument correctly --- evennia/server/evennia_launcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index a4fa6ba7ff..48c2bd3839 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -815,7 +815,7 @@ def start_evennia(pprofiler=False, sprofiler=False): # Windows requires special care create_no_window = 0x08000000 Popen(portal_cmd, env=getenv(), bufsize=-1, - createflags=create_no_window) + creationflags=create_no_window) else: Popen(portal_cmd, env=getenv(), bufsize=-1) except Exception as e: From 403b6c352a7737688b4a93f348bef269fd70c6b2 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 27 Jan 2018 10:57:33 +0100 Subject: [PATCH 129/189] Remove Unix-centric Zombie-process removal that eats Windows --- evennia/server/evennia_launcher.py | 31 +++++++++++++++++++---------- evennia/server/portal/amp_server.py | 17 +++++++++------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 48c2bd3839..f3e6f1c28d 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -481,6 +481,11 @@ Others, like migrate, test and shell is passed on to Django.""" # # ------------------------------------------------------------ + +def _is_windows(): + return os.name == 'nt' + + def _print_info(portal_info_dict, server_info_dict): """ Format info dicts from the Portal/Server for display @@ -811,7 +816,7 @@ def start_evennia(pprofiler=False, sprofiler=False): def _portal_not_running(fail): print("Portal starting {}...".format("(under cProfile)" if pprofiler else "")) try: - if os.name == 'nt': + if _is_windows(): # Windows requires special care create_no_window = 0x08000000 Popen(portal_cmd, env=getenv(), bufsize=-1, @@ -1349,7 +1354,7 @@ def getenv(): env (dict): Environment global dict. """ - sep = ";" if os.name == 'nt' else ":" + sep = ";" if _is_windows() else ":" env = os.environ.copy() env['PYTHONPATH'] = sep.join(sys.path) return env @@ -1409,7 +1414,7 @@ def kill(pidfile, component='Server', callback=None, errback=None, killsignal=SI Ignored on Windows. """ - if os.name == 'nt': + if _is_windows(): # Windows signal sending is very limited. from win32api import GenerateConsoleCtrlEvent, SetConsoleCtrlHandler try: @@ -1427,7 +1432,7 @@ def kill(pidfile, component='Server', callback=None, errback=None, killsignal=SI # Linux/Unix/Mac can send kill signal directly to specific PIDs. pid = get_pid(pidfile) if pid: - if os.name == 'nt': + if _is_windows(): os.remove(pidfile) try: os.kill(int(pid), killsignal) @@ -1607,7 +1612,7 @@ def init_game_directory(path, check_db=True): print(ERROR_LOGDIR_MISSING.format(logfiles=errstr)) sys.exit() - if os.name == 'nt': + if _is_windows(): # We need to handle Windows twisted separately. We create a # batchfile in game/server, linking to the actual binary @@ -1754,12 +1759,12 @@ def run_menu(): elif inp == 6: stop_server_only() elif inp == 7: - if os.name == 'nt': + if _is_windows(): print("Windows can't send kill signals by PID. Use option 8 instead.") else: kill(SERVER_PIDFILE, 'Server') elif inp == 8: - if os.name == 'nt': + if _is_windows(): kill(None) else: kill(SERVER_PIDFILE, 'Server') @@ -1937,10 +1942,16 @@ def main(): elif option == 'sstop': stop_server_only() elif option == 'kill': - kill(SERVER_PIDFILE, 'Server') - kill(PORTAL_PIDFILE, 'Portal') + if _is_windows(): + kill(None) + else: + kill(SERVER_PIDFILE, 'Server') + kill(PORTAL_PIDFILE, 'Portal') elif option == 'skill': - kill(SERVER_PIDFILE, 'Server') + if _is_windows(): + print("This is not supported on Windows. Use 'evennia kill' instead.") + else: + kill(SERVER_PIDFILE, 'Server') elif option != "noop": # pass-through to django manager check_db = False diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index 9baa7ba86f..c550a648c3 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -9,10 +9,14 @@ import sys from twisted.internet import protocol from evennia.server.portal import amp from django.conf import settings -from subprocess import Popen, STDOUT, PIPE +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. @@ -21,7 +25,7 @@ def getenv(): env (dict): Environment global dict. """ - sep = ";" if os.name == 'nt' else ":" + sep = ";" if _is_windows() else ":" env = os.environ.copy() env['PYTHONPATH'] = sep.join(sys.path) return env @@ -156,7 +160,7 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): # eventual errors happening before the Server has # opened its logger. try: - if os.name == 'nt': + if _is_windows(): # Windows requires special care create_no_window = 0x08000000 process = Popen(server_twistd_cmd, env=getenv(), bufsize=-1, @@ -171,11 +175,10 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): self.factory.portal.server_twistd_cmd = server_twistd_cmd logfile.flush() - if process: - # avoid zombie-process + if process and not _is_windows(): + # avoid zombie-process on Unix/BSD process.wait() - return process.pid - return 0 + return def wait_for_disconnect(self, callback, *args, **kwargs): """ From 18a9e4049abb94f7f0b22c0aca64aacd0d53bbe7 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 27 Jan 2018 11:10:28 +0100 Subject: [PATCH 130/189] Make evennia call explicit for Windows' sake --- evennia/server/evennia_launcher.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index f3e6f1c28d..b2f75761d0 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -39,7 +39,7 @@ CTRL_C_EVENT = 0 # Windows SIGINT-like signal # Set up the main python paths to Evennia EVENNIA_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -import evennia +import evennia # noqa EVENNIA_LIB = os.path.join(os.path.dirname(os.path.abspath(evennia.__file__))) EVENNIA_SERVER = os.path.join(EVENNIA_LIB, "server") EVENNIA_RUNNER = os.path.join(EVENNIA_SERVER, "evennia_runner.py") @@ -687,8 +687,10 @@ def query_status(callback=None): else: pstatus, sstatus, ppid, spid, pinfo, sinfo = _parse_status(response) print("Portal: {}{}\nServer: {}{}".format( - wmap[pstatus], " (pid {})".format(get_pid(PORTAL_PIDFILE, ppid)) if pstatus else "", - wmap[sstatus], " (pid {})".format(get_pid(SERVER_PIDFILE, spid)) if sstatus else "")) + wmap[pstatus], " (pid {})".format( + get_pid(PORTAL_PIDFILE, ppid)) if pstatus else "", + wmap[sstatus], " (pid {})".format( + get_pid(SERVER_PIDFILE, spid)) if sstatus else "")) _reactor_stop() def _errback(fail): @@ -1780,10 +1782,11 @@ def run_menu(): query_info() elif inp == 12: print("Running 'evennia --settings settings.py test .' ...") - Popen(['evennia', '--settings', 'settings.py', 'test', '.'], env=getenv()).wait() + Popen([sys.executable, __file__, '--settings', 'settings.py', 'test', '.'], + env=getenv()).wait() elif inp == 13: print("Running 'evennia test evennia' ...") - Popen(['evennia', 'test', 'evennia'], env=getenv()).wait() + Popen([sys.executable, __file__, 'test', 'evennia'], env=getenv()).wait() else: print("Not a valid option.") continue From daa34e7fc2f3255b0131f263ad668c8be58e71d1 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 27 Jan 2018 11:21:42 +0100 Subject: [PATCH 131/189] Turn off kill/skill on Windows since it has no PID management --- evennia/server/evennia_launcher.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index b2f75761d0..f81eed176f 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -1762,12 +1762,12 @@ def run_menu(): stop_server_only() elif inp == 7: if _is_windows(): - print("Windows can't send kill signals by PID. Use option 8 instead.") + print("This option is not supported on Windows.") else: kill(SERVER_PIDFILE, 'Server') elif inp == 8: if _is_windows(): - kill(None) + print("This option is not supported on Windows.") else: kill(SERVER_PIDFILE, 'Server') kill(PORTAL_PIDFILE, 'Portal') @@ -1946,13 +1946,13 @@ def main(): stop_server_only() elif option == 'kill': if _is_windows(): - kill(None) + print("This option is not supported on Windows.") else: kill(SERVER_PIDFILE, 'Server') kill(PORTAL_PIDFILE, 'Portal') elif option == 'skill': if _is_windows(): - print("This is not supported on Windows. Use 'evennia kill' instead.") + print("This option is not supported on Windows.") else: kill(SERVER_PIDFILE, 'Server') elif option != "noop": From c110768293f8c7cdd76a042a38d732c3cd7a1a6b Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 27 Jan 2018 11:28:20 +0100 Subject: [PATCH 132/189] Correct some language --- evennia/server/profiling/dummyrunner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/server/profiling/dummyrunner.py b/evennia/server/profiling/dummyrunner.py index 34b6acafba..30201be5f1 100644 --- a/evennia/server/profiling/dummyrunner.py +++ b/evennia/server/profiling/dummyrunner.py @@ -106,7 +106,7 @@ ERROR_NO_MIXIN = \ error completely. Warning: Don't run dummyrunner on a production database! It will - create a lot of spammy objects and account accounts! + create a lot of spammy objects and accounts! """ From 18029519a259d3a17bc329d61ae0a0bddb0de260 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 27 Jan 2018 22:10:31 +0100 Subject: [PATCH 133/189] Fix a merge artifact --- evennia/server/portal/portal.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index 1163133979..a720c73c71 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -245,10 +245,11 @@ if SSL_ENABLED: ifacestr = "-%s" % interface for port in SSL_PORTS: pstring = "%s:%s" % (ifacestr, port) - factory = telnet_ssl.SSLServerFactory() + factory = protocol.ServerFactory() factory.noisy = False factory.sessionhandler = PORTAL_SESSIONS factory.protocol = telnet_ssl.SSLProtocol + ssl_context = telnet_ssl.getSSLContext() if ssl_context: ssl_service = internet.SSLServer(port, @@ -259,7 +260,6 @@ if SSL_ENABLED: PORTAL.services.addService(ssl_service) INFO_DICT["telnet_ssl"].append("telnet+ssl%s: %s" % (ifacestr, port)) - print(" ssl%s: %s" % (ifacestr, port)) else: INFO_DICT["telnet_ssl"].append( "telnet+ssl%s: %s (deactivated - keys/cert unset)" % (ifacestr, port)) From 4ce407afca4cb244006e2405c22f9d5450c564ae Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 28 Jan 2018 11:08:37 +0100 Subject: [PATCH 134/189] Improve info about and when running evennia --log --- evennia/server/evennia_launcher.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index f81eed176f..c82b922ca0 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -326,7 +326,7 @@ MENU = \ | 7) Kill Server only (send kill signal to process) | | 8) Kill Portal + Server | +--- Information -----------------------------------------------+ - | 9) Tail log file (quickly see errors) | + | 9) Tail log files (quickly see errors) | | 10) Status | | 11) Port info | +--- Testing ---------------------------------------------------+ @@ -486,6 +486,17 @@ def _is_windows(): return os.name == 'nt' +def _file_names_compact(filepath1, filepath2): + "Compact the output of filenames with same base dir" + dirname1 = os.path.dirname(filepath1) + dirname2 = os.path.dirname(filepath2) + if dirname1 == dirname2: + name2 = os.path.basename(filepath2) + return "{} and {}".format(filepath1, name2) + else: + return "{} and {}". format(filepath1, filepath2) + + def _print_info(portal_info_dict, server_info_dict): """ Format info dicts from the Portal/Server for display @@ -1775,7 +1786,8 @@ def run_menu(): if not SERVER_LOGFILE: init_game_directory(CURRENT_DIR, check_db=False) tail_log_files(PORTAL_LOGFILE, SERVER_LOGFILE, 20, 20) - print(" Tailing logfile {} ...".format(SERVER_LOGFILE)) + print(" Tailing logfiles {} (Ctrl-C to exit) ...".format( + _file_names_compact(SERVER_LOGFILE, PORTAL_LOGFILE))) elif inp == 10: query_status() elif inp == 11: @@ -1810,7 +1822,7 @@ def main(): help="creates a new gamedir 'name' at current location") parser.add_argument( '--log', '-l', action='store_true', dest='tail_log', default=False, - help="tail the server logfile to console") + help="tail the portal and server logfiles and print to stdout") parser.add_argument( '--list', nargs='+', action='store', dest='listsetting', metavar="all|", help=("list settings, use 'all' to list all available keys")) @@ -1909,8 +1921,8 @@ def main(): start_lines1, start_lines2 = 0, 0 tail_log_files(PORTAL_LOGFILE, SERVER_LOGFILE, start_lines1, start_lines2) - print(" Tailing logfile {} (Ctrl-C to exit) ...".format(SERVER_LOGFILE)) - + print(" Tailing logfiles {} (Ctrl-C to exit) ...".format( + _file_names_compact(SERVER_LOGFILE, PORTAL_LOGFILE))) if args.dummyrunner: # launch the dummy runner init_game_directory(CURRENT_DIR, check_db=True) From 3ae096859ec185cb086cda6cb69a8bdf00636686 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 1 Feb 2018 21:28:35 +0100 Subject: [PATCH 135/189] Not working. Tried a different query-setup unsuccessfully. --- evennia/typeclasses/managers.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/evennia/typeclasses/managers.py b/evennia/typeclasses/managers.py index 990d7dabe6..ce84798dec 100644 --- a/evennia/typeclasses/managers.py +++ b/evennia/typeclasses/managers.py @@ -244,7 +244,6 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): # query.append(("db_tags__db_category", category.lower())) # return self.filter(**dict(query)) - @returns_typeclass_list def get_by_tag(self, key=None, category=None, tagtype=None): """ Return objects having tags with a given key or category or combination of the two. @@ -276,11 +275,13 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): if n_keys > 1: if n_categories == 1: category = categories[0] - query = Q(db_tags__db_tagtype=tagtype.lower() if tagtype else tagtype, - db_tags__db_category=category.lower() if category else category, - db_tags__db_model=dbmodel) + query = Q() for key in keys: - query = query & Q(db_tags__db_key=key.lower()) + query = query & \ + Q(db_tags__db_tagtype=tagtype.lower() if tagtype else tagtype, + db_tags__db_category=category.lower() if category else category, + db_tags__db_model=dbmodel, + db_tags__db_key=key.lower()) print "Query:", query else: query = Q(db_tags__db_tagtype=tagtype.lower(), From 039308b573d69b453f3b9d90c9df01bf60d6f7b7 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 3 Feb 2018 16:34:12 +0100 Subject: [PATCH 136/189] Start setting up unittests for typeclass tests --- evennia/typeclasses/tests.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 evennia/typeclasses/tests.py diff --git a/evennia/typeclasses/tests.py b/evennia/typeclasses/tests.py new file mode 100644 index 0000000000..0885c35d40 --- /dev/null +++ b/evennia/typeclasses/tests.py @@ -0,0 +1,21 @@ +""" +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 getattr(self.obj1.__class__.objects, methodname)(*args, **kwargs) + + def test_get_by_tag_no_category(self): + self.obj1.tags.add("tag1") + self.obj2.tags.add("tag2") + self.obj2.tags.add("tag3") + self.assertEquals(list(self._manager("get_by_tag", "tag1")), [self.obj1l]) From 56aeaf8486976e0bdd513b3018a3536758eee3a9 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 3 Feb 2018 19:42:24 +0100 Subject: [PATCH 137/189] get_by_tag manager can now query for multiple tag/category combinations --- evennia/typeclasses/managers.py | 80 ++++++++++++--------------------- evennia/typeclasses/tests.py | 44 ++++++++++++++++-- 2 files changed, 69 insertions(+), 55 deletions(-) diff --git a/evennia/typeclasses/managers.py b/evennia/typeclasses/managers.py index ce84798dec..67eb9e065b 100644 --- a/evennia/typeclasses/managers.py +++ b/evennia/typeclasses/managers.py @@ -221,29 +221,6 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): """ return self.get_tag(key=key, category=category, obj=obj, tagtype="alias") -# @returns_typeclass_list -# def get_by_tag(self, key=None, category=None, tagtype=None): -# """ -# Return objects having tags with a given key or category or -# combination of the two. -# -# Args: -# key (str, optional): Tag key. Not case sensitive. -# category (str, optional): Tag category. Not case sensitive. -# tagtype (str or None, optional): 'type' of Tag, by default -# this is either `None` (a normal Tag), `alias` or -# `permission`. -# Returns: -# objects (list): Objects with matching tag. -# """ -# dbmodel = self.model.__dbclass__.__name__.lower() -# query = [("db_tags__db_tagtype", tagtype), ("db_tags__db_model", dbmodel)] -# if key: -# query.append(("db_tags__db_key", key.lower())) -# if category: -# query.append(("db_tags__db_category", category.lower())) -# return self.filter(**dict(query)) - def get_by_tag(self, key=None, category=None, tagtype=None): """ Return objects having tags with a given key or category or combination of the two. @@ -253,7 +230,8 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): key (str or list, optional): Tag key or list of keys. Not case sensitive. category (str or list, optional): Tag category. Not case sensitive. If `key` is a list, a single category can either apply to all keys in that list or this - must be a list matching the `key` list element by element. + must be a list matching the `key` list element by element. If no `key` is given, + all objects with tags of this category are returned. tagtype (str, optional): 'type' of Tag, by default this is either `None` (a normal Tag), `alias` or `permission`. This always apply to all queried tags. @@ -266,39 +244,37 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): than `key`. """ - keys = make_iter(key) - categories = make_iter(category) + 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() - if n_keys > 1: - if n_categories == 1: - category = categories[0] - query = Q() - for key in keys: - query = query & \ - Q(db_tags__db_tagtype=tagtype.lower() if tagtype else tagtype, - db_tags__db_category=category.lower() if category else category, - db_tags__db_model=dbmodel, - db_tags__db_key=key.lower()) - print "Query:", query - else: - query = Q(db_tags__db_tagtype=tagtype.lower(), - db_tags__db_model=dbmodel) - for ikey, key in keys: - category = categories[ikey] - category = category.lower() if category else category - query = query & Q(db_tags__db_key=key.lower(), - db_tags__db_category=category) - return self.filter(query) + query = self.filter(db_tags__db_tagtype__iexact=tagtype, + db_tags__db_model__iexact=dbmodel).distinct() + + if n_keys > 0: + # keys and/or categories given + if n_categories == 0: + categories = [None for _ in range(n_keys)] + elif n_categories == 1 and n_keys > 1: + cat = categories[0] + categories = [cat for _ in range(n_keys)] + elif 1 < n_categories < n_keys: + raise IndexError("get_by_tag needs a single category or a list of categories " + "the same length as the list of tags.") + for ikey, key in enumerate(keys): + query = query.filter(db_tags__db_key__iexact=key, + db_tags__db_category__iexact=categories[ikey]) else: - query = [("db_tags__db_tagtype", tagtype), ("db_tags__db_model", dbmodel)] - if key: - query.append(("db_tags__db_key", keys[0].lower())) - if category: - query.append(("db_tags__db_category", categories[0].lower())) - return self.filter(**dict(query)) + # only one or more categories given + for category in categories: + query = query.filter(db_tags__db_category__iexact=category) + + return query def get_by_permission(self, key=None, category=None): """ diff --git a/evennia/typeclasses/tests.py b/evennia/typeclasses/tests.py index 0885c35d40..b4a2361aae 100644 --- a/evennia/typeclasses/tests.py +++ b/evennia/typeclasses/tests.py @@ -12,10 +12,48 @@ from evennia.utils.test_resources import EvenniaTest class TestTypedObjectManager(EvenniaTest): def _manager(self, methodname, *args, **kwargs): - return getattr(self.obj1.__class__.objects, 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("tag3") - self.assertEquals(list(self._manager("get_by_tag", "tag1")), [self.obj1l]) + 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"]), []) From 84f7fbdd37c414fc90d9b4e039b1451be01f2ef8 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 8 Feb 2018 22:46:14 +0100 Subject: [PATCH 138/189] Add DefaultChannel.basetype_setup and add default locks, empty at_channel_creation to avoid having to use super() --- evennia/comms/comms.py | 16 +++++++++++----- evennia/comms/models.py | 1 + 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/evennia/comms/comms.py b/evennia/comms/comms.py index e40de664d1..7ff62dfc27 100644 --- a/evennia/comms/comms.py +++ b/evennia/comms/comms.py @@ -26,6 +26,7 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)): the hooks called by this method. """ + self.basetype_setup() self.at_channel_creation() self.attributes.add("log_file", "channel_%s.log" % self.key) if hasattr(self, "_createdict"): @@ -46,11 +47,7 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)): if cdict.get("desc"): self.attributes.add("desc", cdict["desc"]) - def at_channel_creation(self): - """ - Called once, when the channel is first created. - - """ + def basetype_setup(self): # delayed import of the channelhandler global _CHANNEL_HANDLER if not _CHANNEL_HANDLER: @@ -58,6 +55,15 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)): # register ourselves with the channelhandler. _CHANNEL_HANDLER.add(self) + self.locks.add("send:all();listen:all();control:perm(Admin)") + + def at_channel_creation(self): + """ + Called once, when the channel is first created. + + """ + pass + # helper methods, for easy overloading def has_connection(self, subscriber): diff --git a/evennia/comms/models.py b/evennia/comms/models.py index b1a5a37ed9..c456a6e72c 100644 --- a/evennia/comms/models.py +++ b/evennia/comms/models.py @@ -527,6 +527,7 @@ class SubscriptionHandler(object): for subscriber in make_iter(entity): if subscriber: clsname = subscriber.__dbclass__.__name__ + print("subscriber:", subscriber, clsname) # chooses the right type if clsname == "ObjectDB": self.obj.db_object_subscriptions.add(subscriber) From f2e5e70d9b4f6e4981778686f8c2f618479b16c1 Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Thu, 15 Feb 2018 17:25:01 -0500 Subject: [PATCH 139/189] Add loc switch to CmdFind and docstring --- evennia/commands/default/building.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index de263fa4ff..ee0062e96b 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2238,12 +2238,14 @@ class CmdFind(COMMAND_DEFAULT_CLASS): Usage: @find[/switches] [= dbrefmin[-dbrefmax]] + @locate - this is a shorthand for using the /loc switch. Switches: room - only look for rooms (location=None) exit - only look for exits (destination!=None) char - only look for characters (BASE_CHARACTER_TYPECLASS) exact- only exact matches are returned. + loc - display object location if exists and match has one result Searches the database for an object of a particular name or exact #dbref. Use *accountname to search for an account. The switches allows for @@ -2266,6 +2268,9 @@ class CmdFind(COMMAND_DEFAULT_CLASS): caller.msg("Usage: @find [= low [-high]]") return + if "locate" in self.cmdstring: # Use option /loc as a default for @locate command alias + switches.append('loc') + searchstring = self.lhs low, high = 1, ObjectDB.objects.all().order_by("-id")[0].id if self.rhs: @@ -2315,8 +2320,8 @@ class CmdFind(COMMAND_DEFAULT_CLASS): else: result = result[0] string += "\n|g %s - %s|n" % (result.get_display_name(caller), result.path) - if "locate" in self.cmdstring and not is_account and result.location: - string += " Location: {}".format(result.location.get_display_name(caller)) + if "loc" in self.switches and not is_account and result.location: + string += " (|wlocation|n: |g{}|n)".format(result.location.get_display_name(caller)) else: # Not an account/dbref search but a wider search; build a queryset. # Searchs for key and aliases @@ -2352,8 +2357,8 @@ class CmdFind(COMMAND_DEFAULT_CLASS): else: string = "|wOne Match|n(#%i-#%i%s):" % (low, high, restrictions) string += "\n |g%s - %s|n" % (results[0].get_display_name(caller), results[0].path) - if "locate" in self.cmdstring and nresults == 1 and results[0].location: - string += " Location: {}".format(results[0].location.get_display_name(caller)) + if "loc" in self.switches and nresults == 1 and results[0].location: + string += " (|wlocation|n: |g{}|n)".format(results[0].location.get_display_name(caller)) else: string = "|wMatch|n(#%i-#%i%s):" % (low, high, restrictions) string += "\n |RNo matches found for '%s'|n" % searchstring From 998cc759d5db8921ba2148336af6a59bb5164b0e Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Thu, 15 Feb 2018 19:41:24 -0500 Subject: [PATCH 140/189] Add test for CmdFind locate alias and loc switch --- evennia/commands/default/tests.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 013baec4cc..850c1b6e4f 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -293,6 +293,12 @@ class TestBuilding(CommandTest): def test_find(self): self.call(building.CmdFind(), "Room2", "One Match") + expect = "One Match(#1#7, loc):\n " +\ + "Char2(#7) evennia.objects.objects.DefaultCharacter (location: Room(#1))" + self.call(building.CmdFind(), "Char2", expect, cmdstring="locate") + self.call(building.CmdFind(), "Char2", expect, cmdstring="@locate") + self.call(building.CmdFind(), "/loc Char2", expect, cmdstring="find") + self.call(building.CmdFind(), "Char2", "One Match", cmdstring="@find") def test_script(self): self.call(building.CmdScript(), "Obj = scripts.Script", "Script scripts.Script successfully added") From a0626721fcae085bfce64bfc258304260fe6f937 Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Fri, 2 Mar 2018 07:14:57 -0500 Subject: [PATCH 141/189] Mark print line as DEBUG This debug line spams unit tests for comms. Be sure to note for easy location for later removal. --- evennia/comms/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/comms/models.py b/evennia/comms/models.py index c456a6e72c..e66f00e813 100644 --- a/evennia/comms/models.py +++ b/evennia/comms/models.py @@ -527,7 +527,7 @@ class SubscriptionHandler(object): for subscriber in make_iter(entity): if subscriber: clsname = subscriber.__dbclass__.__name__ - print("subscriber:", subscriber, clsname) + print("subscriber:", subscriber, clsname) # DEBUG # chooses the right type if clsname == "ObjectDB": self.obj.db_object_subscriptions.add(subscriber) From 63718bb09075dc11a7c338fbb79e8b45bbef4d90 Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Fri, 2 Mar 2018 07:18:18 -0500 Subject: [PATCH 142/189] MUX command add optional self.option, self.split These optional variables aid in parsing, if set. self.options = (tuple of valid /switches expected by this command self.split = Alternate string delimiter to separate left/right side. --- evennia/commands/default/muxcommand.py | 48 +++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/evennia/commands/default/muxcommand.py b/evennia/commands/default/muxcommand.py index 5d8d4b2890..6d731ea14f 100644 --- a/evennia/commands/default/muxcommand.py +++ b/evennia/commands/default/muxcommand.py @@ -79,6 +79,11 @@ class MuxCommand(Command): it here). The rest of the command is stored in self.args, which can start with the switch indicator /. + Optional variables to aid in parsing, if set: + self.options = (tuple of valid /switches expected + by this command (without the /)) + self.split = Alternate string delimiter to separate left/right hand side. + This parser breaks self.args into its constituents and stores them in the following variables: self.switches = [list of /switches (without the /)] @@ -109,15 +114,44 @@ class MuxCommand(Command): else: args = "" switches = switches[0].split('/') + # Parse mux options, comparing them against user-provided switches, expanding abbreviations. + if hasattr(self, "options") and self.options and switches: + # If specific options are known, test them against given switches. + valid_switches, unused_switches, extra_switches = [], [], [] + for element in switches: + option_check = [each for each in self.options if each.lower().startswith(element.lower())] + if len(option_check) > 1: + extra_switches += option_check # Either the option provided is ambiguous, + elif len(option_check) == 1: + valid_switches += option_check # or it is a valid option abbreviation, + elif len(option_check) == 0: + unused_switches += [element] # or an extraneous option to be ignored. + if extra_switches: # User provided switches + self.msg('|g%s|n: |wAmbiguous switch supplied: Did you mean /|C%s|w?' % + (self.cmdstring, ' |nor /|C'.join(extra_switches))) + if unused_switches: + plural = '' if len(unused_switches) == 1 else 'es' + self.msg('|g%s|n: |wExtra switch%s "/|C%s|w" ignored.' % + (self.cmdstring, plural, '|n, /|C'.join(unused_switches))) + switches = valid_switches # Only include valid_switches in command function call arglist = [arg.strip() for arg in args.split()] # check for arg1, arg2, ... = argA, argB, ... constructs - lhs, rhs = args, None + lhs, rhs = args.strip(), None lhslist, rhslist = [arg.strip() for arg in args.split(',')], [] - if args and '=' in args: - lhs, rhs = [arg.strip() for arg in args.split('=', 1)] - lhslist = [arg.strip() for arg in lhs.split(',')] - rhslist = [arg.strip() for arg in rhs.split(',')] + if lhs: + if '=' in lhs: # Default delimiter has priority + # Parse to separate left into left/right sides using default delimiter + lhs, rhs = lhs.split('=', 1) + elif hasattr(self, "split") and self.split and self.split in lhs: + # Parse to separate left into left/right sides using a custom delimiter, if provided. + lhs, rhs = lhs.split(self.split, 1) # At most, split once, into left and right parts. + # Trim user-injected whitespace + rhs = rhs.strip() if rhs is not None else None + lhs = lhs.strip() + # Further split left/right sides by comma delimiter + lhslist = [arg.strip() for arg in lhs.split(',')] if lhs is not None else "" + rhslist = [arg.strip() for arg in rhs.split(',')] if rhs is not None else "" # save to object properties: self.raw = raw @@ -169,6 +203,10 @@ class MuxCommand(Command): string += "\nraw argument (self.raw): |w%s|n \n" % self.raw string += "cmd args (self.args): |w%s|n\n" % self.args string += "cmd switches (self.switches): |w%s|n\n" % self.switches + if hasattr(self, "options"): # Optional + string += "cmd options (self.options): |w%s|n\n" % self.options + if hasattr(self, "split"): # Optional + string += "cmd parse left/right using (self.split): |w%s|n\n" % self.split string += "space-separated arg list (self.arglist): |w%s|n\n" % self.arglist string += "lhs, left-hand side of '=' (self.lhs): |w%s|n\n" % self.lhs string += "lhs, comma separated (self.lhslist): |w%s|n\n" % self.lhslist From d3a0c70945061599b7e09ca344a7a6441199834a Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Fri, 2 Mar 2018 07:22:26 -0500 Subject: [PATCH 143/189] Add tests for MUX command added feature Shortened long lines, added tests with different parses for MUX command added class vars: options and split --- evennia/commands/default/tests.py | 84 ++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 29 deletions(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 38829f0cf5..f581c32589 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -72,7 +72,6 @@ class CommandTest(EvenniaTest): cmdobj.obj = obj or (caller if caller else self.char1) # test old_msg = receiver.msg - returned_msg = "" try: receiver.msg = Mock() cmdobj.at_pre_cmd() @@ -126,9 +125,12 @@ class TestGeneral(CommandTest): self.call(general.CmdPose(), "looks around", "Char looks around") def test_nick(self): - self.call(general.CmdNick(), "testalias = testaliasedstring1", "Inputlinenick 'testalias' mapped to 'testaliasedstring1'.") - self.call(general.CmdNick(), "/account testalias = testaliasedstring2", "Accountnick 'testalias' mapped to 'testaliasedstring2'.") - self.call(general.CmdNick(), "/object testalias = testaliasedstring3", "Objectnick 'testalias' mapped to 'testaliasedstring3'.") + self.call(general.CmdNick(), "testalias = testaliasedstring1", + "Inputlinenick 'testalias' mapped to 'testaliasedstring1'.") + self.call(general.CmdNick(), "/account testalias = testaliasedstring2", + "Accountnick 'testalias' mapped to 'testaliasedstring2'.") + self.call(general.CmdNick(), "/object testalias = testaliasedstring3", + "Objectnick 'testalias' mapped to 'testaliasedstring3'.") self.assertEqual(u"testaliasedstring1", self.char1.nicks.get("testalias")) self.assertEqual(None, self.char1.nicks.get("testalias", category="account")) self.assertEqual(u"testaliasedstring2", self.char1.account.nicks.get("testalias", category="account")) @@ -225,7 +227,8 @@ class TestAccount(CommandTest): self.call(account.CmdColorTest(), "ansi", "ANSI colors:", caller=self.account) def test_char_create(self): - self.call(account.CmdCharCreate(), "Test1=Test char", "Created new character Test1. Use @ic Test1 to enter the game", caller=self.account) + self.call(account.CmdCharCreate(), "Test1=Test char", + "Created new character Test1. Use @ic Test1 to enter the game", caller=self.account) def test_quell(self): self.call(account.CmdQuell(), "", "Quelling to current puppet's permissions (developer).", caller=self.account) @@ -234,7 +237,8 @@ class TestAccount(CommandTest): class TestBuilding(CommandTest): def test_create(self): name = settings.BASE_OBJECT_TYPECLASS.rsplit('.', 1)[1] - self.call(building.CmdCreate(), "/drop TestObj1", "You create a new %s: TestObj1." % name) + self.call(building.CmdCreate(), "/d TestObj1", # /d switch is abbreviated form of /drop + "You create a new %s: TestObj1." % name) def test_examine(self): self.call(building.CmdExamine(), "Obj", "Name/key: Obj") @@ -244,7 +248,8 @@ class TestBuilding(CommandTest): self.call(building.CmdSetObjAlias(), "Obj = TestObj1b", "Alias(es) for 'Obj(#4)' set to 'testobj1b'.") def test_copy(self): - self.call(building.CmdCopy(), "Obj = TestObj2;TestObj2b, TestObj3;TestObj3b", "Copied Obj to 'TestObj3' (aliases: ['TestObj3b']") + self.call(building.CmdCopy(), "Obj = TestObj2;TestObj2b, TestObj3;TestObj3b", + "Copied Obj to 'TestObj3' (aliases: ['TestObj3b']") def test_attribute_commands(self): self.call(building.CmdSetAttribute(), "Obj/test1=\"value1\"", "Created attribute Obj/test1 = 'value1'") @@ -287,7 +292,8 @@ class TestBuilding(CommandTest): def test_typeclass(self): self.call(building.CmdTypeclass(), "Obj = evennia.objects.objects.DefaultExit", - "Obj changed typeclass from evennia.objects.objects.DefaultObject to evennia.objects.objects.DefaultExit.") + "Obj changed typeclass from evennia.objects.objects.DefaultObject " + "to evennia.objects.objects.DefaultExit.") def test_lock(self): self.call(building.CmdLock(), "Obj = test:perm(Developer)", "Added lock 'test:perm(Developer)' to Obj.") @@ -297,15 +303,24 @@ class TestBuilding(CommandTest): 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(), "/loc Char2", expect, cmdstring="find") + self.call(building.CmdFind(), "/l Char2", expect, cmdstring="find") # /l switch is abbreviated form of /loc self.call(building.CmdFind(), "Char2", "One Match", cmdstring="@find") def test_script(self): self.call(building.CmdScript(), "Obj = scripts.Script", "Script scripts.Script successfully added") def test_teleport(self): - self.call(building.CmdTeleport(), "Room2", "Room2(#2)\n|Teleported to Room2.") + self.call(building.CmdTeleport(), "/quiet Room2", "Room2(#2)\n|Teleported to Room2.") + self.call(building.CmdTeleport(), "/t", # /t switch is abbreviated form of /tonone + "Cannot teleport a puppeted object (Char, puppeted by TestAccount(account 1)) to a Nonelocation.") + self.call(building.CmdTeleport(), "/l Room2", # /l switch is abbreviated form of /loc + "Destination has no location.") + self.call(building.CmdTeleport(), "/q me to Room2", # /q switch is abbreviated form of /quiet + "Char is already at Room2.") def test_spawn(self): def getObject(commandTest, objKeyStr): @@ -321,7 +336,7 @@ class TestBuilding(CommandTest): self.call(building.CmdSpawn(), " ", "Usage: @spawn") # Tests "@spawn " without specifying location. - self.call(building.CmdSpawn(), \ + self.call(building.CmdSpawn(), "{'key':'goblin', 'typeclass':'evennia.DefaultCharacter'}", "Spawned goblin") goblin = getObject(self, "goblin") @@ -340,8 +355,8 @@ class TestBuilding(CommandTest): # char1's default location in the future... spawnLoc = self.room1 - self.call(building.CmdSpawn(), \ - "{'prototype':'GOBLIN', 'key':'goblin', 'location':'%s'}" \ + self.call(building.CmdSpawn(), + "{'prototype':'GOBLIN', 'key':'goblin', 'location':'%s'}" % spawnLoc.dbref, "Spawned goblin") goblin = getObject(self, "goblin") self.assertEqual(goblin.location, spawnLoc) @@ -354,70 +369,81 @@ class TestBuilding(CommandTest): self.assertIsInstance(ball, DefaultObject) ball.delete() - # Tests "@spawn/noloc ..." without specifying a location. + # Tests "@spawn/n ..." without specifying a location. # Location should be "None". - self.call(building.CmdSpawn(), "/noloc 'BALL'", "Spawned Ball") + self.call(building.CmdSpawn(), "/n 'BALL'", "Spawned Ball") # /n switch is abbreviated form of /noloc ball = getObject(self, "Ball") self.assertIsNone(ball.location) ball.delete() # Tests "@spawn/noloc ...", but DO specify a location. # Location should be the specified location. - self.call(building.CmdSpawn(), \ - "/noloc {'prototype':'BALL', 'location':'%s'}" \ + self.call(building.CmdSpawn(), + "/noloc {'prototype':'BALL', 'location':'%s'}" % spawnLoc.dbref, "Spawned Ball") ball = getObject(self, "Ball") self.assertEqual(ball.location, spawnLoc) ball.delete() # test calling spawn with an invalid prototype. - self.call(building.CmdSpawn(), \ - "'NO_EXIST'", "No prototype named 'NO_EXIST'") + self.call(building.CmdSpawn(), "'NO_EXIST'", "No prototype named 'NO_EXIST'") class TestComms(CommandTest): def setUp(self): super(CommandTest, self).setUp() - self.call(comms.CmdChannelCreate(), "testchan;test=Test Channel", "Created channel testchan and connected to it.", receiver=self.account) + self.call(comms.CmdChannelCreate(), "testchan;test=Test Channel", + "Created channel testchan and connected to it.", receiver=self.account) def test_toggle_com(self): - self.call(comms.CmdAddCom(), "tc = testchan", "You are already connected to channel testchan. You can now", receiver=self.account) + self.call(comms.CmdAddCom(), "tc = testchan", + "You are already connected to channel testchan. You can now", receiver=self.account) self.call(comms.CmdDelCom(), "tc", "Your alias 'tc' for channel testchan was cleared.", receiver=self.account) def test_channels(self): - self.call(comms.CmdChannels(), "", "Available channels (use comlist,addcom and delcom to manage", receiver=self.account) + self.call(comms.CmdChannels(), "", + "Available channels (use comlist,addcom and delcom to manage", receiver=self.account) def test_all_com(self): - self.call(comms.CmdAllCom(), "", "Available channels (use comlist,addcom and delcom to manage", receiver=self.account) + self.call(comms.CmdAllCom(), "", + "Available channels (use comlist,addcom and delcom to manage", receiver=self.account) def test_clock(self): - self.call(comms.CmdClock(), "testchan=send:all()", "Lock(s) applied. Current locks on testchan:", receiver=self.account) + self.call(comms.CmdClock(), + "testchan=send:all()", "Lock(s) applied. Current locks on testchan:", receiver=self.account) def test_cdesc(self): - self.call(comms.CmdCdesc(), "testchan = Test Channel", "Description of channel 'testchan' set to 'Test Channel'.", receiver=self.account) + self.call(comms.CmdCdesc(), "testchan = Test Channel", + "Description of channel 'testchan' set to 'Test Channel'.", receiver=self.account) def test_cemit(self): - self.call(comms.CmdCemit(), "testchan = Test Message", "[testchan] Test Message|Sent to channel testchan: Test Message", receiver=self.account) + self.call(comms.CmdCemit(), "testchan = Test Message", + "[testchan] Test Message|Sent to channel testchan: Test Message", receiver=self.account) def test_cwho(self): self.call(comms.CmdCWho(), "testchan", "Channel subscriptions\ntestchan:\n TestAccount", receiver=self.account) def test_page(self): - self.call(comms.CmdPage(), "TestAccount2 = Test", "TestAccount2 is offline. They will see your message if they list their pages later.|You paged TestAccount2 with: 'Test'.", receiver=self.account) + self.call(comms.CmdPage(), "TestAccount2 = Test", + "TestAccount2 is offline. They will see your message if they list their pages later." + "|You paged TestAccount2 with: 'Test'.", receiver=self.account) def test_cboot(self): # No one else connected to boot self.call(comms.CmdCBoot(), "", "Usage: @cboot[/quiet] = [:reason]", receiver=self.account) def test_cdestroy(self): - self.call(comms.CmdCdestroy(), "testchan", "[testchan] TestAccount: testchan is being destroyed. Make sure to change your aliases.|Channel 'testchan' was destroyed.", receiver=self.account) + self.call(comms.CmdCdestroy(), "testchan", + "[testchan] TestAccount: testchan is being destroyed. Make sure to change your aliases." + "|Channel 'testchan' was destroyed.", receiver=self.account) class TestBatchProcess(CommandTest): def test_batch_commands(self): # cannot test batchcode here, it must run inside the server process - self.call(batchprocess.CmdBatchCommands(), "example_batch_cmds", "Running Batchcommand processor Automatic mode for example_batch_cmds") + self.call(batchprocess.CmdBatchCommands(), "example_batch_cmds", + "Running Batchcommand processor Automatic mode for example_batch_cmds") # we make sure to delete the button again here to stop the running reactor confirm = building.CmdDestroy.confirm building.CmdDestroy.confirm = False From e939ae84245730b16c69375c4c0eaa4fad5d5d43 Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Fri, 2 Mar 2018 07:24:27 -0500 Subject: [PATCH 144/189] Added options class variable to CmdOption, CmdQuit docstring Switch is plural for multiple Switches --- evennia/commands/default/account.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index 20cc374542..81539bba17 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -455,7 +455,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS): Usage: @option[/save] [name = value] - Switch: + Switches: save - Save the current option settings for future logins. clear - Clear the saved options. @@ -467,6 +467,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS): """ key = "@option" aliases = "@options" + options = ("save", "clear") locks = "cmd:all()" # this is used by the parent @@ -650,6 +651,7 @@ class CmdQuit(COMMAND_DEFAULT_CLASS): game. Use the /all switch to disconnect from all sessions. """ key = "@quit" + options = ("all",) locks = "cmd:all()" # this is used by the parent From cd0283d4e7c0d3a7aeea2a1dbd1a3fb5bb0ce1f7 Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Fri, 2 Mar 2018 07:27:57 -0500 Subject: [PATCH 145/189] Add options class var to some admin commands CmdBoot, CmdDelAccount, CmdEmit + Docstring edit, CmdPerm + Docstring edit. --- evennia/commands/default/admin.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/evennia/commands/default/admin.py b/evennia/commands/default/admin.py index 8b694ffd8f..dba0c49bdd 100644 --- a/evennia/commands/default/admin.py +++ b/evennia/commands/default/admin.py @@ -36,6 +36,7 @@ class CmdBoot(COMMAND_DEFAULT_CLASS): """ key = "@boot" + options = ("quiet", "sid") locks = "cmd:perm(boot) or perm(Admin)" help_category = "Admin" @@ -265,6 +266,7 @@ class CmdDelAccount(COMMAND_DEFAULT_CLASS): """ key = "@delaccount" + options = ("delobj",) locks = "cmd:perm(delaccount) or perm(Developer)" help_category = "Admin" @@ -329,9 +331,9 @@ class CmdEmit(COMMAND_DEFAULT_CLASS): @pemit [, , ... =] Switches: - room : limit emits to rooms only (default) - accounts : limit emits to accounts only - contents : send to the contents of matched objects too + room - limit emits to rooms only (default) + accounts - limit emits to accounts only + contents - send to the contents of matched objects too Emits a message to the selected objects or to your immediate surroundings. If the object is a room, @@ -341,6 +343,7 @@ class CmdEmit(COMMAND_DEFAULT_CLASS): """ key = "@emit" aliases = ["@pemit", "@remit"] + options = ("room", "accounts", "contents") locks = "cmd:perm(emit) or perm(Builder)" help_category = "Admin" @@ -442,14 +445,15 @@ class CmdPerm(COMMAND_DEFAULT_CLASS): @perm[/switch] * [= [,,...]] Switches: - del : delete the given permission from or . - account : set permission on an account (same as adding * to name) + del - delete the given permission from or . + account - set permission on an account (same as adding * to name) This command sets/clears individual permission strings on an object or account. If no permission is given, list all permissions on . """ key = "@perm" aliases = "@setperm" + options = ("del", "account") locks = "cmd:perm(perm) or perm(Developer)" help_category = "Admin" From 8cd21defca49b991e36e8db6d62e9254eb2131be Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Fri, 2 Mar 2018 07:31:33 -0500 Subject: [PATCH 146/189] Adds class var options to some system commands CmdPy, CmdScripts, CmdService, CmdServerLoad and plural of Switch is Switches in docstring --- evennia/commands/default/system.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/evennia/commands/default/system.py b/evennia/commands/default/system.py index 7bd092bad5..e4d6242a56 100644 --- a/evennia/commands/default/system.py +++ b/evennia/commands/default/system.py @@ -245,6 +245,7 @@ class CmdPy(COMMAND_DEFAULT_CLASS): """ key = "@py" aliases = ["!"] + options = ("time", "edit") locks = "cmd:perm(py) or perm(Developer)" help_category = "System" @@ -328,6 +329,7 @@ class CmdScripts(COMMAND_DEFAULT_CLASS): """ key = "@scripts" aliases = ["@globalscript", "@listscripts"] + options = ("start", "stop", "kill", "validate") locks = "cmd:perm(listscripts) or perm(Admin)" help_category = "System" @@ -521,6 +523,7 @@ class CmdService(COMMAND_DEFAULT_CLASS): key = "@service" aliases = ["@services"] + options = ("list", "start", "stop", "delete") locks = "cmd:perm(service) or perm(Developer)" help_category = "System" @@ -672,7 +675,7 @@ class CmdServerLoad(COMMAND_DEFAULT_CLASS): Usage: @server[/mem] - Switch: + Switches: mem - return only a string of the current memory usage flushmem - flush the idmapper cache @@ -703,6 +706,7 @@ class CmdServerLoad(COMMAND_DEFAULT_CLASS): """ key = "@server" aliases = ["@serverload", "@serverprocess"] + options = ("mem", "flushmem") locks = "cmd:perm(list) or perm(Developer)" help_category = "System" From bd235315abb6bdaf476ad36ec08a9cfa08c3cdf0 Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Fri, 2 Mar 2018 07:37:33 -0500 Subject: [PATCH 147/189] Adds class var options to some comms commands CmdCBoot + docstring Switches plural, CmdCemit, CmdPage, CmdIRC2Chan, CmdIRC2Chan. --- evennia/commands/default/comms.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index 37a4934d16..b654bd0297 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -377,7 +377,7 @@ class CmdCBoot(COMMAND_DEFAULT_CLASS): Usage: @cboot[/quiet] = [:reason] - Switches: + Switch: quiet - don't notify the channel Kicks an account or object from a channel you control. @@ -385,6 +385,7 @@ class CmdCBoot(COMMAND_DEFAULT_CLASS): """ key = "@cboot" + options = ("quiet",) locks = "cmd: not pperm(channel_banned)" help_category = "Comms" @@ -453,6 +454,7 @@ class CmdCemit(COMMAND_DEFAULT_CLASS): key = "@cemit" aliases = ["@cmsg"] + options = ("sendername", "quiet") locks = "cmd: not pperm(channel_banned) and pperm(Player)" help_category = "Comms" @@ -683,6 +685,7 @@ class CmdPage(COMMAND_DEFAULT_CLASS): key = "page" aliases = ['tell'] + options = ("last", "list") locks = "cmd:not pperm(page_banned)" help_category = "Comms" @@ -850,6 +853,7 @@ class CmdIRC2Chan(COMMAND_DEFAULT_CLASS): """ key = "@irc2chan" + options = ("delete", "remove", "disconnect", "list", "ssl") locks = "cmd:serversetting(IRC_ENABLED) and pperm(Developer)" help_category = "Comms" @@ -1016,6 +1020,7 @@ class CmdRSS2Chan(COMMAND_DEFAULT_CLASS): """ key = "@rss2chan" + options = ("disconnect", "remove", "list") locks = "cmd:serversetting(RSS_ENABLED) and pperm(Developer)" help_category = "Comms" From f94a0838461681657cec9f23bab6045a06ca841b Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Fri, 2 Mar 2018 07:38:37 -0500 Subject: [PATCH 148/189] Add class var options to CmdSetHelp --- evennia/commands/default/help.py | 1 + 1 file changed, 1 insertion(+) diff --git a/evennia/commands/default/help.py b/evennia/commands/default/help.py index ebce460748..a355ac2e3f 100644 --- a/evennia/commands/default/help.py +++ b/evennia/commands/default/help.py @@ -317,6 +317,7 @@ class CmdSetHelp(COMMAND_DEFAULT_CLASS): """ key = "@sethelp" + options = ("edit", "replace", "append", "extend", "delete") locks = "cmd:perm(Helper)" help_category = "Building" From 3ee3335a3ff138a8ec7f9a39118479ed1c0eb829 Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Fri, 2 Mar 2018 07:40:43 -0500 Subject: [PATCH 149/189] Add class var options to CmdExtendedDesc Inherits from default_cmds.CmdDesc, but unused in CmdExtendedDesc, so needs to be set empty. --- evennia/contrib/extended_room.py | 1 + 1 file changed, 1 insertion(+) diff --git a/evennia/contrib/extended_room.py b/evennia/contrib/extended_room.py index 755c2ac22e..b2f61757f9 100644 --- a/evennia/contrib/extended_room.py +++ b/evennia/contrib/extended_room.py @@ -367,6 +367,7 @@ class CmdExtendedDesc(default_cmds.CmdDesc): """ aliases = ["describe", "detail"] + options = () # Inherits from default_cmds.CmdDesc, but unused here def reset_times(self, obj): """By deleteting the caches we force a re-load.""" From 5357028960d861b00c913ec2dda3a5c39abd4819 Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Fri, 2 Mar 2018 07:44:50 -0500 Subject: [PATCH 150/189] Adds options var to CmdBatchCommands, CmdBatchCode --- evennia/commands/default/batchprocess.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/evennia/commands/default/batchprocess.py b/evennia/commands/default/batchprocess.py index f0b117a816..bf09360c71 100644 --- a/evennia/commands/default/batchprocess.py +++ b/evennia/commands/default/batchprocess.py @@ -237,6 +237,7 @@ class CmdBatchCommands(_COMMAND_DEFAULT_CLASS): """ key = "@batchcommands" aliases = ["@batchcommand", "@batchcmd"] + options = ("interactive",) locks = "cmd:perm(batchcommands) or perm(Developer)" help_category = "Building" @@ -347,6 +348,7 @@ class CmdBatchCode(_COMMAND_DEFAULT_CLASS): """ key = "@batchcode" aliases = ["@batchcodes"] + options = ("interactive", "debug") locks = "cmd:superuser()" help_category = "Building" From 4b8000d88f077aac33b7947bb02c089b2ff83ae3 Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Fri, 2 Mar 2018 07:58:40 -0500 Subject: [PATCH 151/189] Adds class var options and split to CmdTeleport + CmdSetObjAlias, CmdCopy, CmdCpAttr, CmdMvAttr, CmdCreate, CmdDesc, CmdDestroy +docstring edits, CmdDig, CmdTunnel, CmdFind, CmdScript, CmdTag, CmdSpawn --- evennia/commands/default/building.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 4bc107dcfb..f8d0246f39 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -124,6 +124,7 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS): key = "@alias" aliases = "@setobjalias" + options = ("category",) locks = "cmd:perm(setobjalias) or perm(Builder)" help_category = "Building" @@ -218,6 +219,7 @@ class CmdCopy(ObjManipCommand): """ key = "@copy" + options = ("reset",) locks = "cmd:perm(copy) or perm(Builder)" help_category = "Building" @@ -299,6 +301,7 @@ class CmdCpAttr(ObjManipCommand): If you don't supply a source object, yourself is used. """ key = "@cpattr" + options = ("move",) locks = "cmd:perm(cpattr) or perm(Builder)" help_category = "Building" @@ -440,6 +443,7 @@ class CmdMvAttr(ObjManipCommand): object. If you don't supply a source object, yourself is used. """ key = "@mvattr" + options = ("copy",) locks = "cmd:perm(mvattr) or perm(Builder)" help_category = "Building" @@ -488,6 +492,7 @@ class CmdCreate(ObjManipCommand): """ key = "@create" + options = ("drop",) locks = "cmd:perm(create) or perm(Builder)" help_category = "Building" @@ -573,6 +578,7 @@ class CmdDesc(COMMAND_DEFAULT_CLASS): """ key = "@desc" aliases = "@describe" + options = ("edit",) locks = "cmd:perm(desc) or perm(Builder)" help_category = "Building" @@ -631,11 +637,11 @@ class CmdDestroy(COMMAND_DEFAULT_CLASS): Usage: @destroy[/switches] [obj, obj2, obj3, [dbref-dbref], ...] - switches: + Switches: override - The @destroy command will usually avoid accidentally destroying account objects. This switch overrides this safety. force - destroy without confirmation. - examples: + Examples: @destroy house, roof, door, 44-78 @destroy 5-10, flower, 45 @destroy/force north @@ -648,6 +654,7 @@ class CmdDestroy(COMMAND_DEFAULT_CLASS): key = "@destroy" aliases = ["@delete", "@del"] + options = ("override", "force") locks = "cmd:perm(destroy) or perm(Builder)" help_category = "Building" @@ -771,6 +778,7 @@ class CmdDig(ObjManipCommand): would be 'north;no;n'. """ key = "@dig" + options = ("teleport",) locks = "cmd:perm(dig) or perm(Builder)" help_category = "Building" @@ -880,7 +888,7 @@ class CmdDig(ObjManipCommand): new_back_exit.dbref, alias_string) caller.msg("%s%s%s" % (room_string, exit_to_string, exit_back_string)) - if new_room and ('teleport' in self.switches or "tel" in self.switches): + if new_room and 'teleport' in self.switches: caller.move_to(new_room) @@ -913,6 +921,7 @@ class CmdTunnel(COMMAND_DEFAULT_CLASS): key = "@tunnel" aliases = ["@tun"] + options = ("oneway", "tel") locks = "cmd: perm(tunnel) or perm(Builder)" help_category = "Building" @@ -2256,6 +2265,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS): key = "@find" aliases = "@search, @locate" + options = ("room", "exit", "char", "exact", "loc") locks = "cmd:perm(find) or perm(Builder)" help_category = "Building" @@ -2292,7 +2302,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS): restrictions = "" if self.switches: - restrictions = ", %s" % (",".join(self.switches)) + restrictions = ", %s" % (", ".join(self.switches)) if is_dbref or is_account: @@ -2372,7 +2382,7 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS): teleport object to another location Usage: - @tel/switch [ =] + @tel/switch [ to||=] Examples: @tel Limbo @@ -2395,6 +2405,8 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS): is teleported to the target location. """ key = "@tel" aliases = "@teleport" + options = ("quiet", "intoexit", "tonone", "loc") + split = " to " locks = "cmd:perm(teleport) or perm(Builder)" help_category = "Building" @@ -2502,6 +2514,7 @@ class CmdScript(COMMAND_DEFAULT_CLASS): key = "@script" aliases = "@addscript" + options = ("start", "stop") locks = "cmd:perm(script) or perm(Builder)" help_category = "Building" @@ -2601,6 +2614,7 @@ class CmdTag(COMMAND_DEFAULT_CLASS): key = "@tag" aliases = ["@tags"] + options = ("search", "del") locks = "cmd:perm(tag) or perm(Builder)" help_category = "Building" arg_regex = r"(/\w+?(\s|$))|\s|$" @@ -2742,6 +2756,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): """ key = "@spawn" + options = ("noloc", ) locks = "cmd:perm(spawn) or perm(Builder)" help_category = "Building" From 4df5b28721b68a07c0a7edebe26190e4425fd176 Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Fri, 2 Mar 2018 08:02:31 -0500 Subject: [PATCH 152/189] Adds class var options, split to CmdGive + docstring edit, options and docstring edit to CmdNick. --- evennia/commands/default/general.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index 7bf861c91f..932c4fe898 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -88,8 +88,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS): Switches: inputline - replace on the inputline (default) object - replace on object-lookup - account - replace on account-lookup - + account - replace on account-lookup list - show all defined aliases (also "nicks" works) delete - remove nick by index in /list clearall - clear all nicks @@ -118,6 +117,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS): """ key = "nick" + options = ("inputline", "object", "account", "list", "delete", "clearall") aliases = ["nickname", "nicks"] locks = "cmd:all()" @@ -377,12 +377,13 @@ class CmdGive(COMMAND_DEFAULT_CLASS): give away something to someone Usage: - give = + give Gives an items from your inventory to another character, placing it in their inventory. """ key = "give" + split = " to " locks = "cmd:all()" arg_regex = r"\s|$" From 96ebe8d0f3081a77fa94e7cd54d487d012ee8d67 Mon Sep 17 00:00:00 2001 From: arumford Date: Wed, 7 Feb 2018 14:22:38 -0600 Subject: [PATCH 153/189] typo in docstring. --- evennia/comms/channelhandler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/comms/channelhandler.py b/evennia/comms/channelhandler.py index 02c9e19291..058b7d9ba4 100644 --- a/evennia/comms/channelhandler.py +++ b/evennia/comms/channelhandler.py @@ -159,7 +159,7 @@ class ChannelHandler(object): """ The ChannelHandler manages all active in-game channels and 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 this handler must be called to sync it with the database (this is done automatically if creating the channel with From 861c722d3004e4796aa9cc01c58e322f543c7a1e Mon Sep 17 00:00:00 2001 From: Nicholas Matlaga Date: Tue, 13 Feb 2018 12:49:55 -0500 Subject: [PATCH 154/189] Change if statement to better handle objects; move options dict before at_msg_receive call to allow channels to be known in hook --- evennia/comms/models.py | 4 +--- evennia/objects/objects.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/evennia/comms/models.py b/evennia/comms/models.py index c456a6e72c..1123e4e5b6 100644 --- a/evennia/comms/models.py +++ b/evennia/comms/models.py @@ -585,9 +585,7 @@ class SubscriptionHandler(object): for obj in self.all(): from django.core.exceptions import ObjectDoesNotExist try: - if hasattr(obj, 'account'): - if not obj.account: - continue + if hasattr(obj, 'account') and obj.account: obj = obj.account if not obj.is_connected: continue diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 9e4d8d588a..5572ef26e2 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -553,6 +553,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): obj.at_msg_send(text=text, to_obj=self, **kwargs) except Exception: logger.log_trace() + kwargs["options"] = options try: if not self.at_msg_receive(text=text, **kwargs): # if at_msg_receive returns false, we abort message to this object @@ -560,7 +561,6 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): except Exception: logger.log_trace() - kwargs["options"] = options if text and not (isinstance(text, basestring) or isinstance(text, tuple)): # sanitize text before sending across the wire From 5817c1b7907d8d5e16cbaa637ecfe56e8fb121ef Mon Sep 17 00:00:00 2001 From: Nicholas Matlaga Date: Tue, 13 Feb 2018 12:54:03 -0500 Subject: [PATCH 155/189] add in is_connected property to base objects --- evennia/objects/objects.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 5572ef26e2..037f3ff019 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -210,6 +210,14 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): def sessions(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 def has_account(self): """ From cbcb91f48730cb7fc3a28aca7fd0bf1512ce1763 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 21 Feb 2018 20:18:33 +0100 Subject: [PATCH 156/189] Catch the case of a prematurely deleted guest account. Resolves #1500. --- evennia/typeclasses/attributes.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index 84a21c0c27..3f8b4cd742 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -282,6 +282,8 @@ class AttributeHandler(object): "attribute__db_attrtype": self._attrtype, "attribute__db_key__iexact": key.lower(), "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) if conn: attr = conn[0].attribute From fcc103ecea243e22733c56eefadc104d68578f09 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 21 Feb 2018 20:34:29 +0100 Subject: [PATCH 157/189] Add warning if Favico.js library is not reachable --- evennia/web/webclient/templates/webclient/base.html | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/evennia/web/webclient/templates/webclient/base.html b/evennia/web/webclient/templates/webclient/base.html index 30a68c498f..863a90ba11 100644 --- a/evennia/web/webclient/templates/webclient/base.html +++ b/evennia/web/webclient/templates/webclient/base.html @@ -25,7 +25,7 @@ JQuery available. @@ -57,6 +57,12 @@ JQuery available. {% endblock %} + + From 9f7dcebc5b4080d5211582f4dbf3811cfaf151e1 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 1 Mar 2018 20:13:36 +0100 Subject: [PATCH 158/189] Fix bug in accessing attribute through manager --- evennia/typeclasses/managers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/evennia/typeclasses/managers.py b/evennia/typeclasses/managers.py index 67eb9e065b..dcb147cb70 100644 --- a/evennia/typeclasses/managers.py +++ b/evennia/typeclasses/managers.py @@ -8,6 +8,8 @@ import shlex from django.db.models import Q from evennia.utils import idmapper 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", ) _GA = object.__getattribute__ @@ -56,7 +58,7 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): dbmodel = self.model.__dbclass__.__name__.lower() query = [("attribute__db_attrtype", attrtype), ("attribute__db_model", dbmodel)] 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: query.append(("attribute__db_key", key)) if category: From 07f55c8cb6f1b0ea527dc8b3c0bf3352ad5c84c7 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 1 Mar 2018 20:23:18 +0100 Subject: [PATCH 159/189] Make desc/set abide by edit/control locks --- evennia/commands/default/building.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 4bc107dcfb..b43e6f93c3 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -588,6 +588,9 @@ class CmdDesc(COMMAND_DEFAULT_CLASS): if not obj: 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 # launch the editor EvEditor(self.caller, loadfunc=_desc_load, savefunc=_desc_save, @@ -617,7 +620,7 @@ class CmdDesc(COMMAND_DEFAULT_CLASS): if not obj: return desc = self.args - if obj.access(caller, "edit"): + if (obj.access(self.caller, 'control') or obj.access(self.caller, 'edit')): obj.db.desc = desc caller.msg("The description was set on %s." % obj.get_display_name(caller)) else: @@ -1637,6 +1640,10 @@ class CmdSetAttribute(ObjManipCommand): result = [] if "edit" in self.switches: # 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: caller.msg("The Line editor can only be applied " "to one attribute at a time.") @@ -1657,12 +1664,18 @@ class CmdSetAttribute(ObjManipCommand): return else: # 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: if not self.check_attr(obj, attr): continue result.append(self.rm_attr(obj, attr)) else: # 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: if not self.check_attr(obj, attr): continue From d5c1e5306020322f71dc6d4226eb511a12417230 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 1 Mar 2018 20:53:20 +0100 Subject: [PATCH 160/189] Don't allow those with 'edit' access to obj to change the 'control' lock. --- evennia/commands/default/building.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index b43e6f93c3..a964e880eb 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -1909,9 +1909,16 @@ class CmdLock(ObjManipCommand): obj = caller.search(objname) if not obj: 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.") return + lockdef = obj.locks.get(access_type) if lockdef: From 6479e445a5ac170c4ef0d703fd0e447cbb2a6fec Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 1 Mar 2018 21:56:40 +0100 Subject: [PATCH 161/189] Make get_attribute/tag manager methods return querysets --- evennia/typeclasses/managers.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/evennia/typeclasses/managers.py b/evennia/typeclasses/managers.py index dcb147cb70..0bede3b192 100644 --- a/evennia/typeclasses/managers.py +++ b/evennia/typeclasses/managers.py @@ -68,7 +68,9 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): elif value: # strvalue and value are mutually exclusive 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): """ @@ -191,7 +193,9 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): query.append(("tag__db_key", key)) if 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): """ From 48e35bfbe40756da9c6a71890b6b48f99d85e856 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 1 Mar 2018 22:21:22 +0100 Subject: [PATCH 162/189] Remove mutual exclusivity between value/strvalue when searching for Attributes with manager --- evennia/typeclasses/managers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/typeclasses/managers.py b/evennia/typeclasses/managers.py index 0bede3b192..25488f4987 100644 --- a/evennia/typeclasses/managers.py +++ b/evennia/typeclasses/managers.py @@ -65,8 +65,8 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): query.append(("attribute__db_category", category)) if strvalue: query.append(("attribute__db_strvalue", strvalue)) - elif value: - # strvalue and value are mutually exclusive + if value: + # no reason to make strvalue/value mutually exclusive at this level query.append(("attribute__db_value", value)) return Attribute.objects.filter( pk__in=self.model.db_attributes.through.objects.filter( From dbb5373523a160034de0ee1c5865621585d97598 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 3 Mar 2018 10:12:34 +0100 Subject: [PATCH 163/189] Allow nick command to list individual nicks --- evennia/commands/default/general.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index 7bf861c91f..dd0ef11773 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -145,8 +145,9 @@ class CmdNick(COMMAND_DEFAULT_CLASS): caller = self.caller account = self.caller.account or caller 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(category="inputline", return_obj=True) or []) + utils.make_iter(caller.nicks.get(category="object", return_obj=True) or []) + @@ -197,6 +198,28 @@ class CmdNick(COMMAND_DEFAULT_CLASS): nicktypestr, old_nickstring, old_replstring)) 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: caller.msg("Usage: nick[/switches] nickname = [realname]") return From 11965a1c34083e1100819bb0cbe72af0eb5767b7 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 3 Mar 2018 10:27:49 +0100 Subject: [PATCH 164/189] Store all nicks on caller (don't store 'account-nicks' on caller.account) --- evennia/commands/default/general.py | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index dd0ef11773..a5cc179ac7 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -143,7 +143,6 @@ class CmdNick(COMMAND_DEFAULT_CLASS): return re.sub(r"(\$[0-9]+|\*|\?|\[.+?\])", r"|Y\1|n", string) caller = self.caller - account = self.caller.account or caller switches = self.switches nicktypes = [switch for switch in switches if switch in ("object", "account", "inputline")] specified_nicktype = bool(nicktypes) @@ -151,7 +150,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS): nicklist = (utils.make_iter(caller.nicks.get(category="inputline", return_obj=True) or []) + utils.make_iter(caller.nicks.get(category="object", return_obj=True) or []) + - utils.make_iter(account.nicks.get(category="account", return_obj=True) or [])) + utils.make_iter(caller.nicks.get(category="account", return_obj=True) or [])) if 'list' in switches or self.cmdstring in ("nicks", "@nicks"): @@ -190,10 +189,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS): nicktype = oldnick.category nicktypestr = "%s-nick" % nicktype.capitalize() - if nicktype == "account": - account.nicks.remove(old_nickstring, category=nicktype) - else: - caller.nicks.remove(old_nickstring, category=nicktype) + caller.nicks.remove(old_nickstring, category=nicktype) caller.msg("%s removed: '|w%s|n' -> |w%s|n." % ( nicktypestr, old_nickstring, old_replstring)) return @@ -204,11 +200,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS): 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)) + 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): @@ -237,16 +229,11 @@ class CmdNick(COMMAND_DEFAULT_CLASS): errstring = "" string = "" for nicktype in nicktypes: - if nicktype == "account": - obj = account - else: - obj = caller - nicktypestr = "%s-nick" % nicktype.capitalize() old_nickstring = None old_replstring = None - oldnick = obj.nicks.get(key=nickstring, category=nicktype, return_obj=True) + oldnick = caller.nicks.get(key=nickstring, category=nicktype, return_obj=True) if oldnick: _, _, old_nickstring, old_replstring = oldnick.value if replstring: @@ -261,7 +248,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS): else: string += "\n%s '|w%s|n' mapped to '|w%s|n'." % (nicktypestr, nickstring, replstring) try: - obj.nicks.add(nickstring, replstring, category=nicktype) + caller.nicks.add(nickstring, replstring, category=nicktype) except NickTemplateInvalid: caller.msg("You must use the same $-markers both in the nick and in the replacement.") return From 11ddd26c7e0573facdf4567f01eccc87ea638906 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 3 Mar 2018 10:30:14 +0100 Subject: [PATCH 165/189] Add nick-command to account cmdset --- evennia/commands/default/cmdset_account.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/evennia/commands/default/cmdset_account.py b/evennia/commands/default/cmdset_account.py index 4e357a2ce0..d7b887c017 100644 --- a/evennia/commands/default/cmdset_account.py +++ b/evennia/commands/default/cmdset_account.py @@ -11,7 +11,7 @@ command method rather than caller.msg(). from evennia.commands.cmdset import CmdSet from evennia.commands.default import help, comms, admin, system -from evennia.commands.default import building, account +from evennia.commands.default import building, account, general class AccountCmdSet(CmdSet): @@ -39,6 +39,9 @@ class AccountCmdSet(CmdSet): self.add(account.CmdColorTest()) self.add(account.CmdQuell()) + # nicks + self.add(general.CmdNick()) + # testing self.add(building.CmdExamine()) From 3e5f2ef68a5c4b5b6036e64d8651a928fda83f72 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 3 Mar 2018 11:01:06 +0100 Subject: [PATCH 166/189] Clarify nick-deletion mechanism --- evennia/commands/default/general.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index a5cc179ac7..f6f624032d 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -173,26 +173,37 @@ class CmdNick(COMMAND_DEFAULT_CLASS): if 'delete' in switches or 'del' in switches: if not self.args or not self.lhs: - caller.msg("usage nick/delete #num ('nicks' for list)") + caller.msg("usage nick/delete or <#num> ('nicks' for list)") return # see if a number was given arg = self.args.lstrip("#") + oldnicks = [] if arg.isdigit(): # we are given a index in nicklist delindex = int(arg) if 0 < delindex <= len(nicklist): - oldnick = nicklist[delindex - 1] - _, _, old_nickstring, old_replstring = oldnick.value + oldnicks.append(nicklist[delindex - 1]) else: caller.msg("Not a valid nick index. See 'nicks' for a list.") return - nicktype = oldnick.category - nicktypestr = "%s-nick" % nicktype.capitalize() + else: + if not specified_nicktype: + nicktypes = ("object", "account", "inputline") + for nicktype in nicktypes: + oldnicks.append(caller.nicks.get(arg, category=nicktype, return_obj=True)) - caller.nicks.remove(old_nickstring, category=nicktype) - caller.msg("%s removed: '|w%s|n' -> |w%s|n." % ( - nicktypestr, old_nickstring, old_replstring)) - return + 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 From 4eb38e2b69753ba2817a218903c7412174f52fb3 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 3 Mar 2018 11:24:20 +0100 Subject: [PATCH 167/189] Fix unittests --- evennia/commands/default/tests.py | 4 ++-- evennia/comms/models.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 38829f0cf5..dba1d329cc 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -130,8 +130,8 @@ class TestGeneral(CommandTest): self.call(general.CmdNick(), "/account testalias = testaliasedstring2", "Accountnick 'testalias' mapped to 'testaliasedstring2'.") self.call(general.CmdNick(), "/object testalias = testaliasedstring3", "Objectnick 'testalias' mapped to 'testaliasedstring3'.") self.assertEqual(u"testaliasedstring1", self.char1.nicks.get("testalias")) - self.assertEqual(None, self.char1.nicks.get("testalias", category="account")) - self.assertEqual(u"testaliasedstring2", self.char1.account.nicks.get("testalias", category="account")) + self.assertEqual(u"testaliasedstring2", self.char1.nicks.get("testalias", category="account")) + self.assertEqual(None, self.char1.account.nicks.get("testalias", category="account")) self.assertEqual(u"testaliasedstring3", self.char1.nicks.get("testalias", category="object")) def test_get_and_drop(self): diff --git a/evennia/comms/models.py b/evennia/comms/models.py index 1123e4e5b6..c358cf352b 100644 --- a/evennia/comms/models.py +++ b/evennia/comms/models.py @@ -527,7 +527,6 @@ class SubscriptionHandler(object): for subscriber in make_iter(entity): if subscriber: clsname = subscriber.__dbclass__.__name__ - print("subscriber:", subscriber, clsname) # chooses the right type if clsname == "ObjectDB": self.obj.db_object_subscriptions.add(subscriber) From b704363cf75c487f4c35788d6acbe508e7823f29 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 3 Mar 2018 10:12:34 +0100 Subject: [PATCH 168/189] Allow nick command to list individual nicks --- evennia/commands/default/general.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index f6f624032d..7a50f41eda 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -223,6 +223,28 @@ class CmdNick(COMMAND_DEFAULT_CLASS): 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: caller.msg("Usage: nick[/switches] nickname = [realname]") return From 2bc87c0b0d8f92d4030c411a3e802d80520dc3c5 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 3 Mar 2018 18:30:48 +0100 Subject: [PATCH 169/189] Fix a teleport example missing = --- evennia/commands/default/building.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index a964e880eb..853ca6b88f 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2396,7 +2396,7 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS): Examples: @tel Limbo - @tel/quiet box Limbo + @tel/quiet box = Limbo @tel/tonone box Switches: @@ -2412,7 +2412,8 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS): loc - teleport object to the target's location instead of its contents 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" aliases = "@teleport" locks = "cmd:perm(teleport) or perm(Builder)" From 546927dd741ff51018c63494da86d707c2f6419b Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Mon, 5 Mar 2018 14:08:10 -0500 Subject: [PATCH 170/189] Addresses requested changes + shim code class variable name changes handled by shim --- evennia/commands/default/muxcommand.py | 88 +++++++++++++------------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/evennia/commands/default/muxcommand.py b/evennia/commands/default/muxcommand.py index 6d731ea14f..93606cdbe5 100644 --- a/evennia/commands/default/muxcommand.py +++ b/evennia/commands/default/muxcommand.py @@ -80,9 +80,10 @@ class MuxCommand(Command): start with the switch indicator /. Optional variables to aid in parsing, if set: - self.options = (tuple of valid /switches expected - by this command (without the /)) - self.split = Alternate string delimiter to separate left/right hand side. + self.switch_options - (tuple of valid /switches expected by this + command (without the /)) + self.rhs_split - Alternate string delimiter to separate + left/right hand sides. This parser breaks self.args into its constituents and stores them in the following variables: @@ -102,6 +103,18 @@ class MuxCommand(Command): """ raw = self.args args = raw.strip() + # Temporary code to use the old settings before renaming # # + if hasattr(self, "options"): # + self.switch_options = self.options # + if hasattr(self, "split") and not hasattr(self, "rhs_split"): # + self.rhs_split = self.split # # # + # Without explicitly setting these attributes, they assume default values: + if not hasattr(self, "switch_options"): + self.switch_options = None + if not hasattr(self, "rhs_split"): + self.rhs_split = "=" + if not hasattr(self, "account_caller"): + self.account_caller = False # split out switches switches = [] @@ -114,17 +127,19 @@ class MuxCommand(Command): else: args = "" switches = switches[0].split('/') - # Parse mux options, comparing them against user-provided switches, expanding abbreviations. - if hasattr(self, "options") and self.options and switches: - # If specific options are known, test them against given switches. + # 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 = [each for each in self.options if each.lower().startswith(element.lower())] - if len(option_check) > 1: + option_check = [each for each in self.switch_options + if each.lower() == element.lower() or + each.lower().startswith(element.lower())] + match_count = len(option_check) + if match_count > 1: extra_switches += option_check # Either the option provided is ambiguous, - elif len(option_check) == 1: + elif match_count == 1: valid_switches += option_check # or it is a valid option abbreviation, - elif len(option_check) == 0: + elif match_count == 0: unused_switches += [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?' % @@ -138,21 +153,22 @@ class MuxCommand(Command): # check for arg1, arg2, ... = argA, argB, ... constructs lhs, rhs = args.strip(), None - lhslist, rhslist = [arg.strip() for arg in args.split(',')], [] + best_split = self.rhs_split if lhs: - if '=' in lhs: # Default delimiter has priority - # Parse to separate left into left/right sides using default delimiter - lhs, rhs = lhs.split('=', 1) - elif hasattr(self, "split") and self.split and self.split in lhs: - # Parse to separate left into left/right sides using a custom delimiter, if provided. - lhs, rhs = lhs.split(self.split, 1) # At most, split once, into left and right parts. - # 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 "" - + if hasattr(self.rhs_split, '__iter__'): + for this_split in self.rhs_split: + if this_split in lhs: # First delimiter to allow a successful + best_split = this_split # split is the best split. + break + # Parse to separate left into left/right sides using best_split delimiter string + if best_split in lhs: + lhs, rhs = lhs.split(best_split, 1) + # Trim user-injected whitespace + rhs = rhs.strip() if rhs is not None else None + lhs = lhs.strip() + # Further split left/right sides by comma delimiter + lhslist = [arg.strip() for arg in lhs.split(',')] if lhs is not None else "" + rhslist = [arg.strip() for arg in rhs.split(',')] if rhs is not None else "" # save to object properties: self.raw = raw self.switches = switches @@ -167,7 +183,7 @@ class MuxCommand(Command): # sure that self.caller is always the account if possible. We also create # a special property "character" for the puppeted object, if any. This # is convenient for commands defined on the Account only. - if hasattr(self, "account_caller") and self.account_caller: + if self.account_caller: if utils.inherits_from(self.caller, "evennia.objects.objects.DefaultObject"): # caller is an Object/Character self.character = self.caller @@ -203,10 +219,8 @@ class MuxCommand(Command): string += "\nraw argument (self.raw): |w%s|n \n" % self.raw string += "cmd args (self.args): |w%s|n\n" % self.args string += "cmd switches (self.switches): |w%s|n\n" % self.switches - if hasattr(self, "options"): # Optional - string += "cmd options (self.options): |w%s|n\n" % self.options - if hasattr(self, "split"): # Optional - string += "cmd parse left/right using (self.split): |w%s|n\n" % self.split + string += "cmd options (self.switch_options): |w%s|n\n" % self.switch_options + string += "cmd parse left/right using (self.rhs_split): |w%s|n\n" % self.rhs_split string += "space-separated arg list (self.arglist): |w%s|n\n" % self.arglist string += "lhs, left-hand side of '=' (self.lhs): |w%s|n\n" % self.lhs string += "lhs, comma separated (self.lhslist): |w%s|n\n" % self.lhslist @@ -231,18 +245,4 @@ class MuxAccountCommand(MuxCommand): character is actually attached to this Account and Session. """ - def parse(self): - """ - We run the parent parser as usual, then fix the result - """ - super(MuxAccountCommand, self).parse() - - if utils.inherits_from(self.caller, "evennia.objects.objects.DefaultObject"): - # caller is an Object/Character - self.character = self.caller - self.caller = self.caller.account - elif utils.inherits_from(self.caller, "evennia.accounts.accounts.DefaultAccount"): - # caller was already an Account - self.character = self.caller.get_puppet(self.session) - else: - self.character = None + account_caller = True # Using MuxAccountCommand explicitly defaults the caller to an account From 126960f146bad8cc14c02c112da9c24967f3fc5e Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Mon, 5 Mar 2018 14:30:37 -0500 Subject: [PATCH 171/189] class var options renamed switch_options --- evennia/commands/default/account.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index 81539bba17..40805f7465 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -467,7 +467,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS): """ key = "@option" aliases = "@options" - options = ("save", "clear") + switch_options = ("save", "clear") locks = "cmd:all()" # this is used by the parent @@ -651,7 +651,7 @@ class CmdQuit(COMMAND_DEFAULT_CLASS): game. Use the /all switch to disconnect from all sessions. """ key = "@quit" - options = ("all",) + switch_options = ("all",) locks = "cmd:all()" # this is used by the parent From ac41b427c32fdfe2d425bf07494fcccc04fbd02c Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Mon, 5 Mar 2018 14:32:52 -0500 Subject: [PATCH 172/189] class var options renamed switch_options --- evennia/commands/default/admin.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/evennia/commands/default/admin.py b/evennia/commands/default/admin.py index dba0c49bdd..4e517b77f9 100644 --- a/evennia/commands/default/admin.py +++ b/evennia/commands/default/admin.py @@ -36,7 +36,7 @@ class CmdBoot(COMMAND_DEFAULT_CLASS): """ key = "@boot" - options = ("quiet", "sid") + switch_options = ("quiet", "sid") locks = "cmd:perm(boot) or perm(Admin)" help_category = "Admin" @@ -266,7 +266,7 @@ class CmdDelAccount(COMMAND_DEFAULT_CLASS): """ key = "@delaccount" - options = ("delobj",) + switch_options = ("delobj",) locks = "cmd:perm(delaccount) or perm(Developer)" help_category = "Admin" @@ -343,7 +343,7 @@ class CmdEmit(COMMAND_DEFAULT_CLASS): """ key = "@emit" aliases = ["@pemit", "@remit"] - options = ("room", "accounts", "contents") + switch_options = ("room", "accounts", "contents") locks = "cmd:perm(emit) or perm(Builder)" help_category = "Admin" @@ -453,7 +453,7 @@ class CmdPerm(COMMAND_DEFAULT_CLASS): """ key = "@perm" aliases = "@setperm" - options = ("del", "account") + switch_options = ("del", "account") locks = "cmd:perm(perm) or perm(Developer)" help_category = "Admin" From dfcd6e5742d777b2607cbca4b9a13611e243934e Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Mon, 5 Mar 2018 14:34:10 -0500 Subject: [PATCH 173/189] class var options renamed switch_options --- evennia/commands/default/batchprocess.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/commands/default/batchprocess.py b/evennia/commands/default/batchprocess.py index bf09360c71..5970d35887 100644 --- a/evennia/commands/default/batchprocess.py +++ b/evennia/commands/default/batchprocess.py @@ -237,7 +237,7 @@ class CmdBatchCommands(_COMMAND_DEFAULT_CLASS): """ key = "@batchcommands" aliases = ["@batchcommand", "@batchcmd"] - options = ("interactive",) + switch_options = ("interactive",) locks = "cmd:perm(batchcommands) or perm(Developer)" help_category = "Building" @@ -348,7 +348,7 @@ class CmdBatchCode(_COMMAND_DEFAULT_CLASS): """ key = "@batchcode" aliases = ["@batchcodes"] - options = ("interactive", "debug") + switch_options = ("interactive", "debug") locks = "cmd:superuser()" help_category = "Building" From 6c2d9ff04dccc94fa2c6c9a36b0829e505a85dd2 Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Mon, 5 Mar 2018 14:42:13 -0500 Subject: [PATCH 174/189] class vars options, split renamed switch_options, rhs_split --- evennia/commands/default/building.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index f3e58790f5..e2f61d7054 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -124,7 +124,7 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS): key = "@alias" aliases = "@setobjalias" - options = ("category",) + switch_options = ("category",) locks = "cmd:perm(setobjalias) or perm(Builder)" help_category = "Building" @@ -219,7 +219,7 @@ class CmdCopy(ObjManipCommand): """ key = "@copy" - options = ("reset",) + switch_options = ("reset",) locks = "cmd:perm(copy) or perm(Builder)" help_category = "Building" @@ -301,7 +301,7 @@ class CmdCpAttr(ObjManipCommand): If you don't supply a source object, yourself is used. """ key = "@cpattr" - options = ("move",) + switch_options = ("move",) locks = "cmd:perm(cpattr) or perm(Builder)" help_category = "Building" @@ -443,7 +443,7 @@ class CmdMvAttr(ObjManipCommand): object. If you don't supply a source object, yourself is used. """ key = "@mvattr" - options = ("copy",) + switch_options = ("copy",) locks = "cmd:perm(mvattr) or perm(Builder)" help_category = "Building" @@ -492,7 +492,7 @@ class CmdCreate(ObjManipCommand): """ key = "@create" - options = ("drop",) + switch_options = ("drop",) locks = "cmd:perm(create) or perm(Builder)" help_category = "Building" @@ -578,7 +578,7 @@ class CmdDesc(COMMAND_DEFAULT_CLASS): """ key = "@desc" aliases = "@describe" - options = ("edit",) + switch_options = ("edit",) locks = "cmd:perm(desc) or perm(Builder)" help_category = "Building" @@ -657,7 +657,7 @@ class CmdDestroy(COMMAND_DEFAULT_CLASS): key = "@destroy" aliases = ["@delete", "@del"] - options = ("override", "force") + switch_options = ("override", "force") locks = "cmd:perm(destroy) or perm(Builder)" help_category = "Building" @@ -781,7 +781,7 @@ class CmdDig(ObjManipCommand): would be 'north;no;n'. """ key = "@dig" - options = ("teleport",) + switch_options = ("teleport",) locks = "cmd:perm(dig) or perm(Builder)" help_category = "Building" @@ -924,7 +924,7 @@ class CmdTunnel(COMMAND_DEFAULT_CLASS): key = "@tunnel" aliases = ["@tun"] - options = ("oneway", "tel") + switch_options = ("oneway", "tel") locks = "cmd: perm(tunnel) or perm(Builder)" help_category = "Building" @@ -2285,7 +2285,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS): key = "@find" aliases = "@search, @locate" - options = ("room", "exit", "char", "exact", "loc") + switch_options = ("room", "exit", "char", "exact", "loc") locks = "cmd:perm(find) or perm(Builder)" help_category = "Building" @@ -2426,8 +2426,8 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS): """ key = "@tel" aliases = "@teleport" - options = ("quiet", "intoexit", "tonone", "loc") - split = " to " + switch_options = ("quiet", "intoexit", "tonone", "loc") + rhs_split = " to " locks = "cmd:perm(teleport) or perm(Builder)" help_category = "Building" @@ -2535,7 +2535,7 @@ class CmdScript(COMMAND_DEFAULT_CLASS): key = "@script" aliases = "@addscript" - options = ("start", "stop") + switch_options = ("start", "stop") locks = "cmd:perm(script) or perm(Builder)" help_category = "Building" @@ -2777,7 +2777,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): """ key = "@spawn" - options = ("noloc", ) + switch_options = ("noloc", ) locks = "cmd:perm(spawn) or perm(Builder)" help_category = "Building" From 8f2cd86c2ebb611934b50f2b47ad513f7b9efb61 Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Mon, 5 Mar 2018 14:45:07 -0500 Subject: [PATCH 175/189] class var options renamed switch_options --- evennia/commands/default/comms.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index b654bd0297..d9fe0b0d20 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -385,7 +385,7 @@ class CmdCBoot(COMMAND_DEFAULT_CLASS): """ key = "@cboot" - options = ("quiet",) + switch_options = ("quiet",) locks = "cmd: not pperm(channel_banned)" help_category = "Comms" @@ -454,7 +454,7 @@ class CmdCemit(COMMAND_DEFAULT_CLASS): key = "@cemit" aliases = ["@cmsg"] - options = ("sendername", "quiet") + switch_options = ("sendername", "quiet") locks = "cmd: not pperm(channel_banned) and pperm(Player)" help_category = "Comms" @@ -685,7 +685,7 @@ class CmdPage(COMMAND_DEFAULT_CLASS): key = "page" aliases = ['tell'] - options = ("last", "list") + switch_options = ("last", "list") locks = "cmd:not pperm(page_banned)" help_category = "Comms" @@ -853,7 +853,7 @@ class CmdIRC2Chan(COMMAND_DEFAULT_CLASS): """ key = "@irc2chan" - options = ("delete", "remove", "disconnect", "list", "ssl") + switch_options = ("delete", "remove", "disconnect", "list", "ssl") locks = "cmd:serversetting(IRC_ENABLED) and pperm(Developer)" help_category = "Comms" @@ -1020,7 +1020,7 @@ class CmdRSS2Chan(COMMAND_DEFAULT_CLASS): """ key = "@rss2chan" - options = ("disconnect", "remove", "list") + switch_options = ("disconnect", "remove", "list") locks = "cmd:serversetting(RSS_ENABLED) and pperm(Developer)" help_category = "Comms" From 3083d1c08260397067adf7e8aff2043467959b1d Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Mon, 5 Mar 2018 14:52:29 -0500 Subject: [PATCH 176/189] class vars options, split renamed switch_options, rhs_split --- evennia/commands/default/general.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index 254081ba18..56e7825d65 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -117,7 +117,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS): """ key = "nick" - options = ("inputline", "object", "account", "list", "delete", "clearall") + switch_options = ("inputline", "object", "account", "list", "delete", "clearall") aliases = ["nickname", "nicks"] locks = "cmd:all()" @@ -448,7 +448,7 @@ class CmdGive(COMMAND_DEFAULT_CLASS): placing it in their inventory. """ key = "give" - split = " to " + rhs_split = " to " locks = "cmd:all()" arg_regex = r"\s|$" From cf8d5265bb7509682d9af79038b4c521a03f114d Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Mon, 5 Mar 2018 15:17:19 -0500 Subject: [PATCH 177/189] class var options renamed switch_options --- evennia/commands/default/help.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/commands/default/help.py b/evennia/commands/default/help.py index a355ac2e3f..d2011629c1 100644 --- a/evennia/commands/default/help.py +++ b/evennia/commands/default/help.py @@ -317,7 +317,7 @@ class CmdSetHelp(COMMAND_DEFAULT_CLASS): """ key = "@sethelp" - options = ("edit", "replace", "append", "extend", "delete") + switch_options = ("edit", "replace", "append", "extend", "delete") locks = "cmd:perm(Helper)" help_category = "Building" From 8a19f41dd0d2bd2b15913c49a01f03829fe16d5b Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Mon, 5 Mar 2018 15:46:17 -0500 Subject: [PATCH 178/189] class var options renamed switch_options --- evennia/commands/default/system.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/evennia/commands/default/system.py b/evennia/commands/default/system.py index e4d6242a56..c454de9aff 100644 --- a/evennia/commands/default/system.py +++ b/evennia/commands/default/system.py @@ -245,7 +245,7 @@ class CmdPy(COMMAND_DEFAULT_CLASS): """ key = "@py" aliases = ["!"] - options = ("time", "edit") + switch_options = ("time", "edit") locks = "cmd:perm(py) or perm(Developer)" help_category = "System" @@ -329,7 +329,7 @@ class CmdScripts(COMMAND_DEFAULT_CLASS): """ key = "@scripts" aliases = ["@globalscript", "@listscripts"] - options = ("start", "stop", "kill", "validate") + switch_options = ("start", "stop", "kill", "validate") locks = "cmd:perm(listscripts) or perm(Admin)" help_category = "System" @@ -523,7 +523,7 @@ class CmdService(COMMAND_DEFAULT_CLASS): key = "@service" aliases = ["@services"] - options = ("list", "start", "stop", "delete") + switch_options = ("list", "start", "stop", "delete") locks = "cmd:perm(service) or perm(Developer)" help_category = "System" @@ -706,7 +706,7 @@ class CmdServerLoad(COMMAND_DEFAULT_CLASS): """ key = "@server" aliases = ["@serverload", "@serverprocess"] - options = ("mem", "flushmem") + switch_options = ("mem", "flushmem") locks = "cmd:perm(list) or perm(Developer)" help_category = "System" From 564b78ae23daa7ac6df971e2ea689d08b3e23c28 Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Mon, 5 Mar 2018 15:52:47 -0500 Subject: [PATCH 179/189] class var options renamed switch_options --- evennia/contrib/extended_room.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/contrib/extended_room.py b/evennia/contrib/extended_room.py index b2f61757f9..6cacca980c 100644 --- a/evennia/contrib/extended_room.py +++ b/evennia/contrib/extended_room.py @@ -367,7 +367,7 @@ class CmdExtendedDesc(default_cmds.CmdDesc): """ aliases = ["describe", "detail"] - options = () # Inherits from default_cmds.CmdDesc, but unused here + switch_options = () # Inherits from default_cmds.CmdDesc, but unused here def reset_times(self, obj): """By deleteting the caches we force a re-load.""" From e78de5f7f0e535bf1d5188d9b68330405a2b1e8c Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Mon, 5 Mar 2018 17:39:44 -0500 Subject: [PATCH 180/189] Remove code shim after class var renaming Updated rhs_split docstring in MuxCommand --- evennia/commands/default/muxcommand.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/evennia/commands/default/muxcommand.py b/evennia/commands/default/muxcommand.py index 93606cdbe5..7339780fe7 100644 --- a/evennia/commands/default/muxcommand.py +++ b/evennia/commands/default/muxcommand.py @@ -82,8 +82,9 @@ class MuxCommand(Command): 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 to separate - left/right hand sides. + self.rhs_split - Alternate string delimiter or tuple of strings + to separate left/right hand sides. tuple form + gives priority split to first string delimeter. This parser breaks self.args into its constituents and stores them in the following variables: @@ -103,11 +104,6 @@ class MuxCommand(Command): """ raw = self.args args = raw.strip() - # Temporary code to use the old settings before renaming # # - if hasattr(self, "options"): # - self.switch_options = self.options # - if hasattr(self, "split") and not hasattr(self, "rhs_split"): # - self.rhs_split = self.split # # # # Without explicitly setting these attributes, they assume default values: if not hasattr(self, "switch_options"): self.switch_options = None From 4bcdeb48251777133f0233f22d7ce76ddf863100 Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Mon, 5 Mar 2018 20:11:37 -0500 Subject: [PATCH 181/189] MuxCommand multi-delimiter for splits parse tweaks --- evennia/commands/default/muxcommand.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/evennia/commands/default/muxcommand.py b/evennia/commands/default/muxcommand.py index 7339780fe7..77fd889ed9 100644 --- a/evennia/commands/default/muxcommand.py +++ b/evennia/commands/default/muxcommand.py @@ -84,7 +84,7 @@ class MuxCommand(Command): 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 delimeter. + gives priority split to first string delimiter. This parser breaks self.args into its constituents and stores them in the following variables: @@ -149,13 +149,15 @@ class MuxCommand(Command): # check for arg1, arg2, ... = argA, argB, ... constructs lhs, rhs = args.strip(), None - best_split = self.rhs_split if lhs: - if hasattr(self.rhs_split, '__iter__'): + if hasattr(self.rhs_split, '__iter__'): # If delimiter is iterable, try each + best_split = self.rhs_split[0] for this_split in self.rhs_split: - if this_split in lhs: # First delimiter to allow a successful - best_split = this_split # split is the best split. + if this_split in lhs: # delimiter to allow first successful + best_split = this_split # split to be the best split. break + else: + best_split = self.rhs_split # 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) From 4ad5f01193be3a596bda0a9d766fb1d1154f4907 Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Mon, 5 Mar 2018 20:14:20 -0500 Subject: [PATCH 182/189] Add multi-delimiter split to CmdTeleport --- evennia/commands/default/building.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index e2f61d7054..702f2e241a 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2427,7 +2427,7 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS): key = "@tel" aliases = "@teleport" switch_options = ("quiet", "intoexit", "tonone", "loc") - rhs_split = " to " + rhs_split = ("=", " to ") # Prefer = delimiter, but allow " to " usage. locks = "cmd:perm(teleport) or perm(Builder)" help_category = "Building" From 64f57da1e4bc0df471efcf239b6ec065aa222cf9 Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Mon, 5 Mar 2018 20:16:52 -0500 Subject: [PATCH 183/189] Add multi-delimiter split to CmdGive --- evennia/commands/default/general.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index 56e7825d65..eafb7670b0 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -448,7 +448,7 @@ class CmdGive(COMMAND_DEFAULT_CLASS): placing it in their inventory. """ key = "give" - rhs_split = " to " + rhs_split = ("=", " to ") # Prefer = delimiter, but allow " to " usage. locks = "cmd:all()" arg_regex = r"\s|$" From fe3bbf5c657e55e91f3f8a82c4fd3b607722184a Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Mon, 5 Mar 2018 20:36:17 -0500 Subject: [PATCH 184/189] Add test_give for CmdGive parse multiple splits --- evennia/commands/default/tests.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index e79277de9b..fd5d4982ed 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -140,6 +140,13 @@ class TestGeneral(CommandTest): self.call(general.CmdGet(), "Obj", "You pick up Obj.") self.call(general.CmdDrop(), "Obj", "You drop Obj.") + def test_give(self): + self.call(general.CmdGive(), "Obj to Char2", "You aren't carrying Obj.") + self.call(general.CmdGive(), "Obj = Char2", "You aren't carrying Obj.") + self.call(general.CmdGet(), "Obj", "You pick up Obj.") + self.call(general.CmdGive(), "Obj to Char2", "You give") + self.call(general.CmdGive(), "Obj = Char", "You give", caller=self.char2) + def test_say(self): self.call(general.CmdSay(), "Testing", "You say, \"Testing\"") From 4af7164d6238754e4d9bc081d2893662c24d2e69 Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Mon, 5 Mar 2018 23:33:34 -0500 Subject: [PATCH 185/189] Bypass no valid index iterable edge case and slight refactor + comments --- evennia/commands/default/muxcommand.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/evennia/commands/default/muxcommand.py b/evennia/commands/default/muxcommand.py index 77fd889ed9..df49d9a215 100644 --- a/evennia/commands/default/muxcommand.py +++ b/evennia/commands/default/muxcommand.py @@ -113,7 +113,7 @@ class MuxCommand(Command): self.account_caller = False # split out switches - switches = [] + switches, delimiters = [], self.rhs_split if args and len(args) > 1 and raw[0] == "/": # we have a switch, or a set of switches. These end with a space. switches = args[1:].split(None, 1) @@ -150,14 +150,14 @@ class MuxCommand(Command): # check for arg1, arg2, ... = argA, argB, ... constructs lhs, rhs = args.strip(), None if lhs: - if hasattr(self.rhs_split, '__iter__'): # If delimiter is iterable, try each - best_split = self.rhs_split[0] - for this_split in self.rhs_split: - if this_split in lhs: # delimiter to allow first successful - best_split = this_split # split to be the best split. + if delimiters and hasattr(delimiters, '__iter__'): # If delimiter is iterable, + best_split = delimiters[0] # (default to first delimiter) + for this_split in delimiters: # try each delimiter + if this_split in lhs: # to find first successful split + best_split = this_split # to be the best split. break else: - best_split = self.rhs_split + 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) From ef64d8c4136af8cae920e1a56af2198d5fccb9fb Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Sat, 10 Mar 2018 22:41:16 -0500 Subject: [PATCH 186/189] Suggested refactors and change to test exact match --- evennia/commands/default/muxcommand.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/evennia/commands/default/muxcommand.py b/evennia/commands/default/muxcommand.py index df49d9a215..349679f5bd 100644 --- a/evennia/commands/default/muxcommand.py +++ b/evennia/commands/default/muxcommand.py @@ -114,6 +114,8 @@ class MuxCommand(Command): # split out switches switches, delimiters = [], self.rhs_split + if self.switch_options: + self.switch_options = [opt.lower() for opt in self.switch_options] if args and len(args) > 1 and raw[0] == "/": # we have a switch, or a set of switches. These end with a space. switches = args[1:].split(None, 1) @@ -127,16 +129,16 @@ class MuxCommand(Command): if switches and self.switch_options: valid_switches, unused_switches, extra_switches = [], [], [] for element in switches: - option_check = [each for each in self.switch_options - if each.lower() == element.lower() or - each.lower().startswith(element.lower())] + 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 += option_check # Either the option provided is ambiguous, + extra_switches.extend(option_check) # Either the option provided is ambiguous, elif match_count == 1: - valid_switches += option_check # or it is a valid option abbreviation, + valid_switches.extend(option_check) # or it is a valid option abbreviation, elif match_count == 0: - unused_switches += [element] # or an extraneous option to be ignored. + 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))) From 0baba2a0bd8559521f7244f745089f21336feb6e Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Sat, 10 Mar 2018 22:42:39 -0500 Subject: [PATCH 187/189] Add test_mux_command for exact match --- evennia/commands/default/tests.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index fd5d4982ed..bf01ac3039 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -21,6 +21,7 @@ from mock import Mock, mock from evennia.commands.default.cmdset_character import CharacterCmdSet from evennia.utils.test_resources import EvenniaTest from evennia.commands.default import help, general, system, admin, account, building, batchprocess, comms +from evennia.commands.default.muxcommand import MuxCommand from evennia.commands.command import Command, InterruptCommand from evennia.utils import ansi, utils from evennia.server.sessionhandler import SESSIONS @@ -147,6 +148,26 @@ class TestGeneral(CommandTest): self.call(general.CmdGive(), "Obj to Char2", "You give") self.call(general.CmdGive(), "Obj = Char", "You give", caller=self.char2) + def test_mux_command(self): + + class CmdTest(MuxCommand): + key = 'test' + switch_options = ('test', 'testswitch', 'testswitch2') + + def func(self): + self.msg("Switches matched: {}".format(self.switches)) + + self.call(CmdTest(), "/test/testswitch/testswitch2", "Switches matched: ['test', 'testswitch', 'testswitch2']") + self.call(CmdTest(), "/test", "Switches matched: ['test']") + self.call(CmdTest(), "/test/testswitch", "Switches matched: ['test', 'testswitch']") + self.call(CmdTest(), "/testswitch/testswitch2", "Switches matched: ['testswitch', 'testswitch2']") + self.call(CmdTest(), "/testswitch", "Switches matched: ['testswitch']") + self.call(CmdTest(), "/testswitch2", "Switches matched: ['testswitch2']") + self.call(CmdTest(), "/t", "test: Ambiguous switch supplied: " + "Did you mean /test or /testswitch or /testswitch2?|Switches matched: []") + self.call(CmdTest(), "/tests", "test: Ambiguous switch supplied: " + "Did you mean /testswitch or /testswitch2?|Switches matched: []") + def test_say(self): self.call(general.CmdSay(), "Testing", "You say, \"Testing\"") From 38eb6814f6d146d94156024ef9d664771f787ea1 Mon Sep 17 00:00:00 2001 From: maiki Date: Sat, 10 Mar 2018 20:42:17 -0800 Subject: [PATCH 188/189] Remove extra words --- evennia/typeclasses/attributes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index 3f8b4cd742..a97a81b1be 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -245,7 +245,7 @@ class AttributeHandler(object): found from cache or database. Notes: When given a category only, a search for all objects - of that cateogory is done and a the category *name* is is + of that cateogory is done and the category *name* is stored. This tells the system on subsequent calls that the list of cached attributes of this category is up-to-date and that the cache can be queried for category matches From 88d13bc6e64a996abf58b55fc9a7c1eb182a2659 Mon Sep 17 00:00:00 2001 From: maiki Date: Sat, 10 Mar 2018 20:52:06 -0800 Subject: [PATCH 189/189] Minor update --- evennia/contrib/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/evennia/contrib/README.md b/evennia/contrib/README.md index ed3c048c32..ace81db8e5 100644 --- a/evennia/contrib/README.md +++ b/evennia/contrib/README.md @@ -16,7 +16,7 @@ things you want from here into your game folder and change them there. ## Contrib modules * Barter system (Griatch 2012) - A safe and effective barter-system - for any game. Allows safe trading of any godds (including coin) + for any game. Allows safe trading of any goods (including coin). * CharGen (Griatch 2011) - A simple Character creator for OOC mode. Meant as a starting point for a more fleshed-out system. * Clothing (BattleJenkins 2017) - A layered clothing system with @@ -33,7 +33,7 @@ things you want from here into your game folder and change them there. on a character and access it in an emote with a custom marker. * Mail (grungies1138 2016) - An in-game mail system for communication. * Menu login (Griatch 2011) - A login system using menus asking - for name/password rather than giving them as one command + for name/password rather than giving them as one command. * Map Builder (CloudKeeper 2016) - Build a game area based on a 2D "graphical" unicode map. Supports assymmetric exits. * Menu Login (Vincent-lg 2016) - Alternate login system using EvMenu. @@ -59,7 +59,7 @@ things you want from here into your game folder and change them there. ## Contrib packages * EGI_Client (gtaylor 2016) - Client for reporting game status - to the Evennia game index (games.evennia.com) + to the Evennia game index (games.evennia.com). * In-game Python (Vincent Le Goff 2017) - Allow trusted builders to script objects and events using Python from in-game. * Tutorial examples (Griatch 2011, 2015) - A folder of basic