From 280ffacc2d4b2eaa9834184fbe30024f76069d98 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 10 Aug 2017 23:36:56 +0200 Subject: [PATCH 001/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] __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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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/466] 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 7ddb5162ab0d1cd7411b98123b183d4e37bc3781 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Thu, 9 Nov 2017 22:36:11 -0800 Subject: [PATCH 054/466] Added tb_magic.py - only basic input parsing --- evennia/contrib/turnbattle/tb_magic.py | 961 +++++++++++++++++++++++++ 1 file changed, 961 insertions(+) create mode 100644 evennia/contrib/turnbattle/tb_magic.py diff --git a/evennia/contrib/turnbattle/tb_magic.py b/evennia/contrib/turnbattle/tb_magic.py new file mode 100644 index 0000000000..cc639737c0 --- /dev/null +++ b/evennia/contrib/turnbattle/tb_magic.py @@ -0,0 +1,961 @@ +""" +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 TBMagicCharacter object into +your game's character.py module: + + from evennia.contrib.turnbattle.tb_magic import TBMagicCharacter + +And change your game's character typeclass to inherit from TBMagicCharacter +instead of the default: + + class Character(TBMagicCharacter): + +Next, import this module into your default_cmdsets.py module: + + from evennia.contrib.turnbattle import tb_magic + +And add the battle command set to your default command set: + + # + # any commands you add below will overload the default ones. + # + self.add(tb_magic.BattleCmdSet()) + +This module is meant to be heavily expanded on, so you may want to copy it +to your game's 'world' folder and modify it there rather than importing it +in your game and using it as-is. +""" + +from random import randint +from evennia import DefaultCharacter, Command, default_cmds, DefaultScript +from evennia.commands.default.muxcommand import MuxCommand +from evennia.commands.default.help import CmdHelp + +""" +---------------------------------------------------------------------------- +OPTIONS +---------------------------------------------------------------------------- +""" + +TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds +ACTIONS_PER_TURN = 1 # Number of actions allowed per turn + +""" +---------------------------------------------------------------------------- +COMBAT FUNCTIONS START HERE +---------------------------------------------------------------------------- +""" + +def roll_init(character): + """ + Rolls a number between 1-1000 to determine initiative. + + Args: + character (obj): The character to determine initiative for + + Returns: + initiative (int): The character's place in initiative - higher + numbers go first. + + Notes: + By default, does not reference the character and simply returns + a random integer from 1 to 1000. + + Since the character is passed to this function, you can easily reference + a character's stats to determine an initiative roll - for example, if your + character has a 'dexterity' attribute, you can use it to give that character + an advantage in turn order, like so: + + return (randint(1,20)) + character.db.dexterity + + This way, characters with a higher dexterity will go first more often. + """ + return randint(1, 1000) + + +def get_attack(attacker, defender): + """ + Returns a value for an attack roll. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Returns: + attack_value (int): Attack roll value, compared against a defense value + to determine whether an attack hits or misses. + + Notes: + By default, returns a random integer from 1 to 100 without using any + properties from either the attacker or defender. + + This can easily be expanded to return a value based on characters stats, + equipment, and abilities. This is why the attacker and defender are passed + to this function, even though nothing from either one are used in this example. + """ + # For this example, just return a random integer up to 100. + attack_value = randint(1, 100) + return attack_value + + +def get_defense(attacker, defender): + """ + Returns a value for defense, which an attack roll must equal or exceed in order + for an attack to hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Returns: + defense_value (int): Defense value, compared against an attack roll + to determine whether an attack hits or misses. + + Notes: + By default, returns 50, not taking any properties of the defender or + attacker into account. + + As above, this can be expanded upon based on character stats and equipment. + """ + # For this example, just return 50, for about a 50/50 chance of hit. + defense_value = 50 + return defense_value + + +def get_damage(attacker, defender): + """ + Returns a value for damage to be deducted from the defender's HP after abilities + successful hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being damaged + + Returns: + damage_value (int): Damage value, which is to be deducted from the defending + character's HP. + + Notes: + By default, returns a random integer from 15 to 25 without using any + properties from either the attacker or defender. + + Again, this can be expanded upon. + """ + # For this example, just generate a number between 15 and 25. + damage_value = randint(15, 25) + return damage_value + + +def apply_damage(defender, damage): + """ + Applies damage to a target, reducing their HP by the damage amount to a + minimum of 0. + + Args: + defender (obj): Character taking damage + damage (int): Amount of damage being taken + """ + defender.db.hp -= damage # Reduce defender's HP by the damage dealt. + # If this reduces it to 0 or less, set HP to 0. + if defender.db.hp <= 0: + defender.db.hp = 0 + +def at_defeat(defeated): + """ + Announces the defeat of a fighter in combat. + + Args: + defeated (obj): Fighter that's been defeated. + + Notes: + All this does is announce a defeat message by default, but if you + want anything else to happen to defeated fighters (like putting them + into a dying state or something similar) then this is the place to + do it. + """ + defeated.location.msg_contents("%s has been defeated!" % defeated) + +def resolve_attack(attacker, defender, attack_value=None, defense_value=None): + """ + Resolves an attack and outputs the result. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Notes: + Even though the attack and defense values are calculated + extremely simply, they are separated out into their own functions + so that they are easier to expand upon. + """ + # Get an attack roll from the attacker. + if not attack_value: + attack_value = get_attack(attacker, defender) + # Get a defense value from the defender. + if not defense_value: + defense_value = get_defense(attacker, defender) + # If the attack value is lower than the defense value, miss. Otherwise, hit. + if attack_value < defense_value: + attacker.location.msg_contents("%s's attack misses %s!" % (attacker, defender)) + else: + damage_value = get_damage(attacker, defender) # Calculate damage value. + # Announce damage dealt and apply damage. + attacker.location.msg_contents("%s hits %s for %i damage!" % (attacker, defender, damage_value)) + apply_damage(defender, damage_value) + # If defender HP is reduced to 0 or less, call at_defeat. + if defender.db.hp <= 0: + at_defeat(defender) + +def combat_cleanup(character): + """ + Cleans up all the temporary combat-related attributes on a character. + + Args: + character (obj): Character to have their combat attributes removed + + Notes: + Any attribute whose key begins with 'combat_' is temporary and no + longer needed once a fight ends. + """ + for attr in character.attributes.all(): + if attr.key[:7] == "combat_": # If the attribute name starts with 'combat_'... + character.attributes.remove(key=attr.key) # ...then delete it! + + +def is_in_combat(character): + """ + Returns true if the given character is in combat. + + Args: + character (obj): Character to determine if is in combat or not + + Returns: + (bool): True if in combat or False if not in combat + """ + return bool(character.db.combat_turnhandler) + + +def is_turn(character): + """ + Returns true if it's currently the given character's turn in combat. + + Args: + character (obj): Character to determine if it is their turn or not + + Returns: + (bool): True if it is their turn or False otherwise + """ + turnhandler = character.db.combat_turnhandler + currentchar = turnhandler.db.fighters[turnhandler.db.turn] + return bool(character == currentchar) + + +def spend_action(character, actions, action_name=None): + """ + Spends a character's available combat actions and checks for end of turn. + + Args: + character (obj): Character spending the action + actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions + + Kwargs: + action_name (str or None): If a string is given, sets character's last action in + combat to provided string + """ + if not is_in_combat(character): + return + if action_name: + character.db.combat_lastaction = action_name + if actions == 'all': # If spending all actions + character.db.combat_actionsleft = 0 # Set actions to 0 + else: + character.db.combat_actionsleft -= actions # Use up actions. + if character.db.combat_actionsleft < 0: + character.db.combat_actionsleft = 0 # Can't have fewer than 0 actions + character.db.combat_turnhandler.turn_end_check(character) # Signal potential end of turn. + + +""" +---------------------------------------------------------------------------- +CHARACTER TYPECLASS +---------------------------------------------------------------------------- +""" + + +class TBMagicCharacter(DefaultCharacter): + """ + A character able to participate in turn-based combat. Has attributes for current + and maximum HP, and access to combat commands. + """ + + def at_object_creation(self): + """ + Called once, when this object is first created. This is the + normal hook to overload for most object types. + """ + self.db.max_hp = 100 # Set maximum HP to 100 + self.db.hp = self.db.max_hp # Set current HP to maximum + self.db.spells_known = [] # Set empty spells known list + """ + 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 + +""" +---------------------------------------------------------------------------- +SCRIPTS START HERE +---------------------------------------------------------------------------- +""" + + +class TBMagicTurnHandler(DefaultScript): + """ + This is the script that handles the progression of combat through turns. + On creation (when a fight is started) it adds all combat-ready characters + to its roster and then sorts them into a turn order. There can only be one + fight going on in a single room at a time, so the script is assigned to a + room as its object. + + Fights persist until only one participant is left with any HP or all + remaining participants choose to end the combat with the 'disengage' command. + """ + + def at_script_creation(self): + """ + Called once, when the script is created. + """ + self.key = "Combat Turn Handler" + self.interval = 5 # Once every 5 seconds + self.persistent = True + self.db.fighters = [] + + # Add all fighters in the room with at least 1 HP to the combat." + for thing in self.obj.contents: + if thing.db.hp: + self.db.fighters.append(thing) + + # Initialize each fighter for combat + for fighter in self.db.fighters: + self.initialize_for_combat(fighter) + + # Add a reference to this script to the room + self.obj.db.combat_turnhandler = self + + # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order. + # The initiative roll is determined by the roll_init function and can be customized easily. + ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True) + self.db.fighters = ordered_by_roll + + # Announce the turn order. + self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters)) + + # Start first fighter's turn. + self.start_turn(self.db.fighters[0]) + + # Set up the current turn and turn timeout delay. + self.db.turn = 0 + self.db.timer = TURN_TIMEOUT # Set timer to turn timeout specified in options + + def at_stop(self): + """ + Called at script termination. + """ + for fighter in self.db.fighters: + combat_cleanup(fighter) # Clean up the combat attributes for every fighter. + self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location + + def at_repeat(self): + """ + Called once every self.interval seconds. + """ + currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order. + self.db.timer -= self.interval # Count down the timer. + + if self.db.timer <= 0: + # Force current character to disengage if timer runs out. + self.obj.msg_contents("%s's turn timed out!" % currentchar) + spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions. + return + elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left + # Warn the current character if they're about to time out. + currentchar.msg("WARNING: About to time out!") + self.db.timeout_warning_given = True + + def initialize_for_combat(self, character): + """ + Prepares a character for combat when starting or entering a fight. + + Args: + character (obj): Character to initialize for combat. + """ + combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. + character.db.combat_actionsleft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + character.db.combat_turnhandler = self # Add a reference to this turn handler script to the character + character.db.combat_lastaction = "null" # Track last action taken in combat + + def start_turn(self, character): + """ + Readies a character for the start of their turn by replenishing their + available actions and notifying them that their turn has come up. + + Args: + character (obj): Character to be readied. + + Notes: + Here, you only get one action per turn, but you might want to allow more than + one per turn, or even grant a number of actions based on a character's + attributes. You can even add multiple different kinds of actions, I.E. actions + separated for movement, by adding "character.db.combat_movesleft = 3" or + something similar. + """ + character.db.combat_actionsleft = ACTIONS_PER_TURN # Replenish actions + # Prompt the character for their turn and give some information. + character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp) + + def next_turn(self): + """ + Advances to the next character in the turn order. + """ + + # Check to see if every character disengaged as their last action. If so, end combat. + disengage_check = True + for fighter in self.db.fighters: + if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage + disengage_check = False + if disengage_check: # All characters have disengaged + self.obj.msg_contents("All fighters have disengaged! Combat is over!") + self.stop() # Stop this script and end combat. + return + + # Check to see if only one character is left standing. If so, end combat. + defeated_characters = 0 + for fighter in self.db.fighters: + if fighter.db.HP == 0: + defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated) + if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated + for fighter in self.db.fighters: + if fighter.db.HP != 0: + LastStanding = fighter # Pick the one fighter left with HP remaining + self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding) + self.stop() # Stop this script and end combat. + return + + # Cycle to the next turn. + currentchar = self.db.fighters[self.db.turn] + self.db.turn += 1 # Go to the next in the turn order. + if self.db.turn > len(self.db.fighters) - 1: + self.db.turn = 0 # Go back to the first in the turn order once you reach the end. + newchar = self.db.fighters[self.db.turn] # Note the new character + self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer. + self.db.timeout_warning_given = False # Reset the timeout warning. + self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) + self.start_turn(newchar) # Start the new character's turn. + + def turn_end_check(self, character): + """ + Tests to see if a character's turn is over, and cycles to the next turn if it is. + + Args: + character (obj): Character to test for end of turn + """ + if not character.db.combat_actionsleft: # Character has no actions remaining + self.next_turn() + return + + def join_fight(self, character): + """ + Adds a new character to a fight already in progress. + + Args: + character (obj): Character to be added to the fight. + """ + # Inserts the fighter to the turn order, right behind whoever's turn it currently is. + self.db.fighters.insert(self.db.turn, character) + # Tick the turn counter forward one to compensate. + self.db.turn += 1 + # Initialize the character like you do at the start. + self.initialize_for_combat(character) + + +""" +---------------------------------------------------------------------------- +COMMANDS START HERE +---------------------------------------------------------------------------- +""" + + +class CmdFight(Command): + """ + Starts a fight with everyone in the same room as you. + + Usage: + fight + + When you start a fight, everyone in the room who is able to + fight is added to combat, and a turn order is randomly rolled. + When it's your turn, you can attack other characters. + """ + key = "fight" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + here = self.caller.location + fighters = [] + + if not self.caller.db.hp: # If you don't have any hp + self.caller.msg("You can't start a fight if you've been defeated!") + return + if is_in_combat(self.caller): # Already in a fight + self.caller.msg("You're already in a fight!") + return + for thing in here.contents: # Test everything in the room to add it to the fight. + if thing.db.HP: # If the object has HP... + fighters.append(thing) # ...then add it to the fight. + if len(fighters) <= 1: # If you're the only able fighter in the room + self.caller.msg("There's nobody here to fight!") + return + if here.db.combat_turnhandler: # If there's already a fight going on... + here.msg_contents("%s joins the fight!" % self.caller) + here.db.combat_turnhandler.join_fight(self.caller) # Join the fight! + return + here.msg_contents("%s starts a fight!" % self.caller) + # Add a turn handler script to the room, which starts combat. + here.scripts.add("contrib.turnbattle.tb_magic.TBMagicTurnHandler") + # Remember you'll have to change the path to the script if you copy this code to your own modules! + + +class CmdAttack(Command): + """ + Attacks another character. + + Usage: + attack + + When in a fight, you may attack another character. The attack has + a chance to hit, and if successful, will deal damage. + """ + + key = "attack" + help_category = "combat" + + def func(self): + "This performs the actual command." + "Set the attacker to the caller and the defender to the target." + + if not is_in_combat(self.caller): # If not in combat, can't attack. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn, can't attack. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't attack if you have no HP. + self.caller.msg("You can't attack, you've been defeated.") + return + + attacker = self.caller + defender = self.caller.search(self.args) + + if not defender: # No valid target given. + return + + if not defender.db.hp: # Target object has no HP left or to begin with + self.caller.msg("You can't fight that!") + return + + if attacker == defender: # Target and attacker are the same + self.caller.msg("You can't attack yourself!") + return + + "If everything checks out, call the attack resolving function." + resolve_attack(attacker, defender) + spend_action(self.caller, 1, action_name="attack") # Use up one action. + + +class CmdPass(Command): + """ + Passes on your turn. + + Usage: + pass + + When in a fight, you can use this command to end your turn early, even + if there are still any actions you can take. + """ + + key = "pass" + aliases = ["wait", "hold"] + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + if not is_in_combat(self.caller): # Can only pass a turn in combat. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # Can only pass if it's your turn. + self.caller.msg("You can only do that on your turn.") + return + + self.caller.location.msg_contents("%s takes no further action, passing the turn." % self.caller) + spend_action(self.caller, 'all', action_name="pass") # Spend all remaining actions. + + +class CmdDisengage(Command): + """ + Passes your turn and attempts to end combat. + + Usage: + disengage + + Ends your turn early and signals that you're trying to end + the fight. If all participants in a fight disengage, the + fight ends. + """ + + key = "disengage" + aliases = ["spare"] + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + if not is_in_combat(self.caller): # If you're not in combat + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn + self.caller.msg("You can only do that on your turn.") + return + + self.caller.location.msg_contents("%s disengages, ready to stop fighting." % self.caller) + spend_action(self.caller, 'all', action_name="disengage") # Spend all remaining actions. + """ + The action_name kwarg sets the character's last action to "disengage", which is checked by + the turn handler script to see if all fighters have disengaged. + """ + +class CmdLearnSpell(Command): + """ + Learn a magic spell + """ + + key = "learnspell" + help_category = "magic" + + def func(self): + """ + This performs the actual command. + """ + spell_list = sorted(SPELLS.keys()) + args = self.args.lower() + args = args.strip(" ") + caller = self.caller + spell_to_learn = [] + + if not args or len(args) < 3: + caller.msg("Usage: learnspell ") + return + + for spell in spell_list: # Match inputs to spells + if args in spell.lower(): + spell_to_learn.append(spell) + + if spell_to_learn == []: # No spells matched + caller.msg("There is no spell with that name.") + return + if len(spell_to_learn) > 1: # More than one match + matched_spells = ', '.join(spell_to_learn) + caller.msg("Which spell do you mean: %s?" % matched_spells) + return + + if len(spell_to_learn) == 1: # If one match, extract the string + spell_to_learn = spell_to_learn[0] + + if spell_to_learn not in self.caller.db.spells_known: + caller.db.spells_known.append(spell_to_learn) + caller.msg("You learn the spell '%s'!" % spell_to_learn) + return + if spell_to_learn in self.caller.db.spells_known: + caller.msg("You already know the spell '%s'!" % spell_to_learn) + +class CmdCast(MuxCommand): + """ + Cast a magic spell! + """ + + key = "cast" + help_category = "magic" + + def func(self): + """ + This performs the actual command. + """ + caller = self.caller + + syntax_err = "Usage: cast [= ]" + if not self.lhs or len(self.lhs) < 3: # No spell name given + self.caller.msg(syntax_err) + return + + spellname = self.lhs.lower() + spell_to_cast = [] + spell_targets = [] + + if not self.rhs: + spell_targets = [] + elif self.rhs.lower() in ['me', 'self', 'myself']: + spell_targets = [caller] + elif len(self.rhs) > 2: + spell_targets = self.rhslist + + for spell in caller.db.spells_known: # Match inputs to spells + if self.lhs in spell.lower(): + spell_to_cast.append(spell) + + if spell_to_cast == []: # No spells matched + caller.msg("You don't know a spell of that name.") + return + if len(spell_to_cast) > 1: # More than one match + matched_spells = ', '.join(spell_to_cast) + caller.msg("Which spell do you mean: %s?" % matched_spells) + return + + if len(spell_to_cast) == 1: # If one match, extract the string + spell_to_cast = spell_to_cast[0] + + if spell_to_cast not in SPELLS: # Spell isn't defined + caller.msg("ERROR: Spell %s is undefined" % spell_to_cast) + return + + # Time to extract some info from the chosen spell! + spelldata = SPELLS[spell_to_cast] + + # Add in some default data if optional parameters aren't specified + if "combat_spell" not in spelldata: + spelldata.update({"combat_spell":True}) + if "noncombat_spell" not in spelldata: + spelldata.update({"noncombat_spell":True}) + if "max_targets" not in spelldata: + spelldata.update({"max_targets":1}) + + # If spell takes no targets, give error message and return + if len(spell_targets) > 0 and spelldata["target"] == "none": + caller.msg("The spell '%s' isn't cast on a target.") + return + + # If no target is given and spell requires a target, give error message + if spelldata["target"] not in ["self", "none"]: + if len(spell_targets) == 0: + caller.msg("The spell %s requires a target." % spell_to_cast) + return + + # If more targets given than maximum, give error message + if len(spell_targets) > spelldata["max_targets"]: + targplural = "target" + if spelldata["max_targets"] > 1: + targplural = "targets" + caller.msg("The spell %s can only be cast on %i %s." % (spell_to_cast, spelldata["max_targets"], targplural)) + return + + # Set up our candidates for targets + target_candidates = [] + + if spelldata["target"] in ["any", "other"]: + target_candidates = caller.location.contents + caller.contents + + if spelldata["target"] == "anyobj": + prefilter_candidates = caller.location.contents + caller.contents + for thing in prefilter_candidates: + if not thing.attributes.has("max_hp"): # Has no max HP, isn't a fighter + target_candidates.append(thing) + + if spelldata["target"] in ["anychar", "otherchar"]: + prefilter_candidates = caller.location.contents + for thing in prefilter_candidates: + if thing.attributes.has("max_hp"): # Has max HP, is a fighter + target_candidates.append(thing) + + # Now, match each entry in spell_targets to an object + matched_targets = [] + for target in spell_targets: + match = caller.search(target, candidates=target_candidates) + matched_targets.append(match) + spell_targets = matched_targets + + # If no target is given and the spell's target is 'self', set target to self + if len(spell_targets) == 0 and spelldata["target"] == "self": + spell_targets = [caller] + + # Give error message if trying to cast an "other" target spell on yourself + if spelldata["target"] in ["other", "otherchar"]: + if caller in spell_targets: + caller.msg("You can't cast %s on yourself." % spell_to_cast) + return + + # Return if "None" in target list, indicating failed match + if None in spell_targets: + return + + # Give error message if repeats in target list + if len(spell_targets) != len(set(spell_targets)): + caller.msg("You can't specify the same target more than once!") + return + + caller.msg("You cast %s! Fwooosh!" % spell_to_cast) + + +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()) + self.add(CmdLearnSpell()) + self.add(CmdCast()) + +""" +Required values for spells: + + cost (int): MP cost of casting the spell + target (str): Valid targets for the spell. Can be any of: + "none" - No target needed + "self" - Self only + "any" - Any object + "anyobj" - Any object that isn't a character + "anychar" - Any character + "other" - Any object excluding the caster + "otherchar" - Any character excluding the caster + spellfunc (callable): Function that performs the action of the spell. + Must take the following arguments: caster (obj), targets (list), + and cost(int). + +Optional values for spells: + + combat_spell (bool): If the spell can be cast in combat. True by default. + noncombat_spell (bool): If the spell can be cast out of combat. True by default. + max_targets (int): Maximum number of objects that can be targeted by the spell. + 1 by default - unused if target is "none" or "self" + +Any other values specified besides the above will be passed as kwargs to the spellfunc. + +""" + +SPELLS = { +"magic missile":{"spellfunc":None, "target":"otherchar", "cost":3, "noncombat_spell":False, "max_targets":3}, +"cure wounds":{"spellfunc":None, "target":"anychar", "cost":5}, +} \ No newline at end of file From 4a554a44094ba31c1b545f761213ccb56efbf30e Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 12 Nov 2017 01:56:35 -0800 Subject: [PATCH 055/466] 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 056/466] 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 057/466] 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 058/466] 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 059/466] 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 5fe3cd186ddf5832b5f50dab5c538018ac625561 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 12 Nov 2017 18:23:22 -0800 Subject: [PATCH 060/466] Added functional 'cure wounds' spell Also added more spell verification in the 'cast' command, accounting for spell's MP cost and whether it can be used in combat --- evennia/contrib/turnbattle/tb_magic.py | 83 ++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 11 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_magic.py b/evennia/contrib/turnbattle/tb_magic.py index cc639737c0..7b59393e23 100644 --- a/evennia/contrib/turnbattle/tb_magic.py +++ b/evennia/contrib/turnbattle/tb_magic.py @@ -312,6 +312,8 @@ class TBMagicCharacter(DefaultCharacter): self.db.max_hp = 100 # Set maximum HP to 100 self.db.hp = self.db.max_hp # Set current HP to maximum self.db.spells_known = [] # Set empty spells known list + self.db.max_mp = 20 # Set maximum MP to 20 + self.db.mp = self.db.max_mp # Set current MP to maximum """ Adds attributes for a character's current and maximum HP. We're just going to set this value at '100' by default. @@ -733,6 +735,13 @@ class CmdLearnSpell(Command): class CmdCast(MuxCommand): """ Cast a magic spell! + + Notes: This is a quite long command, since it has to cope with all + the different circumstances in which you may or may not be able + to cast a spell. None of the spell's effects are handled by the + command - all the command does is verify that the player's input + is valid for the spell being cast and then call the spell's + function. """ key = "cast" @@ -789,8 +798,29 @@ class CmdCast(MuxCommand): spelldata.update({"noncombat_spell":True}) if "max_targets" not in spelldata: spelldata.update({"max_targets":1}) + + # Store any superfluous options as kwargs to pass to the spell function + kwargs = {} + spelldata_opts = ["spellfunc", "target", "cost", "combat_spell", "noncombat_spell", "max_targets"] + for key in spelldata: + if key not in spelldata_opts: + kwargs.update({key:spelldata[key]}) + + # If caster doesn't have enough MP to cover the spell's cost, give error and return + if spelldata["cost"] > caller.db.mp: + caller.msg("You don't have enough MP to cast '%s'." % spell_to_cast) + + # If in combat and the spell isn't a combat spell, give error message and return + if spelldata["combat_spell"] == False and is_in_combat(caller): + caller.msg("You can't use the spell '%s' in combat." % spell_to_cast) + return + + # If not in combat and the spell isn't a non-combat spell, error ms and return. + if spelldata["noncombat_spell"] == False and is_in_combat(caller) == False: + caller.msg("You can't use the spell '%s' outside of combat." % spell_to_cast) + return - # If spell takes no targets, give error message and return + # If spell takes no targets and one is given, give error message and return if len(spell_targets) > 0 and spelldata["target"] == "none": caller.msg("The spell '%s' isn't cast on a target.") return @@ -798,7 +828,7 @@ class CmdCast(MuxCommand): # If no target is given and spell requires a target, give error message if spelldata["target"] not in ["self", "none"]: if len(spell_targets) == 0: - caller.msg("The spell %s requires a target." % spell_to_cast) + caller.msg("The spell '%s' requires a target." % spell_to_cast) return # If more targets given than maximum, give error message @@ -806,28 +836,32 @@ class CmdCast(MuxCommand): targplural = "target" if spelldata["max_targets"] > 1: targplural = "targets" - caller.msg("The spell %s can only be cast on %i %s." % (spell_to_cast, spelldata["max_targets"], targplural)) + caller.msg("The spell '%s' can only be cast on %i %s." % (spell_to_cast, spelldata["max_targets"], targplural)) return # Set up our candidates for targets target_candidates = [] + # If spell targets 'any' or 'other', any object in caster's inventory or location + # can be targeted by the spell. if spelldata["target"] in ["any", "other"]: target_candidates = caller.location.contents + caller.contents + # If spell targets 'anyobj', only non-character objects can be targeted. if spelldata["target"] == "anyobj": prefilter_candidates = caller.location.contents + caller.contents for thing in prefilter_candidates: if not thing.attributes.has("max_hp"): # Has no max HP, isn't a fighter target_candidates.append(thing) + # If spell targets 'anychar' or 'otherchar', only characters can be targeted. if spelldata["target"] in ["anychar", "otherchar"]: prefilter_candidates = caller.location.contents for thing in prefilter_candidates: if thing.attributes.has("max_hp"): # Has max HP, is a fighter target_candidates.append(thing) - # Now, match each entry in spell_targets to an object + # Now, match each entry in spell_targets to an object in the search candidates matched_targets = [] for target in spell_targets: match = caller.search(target, candidates=target_candidates) @@ -841,11 +875,12 @@ class CmdCast(MuxCommand): # Give error message if trying to cast an "other" target spell on yourself if spelldata["target"] in ["other", "otherchar"]: if caller in spell_targets: - caller.msg("You can't cast %s on yourself." % spell_to_cast) + caller.msg("You can't cast '%s' on yourself." % spell_to_cast) return # Return if "None" in target list, indicating failed match if None in spell_targets: + # No need to give an error message, as 'search' gives one by default. return # Give error message if repeats in target list @@ -853,7 +888,8 @@ class CmdCast(MuxCommand): caller.msg("You can't specify the same target more than once!") return - caller.msg("You cast %s! Fwooosh!" % spell_to_cast) + # Finally, we can cast the spell itself + spelldata["spellfunc"](caller, spell_to_cast, spell_targets, spelldata["cost"], **kwargs) class CmdRest(Command): @@ -927,7 +963,31 @@ class BattleCmdSet(default_cmds.CharacterCmdSet): self.add(CmdCombatHelp()) self.add(CmdLearnSpell()) self.add(CmdCast()) - + +""" +SPELL FUNCTIONS START HERE +""" + +def spell_cure_wounds(caster, spell_name, targets, cost, **kwargs): + """ + Spell that restores HP to a target. + """ + spell_msg = "%s casts %s!" % (caster, spell_name) + + for character in targets: + to_heal = randint(20, 40) # Restore 20 to 40 hp + if character.db.hp + to_heal > character.db.max_hp: + to_heal = character.db.max_hp - character.db.hp # Cap healing to max HP + character.db.hp += to_heal + spell_msg += " %s regains %i HP!" % (character, to_heal) + + caster.db.mp -= cost # Deduct MP cost + + caster.location.msg_contents(spell_msg) # Message the room with spell results + + if is_in_combat(caster): # Spend action if in combat + spend_action(caster, 1, action_name="cast") + """ Required values for spells: @@ -941,8 +1001,8 @@ Required values for spells: "other" - Any object excluding the caster "otherchar" - Any character excluding the caster spellfunc (callable): Function that performs the action of the spell. - Must take the following arguments: caster (obj), targets (list), - and cost(int). + Must take the following arguments: caster (obj), spell_name (str), targets (list), + and cost(int), as well as **kwargs. Optional values for spells: @@ -957,5 +1017,6 @@ Any other values specified besides the above will be passed as kwargs to the spe SPELLS = { "magic missile":{"spellfunc":None, "target":"otherchar", "cost":3, "noncombat_spell":False, "max_targets":3}, -"cure wounds":{"spellfunc":None, "target":"anychar", "cost":5}, -} \ No newline at end of file +"cure wounds":{"spellfunc":spell_cure_wounds, "target":"anychar", "cost":5, "its_a_kwarg":"wow"}, +} + From 83579a2e06ce040ddc9fdd6002b0584a5a159c28 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 12 Nov 2017 21:11:28 -0800 Subject: [PATCH 061/466] Added attack spells, more healing spell variants --- evennia/contrib/turnbattle/tb_magic.py | 134 ++++++++++++++++++++++--- 1 file changed, 120 insertions(+), 14 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_magic.py b/evennia/contrib/turnbattle/tb_magic.py index 7b59393e23..bb451b1e96 100644 --- a/evennia/contrib/turnbattle/tb_magic.py +++ b/evennia/contrib/turnbattle/tb_magic.py @@ -753,10 +753,16 @@ class CmdCast(MuxCommand): """ caller = self.caller - syntax_err = "Usage: cast [= ]" if not self.lhs or len(self.lhs) < 3: # No spell name given - self.caller.msg(syntax_err) - return + caller.msg("Usage: cast = , , ...") + if not caller.db.spells_known: + caller.msg("You don't know any spells.") + return + else: + caller.db.spells_known = sorted(caller.db.spells_known) + spells_known_msg = "You know the following spells:|/" + "|/".join(caller.db.spells_known) + caller.msg(spells_known_msg) # List the spells the player knows + return spellname = self.lhs.lower() spell_to_cast = [] @@ -809,6 +815,7 @@ class CmdCast(MuxCommand): # If caster doesn't have enough MP to cover the spell's cost, give error and return if spelldata["cost"] > caller.db.mp: caller.msg("You don't have enough MP to cast '%s'." % spell_to_cast) + return # If in combat and the spell isn't a combat spell, give error message and return if spelldata["combat_spell"] == False and is_in_combat(caller): @@ -894,13 +901,13 @@ class CmdCast(MuxCommand): class CmdRest(Command): """ - Recovers damage. + Recovers damage and restores MP. Usage: rest - Resting recovers your HP to its maximum, but you can only - rest if you're not in a fight. + Resting recovers your HP and MP to their maximum, but you can + only rest if you're not in a fight. """ key = "rest" @@ -914,9 +921,10 @@ class CmdRest(Command): 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) + self.caller.db.mp = self.caller.db.max_mp # Set current MP to maximum + self.caller.location.msg_contents("%s rests to recover HP and MP." % self.caller) """ - You'll probably want to replace this with your own system for recovering HP. + You'll probably want to replace this with your own system for recovering HP and MP. """ @@ -974,8 +982,16 @@ def spell_cure_wounds(caster, spell_name, targets, cost, **kwargs): """ spell_msg = "%s casts %s!" % (caster, spell_name) + min_healing = 20 + max_healing = 40 + + # Retrieve healing range from kwargs, if present + if "healing_range" in kwargs: + min_healing = kwargs["healing_range"][0] + max_healing = kwargs["healing_range"][1] + for character in targets: - to_heal = randint(20, 40) # Restore 20 to 40 hp + to_heal = randint(min_healing, max_healing) # Restore 20 to 40 hp if character.db.hp + to_heal > character.db.max_hp: to_heal = character.db.max_hp - character.db.hp # Cap healing to max HP character.db.hp += to_heal @@ -986,7 +1002,91 @@ def spell_cure_wounds(caster, spell_name, targets, cost, **kwargs): caster.location.msg_contents(spell_msg) # Message the room with spell results if is_in_combat(caster): # Spend action if in combat - spend_action(caster, 1, action_name="cast") + spend_action(caster, 1, action_name="cast") + +def spell_attack(caster, spell_name, targets, cost, **kwargs): + """ + Spell that deals damage in combat. Similar to resolve_attack. + """ + spell_msg = "%s casts %s!" % (caster, spell_name) + + atkname_single = "The spell" + atkname_plural = "spells" + min_damage = 10 + max_damage = 20 + accuracy = 0 + attack_count = 1 + + # Retrieve some variables from kwargs, if present + if "attack_name" in kwargs: + atkname_single = kwargs["attack_name"][0] + atkname_plural = kwargs["attack_name"][1] + if "damage_range" in kwargs: + min_damage = kwargs["damage_range"][0] + max_damage = kwargs["damage_range"][1] + if "accuracy" in kwargs: + accuracy = kwargs["accuracy"] + if "attack_count" in kwargs: + attack_count = kwargs["attack_count"] + + to_attack = [] + print targets + # If there are more attacks than targets given, attack first target multiple times + if len(targets) < attack_count: + to_attack = to_attack + targets + extra_attacks = attack_count - len(targets) + for n in range(extra_attacks): + to_attack.insert(0, targets[0]) + else: + to_attack = targets + + print to_attack + print targets + + # Set up dictionaries to track number of hits and total damage + total_hits = {} + total_damage = {} + for fighter in targets: + total_hits.update({fighter:0}) + total_damage.update({fighter:0}) + + # Resolve attack for each target + for fighter in to_attack: + attack_value = randint(1, 100) + accuracy # Spell attack roll + defense_value = get_defense(caster, fighter) + if attack_value >= defense_value: + spell_dmg = randint(min_damage, max_damage) # Get spell damage + total_hits[fighter] += 1 + total_damage[fighter] += spell_dmg + + print total_hits + print total_damage + print targets + + for fighter in targets: + # Construct combat message + if total_hits[fighter] == 0: + spell_msg += " The spell misses %s!" % fighter + elif total_hits[fighter] > 0: + attack_count_str = atkname_single + " hits" + if total_hits[fighter] > 1: + attack_count_str = "%i %s hit" % (total_hits[fighter], atkname_plural) + spell_msg += " %s %s for %i damage!" % (attack_count_str, fighter, total_damage[fighter]) + + caster.db.mp -= cost # Deduct MP cost + + caster.location.msg_contents(spell_msg) # Message the room with spell results + + for fighter in targets: + # Apply damage + apply_damage(fighter, total_damage[fighter]) + # If fighter HP is reduced to 0 or less, call at_defeat. + if fighter.db.hp <= 0: + at_defeat(fighter) + + if is_in_combat(caster): # Spend action if in combat + spend_action(caster, 1, action_name="cast") + """ Required values for spells: @@ -1002,7 +1102,7 @@ Required values for spells: "otherchar" - Any character excluding the caster spellfunc (callable): Function that performs the action of the spell. Must take the following arguments: caster (obj), spell_name (str), targets (list), - and cost(int), as well as **kwargs. + and cost (int), as well as **kwargs. Optional values for spells: @@ -1012,11 +1112,17 @@ Optional values for spells: 1 by default - unused if target is "none" or "self" Any other values specified besides the above will be passed as kwargs to the spellfunc. - +You can use kwargs to effectively re-use the same function for different but similar +spells. """ SPELLS = { -"magic missile":{"spellfunc":None, "target":"otherchar", "cost":3, "noncombat_spell":False, "max_targets":3}, -"cure wounds":{"spellfunc":spell_cure_wounds, "target":"anychar", "cost":5, "its_a_kwarg":"wow"}, +"magic missile":{"spellfunc":spell_attack, "target":"otherchar", "cost":3, "noncombat_spell":False, "max_targets":3, + "attack_name":("A bolt", "bolts"), "damage_range":(4, 7), "accuracy":999, "attack_count":3}, +"flame shot":{"spellfunc":spell_attack, "target":"otherchar", "cost":3, "noncombat_spell":False, + "attack_name":("A jet of flame", "jets of flame"), "damage_range":(25, 35)}, +"cure wounds":{"spellfunc":spell_cure_wounds, "target":"anychar", "cost":5}, +"mass cure wounds":{"spellfunc":spell_cure_wounds, "target":"anychar", "cost":10, "max_targets": 5}, +"full heal":{"spellfunc":spell_cure_wounds, "target":"anychar", "cost":12, "healing_range":(100, 100)} } From 858494eebbae228317c87e8d45f5973bbced7b3b Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Wed, 15 Nov 2017 14:14:16 -0800 Subject: [PATCH 062/466] Formatting and documentation --- evennia/contrib/turnbattle/tb_magic.py | 206 +++++++++++++++++++------ 1 file changed, 160 insertions(+), 46 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_magic.py b/evennia/contrib/turnbattle/tb_magic.py index bb451b1e96..c2222645ac 100644 --- a/evennia/contrib/turnbattle/tb_magic.py +++ b/evennia/contrib/turnbattle/tb_magic.py @@ -1,20 +1,38 @@ """ -Simple turn-based combat system +Simple turn-based combat system with spell casting 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 is a version of the 'turnbattle' contrib that includes a basic, +expandable framework for a 'magic system', whereby players can spend +a limited resource (MP) to achieve a wide variety of effects, both in +and out of combat. This does not have to strictly be a system for +magic - it can easily be re-flavored to any other sort of resource +based mechanic, like psionic powers, special moves and stamina, and +so forth. -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. +In this system, spells are learned by name with the 'learnspell' +command, and then used with the 'cast' command. Spells can be cast in or +out of combat - some spells can only be cast in combat, some can only be +cast outside of combat, and some can be cast any time. However, if you +are in combat, you can only cast a spell on your turn, and doing so will +typically use an action (as specified in the spell's funciton). + +Spells are defined at the end of the module in a database that's a +dictionary of dictionaries - each spell is matched by name to a function, +along with various parameters that restrict when the spell can be used and +what the spell can be cast on. Included is a small variety of spells that +damage opponents and heal HP, as well as one that creates an object. + +Because a spell can call any function, a spell can be made to do just +about anything at all. The SPELLS dictionary at the bottom of the module +even allows kwargs to be passed to the spell function, so that the same +function can be re-used for multiple similar spells. + +Spells in this system work on a very basic resource: MP, which is spent +when casting spells and restored by resting. It shouldn't be too difficult +to modify this system to use spell slots, some physical fuel or resource, +or whatever else your game requires. To install and test, import this module's TBMagicCharacter object into your game's character.py module: @@ -43,7 +61,7 @@ in your game and using it as-is. """ from random import randint -from evennia import DefaultCharacter, Command, default_cmds, DefaultScript +from evennia import DefaultCharacter, Command, default_cmds, DefaultScript, create_object from evennia.commands.default.muxcommand import MuxCommand from evennia.commands.default.help import CmdHelp @@ -690,7 +708,12 @@ class CmdDisengage(Command): class CmdLearnSpell(Command): """ - Learn a magic spell + Learn a magic spell. + + Usage: + learnspell + + Adds a spell by name to your list of spells known. """ key = "learnspell" @@ -706,7 +729,7 @@ class CmdLearnSpell(Command): caller = self.caller spell_to_learn = [] - if not args or len(args) < 3: + if not args or len(args) < 3: # No spell given caller.msg("Usage: learnspell ") return @@ -725,23 +748,29 @@ class CmdLearnSpell(Command): if len(spell_to_learn) == 1: # If one match, extract the string spell_to_learn = spell_to_learn[0] - if spell_to_learn not in self.caller.db.spells_known: - caller.db.spells_known.append(spell_to_learn) + if spell_to_learn not in self.caller.db.spells_known: # If the spell isn't known... + caller.db.spells_known.append(spell_to_learn) # ...then add the spell to the character caller.msg("You learn the spell '%s'!" % spell_to_learn) return - if spell_to_learn in self.caller.db.spells_known: - caller.msg("You already know the spell '%s'!" % spell_to_learn) + if spell_to_learn in self.caller.db.spells_known: # Already has the spell specified + caller.msg("You already know the spell '%s'!" % spell_to_learn) + """ + You will almost definitely want to replace this with your own system + for learning spells, perhaps tied to character advancement or finding + items in the game world that spells can be learned from. + """ class CmdCast(MuxCommand): """ - Cast a magic spell! + Cast a magic spell that you know, provided you have the MP + to spend on its casting. - Notes: This is a quite long command, since it has to cope with all - the different circumstances in which you may or may not be able - to cast a spell. None of the spell's effects are handled by the - command - all the command does is verify that the player's input - is valid for the spell being cast and then call the spell's - function. + Usage: + cast [= , , etc...] + + Some spells can be cast on multiple targets, some can be cast + on only yourself, and some don't need a target specified at all. + Typing 'cast' by itself will give you a list of spells you know. """ key = "cast" @@ -829,7 +858,7 @@ class CmdCast(MuxCommand): # If spell takes no targets and one is given, give error message and return if len(spell_targets) > 0 and spelldata["target"] == "none": - caller.msg("The spell '%s' isn't cast on a target.") + caller.msg("The spell '%s' isn't cast on a target." % spell_to_cast) return # If no target is given and spell requires a target, give error message @@ -895,8 +924,16 @@ class CmdCast(MuxCommand): caller.msg("You can't specify the same target more than once!") return - # Finally, we can cast the spell itself + # Finally, we can cast the spell itself. Note that MP is not deducted here! spelldata["spellfunc"](caller, spell_to_cast, spell_targets, spelldata["cost"], **kwargs) + """ + Note: This is a quite long command, since it has to cope with all + the different circumstances in which you may or may not be able + to cast a spell. None of the spell's effects are handled by the + command - all the command does is verify that the player's input + is valid for the spell being cast and then call the spell's + function. + """ class CmdRest(Command): @@ -973,12 +1010,32 @@ class BattleCmdSet(default_cmds.CharacterCmdSet): self.add(CmdCast()) """ +---------------------------------------------------------------------------- SPELL FUNCTIONS START HERE +---------------------------------------------------------------------------- + +These are the functions that are called by the 'cast' command to perform the +effects of various spells. Which spells execute which functions and what +parameters are passed to them are specified at the bottom of the module, in +the 'SPELLS' dictionary. + +All of these functions take the same arguments: + caster (obj): Character casting the spell + spell_name (str): Name of the spell being cast + targets (list): List of objects targeted by the spell + cost (int): MP cost of casting the spell + +These functions also all accept **kwargs, and how these are used is specified +in the docstring for each function. """ -def spell_cure_wounds(caster, spell_name, targets, cost, **kwargs): +def spell_healing(caster, spell_name, targets, cost, **kwargs): """ - Spell that restores HP to a target. + Spell that restores HP to a target or targets. + + kwargs: + healing_range (tuple): Minimum and maximum amount healed to + each target. (20, 40) by default. """ spell_msg = "%s casts %s!" % (caster, spell_name) @@ -1007,6 +1064,20 @@ def spell_cure_wounds(caster, spell_name, targets, cost, **kwargs): def spell_attack(caster, spell_name, targets, cost, **kwargs): """ Spell that deals damage in combat. Similar to resolve_attack. + + kwargs: + attack_name (tuple): Single and plural describing the sort of + attack or projectile that strikes each enemy. + damage_range (tuple): Minimum and maximum damage dealt by the + spell. (10, 20) by default. + accuracy (int): Modifier to the spell's attack roll, determining + an increased or decreased chance to hit. 0 by default. + attack_count (int): How many individual attacks are made as part + of the spell. If the number of attacks exceeds the number of + targets, the first target specified will be attacked more + than once. Just 1 by default - if the attack_count is less + than the number targets given, each target will only be + attacked once. """ spell_msg = "%s casts %s!" % (caster, spell_name) @@ -1030,7 +1101,6 @@ def spell_attack(caster, spell_name, targets, cost, **kwargs): attack_count = kwargs["attack_count"] to_attack = [] - print targets # If there are more attacks than targets given, attack first target multiple times if len(targets) < attack_count: to_attack = to_attack + targets @@ -1038,10 +1108,8 @@ def spell_attack(caster, spell_name, targets, cost, **kwargs): for n in range(extra_attacks): to_attack.insert(0, targets[0]) else: - to_attack = targets + to_attack = to_attack + targets - print to_attack - print targets # Set up dictionaries to track number of hits and total damage total_hits = {} @@ -1058,10 +1126,6 @@ def spell_attack(caster, spell_name, targets, cost, **kwargs): spell_dmg = randint(min_damage, max_damage) # Get spell damage total_hits[fighter] += 1 total_damage[fighter] += spell_dmg - - print total_hits - print total_damage - print targets for fighter in targets: # Construct combat message @@ -1087,11 +1151,54 @@ def spell_attack(caster, spell_name, targets, cost, **kwargs): if is_in_combat(caster): # Spend action if in combat spend_action(caster, 1, action_name="cast") +def spell_conjure(caster, spell_name, targets, cost, **kwargs): + """ + Spell that creates an object. + + kwargs: + obj_key (str): Key of the created object. + obj_desc (str): Desc of the created object. + obj_typeclass (str): Typeclass path of the object. + + If you want to make more use of this particular spell funciton, + you may want to modify it to use the spawner (in evennia.utils.spawner) + instead of creating objects directly. + """ + + obj_key = "a nondescript object" + obj_desc = "A perfectly generic object." + obj_typeclass = "evennia.objects.objects.DefaultObject" + + # Retrieve some variables from kwargs, if present + if "obj_key" in kwargs: + obj_key = kwargs["obj_key"] + if "obj_desc" in kwargs: + obj_desc = kwargs["obj_desc"] + if "obj_typeclass" in kwargs: + obj_typeclass = kwargs["obj_typeclass"] + + conjured_obj = create_object(obj_typeclass, key=obj_key, location=caster.location) # Create object + conjured_obj.db.desc = obj_desc # Add object desc + + caster.db.mp -= cost # Deduct MP cost + + caster.location.msg_contents("%s casts %s, and %s appears!" % (caster, spell_name, conjured_obj)) """ +---------------------------------------------------------------------------- +SPELL DEFINITIONS START HERE +---------------------------------------------------------------------------- +In this section, each spell is matched to a function, and given parameters +that determine its MP cost, valid type and number of targets, and what +function casting the spell executes. + +This data is given as a dictionary of dictionaries - the key of each entry +is the spell's name, and the value is a dictionary of various options and +parameters, some of which are required and others which are optional. + Required values for spells: - cost (int): MP cost of casting the spell + cost (int): MP cost of casting the spell target (str): Valid targets for the spell. Can be any of: "none" - No target needed "self" - Self only @@ -1101,8 +1208,8 @@ Required values for spells: "other" - Any object excluding the caster "otherchar" - Any character excluding the caster spellfunc (callable): Function that performs the action of the spell. - Must take the following arguments: caster (obj), spell_name (str), targets (list), - and cost (int), as well as **kwargs. + Must take the following arguments: caster (obj), spell_name (str), + targets (list), and cost (int), as well as **kwargs. Optional values for spells: @@ -1115,14 +1222,21 @@ Any other values specified besides the above will be passed as kwargs to the spe You can use kwargs to effectively re-use the same function for different but similar spells. """ - + SPELLS = { "magic missile":{"spellfunc":spell_attack, "target":"otherchar", "cost":3, "noncombat_spell":False, "max_targets":3, "attack_name":("A bolt", "bolts"), "damage_range":(4, 7), "accuracy":999, "attack_count":3}, + "flame shot":{"spellfunc":spell_attack, "target":"otherchar", "cost":3, "noncombat_spell":False, - "attack_name":("A jet of flame", "jets of flame"), "damage_range":(25, 35)}, -"cure wounds":{"spellfunc":spell_cure_wounds, "target":"anychar", "cost":5}, -"mass cure wounds":{"spellfunc":spell_cure_wounds, "target":"anychar", "cost":10, "max_targets": 5}, -"full heal":{"spellfunc":spell_cure_wounds, "target":"anychar", "cost":12, "healing_range":(100, 100)} + "attack_name":("A jet of flame", "jets of flame"), "damage_range":(25, 35)}, + +"cure wounds":{"spellfunc":spell_healing, "target":"anychar", "cost":5}, + +"mass cure wounds":{"spellfunc":spell_healing, "target":"anychar", "cost":10, "max_targets": 5}, + +"full heal":{"spellfunc":spell_healing, "target":"anychar", "cost":12, "healing_range":(100, 100)}, + +"cactus conjuration":{"spellfunc":spell_conjure, "target":"none", "cost":2, "combat_spell":False, + "obj_key":"a cactus", "obj_desc":"An ordinary green cactus with little spines."} } From 7701d5f92bed464223a463d3562d8351fa23e270 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Wed, 15 Nov 2017 14:41:43 -0800 Subject: [PATCH 063/466] Comments and documentation, CmdStatus() added --- evennia/contrib/turnbattle/tb_magic.py | 41 +++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/evennia/contrib/turnbattle/tb_magic.py b/evennia/contrib/turnbattle/tb_magic.py index c2222645ac..eabf8c0932 100644 --- a/evennia/contrib/turnbattle/tb_magic.py +++ b/evennia/contrib/turnbattle/tb_magic.py @@ -714,6 +714,22 @@ class CmdLearnSpell(Command): learnspell Adds a spell by name to your list of spells known. + + The following spells are provided as examples: + + |wmagic missile|n (3 MP): Fires three missiles that never miss. Can target + up to three different enemies. + + |wflame shot|n (3 MP): Shoots a high-damage jet of flame at one target. + + |wcure wounds|n (5 MP): Heals damage on one target. + + |wmass cure wounds|n (10 MP): Like 'cure wounds', but can heal up to 5 + targets at once. + + |wfull heal|n (12 MP): Heals one target back to full HP. + + |wcactus conjuration|n (2 MP): Creates a cactus. """ key = "learnspell" @@ -925,7 +941,10 @@ class CmdCast(MuxCommand): return # Finally, we can cast the spell itself. Note that MP is not deducted here! - spelldata["spellfunc"](caller, spell_to_cast, spell_targets, spelldata["cost"], **kwargs) + try: + spelldata["spellfunc"](caller, spell_to_cast, spell_targets, spelldata["cost"], **kwargs) + except Exception: + log_trace("Error in callback for spell: %s." % spell_to_cast) """ Note: This is a quite long command, since it has to cope with all the different circumstances in which you may or may not be able @@ -963,7 +982,25 @@ class CmdRest(Command): """ You'll probably want to replace this with your own system for recovering HP and MP. """ + +class CmdStatus(Command): + """ + Gives combat information. + Usage: + status + + Shows your current and maximum HP and your distance from + other targets in combat. + """ + + key = "status" + help_category = "combat" + + def func(self): + "This performs the actual command." + char = self.caller + char.msg("You have %i / %i HP and %i / %i MP." % (char.db.hp, char.db.max_hp, char.db.mp, char.db.max_mp)) class CmdCombatHelp(CmdHelp): """ @@ -1008,6 +1045,7 @@ class BattleCmdSet(default_cmds.CharacterCmdSet): self.add(CmdCombatHelp()) self.add(CmdLearnSpell()) self.add(CmdCast()) + self.add(CmdStatus()) """ ---------------------------------------------------------------------------- @@ -1182,6 +1220,7 @@ def spell_conjure(caster, spell_name, targets, cost, **kwargs): caster.db.mp -= cost # Deduct MP cost + # Message the room to announce the creation of the object caster.location.msg_contents("%s casts %s, and %s appears!" % (caster, spell_name, conjured_obj)) """ From fda565b274428f54364fa25f5822fd898beaf5bb Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Wed, 15 Nov 2017 14:58:25 -0800 Subject: [PATCH 064/466] Final touches --- evennia/contrib/turnbattle/tb_magic.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_magic.py b/evennia/contrib/turnbattle/tb_magic.py index eabf8c0932..6e16cd0d46 100644 --- a/evennia/contrib/turnbattle/tb_magic.py +++ b/evennia/contrib/turnbattle/tb_magic.py @@ -769,7 +769,7 @@ class CmdLearnSpell(Command): caller.msg("You learn the spell '%s'!" % spell_to_learn) return if spell_to_learn in self.caller.db.spells_known: # Already has the spell specified - caller.msg("You already know the spell '%s'!" % spell_to_learn) + caller.msg("You already know the spell '%s'!" % spell_to_learn) """ You will almost definitely want to replace this with your own system for learning spells, perhaps tied to character advancement or finding @@ -1257,9 +1257,13 @@ Optional values for spells: max_targets (int): Maximum number of objects that can be targeted by the spell. 1 by default - unused if target is "none" or "self" -Any other values specified besides the above will be passed as kwargs to the spellfunc. +Any other values specified besides the above will be passed as kwargs to 'spellfunc'. You can use kwargs to effectively re-use the same function for different but similar -spells. +spells - for example, 'magic missile' and 'flame shot' use the same function, but +behave differently, as they have different damage ranges, accuracy, amount of attacks +made as part of the spell, and so forth. If you make your spell functions flexible +enough, you can make a wide variety of spells just by adding more entries to this +dictionary. """ SPELLS = { From 0616e0b21888e5f8f40951f7a0e046db75ea73ec Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Wed, 15 Nov 2017 16:25:08 -0800 Subject: [PATCH 065/466] Create tb_items.py --- evennia/contrib/turnbattle/tb_items.py | 754 +++++++++++++++++++++++++ 1 file changed, 754 insertions(+) create mode 100644 evennia/contrib/turnbattle/tb_items.py diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py new file mode 100644 index 0000000000..d4883e4c99 --- /dev/null +++ b/evennia/contrib/turnbattle/tb_items.py @@ -0,0 +1,754 @@ +""" +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 TBBasicCharacter object into +your game's character.py module: + + from evennia.contrib.turnbattle.tb_basic import TBBasicCharacter + +And change your game's character typeclass to inherit from TBBasicCharacter +instead of the default: + + class Character(TBBasicCharacter): + +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 + +""" +---------------------------------------------------------------------------- +OPTIONS +---------------------------------------------------------------------------- +""" + +TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds +ACTIONS_PER_TURN = 1 # Number of actions allowed per turn + +""" +---------------------------------------------------------------------------- +COMBAT FUNCTIONS START HERE +---------------------------------------------------------------------------- +""" + +def roll_init(character): + """ + Rolls a number between 1-1000 to determine initiative. + + Args: + character (obj): The character to determine initiative for + + Returns: + initiative (int): The character's place in initiative - higher + numbers go first. + + Notes: + By default, does not reference the character and simply returns + a random integer from 1 to 1000. + + Since the character is passed to this function, you can easily reference + a character's stats to determine an initiative roll - for example, if your + character has a 'dexterity' attribute, you can use it to give that character + an advantage in turn order, like so: + + return (randint(1,20)) + character.db.dexterity + + This way, characters with a higher dexterity will go first more often. + """ + return randint(1, 1000) + + +def get_attack(attacker, defender): + """ + Returns a value for an attack roll. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Returns: + attack_value (int): Attack roll value, compared against a defense value + to determine whether an attack hits or misses. + + Notes: + By default, returns a random integer from 1 to 100 without using any + properties from either the attacker or defender. + + This can easily be expanded to return a value based on characters stats, + equipment, and abilities. This is why the attacker and defender are passed + to this function, even though nothing from either one are used in this example. + """ + # For this example, just return a random integer up to 100. + attack_value = randint(1, 100) + return attack_value + + +def get_defense(attacker, defender): + """ + Returns a value for defense, which an attack roll must equal or exceed in order + for an attack to hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Returns: + defense_value (int): Defense value, compared against an attack roll + to determine whether an attack hits or misses. + + Notes: + By default, returns 50, not taking any properties of the defender or + attacker into account. + + As above, this can be expanded upon based on character stats and equipment. + """ + # For this example, just return 50, for about a 50/50 chance of hit. + defense_value = 50 + return defense_value + + +def get_damage(attacker, defender): + """ + Returns a value for damage to be deducted from the defender's HP after abilities + successful hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being damaged + + Returns: + damage_value (int): Damage value, which is to be deducted from the defending + character's HP. + + Notes: + By default, returns a random integer from 15 to 25 without using any + properties from either the attacker or defender. + + Again, this can be expanded upon. + """ + # For this example, just generate a number between 15 and 25. + damage_value = randint(15, 25) + return damage_value + + +def apply_damage(defender, damage): + """ + Applies damage to a target, reducing their HP by the damage amount to a + minimum of 0. + + Args: + defender (obj): Character taking damage + damage (int): Amount of damage being taken + """ + defender.db.hp -= damage # Reduce defender's HP by the damage dealt. + # If this reduces it to 0 or less, set HP to 0. + if defender.db.hp <= 0: + defender.db.hp = 0 + +def at_defeat(defeated): + """ + Announces the defeat of a fighter in combat. + + Args: + defeated (obj): Fighter that's been defeated. + + Notes: + All this does is announce a defeat message by default, but if you + want anything else to happen to defeated fighters (like putting them + into a dying state or something similar) then this is the place to + do it. + """ + defeated.location.msg_contents("%s has been defeated!" % defeated) + +def resolve_attack(attacker, defender, attack_value=None, defense_value=None): + """ + Resolves an attack and outputs the result. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Notes: + Even though the attack and defense values are calculated + extremely simply, they are separated out into their own functions + so that they are easier to expand upon. + """ + # Get an attack roll from the attacker. + if not attack_value: + attack_value = get_attack(attacker, defender) + # Get a defense value from the defender. + if not defense_value: + defense_value = get_defense(attacker, defender) + # If the attack value is lower than the defense value, miss. Otherwise, hit. + if attack_value < defense_value: + attacker.location.msg_contents("%s's attack misses %s!" % (attacker, defender)) + else: + damage_value = get_damage(attacker, defender) # Calculate damage value. + # Announce damage dealt and apply damage. + attacker.location.msg_contents("%s hits %s for %i damage!" % (attacker, defender, damage_value)) + apply_damage(defender, damage_value) + # If defender HP is reduced to 0 or less, call at_defeat. + if defender.db.hp <= 0: + at_defeat(defender) + +def combat_cleanup(character): + """ + Cleans up all the temporary combat-related attributes on a character. + + Args: + character (obj): Character to have their combat attributes removed + + Notes: + Any attribute whose key begins with 'combat_' is temporary and no + longer needed once a fight ends. + """ + for attr in character.attributes.all(): + if attr.key[:7] == "combat_": # If the attribute name starts with 'combat_'... + character.attributes.remove(key=attr.key) # ...then delete it! + + +def is_in_combat(character): + """ + Returns true if the given character is in combat. + + Args: + character (obj): Character to determine if is in combat or not + + Returns: + (bool): True if in combat or False if not in combat + """ + return bool(character.db.combat_turnhandler) + + +def is_turn(character): + """ + Returns true if it's currently the given character's turn in combat. + + Args: + character (obj): Character to determine if it is their turn or not + + Returns: + (bool): True if it is their turn or False otherwise + """ + turnhandler = character.db.combat_turnhandler + currentchar = turnhandler.db.fighters[turnhandler.db.turn] + return bool(character == currentchar) + + +def spend_action(character, actions, action_name=None): + """ + Spends a character's available combat actions and checks for end of turn. + + Args: + character (obj): Character spending the action + actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions + + Kwargs: + action_name (str or None): If a string is given, sets character's last action in + combat to provided string + """ + if 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 TBBasicCharacter(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 + +""" +---------------------------------------------------------------------------- +SCRIPTS START HERE +---------------------------------------------------------------------------- +""" + + +class TBBasicTurnHandler(DefaultScript): + """ + This is the script that handles the progression of combat through turns. + On creation (when a fight is started) it adds all combat-ready characters + to its roster and then sorts them into a turn order. There can only be one + fight going on in a single room at a time, so the script is assigned to a + room as its object. + + Fights persist until only one participant is left with any HP or all + remaining participants choose to end the combat with the 'disengage' command. + """ + + def at_script_creation(self): + """ + Called once, when the script is created. + """ + self.key = "Combat Turn Handler" + self.interval = 5 # Once every 5 seconds + self.persistent = True + self.db.fighters = [] + + # Add all fighters in the room with at least 1 HP to the combat." + for thing in self.obj.contents: + if thing.db.hp: + self.db.fighters.append(thing) + + # Initialize each fighter for combat + for fighter in self.db.fighters: + self.initialize_for_combat(fighter) + + # Add a reference to this script to the room + self.obj.db.combat_turnhandler = self + + # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order. + # The initiative roll is determined by the roll_init function and can be customized easily. + ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True) + self.db.fighters = ordered_by_roll + + # Announce the turn order. + self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters)) + + # Start first fighter's turn. + self.start_turn(self.db.fighters[0]) + + # Set up the current turn and turn timeout delay. + self.db.turn = 0 + self.db.timer = TURN_TIMEOUT # Set timer to turn timeout specified in options + + def at_stop(self): + """ + Called at script termination. + """ + for fighter in self.db.fighters: + combat_cleanup(fighter) # Clean up the combat attributes for every fighter. + self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location + + def at_repeat(self): + """ + Called once every self.interval seconds. + """ + currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order. + self.db.timer -= self.interval # Count down the timer. + + if self.db.timer <= 0: + # Force current character to disengage if timer runs out. + self.obj.msg_contents("%s's turn timed out!" % currentchar) + spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions. + return + elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left + # Warn the current character if they're about to time out. + currentchar.msg("WARNING: About to time out!") + self.db.timeout_warning_given = True + + def initialize_for_combat(self, character): + """ + Prepares a character for combat when starting or entering a fight. + + Args: + character (obj): Character to initialize for combat. + """ + combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. + character.db.combat_actionsleft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + character.db.combat_turnhandler = self # Add a reference to this turn handler script to the character + character.db.combat_lastaction = "null" # Track last action taken in combat + + def start_turn(self, character): + """ + Readies a character for the start of their turn by replenishing their + available actions and notifying them that their turn has come up. + + Args: + character (obj): Character to be readied. + + Notes: + Here, you only get one action per turn, but you might want to allow more than + one per turn, or even grant a number of actions based on a character's + attributes. You can even add multiple different kinds of actions, I.E. actions + separated for movement, by adding "character.db.combat_movesleft = 3" or + something similar. + """ + character.db.combat_actionsleft = ACTIONS_PER_TURN # Replenish actions + # Prompt the character for their turn and give some information. + character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp) + + def next_turn(self): + """ + Advances to the next character in the turn order. + """ + + # Check to see if every character disengaged as their last action. If so, end combat. + disengage_check = True + for fighter in self.db.fighters: + if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage + disengage_check = False + if disengage_check: # All characters have disengaged + self.obj.msg_contents("All fighters have disengaged! Combat is over!") + self.stop() # Stop this script and end combat. + return + + # Check to see if only one character is left standing. If so, end combat. + defeated_characters = 0 + for fighter in self.db.fighters: + if fighter.db.HP == 0: + defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated) + if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated + for fighter in self.db.fighters: + if fighter.db.HP != 0: + LastStanding = fighter # Pick the one fighter left with HP remaining + self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding) + self.stop() # Stop this script and end combat. + return + + # Cycle to the next turn. + currentchar = self.db.fighters[self.db.turn] + self.db.turn += 1 # Go to the next in the turn order. + if self.db.turn > len(self.db.fighters) - 1: + self.db.turn = 0 # Go back to the first in the turn order once you reach the end. + newchar = self.db.fighters[self.db.turn] # Note the new character + self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer. + self.db.timeout_warning_given = False # Reset the timeout warning. + self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) + self.start_turn(newchar) # Start the new character's turn. + + def turn_end_check(self, character): + """ + Tests to see if a character's turn is over, and cycles to the next turn if it is. + + Args: + character (obj): Character to test for end of turn + """ + if not character.db.combat_actionsleft: # Character has no actions remaining + self.next_turn() + return + + def join_fight(self, character): + """ + Adds a new character to a fight already in progress. + + Args: + character (obj): Character to be added to the fight. + """ + # Inserts the fighter to the turn order, right behind whoever's turn it currently is. + self.db.fighters.insert(self.db.turn, character) + # Tick the turn counter forward one to compensate. + self.db.turn += 1 + # Initialize the character like you do at the start. + self.initialize_for_combat(character) + + +""" +---------------------------------------------------------------------------- +COMMANDS START HERE +---------------------------------------------------------------------------- +""" + + +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.TBBasicTurnHandler") + # 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()) From 35340f86c8ed658b2176e0b3ad324b10208c7777 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Wed, 15 Nov 2017 23:12:25 -0800 Subject: [PATCH 066/466] Added 'use' command, item functions, example items --- evennia/contrib/turnbattle/tb_items.py | 268 ++++++++++++++++++++++--- 1 file changed, 245 insertions(+), 23 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index d4883e4c99..70cedd7fda 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -1,41 +1,48 @@ """ -Simple turn-based combat system +Simple turn-based combat system with items and status effects 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 is a version of the 'turnbattle' combat system that includes status +effects and usable items, which can instill these status effects, cure +them, or do just about anything else. -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. +Status effects are stored on characters as a dictionary, where the key +is the name of the status effect and the value is a list of two items: +an integer representing the number of turns left until the status runs +out, and the character upon whose turn the condition timer is ticked +down. Unlike most combat-related attributes, conditions aren't wiped +once combat ends - if out of combat, they tick down in real time +instead. -To install and test, import this module's TBBasicCharacter object into +Items aren't given any sort of special typeclass - instead, whether or +not an object counts as an item is determined by its attributes. To make +an object into an item, it must have the attribute 'item_on_use', with +the value given as a callable - this is the function that will be called +when an item is used. Other properties of the item, such as how many +uses it has, whether it's destroyed when its uses are depleted, and such +can be specified on the item as well, but they are optional. + +To install and test, import this module's TBItemsCharacter object into your game's character.py module: - from evennia.contrib.turnbattle.tb_basic import TBBasicCharacter + from evennia.contrib.turnbattle.tb_items import TBItemsCharacter -And change your game's character typeclass to inherit from TBBasicCharacter +And change your game's character typeclass to inherit from TBItemsCharacter instead of the default: - class Character(TBBasicCharacter): + class Character(TBItemsCharacter): Next, import this module into your default_cmdsets.py module: - from evennia.contrib.turnbattle import tb_basic + from evennia.contrib.turnbattle import tb_items And add the battle command set to your default command set: # # any commands you add below will overload the default ones. # - self.add(tb_basic.BattleCmdSet()) + self.add(tb_items.BattleCmdSet()) This module is meant to be heavily expanded on, so you may want to copy it to your game's 'world' folder and modify it there rather than importing it @@ -44,7 +51,9 @@ in your game and using it as-is. from random import randint from evennia import DefaultCharacter, Command, default_cmds, DefaultScript +from evennia.commands.default.muxcommand import MuxCommand from evennia.commands.default.help import CmdHelp +from evennia.utils.spawner import spawn """ ---------------------------------------------------------------------------- @@ -190,7 +199,7 @@ def at_defeat(defeated): """ defeated.location.msg_contents("%s has been defeated!" % defeated) -def resolve_attack(attacker, defender, attack_value=None, defense_value=None): +def resolve_attack(attacker, defender, attack_value=None, defense_value=None, damage_value=None): """ Resolves an attack and outputs the result. @@ -213,7 +222,8 @@ def resolve_attack(attacker, defender, attack_value=None, defense_value=None): 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. + if not damage_value: + damage_value = get_damage(attacker, defender) # Calculate damage value. # Announce damage dealt and apply damage. attacker.location.msg_contents("%s hits %s for %i damage!" % (attacker, defender, damage_value)) apply_damage(defender, damage_value) @@ -287,6 +297,33 @@ 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. +def spend_item_use(item): + """ + Spends one use on an item with limited uses. If item.db.item_consumable + is 'True', the item is destroyed if it runs out of uses - if it's a string + instead of 'True', it will also spawn a new object as residue, using the + value of item.db.item_consumable as the name of the prototype to spawn. + + + """ + if item.db.item_uses: + item.db.item_uses -= 1 # Spend one use + if item.db.item_uses > 0: # Has uses remaining + # Inform th eplayer + self.caller.msg("%s has %i uses remaining." % (item.key.capitalize(), item.db.item_uses)) + else: # All uses spent + if not item.db.item_consumable: + # If not consumable, just inform the player that the uses are gone + self.caller.msg("%s has no uses remaining." % item.key.capitalize()) + else: # If consumable + if item.db.item_consumable == True: # If the value is 'True', just destroy the item + self.caller.msg("%s has been consumed." % item.key.capitalize()) + item.delete() # Delete the spent item + else: # If a string, use value of item_consumable to spawn an object in its place + residue = spawn({"prototype":item.db.item_consumable})[0] # Spawn the residue + residue.location = item.location # Move the residue to the same place as the item + self.caller.msg("After using %s, you are left with %s." % (item, residue)) + item.delete() # Delete the spent item """ ---------------------------------------------------------------------------- @@ -295,7 +332,7 @@ CHARACTER TYPECLASS """ -class TBBasicCharacter(DefaultCharacter): +class TBItemsCharacter(DefaultCharacter): """ A character able to participate in turn-based combat. Has attributes for current and maximum HP, and access to combat commands. @@ -348,7 +385,7 @@ SCRIPTS START HERE """ -class TBBasicTurnHandler(DefaultScript): +class TBItemsTurnHandler(DefaultScript): """ This is the script that handles the progression of combat through turns. On creation (when a fight is started) it adds all combat-ready characters @@ -563,7 +600,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.TBBasicTurnHandler") + here.scripts.add("contrib.turnbattle.tb_items.TBItemsTurnHandler") # Remember you'll have to change the path to the script if you copy this code to your own modules! @@ -736,6 +773,73 @@ class CmdCombatHelp(CmdHelp): super(CmdCombatHelp, self).func() # Call the default help command +class CmdUse(MuxCommand): + """ + Use an item. + + Usage: + use [= target] + + Items: you just GOTTA use them. + """ + + key = "use" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + item = self.caller.search(self.lhs, candidates=self.caller.contents) + if not item: + return + + target = None + if self.rhs: + target = self.caller.search(self.rhs) + if not target: + return + + if is_in_combat(self.caller): + if not is_turn(self.caller): + self.caller.msg("You can only use items on your turn.") + return + + if not item.db.item_func: # Object has no item_func, not usable + self.caller.msg("'%s' is not a usable item." % item.key.capitalize()) + return + + if item.attributes.has("item_uses"): # Item has limited uses + if item.db.item_uses <= 0: # Limited uses are spent + self.caller.msg("'%s' has no uses remaining." % item.key.capitalize()) + return + + kwargs = {} + if item.db.item_kwargs: + kwargs = item.db.item_kwargs # Set kwargs to pass to item_func + + # Match item_func string to function + try: + item_func = ITEMFUNCS[item.db.item_func] + except KeyError: + self.caller.msg("ERROR: %s not defined in ITEMFUNCS" % item.db.item_func) + return + + # Call the item function - abort if it returns False, indicating an error. + # Regardless of what the function returns (if anything), it's still executed. + if item_func(item, self.caller, target, **kwargs) == False: + return + + # If we haven't returned yet, we assume the item was used successfully. + + # Spend one use if item has limited uses + spend_item_use(item) + + # Spend an action if in combat + if is_in_combat(self.caller): + spend_action(self.caller, 1, action_name="item") + + class BattleCmdSet(default_cmds.CharacterCmdSet): """ This command set includes all the commmands used in the battle system. @@ -752,3 +856,121 @@ class BattleCmdSet(default_cmds.CharacterCmdSet): self.add(CmdPass()) self.add(CmdDisengage()) self.add(CmdCombatHelp()) + self.add(CmdUse()) + +""" +ITEM FUNCTIONS START HERE +""" + +def itemfunc_heal(item, user, target, **kwargs): + """ + Item function that heals HP. + """ + if not target: + target = user # Target user if none specified + + if not target.attributes.has("max_hp"): # Has no HP to speak of + user.msg("You can't use %s on that." % item) + return False + + if target.db.hp >= target.db.max_hp: + user.msg("%s is already at full health." % target) + return False + + min_healing = 20 + max_healing = 40 + + # Retrieve healing range from kwargs, if present + if "healing_range" in kwargs: + min_healing = kwargs["healing_range"][0] + max_healing = kwargs["healing_range"][1] + + to_heal = randint(min_healing, max_healing) # Restore 20 to 40 hp + if target.db.hp + to_heal > target.db.max_hp: + to_heal = target.db.max_hp - target.db.hp # Cap healing to max HP + target.db.hp += to_heal + + user.location.msg_contents("%s uses %s! %s regains %i HP!" % (user, item, target, to_heal)) + +def itemfunc_attack(item, user, target, **kwargs): + """ + Item function that attacks a target. + """ + if not is_in_combat(user): + user.msg("You can only use that in combat.") + return False + + if not target: + user.msg("You have to specify a target to use %s! (use = )" % item) + return False + + if target == user: + user.msg("You can't attack yourself!") + return False + + if not target.db.hp: # Has no HP + user.msg("You can't use %s on that." % item) + return False + + min_damage = 20 + max_damage = 40 + accuracy = 0 + + # Retrieve values from kwargs, if present + if "damage_range" in kwargs: + min_damage = kwargs["damage_range"][0] + max_damage = kwargs["damage_range"][1] + if "accuracy" in kwargs: + accuracy = kwargs["accuracy"] + + # Roll attack and damage + attack_value = randint(1, 100) + accuracy + damage_value = randint(min_damage, max_damage) + + user.location.msg_contents("%s attacks %s with %s!" % (user, target, item)) + resolve_attack(user, target, attack_value=attack_value, damage_value=damage_value) + +# Match strings to item functions here. We can't store callables on +# prototypes, so we store a string instead, matching that string to +# a callable in this dictionary. +ITEMFUNCS = { + "heal":itemfunc_heal, + "attack":itemfunc_attack +} + +""" +ITEM PROTOTYPES START HERE +""" + +MEDKIT = { + "key" : "a medical kit", + "aliases" : ["medkit"], + "desc" : "A standard medical kit. It can be used a few times to heal wounds.", + "item_func" : "heal", + "item_uses" : 3, + "item_consumable" : True, + "item_kwargs" : {"healing_range":(15, 25)} +} + +GLASS_BOTTLE = { + "key" : "a glass bottle", + "desc" : "An empty glass bottle." +} + +HEALTH_POTION = { + "key" : "a health potion", + "desc" : "A glass bottle full of a mystical potion that heals wounds when used.", + "item_func" : "heal", + "item_uses" : 1, + "item_consumable" : "GLASS_BOTTLE", + "item_kwargs" : {"healing_range":(35, 50)} +} + +BOMB = { + "key" : "a rotund bomb", + "desc" : "A large black sphere with a fuse at the end. Can be used on enemies in combat.", + "item_func" : "attack", + "item_uses" : 1, + "item_consumable" : True, + "item_kwargs" : {"damage_range":(25, 40), "accuracy":25} +} \ No newline at end of file From 5ce18379c0e36db8f8ecf0c15fa3eecdde855152 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Thu, 16 Nov 2017 00:15:20 -0800 Subject: [PATCH 067/466] Proper implementation of spend_item_use() --- evennia/contrib/turnbattle/tb_items.py | 36 ++++++++++++++------------ 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index 70cedd7fda..b99fba599d 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -17,7 +17,7 @@ instead. Items aren't given any sort of special typeclass - instead, whether or not an object counts as an item is determined by its attributes. To make -an object into an item, it must have the attribute 'item_on_use', with +an object into an item, it must have the attribute 'item_func', with the value given as a callable - this is the function that will be called when an item is used. Other properties of the item, such as how many uses it has, whether it's destroyed when its uses are depleted, and such @@ -297,32 +297,30 @@ 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. -def spend_item_use(item): +def spend_item_use(item, user): """ Spends one use on an item with limited uses. If item.db.item_consumable is 'True', the item is destroyed if it runs out of uses - if it's a string instead of 'True', it will also spawn a new object as residue, using the value of item.db.item_consumable as the name of the prototype to spawn. - - """ if item.db.item_uses: item.db.item_uses -= 1 # Spend one use if item.db.item_uses > 0: # Has uses remaining - # Inform th eplayer - self.caller.msg("%s has %i uses remaining." % (item.key.capitalize(), item.db.item_uses)) + # Inform the player + user.msg("%s has %i uses remaining." % (item.key.capitalize(), item.db.item_uses)) else: # All uses spent if not item.db.item_consumable: # If not consumable, just inform the player that the uses are gone - self.caller.msg("%s has no uses remaining." % item.key.capitalize()) + user.msg("%s has no uses remaining." % item.key.capitalize()) else: # If consumable if item.db.item_consumable == True: # If the value is 'True', just destroy the item - self.caller.msg("%s has been consumed." % item.key.capitalize()) + user.msg("%s has been consumed." % item.key.capitalize()) item.delete() # Delete the spent item else: # If a string, use value of item_consumable to spawn an object in its place residue = spawn({"prototype":item.db.item_consumable})[0] # Spawn the residue residue.location = item.location # Move the residue to the same place as the item - self.caller.msg("After using %s, you are left with %s." % (item, residue)) + user.msg("After using %s, you are left with %s." % (item, residue)) item.delete() # Delete the spent item """ @@ -790,16 +788,19 @@ class CmdUse(MuxCommand): """ This performs the actual command. """ + # Search for item item = self.caller.search(self.lhs, candidates=self.caller.contents) if not item: return + # Search for target, if any is given target = None if self.rhs: target = self.caller.search(self.rhs) if not target: return - + + # If in combat, can only use items on your turn if is_in_combat(self.caller): if not is_turn(self.caller): self.caller.msg("You can only use items on your turn.") @@ -814,9 +815,10 @@ class CmdUse(MuxCommand): self.caller.msg("'%s' has no uses remaining." % item.key.capitalize()) return + # Set kwargs to pass to item_func kwargs = {} if item.db.item_kwargs: - kwargs = item.db.item_kwargs # Set kwargs to pass to item_func + kwargs = item.db.item_kwargs # Match item_func string to function try: @@ -826,14 +828,14 @@ class CmdUse(MuxCommand): return # Call the item function - abort if it returns False, indicating an error. + # This performs the actual action of using the item. # Regardless of what the function returns (if anything), it's still executed. if item_func(item, self.caller, target, **kwargs) == False: return # If we haven't returned yet, we assume the item was used successfully. - # Spend one use if item has limited uses - spend_item_use(item) + spend_item_use(item, self.caller) # Spend an action if in combat if is_in_combat(self.caller): @@ -871,7 +873,7 @@ def itemfunc_heal(item, user, target, **kwargs): if not target.attributes.has("max_hp"): # Has no HP to speak of user.msg("You can't use %s on that." % item) - return False + return False # Returning false aborts the item use if target.db.hp >= target.db.max_hp: user.msg("%s is already at full health." % target) @@ -898,7 +900,7 @@ def itemfunc_attack(item, user, target, **kwargs): """ if not is_in_combat(user): user.msg("You can only use that in combat.") - return False + return False # Returning false aborts the item use if not target: user.msg("You have to specify a target to use %s! (use = )" % item) @@ -906,7 +908,7 @@ def itemfunc_attack(item, user, target, **kwargs): if target == user: user.msg("You can't attack yourself!") - return False + return False if not target.db.hp: # Has no HP user.msg("You can't use %s on that." % item) @@ -940,6 +942,8 @@ ITEMFUNCS = { """ ITEM PROTOTYPES START HERE + +Copy these to your game's /world/prototypes.py module! """ MEDKIT = { From 91c333e6d3c75d136ad3b684dc0dd2439394e33b Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Fri, 17 Nov 2017 16:46:27 -0800 Subject: [PATCH 068/466] Move some item logic from CmdUse to new func use_item --- evennia/contrib/turnbattle/tb_items.py | 57 +++++++++++++++----------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index b99fba599d..94ab4bacea 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -322,6 +322,36 @@ def spend_item_use(item, user): residue.location = item.location # Move the residue to the same place as the item user.msg("After using %s, you are left with %s." % (item, residue)) item.delete() # Delete the spent item + +def use_item(user, item, target): + """ + Performs the action of using an item. + """ + # Set kwargs to pass to item_func + kwargs = {} + if item.db.item_kwargs: + kwargs = item.db.item_kwargs + + # Match item_func string to function + try: + item_func = ITEMFUNCS[item.db.item_func] + except KeyError: + user.msg("ERROR: %s not defined in ITEMFUNCS" % item.db.item_func) + return + + # Call the item function - abort if it returns False, indicating an error. + # This performs the actual action of using the item. + # Regardless of what the function returns (if anything), it's still executed. + if item_func(item, user, target, **kwargs) == False: + return + + # If we haven't returned yet, we assume the item was used successfully. + # Spend one use if item has limited uses + spend_item_use(item, user) + + # Spend an action if in combat + if is_in_combat(user): + spend_action(user, 1, action_name="item") """ ---------------------------------------------------------------------------- @@ -815,31 +845,8 @@ class CmdUse(MuxCommand): self.caller.msg("'%s' has no uses remaining." % item.key.capitalize()) return - # Set kwargs to pass to item_func - kwargs = {} - if item.db.item_kwargs: - kwargs = item.db.item_kwargs - - # Match item_func string to function - try: - item_func = ITEMFUNCS[item.db.item_func] - except KeyError: - self.caller.msg("ERROR: %s not defined in ITEMFUNCS" % item.db.item_func) - return - - # Call the item function - abort if it returns False, indicating an error. - # This performs the actual action of using the item. - # Regardless of what the function returns (if anything), it's still executed. - if item_func(item, self.caller, target, **kwargs) == False: - return - - # If we haven't returned yet, we assume the item was used successfully. - # Spend one use if item has limited uses - spend_item_use(item, self.caller) - - # Spend an action if in combat - if is_in_combat(self.caller): - spend_action(self.caller, 1, action_name="item") + # If everything checks out, call the use_item function + use_item(self.caller, item, target) class BattleCmdSet(default_cmds.CharacterCmdSet): From e61df0a400e8952c9b3c0c352cd36fda5d3e5804 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Fri, 17 Nov 2017 21:17:35 -0800 Subject: [PATCH 069/466] Start porting in condition code from coolbattles --- evennia/contrib/turnbattle/tb_items.py | 78 +++++++++++++++++++------- 1 file changed, 58 insertions(+), 20 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index 94ab4bacea..8f33e717b1 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -304,24 +304,28 @@ def spend_item_use(item, user): instead of 'True', it will also spawn a new object as residue, using the value of item.db.item_consumable as the name of the prototype to spawn. """ - if item.db.item_uses: - item.db.item_uses -= 1 # Spend one use - if item.db.item_uses > 0: # Has uses remaining - # Inform the player - user.msg("%s has %i uses remaining." % (item.key.capitalize(), item.db.item_uses)) - else: # All uses spent - if not item.db.item_consumable: - # If not consumable, just inform the player that the uses are gone - user.msg("%s has no uses remaining." % item.key.capitalize()) - else: # If consumable - if item.db.item_consumable == True: # If the value is 'True', just destroy the item - user.msg("%s has been consumed." % item.key.capitalize()) - item.delete() # Delete the spent item - else: # If a string, use value of item_consumable to spawn an object in its place - residue = spawn({"prototype":item.db.item_consumable})[0] # Spawn the residue - residue.location = item.location # Move the residue to the same place as the item - user.msg("After using %s, you are left with %s." % (item, residue)) - item.delete() # Delete the spent item + item.db.item_uses -= 1 # Spend one use + + if item.db.item_uses > 0: # Has uses remaining + # Inform the player + user.msg("%s has %i uses remaining." % (item.key.capitalize(), item.db.item_uses)) + + else: # All uses spent + + if not item.db.item_consumable: # Item isn't consumable + # Just inform the player that the uses are gone + user.msg("%s has no uses remaining." % item.key.capitalize()) + + else: # If item is consumable + if item.db.item_consumable == True: # If the value is 'True', just destroy the item + user.msg("%s has been consumed." % item.key.capitalize()) + item.delete() # Delete the spent item + + else: # If a string, use value of item_consumable to spawn an object in its place + residue = spawn({"prototype":item.db.item_consumable})[0] # Spawn the residue + residue.location = item.location # Move the residue to the same place as the item + user.msg("After using %s, you are left with %s." % (item, residue)) + item.delete() # Delete the spent item def use_item(user, item, target): """ @@ -347,11 +351,38 @@ def use_item(user, item, target): # If we haven't returned yet, we assume the item was used successfully. # Spend one use if item has limited uses - spend_item_use(item, user) + if item.db.item_uses: + spend_item_use(item, user) # Spend an action if in combat if is_in_combat(user): spend_action(user, 1, action_name="item") + +def condition_tickdown(character, turnchar): + """ + Ticks down the duration of conditions on a character at the end of a given character's turn. + """ + + for key in character.db.conditions: + # The first value is the remaining turns - the second value is whose turn to count down on. + condition_duration = character.db.conditions[key][0] + condition_turnchar = character.db.conditions[key][1] + # Count down if the given turn character matches the condition's turn character. + if condition_turnchar == turnchar: + character.db.conditions[key][0] -= 1 + if character.db.conditions[key][0] <= 0: + # If the duration is brought down to 0, remove the condition and inform everyone. + character.location.msg_contents("%s no longer has the '%s' condition." % (str(character), str(key))) + del character.db.conditions[key] + +def add_condition(character, turnchar, condition, duration): + """ + Adds a condition to a fighter. + """ + # The first value is the remaining turns - the second value is whose turn to count down on. + character.db.conditions.update({condition:[duration, turnchar]}) + # Tell everyone! + character.location.msg_contents("%s gains the '%s' condition." % (character, condition)) """ ---------------------------------------------------------------------------- @@ -373,6 +404,7 @@ class TBItemsCharacter(DefaultCharacter): """ self.db.max_hp = 100 # Set maximum HP to 100 self.db.hp = self.db.max_hp # Set current HP to maximum + self.db.conditions = {} # Set empty dict for conditions """ Adds attributes for a character's current and maximum HP. We're just going to set this value at '100' by default. @@ -550,6 +582,11 @@ class TBItemsTurnHandler(DefaultScript): 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. + + # Count down condition timers. + for fighter in self.db.fighters: + condition_tickdown(fighter, newchar) + newchar = self.db.fighters[self.db.turn] # Note the new character self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer. self.db.timeout_warning_given = False # Reset the timeout warning. @@ -796,7 +833,8 @@ class CmdCombatHelp(CmdHelp): 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.|/") + "|wDisengage:|n End your turn and attempt to end combat.|/" + + "|wUse:|n Use an item you're carrying.") else: super(CmdCombatHelp, self).func() # Call the default help command From ae060ecc775dea948b5fcac0b4d8c30942e4ee86 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Fri, 17 Nov 2017 21:19:02 -0800 Subject: [PATCH 070/466] Fix weird spacing in use_item() --- evennia/contrib/turnbattle/tb_items.py | 56 +++++++++++++------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index 8f33e717b1..ea3eb89fc6 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -328,35 +328,35 @@ def spend_item_use(item, user): item.delete() # Delete the spent item def use_item(user, item, target): - """ - Performs the action of using an item. - """ - # Set kwargs to pass to item_func - kwargs = {} - if item.db.item_kwargs: - kwargs = item.db.item_kwargs - - # Match item_func string to function - try: - item_func = ITEMFUNCS[item.db.item_func] - except KeyError: - user.msg("ERROR: %s not defined in ITEMFUNCS" % item.db.item_func) - return + """ + Performs the action of using an item. + """ + # Set kwargs to pass to item_func + kwargs = {} + if item.db.item_kwargs: + kwargs = item.db.item_kwargs - # Call the item function - abort if it returns False, indicating an error. - # This performs the actual action of using the item. - # Regardless of what the function returns (if anything), it's still executed. - if item_func(item, user, target, **kwargs) == False: - return - - # If we haven't returned yet, we assume the item was used successfully. - # Spend one use if item has limited uses - if item.db.item_uses: - spend_item_use(item, user) - - # Spend an action if in combat - if is_in_combat(user): - spend_action(user, 1, action_name="item") + # Match item_func string to function + try: + item_func = ITEMFUNCS[item.db.item_func] + except KeyError: + user.msg("ERROR: %s not defined in ITEMFUNCS" % item.db.item_func) + return + + # Call the item function - abort if it returns False, indicating an error. + # This performs the actual action of using the item. + # Regardless of what the function returns (if anything), it's still executed. + if item_func(item, user, target, **kwargs) == False: + return + + # If we haven't returned yet, we assume the item was used successfully. + # Spend one use if item has limited uses + if item.db.item_uses: + spend_item_use(item, user) + + # Spend an action if in combat + if is_in_combat(user): + spend_action(user, 1, action_name="item") def condition_tickdown(character, turnchar): """ From 44c75d8d18254ea1c0a5c6f3a8173c247aa91cf4 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sat, 18 Nov 2017 13:22:03 -0800 Subject: [PATCH 071/466] Added functional condition, TickerHandler countdown --- evennia/contrib/turnbattle/tb_items.py | 92 +++++++++++++++++++++++--- 1 file changed, 84 insertions(+), 8 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index ea3eb89fc6..8457a13bd6 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -54,6 +54,7 @@ from evennia import DefaultCharacter, Command, default_cmds, DefaultScript from evennia.commands.default.muxcommand import MuxCommand from evennia.commands.default.help import CmdHelp from evennia.utils.spawner import spawn +from evennia import TICKER_HANDLER as tickerhandler """ ---------------------------------------------------------------------------- @@ -360,7 +361,7 @@ def use_item(user, item, target): def condition_tickdown(character, turnchar): """ - Ticks down the duration of conditions on a character at the end of a given character's turn. + Ticks down the duration of conditions on a character at the start of a given character's turn. """ for key in character.db.conditions: @@ -405,6 +406,8 @@ class TBItemsCharacter(DefaultCharacter): self.db.max_hp = 100 # Set maximum HP to 100 self.db.hp = self.db.max_hp # Set current HP to maximum self.db.conditions = {} # Set empty dict for conditions + # Subscribe character to the ticker handler + tickerhandler.add(30, self.at_update) """ Adds attributes for a character's current and maximum HP. We're just going to set this value at '100' by default. @@ -437,6 +440,42 @@ class TBItemsCharacter(DefaultCharacter): self.msg("You can't move, you've been defeated!") return False return True + + def at_turn_start(self): + """ + Hook called at the beginning of this character's turn in combat. + """ + # Prompt the character for their turn and give some information. + self.msg("|wIt's your turn! You have %i HP remaining.|n" % self.db.hp) + + # Apply conditions that fire at the start of each turn. + self.apply_turn_conditions() + + def apply_turn_conditions(self): + """ + Applies the effect of conditions that occur at the start of each + turn in combat, or every 30 seconds out of combat. + """ + if "Regeneration" in self.db.conditions: + to_heal = randint(4, 8) # Restore 4 to 8 HP + if self.db.hp + to_heal > self.db.max_hp: + to_heal = self.db.max_hp - self.db.hp # Cap healing to max HP + self.db.hp += to_heal + self.location.msg_contents("%s regains %i HP from Regeneration." % (self, to_heal)) + + def at_update(self): + """ + Fires every 30 seconds. + """ + # Change all conditions to update on character's turn. + for key in self.db.conditions: + self.db.conditions[key][1] = self + if not is_in_combat(self): # Not in combat + # Apply conditions that fire every turn + self.apply_turn_conditions() + # Tick down condition durations + condition_tickdown(self, self) + """ ---------------------------------------------------------------------------- @@ -546,8 +585,8 @@ class TBItemsTurnHandler(DefaultScript): something similar. """ character.db.combat_actionsleft = ACTIONS_PER_TURN # Replenish actions - # Prompt the character for their turn and give some information. - character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp) + # Call character's at_turn_start() hook. + character.at_turn_start() def next_turn(self): """ @@ -582,16 +621,17 @@ class TBItemsTurnHandler(DefaultScript): 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. - - # Count down condition timers. - for fighter in self.db.fighters: - condition_tickdown(fighter, newchar) newchar = self.db.fighters[self.db.turn] # Note the new character + self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer. self.db.timeout_warning_given = False # Reset the timeout warning. self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) self.start_turn(newchar) # Start the new character's turn. + + # Count down condition timers. + for fighter in self.db.fighters: + condition_tickdown(fighter, newchar) def turn_end_check(self, character): """ @@ -939,6 +979,32 @@ def itemfunc_heal(item, user, target, **kwargs): user.location.msg_contents("%s uses %s! %s regains %i HP!" % (user, item, target, to_heal)) +def itemfunc_add_condition(item, user, target, **kwargs): + """ + Item function that gives the target a condition. + + Should mostly be used for beneficial conditions - use itemfunc_attack + for an item that can give an enemy a harmful condition. + """ + condition = "Regeneration" + duration = 5 + + if not target: + target = user # Target user if none specified + + if not target.attributes.has("max_hp"): # Is not a fighter + user.msg("You can't use %s on that." % item) + return False # Returning false aborts the item use + + # Retrieve condition / duration from kwargs, if present + if "condition" in kwargs: + condition = kwargs["condition"] + if "duration" in kwargs: + duration = kwargs["duration"] + + user.location.msg_contents("%s uses %s!" % (user, item)) + add_condition(target, user, condition, duration) # Add condition to the target + def itemfunc_attack(item, user, target, **kwargs): """ Item function that attacks a target. @@ -982,7 +1048,8 @@ def itemfunc_attack(item, user, target, **kwargs): # a callable in this dictionary. ITEMFUNCS = { "heal":itemfunc_heal, - "attack":itemfunc_attack + "attack":itemfunc_attack, + "add_condition":itemfunc_add_condition } """ @@ -1015,6 +1082,15 @@ HEALTH_POTION = { "item_kwargs" : {"healing_range":(35, 50)} } +REGEN_POTION = { + "key" : "a regeneration potion", + "desc" : "A glass bottle full of a mystical potion that regenerates wounds over time.", + "item_func" : "add_condition", + "item_uses" : 1, + "item_consumable" : "GLASS_BOTTLE", + "item_kwargs" : {"condition":"Regeneration", "duration":10} +} + BOMB = { "key" : "a rotund bomb", "desc" : "A large black sphere with a fuse at the end. Can be used on enemies in combat.", From 686b290b5da1709de4b2d1a31bfafed4890510d5 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sat, 18 Nov 2017 13:25:47 -0800 Subject: [PATCH 072/466] Fix condition ticking --- evennia/contrib/turnbattle/tb_items.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index 8457a13bd6..b7cc6dd808 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -473,8 +473,8 @@ class TBItemsCharacter(DefaultCharacter): if not is_in_combat(self): # Not in combat # Apply conditions that fire every turn self.apply_turn_conditions() - # Tick down condition durations - condition_tickdown(self, self) + # Tick down condition durations + condition_tickdown(self, self) """ From 9d7921fee5e159c99ad2abbabdaa6e5236a90ed8 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sat, 18 Nov 2017 17:28:52 -0800 Subject: [PATCH 073/466] Add "Poisoned" condition, more condition items Added the ability for attack items to inflict conditions on hit, as well as items that can cure specific conditions. --- evennia/contrib/turnbattle/tb_items.py | 70 ++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index b7cc6dd808..22c619cbd8 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -200,7 +200,8 @@ def at_defeat(defeated): """ defeated.location.msg_contents("%s has been defeated!" % defeated) -def resolve_attack(attacker, defender, attack_value=None, defense_value=None, damage_value=None): +def resolve_attack(attacker, defender, attack_value=None, defense_value=None, + damage_value=None, inflict_condition=[]): """ Resolves an attack and outputs the result. @@ -228,6 +229,9 @@ def resolve_attack(attacker, defender, attack_value=None, defense_value=None, da # Announce damage dealt and apply damage. attacker.location.msg_contents("%s hits %s for %i damage!" % (attacker, defender, damage_value)) apply_damage(defender, damage_value) + # Inflict conditions on hit, if any specified + for condition in inflict_condition: + add_condition(defender, attacker, condition[0], condition[1]) # If defender HP is reduced to 0 or less, call at_defeat. if defender.db.hp <= 0: at_defeat(defender) @@ -456,12 +460,22 @@ class TBItemsCharacter(DefaultCharacter): Applies the effect of conditions that occur at the start of each turn in combat, or every 30 seconds out of combat. """ + # Regeneration: restores 4 to 8 HP at the start of character's turn if "Regeneration" in self.db.conditions: to_heal = randint(4, 8) # Restore 4 to 8 HP if self.db.hp + to_heal > self.db.max_hp: to_heal = self.db.max_hp - self.db.hp # Cap healing to max HP self.db.hp += to_heal self.location.msg_contents("%s regains %i HP from Regeneration." % (self, to_heal)) + + # Poisoned: does 4 to 8 damage at the start of character's turn + if "Poisoned" in self.db.conditions: + to_hurt = randint(4, 8) # Deal 4 to 8 damage + apply_damage(self, to_hurt) + self.location.msg_contents("%s takes %i damage from being Poisoned." % (self, to_hurt)) + if self.db.hp <= 0: + # Call at_defeat if poison defeats the character + at_defeat(self) def at_update(self): """ @@ -1005,6 +1019,33 @@ def itemfunc_add_condition(item, user, target, **kwargs): user.location.msg_contents("%s uses %s!" % (user, item)) add_condition(target, user, condition, duration) # Add condition to the target +def itemfunc_cure_condition(item, user, target, **kwargs): + """ + Item function that'll remove given conditions from a target. + """ + to_cure = ["Poisoned"] + + if not target: + target = user # Target user if none specified + + if not target.attributes.has("max_hp"): # Is not a fighter + user.msg("You can't use %s on that." % item) + return False # Returning false aborts the item use + + # Retrieve condition(s) to cure from kwargs, if present + if "to_cure" in kwargs: + to_cure = kwargs["to_cure"] + + item_msg = "%s uses %s! " % (user, item) + + for key in target.db.conditions: + if key in to_cure: + # If condition specified in to_cure, remove it. + item_msg += "%s no longer has the '%s' condition. " % (str(target), str(key)) + del target.db.conditions[key] + + user.location.msg_contents(item_msg) + def itemfunc_attack(item, user, target, **kwargs): """ Item function that attacks a target. @@ -1028,6 +1069,7 @@ def itemfunc_attack(item, user, target, **kwargs): min_damage = 20 max_damage = 40 accuracy = 0 + inflict_condition = [] # Retrieve values from kwargs, if present if "damage_range" in kwargs: @@ -1035,13 +1077,16 @@ def itemfunc_attack(item, user, target, **kwargs): max_damage = kwargs["damage_range"][1] if "accuracy" in kwargs: accuracy = kwargs["accuracy"] + if "inflict_condition" in kwargs: + inflict_condition = kwargs["inflict_condition"] # Roll attack and damage attack_value = randint(1, 100) + accuracy damage_value = randint(min_damage, max_damage) user.location.msg_contents("%s attacks %s with %s!" % (user, target, item)) - resolve_attack(user, target, attack_value=attack_value, damage_value=damage_value) + resolve_attack(user, target, attack_value=attack_value, + damage_value=damage_value, inflict_condition=inflict_condition) # Match strings to item functions here. We can't store callables on # prototypes, so we store a string instead, matching that string to @@ -1049,7 +1094,8 @@ def itemfunc_attack(item, user, target, **kwargs): ITEMFUNCS = { "heal":itemfunc_heal, "attack":itemfunc_attack, - "add_condition":itemfunc_add_condition + "add_condition":itemfunc_add_condition, + "cure_condition":itemfunc_cure_condition } """ @@ -1098,4 +1144,22 @@ BOMB = { "item_uses" : 1, "item_consumable" : True, "item_kwargs" : {"damage_range":(25, 40), "accuracy":25} +} + +POISON_DART = { + "key" : "a poison dart", + "desc" : "A thin dart coated in deadly poison. Can be used on enemies in combat", + "item_func" : "attack", + "item_uses" : 1, + "item_consumable" : True, + "item_kwargs" : {"damage_range":(5, 10), "accuracy":25, "inflict_condition":[("Poisoned", 10)]} +} + +ANTIDOTE_POTION = { + "key" : "an antidote potion", + "desc" : "A glass bottle full of a mystical potion that cures poison when used.", + "item_func" : "cure_condition", + "item_uses" : 1, + "item_consumable" : "GLASS_BOTTLE", + "item_kwargs" : {"to_cure":["Poisoned"]} } \ No newline at end of file From 7a933425f3e1c6ed215796d228e48f5ec688e729 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sat, 18 Nov 2017 18:02:54 -0800 Subject: [PATCH 074/466] More documentation, 'True' duration for indefinite conditions --- evennia/contrib/turnbattle/tb_items.py | 71 ++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index 22c619cbd8..1ab0e1d324 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -372,13 +372,15 @@ def condition_tickdown(character, turnchar): # The first value is the remaining turns - the second value is whose turn to count down on. condition_duration = character.db.conditions[key][0] condition_turnchar = character.db.conditions[key][1] - # Count down if the given turn character matches the condition's turn character. - if condition_turnchar == turnchar: - character.db.conditions[key][0] -= 1 - if character.db.conditions[key][0] <= 0: - # If the duration is brought down to 0, remove the condition and inform everyone. - character.location.msg_contents("%s no longer has the '%s' condition." % (str(character), str(key))) - del character.db.conditions[key] + # If the duration is 'True', then condition doesn't tick down - it lasts indefinitely. + if not condition_duration == True: + # Count down if the given turn character matches the condition's turn character. + if condition_turnchar == turnchar: + character.db.conditions[key][0] -= 1 + if character.db.conditions[key][0] <= 0: + # If the duration is brought down to 0, remove the condition and inform everyone. + character.location.msg_contents("%s no longer has the '%s' condition." % (str(character), str(key))) + del character.db.conditions[key] def add_condition(character, turnchar, condition, duration): """ @@ -960,12 +962,35 @@ class BattleCmdSet(default_cmds.CharacterCmdSet): self.add(CmdUse()) """ +---------------------------------------------------------------------------- ITEM FUNCTIONS START HERE +---------------------------------------------------------------------------- + +These functions carry out the action of using an item - every item should +contain a db entry "item_func", with its value being a string that is +matched to one of these functions in the ITEMFUNCS dictionary below. + +Every item function must take the following arguments: + item (obj): The item being used + user (obj): The character using the item + target (obj): The target of the item use + +Item functions must also accept **kwargs - these keyword arguments can be +used to define how different items that use the same function can have +different effects (for example, different attack items doing different +amounts of damage). + +Each function below contains a description of what kwargs the function will +take and the effect they have on the result. """ def itemfunc_heal(item, user, target, **kwargs): """ Item function that heals HP. + + kwargs: + min_healing(int): Minimum amount of HP recovered + max_healing(int): Maximum amount of HP recovered """ if not target: target = user # Target user if none specified @@ -997,8 +1022,13 @@ def itemfunc_add_condition(item, user, target, **kwargs): """ Item function that gives the target a condition. - Should mostly be used for beneficial conditions - use itemfunc_attack - for an item that can give an enemy a harmful condition. + kwargs: + condition(str): Condition added by the item + duration(int): Number of turns the condition lasts, or True for indefinite + + Notes: + Should mostly be used for beneficial conditions - use itemfunc_attack + for an item that can give an enemy a harmful condition. """ condition = "Regeneration" duration = 5 @@ -1022,6 +1052,9 @@ def itemfunc_add_condition(item, user, target, **kwargs): def itemfunc_cure_condition(item, user, target, **kwargs): """ Item function that'll remove given conditions from a target. + + kwargs: + to_cure(list): List of conditions (str) that the item cures when used """ to_cure = ["Poisoned"] @@ -1049,6 +1082,17 @@ def itemfunc_cure_condition(item, user, target, **kwargs): def itemfunc_attack(item, user, target, **kwargs): """ Item function that attacks a target. + + kwargs: + min_damage(int): Minimum damage dealt by the attack + max_damage(int): Maximum damage dealth by the attack + accuracy(int): Bonus / penalty to attack accuracy roll + inflict_condition(list): List of conditions inflicted on hit, + formatted as a (str, int) tuple containing condition name + and duration. + + Notes: + Calls resolve_attack at the end. """ if not is_in_combat(user): user.msg("You can only use that in combat.") @@ -1099,9 +1143,14 @@ ITEMFUNCS = { } """ -ITEM PROTOTYPES START HERE +---------------------------------------------------------------------------- +PROTOTYPES START HERE +---------------------------------------------------------------------------- -Copy these to your game's /world/prototypes.py module! +You can paste these prototypes into your game's prototypes.py module in your +/world/ folder, and use the spawner to create them - they serve as examples +of items you can make and a handy way to demonstrate the system for +conditions as well. """ MEDKIT = { From 1d65a0a0cf202f822a623e34622edaf3a9dd6db2 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sat, 18 Nov 2017 18:43:14 -0800 Subject: [PATCH 075/466] More documentation, fix error in at_update() at_update() erroneously changed the turnchar on conditions during combat - this has been fixed. --- evennia/contrib/turnbattle/tb_items.py | 73 +++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 8 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index 1ab0e1d324..db5be80be8 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -304,10 +304,17 @@ def spend_action(character, actions, action_name=None): def spend_item_use(item, user): """ - Spends one use on an item with limited uses. If item.db.item_consumable - is 'True', the item is destroyed if it runs out of uses - if it's a string - instead of 'True', it will also spawn a new object as residue, using the - value of item.db.item_consumable as the name of the prototype to spawn. + Spends one use on an item with limited uses. + + Args: + item (obj): Item being used + user (obj): Character using the item + + Notes: + If item.db.item_consumable is 'True', the item is destroyed if it + runs out of uses - if it's a string instead of 'True', it will also + spawn a new object as residue, using the value of item.db.item_consumable + as the name of the prototype to spawn. """ item.db.item_uses -= 1 # Spend one use @@ -335,7 +342,17 @@ def spend_item_use(item, user): def use_item(user, item, target): """ Performs the action of using an item. + + Args: + user (obj): Character using the item + item (obj): Item being used + target (obj): Target of the item use """ + # If item is self only, abort use + if item.db.item_selfonly and user == target: + user.msg("%s can only be used on yourself." % item) + return + # Set kwargs to pass to item_func kwargs = {} if item.db.item_kwargs: @@ -344,7 +361,7 @@ def use_item(user, item, target): # Match item_func string to function try: item_func = ITEMFUNCS[item.db.item_func] - except KeyError: + except KeyError: # If item_func string doesn't match to a function in ITEMFUNCS user.msg("ERROR: %s not defined in ITEMFUNCS" % item.db.item_func) return @@ -366,6 +383,15 @@ def use_item(user, item, target): def condition_tickdown(character, turnchar): """ Ticks down the duration of conditions on a character at the start of a given character's turn. + + Args: + character (obj): Character to tick down the conditions of + turnchar (obj): Character whose turn it currently is + + Notes: + In combat, this is called on every fighter at the start of every character's turn. Out of + combat, it's instead called when a character's at_update() hook is called, which is every + 30 seconds. """ for key in character.db.conditions: @@ -385,6 +411,12 @@ def condition_tickdown(character, turnchar): def add_condition(character, turnchar, condition, duration): """ Adds a condition to a fighter. + + Args: + character (obj): Character to give the condition to + turnchar (obj): Character whose turn to tick down the condition on in combat + condition (str): Name of the condition + duration (int or True): Number of turns the condition lasts, or True for indefinite """ # The first value is the remaining turns - the second value is whose turn to count down on. character.db.conditions.update({condition:[duration, turnchar]}) @@ -417,6 +449,11 @@ class TBItemsCharacter(DefaultCharacter): """ Adds attributes for a character's current and maximum HP. We're just going to set this value at '100' by default. + + An empty dictionary is created to store conditions later, + and the character is subscribed to the Ticker Handler, which + will call at_update() on the character every 30 seconds. This + is used to tick down conditions out of combat. You may want to expand this to include various 'stats' that can be changed at creation and factor into combat calculations. @@ -483,10 +520,10 @@ class TBItemsCharacter(DefaultCharacter): """ Fires every 30 seconds. """ - # Change all conditions to update on character's turn. - for key in self.db.conditions: - self.db.conditions[key][1] = self if not is_in_combat(self): # Not in combat + # Change all conditions to update on character's turn. + for key in self.db.conditions: + self.db.conditions[key][1] = self # Apply conditions that fire every turn self.apply_turn_conditions() # Tick down condition durations @@ -1151,6 +1188,26 @@ You can paste these prototypes into your game's prototypes.py module in your /world/ folder, and use the spawner to create them - they serve as examples of items you can make and a handy way to demonstrate the system for conditions as well. + +Items don't have any particular typeclass - any object with a db entry +"item_func" that references one of the functions given above can be used as +an item with the 'use' command. + +Only "item_func" is required, but item behavior can be further modified by +specifying any of the following: + + item_uses (int): If defined, item has a limited number of uses + + item_selfonly (bool): If True, user can only use the item on themself + + item_consumable(True or str): If True, item is destroyed when it runs + out of uses. If a string is given, the item will spawn a new + object as it's destroyed, with the string specifying what prototype + to spawn. + + item_kwargs (dict): Keyword arguments to pass to the function defined in + item_func. Unique to each function, and can be used to make multiple + items using the same function work differently. """ MEDKIT = { From 42db3aa7f50fc14d26e2cde37fa07207ff489470 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 19 Nov 2017 12:16:38 -0800 Subject: [PATCH 076/466] More conditions and documentation --- evennia/contrib/turnbattle/tb_items.py | 116 ++++++++++++++++++------- 1 file changed, 84 insertions(+), 32 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index db5be80be8..c101dca9a8 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -3,17 +3,34 @@ Simple turn-based combat system with items and status effects Contrib - Tim Ashley Jenkins 2017 -This is a version of the 'turnbattle' combat system that includes status -effects and usable items, which can instill these status effects, cure +This is a version of the 'turnbattle' combat system that includes +conditions and usable items, which can instill these conditions, cure them, or do just about anything else. -Status effects are stored on characters as a dictionary, where the key -is the name of the status effect and the value is a list of two items: -an integer representing the number of turns left until the status runs -out, and the character upon whose turn the condition timer is ticked -down. Unlike most combat-related attributes, conditions aren't wiped -once combat ends - if out of combat, they tick down in real time -instead. +Conditions are stored on characters as a dictionary, where the key +is the name of the condition and the value is a list of two items: +an integer representing the number of turns left until the condition +runs out, and the character upon whose turn the condition timer is +ticked down. Unlike most combat-related attributes, conditions aren't +wiped once combat ends - if out of combat, they tick down in real time +instead. + +This module includes a number of example conditions: + + Regeneration: Character recovers HP every turn + Poisoned: Character loses HP every turn + Accuracy Up: +25 to character's attack rolls + Accuracy Down: -25 to character's attack rolls + Damage Up: +5 to character's damage + Damage Down: -5 to character's damage + Defense Up: +15 to character's defense + Defense Down: -15 to character's defense + Haste: +1 action per turn + Paralyzed: No actions per turn + Frightened: Character can't use the 'attack' command + +Since conditions can have a wide variety of effects, their code is +scattered throughout the other functions wherever they may apply. Items aren't given any sort of special typeclass - instead, whether or not an object counts as an item is determined by its attributes. To make @@ -64,6 +81,7 @@ OPTIONS TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds ACTIONS_PER_TURN = 1 # Number of actions allowed per turn +NONCOMBAT_TURN_TIME = 30 # Time per turn count out of combat """ ---------------------------------------------------------------------------- @@ -111,15 +129,18 @@ def get_attack(attacker, defender): 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. + This is where conditions affecting attack rolls are applied, as well. + Accuracy Up and Accuracy Down are also accounted for in itemfunc_attack(), + so that attack items' accuracy is affected as well. """ # For this example, just return a random integer up to 100. attack_value = randint(1, 100) + # Add 25 to the roll if the attacker has the "Accuracy Up" condition. + if "Accuracy Up" in attacker.db.conditions: + attack_value += 25 + # Subtract 25 from the roll if the attack has the "Accuracy Down" condition. + if "Accuracy Down" in attacker.db.conditions: + attack_value -= 25 return attack_value @@ -137,13 +158,16 @@ def get_defense(attacker, defender): 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. + This is where conditions affecting defense are accounted for. """ # For this example, just return 50, for about a 50/50 chance of hit. defense_value = 50 + # Add 15 to defense if the defender has the "Defense Up" condition. + if "Defense Up" in defender.db.conditions: + defense_value += 15 + # Subtract 15 from defense if the defender has the "Defense Down" condition. + if "Defense Down" in defender.db.conditions: + defense_value -= 15 return defense_value @@ -161,13 +185,18 @@ def get_damage(attacker, defender): 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. + This is where conditions affecting damage are accounted for. Since attack items + roll their own damage in itemfunc_attack(), their damage is unaffected by any + conditions. """ # For this example, just generate a number between 15 and 25. damage_value = randint(15, 25) + # Add 5 to damage roll if attacker has the "Damage Up" condition. + if "Damage Up" in attacker.db.conditions: + damage_value += 5 + # Subtract 5 from the roll if the attacker has the "Damage Down" condition. + if "Damage Down" in attacker.db.conditions: + damage_value -= 5 return damage_value @@ -208,11 +237,17 @@ def resolve_attack(attacker, defender, attack_value=None, defense_value=None, Args: attacker (obj): Character doing the attacking defender (obj): Character being attacked + + Options: + attack_value (int): Override for attack roll + defense_value (int): Override for defense value + damage_value (int): Override for damage value + inflict_condition (list): Conditions to inflict upon hit, a + list of tuples formated as (condition(str), duration(int)) Notes: - 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. + This function is called by normal attacks as well as attacks + made with items. """ # Get an attack roll from the attacker. if not attack_value: @@ -391,14 +426,14 @@ def condition_tickdown(character, turnchar): Notes: In combat, this is called on every fighter at the start of every character's turn. Out of combat, it's instead called when a character's at_update() hook is called, which is every - 30 seconds. + 30 seconds by default. """ for key in character.db.conditions: # The first value is the remaining turns - the second value is whose turn to count down on. condition_duration = character.db.conditions[key][0] condition_turnchar = character.db.conditions[key][1] - # If the duration is 'True', then condition doesn't tick down - it lasts indefinitely. + # If the duration is 'True', then the condition doesn't tick down - it lasts indefinitely. if not condition_duration == True: # Count down if the given turn character matches the condition's turn character. if condition_turnchar == turnchar: @@ -445,15 +480,16 @@ class TBItemsCharacter(DefaultCharacter): self.db.hp = self.db.max_hp # Set current HP to maximum self.db.conditions = {} # Set empty dict for conditions # Subscribe character to the ticker handler - tickerhandler.add(30, self.at_update) + tickerhandler.add(NONCOMBAT_TURN_TIME, self.at_update) """ Adds attributes for a character's current and maximum HP. We're just going to set this value at '100' by default. An empty dictionary is created to store conditions later, and the character is subscribed to the Ticker Handler, which - will call at_update() on the character every 30 seconds. This - is used to tick down conditions out of combat. + will call at_update() on the character, with the interval + specified by NONCOMBAT_TURN_TIME above. This is used to tick + down conditions out of combat. You may want to expand this to include various 'stats' that can be changed at creation and factor into combat calculations. @@ -515,6 +551,16 @@ class TBItemsCharacter(DefaultCharacter): if self.db.hp <= 0: # Call at_defeat if poison defeats the character at_defeat(self) + + # Haste: Gain an extra action in combat. + if is_in_combat(self) and "Haste" in self.db.conditions: + self.db.combat_actionsleft += 1 + self.msg("You gain an extra action this turn from Haste!") + + # Paralyzed: Have no actions in combat. + if is_in_combat(self) and "Paralyzed" in self.db.conditions: + self.db.combat_actionsleft = 0 + self.msg("You're Paralyzed, and can't act this turn!") def at_update(self): """ @@ -791,6 +837,10 @@ class CmdAttack(Command): if not self.caller.db.hp: # Can't attack if you have no HP. self.caller.msg("You can't attack, you've been defeated.") return + + if "Frightened" in self.caller.db.conditions: # Can't attack if frightened + self.caller.msg("You're too frightened to attack!") + return attacker = self.caller defender = self.caller.search(self.args) @@ -939,7 +989,9 @@ class CmdUse(MuxCommand): Usage: use [= target] - Items: you just GOTTA use them. + An item can have various function - looking at the item may + provide information as to its effects. Some items can be used + to attack others, and as such can only be used in combat. """ key = "use" From ba964797def81a7babe6454ea38138729a39a8dd Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 19 Nov 2017 12:59:07 -0800 Subject: [PATCH 077/466] Fixed all conditions lasting indefinitely Turns out 1 == True, but not 1 is True - learn something new every day! --- evennia/contrib/turnbattle/tb_items.py | 49 +++++++++++++++++++------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index c101dca9a8..6a30b3cda3 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -384,7 +384,7 @@ def use_item(user, item, target): target (obj): Target of the item use """ # If item is self only, abort use - if item.db.item_selfonly and user == target: + if item.db.item_selfonly and user != target: user.msg("%s can only be used on yourself." % item) return @@ -434,7 +434,7 @@ def condition_tickdown(character, turnchar): condition_duration = character.db.conditions[key][0] condition_turnchar = character.db.conditions[key][1] # If the duration is 'True', then the condition doesn't tick down - it lasts indefinitely. - if not condition_duration == True: + if not condition_duration is True: # Count down if the given turn character matches the condition's turn character. if condition_turnchar == turnchar: character.db.conditions[key][0] -= 1 @@ -1109,18 +1109,17 @@ def itemfunc_heal(item, user, target, **kwargs): def itemfunc_add_condition(item, user, target, **kwargs): """ - Item function that gives the target a condition. + Item function that gives the target one or more conditions. kwargs: - condition(str): Condition added by the item - duration(int): Number of turns the condition lasts, or True for indefinite + conditions (list): Conditions added by the item + formatted as a list of tuples: (condition (str), duration (int or True)) Notes: Should mostly be used for beneficial conditions - use itemfunc_attack for an item that can give an enemy a harmful condition. """ - condition = "Regeneration" - duration = 5 + conditions = [("Regeneration", 5)] if not target: target = user # Target user if none specified @@ -1130,13 +1129,14 @@ def itemfunc_add_condition(item, user, target, **kwargs): return False # Returning false aborts the item use # Retrieve condition / duration from kwargs, if present - if "condition" in kwargs: - condition = kwargs["condition"] - if "duration" in kwargs: - duration = kwargs["duration"] + if "conditions" in kwargs: + conditions = kwargs["conditions"] user.location.msg_contents("%s uses %s!" % (user, item)) - add_condition(target, user, condition, duration) # Add condition to the target + + # Add conditions to the target + for condition in conditions: + add_condition(target, user, condition[0], condition[1]) def itemfunc_cure_condition(item, user, target, **kwargs): """ @@ -1217,6 +1217,12 @@ def itemfunc_attack(item, user, target, **kwargs): attack_value = randint(1, 100) + accuracy damage_value = randint(min_damage, max_damage) + # Account for "Accuracy Up" and "Accuracy Down" conditions + if "Accuracy Up" in user.db.conditions: + attack_value += 25 + if "Accuracy Down" in user.db.conditions: + attack_value -= 25 + user.location.msg_contents("%s attacks %s with %s!" % (user, target, item)) resolve_attack(user, target, attack_value=attack_value, damage_value=damage_value, inflict_condition=inflict_condition) @@ -1292,7 +1298,16 @@ REGEN_POTION = { "item_func" : "add_condition", "item_uses" : 1, "item_consumable" : "GLASS_BOTTLE", - "item_kwargs" : {"condition":"Regeneration", "duration":10} + "item_kwargs" : {"conditions":[("Regeneration", 10)]} +} + +HASTE_POTION = { + "key" : "a haste potion", + "desc" : "A glass bottle full of a mystical potion that hastens its user.", + "item_func" : "add_condition", + "item_uses" : 1, + "item_consumable" : "GLASS_BOTTLE", + "item_kwargs" : {"conditions":[("Haste", 10)]} } BOMB = { @@ -1320,4 +1335,12 @@ ANTIDOTE_POTION = { "item_uses" : 1, "item_consumable" : "GLASS_BOTTLE", "item_kwargs" : {"to_cure":["Poisoned"]} +} + +AMULET_OF_MIGHT = { + "key" : "The Amulet of Might", + "desc" : "The one who holds this amulet can call upon its power to gain great strength.", + "item_func" : "add_condition", + "item_selfonly" : True, + "item_kwargs" : {"conditions":[("Damage Up", 3), ("Accuracy Up", 3), ("Defense Up", 3)]} } \ No newline at end of file From d99b0b7819a4b98434a44fc548a2a84399af4374 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 19 Nov 2017 13:18:55 -0800 Subject: [PATCH 078/466] More item prototypes - probably ready to go! --- evennia/contrib/turnbattle/tb_items.py | 32 ++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index 6a30b3cda3..25b7625991 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -383,7 +383,11 @@ def use_item(user, item, target): item (obj): Item being used target (obj): Target of the item use """ - # If item is self only, abort use + # If item is self only and no target given, set target to self. + if item.db.item_selfonly and target == None: + target = user + + # If item is self only, abort use if used on others. if item.db.item_selfonly and user != target: user.msg("%s can only be used on yourself." % item) return @@ -560,7 +564,8 @@ class TBItemsCharacter(DefaultCharacter): # Paralyzed: Have no actions in combat. if is_in_combat(self) and "Paralyzed" in self.db.conditions: self.db.combat_actionsleft = 0 - self.msg("You're Paralyzed, and can't act this turn!") + self.location.msg_contents("%s is Paralyzed, and can't act this turn!" % self) + self.db.combat_turnhandler.turn_end_check(self) def at_update(self): """ @@ -1328,6 +1333,21 @@ POISON_DART = { "item_kwargs" : {"damage_range":(5, 10), "accuracy":25, "inflict_condition":[("Poisoned", 10)]} } +TASER = { + "key" : "a taser", + "desc" : "A device that can be used to paralyze enemies in combat.", + "item_func" : "attack", + "item_kwargs" : {"damage_range":(10, 20), "accuracy":0, "inflict_condition":[("Paralyzed", 1)]} +} + +GHOST_GUN = { + "key" : "a ghost gun", + "desc" : "A gun that fires scary ghosts at people. Anyone hit by a ghost becomes frightened.", + "item_func" : "attack", + "item_uses" : 6, + "item_kwargs" : {"damage_range":(5, 10), "accuracy":15, "inflict_condition":[("Frightened", 1)]} +} + ANTIDOTE_POTION = { "key" : "an antidote potion", "desc" : "A glass bottle full of a mystical potion that cures poison when used.", @@ -1343,4 +1363,12 @@ AMULET_OF_MIGHT = { "item_func" : "add_condition", "item_selfonly" : True, "item_kwargs" : {"conditions":[("Damage Up", 3), ("Accuracy Up", 3), ("Defense Up", 3)]} +} + +AMULET_OF_WEAKNESS = { + "key" : "The Amulet of Weakness", + "desc" : "The one who holds this amulet can call upon its power to gain great weakness. It's not a terribly useful artifact.", + "item_func" : "add_condition", + "item_selfonly" : True, + "item_kwargs" : {"conditions":[("Damage Down", 3), ("Accuracy Down", 3), ("Defense Down", 3)]} } \ No newline at end of file From 68e46f1e4ed842253dd30e2501dd5c668ddf94b3 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 19 Nov 2017 13:24:44 -0800 Subject: [PATCH 079/466] Update readme --- evennia/contrib/turnbattle/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/evennia/contrib/turnbattle/README.md b/evennia/contrib/turnbattle/README.md index 729c42a099..d5c86d90f3 100644 --- a/evennia/contrib/turnbattle/README.md +++ b/evennia/contrib/turnbattle/README.md @@ -21,6 +21,19 @@ implemented and customized: the battle system, including commands for wielding weapons and donning armor, and modifiers to accuracy and damage based on currently used equipment. + + tb_items.py - Adds usable items and conditions/status effects, and gives + a lot of examples for each. Items can perform nearly any sort of + function, including healing, adding or curing conditions, or + being used to attack. Conditions affect a fighter's attributes + and options in combat and persist outside of fights, counting + down per turn in combat and in real time outside combat. + + tb_magic.py - Adds a spellcasting system, allowing characters to cast + spells with a variety of effects by spending MP. Spells are + linked to functions, and as such can perform any sort of action + the developer can imagine - spells for attacking, healing and + conjuring objects are included as examples. tb_range.py - Adds a system for abstract positioning and movement, which tracks the distance between different characters and objects in From 9f86034cf301276375b8ce1516c09f341c7e41e1 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 19 Nov 2017 13:25:37 -0800 Subject: [PATCH 080/466] Fix readme spacing --- evennia/contrib/turnbattle/README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/evennia/contrib/turnbattle/README.md b/evennia/contrib/turnbattle/README.md index d5c86d90f3..fd2563bceb 100644 --- a/evennia/contrib/turnbattle/README.md +++ b/evennia/contrib/turnbattle/README.md @@ -23,17 +23,17 @@ implemented and customized: currently used equipment. tb_items.py - Adds usable items and conditions/status effects, and gives - a lot of examples for each. Items can perform nearly any sort of - function, including healing, adding or curing conditions, or - being used to attack. Conditions affect a fighter's attributes - and options in combat and persist outside of fights, counting - down per turn in combat and in real time outside combat. + a lot of examples for each. Items can perform nearly any sort of + function, including healing, adding or curing conditions, or + being used to attack. Conditions affect a fighter's attributes + and options in combat and persist outside of fights, counting + down per turn in combat and in real time outside combat. tb_magic.py - Adds a spellcasting system, allowing characters to cast - spells with a variety of effects by spending MP. Spells are - linked to functions, and as such can perform any sort of action - the developer can imagine - spells for attacking, healing and - conjuring objects are included as examples. + spells with a variety of effects by spending MP. Spells are + linked to functions, and as such can perform any sort of action + the developer can imagine - spells for attacking, healing and + conjuring objects are included as examples. tb_range.py - Adds a system for abstract positioning and movement, which tracks the distance between different characters and objects in From cc398f985117f6f8d2f81429b43c77b539444588 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 29 Nov 2017 19:32:50 +0100 Subject: [PATCH 081/466] 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 0c0b5c982a9c517f52981195be878fb1106b2f49 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Wed, 29 Nov 2017 13:54:22 -0800 Subject: [PATCH 082/466] Added options for conditions at top of module --- evennia/contrib/turnbattle/tb_items.py | 38 ++++++++++++++++---------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index 25b7625991..98a39774c4 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -83,6 +83,16 @@ TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds ACTIONS_PER_TURN = 1 # Number of actions allowed per turn NONCOMBAT_TURN_TIME = 30 # Time per turn count out of combat +# Condition options start here +REGEN_RATE = (4, 8) # Min and max HP regen for Regeneration +POISON_RATE = (4, 8) # Min and max damage for Poisoned +ACC_UP_MOD = 25 # Accuracy Up attack roll bonus +ACC_DOWN_MOD = -25 # Accuracy Down attack roll penalty +DMG_UP_MOD = 5 # Damage Up damage roll bonus +DMG_DOWN_MOD = -5 # Damage Down damage roll penalty +DEF_UP_MOD = 15 # Defense Up defense bonus +DEF_DOWN_MOD = -15 # Defense Down defense penalty + """ ---------------------------------------------------------------------------- COMBAT FUNCTIONS START HERE @@ -135,12 +145,12 @@ def get_attack(attacker, defender): """ # For this example, just return a random integer up to 100. attack_value = randint(1, 100) - # Add 25 to the roll if the attacker has the "Accuracy Up" condition. + # Add to the roll if the attacker has the "Accuracy Up" condition. if "Accuracy Up" in attacker.db.conditions: - attack_value += 25 - # Subtract 25 from the roll if the attack has the "Accuracy Down" condition. + attack_value += ACC_UP_MOD + # Subtract from the roll if the attack has the "Accuracy Down" condition. if "Accuracy Down" in attacker.db.conditions: - attack_value -= 25 + attack_value += ACC_DOWN_MOD return attack_value @@ -162,12 +172,12 @@ def get_defense(attacker, defender): """ # For this example, just return 50, for about a 50/50 chance of hit. defense_value = 50 - # Add 15 to defense if the defender has the "Defense Up" condition. + # Add to defense if the defender has the "Defense Up" condition. if "Defense Up" in defender.db.conditions: - defense_value += 15 - # Subtract 15 from defense if the defender has the "Defense Down" condition. + defense_value += DEF_UP_MOD + # Subtract from defense if the defender has the "Defense Down" condition. if "Defense Down" in defender.db.conditions: - defense_value -= 15 + defense_value += DEF_DOWN_MOD return defense_value @@ -191,12 +201,12 @@ def get_damage(attacker, defender): """ # For this example, just generate a number between 15 and 25. damage_value = randint(15, 25) - # Add 5 to damage roll if attacker has the "Damage Up" condition. + # Add to damage roll if attacker has the "Damage Up" condition. if "Damage Up" in attacker.db.conditions: - damage_value += 5 - # Subtract 5 from the roll if the attacker has the "Damage Down" condition. + damage_value += DMG_UP_MOD + # Subtract from the roll if the attacker has the "Damage Down" condition. if "Damage Down" in attacker.db.conditions: - damage_value -= 5 + damage_value += DMG_DOWN_MOD return damage_value @@ -541,7 +551,7 @@ class TBItemsCharacter(DefaultCharacter): """ # Regeneration: restores 4 to 8 HP at the start of character's turn if "Regeneration" in self.db.conditions: - to_heal = randint(4, 8) # Restore 4 to 8 HP + to_heal = randint(REGEN_RATE[0], REGEN_RAGE[1]) # Restore HP if self.db.hp + to_heal > self.db.max_hp: to_heal = self.db.max_hp - self.db.hp # Cap healing to max HP self.db.hp += to_heal @@ -549,7 +559,7 @@ class TBItemsCharacter(DefaultCharacter): # Poisoned: does 4 to 8 damage at the start of character's turn if "Poisoned" in self.db.conditions: - to_hurt = randint(4, 8) # Deal 4 to 8 damage + to_hurt = randint(POISON_RATE[0], POISON_RATE[1]) # Deal damage apply_damage(self, to_hurt) self.location.msg_contents("%s takes %i damage from being Poisoned." % (self, to_hurt)) if self.db.hp <= 0: From eb95416ee84b37d04c77d8f73569be105a6b4e0f Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Wed, 29 Nov 2017 15:01:49 -0800 Subject: [PATCH 083/466] Unit tests for tb_items --- evennia/contrib/tests.py | 125 ++++++++++++++++++++++++- evennia/contrib/turnbattle/tb_items.py | 5 +- 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 4076ade4dd..6c5798cb34 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -916,7 +916,7 @@ class TestTutorialWorldRooms(CommandTest): # test turnbattle -from evennia.contrib.turnbattle import tb_basic, tb_equip, tb_range +from evennia.contrib.turnbattle import tb_basic, tb_equip, tb_range, tb_items from evennia.objects.objects import DefaultRoom @@ -962,6 +962,18 @@ class TestTurnBattleCmd(CommandTest): 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.") + # Test item commands + def test_turnbattlecmd(self): + testitem = create_object(key="test item") + testitem.move_to(self.char1) + self.call(tb_items.CmdUse(), "item", "'Test item' is not a usable item.") + # Also test the commands that are the same in the basic module + self.call(tb_items.CmdFight(), "", "You can't start a fight if you've been defeated!") + self.call(tb_items.CmdAttack(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_items.CmdPass(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_items.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_items.CmdRest(), "", "Char rests to recover HP.") + class TestTurnBattleFunc(EvenniaTest): @@ -1214,6 +1226,117 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(tb_range.get_range(attacker, defender) == 1) # Remove the script at the end turnhandler.stop() + + # Test functions in tb_items. + def test_tbitemsfunc(self): + attacker = create_object(tb_items.TBItemsCharacter, key="Attacker") + defender = create_object(tb_items.TBItemsCharacter, key="Defender") + testroom = create_object(DefaultRoom, key="Test Room") + attacker.location = testroom + defender.loaction = testroom + # Initiative roll + initiative = tb_items.roll_init(attacker) + self.assertTrue(initiative >= 0 and initiative <= 1000) + # Attack roll + attack_roll = tb_items.get_attack(attacker, defender) + self.assertTrue(attack_roll >= 0 and attack_roll <= 100) + # Defense roll + defense_roll = tb_items.get_defense(attacker, defender) + self.assertTrue(defense_roll == 50) + # Damage roll + damage_roll = tb_items.get_damage(attacker, defender) + self.assertTrue(damage_roll >= 15 and damage_roll <= 25) + # Apply damage + defender.db.hp = 10 + tb_items.apply_damage(defender, 3) + self.assertTrue(defender.db.hp == 7) + # Resolve attack + defender.db.hp = 40 + tb_items.resolve_attack(attacker, defender, attack_value=20, defense_value=10) + self.assertTrue(defender.db.hp < 40) + # Combat cleanup + attacker.db.Combat_attribute = True + tb_items.combat_cleanup(attacker) + self.assertFalse(attacker.db.combat_attribute) + # Is in combat + self.assertFalse(tb_items.is_in_combat(attacker)) + # Set up turn handler script for further tests + attacker.location.scripts.add(tb_items.TBItemsTurnHandler) + turnhandler = attacker.db.combat_TurnHandler + self.assertTrue(attacker.db.combat_TurnHandler) + # Set the turn handler's interval very high to keep it from repeating during tests. + turnhandler.interval = 10000 + # Force turn order + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + # Test is turn + self.assertTrue(tb_items.is_turn(attacker)) + # Spend actions + attacker.db.Combat_ActionsLeft = 1 + tb_items.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_items.TBItemsCharacter, 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() + # Now time to test item stuff. + user = create_object(tb_items.TBItemsCharacter, key="User") + testroom = create_object(DefaultRoom, key="Test Room") + user.location = testroom + test_healpotion = create_object(key="healing potion") + test_healpotion.db.item_func = "heal" + test_healpotion.db.item_uses = 3 + # Spend item use + tb_items.spend_item_use(test_healpotion, user) + self.assertTrue(test_healpotion.db.item_uses == 2) + # Use item + user.db.hp = 2 + tb_items.use_item(user, test_healpotion, user) + self.assertTrue(user.db.hp > 2) + # Add contition + tb_items.add_condition(user, user, "Test", 5) + self.assertTrue(user.db.conditions == {"Test":[5, user]}) + # Condition tickdown + tb_items.condition_tickdown(user, user) + self.assertTrue(user.db.conditions == {"Test":[4, user]}) + # Test item functions now! + # Item heal + user.db.hp = 2 + tb_items.itemfunc_heal(test_healpotion, user, user) + # Item add condition + user.db.conditions = {} + tb_items.itemfunc_add_condition(test_healpotion, user, user) + self.assertTrue(user.db.conditions == {"Regeneration":[5, user]}) + # Item cure condition + user.db.conditions = {"Poisoned":[5, user]} + tb_items.itemfunc_cure_condition(test_healpotion, user, user) + self.assertTrue(user.db.conditions == {}) # Test tree select diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index 98a39774c4..f367fec269 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -83,7 +83,10 @@ TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds ACTIONS_PER_TURN = 1 # Number of actions allowed per turn NONCOMBAT_TURN_TIME = 30 # Time per turn count out of combat -# Condition options start here +# Condition options start here. +# If you need to make changes to how your conditions work later, +# it's best to put the easily tweakable values all in one place! + REGEN_RATE = (4, 8) # Min and max HP regen for Regeneration POISON_RATE = (4, 8) # Min and max damage for Poisoned ACC_UP_MOD = 25 # Accuracy Up attack roll bonus From 785522fb3ceeae2f55f6c9b4dc908ab5b28f174f Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Wed, 29 Nov 2017 15:08:55 -0800 Subject: [PATCH 084/466] Attempt to fix TickerHandler error in unit tests --- evennia/contrib/tests.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 6c5798cb34..bf03036117 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1337,6 +1337,8 @@ class TestTurnBattleFunc(EvenniaTest): user.db.conditions = {"Poisoned":[5, user]} tb_items.itemfunc_cure_condition(test_healpotion, user, user) self.assertTrue(user.db.conditions == {}) + # Delete the test character to prevent ticker handler problems + user.delete() # Test tree select From 9e8a400049cf9db6f6d3780e9000d6e3791422cd Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Wed, 29 Nov 2017 15:15:47 -0800 Subject: [PATCH 085/466] Manually unsubscribe ticker handler --- evennia/contrib/tests.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index bf03036117..04a1753933 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1307,6 +1307,7 @@ class TestTurnBattleFunc(EvenniaTest): turnhandler.stop() # Now time to test item stuff. user = create_object(tb_items.TBItemsCharacter, key="User") + user.TICKER_HANDLER.remove(interval=30, callback=user.at_update) testroom = create_object(DefaultRoom, key="Test Room") user.location = testroom test_healpotion = create_object(key="healing potion") @@ -1337,7 +1338,7 @@ class TestTurnBattleFunc(EvenniaTest): user.db.conditions = {"Poisoned":[5, user]} tb_items.itemfunc_cure_condition(test_healpotion, user, user) self.assertTrue(user.db.conditions == {}) - # Delete the test character to prevent ticker handler problems + # Delete the test character user.delete() # Test tree select From 0c8db01d5616367f225869d31c7ab188968739a3 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Wed, 29 Nov 2017 15:29:49 -0800 Subject: [PATCH 086/466] TickerHandler stuff, more --- evennia/contrib/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 04a1753933..7a3c660a72 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1307,7 +1307,7 @@ class TestTurnBattleFunc(EvenniaTest): turnhandler.stop() # Now time to test item stuff. user = create_object(tb_items.TBItemsCharacter, key="User") - user.TICKER_HANDLER.remove(interval=30, callback=user.at_update) + tb_items.tickerhandler.remove(interval=30, callback=user.at_update) testroom = create_object(DefaultRoom, key="Test Room") user.location = testroom test_healpotion = create_object(key="healing potion") From dc67f4b871913995ff3dc424be1177172172dbbd Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Wed, 29 Nov 2017 15:37:40 -0800 Subject: [PATCH 087/466] Ugh!!! TickerHandler changes, more --- evennia/contrib/tests.py | 2 +- evennia/contrib/turnbattle/tb_items.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 7a3c660a72..8d40aeaadb 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1307,7 +1307,7 @@ class TestTurnBattleFunc(EvenniaTest): turnhandler.stop() # Now time to test item stuff. user = create_object(tb_items.TBItemsCharacter, key="User") - tb_items.tickerhandler.remove(interval=30, callback=user.at_update) + tb_items.tickerhandler.remove(interval=30, callback=user.at_update, idstring="update") testroom = create_object(DefaultRoom, key="Test Room") user.location = testroom test_healpotion = create_object(key="healing potion") diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index f367fec269..dca5856fe5 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -497,7 +497,7 @@ class TBItemsCharacter(DefaultCharacter): self.db.hp = self.db.max_hp # Set current HP to maximum self.db.conditions = {} # Set empty dict for conditions # Subscribe character to the ticker handler - tickerhandler.add(NONCOMBAT_TURN_TIME, self.at_update) + tickerhandler.add(NONCOMBAT_TURN_TIME, self.at_update, idstring="update") """ Adds attributes for a character's current and maximum HP. We're just going to set this value at '100' by default. From 2549a8d8e1ff08f936978f5f01e61e6132ef82f8 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Wed, 29 Nov 2017 20:08:22 -0800 Subject: [PATCH 088/466] Comment out tb_items tests for now --- evennia/contrib/tests.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 8d40aeaadb..9091fa65ec 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1316,6 +1316,8 @@ class TestTurnBattleFunc(EvenniaTest): # Spend item use tb_items.spend_item_use(test_healpotion, user) self.assertTrue(test_healpotion.db.item_uses == 2) + # Commenting this stuff out just to make sure it's the problem. + """ # Use item user.db.hp = 2 tb_items.use_item(user, test_healpotion, user) @@ -1340,6 +1342,7 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(user.db.conditions == {}) # Delete the test character user.delete() + """ # Test tree select From 0932a839ba53bb85beb6bdb8b3543237e73b9a33 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Wed, 29 Nov 2017 20:18:54 -0800 Subject: [PATCH 089/466] Also remove ticker handler for 'attacker' and 'defender' Whoops! I forgot that ALL my test characters are getting subscribed to the ticker handler here - maybe that's the problem? --- evennia/contrib/tests.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 9091fa65ec..8de5d61fe9 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1230,7 +1230,9 @@ class TestTurnBattleFunc(EvenniaTest): # Test functions in tb_items. def test_tbitemsfunc(self): attacker = create_object(tb_items.TBItemsCharacter, key="Attacker") + tb_items.tickerhandler.remove(interval=30, callback=attacker.at_update, idstring="update") defender = create_object(tb_items.TBItemsCharacter, key="Defender") + tb_items.tickerhandler.remove(interval=30, callback=defender.at_update, idstring="update") testroom = create_object(DefaultRoom, key="Test Room") attacker.location = testroom defender.loaction = testroom @@ -1316,8 +1318,6 @@ class TestTurnBattleFunc(EvenniaTest): # Spend item use tb_items.spend_item_use(test_healpotion, user) self.assertTrue(test_healpotion.db.item_uses == 2) - # Commenting this stuff out just to make sure it's the problem. - """ # Use item user.db.hp = 2 tb_items.use_item(user, test_healpotion, user) @@ -1342,7 +1342,6 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(user.db.conditions == {}) # Delete the test character user.delete() - """ # Test tree select From a3caaf8c5ffc1b1978f36bd2b3d6909ae3805b9b Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Wed, 29 Nov 2017 20:26:26 -0800 Subject: [PATCH 090/466] Just comment it all out. Travis won't even tell me why it failed this time. --- evennia/contrib/tests.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 8de5d61fe9..088377380a 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1226,7 +1226,8 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(tb_range.get_range(attacker, defender) == 1) # Remove the script at the end turnhandler.stop() - + + """ # Test functions in tb_items. def test_tbitemsfunc(self): attacker = create_object(tb_items.TBItemsCharacter, key="Attacker") @@ -1342,6 +1343,7 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(user.db.conditions == {}) # Delete the test character user.delete() + """" # Test tree select From 1420c8773e8e345ec60de43570253e269b67da02 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Wed, 29 Nov 2017 20:32:00 -0800 Subject: [PATCH 091/466] Comment it out right this time --- evennia/contrib/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 088377380a..5e8a73bb19 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1227,7 +1227,7 @@ class TestTurnBattleFunc(EvenniaTest): # Remove the script at the end turnhandler.stop() - """ +""" # Test functions in tb_items. def test_tbitemsfunc(self): attacker = create_object(tb_items.TBItemsCharacter, key="Attacker") @@ -1343,7 +1343,7 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(user.db.conditions == {}) # Delete the test character user.delete() - """" +""" # Test tree select From 90dc745d73f6b21a5c769801a2fe8fec3802101f Mon Sep 17 00:00:00 2001 From: Tehom Date: Fri, 8 Dec 2017 02:59:29 -0500 Subject: [PATCH 092/466] 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 093/466] 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 094/466] 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 b213f4a2433c0cd7509a7386408d99172fc5bf96 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sun, 10 Dec 2017 18:50:34 -0800 Subject: [PATCH 095/466] Test character class without tickerhandler --- evennia/contrib/tests.py | 9 ++++----- evennia/contrib/turnbattle/tb_items.py | 12 +++++++++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 8d40aeaadb..8af7fe59eb 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1229,8 +1229,8 @@ class TestTurnBattleFunc(EvenniaTest): # Test functions in tb_items. def test_tbitemsfunc(self): - attacker = create_object(tb_items.TBItemsCharacter, key="Attacker") - defender = create_object(tb_items.TBItemsCharacter, key="Defender") + attacker = create_object(tb_items.TBItemsCharacterTest, key="Attacker") + defender = create_object(tb_items.TBItemsCharacterTest, key="Defender") testroom = create_object(DefaultRoom, key="Test Room") attacker.location = testroom defender.loaction = testroom @@ -1297,7 +1297,7 @@ class TestTurnBattleFunc(EvenniaTest): turnhandler.turn_end_check(attacker) self.assertTrue(turnhandler.db.turn == 1) # Join fight - joiner = create_object(tb_items.TBItemsCharacter, key="Joiner") + joiner = create_object(tb_items.TBItemsCharacterTest, key="Joiner") turnhandler.db.fighters = [attacker, defender] turnhandler.db.turn = 0 turnhandler.join_fight(joiner) @@ -1306,8 +1306,7 @@ class TestTurnBattleFunc(EvenniaTest): # Remove the script at the end turnhandler.stop() # Now time to test item stuff. - user = create_object(tb_items.TBItemsCharacter, key="User") - tb_items.tickerhandler.remove(interval=30, callback=user.at_update, idstring="update") + user = create_object(tb_items.TBItemsCharacterTest, key="User") testroom = create_object(DefaultRoom, key="Test Room") user.location = testroom test_healpotion = create_object(key="healing potion") diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index dca5856fe5..a51edbbbdc 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -592,7 +592,17 @@ class TBItemsCharacter(DefaultCharacter): self.apply_turn_conditions() # Tick down condition durations condition_tickdown(self, self) - + +class TBItemsCharacterTest(TBItemsCharacter): + """ + Just like the TBItemsCharacter, but doesn't subscribe to the TickerHandler. + This makes it easier to run unit tests on. + """ + def at_object_creation(self): + self.db.max_hp = 100 # Set maximum HP to 100 + self.db.hp = self.db.max_hp # Set current HP to maximum + self.db.conditions = {} # Set empty dict for conditions + """ ---------------------------------------------------------------------------- From f738427ff3d12de74249e36e0c8686ae3d939ffe Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 10 Dec 2017 18:52:34 -0800 Subject: [PATCH 096/466] Add tickerhandler-free character class for tests --- evennia/contrib/turnbattle/tb_items.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index dca5856fe5..d0e9fe8e34 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -592,7 +592,17 @@ class TBItemsCharacter(DefaultCharacter): self.apply_turn_conditions() # Tick down condition durations condition_tickdown(self, self) - + +class TBItemsCharacterTest(TBItemsCharacter): + """ + Just like the TBItemsCharacter, but doesn't subscribe to the TickerHandler. + This makes it easier to run unit tests on. + """ + def at_object_creation(self): + self.db.max_hp = 100 # Set maximum HP to 100 + self.db.hp = self.db.max_hp # Set current HP to maximum + self.db.conditions = {} # Set empty dict for conditions + """ ---------------------------------------------------------------------------- @@ -1384,4 +1394,4 @@ AMULET_OF_WEAKNESS = { "item_func" : "add_condition", "item_selfonly" : True, "item_kwargs" : {"conditions":[("Damage Down", 3), ("Accuracy Down", 3), ("Defense Down", 3)]} -} \ No newline at end of file +} From 52d505b011bbbb46cfcb768e0d18c5a92afdd20f Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 10 Dec 2017 18:53:10 -0800 Subject: [PATCH 097/466] Use test character class instead --- evennia/contrib/tests.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 5e8a73bb19..8af7fe59eb 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1226,14 +1226,11 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(tb_range.get_range(attacker, defender) == 1) # Remove the script at the end turnhandler.stop() - -""" + # Test functions in tb_items. def test_tbitemsfunc(self): - attacker = create_object(tb_items.TBItemsCharacter, key="Attacker") - tb_items.tickerhandler.remove(interval=30, callback=attacker.at_update, idstring="update") - defender = create_object(tb_items.TBItemsCharacter, key="Defender") - tb_items.tickerhandler.remove(interval=30, callback=defender.at_update, idstring="update") + attacker = create_object(tb_items.TBItemsCharacterTest, key="Attacker") + defender = create_object(tb_items.TBItemsCharacterTest, key="Defender") testroom = create_object(DefaultRoom, key="Test Room") attacker.location = testroom defender.loaction = testroom @@ -1300,7 +1297,7 @@ class TestTurnBattleFunc(EvenniaTest): turnhandler.turn_end_check(attacker) self.assertTrue(turnhandler.db.turn == 1) # Join fight - joiner = create_object(tb_items.TBItemsCharacter, key="Joiner") + joiner = create_object(tb_items.TBItemsCharacterTest, key="Joiner") turnhandler.db.fighters = [attacker, defender] turnhandler.db.turn = 0 turnhandler.join_fight(joiner) @@ -1309,8 +1306,7 @@ class TestTurnBattleFunc(EvenniaTest): # Remove the script at the end turnhandler.stop() # Now time to test item stuff. - user = create_object(tb_items.TBItemsCharacter, key="User") - tb_items.tickerhandler.remove(interval=30, callback=user.at_update, idstring="update") + user = create_object(tb_items.TBItemsCharacterTest, key="User") testroom = create_object(DefaultRoom, key="Test Room") user.location = testroom test_healpotion = create_object(key="healing potion") @@ -1343,7 +1339,6 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(user.db.conditions == {}) # Delete the test character user.delete() -""" # Test tree select From 717999f56f5f27db00b5f45756e0de2ececa02b0 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Mon, 11 Dec 2017 14:35:18 -0800 Subject: [PATCH 098/466] Unit tests for tb_magic --- evennia/contrib/tests.py | 96 +++++++++++++++++++++++++- evennia/contrib/turnbattle/tb_magic.py | 8 +++ 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 8af7fe59eb..5203354fff 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -916,7 +916,7 @@ class TestTutorialWorldRooms(CommandTest): # test turnbattle -from evennia.contrib.turnbattle import tb_basic, tb_equip, tb_range, tb_items +from evennia.contrib.turnbattle import tb_basic, tb_equip, tb_range, tb_items, tb_magic from evennia.objects.objects import DefaultRoom @@ -963,7 +963,7 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_range.CmdRest(), "", "Char rests to recover HP.") # Test item commands - def test_turnbattlecmd(self): + def test_turnbattleitemcmd(self): testitem = create_object(key="test item") testitem.move_to(self.char1) self.call(tb_items.CmdUse(), "item", "'Test item' is not a usable item.") @@ -974,6 +974,18 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_items.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_items.CmdRest(), "", "Char rests to recover HP.") + # Test magic commands + def test_turnbattlemagiccmd(self): + self.call(tb_magic.CmdStatus(), "", "You have 100 / 100 HP and 20 / 20 MP.") + self.call(tb_magic.CmdLearnSpell(), "test spell", "There is no spell with that name.") + self.call(tb_magic.CmdCast(), "", "Usage: cast = , ") + # Also test the commands that are the same in the basic module + self.call(tb_magic.CmdFight(), "", "There's nobody here to fight!") + self.call(tb_magic.CmdAttack(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_magic.CmdPass(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_magic.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_magic.CmdRest(), "", "Char rests to recover HP and MP.") + class TestTurnBattleFunc(EvenniaTest): @@ -1339,6 +1351,86 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(user.db.conditions == {}) # Delete the test character user.delete() + + # Test combat functions in tb_magic. + def test_tbbasicfunc(self): + attacker = create_object(tb_magic.TBMagicCharacter, key="Attacker") + defender = create_object(tb_magic.TBMagicCharacter, key="Defender") + testroom = create_object(DefaultRoom, key="Test Room") + attacker.location = testroom + defender.loaction = testroom + # Initiative roll + initiative = tb_magic.roll_init(attacker) + self.assertTrue(initiative >= 0 and initiative <= 1000) + # Attack roll + attack_roll = tb_magic.get_attack(attacker, defender) + self.assertTrue(attack_roll >= 0 and attack_roll <= 100) + # Defense roll + defense_roll = tb_magic.get_defense(attacker, defender) + self.assertTrue(defense_roll == 50) + # Damage roll + damage_roll = tb_magic.get_damage(attacker, defender) + self.assertTrue(damage_roll >= 15 and damage_roll <= 25) + # Apply damage + defender.db.hp = 10 + tb_magic.apply_damage(defender, 3) + self.assertTrue(defender.db.hp == 7) + # Resolve attack + defender.db.hp = 40 + tb_magic.resolve_attack(attacker, defender, attack_value=20, defense_value=10) + self.assertTrue(defender.db.hp < 40) + # Combat cleanup + attacker.db.Combat_attribute = True + tb_magic.combat_cleanup(attacker) + self.assertFalse(attacker.db.combat_attribute) + # Is in combat + self.assertFalse(tb_magic.is_in_combat(attacker)) + # Set up turn handler script for further tests + attacker.location.scripts.add(tb_magic.TBMagicTurnHandler) + turnhandler = attacker.db.combat_TurnHandler + self.assertTrue(attacker.db.combat_TurnHandler) + # Set the turn handler's interval very high to keep it from repeating during tests. + turnhandler.interval = 10000 + # Force turn order + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + # Test is turn + self.assertTrue(tb_magic.is_turn(attacker)) + # Spend actions + attacker.db.Combat_ActionsLeft = 1 + tb_magic.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_magic.TBMagicCharacter, 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 tree select diff --git a/evennia/contrib/turnbattle/tb_magic.py b/evennia/contrib/turnbattle/tb_magic.py index 6e16cd0d46..01101837b3 100644 --- a/evennia/contrib/turnbattle/tb_magic.py +++ b/evennia/contrib/turnbattle/tb_magic.py @@ -1000,6 +1000,14 @@ class CmdStatus(Command): def func(self): "This performs the actual command." char = self.caller + + if not char.db.max_hp: # Character not initialized, IE in unit tests + char.db.hp = 100 + char.db.max_hp = 100 + char.db.spells_known = [] + char.db.max_mp = 20 + char.db.mp = char.db.max_mp + char.msg("You have %i / %i HP and %i / %i MP." % (char.db.hp, char.db.max_hp, char.db.mp, char.db.max_mp)) class CmdCombatHelp(CmdHelp): From abaf8d0a197b6a026c5d9d399f7a91cdcd163ea1 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Tue, 12 Dec 2017 18:56:36 +0100 Subject: [PATCH 099/466] 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 100/466] 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 101/466] 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 102/466] 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 103/466] 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 104/466] 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 105/466] 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 106/466] 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 107/466] [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 108/466] 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 109/466] 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 110/466] 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 111/466] 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 112/466] 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 113/466] 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 114/466] 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 115/466] 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 116/466] 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 117/466] 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 118/466] 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 119/466] 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 120/466] 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 121/466] 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 122/466] 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 123/466] 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 124/466] 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 125/466] 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 126/466] 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 127/466] 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 128/466] `@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 129/466] 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 130/466] 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 131/466] 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 132/466] 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 133/466] 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 134/466] 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 135/466] 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 136/466] 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 137/466] 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 138/466] 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 139/466] 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 140/466] 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 141/466] 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 142/466] 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 143/466] 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 144/466] 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 145/466] 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 146/466] 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 147/466] 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 148/466] 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 149/466] 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 150/466] 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 151/466] 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 152/466] 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 153/466] 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 154/466] 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 155/466] 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 156/466] 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 157/466] 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 158/466] 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 159/466] 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 160/466] 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 161/466] 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 162/466] 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 163/466] 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 164/466] 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 165/466] 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 166/466] 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 167/466] 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 168/466] 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 169/466] 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 170/466] 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 171/466] 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 172/466] 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 173/466] 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 174/466] 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 175/466] 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 176/466] 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 177/466] 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 178/466] 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 179/466] 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 180/466] 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 181/466] 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 182/466] 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 183/466] 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 184/466] 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 185/466] 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 186/466] 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 187/466] 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 188/466] 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 189/466] 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 190/466] 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 191/466] 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 192/466] 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 193/466] 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 194/466] 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 195/466] 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 196/466] 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 197/466] 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 198/466] 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 199/466] 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 200/466] 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 201/466] 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 202/466] 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 203/466] 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 204/466] 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 205/466] 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 70a81a939c7c88682571bf3297df56fe940440ea Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 28 Feb 2018 19:17:26 +0100 Subject: [PATCH 206/466] OLC systen. Create olc_storage mechanism --- evennia/accounts/accounts.py | 21 +++++ evennia/objects/objects.py | 6 +- evennia/scripts/scripts.py | 16 ++++ evennia/typeclasses/attributes.py | 4 +- evennia/utils/create.py | 16 +++- evennia/utils/olc/__init__.py | 0 evennia/utils/olc/olc_storage.py | 151 ++++++++++++++++++++++++++++++ 7 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 evennia/utils/olc/__init__.py create mode 100644 evennia/utils/olc/olc_storage.py diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index b86b6eb16e..602ccc2a69 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -631,10 +631,31 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): # this will only be set if the utils.create_account # function was used to create the object. cdict = self._createdict + updates = [] + if not cdict.get("key"): + if not self.db_key: + self.db_key = "#%i" % self.dbid + updates.append("db_key") + elif self.key != cdict.get("key"): + updates.append("db_key") + self.db_key = cdict["key"] + if updates: + self.save(update_fields=updates) + if cdict.get("locks"): self.locks.add(cdict["locks"]) if cdict.get("permissions"): permissions = cdict["permissions"] + if cdict.get("tags"): + # this should be a list of tags, tuples (key, category) or (key, category, data) + self.tags.batch_add(*cdict["tags"]) + if cdict.get("attributes"): + # this should be tuples (key, val, ...) + self.attributes.batch_add(*cdict["attributes"]) + if cdict.get("nattributes"): + # this should be a dict of nattrname:value + for key, value in cdict["nattributes"]: + self.nattributes.add(key, value) del self._createdict self.permissions.batch_add(*permissions) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 037f3ff019..6cb948da03 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -1002,14 +1002,14 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): cdict["location"].at_object_receive(self, None) self.at_after_move(None) if cdict.get("tags"): - # this should be a list of tags + # this should be a list of tags, tuples (key, category) or (key, category, data) self.tags.batch_add(*cdict["tags"]) if cdict.get("attributes"): - # this should be a dict of attrname:value + # this should be tuples (key, val, ...) self.attributes.batch_add(*cdict["attributes"]) if cdict.get("nattributes"): # this should be a dict of nattrname:value - for key, value in cdict["nattributes"].items(): + for key, value in cdict["nattributes"]: self.nattributes.add(key, value) del self._createdict diff --git a/evennia/scripts/scripts.py b/evennia/scripts/scripts.py index db6e9652cb..24e25592fa 100644 --- a/evennia/scripts/scripts.py +++ b/evennia/scripts/scripts.py @@ -513,6 +513,22 @@ class DefaultScript(ScriptBase): updates.append("db_persistent") if updates: self.save(update_fields=updates) + + if cdict.get("permissions"): + self.permissions.batch_add(*cdict["permissions"]) + if cdict.get("locks"): + self.locks.add(cdict["locks"]) + if cdict.get("tags"): + # this should be a list of tags, tuples (key, category) or (key, category, data) + self.tags.batch_add(*cdict["tags"]) + if cdict.get("attributes"): + # this should be tuples (key, val, ...) + self.attributes.batch_add(*cdict["attributes"]) + if cdict.get("nattributes"): + # this should be a dict of nattrname:value + for key, value in cdict["nattributes"]: + self.nattributes.add(key, value) + if not cdict.get("autostart"): # don't auto-start the script return diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index 3f8b4cd742..03ef255093 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -530,8 +530,8 @@ class AttributeHandler(object): repeat-calling add when having many Attributes to add. Args: - indata (tuple): Tuples of varying length representing the - Attribute to add to this object. + indata (list): List of tuples of varying length representing the + Attribute to add to this object. Supported tuples are - `(key, value)` - `(key, value, category)` - `(key, value, category, lockstring)` diff --git a/evennia/utils/create.py b/evennia/utils/create.py index 1404e4caaa..c5fb6f8416 100644 --- a/evennia/utils/create.py +++ b/evennia/utils/create.py @@ -54,7 +54,8 @@ _GA = object.__getattribute__ def create_object(typeclass=None, key=None, location=None, home=None, permissions=None, locks=None, aliases=None, tags=None, - destination=None, report_to=None, nohome=False): + destination=None, report_to=None, nohome=False, attributes=None, + nattributes=None): """ Create a new in-game object. @@ -68,13 +69,18 @@ def create_object(typeclass=None, key=None, location=None, home=None, permissions (list): A list of permission strings or tuples (permstring, category). locks (str): one or more lockstrings, separated by semicolons. aliases (list): A list of alternative keys or tuples (aliasstring, category). - tags (list): List of tag keys or tuples (tagkey, category). + tags (list): List of tag keys or tuples (tagkey, category) or (tagkey, category, data). destination (Object or str): Obj or #dbref to use as an Exit's target. report_to (Object): The object to return error messages to. nohome (bool): This allows the creation of objects without a default home location; only used when creating the default location itself or during unittests. + attributes (list): Tuples on the form (key, value) or (key, value, category), + (key, value, lockstring) or (key, value, lockstring, default_access). + to set as Attributes on the new object. + nattributes (list): Non-persistent tuples on the form (key, value). Note that + adding this rarely makes sense since this data will not survive a reload. Returns: object (Object): A newly created object of the given typeclass. @@ -122,7 +128,8 @@ def create_object(typeclass=None, key=None, location=None, home=None, # store the call signature for the signal new_object._createdict = dict(key=key, location=location, destination=destination, home=home, typeclass=typeclass.path, permissions=permissions, locks=locks, - aliases=aliases, tags=tags, report_to=report_to, nohome=nohome) + aliases=aliases, tags=tags, report_to=report_to, nohome=nohome, + attributes=attributes, nattributes=nattributes) # this will trigger the save signal which in turn calls the # at_first_save hook on the typeclass, where the _createdict can be # used. @@ -139,7 +146,8 @@ object = create_object def create_script(typeclass=None, key=None, obj=None, account=None, locks=None, interval=None, start_delay=None, repeats=None, - persistent=None, autostart=True, report_to=None, desc=None): + persistent=None, autostart=True, report_to=None, desc=None, + tags=None, attributes=None): """ Create a new script. All scripts are a combination of a database object that communicates with the database, and an typeclass that diff --git a/evennia/utils/olc/__init__.py b/evennia/utils/olc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evennia/utils/olc/olc_storage.py b/evennia/utils/olc/olc_storage.py new file mode 100644 index 0000000000..d64956f8a0 --- /dev/null +++ b/evennia/utils/olc/olc_storage.py @@ -0,0 +1,151 @@ +""" +OLC storage and sharing mechanism. + +This sets up a central storage for prototypes. The idea is to make these +available in a repository for buildiers to use. Each prototype is stored +in a Script so that it can be tagged for quick sorting/finding and locked for limiting +access. + +""" + +from evennia.scripts.scripts import DefaultScript +from evennia.utils.create import create_script +from evennia.utils.utils import make_iter +from evennia.utils.evtable import EvTable + + +class PersistentPrototype(DefaultScript): + """ + This stores a single prototype + """ + key = "persistent_prototype" + desc = "Stores a prototoype" + + +def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete=False): + """ + Store a prototype persistently. + + Args: + caller (Account or Object): Caller aiming to store prototype. At this point + the caller should have permission to 'add' new prototypes, but to edit + an existing prototype, the 'edit' lock must be passed on that prototype. + key (str): Name of prototype to store. + prototype (dict): Prototype dict. + desc (str, optional): Description of prototype, to use in listing. + tags (list, optional): Tag-strings to apply to prototype. These are always + applied with the 'persistent_prototype' category. + locks (str, optional): Locks to apply to this prototype. Used locks + are 'use' and 'edit' + delete (bool, optional): Delete an existing prototype identified by 'key'. + This requires `caller` to pass the 'edit' lock of the prototype. + Returns: + stored (StoredPrototype or None): The resulting prototype (new or edited), + or None if deleting. + Raises: + PermissionError: If edit lock was not passed by caller. + + """ + key = key.lower() + locks = locks if locks else "use:all();edit:id({}) or edit:perm(Admin)".format(caller.id) + tags = [(tag, "persistent_prototype") for tag in make_iter(tags)] + + stored_prototype = PersistentPrototype.objects.filter(db_key=key) + + if stored_prototype: + stored_prototype = stored_prototype[0] + if not stored_prototype.access(caller, 'edit'): + PermissionError("{} does not have permission to edit prototype {}".format(caller, key)) + + if delete: + stored_prototype.delete() + return + + if desc: + stored_prototype.desc = desc + if tags: + stored_prototype.tags.add(tags) + if locks: + stored_prototype.locks.add(locks) + if prototype: + stored_prototype.attributes.add("prototype", prototype) + else: + stored_prototype = create_script( + PersistentPrototype, key=key, desc=desc, persistent=True, + locks=locks, tags=tags, attributes=[("prototype", prototype)]) + return stored_prototype + + +def search_prototype(key=None, tags=None): + """ + Find prototypes based on key and/or tags. + + Kwargs: + key (str): An exact or partial key to query for. + tags (str or list): Tag key or keys to query for. These + will always be applied with the 'persistent_protototype' + tag category. + Return: + matches (queryset): All found PersistentPrototypes. This will + be all prototypes if no arguments are given. + + Note: + This will use the tags to make a subselection before attempting + to match on the key. So if key/tags don't match up nothing will + be found. + + """ + matches = PersistentPrototype.objects.all() + if tags: + # exact match on tag(s) + tags = make_iter(tags) + tag_categories = ("persistent_prototype" for _ in tags) + matches = matches.get_by_tag(tags, tag_categories) + if key: + # partial match on key + matches = matches.filter(db_key=key) or matches.filter(db_key__icontains=key) + return matches + + +def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): + """ + Collate a list of found prototypes based on search criteria and access. + + Args: + caller (Account or Object): The object requesting the list. + key (str, optional): Exact or partial key to query for. + tags (str or list, optional): Tag key or keys to query for. + show_non_use (bool, optional): Show also prototypes the caller may not use. + show_non_edit (bool, optional): Show also prototypes the caller may not edit. + Returns: + table (EvTable or None): An EvTable representation of the prototypes. None + if no prototypes were found. + + """ + prototypes = search_prototype(key, tags) + + if not prototypes: + return None + + # gather access permissions as (key, desc, can_use, can_edit) + prototypes = [(prototype.key, prototype.desc, + prototype.access(caller, "use"), prototype.access(caller, "edit")) + for prototype in prototypes] + + if not show_non_use: + prototypes = [tup for tup in prototypes if tup[2]] + if not show_non_edit: + prototypes = [tup for tup in prototypes if tup[3]] + + if not prototypes: + return None + + table = [] + for i in range(len(prototypes[0])): + table.append([tup[i] for tup in prototypes]) + table = EvTable("Key", "Desc", "Use", "Edit", table, crop=True, width=78) + table.reformat_column(0, width=28) + table.reformat_column(1, width=40) + table.reformat_column(2, width=5) + table.reformat_column(3, width=5) + return table From d7deed0b51af12fb40f9424f1521bbf5e1874049 Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 2 Mar 2018 23:16:15 +0100 Subject: [PATCH 207/466] Correct bugs in script_creation, fix unittest for olc_storage --- evennia/scripts/scripts.py | 171 ++++++++++++++++--------------- evennia/utils/create.py | 14 ++- evennia/utils/olc/olc_storage.py | 18 ++-- 3 files changed, 110 insertions(+), 93 deletions(-) diff --git a/evennia/scripts/scripts.py b/evennia/scripts/scripts.py index 24e25592fa..67367df3bb 100644 --- a/evennia/scripts/scripts.py +++ b/evennia/scripts/scripts.py @@ -141,15 +141,6 @@ class ScriptBase(with_metaclass(TypeclassBase, ScriptDB)): """ objects = ScriptManager() - -class DefaultScript(ScriptBase): - """ - This is the base TypeClass for all Scripts. Scripts describe - events, timers and states in game, they can have a time component - or describe a state that changes under certain conditions. - - """ - def __eq__(self, other): """ Compares two Scripts. Compares dbids. @@ -239,7 +230,96 @@ class DefaultScript(ScriptBase): logger.log_trace() return None - # Public methods + def at_script_creation(self): + """ + Should be overridden in child. + + """ + pass + + def at_first_save(self, **kwargs): + """ + This is called after very first time this object is saved. + Generally, you don't need to overload this, but only the hooks + called by this method. + + Args: + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + self.at_script_creation() + + if hasattr(self, "_createdict"): + # this will only be set if the utils.create_script + # function was used to create the object. We want + # the create call's kwargs to override the values + # set by hooks. + cdict = self._createdict + updates = [] + if not cdict.get("key"): + if not self.db_key: + self.db_key = "#%i" % self.dbid + updates.append("db_key") + elif self.db_key != cdict["key"]: + self.db_key = cdict["key"] + updates.append("db_key") + if cdict.get("interval") and self.interval != cdict["interval"]: + self.db_interval = cdict["interval"] + updates.append("db_interval") + if cdict.get("start_delay") and self.start_delay != cdict["start_delay"]: + self.db_start_delay = cdict["start_delay"] + updates.append("db_start_delay") + if cdict.get("repeats") and self.repeats != cdict["repeats"]: + self.db_repeats = cdict["repeats"] + updates.append("db_repeats") + if cdict.get("persistent") and self.persistent != cdict["persistent"]: + self.db_persistent = cdict["persistent"] + updates.append("db_persistent") + if cdict.get("desc") and self.desc != cdict["desc"]: + self.db_desc = cdict["desc"] + updates.append("db_desc") + if updates: + self.save(update_fields=updates) + + if cdict.get("permissions"): + self.permissions.batch_add(*cdict["permissions"]) + if cdict.get("locks"): + self.locks.add(cdict["locks"]) + if cdict.get("tags"): + # this should be a list of tags, tuples (key, category) or (key, category, data) + self.tags.batch_add(*cdict["tags"]) + if cdict.get("attributes"): + # this should be tuples (key, val, ...) + self.attributes.batch_add(*cdict["attributes"]) + if cdict.get("nattributes"): + # this should be a dict of nattrname:value + for key, value in cdict["nattributes"]: + self.nattributes.add(key, value) + + if not cdict.get("autostart"): + # don't auto-start the script + return + + # auto-start script (default) + self.start() + + +class DefaultScript(ScriptBase): + """ + This is the base TypeClass for all Scripts. Scripts describe + events, timers and states in game, they can have a time component + or describe a state that changes under certain conditions. + + """ + + def at_script_creation(self): + """ + Only called once, when script is first created. + + """ + pass + def time_until_next_repeat(self): """ @@ -472,77 +552,6 @@ class DefaultScript(ScriptBase): if task: task.force_repeat() - def at_first_save(self, **kwargs): - """ - This is called after very first time this object is saved. - Generally, you don't need to overload this, but only the hooks - called by this method. - - Args: - **kwargs (dict): Arbitrary, optional arguments for users - overriding the call (unused by default). - - """ - self.at_script_creation() - - if hasattr(self, "_createdict"): - # this will only be set if the utils.create_script - # function was used to create the object. We want - # the create call's kwargs to override the values - # set by hooks. - cdict = self._createdict - updates = [] - if not cdict.get("key"): - if not self.db_key: - self.db_key = "#%i" % self.dbid - updates.append("db_key") - elif self.db_key != cdict["key"]: - self.db_key = cdict["key"] - updates.append("db_key") - if cdict.get("interval") and self.interval != cdict["interval"]: - self.db_interval = cdict["interval"] - updates.append("db_interval") - if cdict.get("start_delay") and self.start_delay != cdict["start_delay"]: - self.db_start_delay = cdict["start_delay"] - updates.append("db_start_delay") - if cdict.get("repeats") and self.repeats != cdict["repeats"]: - self.db_repeats = cdict["repeats"] - updates.append("db_repeats") - if cdict.get("persistent") and self.persistent != cdict["persistent"]: - self.db_persistent = cdict["persistent"] - updates.append("db_persistent") - if updates: - self.save(update_fields=updates) - - if cdict.get("permissions"): - self.permissions.batch_add(*cdict["permissions"]) - if cdict.get("locks"): - self.locks.add(cdict["locks"]) - if cdict.get("tags"): - # this should be a list of tags, tuples (key, category) or (key, category, data) - self.tags.batch_add(*cdict["tags"]) - if cdict.get("attributes"): - # this should be tuples (key, val, ...) - self.attributes.batch_add(*cdict["attributes"]) - if cdict.get("nattributes"): - # this should be a dict of nattrname:value - for key, value in cdict["nattributes"]: - self.nattributes.add(key, value) - - if not cdict.get("autostart"): - # don't auto-start the script - return - - # auto-start script (default) - self.start() - - def at_script_creation(self): - """ - Only called once, by the create function. - - """ - pass - def is_valid(self): """ Is called to check if the script is valid to run at this time. diff --git a/evennia/utils/create.py b/evennia/utils/create.py index c5fb6f8416..36db7e5a60 100644 --- a/evennia/utils/create.py +++ b/evennia/utils/create.py @@ -101,6 +101,7 @@ def create_object(typeclass=None, key=None, location=None, home=None, locks = make_iter(locks) if locks is not None else None aliases = make_iter(aliases) if aliases is not None else None tags = make_iter(tags) if tags is not None else None + attributes = make_iter(attributes) if attributes is not None else None if isinstance(typeclass, basestring): @@ -177,7 +178,9 @@ def create_script(typeclass=None, key=None, obj=None, account=None, locks=None, created or if the `start` method must be called explicitly. report_to (Object): The object to return error messages to. desc (str): Optional description of script - + tags (list): List of tags or tuples (tag, category). + attributes (list): List if tuples (key, value) or (key, value, category) + (key, value, lockstring) or (key, value, lockstring, default_access). See evennia.scripts.manager for methods to manipulate existing scripts in the database. @@ -198,9 +201,9 @@ def create_script(typeclass=None, key=None, obj=None, account=None, locks=None, if key: kwarg["db_key"] = key if account: - kwarg["db_account"] = dbid_to_obj(account, _ScriptDB) + kwarg["db_account"] = dbid_to_obj(account, _AccountDB) if obj: - kwarg["db_obj"] = dbid_to_obj(obj, _ScriptDB) + kwarg["db_obj"] = dbid_to_obj(obj, _ObjectDB) if interval: kwarg["db_interval"] = interval if start_delay: @@ -211,6 +214,8 @@ def create_script(typeclass=None, key=None, obj=None, account=None, locks=None, kwarg["db_persistent"] = persistent if desc: kwarg["db_desc"] = desc + tags = make_iter(tags) if tags is not None else None + attributes = make_iter(attributes) if attributes is not None else None # create new instance new_script = typeclass(**kwarg) @@ -218,7 +223,8 @@ def create_script(typeclass=None, key=None, obj=None, account=None, locks=None, # store the call signature for the signal new_script._createdict = dict(key=key, obj=obj, account=account, locks=locks, interval=interval, start_delay=start_delay, repeats=repeats, persistent=persistent, - autostart=autostart, report_to=report_to) + autostart=autostart, report_to=report_to, desc=desc, + tags=tags, attributes=attributes) # this will trigger the save signal which in turn calls the # at_first_save hook on the typeclass, where the _createdict # can be used. diff --git a/evennia/utils/olc/olc_storage.py b/evennia/utils/olc/olc_storage.py index d64956f8a0..f96a79edb2 100644 --- a/evennia/utils/olc/olc_storage.py +++ b/evennia/utils/olc/olc_storage.py @@ -18,8 +18,9 @@ class PersistentPrototype(DefaultScript): """ This stores a single prototype """ - key = "persistent_prototype" - desc = "Stores a prototoype" + def at_script_creation(self): + self.key = "empty prototype" + self.desc = "A prototype" def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete=False): @@ -64,7 +65,7 @@ def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete if desc: stored_prototype.desc = desc if tags: - stored_prototype.tags.add(tags) + stored_prototype.tags.batch_add(*tags) if locks: stored_prototype.locks.add(locks) if prototype: @@ -95,12 +96,13 @@ def search_prototype(key=None, tags=None): be found. """ - matches = PersistentPrototype.objects.all() if tags: # exact match on tag(s) tags = make_iter(tags) - tag_categories = ("persistent_prototype" for _ in tags) - matches = matches.get_by_tag(tags, tag_categories) + tag_categories = ["persistent_prototype" for _ in tags] + matches = PersistentPrototype.objects.get_by_tag(tags, tag_categories) + else: + matches = PersistentPrototype.objects.all() if key: # partial match on key matches = matches.filter(db_key=key) or matches.filter(db_key__icontains=key) @@ -142,8 +144,8 @@ def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non table = [] for i in range(len(prototypes[0])): - table.append([tup[i] for tup in prototypes]) - table = EvTable("Key", "Desc", "Use", "Edit", table, crop=True, width=78) + table.append([str(tup[i]) for tup in prototypes]) + table = EvTable("Key", "Desc", "Use", "Edit", table=table, crop=True, width=78) table.reformat_column(0, width=28) table.reformat_column(1, width=40) table.reformat_column(2, width=5) From 3e7dae6a8c4e9bbb86b3875d8ed56aa8f5de03c8 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 3 Mar 2018 14:57:55 +0100 Subject: [PATCH 208/466] Refactor, include readonly prototypes --- evennia/utils/olc/olc_storage.py | 148 +++++++++++++++++++++++++++---- 1 file changed, 131 insertions(+), 17 deletions(-) diff --git a/evennia/utils/olc/olc_storage.py b/evennia/utils/olc/olc_storage.py index f96a79edb2..a10c0d508b 100644 --- a/evennia/utils/olc/olc_storage.py +++ b/evennia/utils/olc/olc_storage.py @@ -6,13 +6,41 @@ available in a repository for buildiers to use. Each prototype is stored in a Script so that it can be tagged for quick sorting/finding and locked for limiting access. +This system also takes into consideration prototypes defined and stored in modules. +Such prototypes are considered 'read-only' to the system and can only be modified +in code. To replace a default prototype, add the same-name prototype in a +custom module read later in the settings.PROTOTYPE_MODULES list. To remove a default +prototype, override its name with an empty dict. + """ +from django.conf import settings from evennia.scripts.scripts import DefaultScript from evennia.utils.create import create_script -from evennia.utils.utils import make_iter +from evennia.utils.utils import make_iter, all_from_module from evennia.utils.evtable import EvTable +# prepare the available prototypes defined in modules + +_READONLY_PROTOTYPES = {} +_READONLY_PROTOTYPE_MODULES = {} + +for mod in settings.PROTOTYPE_MODULES: + # to remove a default prototype, override it with an empty dict. + # internally we store as (key, desc, locks, tags, prototype_dict) + prots = [(key, prot) for key, prot in all_from_module(mod).items() + if prot and isinstance(prot, dict)] + _READONLY_PROTOTYPES.update( + {key.lower(): + (key.lower(), + prot['prototype_desc'] if 'prototype_desc' in prot else mod, + prot['prototype_lock'] if 'prototype_lock' in prot else "use:all()", + set(make_iter( + prot['prototype_tags']) if 'prototype_tags' in prot else ["base-prototype"]), + prot) + for key, prot in prots}) + _READONLY_PROTOTYPE_MODULES.update({tup[0]: mod for tup in prots}) + class PersistentPrototype(DefaultScript): """ @@ -46,17 +74,25 @@ def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete Raises: PermissionError: If edit lock was not passed by caller. + """ + key_orig = key key = key.lower() locks = locks if locks else "use:all();edit:id({}) or edit:perm(Admin)".format(caller.id) tags = [(tag, "persistent_prototype") for tag in make_iter(tags)] + if key in _READONLY_PROTOTYPES: + mod = _READONLY_PROTOTYPE_MODULES.get(key, "N/A") + raise PermissionError("{} is a read-only prototype " + "(defined as code in {}).".format(key_orig, mod)) + stored_prototype = PersistentPrototype.objects.filter(db_key=key) if stored_prototype: stored_prototype = stored_prototype[0] if not stored_prototype.access(caller, 'edit'): - PermissionError("{} does not have permission to edit prototype {}".format(caller, key)) + raise PermissionError("{} does not have permission to " + "edit prototype {}".format(caller, key)) if delete: stored_prototype.delete() @@ -77,9 +113,9 @@ def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete return stored_prototype -def search_prototype(key=None, tags=None): +def search_persistent_prototype(key=None, tags=None): """ - Find prototypes based on key and/or tags. + Find persistent (database-stored) prototypes based on key and/or tags. Kwargs: key (str): An exact or partial key to query for. @@ -87,13 +123,10 @@ def search_prototype(key=None, tags=None): will always be applied with the 'persistent_protototype' tag category. Return: - matches (queryset): All found PersistentPrototypes. This will - be all prototypes if no arguments are given. + matches (queryset): All found PersistentPrototypes Note: - This will use the tags to make a subselection before attempting - to match on the key. So if key/tags don't match up nothing will - be found. + This will not include read-only prototypes defined in modules. """ if tags: @@ -109,6 +142,68 @@ def search_prototype(key=None, tags=None): return matches +def search_readonly_prototype(key=None, tags=None): + """ + Find read-only prototypes, defined in modules. + + Kwargs: + key (str): An exact or partial key to query for. + tags (str or list): Tag key to query for. + + Return: + matches (list): List of prototype tuples that includes + prototype metadata, on the form + `(key, desc, lockstring, taglist, prototypedict)` + + """ + matches = [] + if tags: + # use tags to limit selection + tagset = set(tags) + matches = {key: tup for key, tup in _READONLY_PROTOTYPES.items() + if tagset.intersection(tup[3])} + else: + matches = _READONLY_PROTOTYPES + + if key: + if key in matches: + # exact match + return matches[key] + else: + # fuzzy matching + return [tup for pkey, tup in matches.items() if key in pkey] + return matches + + +def search_prototype(key=None, tags=None): + """ + Find prototypes based on key and/or tags. + + Kwargs: + key (str): An exact or partial key to query for. + tags (str or list): Tag key or keys to query for. These + will always be applied with the 'persistent_protototype' + tag category. + Return: + matches (list): All found prototype dicts. + + Note: + The available prototypes is a combination of those supplied in + PROTOTYPE_MODULES and those stored from in-game. For the latter, + this will use the tags to make a subselection before attempting + to match on the key. So if key/tags don't match up nothing will + be found. + + """ + matches = [] + if key and key in _READONLY_PROTOTYPES: + matches.append(_READONLY_PROTOTYPES[key][3]) + else: + matches.extend([prot.attributes.get("prototype") + for prot in search_persistent_prototype(key, tags)]) + return matches + + def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): """ Collate a list of found prototypes based on search criteria and access. @@ -124,20 +219,39 @@ def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non if no prototypes were found. """ - prototypes = search_prototype(key, tags) + # handle read-only prototypes separately + if key and key in _READONLY_PROTOTYPES: + readonly_prototypes = _READONLY_PROTOTYPES[key] + else: + readonly_prototypes = _READONLY_PROTOTYPES.values() + + # get use-permissions of readonly attributes (edit is always False) + readonly_prototypes = [ + (tup[0], + tup[1], + ("{}/N".format('Y' + if caller.locks.check_lockstring(caller, tup[2], access_type='use') else 'N')), + ",".join(tup[3])) for tup in readonly_prototypes] + + # next, handle db-stored prototypes + prototypes = search_persistent_prototype(key, tags) if not prototypes: return None - # gather access permissions as (key, desc, can_use, can_edit) + # gather access permissions as (key, desc, tags, can_use, can_edit) prototypes = [(prototype.key, prototype.desc, - prototype.access(caller, "use"), prototype.access(caller, "edit")) + "{}/{}".format('Y' if prototype.access(caller, "use") else 'N', + 'Y' if prototype.access(caller, "edit") else 'N'), + ",".join(prototype.tags.get(category="persistent_prototype"))) for prototype in prototypes] + prototypes = prototypes + readonly_prototypes + if not show_non_use: - prototypes = [tup for tup in prototypes if tup[2]] + prototypes = [tup for tup in sorted(prototypes, key=lambda o: o[0]) if tup[2]] if not show_non_edit: - prototypes = [tup for tup in prototypes if tup[3]] + prototypes = [tup for tup in sorted(prototypes, key=lambda o: o[0]) if tup[3]] if not prototypes: return None @@ -145,9 +259,9 @@ def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non table = [] for i in range(len(prototypes[0])): table.append([str(tup[i]) for tup in prototypes]) - table = EvTable("Key", "Desc", "Use", "Edit", table=table, crop=True, width=78) + table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=78) table.reformat_column(0, width=28) table.reformat_column(1, width=40) - table.reformat_column(2, width=5) - table.reformat_column(3, width=5) + table.reformat_column(2, width=11, align='r') + table.reformat_column(3, width=20) return table From a7eed91d903ddd09a0c65be6dce8122bab40acd2 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 3 Mar 2018 15:19:18 +0100 Subject: [PATCH 209/466] Use namedtuples for internal meta info --- evennia/utils/olc/olc_storage.py | 37 +++++++++++++++++--------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/evennia/utils/olc/olc_storage.py b/evennia/utils/olc/olc_storage.py index a10c0d508b..7c4adad5c4 100644 --- a/evennia/utils/olc/olc_storage.py +++ b/evennia/utils/olc/olc_storage.py @@ -14,6 +14,7 @@ prototype, override its name with an empty dict. """ +from collections import namedtuple from django.conf import settings from evennia.scripts.scripts import DefaultScript from evennia.utils.create import create_script @@ -25,19 +26,22 @@ from evennia.utils.evtable import EvTable _READONLY_PROTOTYPES = {} _READONLY_PROTOTYPE_MODULES = {} +# storage of meta info about the prototype +MetaProto = namedtuple('MetaProto', ['key', 'desc', 'locks', 'tags', 'prototype']) + for mod in settings.PROTOTYPE_MODULES: # to remove a default prototype, override it with an empty dict. # internally we store as (key, desc, locks, tags, prototype_dict) prots = [(key, prot) for key, prot in all_from_module(mod).items() if prot and isinstance(prot, dict)] _READONLY_PROTOTYPES.update( - {key.lower(): - (key.lower(), - prot['prototype_desc'] if 'prototype_desc' in prot else mod, - prot['prototype_lock'] if 'prototype_lock' in prot else "use:all()", - set(make_iter( - prot['prototype_tags']) if 'prototype_tags' in prot else ["base-prototype"]), - prot) + {key.lower(): MetaProto( + key.lower(), + prot['prototype_desc'] if 'prototype_desc' in prot else mod, + prot['prototype_lock'] if 'prototype_lock' in prot else "use:all()", + set(make_iter( + prot['prototype_tags']) if 'prototype_tags' in prot else ["base-prototype"]), + prot) for key, prot in prots}) _READONLY_PROTOTYPE_MODULES.update({tup[0]: mod for tup in prots}) @@ -151,17 +155,16 @@ def search_readonly_prototype(key=None, tags=None): tags (str or list): Tag key to query for. Return: - matches (list): List of prototype tuples that includes - prototype metadata, on the form - `(key, desc, lockstring, taglist, prototypedict)` + matches (list): List of MetaProto tuples that includes + prototype metadata, """ matches = [] if tags: # use tags to limit selection tagset = set(tags) - matches = {key: tup for key, tup in _READONLY_PROTOTYPES.items() - if tagset.intersection(tup[3])} + matches = {key: metaproto for key, metaproto in _READONLY_PROTOTYPES.items() + if tagset.intersection(metaproto.tags)} else: matches = _READONLY_PROTOTYPES @@ -171,7 +174,7 @@ def search_readonly_prototype(key=None, tags=None): return matches[key] else: # fuzzy matching - return [tup for pkey, tup in matches.items() if key in pkey] + return [metaproto for pkey, metaproto in matches.items() if key in pkey] return matches @@ -227,11 +230,11 @@ def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non # get use-permissions of readonly attributes (edit is always False) readonly_prototypes = [ - (tup[0], - tup[1], + (tup.key, + tup.desc, ("{}/N".format('Y' - if caller.locks.check_lockstring(caller, tup[2], access_type='use') else 'N')), - ",".join(tup[3])) for tup in readonly_prototypes] + if caller.locks.check_lockstring(caller, tup.locks, access_type='use') else 'N')), + ",".join(tup.tags)) for tup in readonly_prototypes] # next, handle db-stored prototypes prototypes = search_persistent_prototype(key, tags) From 8c4ceea4cd9b35f67cc18c440a14e559d85bbfd9 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 3 Mar 2018 16:24:17 +0100 Subject: [PATCH 210/466] Use readonly-search for prototypes --- evennia/utils/olc/olc_storage.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/evennia/utils/olc/olc_storage.py b/evennia/utils/olc/olc_storage.py index 7c4adad5c4..113d0296cb 100644 --- a/evennia/utils/olc/olc_storage.py +++ b/evennia/utils/olc/olc_storage.py @@ -159,7 +159,7 @@ def search_readonly_prototype(key=None, tags=None): prototype metadata, """ - matches = [] + matches = {} if tags: # use tags to limit selection tagset = set(tags) @@ -171,11 +171,12 @@ def search_readonly_prototype(key=None, tags=None): if key: if key in matches: # exact match - return matches[key] + return [matches[key]] else: # fuzzy matching return [metaproto for pkey, metaproto in matches.items() if key in pkey] - return matches + else: + return [match for match in matches.values()] def search_prototype(key=None, tags=None): @@ -223,10 +224,7 @@ def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non """ # handle read-only prototypes separately - if key and key in _READONLY_PROTOTYPES: - readonly_prototypes = _READONLY_PROTOTYPES[key] - else: - readonly_prototypes = _READONLY_PROTOTYPES.values() + readonly_prototypes = search_readonly_prototype(key, tags) # get use-permissions of readonly attributes (edit is always False) readonly_prototypes = [ @@ -239,9 +237,6 @@ def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non # next, handle db-stored prototypes prototypes = search_persistent_prototype(key, tags) - if not prototypes: - return None - # gather access permissions as (key, desc, tags, can_use, can_edit) prototypes = [(prototype.key, prototype.desc, "{}/{}".format('Y' if prototype.access(caller, "use") else 'N', @@ -251,6 +246,9 @@ def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non prototypes = prototypes + readonly_prototypes + if not prototypes: + return None + if not show_non_use: prototypes = [tup for tup in sorted(prototypes, key=lambda o: o[0]) if tup[2]] if not show_non_edit: From 1dbbec0eba6d2a0f69ada146ee44f90ce915f80f Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 3 Mar 2018 18:29:37 +0100 Subject: [PATCH 211/466] Start expanding spawn command for prot-storage --- evennia/commands/default/building.py | 33 +++- evennia/utils/olc/olc_storage.py | 17 +- evennia/utils/spawner.py | 276 ++++++++++++++++++++++++++- 3 files changed, 310 insertions(+), 16 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 853ca6b88f..6cbef476d4 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -12,7 +12,7 @@ from evennia.commands.cmdhandler import get_and_merge_cmdsets from evennia.utils import create, utils, search from evennia.utils.utils import inherits_from, class_from_module from evennia.utils.eveditor import EvEditor -from evennia.utils.spawner import spawn +from evennia.utils.spawner import spawn, search_prototype, list_prototypes from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -2731,17 +2731,29 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): spawn objects from prototype Usage: - @spawn - @spawn[/switch] - @spawn[/switch] {prototype dictionary} + @spawn[/noloc] + @spawn[/noloc] - Switch: + @spawn/search [query] + @spawn/list [tag, tag] + @spawn/show + + @spawn/save [;desc[;tag,tag,..[;lockstring]]] + @spawn/menu + + Switches: noloc - allow location to be None if not specified explicitly. Otherwise, location will default to caller's current location. + search - search prototype by name or tags. + list - list available prototypes, optionally limit by tags. + show - inspect prototype by key. + save - save a prototype to the database. It will be listable by /list. + menu - manipulate prototype in a menu interface. Example: @spawn GOBLIN @spawn {"key":"goblin", "typeclass":"monster.Monster", "location":"#2"} + @spawn/save {"key": "grunt", prototype: "goblin"};;mobs;edit:all() Dictionary keys: |wprototype |n - name of parent prototype to use. Can be a list for @@ -2760,12 +2772,16 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): The available prototypes are defined globally in modules set in settings.PROTOTYPE_MODULES. If @spawn is used without arguments it displays a list of available prototypes. + """ key = "@spawn" locks = "cmd:perm(spawn) or perm(Builder)" help_category = "Building" + def parser(self): + super(CmdSpawn, self).parser() + def func(self): """Implements the spawner""" @@ -2774,6 +2790,13 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): prots = ", ".join(sorted(prototypes.keys())) return "\nAvailable prototypes (case sensitive): %s" % ( "\n" + utils.fill(prots) if prots else "None") + caller = self.caller + + if not self.args: + ncount = len(search_prototype()) + caller.msg("Usage: @spawn or {key: value, ...}" + "\n ({} existing prototypes. Use /list to inspect)".format(ncount)) + return prototypes = spawn(return_prototypes=True) if not self.args: diff --git a/evennia/utils/olc/olc_storage.py b/evennia/utils/olc/olc_storage.py index 113d0296cb..cda1e6d0eb 100644 --- a/evennia/utils/olc/olc_storage.py +++ b/evennia/utils/olc/olc_storage.py @@ -228,11 +228,12 @@ def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non # get use-permissions of readonly attributes (edit is always False) readonly_prototypes = [ - (tup.key, - tup.desc, + (metaproto.key, + metaproto.desc, ("{}/N".format('Y' - if caller.locks.check_lockstring(caller, tup.locks, access_type='use') else 'N')), - ",".join(tup.tags)) for tup in readonly_prototypes] + if caller.locks.check_lockstring(caller, metaproto.locks, access_type='use') else 'N')), + ",".join(metaproto.tags)) + for metaproto in sorted(readonly_prototypes, key=lambda o: o.key)] # next, handle db-stored prototypes prototypes = search_persistent_prototype(key, tags) @@ -242,7 +243,7 @@ def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non "{}/{}".format('Y' if prototype.access(caller, "use") else 'N', 'Y' if prototype.access(caller, "edit") else 'N'), ",".join(prototype.tags.get(category="persistent_prototype"))) - for prototype in prototypes] + for prototype in sorted(prototypes, key=lambda o: o.key)] prototypes = prototypes + readonly_prototypes @@ -250,16 +251,16 @@ def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non return None if not show_non_use: - prototypes = [tup for tup in sorted(prototypes, key=lambda o: o[0]) if tup[2]] + prototypes = [metaproto for metaproto in prototypes if metaproto[2].split("/", 1)[0] == 'Y'] if not show_non_edit: - prototypes = [tup for tup in sorted(prototypes, key=lambda o: o[0]) if tup[3]] + prototypes = [metaproto for metaproto in prototypes if metaproto[2].split("/", 1)[1] == 'Y'] if not prototypes: return None table = [] for i in range(len(prototypes[0])): - table.append([str(tup[i]) for tup in prototypes]) + table.append([str(metaproto[i]) for metaproto in prototypes]) table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=78) table.reformat_column(0, width=28) table.reformat_column(1, width=40) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 86b24e5e4f..d1d4bea920 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -86,6 +86,21 @@ The *goblin archwizard* will have some different attacks, but will otherwise have the same spells as a *goblin wizard* who in turn shares many traits with a normal *goblin*. + +Storage mechanism: + +This sets up a central storage for prototypes. The idea is to make these +available in a repository for buildiers to use. Each prototype is stored +in a Script so that it can be tagged for quick sorting/finding and locked for limiting +access. + +This system also takes into consideration prototypes defined and stored in modules. +Such prototypes are considered 'read-only' to the system and can only be modified +in code. To replace a default prototype, add the same-name prototype in a +custom module read later in the settings.PROTOTYPE_MODULES list. To remove a default +prototype, override its name with an empty dict. + + """ from __future__ import print_function @@ -96,7 +111,35 @@ import evennia from evennia.objects.models import ObjectDB from evennia.utils.utils import make_iter, all_from_module, dbid_to_obj +from collections import namedtuple +from evennia.scripts.scripts import DefaultScript +from evennia.utils.create import create_script +from evennia.utils.evtable import EvTable + + _CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") +_READONLY_PROTOTYPES = {} +_READONLY_PROTOTYPE_MODULES = {} + + +# storage of meta info about the prototype +MetaProto = namedtuple('MetaProto', ['key', 'desc', 'locks', 'tags', 'prototype']) + +for mod in settings.PROTOTYPE_MODULES: + # to remove a default prototype, override it with an empty dict. + # internally we store as (key, desc, locks, tags, prototype_dict) + prots = [(key, prot) for key, prot in all_from_module(mod).items() + if prot and isinstance(prot, dict)] + _READONLY_PROTOTYPES.update( + {key.lower(): MetaProto( + key.lower(), + prot['prototype_desc'] if 'prototype_desc' in prot else mod, + prot['prototype_lock'] if 'prototype_lock' in prot else "use:all()", + set(make_iter( + prot['prototype_tags']) if 'prototype_tags' in prot else ["base-prototype"]), + prot) + for key, prot in prots}) + _READONLY_PROTOTYPE_MODULES.update({tup[0]: mod for tup in prots}) def _handle_dbref(inp): @@ -119,7 +162,8 @@ def _validate_prototype(key, prototype, protparents, visited): raise RuntimeError("%s tries to prototype itself." % key or prototype) protparent = protparents.get(protstring) if not protparent: - raise RuntimeError("%s's prototype '%s' was not found." % (key or prototype, protstring)) + raise RuntimeError( + "%s's prototype '%s' was not found." % (key or prototype, protstring)) _validate_prototype(protstring, protparent, protparents, visited) @@ -303,9 +347,235 @@ def spawn(*prototypes, **kwargs): return _batch_create_object(*objsparams) -if __name__ == "__main__": - # testing +# Prototype storage mechanisms + +class PersistentPrototype(DefaultScript): + """ + This stores a single prototype + """ + def at_script_creation(self): + self.key = "empty prototype" + self.desc = "A prototype" + + +def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete=False): + """ + Store a prototype persistently. + + Args: + caller (Account or Object): Caller aiming to store prototype. At this point + the caller should have permission to 'add' new prototypes, but to edit + an existing prototype, the 'edit' lock must be passed on that prototype. + key (str): Name of prototype to store. + prototype (dict): Prototype dict. + desc (str, optional): Description of prototype, to use in listing. + tags (list, optional): Tag-strings to apply to prototype. These are always + applied with the 'persistent_prototype' category. + locks (str, optional): Locks to apply to this prototype. Used locks + are 'use' and 'edit' + delete (bool, optional): Delete an existing prototype identified by 'key'. + This requires `caller` to pass the 'edit' lock of the prototype. + Returns: + stored (StoredPrototype or None): The resulting prototype (new or edited), + or None if deleting. + Raises: + PermissionError: If edit lock was not passed by caller. + + + """ + key_orig = key + key = key.lower() + locks = locks if locks else "use:all();edit:id({}) or edit:perm(Admin)".format(caller.id) + tags = [(tag, "persistent_prototype") for tag in make_iter(tags)] + + if key in _READONLY_PROTOTYPES: + mod = _READONLY_PROTOTYPE_MODULES.get(key, "N/A") + raise PermissionError("{} is a read-only prototype " + "(defined as code in {}).".format(key_orig, mod)) + + stored_prototype = PersistentPrototype.objects.filter(db_key=key) + + if stored_prototype: + stored_prototype = stored_prototype[0] + if not stored_prototype.access(caller, 'edit'): + raise PermissionError("{} does not have permission to " + "edit prototype {}".format(caller, key)) + + if delete: + stored_prototype.delete() + return + + if desc: + stored_prototype.desc = desc + if tags: + stored_prototype.tags.batch_add(*tags) + if locks: + stored_prototype.locks.add(locks) + if prototype: + stored_prototype.attributes.add("prototype", prototype) + else: + stored_prototype = create_script( + PersistentPrototype, key=key, desc=desc, persistent=True, + locks=locks, tags=tags, attributes=[("prototype", prototype)]) + return stored_prototype + + +def search_persistent_prototype(key=None, tags=None): + """ + Find persistent (database-stored) prototypes based on key and/or tags. + + Kwargs: + key (str): An exact or partial key to query for. + tags (str or list): Tag key or keys to query for. These + will always be applied with the 'persistent_protototype' + tag category. + Return: + matches (queryset): All found PersistentPrototypes + + Note: + This will not include read-only prototypes defined in modules. + + """ + if tags: + # exact match on tag(s) + tags = make_iter(tags) + tag_categories = ["persistent_prototype" for _ in tags] + matches = PersistentPrototype.objects.get_by_tag(tags, tag_categories) + else: + matches = PersistentPrototype.objects.all() + if key: + # partial match on key + matches = matches.filter(db_key=key) or matches.filter(db_key__icontains=key) + return matches + + +def search_readonly_prototype(key=None, tags=None): + """ + Find read-only prototypes, defined in modules. + + Kwargs: + key (str): An exact or partial key to query for. + tags (str or list): Tag key to query for. + + Return: + matches (list): List of MetaProto tuples that includes + prototype metadata, + + """ + matches = {} + if tags: + # use tags to limit selection + tagset = set(tags) + matches = {key: metaproto for key, metaproto in _READONLY_PROTOTYPES.items() + if tagset.intersection(metaproto.tags)} + else: + matches = _READONLY_PROTOTYPES + + if key: + if key in matches: + # exact match + return [matches[key]] + else: + # fuzzy matching + return [metaproto for pkey, metaproto in matches.items() if key in pkey] + else: + return [match for match in matches.values()] + + +def search_prototype(key=None, tags=None): + """ + Find prototypes based on key and/or tags. + + Kwargs: + key (str): An exact or partial key to query for. + tags (str or list): Tag key or keys to query for. These + will always be applied with the 'persistent_protototype' + tag category. + Return: + matches (list): All found prototype dicts. + + Note: + The available prototypes is a combination of those supplied in + PROTOTYPE_MODULES and those stored from in-game. For the latter, + this will use the tags to make a subselection before attempting + to match on the key. So if key/tags don't match up nothing will + be found. + + """ + matches = [] + if key and key in _READONLY_PROTOTYPES: + matches.append(_READONLY_PROTOTYPES[key][3]) + else: + matches.extend([prot.attributes.get("prototype") + for prot in search_persistent_prototype(key, tags)]) + return matches + + +def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): + """ + Collate a list of found prototypes based on search criteria and access. + + Args: + caller (Account or Object): The object requesting the list. + key (str, optional): Exact or partial key to query for. + tags (str or list, optional): Tag key or keys to query for. + show_non_use (bool, optional): Show also prototypes the caller may not use. + show_non_edit (bool, optional): Show also prototypes the caller may not edit. + Returns: + table (EvTable or None): An EvTable representation of the prototypes. None + if no prototypes were found. + + """ + # handle read-only prototypes separately + readonly_prototypes = search_readonly_prototype(key, tags) + + # get use-permissions of readonly attributes (edit is always False) + readonly_prototypes = [ + (metaproto.key, + metaproto.desc, + ("{}/N".format('Y' + if caller.locks.check_lockstring(caller, metaproto.locks, access_type='use') else 'N')), + ",".join(metaproto.tags)) + for metaproto in sorted(readonly_prototypes, key=lambda o: o.key)] + + # next, handle db-stored prototypes + prototypes = search_persistent_prototype(key, tags) + + # gather access permissions as (key, desc, tags, can_use, can_edit) + prototypes = [(prototype.key, prototype.desc, + "{}/{}".format('Y' if prototype.access(caller, "use") else 'N', + 'Y' if prototype.access(caller, "edit") else 'N'), + ",".join(prototype.tags.get(category="persistent_prototype"))) + for prototype in sorted(prototypes, key=lambda o: o.key)] + + prototypes = prototypes + readonly_prototypes + + if not prototypes: + return None + + if not show_non_use: + prototypes = [metaproto for metaproto in prototypes if metaproto[2].split("/", 1)[0] == 'Y'] + if not show_non_edit: + prototypes = [metaproto for metaproto in prototypes if metaproto[2].split("/", 1)[1] == 'Y'] + + if not prototypes: + return None + + table = [] + for i in range(len(prototypes[0])): + table.append([str(metaproto[i]) for metaproto in prototypes]) + table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=78) + table.reformat_column(0, width=28) + table.reformat_column(1, width=40) + table.reformat_column(2, width=11, align='r') + table.reformat_column(3, width=20) + return table + + +# Testing + +if __name__ == "__main__": protparents = { "NOBODY": {}, # "INFINITE" : { From f693d56f1471c8f652941f684c91987afc96f215 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 3 Mar 2018 19:55:37 +0100 Subject: [PATCH 212/466] Continue working with new spawn additions --- evennia/commands/default/building.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 6cbef476d4..9bc7915497 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -12,6 +12,7 @@ from evennia.commands.cmdhandler import get_and_merge_cmdsets from evennia.utils import create, utils, search from evennia.utils.utils import inherits_from, class_from_module from evennia.utils.eveditor import EvEditor +from evennia.utils.evmore import EvMore from evennia.utils.spawner import spawn, search_prototype, list_prototypes from evennia.utils.ansi import raw @@ -2736,7 +2737,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): @spawn/search [query] @spawn/list [tag, tag] - @spawn/show + @spawn/show [] @spawn/save [;desc[;tag,tag,..[;lockstring]]] @spawn/menu @@ -2746,7 +2747,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): location will default to caller's current location. search - search prototype by name or tags. list - list available prototypes, optionally limit by tags. - show - inspect prototype by key. + show - inspect prototype by key. If not given, acts like list. save - save a prototype to the database. It will be listable by /list. menu - manipulate prototype in a menu interface. @@ -2792,12 +2793,28 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): "\n" + utils.fill(prots) if prots else "None") caller = self.caller + + if 'show' in self.switches: + # the argument is a key in this case (may be a partial key) + if not self.args: + self.switches.append('list') + else: + EvMore(caller, unicode(list_prototypes(key=self.args), exit_on_lastpage=True)) + return + + if 'list' in self.switches: + # for list, all optional arguments are tags + EvMore(caller, unicode(list_prototypes(tags=self.lhslist)), exit_on_lastpage=True) + return + if not self.args: ncount = len(search_prototype()) caller.msg("Usage: @spawn or {key: value, ...}" "\n ({} existing prototypes. Use /list to inspect)".format(ncount)) return + + prototypes = spawn(return_prototypes=True) if not self.args: string = "Usage: @spawn {key:value, key, value, ... }" From 9e7dc14cbbe44e050231603d2dd50d968217c18a Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 4 Mar 2018 11:39:55 +0100 Subject: [PATCH 213/466] First version of expanded spawn command with storage --- evennia/commands/default/building.py | 175 ++++++++++++---- evennia/commands/default/muxcommand.py | 2 +- evennia/utils/evmore.py | 30 ++- evennia/utils/olc/olc_storage.py | 269 ------------------------- evennia/utils/spawner.py | 33 ++- 5 files changed, 181 insertions(+), 328 deletions(-) delete mode 100644 evennia/utils/olc/olc_storage.py diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 9bc7915497..dfa78ea074 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -13,7 +13,7 @@ from evennia.utils import create, utils, search from evennia.utils.utils import inherits_from, class_from_module from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore -from evennia.utils.spawner import spawn, search_prototype, list_prototypes +from evennia.utils.spawner import spawn, search_prototype, list_prototypes, store_prototype from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -2735,11 +2735,11 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): @spawn[/noloc] @spawn[/noloc] - @spawn/search [query] + @spawn/search [key][;tag[,tag]] @spawn/list [tag, tag] @spawn/show [] - @spawn/save [;desc[;tag,tag,..[;lockstring]]] + @spawn/save [;desc[;tag,tag[,...][;lockstring]]] = @spawn/menu Switches: @@ -2786,73 +2786,164 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): def func(self): """Implements the spawner""" - def _show_prototypes(prototypes): - """Helper to show a list of available prototypes""" - prots = ", ".join(sorted(prototypes.keys())) - return "\nAvailable prototypes (case sensitive): %s" % ( - "\n" + utils.fill(prots) if prots else "None") + def _parse_prototype(inp, allow_key=False): + try: + # make use of _convert_from_string from the SetAttribute command + prototype = _convert_from_string(self, inp) + except SyntaxError: + # this means literal_eval tried to parse a faulty string + string = ("|RCritical Python syntax error in argument. Only primitive " + "Python structures are allowed. \nYou also need to use correct " + "Python syntax. Remember especially to put quotes around all " + "strings inside lists and dicts.|n") + self.caller.msg(string) + return None + if isinstance(prototype, dict): + # an actual prototype. We need to make sure it's safe. Don't allow exec + if "exec" in prototype and not self.caller.check_permstring("Developer"): + self.caller.msg("Spawn aborted: You don't have access to " + "use the 'exec' prototype key.") + return None + elif isinstance(prototype, basestring): + # a prototype key + if allow_key: + return prototype + else: + self.caller.msg("The prototype must be defined as a Python dictionary.") + else: + caller.msg("The prototype must be given either as a Python dictionary or a key") + return None + + + def _search_show_prototype(query): + # prototype detail + strings = [] + metaprots = search_prototype(key=query, return_meta=True) + if metaprots: + for metaprot in metaprots: + header = ( + "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" + "|cdesc:|n {} \n|cprototype:|n ".format( + metaprot.key, ", ".join(metaprot.tags), + metaprot.locks, metaprot.desc)) + prototype = ("{{\n {} \n}}".format("\n ".join("{!r}: {!r},".format(key, value) + for key, value in + sorted(metaprot.prototype.items())).rstrip(","))) + strings.append(header + prototype) + return "\n".join(strings) + else: + return False + caller = self.caller + if 'search' in self.switches: + # query for a key match + if not self.args: + self.switches.append("list") + else: + key, tags = self.args.strip(), None + if ';' in self.args: + key, tags = (part.strip().lower() for part in self.args.split(";", 1)) + tags = [tag.strip() for tag in tags.split(",")] if tags else None + EvMore(caller, unicode(list_prototypes(caller, key=key, tags=tags)), + exit_on_lastpage=True) + return if 'show' in self.switches: # the argument is a key in this case (may be a partial key) if not self.args: self.switches.append('list') else: - EvMore(caller, unicode(list_prototypes(key=self.args), exit_on_lastpage=True)) + matchstring = _search_show_prototype(self.args) + if matchstring: + caller.msg(matchstring) + else: + caller.msg("No prototype '{}' was found.".format(self.args)) return if 'list' in self.switches: # for list, all optional arguments are tags - EvMore(caller, unicode(list_prototypes(tags=self.lhslist)), exit_on_lastpage=True) + EvMore(caller, unicode(list_prototypes(caller, + tags=self.lhslist)), exit_on_lastpage=True) + return + + if 'save' in self.switches: + if not self.args or not self.rhs: + caller.msg("Usage: @spawn/save [;desc[;tag,tag[,...][;lockstring]]] = ") + return + + # handle lhs + parts = self.rhs.split(";", 3) + key, desc, tags, lockstring = "", "", [], "" + nparts = len(parts) + if nparts == 1: + key = parts.strip() + elif nparts == 2: + key, desc = (part.strip() for part in parts) + elif nparts == 3: + key, desc, tags = (part.strip() for part in parts) + tags = [tag.strip().lower() for tag in tags.split(",")] + else: + # lockstrings can itself contain ; + key, desc, tags, lockstring = (part.strip() for part in parts) + tags = [tag.strip().lower() for tag in tags.split(",")] + + # handle rhs: + prototype = _parse_prototype(caller, self.rhs) + if not prototype: + return + + # check for existing prototype + matchstring = _search_show_prototype(key) + if matchstring: + caller.msg("|yExisting saved prototype found:|n\n{}".format(matchstring)) + answer = ("Do you want to replace the existing prototype? Y/[N]") + if not answer.lower() not in ["y", "yes"]: + caller.msg("Save cancelled.") + + # all seems ok. Try to save. + try: + store_prototype(caller, key, prototype, desc=desc, tags=tags, locks=lockstring) + except PermissionError as err: + caller.msg("|rError saving:|R {}|n".format(err)) + return + caller.msg("Saved prototype:") + caller.execute_cmd("spawn/show {}".format(key)) return if not self.args: ncount = len(search_prototype()) - caller.msg("Usage: @spawn or {key: value, ...}" + caller.msg("Usage: @spawn or {{key: value, ...}}" "\n ({} existing prototypes. Use /list to inspect)".format(ncount)) return + # A direct creation of an object from a given prototype - - prototypes = spawn(return_prototypes=True) - if not self.args: - string = "Usage: @spawn {key:value, key, value, ... }" - self.caller.msg(string + _show_prototypes(prototypes)) - return - try: - # make use of _convert_from_string from the SetAttribute command - prototype = _convert_from_string(self, self.args) - except SyntaxError: - # this means literal_eval tried to parse a faulty string - string = "|RCritical Python syntax error in argument. " - string += "Only primitive Python structures are allowed. " - string += "\nYou also need to use correct Python syntax. " - string += "Remember especially to put quotes around all " - string += "strings inside lists and dicts.|n" - self.caller.msg(string) + prototype = _parse_prototype(self.args, allow_key=True) + if not prototype: return if isinstance(prototype, basestring): - # A prototype key - keystr = prototype - prototype = prototypes.get(prototype, None) - if not prototype: - string = "No prototype named '%s'." % keystr - self.caller.msg(string + _show_prototypes(prototypes)) + # A prototype key we are looking to apply + metaprotos = search_prototype(prototype) + nprots = len(metaprotos) + if not metaprotos: + caller.msg("No prototype named '%s'." % prototype) return - elif isinstance(prototype, dict): - # we got the prototype on the command line. We must make sure to not allow - # the 'exec' key unless we are developers or higher. - if "exec" in prototype and not self.caller.check_permstring("Developer"): - self.caller.msg("Spawn aborted: You don't have access to use the 'exec' prototype key.") + elif nprots > 1: + caller.msg("Found {} prototypes matching '{}':\n {}".format( + nprots, prototype, ", ".join(metaproto.key for metaproto in metaprotos))) return - else: - self.caller.msg("The prototype must be a prototype key or a Python dictionary.") - return + # we have a metaprot, check access + metaproto = metaprotos[0] + if not caller.locks.check_lockstring(caller, metaproto.locks, access_type='use'): + caller.msg("You don't have access to use this prototype.") + return + prototype = metaproto.prototype if "noloc" not in self.switches and "location" not in prototype: prototype["location"] = self.caller.location + # proceed to spawning for obj in spawn(prototype): self.caller.msg("Spawned %s." % obj.get_display_name(self.caller)) diff --git a/evennia/commands/default/muxcommand.py b/evennia/commands/default/muxcommand.py index 5d8d4b2890..b3a0d066d5 100644 --- a/evennia/commands/default/muxcommand.py +++ b/evennia/commands/default/muxcommand.py @@ -113,7 +113,7 @@ class MuxCommand(Command): # check for arg1, arg2, ... = argA, argB, ... constructs lhs, rhs = args, None - lhslist, rhslist = [arg.strip() for arg in args.split(',')], [] + lhslist, rhslist = [arg.strip() for arg in args.split(',') if arg], [] if args and '=' in args: lhs, rhs = [arg.strip() for arg in args.split('=', 1)] lhslist = [arg.strip() for arg in lhs.split(',')] diff --git a/evennia/utils/evmore.py b/evennia/utils/evmore.py index 169091396b..e0ec091005 100644 --- a/evennia/utils/evmore.py +++ b/evennia/utils/evmore.py @@ -202,15 +202,18 @@ class EvMore(object): # goto top of the text self.page_top() - def display(self): + def display(self, show_footer=True): """ Pretty-print the page. """ pos = self._pos text = self._pages[pos] - page = _DISPLAY.format(text=text, - pageno=pos + 1, - pagemax=self._npages) + if show_footer: + page = _DISPLAY.format(text=text, + pageno=pos + 1, + pagemax=self._npages) + else: + page = text # check to make sure our session is still valid sessions = self._caller.sessions.get() if not sessions: @@ -245,9 +248,11 @@ class EvMore(object): self.page_quit() else: self._pos += 1 - self.display() - if self.exit_on_lastpage and self._pos >= self._npages - 1: - self.page_quit() + if self.exit_on_lastpage and self._pos >= (self._npages - 1): + self.display(show_footer=False) + self.page_quit(quiet=True) + else: + self.display() def page_back(self): """ @@ -256,16 +261,18 @@ class EvMore(object): self._pos = max(0, self._pos - 1) self.display() - def page_quit(self): + def page_quit(self, quiet=False): """ Quit the pager """ del self._caller.ndb._more - self._caller.msg(text=self._exit_msg, **self._kwargs) + if not quiet: + self._caller.msg(text=self._exit_msg, **self._kwargs) self._caller.cmdset.remove(CmdSetMore) -def msg(caller, text="", always_page=False, session=None, justify_kwargs=None, **kwargs): +def msg(caller, text="", always_page=False, session=None, + justify_kwargs=None, exit_on_lastpage=True, **kwargs): """ More-supported version of msg, mimicking the normal msg method. @@ -280,9 +287,10 @@ def msg(caller, text="", always_page=False, session=None, justify_kwargs=None, * justify_kwargs (dict, bool or None, optional): If given, this should be valid keyword arguments to the utils.justify() function. If False, no justification will be done. + exit_on_lastpage (bool, optional): Immediately exit pager when reaching the last page. kwargs (any, optional): These will be passed on to the `caller.msg` method. """ EvMore(caller, text, always_page=always_page, session=session, - justify_kwargs=justify_kwargs, **kwargs) + justify_kwargs=justify_kwargs, exit_on_lastpage=exit_on_lastpage, **kwargs) diff --git a/evennia/utils/olc/olc_storage.py b/evennia/utils/olc/olc_storage.py deleted file mode 100644 index cda1e6d0eb..0000000000 --- a/evennia/utils/olc/olc_storage.py +++ /dev/null @@ -1,269 +0,0 @@ -""" -OLC storage and sharing mechanism. - -This sets up a central storage for prototypes. The idea is to make these -available in a repository for buildiers to use. Each prototype is stored -in a Script so that it can be tagged for quick sorting/finding and locked for limiting -access. - -This system also takes into consideration prototypes defined and stored in modules. -Such prototypes are considered 'read-only' to the system and can only be modified -in code. To replace a default prototype, add the same-name prototype in a -custom module read later in the settings.PROTOTYPE_MODULES list. To remove a default -prototype, override its name with an empty dict. - -""" - -from collections import namedtuple -from django.conf import settings -from evennia.scripts.scripts import DefaultScript -from evennia.utils.create import create_script -from evennia.utils.utils import make_iter, all_from_module -from evennia.utils.evtable import EvTable - -# prepare the available prototypes defined in modules - -_READONLY_PROTOTYPES = {} -_READONLY_PROTOTYPE_MODULES = {} - -# storage of meta info about the prototype -MetaProto = namedtuple('MetaProto', ['key', 'desc', 'locks', 'tags', 'prototype']) - -for mod in settings.PROTOTYPE_MODULES: - # to remove a default prototype, override it with an empty dict. - # internally we store as (key, desc, locks, tags, prototype_dict) - prots = [(key, prot) for key, prot in all_from_module(mod).items() - if prot and isinstance(prot, dict)] - _READONLY_PROTOTYPES.update( - {key.lower(): MetaProto( - key.lower(), - prot['prototype_desc'] if 'prototype_desc' in prot else mod, - prot['prototype_lock'] if 'prototype_lock' in prot else "use:all()", - set(make_iter( - prot['prototype_tags']) if 'prototype_tags' in prot else ["base-prototype"]), - prot) - for key, prot in prots}) - _READONLY_PROTOTYPE_MODULES.update({tup[0]: mod for tup in prots}) - - -class PersistentPrototype(DefaultScript): - """ - This stores a single prototype - """ - def at_script_creation(self): - self.key = "empty prototype" - self.desc = "A prototype" - - -def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete=False): - """ - Store a prototype persistently. - - Args: - caller (Account or Object): Caller aiming to store prototype. At this point - the caller should have permission to 'add' new prototypes, but to edit - an existing prototype, the 'edit' lock must be passed on that prototype. - key (str): Name of prototype to store. - prototype (dict): Prototype dict. - desc (str, optional): Description of prototype, to use in listing. - tags (list, optional): Tag-strings to apply to prototype. These are always - applied with the 'persistent_prototype' category. - locks (str, optional): Locks to apply to this prototype. Used locks - are 'use' and 'edit' - delete (bool, optional): Delete an existing prototype identified by 'key'. - This requires `caller` to pass the 'edit' lock of the prototype. - Returns: - stored (StoredPrototype or None): The resulting prototype (new or edited), - or None if deleting. - Raises: - PermissionError: If edit lock was not passed by caller. - - - """ - key_orig = key - key = key.lower() - locks = locks if locks else "use:all();edit:id({}) or edit:perm(Admin)".format(caller.id) - tags = [(tag, "persistent_prototype") for tag in make_iter(tags)] - - if key in _READONLY_PROTOTYPES: - mod = _READONLY_PROTOTYPE_MODULES.get(key, "N/A") - raise PermissionError("{} is a read-only prototype " - "(defined as code in {}).".format(key_orig, mod)) - - stored_prototype = PersistentPrototype.objects.filter(db_key=key) - - if stored_prototype: - stored_prototype = stored_prototype[0] - if not stored_prototype.access(caller, 'edit'): - raise PermissionError("{} does not have permission to " - "edit prototype {}".format(caller, key)) - - if delete: - stored_prototype.delete() - return - - if desc: - stored_prototype.desc = desc - if tags: - stored_prototype.tags.batch_add(*tags) - if locks: - stored_prototype.locks.add(locks) - if prototype: - stored_prototype.attributes.add("prototype", prototype) - else: - stored_prototype = create_script( - PersistentPrototype, key=key, desc=desc, persistent=True, - locks=locks, tags=tags, attributes=[("prototype", prototype)]) - return stored_prototype - - -def search_persistent_prototype(key=None, tags=None): - """ - Find persistent (database-stored) prototypes based on key and/or tags. - - Kwargs: - key (str): An exact or partial key to query for. - tags (str or list): Tag key or keys to query for. These - will always be applied with the 'persistent_protototype' - tag category. - Return: - matches (queryset): All found PersistentPrototypes - - Note: - This will not include read-only prototypes defined in modules. - - """ - if tags: - # exact match on tag(s) - tags = make_iter(tags) - tag_categories = ["persistent_prototype" for _ in tags] - matches = PersistentPrototype.objects.get_by_tag(tags, tag_categories) - else: - matches = PersistentPrototype.objects.all() - if key: - # partial match on key - matches = matches.filter(db_key=key) or matches.filter(db_key__icontains=key) - return matches - - -def search_readonly_prototype(key=None, tags=None): - """ - Find read-only prototypes, defined in modules. - - Kwargs: - key (str): An exact or partial key to query for. - tags (str or list): Tag key to query for. - - Return: - matches (list): List of MetaProto tuples that includes - prototype metadata, - - """ - matches = {} - if tags: - # use tags to limit selection - tagset = set(tags) - matches = {key: metaproto for key, metaproto in _READONLY_PROTOTYPES.items() - if tagset.intersection(metaproto.tags)} - else: - matches = _READONLY_PROTOTYPES - - if key: - if key in matches: - # exact match - return [matches[key]] - else: - # fuzzy matching - return [metaproto for pkey, metaproto in matches.items() if key in pkey] - else: - return [match for match in matches.values()] - - -def search_prototype(key=None, tags=None): - """ - Find prototypes based on key and/or tags. - - Kwargs: - key (str): An exact or partial key to query for. - tags (str or list): Tag key or keys to query for. These - will always be applied with the 'persistent_protototype' - tag category. - Return: - matches (list): All found prototype dicts. - - Note: - The available prototypes is a combination of those supplied in - PROTOTYPE_MODULES and those stored from in-game. For the latter, - this will use the tags to make a subselection before attempting - to match on the key. So if key/tags don't match up nothing will - be found. - - """ - matches = [] - if key and key in _READONLY_PROTOTYPES: - matches.append(_READONLY_PROTOTYPES[key][3]) - else: - matches.extend([prot.attributes.get("prototype") - for prot in search_persistent_prototype(key, tags)]) - return matches - - -def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): - """ - Collate a list of found prototypes based on search criteria and access. - - Args: - caller (Account or Object): The object requesting the list. - key (str, optional): Exact or partial key to query for. - tags (str or list, optional): Tag key or keys to query for. - show_non_use (bool, optional): Show also prototypes the caller may not use. - show_non_edit (bool, optional): Show also prototypes the caller may not edit. - Returns: - table (EvTable or None): An EvTable representation of the prototypes. None - if no prototypes were found. - - """ - # handle read-only prototypes separately - readonly_prototypes = search_readonly_prototype(key, tags) - - # get use-permissions of readonly attributes (edit is always False) - readonly_prototypes = [ - (metaproto.key, - metaproto.desc, - ("{}/N".format('Y' - if caller.locks.check_lockstring(caller, metaproto.locks, access_type='use') else 'N')), - ",".join(metaproto.tags)) - for metaproto in sorted(readonly_prototypes, key=lambda o: o.key)] - - # next, handle db-stored prototypes - prototypes = search_persistent_prototype(key, tags) - - # gather access permissions as (key, desc, tags, can_use, can_edit) - prototypes = [(prototype.key, prototype.desc, - "{}/{}".format('Y' if prototype.access(caller, "use") else 'N', - 'Y' if prototype.access(caller, "edit") else 'N'), - ",".join(prototype.tags.get(category="persistent_prototype"))) - for prototype in sorted(prototypes, key=lambda o: o.key)] - - prototypes = prototypes + readonly_prototypes - - if not prototypes: - return None - - if not show_non_use: - prototypes = [metaproto for metaproto in prototypes if metaproto[2].split("/", 1)[0] == 'Y'] - if not show_non_edit: - prototypes = [metaproto for metaproto in prototypes if metaproto[2].split("/", 1)[1] == 'Y'] - - if not prototypes: - return None - - table = [] - for i in range(len(prototypes[0])): - table.append([str(metaproto[i]) for metaproto in prototypes]) - table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=78) - table.reformat_column(0, width=28) - table.reformat_column(1, width=40) - table.reformat_column(2, width=11, align='r') - table.reformat_column(3, width=20) - return table diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index d1d4bea920..3b2bec932c 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -483,7 +483,7 @@ def search_readonly_prototype(key=None, tags=None): return [match for match in matches.values()] -def search_prototype(key=None, tags=None): +def search_prototype(key=None, tags=None, return_meta=True): """ Find prototypes based on key and/or tags. @@ -492,8 +492,11 @@ def search_prototype(key=None, tags=None): tags (str or list): Tag key or keys to query for. These will always be applied with the 'persistent_protototype' tag category. + return_meta (bool): If False, only return prototype dicts, if True + return MetaProto namedtuples including prototype meta info + Return: - matches (list): All found prototype dicts. + matches (list): All found prototype dicts or MetaProtos Note: The available prototypes is a combination of those supplied in @@ -505,10 +508,30 @@ def search_prototype(key=None, tags=None): """ matches = [] if key and key in _READONLY_PROTOTYPES: - matches.append(_READONLY_PROTOTYPES[key][3]) + if return_meta: + matches.append(_READONLY_PROTOTYPES[key]) + else: + matches.append(_READONLY_PROTOTYPES[key][3]) + elif tags: + if return_meta: + matches.extend( + [MetaProto(prot.key, prot.desc, prot.locks.all(), + prot.tags.all(), prot.attributes.get("prototype")) + for prot in search_persistent_prototype(key, tags)]) + else: + matches.extend([prot.attributes.get("prototype") + for prot in search_persistent_prototype(key, tags)]) else: - matches.extend([prot.attributes.get("prototype") - for prot in search_persistent_prototype(key, tags)]) + # neither key nor tags given. Return all. + if return_meta: + matches = [MetaProto(prot.key, prot.desc, prot.locks.all(), + prot.tags.all(), prot.attributes.get("prototype")) + for prot in search_persistent_prototype(key, tags)] + \ + list(_READONLY_PROTOTYPES.values()) + else: + matches = [prot.attributes.get("prototype") + for prot in search_persistent_prototype()] + \ + [metaprot[3] for metaprot in _READONLY_PROTOTYPES.values()] return matches From ce602716f1b55fcbe0373e396ae3ea800e9bec88 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 4 Mar 2018 16:25:18 +0100 Subject: [PATCH 214/466] Improve parse of spawn arguments --- evennia/commands/default/building.py | 126 +++++++++++++++------------ evennia/utils/spawner.py | 49 +++++------ 2 files changed, 89 insertions(+), 86 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index dfa78ea074..d493e850b6 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -13,7 +13,8 @@ from evennia.utils import create, utils, search from evennia.utils.utils import inherits_from, class_from_module from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore -from evennia.utils.spawner import spawn, search_prototype, list_prototypes, store_prototype +from evennia.utils.spawner import (spawn, search_prototype, list_prototypes, + store_prototype, build_metaproto) from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -27,12 +28,8 @@ __all__ = ("ObjManipCommand", "CmdSetObjAlias", "CmdCopy", "CmdLock", "CmdExamine", "CmdFind", "CmdTeleport", "CmdScript", "CmdTag", "CmdSpawn") -try: - # used by @set - from ast import literal_eval as _LITERAL_EVAL -except ImportError: - # literal_eval is not available before Python 2.6 - _LITERAL_EVAL = None +# used by @set +from ast import literal_eval as _LITERAL_EVAL # used by @find CHAR_TYPECLASS = settings.BASE_CHARACTER_TYPECLASS @@ -1450,17 +1447,16 @@ def _convert_from_string(cmd, strobj): # if nothing matches, return as-is return obj - if _LITERAL_EVAL: - # Use literal_eval to parse python structure exactly. - try: - return _LITERAL_EVAL(strobj) - except (SyntaxError, ValueError): - # treat as string - strobj = utils.to_str(strobj) - string = "|RNote: name \"|r%s|R\" was converted to a string. " \ - "Make sure this is acceptable." % strobj - cmd.caller.msg(string) - return strobj + # Use literal_eval to parse python structure exactly. + try: + return _LITERAL_EVAL(strobj) + except (SyntaxError, ValueError): + # treat as string + strobj = utils.to_str(strobj) + string = "|RNote: name \"|r%s|R\" was converted to a string. " \ + "Make sure this is acceptable." % strobj + cmd.caller.msg(string) + return strobj else: # fall back to old recursive solution (does not support # nested lists/dicts) @@ -2786,46 +2782,44 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): def func(self): """Implements the spawner""" - def _parse_prototype(inp, allow_key=False): + def _parse_prototype(inp, expect=dict): + err = None try: - # make use of _convert_from_string from the SetAttribute command - prototype = _convert_from_string(self, inp) - except SyntaxError: - # this means literal_eval tried to parse a faulty string - string = ("|RCritical Python syntax error in argument. Only primitive " - "Python structures are allowed. \nYou also need to use correct " - "Python syntax. Remember especially to put quotes around all " - "strings inside lists and dicts.|n") - self.caller.msg(string) - return None - if isinstance(prototype, dict): + prototype = _LITERAL_EVAL(inp) + except (SyntaxError, ValueError) as err: + # treat as string + prototype = utils.to_str(inp) + finally: + if not isinstance(prototype, expect): + if err: + string = ("{}\n|RCritical Python syntax error in argument. Only primitive " + "Python structures are allowed. \nYou also need to use correct " + "Python syntax. Remember especially to put quotes around all " + "strings inside lists and dicts.|n".format(err)) + else: + string = "Expected {}, got {}.".format(expect, type(prototype)) + self.caller.msg(string) + return None + if expect == dict: # an actual prototype. We need to make sure it's safe. Don't allow exec if "exec" in prototype and not self.caller.check_permstring("Developer"): self.caller.msg("Spawn aborted: You don't have access to " "use the 'exec' prototype key.") return None - elif isinstance(prototype, basestring): - # a prototype key - if allow_key: - return prototype - else: - self.caller.msg("The prototype must be defined as a Python dictionary.") - else: - caller.msg("The prototype must be given either as a Python dictionary or a key") - return None + return prototype - - def _search_show_prototype(query): + def _search_show_prototype(query, metaprots=None): # prototype detail strings = [] - metaprots = search_prototype(key=query, return_meta=True) + if not metaprots: + metaprots = search_prototype(key=query, return_meta=True) if metaprots: for metaprot in metaprots: header = ( "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" "|cdesc:|n {} \n|cprototype:|n ".format( metaprot.key, ", ".join(metaprot.tags), - metaprot.locks, metaprot.desc)) + "; ".join(metaprot.locks), metaprot.desc)) prototype = ("{{\n {} \n}}".format("\n ".join("{!r}: {!r},".format(key, value) for key, value in sorted(metaprot.prototype.items())).rstrip(","))) @@ -2869,11 +2863,12 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if 'save' in self.switches: if not self.args or not self.rhs: - caller.msg("Usage: @spawn/save [;desc[;tag,tag[,...][;lockstring]]] = ") + caller.msg( + "Usage: @spawn/save [;desc[;tag,tag[,...][;lockstring]]] = ") return # handle lhs - parts = self.rhs.split(";", 3) + parts = self.lhs.split(";", 3) key, desc, tags, lockstring = "", "", [], "" nparts = len(parts) if nparts == 1: @@ -2889,17 +2884,26 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): tags = [tag.strip().lower() for tag in tags.split(",")] # handle rhs: - prototype = _parse_prototype(caller, self.rhs) + prototype = _parse_prototype(self.rhs) if not prototype: return - # check for existing prototype - matchstring = _search_show_prototype(key) - if matchstring: - caller.msg("|yExisting saved prototype found:|n\n{}".format(matchstring)) - answer = ("Do you want to replace the existing prototype? Y/[N]") - if not answer.lower() not in ["y", "yes"]: - caller.msg("Save cancelled.") + # present prototype to save + new_matchstring = _search_show_prototype( + "", metaprots=[build_metaproto(key, desc, [lockstring], tags, prototype)]) + string = "|yCreating new prototype:|n\n{}".format(new_matchstring) + question = "\nDo you want to continue saving? [Y]/N" + + # check for existing prototype, + old_matchstring = _search_show_prototype(key) + if old_matchstring: + string += "\n|yExisting saved prototype found:|n\n{}".format(old_matchstring) + question = "\n|yDo you want to replace the existing prototype?|n [Y]/N" + + answer = yield(string + question) + if answer.lower() in ["n", "no"]: + caller.msg("|rSave cancelled.|n") + return # all seems ok. Try to save. try: @@ -2907,8 +2911,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): except PermissionError as err: caller.msg("|rError saving:|R {}|n".format(err)) return - caller.msg("Saved prototype:") - caller.execute_cmd("spawn/show {}".format(key)) + caller.msg("|gSaved prototype:|n {}".format(key)) return if not self.args: @@ -2919,12 +2922,16 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # A direct creation of an object from a given prototype - prototype = _parse_prototype(self.args, allow_key=True) + prototype = _parse_prototype( + self.args, expect=dict if self.args.strip().startswith("{") else basestring) if not prototype: + # this will only let through dicts or strings return + key = '' if isinstance(prototype, basestring): # A prototype key we are looking to apply + key = prototype metaprotos = search_prototype(prototype) nprots = len(metaprotos) if not metaprotos: @@ -2945,5 +2952,8 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): prototype["location"] = self.caller.location # proceed to spawning - for obj in spawn(prototype): - self.caller.msg("Spawned %s." % obj.get_display_name(self.caller)) + try: + for obj in spawn(prototype): + self.caller.msg("Spawned %s." % obj.get_display_name(self.caller)) + except RuntimeError as err: + caller.msg(err) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 3b2bec932c..1b0bbb63a3 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -359,6 +359,14 @@ class PersistentPrototype(DefaultScript): self.desc = "A prototype" +def build_metaproto(key, desc, locks, tags, prototype): + """ + Create a metaproto from combinant parts. + + """ + return MetaProto(key, desc, locks, tags, dict(prototype)) + + def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete=False): """ Store a prototype persistently. @@ -386,7 +394,7 @@ def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete """ key_orig = key key = key.lower() - locks = locks if locks else "use:all();edit:id({}) or edit:perm(Admin)".format(caller.id) + locks = locks if locks else "use:all();edit:id({}) or perm(Admin)".format(caller.id) tags = [(tag, "persistent_prototype") for tag in make_iter(tags)] if key in _READONLY_PROTOTYPES: @@ -506,34 +514,19 @@ def search_prototype(key=None, tags=None, return_meta=True): be found. """ - matches = [] - if key and key in _READONLY_PROTOTYPES: - if return_meta: - matches.append(_READONLY_PROTOTYPES[key]) - else: - matches.append(_READONLY_PROTOTYPES[key][3]) - elif tags: - if return_meta: - matches.extend( - [MetaProto(prot.key, prot.desc, prot.locks.all(), - prot.tags.all(), prot.attributes.get("prototype")) - for prot in search_persistent_prototype(key, tags)]) - else: - matches.extend([prot.attributes.get("prototype") - for prot in search_persistent_prototype(key, tags)]) - else: - # neither key nor tags given. Return all. - if return_meta: - matches = [MetaProto(prot.key, prot.desc, prot.locks.all(), - prot.tags.all(), prot.attributes.get("prototype")) - for prot in search_persistent_prototype(key, tags)] + \ - list(_READONLY_PROTOTYPES.values()) - else: - matches = [prot.attributes.get("prototype") - for prot in search_persistent_prototype()] + \ - [metaprot[3] for metaprot in _READONLY_PROTOTYPES.values()] - return matches + readonly_prototypes = search_readonly_prototype(key, tags) + persistent_prototypes = search_persistent_prototype(key, tags) + if return_meta: + persistent_prototypes = [ + build_metaproto(prot.key, prot.desc, prot.locks.all(), + prot.tags.all(), prot.attributes.get("prototype")) + for prot in persistent_prototypes] + else: + readonly_prototypes = [metaprot.prototyp for metaprot in readonly_prototypes] + persistent_prototypes = [prot.attributes.get("prototype") for prot in persistent_prototypes] + + return persistent_prototypes + readonly_prototypes def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): """ From 546927dd741ff51018c63494da86d707c2f6419b Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Mon, 5 Mar 2018 14:08:10 -0500 Subject: [PATCH 215/466] 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 216/466] 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 217/466] 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 218/466] 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 219/466] 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 220/466] 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 221/466] 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 222/466] 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 223/466] 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 224/466] 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 225/466] 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 226/466] 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 227/466] 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 228/466] 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 229/466] 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 230/466] 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 dd16d9783415a9ff9e413d37a63993b462692d4c Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 10 Mar 2018 08:40:28 +0100 Subject: [PATCH 231/466] Test refactoring of spawner (untested) --- evennia/utils/spawner.py | 423 ++++++++++++++++++++------------------- 1 file changed, 217 insertions(+), 206 deletions(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 1b0bbb63a3..00cfb25627 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -141,212 +141,6 @@ for mod in settings.PROTOTYPE_MODULES: for key, prot in prots}) _READONLY_PROTOTYPE_MODULES.update({tup[0]: mod for tup in prots}) - -def _handle_dbref(inp): - return dbid_to_obj(inp, ObjectDB) - - -def _validate_prototype(key, prototype, protparents, visited): - """ - Run validation on a prototype, checking for inifinite regress. - - """ - assert isinstance(prototype, dict) - if id(prototype) in visited: - raise RuntimeError("%s has infinite nesting of prototypes." % key or prototype) - visited.append(id(prototype)) - protstrings = prototype.get("prototype") - if protstrings: - for protstring in make_iter(protstrings): - if key is not None and protstring == key: - raise RuntimeError("%s tries to prototype itself." % key or prototype) - protparent = protparents.get(protstring) - if not protparent: - raise RuntimeError( - "%s's prototype '%s' was not found." % (key or prototype, protstring)) - _validate_prototype(protstring, protparent, protparents, visited) - - -def _get_prototype(dic, prot, protparents): - """ - Recursively traverse a prototype dictionary, including multiple - inheritance. Use _validate_prototype before this, we don't check - for infinite recursion here. - - """ - if "prototype" in dic: - # move backwards through the inheritance - for prototype in make_iter(dic["prototype"]): - # Build the prot dictionary in reverse order, overloading - new_prot = _get_prototype(protparents.get(prototype, {}), prot, protparents) - prot.update(new_prot) - prot.update(dic) - prot.pop("prototype", None) # we don't need this anymore - return prot - - -def _batch_create_object(*objparams): - """ - This is a cut-down version of the create_object() function, - optimized for speed. It does NOT check and convert various input - so make sure the spawned Typeclass works before using this! - - Args: - objsparams (tuple): Parameters for the respective creation/add - handlers in the following order: - - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`. - - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`. - - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`. - - `aliases` (list): A list of alias strings for - adding with `new_object.aliases.batch_add(*aliases)`. - - `nattributes` (list): list of tuples `(key, value)` to be loop-added to - add with `new_obj.nattributes.add(*tuple)`. - - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for - adding with `new_obj.attributes.batch_add(*attributes)`. - - `tags` (list): list of tuples `(key, category)` for adding - with `new_obj.tags.batch_add(*tags)`. - - `execs` (list): Code strings to execute together with the creation - of each object. They will be executed with `evennia` and `obj` - (the newly created object) available in the namespace. Execution - will happend after all other properties have been assigned and - is intended for calling custom handlers etc. - for the respective creation/add handlers in the following - order: (create_kwargs, permissions, locks, aliases, nattributes, - attributes, tags, execs) - - Returns: - objects (list): A list of created objects - - Notes: - The `exec` list will execute arbitrary python code so don't allow this to be available to - unprivileged users! - - """ - - # bulk create all objects in one go - - # unfortunately this doesn't work since bulk_create doesn't creates pks; - # the result would be duplicate objects at the next stage, so we comment - # it out for now: - # dbobjs = _ObjectDB.objects.bulk_create(dbobjs) - - dbobjs = [ObjectDB(**objparam[0]) for objparam in objparams] - objs = [] - for iobj, obj in enumerate(dbobjs): - # call all setup hooks on each object - objparam = objparams[iobj] - # setup - obj._createdict = {"permissions": make_iter(objparam[1]), - "locks": objparam[2], - "aliases": make_iter(objparam[3]), - "nattributes": objparam[4], - "attributes": objparam[5], - "tags": make_iter(objparam[6])} - # this triggers all hooks - obj.save() - # run eventual extra code - for code in objparam[7]: - if code: - exec(code, {}, {"evennia": evennia, "obj": obj}) - objs.append(obj) - return objs - - -def spawn(*prototypes, **kwargs): - """ - Spawn a number of prototyped objects. - - Args: - prototypes (dict): Each argument should be a prototype - dictionary. - Kwargs: - prototype_modules (str or list): A python-path to a prototype - module, or a list of such paths. These will be used to build - the global protparents dictionary accessible by the input - prototypes. If not given, it will instead look for modules - defined by settings.PROTOTYPE_MODULES. - prototype_parents (dict): A dictionary holding a custom - prototype-parent dictionary. Will overload same-named - prototypes from prototype_modules. - return_prototypes (bool): Only return a list of the - prototype-parents (no object creation happens) - - """ - - protparents = {} - protmodules = make_iter(kwargs.get("prototype_modules", [])) - if not protmodules and hasattr(settings, "PROTOTYPE_MODULES"): - protmodules = make_iter(settings.PROTOTYPE_MODULES) - for prototype_module in protmodules: - protparents.update(dict((key, val) for key, val in - all_from_module(prototype_module).items() if isinstance(val, dict))) - # overload module's protparents with specifically given protparents - protparents.update(kwargs.get("prototype_parents", {})) - for key, prototype in protparents.items(): - _validate_prototype(key, prototype, protparents, []) - - if "return_prototypes" in kwargs: - # only return the parents - return copy.deepcopy(protparents) - - objsparams = [] - for prototype in prototypes: - - _validate_prototype(None, prototype, protparents, []) - prot = _get_prototype(prototype, {}, protparents) - if not prot: - continue - - # extract the keyword args we need to create the object itself. If we get a callable, - # call that to get the value (don't catch errors) - create_kwargs = {} - keyval = prot.pop("key", "Spawned Object %06i" % randint(1, 100000)) - create_kwargs["db_key"] = keyval() if callable(keyval) else keyval - - locval = prot.pop("location", None) - create_kwargs["db_location"] = locval() if callable(locval) else _handle_dbref(locval) - - homval = prot.pop("home", settings.DEFAULT_HOME) - create_kwargs["db_home"] = homval() if callable(homval) else _handle_dbref(homval) - - destval = prot.pop("destination", None) - create_kwargs["db_destination"] = destval() if callable(destval) else _handle_dbref(destval) - - typval = prot.pop("typeclass", settings.BASE_OBJECT_TYPECLASS) - create_kwargs["db_typeclass_path"] = typval() if callable(typval) else typval - - # extract calls to handlers - permval = prot.pop("permissions", []) - permission_string = permval() if callable(permval) else permval - lockval = prot.pop("locks", "") - lock_string = lockval() if callable(lockval) else lockval - aliasval = prot.pop("aliases", "") - alias_string = aliasval() if callable(aliasval) else aliasval - tagval = prot.pop("tags", []) - tags = tagval() if callable(tagval) else tagval - attrval = prot.pop("attrs", []) - attributes = attrval() if callable(tagval) else attrval - - exval = prot.pop("exec", "") - execs = make_iter(exval() if callable(exval) else exval) - - # extract ndb assignments - nattributes = dict((key.split("_", 1)[1], value() if callable(value) else value) - for key, value in prot.items() if key.startswith("ndb_")) - - # the rest are attributes - simple_attributes = [(key, value()) if callable(value) else (key, value) - for key, value in prot.items() if not key.startswith("ndb_")] - attributes = attributes + simple_attributes - attributes = [tup for tup in attributes if not tup[0] in _CREATE_OBJECT_KWARGS] - - # pack for call into _batch_create_object - objsparams.append((create_kwargs, permission_string, lock_string, - alias_string, nattributes, attributes, tags, execs)) - - return _batch_create_object(*objsparams) - - # Prototype storage mechanisms @@ -528,6 +322,20 @@ def search_prototype(key=None, tags=None, return_meta=True): return persistent_prototypes + readonly_prototypes + +def get_protparents(): + """ + Get prototype parents. These are a combination of meta-key and prototype-dict and are used when + a prototype refers to another parent-prototype. + + """ + # get all prototypes + metaprotos = search_prototype(return_meta=True) + # organize by key + return {metaproto.key: metaproto.prototype for metaproto in metaprotos} + + + def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): """ Collate a list of found prototypes based on search criteria and access. @@ -588,6 +396,209 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed table.reformat_column(3, width=20) return table +# Spawner mechanism + + +def _handle_dbref(inp): + return dbid_to_obj(inp, ObjectDB) + + +def _validate_prototype(key, prototype, protparents, visited): + """ + Run validation on a prototype, checking for inifinite regress. + + """ + print("validate_prototype {}, {}, {}, {}".format(key, prototype, protparents, visited)) + assert isinstance(prototype, dict) + if id(prototype) in visited: + raise RuntimeError("%s has infinite nesting of prototypes." % key or prototype) + visited.append(id(prototype)) + protstrings = prototype.get("prototype") + if protstrings: + for protstring in make_iter(protstrings): + if key is not None and protstring == key: + raise RuntimeError("%s tries to prototype itself." % key or prototype) + protparent = protparents.get(protstring) + if not protparent: + raise RuntimeError( + "%s's prototype '%s' was not found." % (key or prototype, protstring)) + _validate_prototype(protstring, protparent, protparents, visited) + + +def _get_prototype(dic, prot, protparents): + """ + Recursively traverse a prototype dictionary, including multiple + inheritance. Use _validate_prototype before this, we don't check + for infinite recursion here. + + """ + if "prototype" in dic: + # move backwards through the inheritance + for prototype in make_iter(dic["prototype"]): + # Build the prot dictionary in reverse order, overloading + new_prot = _get_prototype(protparents.get(prototype.lower(), {}), prot, protparents) + prot.update(new_prot) + prot.update(dic) + prot.pop("prototype", None) # we don't need this anymore + return prot + + +def _batch_create_object(*objparams): + """ + This is a cut-down version of the create_object() function, + optimized for speed. It does NOT check and convert various input + so make sure the spawned Typeclass works before using this! + + Args: + objsparams (tuple): Parameters for the respective creation/add + handlers in the following order: + - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`. + - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`. + - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`. + - `aliases` (list): A list of alias strings for + adding with `new_object.aliases.batch_add(*aliases)`. + - `nattributes` (list): list of tuples `(key, value)` to be loop-added to + add with `new_obj.nattributes.add(*tuple)`. + - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for + adding with `new_obj.attributes.batch_add(*attributes)`. + - `tags` (list): list of tuples `(key, category)` for adding + with `new_obj.tags.batch_add(*tags)`. + - `execs` (list): Code strings to execute together with the creation + of each object. They will be executed with `evennia` and `obj` + (the newly created object) available in the namespace. Execution + will happend after all other properties have been assigned and + is intended for calling custom handlers etc. + for the respective creation/add handlers in the following + order: (create_kwargs, permissions, locks, aliases, nattributes, + attributes, tags, execs) + + Returns: + objects (list): A list of created objects + + Notes: + The `exec` list will execute arbitrary python code so don't allow this to be available to + unprivileged users! + + """ + + # bulk create all objects in one go + + # unfortunately this doesn't work since bulk_create doesn't creates pks; + # the result would be duplicate objects at the next stage, so we comment + # it out for now: + # dbobjs = _ObjectDB.objects.bulk_create(dbobjs) + + dbobjs = [ObjectDB(**objparam[0]) for objparam in objparams] + objs = [] + for iobj, obj in enumerate(dbobjs): + # call all setup hooks on each object + objparam = objparams[iobj] + # setup + obj._createdict = {"permissions": make_iter(objparam[1]), + "locks": objparam[2], + "aliases": make_iter(objparam[3]), + "nattributes": objparam[4], + "attributes": objparam[5], + "tags": make_iter(objparam[6])} + # this triggers all hooks + obj.save() + # run eventual extra code + for code in objparam[7]: + if code: + exec(code, {}, {"evennia": evennia, "obj": obj}) + objs.append(obj) + return objs + +def spawn(*prototypes, **kwargs): + """ + Spawn a number of prototyped objects. + + Args: + prototypes (dict): Each argument should be a prototype + dictionary. + Kwargs: + prototype_modules (str or list): A python-path to a prototype + module, or a list of such paths. These will be used to build + the global protparents dictionary accessible by the input + prototypes. If not given, it will instead look for modules + defined by settings.PROTOTYPE_MODULES. + prototype_parents (dict): A dictionary holding a custom + prototype-parent dictionary. Will overload same-named + prototypes from prototype_modules. + return_prototypes (bool): Only return a list of the + prototype-parents (no object creation happens) + + """ + # get available protparents + protparents = get_protparents() + + # overload module's protparents with specifically given protparents + protparents.update(kwargs.get("prototype_parents", {})) + for key, prototype in protparents.items(): + _validate_prototype(key.lower(), prototype, protparents, []) + + if "return_prototypes" in kwargs: + # only return the parents + return copy.deepcopy(protparents) + + objsparams = [] + for prototype in prototypes: + + _validate_prototype(None, prototype, protparents, []) + prot = _get_prototype(prototype, {}, protparents) + if not prot: + continue + + # extract the keyword args we need to create the object itself. If we get a callable, + # call that to get the value (don't catch errors) + create_kwargs = {} + keyval = prot.pop("key", "Spawned Object %06i" % randint(1, 100000)) + create_kwargs["db_key"] = keyval() if callable(keyval) else keyval + + locval = prot.pop("location", None) + create_kwargs["db_location"] = locval() if callable(locval) else _handle_dbref(locval) + + homval = prot.pop("home", settings.DEFAULT_HOME) + create_kwargs["db_home"] = homval() if callable(homval) else _handle_dbref(homval) + + destval = prot.pop("destination", None) + create_kwargs["db_destination"] = destval() if callable(destval) else _handle_dbref(destval) + + typval = prot.pop("typeclass", settings.BASE_OBJECT_TYPECLASS) + create_kwargs["db_typeclass_path"] = typval() if callable(typval) else typval + + # extract calls to handlers + permval = prot.pop("permissions", []) + permission_string = permval() if callable(permval) else permval + lockval = prot.pop("locks", "") + lock_string = lockval() if callable(lockval) else lockval + aliasval = prot.pop("aliases", "") + alias_string = aliasval() if callable(aliasval) else aliasval + tagval = prot.pop("tags", []) + tags = tagval() if callable(tagval) else tagval + attrval = prot.pop("attrs", []) + attributes = attrval() if callable(tagval) else attrval + + exval = prot.pop("exec", "") + execs = make_iter(exval() if callable(exval) else exval) + + # extract ndb assignments + nattributes = dict((key.split("_", 1)[1], value() if callable(value) else value) + for key, value in prot.items() if key.startswith("ndb_")) + + # the rest are attributes + simple_attributes = [(key, value()) if callable(value) else (key, value) + for key, value in prot.items() if not key.startswith("ndb_")] + attributes = attributes + simple_attributes + attributes = [tup for tup in attributes if not tup[0] in _CREATE_OBJECT_KWARGS] + + # pack for call into _batch_create_object + objsparams.append((create_kwargs, permission_string, lock_string, + alias_string, nattributes, attributes, tags, execs)) + + return _batch_create_object(*objsparams) + + # Testing From 43ecca0d41f22733e30191d8e6587153156de98a Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 10 Mar 2018 14:43:11 +0100 Subject: [PATCH 232/466] Change validation syntax, spawn mechanism not working --- evennia/commands/default/building.py | 7 ++++- evennia/utils/spawner.py | 40 +++++++++++++++++++--------- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index d493e850b6..3efcc0d641 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -14,7 +14,7 @@ from evennia.utils.utils import inherits_from, class_from_module from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore from evennia.utils.spawner import (spawn, search_prototype, list_prototypes, - store_prototype, build_metaproto) + store_prototype, build_metaproto, validate_prototype) from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -2806,6 +2806,11 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): self.caller.msg("Spawn aborted: You don't have access to " "use the 'exec' prototype key.") return None + try: + validate_prototype(prototype) + except RuntimeError as err: + self.caller.msg(str(err)) + return return prototype def _search_show_prototype(query, metaprots=None): diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 00cfb25627..412df31126 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -370,7 +370,7 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed prototypes = [(prototype.key, prototype.desc, "{}/{}".format('Y' if prototype.access(caller, "use") else 'N', 'Y' if prototype.access(caller, "edit") else 'N'), - ",".join(prototype.tags.get(category="persistent_prototype"))) + ",".join(prototype.tags.get(category="persistent_prototype", return_list=True))) for prototype in sorted(prototypes, key=lambda o: o.key)] prototypes = prototypes + readonly_prototypes @@ -403,32 +403,45 @@ def _handle_dbref(inp): return dbid_to_obj(inp, ObjectDB) -def _validate_prototype(key, prototype, protparents, visited): +def validate_prototype(prototype, protkey=None, protparents=None, _visited=None): """ Run validation on a prototype, checking for inifinite regress. + Args: + prototype (dict): Prototype to validate. + protkey (str, optional): The name of the prototype definition, if any. + protpartents (dict, optional): The available prototype parent library. If + note given this will be determined from settings/database. + _visited (list, optional): This is an internal work array and should not be set manually. + Raises: + RuntimeError: If prototype has invalid structure. + """ - print("validate_prototype {}, {}, {}, {}".format(key, prototype, protparents, visited)) + print("validate_prototype {}, {}, {}, {}".format(protkey, prototype, protparents, _visited)) + if not protparents: + protparents = get_protparents() + if _visited is None: + _visited = [] assert isinstance(prototype, dict) - if id(prototype) in visited: - raise RuntimeError("%s has infinite nesting of prototypes." % key or prototype) - visited.append(id(prototype)) + if id(prototype) in _visited: + raise RuntimeError("%s has infinite nesting of prototypes." % protkey or prototype) + _visited.append(id(prototype)) protstrings = prototype.get("prototype") if protstrings: for protstring in make_iter(protstrings): - if key is not None and protstring == key: - raise RuntimeError("%s tries to prototype itself." % key or prototype) + if protkey is not None and protstring == protkey: + raise RuntimeError("%s tries to prototype itself." % protkey or prototype) protparent = protparents.get(protstring) if not protparent: raise RuntimeError( - "%s's prototype '%s' was not found." % (key or prototype, protstring)) - _validate_prototype(protstring, protparent, protparents, visited) + "%s's prototype '%s' was not found." % (protkey or prototype, protstring)) + validate_prototype(protparent, protstring, protparents, _visited) def _get_prototype(dic, prot, protparents): """ Recursively traverse a prototype dictionary, including multiple - inheritance. Use _validate_prototype before this, we don't check + inheritance. Use validate_prototype before this, we don't check for infinite recursion here. """ @@ -509,6 +522,7 @@ def _batch_create_object(*objparams): objs.append(obj) return objs + def spawn(*prototypes, **kwargs): """ Spawn a number of prototyped objects. @@ -535,7 +549,7 @@ def spawn(*prototypes, **kwargs): # overload module's protparents with specifically given protparents protparents.update(kwargs.get("prototype_parents", {})) for key, prototype in protparents.items(): - _validate_prototype(key.lower(), prototype, protparents, []) + validate_prototype(prototype, key.lower(), protparents) if "return_prototypes" in kwargs: # only return the parents @@ -544,7 +558,7 @@ def spawn(*prototypes, **kwargs): objsparams = [] for prototype in prototypes: - _validate_prototype(None, prototype, protparents, []) + validate_prototype(prototype, None, protparents) prot = _get_prototype(prototype, {}, protparents) if not prot: continue From 7af714aa535c0246139b1a09a277b9022632537b Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 10 Mar 2018 14:57:08 +0100 Subject: [PATCH 233/466] Working spawning from both module and store --- evennia/utils/spawner.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 412df31126..3e4bf78112 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -422,13 +422,19 @@ def validate_prototype(prototype, protkey=None, protparents=None, _visited=None) protparents = get_protparents() if _visited is None: _visited = [] + protkey = protkey.lower() if protkey is not None else None + assert isinstance(prototype, dict) + if id(prototype) in _visited: raise RuntimeError("%s has infinite nesting of prototypes." % protkey or prototype) + _visited.append(id(prototype)) protstrings = prototype.get("prototype") + if protstrings: for protstring in make_iter(protstrings): + protstring = protstring.lower() if protkey is not None and protstring == protkey: raise RuntimeError("%s tries to prototype itself." % protkey or prototype) protparent = protparents.get(protstring) @@ -546,6 +552,8 @@ def spawn(*prototypes, **kwargs): # get available protparents protparents = get_protparents() + print("protparents: {}".format(protparents)) + # overload module's protparents with specifically given protparents protparents.update(kwargs.get("prototype_parents", {})) for key, prototype in protparents.items(): From b4cf019f3d42e1e55f83de43c6e0cf8e32181128 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 11 Mar 2018 00:38:32 +0100 Subject: [PATCH 234/466] Start making tree-parser of prototypes --- evennia/commands/default/building.py | 2 +- evennia/utils/spawner.py | 72 ++++++++++++++++++++-------- 2 files changed, 53 insertions(+), 21 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 3efcc0d641..9d8f0277c2 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2803,7 +2803,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if expect == dict: # an actual prototype. We need to make sure it's safe. Don't allow exec if "exec" in prototype and not self.caller.check_permstring("Developer"): - self.caller.msg("Spawn aborted: You don't have access to " + self.caller.msg("Spawn aborted: You are not allowed to " "use the 'exec' prototype key.") return None try: diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 3e4bf78112..9a0c95641b 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -223,7 +223,7 @@ def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete return stored_prototype -def search_persistent_prototype(key=None, tags=None): +def search_persistent_prototype(key=None, tags=None, return_metaprotos=False): """ Find persistent (database-stored) prototypes based on key and/or tags. @@ -232,8 +232,10 @@ def search_persistent_prototype(key=None, tags=None): tags (str or list): Tag key or keys to query for. These will always be applied with the 'persistent_protototype' tag category. + return_metaproto (bool): Return results as metaprotos. Return: - matches (queryset): All found PersistentPrototypes + matches (queryset or list): All found PersistentPrototypes. If `return_metaprotos` + is set, return a list of MetaProtos. Note: This will not include read-only prototypes defined in modules. @@ -249,6 +251,11 @@ def search_persistent_prototype(key=None, tags=None): if key: # partial match on key matches = matches.filter(db_key=key) or matches.filter(db_key__icontains=key) + if return_metaprotos: + return [build_metaproto(match.key, match.desc, match.locks.all(), + match.tags.get(category="persistent_prototype", return_list=True), + match.attributes.get("prototype")) + for match in matches] return matches @@ -335,8 +342,30 @@ def get_protparents(): return {metaproto.key: metaproto.prototype for metaproto in metaprotos} +def gather_prototype_tree(metaprotos): + """ + Build nested structure of metaprotos, starting from the roots with no parents. -def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): + Args: + metaprotos (list): All metaprotos to structure. + Returns: + tree (list): A list of lists representing all root metaprotos and + their children. + """ + roots = [mproto for mproto in metaprotos if 'prototype' not in mproto] + + def _iterate_tree(root): + rootkey = root.key + children = [_iterate_tree(mproto) for mproto in metaprotos + if mproto.prototype.get('prototype') == rootkey] + if children: + return children + return root + return [_iterate_tree(root) for root in roots] + + +def list_prototypes(caller, key=None, tags=None, show_non_use=False, + show_non_edit=True, sort_tree=True): """ Collate a list of found prototypes based on search criteria and access. @@ -346,34 +375,38 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed tags (str or list, optional): Tag key or keys to query for. show_non_use (bool, optional): Show also prototypes the caller may not use. show_non_edit (bool, optional): Show also prototypes the caller may not edit. + sort_tree (bool, optional): Order prototypes by inheritance tree. Returns: table (EvTable or None): An EvTable representation of the prototypes. None if no prototypes were found. """ - # handle read-only prototypes separately - readonly_prototypes = search_readonly_prototype(key, tags) + # get metaprotos for readonly and db-based prototypes + metaprotos = search_readonly_prototype(key, tags) + metaprotos += search_persistent_prototype(key, tags, return_metaprotos=True) + + if sort_tree: + def _print_tree(mproto, level=0): + + prototypes = [ + (metaproto.key, + metaproto.desc, + ("{}/N".format('Y' + if caller.locks.check_lockstring(caller, metaproto.locks, access_type='use') else 'N')), + ",".join(metaproto.tags)) + for metaproto in sorted(metaprotos, key=lambda o: o.key)] + + tree = gather_prototype_tree(metaprotos) + # get use-permissions of readonly attributes (edit is always False) - readonly_prototypes = [ + prototypes = [ (metaproto.key, metaproto.desc, ("{}/N".format('Y' if caller.locks.check_lockstring(caller, metaproto.locks, access_type='use') else 'N')), ",".join(metaproto.tags)) - for metaproto in sorted(readonly_prototypes, key=lambda o: o.key)] - - # next, handle db-stored prototypes - prototypes = search_persistent_prototype(key, tags) - - # gather access permissions as (key, desc, tags, can_use, can_edit) - prototypes = [(prototype.key, prototype.desc, - "{}/{}".format('Y' if prototype.access(caller, "use") else 'N', - 'Y' if prototype.access(caller, "edit") else 'N'), - ",".join(prototype.tags.get(category="persistent_prototype", return_list=True))) - for prototype in sorted(prototypes, key=lambda o: o.key)] - - prototypes = prototypes + readonly_prototypes + for metaproto in sorted(metaprotos, key=lambda o: o.key)] if not prototypes: return None @@ -417,7 +450,6 @@ def validate_prototype(prototype, protkey=None, protparents=None, _visited=None) RuntimeError: If prototype has invalid structure. """ - print("validate_prototype {}, {}, {}, {}".format(protkey, prototype, protparents, _visited)) if not protparents: protparents = get_protparents() if _visited is None: From ef64d8c4136af8cae920e1a56af2198d5fccb9fb Mon Sep 17 00:00:00 2001 From: BlauFeuer Date: Sat, 10 Mar 2018 22:41:16 -0500 Subject: [PATCH 235/466] 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 236/466] 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 237/466] 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 238/466] 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 From e1d097d815e88524f37c607f49bce3b10d8e2921 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 15 Mar 2018 21:19:06 +0100 Subject: [PATCH 239/466] Start refining tree display --- evennia/utils/spawner.py | 59 ++++++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 9a0c95641b..5a154ef4c1 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -111,7 +111,7 @@ import evennia from evennia.objects.models import ObjectDB from evennia.utils.utils import make_iter, all_from_module, dbid_to_obj -from collections import namedtuple +from collections import namedtuple, defaultdict from evennia.scripts.scripts import DefaultScript from evennia.utils.create import create_script from evennia.utils.evtable import EvTable @@ -342,7 +342,7 @@ def get_protparents(): return {metaproto.key: metaproto.prototype for metaproto in metaprotos} -def gather_prototype_tree(metaprotos): +def get_prototype_tree(metaprotos): """ Build nested structure of metaprotos, starting from the roots with no parents. @@ -352,16 +352,40 @@ def gather_prototype_tree(metaprotos): tree (list): A list of lists representing all root metaprotos and their children. """ - roots = [mproto for mproto in metaprotos if 'prototype' not in mproto] + mapping = {mproto.key.lower(): mproto for mproto in metaprotos} + parents = defaultdict(list) + + for key, mproto in mapping: + proto = mproto.prototype.get('prototype', None) + if isinstance(proto, basestring): + parents[key].append(proto.lower()) + elif isinstance(proto, (tuple, list)): + parents[key].extend([pro.lower() for pro in proto]) + + def _iterate(root): + prts = parents[root] + + + + return parents + + roots = [root for root in metaprotos if not root.prototype.get('prototype')] def _iterate_tree(root): - rootkey = root.key - children = [_iterate_tree(mproto) for mproto in metaprotos - if mproto.prototype.get('prototype') == rootkey] + rootkey = root.key.lower() + children = [ + _iterate_tree(mproto) for mproto in metaprotos + if rootkey in [mp.lower() for mp in make_iter(mproto.prototype.get('prototype', ''))]] if children: return children return root - return [_iterate_tree(root) for root in roots] + tree = [] + for root in roots: + tree.append(root) + branch = _iterate_tree(root) + if branch: + tree.append(branch) + return tree def list_prototypes(caller, key=None, tags=None, show_non_use=False, @@ -386,18 +410,17 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, metaprotos += search_persistent_prototype(key, tags, return_metaprotos=True) if sort_tree: - def _print_tree(mproto, level=0): - - prototypes = [ - (metaproto.key, - metaproto.desc, - ("{}/N".format('Y' - if caller.locks.check_lockstring(caller, metaproto.locks, access_type='use') else 'N')), - ",".join(metaproto.tags)) - for metaproto in sorted(metaprotos, key=lambda o: o.key)] - - tree = gather_prototype_tree(metaprotos) + def _print_tree(struct, level=0): + indent = " " * level + if isinstance(struct, list): + # a sub-branch + return "\n".join("{}{}".format( + indent, _print_tree(leaf, level + 2)) for leaf in struct) + else: + # an actual mproto + return "{}{}".format(indent, struct.key) + print(_print_tree(get_prototype_tree(metaprotos))) # get use-permissions of readonly attributes (edit is always False) prototypes = [ From 04777178a5ffa41b2397467b138a1151c9f85e82 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 15 Mar 2018 22:41:18 +0100 Subject: [PATCH 240/466] Test with different tree solution --- evennia/utils/spawner.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 5a154ef4c1..6b12f99ac6 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -362,12 +362,14 @@ def get_prototype_tree(metaprotos): elif isinstance(proto, (tuple, list)): parents[key].extend([pro.lower() for pro in proto]) - def _iterate(root): - prts = parents[root] + def _iterate(child, level=0): + tree = [_iterate(parent, level + 1) for parent in parents[key]] + return tree if tree else level * " " + child + for key in parents: + print("Mproto {}:\n{}".format(_iterate(key, level=0))) - - return parents + return [] roots = [root for root in metaprotos if not root.prototype.get('prototype')] From 0cdf208b22594034e6df7d3b86442947d44e2b1e Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 17 Mar 2018 18:17:28 +0100 Subject: [PATCH 241/466] Bug fixes for spawner olc --- evennia/commands/default/building.py | 7 ++- evennia/utils/spawner.py | 87 +++++----------------------- 2 files changed, 18 insertions(+), 76 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 9d8f0277c2..3730cc934e 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2743,8 +2743,9 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): location will default to caller's current location. search - search prototype by name or tags. list - list available prototypes, optionally limit by tags. - show - inspect prototype by key. If not given, acts like list. + show, examine - inspect prototype by key. If not given, acts like list. save - save a prototype to the database. It will be listable by /list. + delete - remove a prototype from database, if allowed to. menu - manipulate prototype in a menu interface. Example: @@ -2824,7 +2825,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" "|cdesc:|n {} \n|cprototype:|n ".format( metaprot.key, ", ".join(metaprot.tags), - "; ".join(metaprot.locks), metaprot.desc)) + metaprot.locks, metaprot.desc)) prototype = ("{{\n {} \n}}".format("\n ".join("{!r}: {!r},".format(key, value) for key, value in sorted(metaprot.prototype.items())).rstrip(","))) @@ -2848,7 +2849,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): exit_on_lastpage=True) return - if 'show' in self.switches: + if 'show' in self.switches or 'examine' in self.switches: # the argument is a key in this case (may be a partial key) if not self.args: self.switches.append('list') diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 6b12f99ac6..92f1261f7e 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -158,7 +158,7 @@ def build_metaproto(key, desc, locks, tags, prototype): Create a metaproto from combinant parts. """ - return MetaProto(key, desc, locks, tags, dict(prototype)) + return MetaProto(key, desc, make_iter(locks), tags, dict(prototype)) def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete=False): @@ -249,7 +249,7 @@ def search_persistent_prototype(key=None, tags=None, return_metaprotos=False): else: matches = PersistentPrototype.objects.all() if key: - # partial match on key + # exact or partial match on key matches = matches.filter(db_key=key) or matches.filter(db_key__icontains=key) if return_metaprotos: return [build_metaproto(match.key, match.desc, match.locks.all(), @@ -316,18 +316,20 @@ def search_prototype(key=None, tags=None, return_meta=True): """ readonly_prototypes = search_readonly_prototype(key, tags) - persistent_prototypes = search_persistent_prototype(key, tags) + persistent_prototypes = search_persistent_prototype(key, tags, return_metaprotos=True) - if return_meta: - persistent_prototypes = [ - build_metaproto(prot.key, prot.desc, prot.locks.all(), - prot.tags.all(), prot.attributes.get("prototype")) - for prot in persistent_prototypes] - else: - readonly_prototypes = [metaprot.prototyp for metaprot in readonly_prototypes] - persistent_prototypes = [prot.attributes.get("prototype") for prot in persistent_prototypes] + matches = persistent_prototypes + readonly_prototypes + if len(matches) > 1 and key: + key = key.lower() + # avoid duplicates if an exact match exist between the two types + filter_matches = [mta for mta in matches if mta.key == key] + if len(filter_matches) < len(matches): + matches = filter_matches - return persistent_prototypes + readonly_prototypes + if not return_meta: + matches = [mta.prototype for mta in matches] + + return matches def get_protparents(): @@ -342,54 +344,6 @@ def get_protparents(): return {metaproto.key: metaproto.prototype for metaproto in metaprotos} -def get_prototype_tree(metaprotos): - """ - Build nested structure of metaprotos, starting from the roots with no parents. - - Args: - metaprotos (list): All metaprotos to structure. - Returns: - tree (list): A list of lists representing all root metaprotos and - their children. - """ - mapping = {mproto.key.lower(): mproto for mproto in metaprotos} - parents = defaultdict(list) - - for key, mproto in mapping: - proto = mproto.prototype.get('prototype', None) - if isinstance(proto, basestring): - parents[key].append(proto.lower()) - elif isinstance(proto, (tuple, list)): - parents[key].extend([pro.lower() for pro in proto]) - - def _iterate(child, level=0): - tree = [_iterate(parent, level + 1) for parent in parents[key]] - return tree if tree else level * " " + child - - for key in parents: - print("Mproto {}:\n{}".format(_iterate(key, level=0))) - - return [] - - roots = [root for root in metaprotos if not root.prototype.get('prototype')] - - def _iterate_tree(root): - rootkey = root.key.lower() - children = [ - _iterate_tree(mproto) for mproto in metaprotos - if rootkey in [mp.lower() for mp in make_iter(mproto.prototype.get('prototype', ''))]] - if children: - return children - return root - tree = [] - for root in roots: - tree.append(root) - branch = _iterate_tree(root) - if branch: - tree.append(branch) - return tree - - def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True, sort_tree=True): """ @@ -411,19 +365,6 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, metaprotos = search_readonly_prototype(key, tags) metaprotos += search_persistent_prototype(key, tags, return_metaprotos=True) - if sort_tree: - def _print_tree(struct, level=0): - indent = " " * level - if isinstance(struct, list): - # a sub-branch - return "\n".join("{}{}".format( - indent, _print_tree(leaf, level + 2)) for leaf in struct) - else: - # an actual mproto - return "{}{}".format(indent, struct.key) - - print(_print_tree(get_prototype_tree(metaprotos))) - # get use-permissions of readonly attributes (edit is always False) prototypes = [ (metaproto.key, From 0dde856e3e6c9fa4740b75dcf30d6716e6e44ecb Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 17 Mar 2018 19:47:44 +0100 Subject: [PATCH 242/466] Spawner/olc mechanism working --- evennia/commands/default/building.py | 28 +++++++++++++++++++++---- evennia/utils/spawner.py | 31 ++++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 3730cc934e..9ea9707498 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -14,7 +14,8 @@ from evennia.utils.utils import inherits_from, class_from_module from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore from evennia.utils.spawner import (spawn, search_prototype, list_prototypes, - store_prototype, build_metaproto, validate_prototype) + store_prototype, build_metaproto, validate_prototype, + delete_prototype, PermissionError) from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -2777,9 +2778,6 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): locks = "cmd:perm(spawn) or perm(Builder)" help_category = "Building" - def parser(self): - super(CmdSpawn, self).parser() - def func(self): """Implements the spawner""" @@ -2867,7 +2865,28 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): tags=self.lhslist)), exit_on_lastpage=True) return + if 'delete' in self.switches: + # remove db-based prototype + matchstring = _search_show_prototype(self.args) + if matchstring: + question = "\nDo you want to continue deleting? [Y]/N" + string = "|rDeleting prototype:|n\n{}".format(matchstring) + answer = yield(string + question) + if answer.lower() in ["n", "no"]: + caller.msg("|rDeletion cancelled.|n") + return + try: + success = delete_prototype(caller, self.args) + except PermissionError as err: + caller.msg("|rError deleting:|R {}|n".format(err)) + caller.msg("Deletion {}.".format( + 'successful' if success else 'failed (does the prototype exist?)')) + return + else: + caller.msg("Could not find prototype '{}'".format(key)) + if 'save' in self.switches: + # store a prototype to the database store if not self.args or not self.rhs: caller.msg( "Usage: @spawn/save [;desc[;tag,tag[,...][;lockstring]]] = ") @@ -2902,6 +2921,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # check for existing prototype, old_matchstring = _search_show_prototype(key) + if old_matchstring: string += "\n|yExisting saved prototype found:|n\n{}".format(old_matchstring) question = "\n|yDo you want to replace the existing prototype?|n [Y]/N" diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 92f1261f7e..2a003069a4 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -122,6 +122,9 @@ _READONLY_PROTOTYPES = {} _READONLY_PROTOTYPE_MODULES = {} +class PermissionError(RuntimeError): + pass + # storage of meta info about the prototype MetaProto = namedtuple('MetaProto', ['key', 'desc', 'locks', 'tags', 'prototype']) @@ -199,14 +202,16 @@ def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete stored_prototype = PersistentPrototype.objects.filter(db_key=key) if stored_prototype: + # edit existing prototype stored_prototype = stored_prototype[0] if not stored_prototype.access(caller, 'edit'): raise PermissionError("{} does not have permission to " - "edit prototype {}".format(caller, key)) + "edit prototype {}.".format(caller, key)) if delete: + # delete prototype stored_prototype.delete() - return + return True if desc: stored_prototype.desc = desc @@ -216,13 +221,33 @@ def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete stored_prototype.locks.add(locks) if prototype: stored_prototype.attributes.add("prototype", prototype) + elif delete: + # didn't find what to delete + return False else: + # create a new prototype stored_prototype = create_script( PersistentPrototype, key=key, desc=desc, persistent=True, locks=locks, tags=tags, attributes=[("prototype", prototype)]) return stored_prototype +def delete_prototype(caller, key): + """ + Delete a stored prototype + + Args: + caller (Account or Object): Caller aiming to delete a prototype. + key (str): The persistent prototype to delete. + Returns: + success (bool): If deletion worked or not. + Raises: + PermissionError: If 'edit' lock was not passed. + + """ + return store_prototype(caller, key, None, delete=True) + + def search_persistent_prototype(key=None, tags=None, return_metaprotos=False): """ Find persistent (database-stored) prototypes based on key and/or tags. @@ -550,8 +575,6 @@ def spawn(*prototypes, **kwargs): # get available protparents protparents = get_protparents() - print("protparents: {}".format(protparents)) - # overload module's protparents with specifically given protparents protparents.update(kwargs.get("prototype_parents", {})) for key, prototype in protparents.items(): From b55638b622ec00fbd480cb01726271dec96f88fd Mon Sep 17 00:00:00 2001 From: friarzen Date: Sat, 17 Mar 2018 20:50:51 +0000 Subject: [PATCH 243/466] Alpha webclient split interface support --- .../static/webclient/css/webclient.css | 167 +++++++++------ .../static/webclient/js/splithandler.js | 77 +++++++ .../static/webclient/js/webclient_gui.js | 198 +++++++++++++----- .../webclient/templates/webclient/base.html | 113 +++++----- .../templates/webclient/webclient.html | 57 ++++- 5 files changed, 434 insertions(+), 178 deletions(-) create mode 100644 evennia/web/webclient/static/webclient/js/splithandler.js diff --git a/evennia/web/webclient/static/webclient/css/webclient.css b/evennia/web/webclient/static/webclient/css/webclient.css index 1c94a1f9fd..94344386a1 100644 --- a/evennia/web/webclient/static/webclient/css/webclient.css +++ b/evennia/web/webclient/static/webclient/css/webclient.css @@ -8,10 +8,11 @@ --- */ /* Overall element look */ -html, body, #clientwrapper { height: 100% } +html, body { + height: 100%; + width: 100%; +} body { - margin: 0; - padding: 0; background: #000; color: #ccc; font-size: .9em; @@ -19,6 +20,12 @@ body { line-height: 1.6em; overflow: hidden; } +@media screen and (max-width: 480px) { + body { + font-size: .5rem; + line-height: .7rem; + } +} a:link, a:visited { color: inherit; } @@ -74,93 +81,75 @@ div {margin:0px;} } /* Style specific classes corresponding to formatted, narative text. */ - +.wrapper { + height: 100%; +} /* Container surrounding entire client */ -#wrapper { - position: relative; - height: 100% +#clientwrapper { + height: 100%; } /* Main scrolling message area */ + #messagewindow { - position: absolute; - overflow: auto; - padding: 1em; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; - top: 0; - left: 0; - right: 0; - bottom: 70px; + overflow-y: auto; + overflow-x: hidden; + overflow-wrap: break-word; } -/* Input area containing input field and button */ -#inputform { - position: absolute; - width: 100%; - padding: 0; - bottom: 0; - margin: 0; - padding-bottom: 10px; - border-top: 1px solid #555; -} - -#inputcontrol { - width: 100%; - padding: 0; +#messagewindow { + overflow-y: auto; + overflow-x: hidden; + overflow-wrap: break-word; } /* Input field */ -#inputfield, #inputsend, #inputsizer { - display: block; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; - height: 50px; +#inputfield, #inputsizer { + height: 100%; background: #000; color: #fff; - padding: 0 .45em; - font-size: 1.1em; + padding: 0 .45rem; + font-size: 1.1rem; font-family: 'DejaVu Sans Mono', Consolas, Inconsolata, 'Lucida Console', monospace; -} - -#inputfield, #inputsizer { - float: left; - width: 95%; - border: 0; resize: none; - line-height: normal; +} +#inputsend { + height: 100%; +} +#inputcontrol { + height: 100%; } #inputfield:focus { - outline: 0; -} - -#inputsizer { - margin-left: -9999px; -} - -/* Input 'send' button */ -#inputsend { - float: right; - width: 3%; - max-width: 25px; - margin-right: 10px; - border: 0; - background: #555; } /* prompt area above input field */ -#prompt { - margin-top: 10px; - padding: 0 .45em; +.prompt { + max-height: 3rem; +} + +.splitbutton { + position: absolute; + right: 1%; + top: 1%; + z-index: 1; + width: 2rem; + height: 2rem; + font-size: 2rem; + color: #a6a6a6; + background-color: transparent; + border: 0px; +} + +.splitbutton:hover { + color: white; + cursor: pointer; } #optionsbutton { - width: 40px; - font-size: 20px; + width: 2rem; + font-size: 2rem; color: #a6a6a6; background-color: transparent; border: 0px; @@ -173,8 +162,8 @@ div {margin:0px;} #toolbar { position: fixed; - top: 0; - right: 5px; + top: .5rem; + right: .5rem; z-index: 1; } @@ -248,6 +237,48 @@ div {margin:0px;} text-decoration: none; cursor: pointer; } +.gutter.gutter-vertical { + cursor: row-resize; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFAQMAAABo7865AAAABlBMVEVHcEzMzMzyAv2sAAAAAXRSTlMAQObYZgAAABBJREFUeF5jOAMEEAIEEFwAn3kMwcB6I2AAAAAASUVORK5CYII=') +} + +.gutter.gutter-horizontal { + cursor: col-resize; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==') +} + +.split { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + + overflow-y: auto; + overflow-x: hidden; +} + +.content { + border: 1px solid #C0C0C0; + box-shadow: inset 0 1px 2px #e4e4e4; + background-color: black; + padding: 1rem; +} +@media screen and (max-width: 480px) { + .content { + padding: .5rem; + } +} + +.gutter { + background-color: grey; + + background-repeat: no-repeat; + background-position: 50%; +} + +.split.split-horizontal, .gutter.gutter-horizontal { + height: 100%; + float: left; +} /* XTERM256 colors */ diff --git a/evennia/web/webclient/static/webclient/js/splithandler.js b/evennia/web/webclient/static/webclient/js/splithandler.js new file mode 100644 index 0000000000..56890009e5 --- /dev/null +++ b/evennia/web/webclient/static/webclient/js/splithandler.js @@ -0,0 +1,77 @@ +// Use split.js to create a basic ui +var SplitHandler = (function () { + var num_splits = 0; + var split_panes = {}; + + var set_pane_types = function(splitpane, types) { + split_panes[splitpane]['types'] = types; + } + + var dynamic_split = function(splitpane, direction, update_method1, update_method2) { + var first = ++num_splits; + var second = ++num_splits; + + var first_div = $( '
' ) + var first_sub = $( '
' ) + var second_div = $( '
' ) + var second_sub = $( '
' ) + + // check to see if this pane contains the primary message window. + contents = $('#'+splitpane).contents(); + if( contents ) { + // it does, so move it to the first new div (TODO -- selectable between first/second?) + contents.appendTo(first_sub); + } + + first_div.append( first_sub ); + second_div.append( second_sub ); + + // update the split_panes array to remove this split + delete( split_panes[splitpane] ); + + // now vaporize the current split_N-sub placeholder and create two new panes. + $('#'+splitpane).parent().append(first_div); + $('#'+splitpane).parent().append(second_div); + $('#'+splitpane).remove(); + + // And split + Split(['#split_'+first,'#split_'+second], { + direction: direction, + sizes: [50,50], + gutterSize: 4, + minSize: [50,50], + }); + + // store our new splits for future splits/uses by the main UI. + split_panes['split_'+first +'-sub'] = { 'types': [], 'update_method': update_method1 }; + split_panes['split_'+second+'-sub'] = { 'types': [], 'update_method': update_method2 }; + } + + + var init = function(settings) { + //change Mustache tags to ruby-style (Django gets mad otherwise) + var customTags = [ '<%', '%>' ]; + Mustache.tags = customTags; + + var input_template = $('#input-template').html(); + Mustache.parse(input_template); + + Split(['#main','#input'], { + direction: 'vertical', + sizes: [90,10], + gutterSize: 4, + minSize: [50,50], + }); + + var input_render = Mustache.render(input_template); + $('[data-role-input]').html(input_render); + console.log("SplitHandler initialized"); + } + + return { + init: init, + set_pane_types: set_pane_types, + dynamic_split: dynamic_split, + split_panes: split_panes, + } +})(); diff --git a/evennia/web/webclient/static/webclient/js/webclient_gui.js b/evennia/web/webclient/static/webclient/js/webclient_gui.js index 57d9b0b7c0..b4e5168769 100644 --- a/evennia/web/webclient/static/webclient/js/webclient_gui.js +++ b/evennia/web/webclient/static/webclient/js/webclient_gui.js @@ -17,6 +17,10 @@ var options = {}; + +var known_types = new Array(); + known_types.push('help'); + // // GUI Elements // @@ -106,6 +110,7 @@ function togglePopup(dialogname, content) { // Grab text from inputline and send to Evennia function doSendText() { + console.log("sending text"); if (!Evennia.isConnected()) { var reconnect = confirm("Not currently connected. Reconnect?"); if (reconnect) { @@ -158,6 +163,10 @@ function onKeydown (event) { var code = event.which; var history_entry = null; var inputfield = $("#inputfield"); + if (code === 9) { + return; + } + inputfield.focus(); if (code === 13) { // Enter key sends text @@ -205,64 +214,68 @@ function onKeyPress (event) { } var resizeInputField = function () { - var min_height = 50; - var max_height = 300; - var prev_text_len = 0; + return function() { + var wrapper = $("#inputform") + var input = $("#inputcontrol") + var prompt = $("#prompt") - // Check to see if we should change the height of the input area - return function () { - var inputfield = $("#inputfield"); - var scrollh = inputfield.prop("scrollHeight"); - var clienth = inputfield.prop("clientHeight"); - var newh = 0; - var curr_text_len = inputfield.val().length; - - if (scrollh > clienth && scrollh <= max_height) { - // Need to make it bigger - newh = scrollh; - } - else if (curr_text_len < prev_text_len) { - // There is less text in the field; try to make it smaller - // To avoid repaints, we draw the text in an offscreen element and - // determine its dimensions. - var sizer = $('#inputsizer') - .css("width", inputfield.prop("clientWidth")) - .text(inputfield.val()); - newh = sizer.prop("scrollHeight"); - } - - if (newh != 0) { - newh = Math.min(newh, max_height); - if (clienth != newh) { - inputfield.css("height", newh + "px"); - doWindowResize(); - } - } - prev_text_len = curr_text_len; + input.height(wrapper.height() - (input.offset().top - wrapper.offset().top)); } }(); // Handle resizing of client function doWindowResize() { - var formh = $('#inputform').outerHeight(true); - var message_scrollh = $("#messagewindow").prop("scrollHeight"); - $("#messagewindow") - .css({"bottom": formh}) // leave space for the input form - .scrollTop(message_scrollh); // keep the output window scrolled to the bottom + resizeInputField(); + var resizable = $("[data-update-append]"); + var parents = resizable.closest(".split") + parents.animate({ + scrollTop: parents.prop("scrollHeight") + }, 0); } // Handle text coming from the server function onText(args, kwargs) { - // append message to previous ones, then scroll so latest is at - // the bottom. Send 'cls' kwarg to modify the output class. - var renderto = "main"; - if (kwargs["type"] == "help") { - if (("helppopup" in options) && (options["helppopup"])) { - renderto = "#helpdialog"; + var use_default_pane = true; + + if ( kwargs && 'type' in kwargs ) { + var msgtype = kwargs['type']; + if ( ! known_types.includes(msgtype) ) { + // this is a new output type that can be mapped to panes + console.log('detected new output type: ' + msgtype) + known_types.push(msgtype); + } + + if ( msgtype == 'help' ) { + if (("helppopup" in options) && (options["helppopup"])) { + openPopup("#helpdialog", args[0]); + return; + } + // fall through to the default output + + } else { + // pass this message to each pane that has this msgtype mapped + if( SplitHandler ) { + for ( var key in SplitHandler.split_panes) { + var pane = SplitHandler.split_panes[key]; + console.log(pane); + // is this message type mapped to this pane? + if ( (pane['types'].length > 0) && pane['types'].includes(msgtype) ) { + // yes, so append/replace this pane's inner div with this message + if ( pane['update_method'] == 'replace' ) { + $('#'+key).html(args[0]) + } else { + $('#'+key).append(args[0]).animate({ scrollTop: document.getElementById("#"+key).scrollHeight }, 0); + } + // record sending this message to a pane, no need to update the default div + use_default_pane = false; + } + } + } } } - if (renderto == "main") { + // append message to default pane, then scroll so latest is at the bottom. + if(use_default_pane) { var mwin = $("#messagewindow"); var cls = kwargs == null ? 'out' : kwargs['cls']; mwin.append("
" + args[0] + "
"); @@ -271,8 +284,6 @@ function onText(args, kwargs) { }, 0); onNewLine(args[0], null); - } else { - openPopup(renderto, args[0]); } } @@ -377,7 +388,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 = { @@ -427,6 +441,81 @@ function doStartDragDialog(event) { $(document).bind("mouseup", undrag); } + +function onSplitDialogClose() { + var pane = $("input[name=pane]:checked").attr("value"); + var direction = $("input[name=direction]:checked").attr("value"); + var flow1 = $("input[name=flow1]:checked").attr("value"); + var flow2 = $("input[name=flow2]:checked").attr("value"); + + SplitHandler.dynamic_split( pane, direction, flow1, flow2 ); + + closePopup("#splitdialog"); +} + + +function onSplitDialog() { + var dialog = $("#splitdialogcontent"); + dialog.empty(); + + dialog.append("

Split?

"); + dialog.append(' top/bottom
'); + dialog.append(' side-by-side
'); + + dialog.append("

Split Which Pane?

"); + for ( var pane in SplitHandler.split_panes ) { + dialog.append(''+ pane +'
'); + } + + dialog.append("

New First Pane Flow

"); + dialog.append('append
'); + dialog.append('replace
'); + + dialog.append("

New Second Pane Flow

"); + dialog.append('append
'); + dialog.append('replace
'); + + dialog.append('
Split It
'); + + $("#splitclose").bind("click", onSplitDialogClose); + + openPopup("#splitdialog"); +} + +function onPaneControlDialogClose() { + var pane = $("input[name=pane]:checked").attr("value"); + + var types = new Array; + $('#splitdialogcontent input[type=checkbox]:checked').each(function() { + types.push( $(this).attr('value') ); + }); + + SplitHandler.set_pane_types( pane, types ); + + closePopup("#splitdialog"); +} + +function onPaneControlDialog() { + var dialog = $("#splitdialogcontent"); + dialog.empty(); + + dialog.append("

Set Which Pane?

"); + for ( var pane in SplitHandler.split_panes ) { + dialog.append(''+ pane +'
'); + } + + dialog.append("

Which content types?

"); + for ( var type in known_types ) { + dialog.append(''+ known_types[type] +'
'); + } + + dialog.append('
Make It So
'); + + $("#paneclose").bind("click", onPaneControlDialogClose); + + openPopup("#splitdialog"); +} + // // Register Events // @@ -434,6 +523,16 @@ function doStartDragDialog(event) { // Event when client finishes loading $(document).ready(function() { + if( SplitHandler ) { + SplitHandler.init(); + SplitHandler.split_panes['main-sub'] = {'types': ['help'], 'update_method': 'replace'}; + $("#splitbutton").bind("click", onSplitDialog); + $("#panebutton").bind("click", onPaneControlDialog); + } else { + $("#splitbutton").hide(); + $("#panebutton").hide(); + } + if ("Notification" in window) { Notification.requestPermission(); } @@ -450,7 +549,7 @@ $(document).ready(function() { //$(document).on("visibilitychange", onVisibilityChange); - $("#inputfield").bind("resize", doWindowResize) + $("[data-role-input]").bind("resize", doWindowResize) .keypress(onKeyPress) .bind("paste", resizeInputField) .bind("cut", resizeInputField); @@ -503,6 +602,7 @@ $(document).ready(function() { }, 60000*3 ); + console.log("Completed GUI setup"); }); diff --git a/evennia/web/webclient/templates/webclient/base.html b/evennia/web/webclient/templates/webclient/base.html index f5f47b230f..f31e4c89f1 100644 --- a/evennia/web/webclient/templates/webclient/base.html +++ b/evennia/web/webclient/templates/webclient/base.html @@ -14,55 +14,16 @@ JQuery available. + + + + + - - - - {% block jquery_import %} - - {% endblock %} - - - - - - - - - {% block guilib_import %} - - {% endblock %} - - + @@ -79,17 +40,69 @@ JQuery available. web browser supporting javascript.

This error could also be due to not being able to access the online jQuery javascript library.

- -
-
+
{% block client %} {% endblock %}
+ + + {% block jquery_import %} + + {% endblock %} + + + + + + + + + + + + + + + + + + {% block guilib_import %} + + {% endblock %} + + + + + {% block scripts %} + {% endblock %} diff --git a/evennia/web/webclient/templates/webclient/webclient.html b/evennia/web/webclient/templates/webclient/webclient.html index 1c641bffb0..2b138cb8bd 100644 --- a/evennia/web/webclient/templates/webclient/webclient.html +++ b/evennia/web/webclient/templates/webclient/webclient.html @@ -8,20 +8,30 @@ {% block client %} +
+ + + +
-
-
- -
-
-
-
-
- - + +
+
+
+
+
+ + +
+
+ + +
+
Split Pane×
+
+
-
@@ -47,4 +57,29 @@
+ + + + + + +{% endblock %} +{% block scripts %} {% endblock %} From f95f66633dc28f307441c3ff8ff20746f1836a63 Mon Sep 17 00:00:00 2001 From: friarzen Date: Sat, 17 Mar 2018 21:50:11 +0000 Subject: [PATCH 244/466] Example of how to tag msg() with a type --- 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 2f4c51a227..aef5309d32 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -71,7 +71,7 @@ class CmdLook(COMMAND_DEFAULT_CLASS): target = caller.search(self.args) if not target: return - self.msg(caller.at_look(target)) + self.msg((caller.at_look(target), {'type':'look'}), options=None) class CmdNick(COMMAND_DEFAULT_CLASS): From bf80879ffcc11c6347c8389da5a8838767e0c487 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 17 Mar 2018 23:29:16 +0100 Subject: [PATCH 245/466] Add typeclass/list to list all available typeclasses --- evennia/commands/default/building.py | 23 ++++++++++++++++++++++- evennia/utils/utils.py | 22 +++++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 9ea9707498..9e8199d546 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -10,7 +10,7 @@ from evennia.objects.models import ObjectDB from evennia.locks.lockhandler import LockException from evennia.commands.cmdhandler import get_and_merge_cmdsets from evennia.utils import create, utils, search -from evennia.utils.utils import inherits_from, class_from_module +from evennia.utils.utils import inherits_from, class_from_module, get_all_typeclasses from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore from evennia.utils.spawner import (spawn, search_prototype, list_prototypes, @@ -1702,6 +1702,7 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): object - basically making this a new clean object. force - change to the typeclass also if the object already has a typeclass of the same name. + list - show available typeclasses. Example: @type button = examples.red_button.RedButton @@ -1733,6 +1734,26 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): caller = self.caller + if 'list' in self.switches: + tclasses = get_all_typeclasses() + print(list(tclasses.keys())) + contribs = [key for key in sorted(tclasses) + if key.startswith("evennia.contrib")] or [""] + core = [key for key in sorted(tclasses) + if key.startswith("evennia") and key not in contribs] or [""] + game = [key for key in sorted(tclasses) + if not key.startswith("evennia")] or [""] + string = ("|wCore typeclasses|n\n" + " {core}\n" + "|wLoaded Contrib typeclasses|n\n" + " {contrib}\n" + "|wGame-dir typeclasses|n\n" + " {game}").format(core="\n ".join(core), + contrib="\n ".join(contribs), + game="\n ".join(game)) + caller.msg(string) + return + if not self.args: caller.msg("Usage: %s [= typeclass]" % self.cmdstring) return diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 10621f0feb..a8d2171f75 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -20,12 +20,13 @@ import textwrap import random from os.path import join as osjoin from importlib import import_module -from inspect import ismodule, trace, getmembers, getmodule +from inspect import ismodule, trace, getmembers, getmodule, getmro from collections import defaultdict, OrderedDict from twisted.internet import threads, reactor, task from django.conf import settings from django.utils import timezone from django.utils.translation import ugettext as _ +from django.apps import apps from evennia.utils import logger _MULTIMATCH_TEMPLATE = settings.SEARCH_MULTIMATCH_TEMPLATE @@ -1879,3 +1880,22 @@ def get_game_dir_path(): else: os.chdir(os.pardir) raise RuntimeError("server/conf/settings.py not found: Must start from inside game dir.") + + +def get_all_typeclasses(): + """ + List available typeclasses from all available modules. + + Returns: + typeclasses (dict): On the form {"typeclass.path": typeclass, ...} + + Notes: + This will dynamicall retrieve all abstract django models inheriting at any distance + from the TypedObject base (aka a Typeclass) so it will work fine with any custom + classes being added. + + """ + from evennia.typeclasses.models import TypedObject + typeclasses = {"{}.{}".format(model.__module__, model.__name__): model + for model in apps.get_models() if TypedObject in getmro(model)} + return typeclasses From e003bac7452676a6d1023e1f8ed42f85bde88445 Mon Sep 17 00:00:00 2001 From: friarzen Date: Sun, 18 Mar 2018 00:33:08 +0000 Subject: [PATCH 246/466] move the initial settings for the main split where it belongs --- evennia/web/webclient/static/webclient/js/splithandler.js | 2 ++ evennia/web/webclient/static/webclient/js/webclient_gui.js | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/evennia/web/webclient/static/webclient/js/splithandler.js b/evennia/web/webclient/static/webclient/js/splithandler.js index 56890009e5..7ba8e45e17 100644 --- a/evennia/web/webclient/static/webclient/js/splithandler.js +++ b/evennia/web/webclient/static/webclient/js/splithandler.js @@ -63,6 +63,8 @@ var SplitHandler = (function () { minSize: [50,50], }); + split_panes['main-sub'] = {'types': [], 'update_method': 'append'}; + var input_render = Mustache.render(input_template); $('[data-role-input]').html(input_render); console.log("SplitHandler initialized"); diff --git a/evennia/web/webclient/static/webclient/js/webclient_gui.js b/evennia/web/webclient/static/webclient/js/webclient_gui.js index b4e5168769..d07239039f 100644 --- a/evennia/web/webclient/static/webclient/js/webclient_gui.js +++ b/evennia/web/webclient/static/webclient/js/webclient_gui.js @@ -525,7 +525,6 @@ $(document).ready(function() { if( SplitHandler ) { SplitHandler.init(); - SplitHandler.split_panes['main-sub'] = {'types': ['help'], 'update_method': 'replace'}; $("#splitbutton").bind("click", onSplitDialog); $("#panebutton").bind("click", onPaneControlDialog); } else { From b364807b4bb5d8af2d25aca7d49d919492d0e015 Mon Sep 17 00:00:00 2001 From: friarzen Date: Sun, 18 Mar 2018 00:45:23 +0000 Subject: [PATCH 247/466] Make the initial login 'look' match CmdLook --- evennia/objects/objects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index f583570707..a588af0000 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -1762,7 +1762,7 @@ class DefaultCharacter(DefaultObject): """ self.msg("\nYou become |c%s|n.\n" % self.name) - self.msg(self.at_look(self.location)) + self.msg((self.at_look(self.location), {'type':'look'}), options = None) def message(obj, from_obj): obj.msg("%s has entered the game." % self.get_display_name(obj), from_obj=from_obj) From ed38c17cd20fbd8e7cdf7c6e3344df7efcc3302b Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 18 Mar 2018 08:21:40 +0100 Subject: [PATCH 248/466] Expand typeclass/show to view typeclass docstrings --- evennia/commands/default/building.py | 35 ++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index beaaeba5a5..a948da14a9 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -1700,11 +1700,13 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): @typeclass[/switch] [= typeclass.path] @type '' @parent '' + @typeclass/list/show [typeclass.path] @swap - this is a shorthand for using /force/reset flags. @update - this is a shorthand for using the /force/reload flag. Switch: - show - display the current typeclass of object (default) + show, examine - display the current typeclass of object (default) or, if + given a typeclass path, show the docstring of that typeclass. update - *only* re-run at_object_creation on this object meaning locks or other properties set later may remain. reset - clean out *all* the attributes and properties on the @@ -1712,6 +1714,8 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): force - change to the typeclass also if the object already has a typeclass of the same name. list - show available typeclasses. + + Example: @type button = examples.red_button.RedButton @@ -1767,6 +1771,33 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): caller.msg("Usage: %s [= typeclass]" % self.cmdstring) return + if "show" in self.switches or "examine" in self.switches: + oquery = self.lhs + obj = caller.search(oquery, quiet=True) + if not obj: + # no object found to examine, see if it's a typeclass-path instead + tclasses = get_all_typeclasses() + matches = [(key, tclass) + for key, tclass in tclasses.items() if key.endswith(oquery)] + nmatches = len(matches) + if nmatches > 1: + caller.msg("Multiple typeclasses found matching {}:\n {}".format( + oquery, "\n ".join(tup[0] for tup in matches))) + elif not matches: + caller.msg("No object or typeclass path found to match '{}'".format(oquery)) + else: + # one match found + caller.msg("Docstring for typeclass '{}':\n{}".format( + oquery, matches[0][1].__doc__)) + else: + # do the search again to get the error handling in case of multi-match + obj = caller.search(oquery) + if not obj: + return + caller.msg("{}'s current typeclass is '{}.{}'".format( + obj.name, obj.__class__.__module__, obj.__class__.__name__)) + return + # get object to swap on obj = caller.search(self.lhs) if not obj: @@ -1779,7 +1810,7 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): new_typeclass = self.rhs or obj.path - if "show" in self.switches: + if "show" in self.switches or "examine" in self.switches: string = "%s's current typeclass is %s." % (obj.name, obj.__class__) caller.msg(string) return From a16e8894b39c4b60bc8f983234ac87f8c9e4d21b Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 18 Mar 2018 12:53:38 +0100 Subject: [PATCH 249/466] Fix unit tests --- evennia/commands/default/building.py | 23 ++++---- evennia/commands/default/tests.py | 8 ++- evennia/utils/spawner.py | 81 ++++++++++++++-------------- 3 files changed, 62 insertions(+), 50 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index a948da14a9..c2200470c3 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -14,8 +14,8 @@ from evennia.utils.utils import inherits_from, class_from_module, get_all_typecl from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore from evennia.utils.spawner import (spawn, search_prototype, list_prototypes, - store_prototype, build_metaproto, validate_prototype, - delete_prototype, PermissionError) + save_db_prototype, build_metaproto, validate_prototype, + delete_db_prototype, PermissionError) from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -1739,6 +1739,7 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): key = "@typeclass" aliases = ["@type", "@parent", "@swap", "@update"] + switch_options = ("show", "examine", "update", "reset", "force", "list") locks = "cmd:perm(typeclass) or perm(Builder)" help_category = "Building" @@ -1749,7 +1750,6 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): if 'list' in self.switches: tclasses = get_all_typeclasses() - print(list(tclasses.keys())) contribs = [key for key in sorted(tclasses) if key.startswith("evennia.contrib")] or [""] core = [key for key in sorted(tclasses) @@ -1764,7 +1764,7 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): " {game}").format(core="\n ".join(core), contrib="\n ".join(contribs), game="\n ".join(game)) - caller.msg(string) + EvMore(caller, string, exit_on_lastpage=True) return if not self.args: @@ -2841,7 +2841,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): """ key = "@spawn" - switch_options = ("noloc", ) + switch_options = ("noloc", "search", "list", "show", "save", "delete", "menu") locks = "cmd:perm(spawn) or perm(Builder)" help_category = "Building" @@ -2912,7 +2912,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): tags = [tag.strip() for tag in tags.split(",")] if tags else None EvMore(caller, unicode(list_prototypes(caller, key=key, tags=tags)), exit_on_lastpage=True) - return + return if 'show' in self.switches or 'examine' in self.switches: # the argument is a key in this case (may be a partial key) @@ -2943,7 +2943,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): caller.msg("|rDeletion cancelled.|n") return try: - success = delete_prototype(caller, self.args) + success = delete_db_prototype(caller, self.args) except PermissionError as err: caller.msg("|rError deleting:|R {}|n".format(err)) caller.msg("Deletion {}.".format( @@ -2961,10 +2961,12 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # handle lhs parts = self.lhs.split(";", 3) - key, desc, tags, lockstring = "", "", [], "" + key, desc, tags, lockstring = ( + "", "User-created prototype", ["user-created"], + "edit:id({}) or perm(Admin); use:all()".format(caller.id)) nparts = len(parts) if nparts == 1: - key = parts.strip() + key = parts[0].strip() elif nparts == 2: key, desc = (part.strip() for part in parts) elif nparts == 3: @@ -3000,7 +3002,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # all seems ok. Try to save. try: - store_prototype(caller, key, prototype, desc=desc, tags=tags, locks=lockstring) + save_db_prototype(caller, key, prototype, desc=desc, tags=tags, locks=lockstring) except PermissionError as err: caller.msg("|rError saving:|R {}|n".format(err)) return @@ -3038,6 +3040,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): metaproto = metaprotos[0] if not caller.locks.check_lockstring(caller, metaproto.locks, access_type='use'): caller.msg("You don't have access to use this prototype.") + print("spawning2 {}:{} - {}".format(self.cmdstring, self.args, prototype)) return prototype = metaproto.prototype diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index bf01ac3039..fe738a3e07 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -27,6 +27,7 @@ from evennia.utils import ansi, utils from evennia.server.sessionhandler import SESSIONS from evennia import search_object from evennia import DefaultObject, DefaultCharacter +from evennia.utils import spawner # set up signal here since we are not starting the server @@ -390,8 +391,10 @@ class TestBuilding(CommandTest): self.assertEqual(goblin.location, spawnLoc) goblin.delete() + spawner.save_db_prototype(self.char1, "ball", {'key': 'Ball', 'prototype': 'GOBLIN'}) + # Tests "@spawn " - self.call(building.CmdSpawn(), "'BALL'", "Spawned Ball") + self.call(building.CmdSpawn(), "ball", "Spawned Ball") ball = getObject(self, "Ball") self.assertEqual(ball.location, self.char1.location) self.assertIsInstance(ball, DefaultObject) @@ -416,6 +419,9 @@ class TestBuilding(CommandTest): # test calling spawn with an invalid prototype. self.call(building.CmdSpawn(), "'NO_EXIST'", "No prototype named 'NO_EXIST'") + # Test listing commands + self.call(building.CmdSpawn(), "/list", "| Key ") + class TestComms(CommandTest): diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 2a003069a4..6426aa0acc 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -109,17 +109,17 @@ from django.conf import settings from random import randint import evennia from evennia.objects.models import ObjectDB -from evennia.utils.utils import make_iter, all_from_module, dbid_to_obj +from evennia.utils.utils import make_iter, all_from_module, dbid_to_obj, is_iter -from collections import namedtuple, defaultdict +from collections import namedtuple from evennia.scripts.scripts import DefaultScript from evennia.utils.create import create_script from evennia.utils.evtable import EvTable _CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") -_READONLY_PROTOTYPES = {} -_READONLY_PROTOTYPE_MODULES = {} +_MODULE_PROTOTYPES = {} +_MODULE_PROTOTYPE_MODULES = {} class PermissionError(RuntimeError): @@ -133,7 +133,7 @@ for mod in settings.PROTOTYPE_MODULES: # internally we store as (key, desc, locks, tags, prototype_dict) prots = [(key, prot) for key, prot in all_from_module(mod).items() if prot and isinstance(prot, dict)] - _READONLY_PROTOTYPES.update( + _MODULE_PROTOTYPES.update( {key.lower(): MetaProto( key.lower(), prot['prototype_desc'] if 'prototype_desc' in prot else mod, @@ -142,12 +142,12 @@ for mod in settings.PROTOTYPE_MODULES: prot['prototype_tags']) if 'prototype_tags' in prot else ["base-prototype"]), prot) for key, prot in prots}) - _READONLY_PROTOTYPE_MODULES.update({tup[0]: mod for tup in prots}) + _MODULE_PROTOTYPE_MODULES.update({tup[0]: mod for tup in prots}) # Prototype storage mechanisms -class PersistentPrototype(DefaultScript): +class DbPrototype(DefaultScript): """ This stores a single prototype """ @@ -161,10 +161,10 @@ def build_metaproto(key, desc, locks, tags, prototype): Create a metaproto from combinant parts. """ - return MetaProto(key, desc, make_iter(locks), tags, dict(prototype)) + return MetaProto(key, desc, ";".join(locks) if is_iter(locks) else locks, tags, dict(prototype)) -def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete=False): +def save_db_prototype(caller, key, prototype, desc="", tags=None, locks="", delete=False): """ Store a prototype persistently. @@ -176,7 +176,7 @@ def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete prototype (dict): Prototype dict. desc (str, optional): Description of prototype, to use in listing. tags (list, optional): Tag-strings to apply to prototype. These are always - applied with the 'persistent_prototype' category. + applied with the 'db_prototype' category. locks (str, optional): Locks to apply to this prototype. Used locks are 'use' and 'edit' delete (bool, optional): Delete an existing prototype identified by 'key'. @@ -192,14 +192,14 @@ def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete key_orig = key key = key.lower() locks = locks if locks else "use:all();edit:id({}) or perm(Admin)".format(caller.id) - tags = [(tag, "persistent_prototype") for tag in make_iter(tags)] + tags = [(tag, "db_prototype") for tag in make_iter(tags)] - if key in _READONLY_PROTOTYPES: - mod = _READONLY_PROTOTYPE_MODULES.get(key, "N/A") + if key in _MODULE_PROTOTYPES: + mod = _MODULE_PROTOTYPE_MODULES.get(key, "N/A") raise PermissionError("{} is a read-only prototype " "(defined as code in {}).".format(key_orig, mod)) - stored_prototype = PersistentPrototype.objects.filter(db_key=key) + stored_prototype = DbPrototype.objects.filter(db_key=key) if stored_prototype: # edit existing prototype @@ -227,12 +227,12 @@ def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete else: # create a new prototype stored_prototype = create_script( - PersistentPrototype, key=key, desc=desc, persistent=True, + DbPrototype, key=key, desc=desc, persistent=True, locks=locks, tags=tags, attributes=[("prototype", prototype)]) return stored_prototype -def delete_prototype(caller, key): +def delete_db_prototype(caller, key): """ Delete a stored prototype @@ -245,21 +245,21 @@ def delete_prototype(caller, key): PermissionError: If 'edit' lock was not passed. """ - return store_prototype(caller, key, None, delete=True) + return save_db_prototype(caller, key, None, delete=True) -def search_persistent_prototype(key=None, tags=None, return_metaprotos=False): +def search_db_prototype(key=None, tags=None, return_metaprotos=False): """ Find persistent (database-stored) prototypes based on key and/or tags. Kwargs: key (str): An exact or partial key to query for. tags (str or list): Tag key or keys to query for. These - will always be applied with the 'persistent_protototype' + will always be applied with the 'db_protototype' tag category. return_metaproto (bool): Return results as metaprotos. Return: - matches (queryset or list): All found PersistentPrototypes. If `return_metaprotos` + matches (queryset or list): All found DbPrototypes. If `return_metaprotos` is set, return a list of MetaProtos. Note: @@ -269,22 +269,22 @@ def search_persistent_prototype(key=None, tags=None, return_metaprotos=False): if tags: # exact match on tag(s) tags = make_iter(tags) - tag_categories = ["persistent_prototype" for _ in tags] - matches = PersistentPrototype.objects.get_by_tag(tags, tag_categories) + tag_categories = ["db_prototype" for _ in tags] + matches = DbPrototype.objects.get_by_tag(tags, tag_categories) else: - matches = PersistentPrototype.objects.all() + matches = DbPrototype.objects.all() if key: # exact or partial match on key matches = matches.filter(db_key=key) or matches.filter(db_key__icontains=key) if return_metaprotos: return [build_metaproto(match.key, match.desc, match.locks.all(), - match.tags.get(category="persistent_prototype", return_list=True), + match.tags.get(category="db_prototype", return_list=True), match.attributes.get("prototype")) for match in matches] return matches -def search_readonly_prototype(key=None, tags=None): +def search_module_prototype(key=None, tags=None): """ Find read-only prototypes, defined in modules. @@ -301,10 +301,10 @@ def search_readonly_prototype(key=None, tags=None): if tags: # use tags to limit selection tagset = set(tags) - matches = {key: metaproto for key, metaproto in _READONLY_PROTOTYPES.items() + matches = {key: metaproto for key, metaproto in _MODULE_PROTOTYPES.items() if tagset.intersection(metaproto.tags)} else: - matches = _READONLY_PROTOTYPES + matches = _MODULE_PROTOTYPES if key: if key in matches: @@ -324,7 +324,7 @@ def search_prototype(key=None, tags=None, return_meta=True): Kwargs: key (str): An exact or partial key to query for. tags (str or list): Tag key or keys to query for. These - will always be applied with the 'persistent_protototype' + will always be applied with the 'db_protototype' tag category. return_meta (bool): If False, only return prototype dicts, if True return MetaProto namedtuples including prototype meta info @@ -340,15 +340,15 @@ def search_prototype(key=None, tags=None, return_meta=True): be found. """ - readonly_prototypes = search_readonly_prototype(key, tags) - persistent_prototypes = search_persistent_prototype(key, tags, return_metaprotos=True) + module_prototypes = search_module_prototype(key, tags) + db_prototypes = search_db_prototype(key, tags, return_metaprotos=True) - matches = persistent_prototypes + readonly_prototypes + matches = db_prototypes + module_prototypes if len(matches) > 1 and key: key = key.lower() # avoid duplicates if an exact match exist between the two types filter_matches = [mta for mta in matches if mta.key == key] - if len(filter_matches) < len(matches): + if filter_matches and len(filter_matches) < len(matches): matches = filter_matches if not return_meta: @@ -369,8 +369,7 @@ def get_protparents(): return {metaproto.key: metaproto.prototype for metaproto in metaprotos} -def list_prototypes(caller, key=None, tags=None, show_non_use=False, - show_non_edit=True, sort_tree=True): +def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): """ Collate a list of found prototypes based on search criteria and access. @@ -380,22 +379,27 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, tags (str or list, optional): Tag key or keys to query for. show_non_use (bool, optional): Show also prototypes the caller may not use. show_non_edit (bool, optional): Show also prototypes the caller may not edit. - sort_tree (bool, optional): Order prototypes by inheritance tree. Returns: table (EvTable or None): An EvTable representation of the prototypes. None if no prototypes were found. """ + # this allows us to pass lists of empty strings + tags = [tag for tag in make_iter(tags) if tag] + # get metaprotos for readonly and db-based prototypes - metaprotos = search_readonly_prototype(key, tags) - metaprotos += search_persistent_prototype(key, tags, return_metaprotos=True) + metaprotos = search_module_prototype(key, tags) + metaprotos += search_db_prototype(key, tags, return_metaprotos=True) # get use-permissions of readonly attributes (edit is always False) prototypes = [ (metaproto.key, metaproto.desc, ("{}/N".format('Y' - if caller.locks.check_lockstring(caller, metaproto.locks, access_type='use') else 'N')), + if caller.locks.check_lockstring( + caller, + metaproto.locks, + access_type='use') else 'N')), ",".join(metaproto.tags)) for metaproto in sorted(metaprotos, key=lambda o: o.key)] @@ -642,7 +646,6 @@ def spawn(*prototypes, **kwargs): return _batch_create_object(*objsparams) - # Testing if __name__ == "__main__": From 0967c9f6682b0e02c01311a970d37932cc098a7c Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 18 Mar 2018 16:31:01 +0100 Subject: [PATCH 250/466] Add lockhandler.append to update lock string --- evennia/commands/default/building.py | 5 ++- evennia/locks/lockhandler.py | 48 +++++++++++++++++++++++----- evennia/utils/spawner.py | 7 +++- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index c2200470c3..c2aaa16a4c 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -3002,7 +3002,10 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # all seems ok. Try to save. try: - save_db_prototype(caller, key, prototype, desc=desc, tags=tags, locks=lockstring) + prot = save_db_prototype(caller, key, prototype, desc=desc, tags=tags, locks=lockstring) + prot.locks.append("edit", "perm(Admin)") + if not prot.locks.get("use"): + prot.locks.add("use:all()") except PermissionError as err: caller.msg("|rError saving:|R {}|n".format(err)) return diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py index b8801f9655..6b1a30ab03 100644 --- a/evennia/locks/lockhandler.py +++ b/evennia/locks/lockhandler.py @@ -421,6 +421,28 @@ class LockHandler(object): self._cache_locks(self.obj.lock_storage) self.cache_lock_bypass(self.obj) + def append(self, access_type, lockstring, op='or'): + """ + Append a lock definition to access_type if it doesn't already exist. + + Args: + access_type (str): Access type. + lockstring (str): A valid lockstring, without the operator to + link it to an eventual existing lockstring. + op (str): An operator 'and', 'or', 'and not', 'or not' used + for appending the lockstring to an existing access-type. + Note: + The most common use of this method is for use in commands where + the user can specify their own lockstrings. This method allows + the system to auto-add things like Admin-override access. + + """ + old_lockstring = self.get(access_type) + if not lockstring.strip().lower() in old_lockstring.lower(): + lockstring = "{old} {op} {new}".format( + old=old_lockstring, op=op, new=lockstring.strip()) + self.add(lockstring) + def check(self, accessing_obj, access_type, default=False, no_superuser_bypass=False): """ Checks a lock of the correct type by passing execution off to @@ -459,9 +481,13 @@ class LockHandler(object): return True except AttributeError: # happens before session is initiated. - if not no_superuser_bypass and ((hasattr(accessing_obj, 'is_superuser') and accessing_obj.is_superuser) or - (hasattr(accessing_obj, 'account') and hasattr(accessing_obj.account, 'is_superuser') and accessing_obj.account.is_superuser) or - (hasattr(accessing_obj, 'get_account') and (not accessing_obj.get_account() or accessing_obj.get_account().is_superuser))): + if not no_superuser_bypass and ( + (hasattr(accessing_obj, 'is_superuser') and accessing_obj.is_superuser) or + (hasattr(accessing_obj, 'account') and + hasattr(accessing_obj.account, 'is_superuser') and + accessing_obj.account.is_superuser) or + (hasattr(accessing_obj, 'get_account') and + (not accessing_obj.get_account() or accessing_obj.get_account().is_superuser))): return True # no superuser or bypass -> normal lock operation @@ -469,7 +495,8 @@ class LockHandler(object): # we have a lock, test it. evalstring, func_tup, raw_string = self.locks[access_type] # execute all lock funcs in the correct order, producing a tuple of True/False results. - true_false = tuple(bool(tup[0](accessing_obj, self.obj, *tup[1], **tup[2])) for tup in func_tup) + true_false = tuple(bool( + tup[0](accessing_obj, self.obj, *tup[1], **tup[2])) for tup in func_tup) # the True/False tuple goes into evalstring, which combines them # with AND/OR/NOT in order to get the final result. return eval(evalstring % true_false) @@ -520,9 +547,13 @@ class LockHandler(object): if accessing_obj.locks.lock_bypass and not no_superuser_bypass: return True except AttributeError: - if no_superuser_bypass and ((hasattr(accessing_obj, 'is_superuser') and accessing_obj.is_superuser) or - (hasattr(accessing_obj, 'account') and hasattr(accessing_obj.account, 'is_superuser') and accessing_obj.account.is_superuser) or - (hasattr(accessing_obj, 'get_account') and (not accessing_obj.get_account() or accessing_obj.get_account().is_superuser))): + if no_superuser_bypass and ( + (hasattr(accessing_obj, 'is_superuser') and accessing_obj.is_superuser) or + (hasattr(accessing_obj, 'account') and + hasattr(accessing_obj.account, 'is_superuser') and + accessing_obj.account.is_superuser) or + (hasattr(accessing_obj, 'get_account') and + (not accessing_obj.get_account() or accessing_obj.get_account().is_superuser))): return True if ":" not in lockstring: lockstring = "%s:%s" % ("_dummy", lockstring) @@ -538,7 +569,8 @@ class LockHandler(object): else: # if no access types was given and multiple locks were # embedded in the lockstring we assume all must be true - return all(self._eval_access_type(accessing_obj, locks, access_type) for access_type in locks) + return all(self._eval_access_type( + accessing_obj, locks, access_type) for access_type in locks) def _test(): diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 6426aa0acc..ed92dfadd5 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -161,7 +161,12 @@ def build_metaproto(key, desc, locks, tags, prototype): Create a metaproto from combinant parts. """ - return MetaProto(key, desc, ";".join(locks) if is_iter(locks) else locks, tags, dict(prototype)) + if locks: + locks = (";".join(locks) if is_iter(locks) else locks) + else: + locks = [] + prototype = dict(prototype) if prototype else {} + return MetaProto(key, desc, locks, tags, dict(prototype)) def save_db_prototype(caller, key, prototype, desc="", tags=None, locks="", delete=False): From 641ea746a5b817a51b643c84e2a68681f4c20a5f Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 18 Mar 2018 17:28:52 +0100 Subject: [PATCH 251/466] Further stabilizing of spawner storage mechanism and error checking --- evennia/commands/default/building.py | 39 +++++++++++++------ evennia/locks/lockhandler.py | 56 ++++++++++++++++++++++------ evennia/utils/spawner.py | 6 +++ 3 files changed, 79 insertions(+), 22 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index c2aaa16a4c..94362ec58e 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2229,12 +2229,15 @@ class CmdExamine(ObjManipCommand): else: things.append(content) if exits: - string += "\n|wExits|n: %s" % ", ".join(["%s(%s)" % (exit.name, exit.dbref) for exit in exits]) + string += "\n|wExits|n: %s" % ", ".join( + ["%s(%s)" % (exit.name, exit.dbref) for exit in exits]) if pobjs: - string += "\n|wCharacters|n: %s" % ", ".join(["|c%s|n(%s)" % (pobj.name, pobj.dbref) for pobj in pobjs]) + string += "\n|wCharacters|n: %s" % ", ".join( + ["|c%s|n(%s)" % (pobj.name, pobj.dbref) for pobj in pobjs]) if things: - string += "\n|wContents|n: %s" % ", ".join(["%s(%s)" % (cont.name, cont.dbref) for cont in obj.contents - if cont not in exits and cont not in pobjs]) + string += "\n|wContents|n: %s" % ", ".join( + ["%s(%s)" % (cont.name, cont.dbref) for cont in obj.contents + if cont not in exits and cont not in pobjs]) separator = "-" * _DEFAULT_WIDTH # output info return '%s\n%s\n%s' % (separator, string.strip(), separator) @@ -2961,9 +2964,6 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # handle lhs parts = self.lhs.split(";", 3) - key, desc, tags, lockstring = ( - "", "User-created prototype", ["user-created"], - "edit:id({}) or perm(Admin); use:all()".format(caller.id)) nparts = len(parts) if nparts == 1: key = parts[0].strip() @@ -2971,11 +2971,25 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): key, desc = (part.strip() for part in parts) elif nparts == 3: key, desc, tags = (part.strip() for part in parts) - tags = [tag.strip().lower() for tag in tags.split(",")] + tags = [tag.strip().lower() for tag in tags.split(",") if tag] else: # lockstrings can itself contain ; key, desc, tags, lockstring = (part.strip() for part in parts) - tags = [tag.strip().lower() for tag in tags.split(",")] + tags = [tag.strip().lower() for tag in tags.split(",") if tag] + if not key: + caller.msg("The prototype must have a key.") + return + if not desc: + desc = "User-created prototype" + if not tags: + tags = ["user"] + if not lockstring: + lockstring = "edit:id({}) or perm(Admin); use:all()".format(caller.id) + + is_valid, err = caller.locks.validate(lockstring) + if not is_valid: + caller.msg("|rLock error|n: {}".format(err)) + return # handle rhs: prototype = _parse_prototype(self.rhs) @@ -3002,7 +3016,11 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # all seems ok. Try to save. try: - prot = save_db_prototype(caller, key, prototype, desc=desc, tags=tags, locks=lockstring) + prot = save_db_prototype( + caller, key, prototype, desc=desc, tags=tags, locks=lockstring) + if not prot: + caller.msg("|rError saving:|R {}.|n".format(key)) + return prot.locks.append("edit", "perm(Admin)") if not prot.locks.get("use"): prot.locks.add("use:all()") @@ -3043,7 +3061,6 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): metaproto = metaprotos[0] if not caller.locks.check_lockstring(caller, metaproto.locks, access_type='use'): caller.msg("You don't have access to use this prototype.") - print("spawning2 {}:{} - {}".format(self.cmdstring, self.args, prototype)) return prototype = metaproto.prototype diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py index 6b1a30ab03..20eb117e42 100644 --- a/evennia/locks/lockhandler.py +++ b/evennia/locks/lockhandler.py @@ -287,7 +287,7 @@ class LockHandler(object): """ self.lock_bypass = hasattr(obj, "is_superuser") and obj.is_superuser - def add(self, lockstring): + def add(self, lockstring, validate_only=False): """ Add a new lockstring to handler. @@ -296,10 +296,12 @@ class LockHandler(object): `":"`. Multiple access types should be separated by semicolon (`;`). Alternatively, a list with lockstrings. - + validate_only (bool, optional): If True, validate the lockstring but + don't actually store it. Returns: success (bool): The outcome of the addition, `False` on - error. + error. If `validate_only` is True, this will be a tuple + (bool, error), for pass/fail and a string error. """ if isinstance(lockstring, basestring): @@ -308,21 +310,41 @@ class LockHandler(object): lockdefs = [lockdef for locks in lockstring for lockdef in locks.split(";")] lockstring = ";".join(lockdefs) + err = "" # sanity checks for lockdef in lockdefs: if ':' not in lockdef: - self._log_error(_("Lock: '%s' contains no colon (:).") % lockdef) - return False + err = _("Lock: '{lockdef}' contains no colon (:).").format(lockdef=lockdef) + if validate_only: + return False, err + else: + self._log_error(err) + return False access_type, rhs = [part.strip() for part in lockdef.split(':', 1)] if not access_type: - self._log_error(_("Lock: '%s' has no access_type (left-side of colon is empty).") % lockdef) - return False + err = _("Lock: '{lockdef}' has no access_type " + "(left-side of colon is empty).").format(lockdef=lockdef) + if validate_only: + return False, err + else: + self._log_error(err) + return False if rhs.count('(') != rhs.count(')'): - self._log_error(_("Lock: '%s' has mismatched parentheses.") % lockdef) - return False + err = _("Lock: '{lockdef}' has mismatched parentheses.").format(lockdef=lockdef) + if validate_only: + return False, err + else: + self._log_error(err) + return False if not _RE_FUNCS.findall(rhs): - self._log_error(_("Lock: '%s' has no valid lock functions.") % lockdef) - return False + err = _("Lock: '{lockdef}' has no valid lock functions.").format(lockdef=lockdef) + if validate_only: + return False, err + else: + self._log_error(err) + return False + if validate_only: + return True, None # get the lock string storage_lockstring = self.obj.lock_storage if storage_lockstring: @@ -334,6 +356,18 @@ class LockHandler(object): self._save_locks() return True + def validate(self, lockstring): + """ + Validate lockstring syntactically, without saving it. + + Args: + lockstring (str): Lockstring to validate. + Returns: + valid (bool): If validation passed or not. + + """ + return self.add(lockstring, validate_only=True) + def replace(self, lockstring): """ Replaces the lockstring entirely. diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index ed92dfadd5..01212a38d4 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -197,6 +197,12 @@ def save_db_prototype(caller, key, prototype, desc="", tags=None, locks="", dele key_orig = key key = key.lower() locks = locks if locks else "use:all();edit:id({}) or perm(Admin)".format(caller.id) + + is_valid, err = caller.locks.validate(locks) + if not is_valid: + caller.msg("Lock error: {}".format(err)) + return False + tags = [(tag, "db_prototype") for tag in make_iter(tags)] if key in _MODULE_PROTOTYPES: From 2d791252e3cdfa95f171bacc1bda1c48d1cdb30f Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 18 Mar 2018 21:16:39 +0100 Subject: [PATCH 252/466] Start adding menu OLC mechanic for spawner. The EvMenu behaves strangely; going from desc->tags by setting the description means that the back-option no longer works, giving an error that the desc-node is not defined ... --- evennia/commands/default/building.py | 26 +++- evennia/utils/evmenu.py | 4 +- evennia/utils/spawner.py | 204 ++++++++++++++++++++++++++- 3 files changed, 227 insertions(+), 7 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 94362ec58e..dc70e52cea 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -15,7 +15,7 @@ from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore from evennia.utils.spawner import (spawn, search_prototype, list_prototypes, save_db_prototype, build_metaproto, validate_prototype, - delete_db_prototype, PermissionError) + delete_db_prototype, PermissionError, start_olc) from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -2806,7 +2806,8 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): @spawn/show [] @spawn/save [;desc[;tag,tag[,...][;lockstring]]] = - @spawn/menu + @spawn/menu [] + @olc - equivalent to @spawn/menu Switches: noloc - allow location to be None if not specified explicitly. Otherwise, @@ -2816,7 +2817,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): show, examine - inspect prototype by key. If not given, acts like list. save - save a prototype to the database. It will be listable by /list. delete - remove a prototype from database, if allowed to. - menu - manipulate prototype in a menu interface. + menu, olc - create/manipulate prototype in a menu interface. Example: @spawn GOBLIN @@ -2844,7 +2845,8 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): """ key = "@spawn" - switch_options = ("noloc", "search", "list", "show", "save", "delete", "menu") + aliases = ["@olc"] + switch_options = ("noloc", "search", "list", "show", "save", "delete", "menu", "olc") locks = "cmd:perm(spawn) or perm(Builder)" help_category = "Building" @@ -2904,6 +2906,22 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): caller = self.caller + if self.cmdstring == "olc" or 'menu' in self.switches or 'olc' in self.switches: + # OLC menu mode + metaprot = None + if self.lhs: + key = self.lhs + metaprot = search_prototype(key=key, return_meta=True) + if len(metaprot) > 1: + caller.msg("More than one match for {}:\n{}".format( + key, "\n".join(mproto.key for mproto in metaprot))) + return + elif metaprot: + # one match + metaprot = metaprot[0] + start_olc(caller, self.session, metaprot) + return + if 'search' in self.switches: # query for a key match if not self.args: diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 3d8fb6b789..9509bbd884 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -945,9 +945,11 @@ class EvMenu(object): node (str): The formatted node to display. """ + screen_width = self._session.protocol_flags.get("SCREENWIDTH", {0: 78})[0] + nodetext_width_max = max(m_len(line) for line in nodetext.split("\n")) options_width_max = max(m_len(line) for line in optionstext.split("\n")) - total_width = max(options_width_max, nodetext_width_max) + total_width = min(screen_width, max(options_width_max, nodetext_width_max)) separator1 = "_" * total_width + "\n\n" if nodetext_width_max else "" separator2 = "\n" + "_" * total_width + "\n\n" if total_width else "" return separator1 + "|n" + nodetext + "|n" + separator2 + "|n" + optionstext diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 01212a38d4..e4d403157e 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -109,17 +109,19 @@ from django.conf import settings from random import randint import evennia from evennia.objects.models import ObjectDB -from evennia.utils.utils import make_iter, all_from_module, dbid_to_obj, is_iter +from evennia.utils.utils import make_iter, all_from_module, dbid_to_obj, is_iter, crop from collections import namedtuple from evennia.scripts.scripts import DefaultScript from evennia.utils.create import create_script from evennia.utils.evtable import EvTable +from evennia.utils.evmenu import EvMenu _CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") _MODULE_PROTOTYPES = {} _MODULE_PROTOTYPE_MODULES = {} +_MENU_CROP_WIDTH = 15 class PermissionError(RuntimeError): @@ -156,7 +158,7 @@ class DbPrototype(DefaultScript): self.desc = "A prototype" -def build_metaproto(key, desc, locks, tags, prototype): +def build_metaproto(key='', desc='', locks='', tags=None, prototype=None): """ Create a metaproto from combinant parts. @@ -657,6 +659,204 @@ def spawn(*prototypes, **kwargs): return _batch_create_object(*objsparams) +# prototype design menu nodes + +def _get_menu_metaprot(caller): + if hasattr(caller.ndb._menutree, "olc_metaprot"): + return caller.ndb._menutree.olc_metaprot + else: + metaproto = build_metaproto(None, '', [], [], None) + caller.ndb._menutree.olc_metaprot = metaproto + caller.ndb._menutree.olc_new = True + return metaproto + + +def _set_menu_metaprot(caller, field, value): + metaprot = _get_menu_metaprot(caller) + kwargs = dict(metaprot.__dict__) + kwargs[field] = value + caller.ndb._menutree.olc_metaprot = build_metaproto(**kwargs) + + +def node_index(caller): + metaprot = _get_menu_metaprot(caller) + key = "|g{}|n".format( + crop(metaprot.key, _MENU_CROP_WIDTH)) if metaprot.key else "|rundefined, required|n" + desc = "|g{}|n".format( + crop(metaprot.desc, _MENU_CROP_WIDTH)) if metaprot.desc else "''" + tags = "|g{}|n".format( + crop(", ".join(metaprot.tags), _MENU_CROP_WIDTH)) if metaprot.tags else [] + locks = "|g{}|n".format( + crop(", ".join(metaprot.locks), _MENU_CROP_WIDTH)) if metaprot.tags else [] + prot = "|gdefined|n" if metaprot.prototype else "|rundefined, required|n" + + text = ("|c --- Prototype wizard --- |n\n" + "(make choice; q to abort, h for help)") + options = ( + {"desc": "Key ({})".format(key), "goto": "node_key"}, + {"desc": "Description ({})".format(desc), "goto": "node_desc"}, + {"desc": "Tags ({})".format(tags), "goto": "node_tags"}, + {"desc": "Locks ({})".format(locks), "goto": "node_locks"}, + {"desc": "Prototype ({})".format(prot), "goto": "node_prototype_index"}) + return text, options + + +def _node_check_key(caller, key): + old_metaprot = search_prototype(key) + olc_new = caller.ndb._menutree.olc_new + key = key.strip().lower() + if old_metaprot: + # we are starting a new prototype that matches an existing + if not caller.locks.check_lockstring(caller, old_metaprot.locks, access_type='edit'): + # return to the node_key to try another key + caller.msg("Prototype '{key}' already exists and you don't " + "have permission to edit it.".format(key=key)) + return "node_key" + elif olc_new: + # we are selecting an existing prototype to edit. Reset to index. + del caller.ndb._menutree.olc_new + caller.ndb._menutree.olc_metaprot = old_metaprot + caller.msg("Prototype already exists. Reloading.") + return "node_index" + + # continue on + _set_menu_metaprot(caller, 'key', key) + caller.msg("Key '{key}' was set.".format(key=key)) + return "node_desc" + + +def node_key(caller): + metaprot = _get_menu_metaprot(caller) + text = ["The |ckey|n must be unique and is used to find and use " + "the prototype to spawn new entities. It is not case sensitive."] + old_key = metaprot.key + if old_key: + text.append("Current key is '|y{key}|n'".format(key=old_key)) + else: + text.append("The key is currently unset.") + text.append("Enter text or make choice (q for quit, h for help)") + text = "\n".join(text) + options = ({"desc": "forward (desc)", + "goto": "node_desc"}, + {"desc": "back (index)", + "goto": "node_index"}, + {"key": "_default", + "desc": "enter a key", + "goto": _node_check_key}) + return text, options + + +def _node_check_desc(caller, desc): + desc = desc.strip() + _set_menu_metaprot(caller, 'desc', desc) + caller.msg("Description was set to '{desc}'.".format(desc=desc)) + return "node_tags" + + +def node_desc(caller): + metaprot = _get_menu_metaprot(caller) + text = ["|cDescribe|n briefly the prototype for viewing in listings."] + desc = metaprot.desc + + if desc: + text.append("The current desc is:\n\"|y{desc}|n\"".format(desc)) + else: + text.append("Description is currently unset.") + text = "\n".join(text) + options = ({"desc": "forward (tags)", + "goto": "node_tags"}, + {"desc": "back (key)", + "goto": "node_key"}, + {"key": "_default", + "desc": "enter a description", + "goto": _node_check_desc}) + + return text, options + + +def _node_check_tags(caller, tags): + tags = [part.strip().lower() for part in tags.split(",")] + _set_menu_metaprot(caller, 'tags', tags) + caller.msg("Tags {tags} were set".format(tags=tags)) + return "node_locks" + + +def node_tags(caller): + metaprot = _get_menu_metaprot(caller) + text = ["|cTags|n can be used to find prototypes. They are case-insitive. " + "Separate multiple by tags by commas."] + tags = metaprot.tags + + if tags: + text.append("The current tags are:\n|y{tags}|n".format(tags)) + else: + text.append("No tags are currently set.") + text = "\n".join(text) + options = ({"desc": "forward (locks)", + "goto": "node_locks"}, + {"desc": "back (desc)", + "goto": "node_desc"}, + {"key": "_default", + "desc": "enter tags separated by commas", + "goto": _node_check_tags}) + return text, options + + +def _node_check_locks(caller, lockstring): + # TODO - have a way to validate lock string here + _set_menu_metaprot(caller, 'locks', lockstring) + caller.msg("Set lockstring '{lockstring}'.".format(lockstring=lockstring)) + return "node_prototype_index" + + +def node_locks(caller): + metaprot = _get_menu_metaprot(caller) + text = ["Set |ylocks|n on the prototype. There are two valid lock types: " + "'edit' (who can edit the prototype) and 'use' (who can apply the prototype)\n" + "(If you are unsure, leave as default.)"] + locks = metaprot.locks + if locks: + text.append("Current lock is |y'{lockstring}'|n".format(lockstring=locks)) + else: + text.append("Lock unset - if not changed the default lockstring will be set as\n" + " |y'use:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) + text = "\n".join(text) + options = ({"desc": "forward (prototype)", + "goto": "node_prototype_index"}, + {"desc": "back (tags)", + "goto": "node_tags"}, + {"key": "_default", + "desc": "enter lockstring", + "goto": _node_check_locks}) + + return text, options + + +def node_prototype_index(caller): + pass + + +def start_olc(caller, session=None, metaproto=None): + """ + Start menu-driven olc system for prototypes. + + Args: + caller (Object or Account): The entity starting the menu. + session (Session, optional): The individual session to get data. + metaproto (MetaProto, optional): Given when editing an existing + prototype rather than creating a new one. + + """ + + menudata = {"node_index": node_index, + "node_key": node_key, + "node_desc": node_desc, + "node_tags": node_tags, + "node_locks": node_locks, + "node_prototype_index": node_prototype_index} + EvMenu(caller, menudata, startnode='node_index', session=session, olc_metaproto=metaproto) + + # Testing if __name__ == "__main__": From b1ab4dd667bbf6e05a8a05be88c99c24a1a65eeb Mon Sep 17 00:00:00 2001 From: friarzen Date: Mon, 19 Mar 2018 00:59:28 +0000 Subject: [PATCH 253/466] Fix append scrolling -- needs more testing --- .../web/webclient/static/webclient/js/webclient_gui.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/evennia/web/webclient/static/webclient/js/webclient_gui.js b/evennia/web/webclient/static/webclient/js/webclient_gui.js index d07239039f..4189e026fb 100644 --- a/evennia/web/webclient/static/webclient/js/webclient_gui.js +++ b/evennia/web/webclient/static/webclient/js/webclient_gui.js @@ -257,14 +257,15 @@ function onText(args, kwargs) { if( SplitHandler ) { for ( var key in SplitHandler.split_panes) { var pane = SplitHandler.split_panes[key]; - console.log(pane); // is this message type mapped to this pane? if ( (pane['types'].length > 0) && pane['types'].includes(msgtype) ) { // yes, so append/replace this pane's inner div with this message if ( pane['update_method'] == 'replace' ) { $('#'+key).html(args[0]) } else { - $('#'+key).append(args[0]).animate({ scrollTop: document.getElementById("#"+key).scrollHeight }, 0); + $('#'+key).append(args[0]); + var scrollHeight = $('#'+key).parent().prop("scrollHeight"); + $('#'+key).parent().animate({ scrollTop: scrollHeight }, 0); } // record sending this message to a pane, no need to update the default div use_default_pane = false; @@ -279,9 +280,8 @@ function onText(args, kwargs) { var mwin = $("#messagewindow"); var cls = kwargs == null ? 'out' : kwargs['cls']; mwin.append("
" + args[0] + "
"); - mwin.animate({ - scrollTop: document.getElementById("messagewindow").scrollHeight - }, 0); + var scrollHeight = mwin.parent().parent().prop("scrollHeight"); + mwin.parent().parent().animate({ scrollTop: scrollHeight }, 0); onNewLine(args[0], null); } From ceefd1eef134c2ebda78591e0ffcbbef8c45c81d Mon Sep 17 00:00:00 2001 From: Nicholas Matlaga Date: Mon, 19 Mar 2018 11:20:08 -0400 Subject: [PATCH 254/466] fix webclient/base.html (500 error) --- .../webclient/templates/webclient/base.html | 100 +++++++----------- 1 file changed, 37 insertions(+), 63 deletions(-) diff --git a/evennia/web/webclient/templates/webclient/base.html b/evennia/web/webclient/templates/webclient/base.html index 3852b45273..a5c65fad2c 100644 --- a/evennia/web/webclient/templates/webclient/base.html +++ b/evennia/web/webclient/templates/webclient/base.html @@ -13,16 +13,18 @@ JQuery available. - - + + + + {% block jquery_import %} - + {% endblock %} + + + - + + + + + + + + {% block guilib_import %} + + {% endblock %} + - - + + + + {% block scripts %} + {% endblock %} @@ -74,6 +101,10 @@ JQuery available. web browser supporting javascript.

This error could also be due to not being able to access the online jQuery javascript library.

+ + @@ -81,62 +112,5 @@ JQuery available. {% block client %} {% endblock %} - - - - {% block jquery_import %} - - {% endblock %} - - - - - - - - - - - - - - - - - - {% block guilib_import %} - - {% endblock %} - - - - - {% block scripts %} - {% endblock %} From 2c2bd127b8c51f95ce803a65c3b32ebf26d7200b Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 19 Mar 2018 20:27:55 +0100 Subject: [PATCH 255/466] [fix] Add better error reporting from EvMenu --- evennia/utils/evmenu.py | 6 +++++- evennia/utils/spawner.py | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 9509bbd884..a6a77a871f 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -182,7 +182,8 @@ _CMD_NOINPUT = cmdhandler.CMD_NOINPUT # i18n from django.utils.translation import ugettext as _ -_ERR_NOT_IMPLEMENTED = _("Menu node '{nodename}' is not implemented. Make another choice.") +_ERR_NOT_IMPLEMENTED = _("Menu node '{nodename}' is either not implemented or " + "caused an error. Make another choice.") _ERR_GENERAL = _("Error in menu node '{nodename}'.") _ERR_NO_OPTION_DESC = _("No description.") _HELP_FULL = _("Commands: , help, quit") @@ -573,6 +574,7 @@ class EvMenu(object): except EvMenuError: errmsg = _ERR_GENERAL.format(nodename=callback) self.caller.msg(errmsg, self._session) + logger.log_trace() raise return ret @@ -606,9 +608,11 @@ class EvMenu(object): nodetext, options = ret, None except KeyError: self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session) + logger.log_trace() raise EvMenuError except Exception: self.caller.msg(_ERR_GENERAL.format(nodename=nodename), session=self._session) + logger.log_trace() raise # store options to make them easier to test diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index e4d403157e..33fbb91346 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -754,12 +754,13 @@ def _node_check_desc(caller, desc): def node_desc(caller): + metaprot = _get_menu_metaprot(caller) text = ["|cDescribe|n briefly the prototype for viewing in listings."] desc = metaprot.desc if desc: - text.append("The current desc is:\n\"|y{desc}|n\"".format(desc)) + text.append("The current desc is:\n\"|y{desc}|n\"".format(desc=desc)) else: text.append("Description is currently unset.") text = "\n".join(text) @@ -788,7 +789,7 @@ def node_tags(caller): tags = metaprot.tags if tags: - text.append("The current tags are:\n|y{tags}|n".format(tags)) + text.append("The current tags are:\n|y{tags}|n".format(tags=tags)) else: text.append("No tags are currently set.") text = "\n".join(text) From ad4b58a6cfe91485e133dcff897d754936b2dcce Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 19 Mar 2018 20:59:32 +0100 Subject: [PATCH 256/466] Separate prototype meta-properties from prototype properties in menu --- evennia/utils/spawner.py | 73 +++++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 33fbb91346..8ce91fc970 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -678,7 +678,7 @@ def _set_menu_metaprot(caller, field, value): caller.ndb._menutree.olc_metaprot = build_metaproto(**kwargs) -def node_index(caller): +def node_meta_index(caller): metaprot = _get_menu_metaprot(caller) key = "|g{}|n".format( crop(metaprot.key, _MENU_CROP_WIDTH)) if metaprot.key else "|rundefined, required|n" @@ -693,11 +693,11 @@ def node_index(caller): text = ("|c --- Prototype wizard --- |n\n" "(make choice; q to abort, h for help)") options = ( - {"desc": "Key ({})".format(key), "goto": "node_key"}, - {"desc": "Description ({})".format(desc), "goto": "node_desc"}, - {"desc": "Tags ({})".format(tags), "goto": "node_tags"}, - {"desc": "Locks ({})".format(locks), "goto": "node_locks"}, - {"desc": "Prototype ({})".format(prot), "goto": "node_prototype_index"}) + {"desc": "Key ({})".format(key), "goto": "node_meta_key"}, + {"desc": "Description ({})".format(desc), "goto": "node_meta_desc"}, + {"desc": "Tags ({})".format(tags), "goto": "node_meta_tags"}, + {"desc": "Locks ({})".format(locks), "goto": "node_meta_locks"}, + {"desc": "Prototype[menu] ({})".format(prot), "goto": "node_prototype_index"}) return text, options @@ -708,38 +708,39 @@ def _node_check_key(caller, key): if old_metaprot: # we are starting a new prototype that matches an existing if not caller.locks.check_lockstring(caller, old_metaprot.locks, access_type='edit'): - # return to the node_key to try another key + # return to the node_meta_key to try another key caller.msg("Prototype '{key}' already exists and you don't " "have permission to edit it.".format(key=key)) - return "node_key" + return "node_meta_key" elif olc_new: # we are selecting an existing prototype to edit. Reset to index. del caller.ndb._menutree.olc_new caller.ndb._menutree.olc_metaprot = old_metaprot caller.msg("Prototype already exists. Reloading.") - return "node_index" + return "node_meta_index" # continue on _set_menu_metaprot(caller, 'key', key) caller.msg("Key '{key}' was set.".format(key=key)) - return "node_desc" + return "node_meta_desc" -def node_key(caller): +def node_meta_key(caller): metaprot = _get_menu_metaprot(caller) - text = ["The |ckey|n must be unique and is used to find and use " - "the prototype to spawn new entities. It is not case sensitive."] + text = ["The prototype name, or |ckey|n, uniquely identifies the prototype. " + "It is used to find and use the prototype to spawn new entities. " + "It is not case sensitive."] old_key = metaprot.key if old_key: text.append("Current key is '|y{key}|n'".format(key=old_key)) else: text.append("The key is currently unset.") - text.append("Enter text or make choice (q for quit, h for help)") + text.append("Enter text or make a choice (q for quit, h for help)") text = "\n".join(text) options = ({"desc": "forward (desc)", - "goto": "node_desc"}, + "goto": "node_meta_desc"}, {"desc": "back (index)", - "goto": "node_index"}, + "goto": "node_meta_index"}, {"key": "_default", "desc": "enter a key", "goto": _node_check_key}) @@ -750,24 +751,24 @@ def _node_check_desc(caller, desc): desc = desc.strip() _set_menu_metaprot(caller, 'desc', desc) caller.msg("Description was set to '{desc}'.".format(desc=desc)) - return "node_tags" + return "node_meta_tags" -def node_desc(caller): +def node_meta_desc(caller): metaprot = _get_menu_metaprot(caller) text = ["|cDescribe|n briefly the prototype for viewing in listings."] desc = metaprot.desc if desc: - text.append("The current desc is:\n\"|y{desc}|n\"".format(desc=desc)) + text.append("The current meta desc is:\n\"|y{desc}|n\"".format(desc=desc)) else: text.append("Description is currently unset.") text = "\n".join(text) options = ({"desc": "forward (tags)", - "goto": "node_tags"}, + "goto": "node_meta_tags"}, {"desc": "back (key)", - "goto": "node_key"}, + "goto": "node_meta_key"}, {"key": "_default", "desc": "enter a description", "goto": _node_check_desc}) @@ -779,12 +780,12 @@ def _node_check_tags(caller, tags): tags = [part.strip().lower() for part in tags.split(",")] _set_menu_metaprot(caller, 'tags', tags) caller.msg("Tags {tags} were set".format(tags=tags)) - return "node_locks" + return "node_meta_locks" -def node_tags(caller): +def node_meta_tags(caller): metaprot = _get_menu_metaprot(caller) - text = ["|cTags|n can be used to find prototypes. They are case-insitive. " + text = ["|cTags|n can be used to classify and find prototypes. Tags are case-insensitive. " "Separate multiple by tags by commas."] tags = metaprot.tags @@ -794,9 +795,9 @@ def node_tags(caller): text.append("No tags are currently set.") text = "\n".join(text) options = ({"desc": "forward (locks)", - "goto": "node_locks"}, + "goto": "node_meta_locks"}, {"desc": "back (desc)", - "goto": "node_desc"}, + "goto": "node_meta_desc"}, {"key": "_default", "desc": "enter tags separated by commas", "goto": _node_check_tags}) @@ -810,7 +811,7 @@ def _node_check_locks(caller, lockstring): return "node_prototype_index" -def node_locks(caller): +def node_meta_locks(caller): metaprot = _get_menu_metaprot(caller) text = ["Set |ylocks|n on the prototype. There are two valid lock types: " "'edit' (who can edit the prototype) and 'use' (who can apply the prototype)\n" @@ -825,7 +826,7 @@ def node_locks(caller): options = ({"desc": "forward (prototype)", "goto": "node_prototype_index"}, {"desc": "back (tags)", - "goto": "node_tags"}, + "goto": "node_meta_tags"}, {"key": "_default", "desc": "enter lockstring", "goto": _node_check_locks}) @@ -834,6 +835,10 @@ def node_locks(caller): def node_prototype_index(caller): + metaprot = _get_menu_metaprot(caller) + text = [" |c--- Prototype menu --- |n" + ] + pass @@ -849,13 +854,13 @@ def start_olc(caller, session=None, metaproto=None): """ - menudata = {"node_index": node_index, - "node_key": node_key, - "node_desc": node_desc, - "node_tags": node_tags, - "node_locks": node_locks, + menudata = {"node_meta_index": node_meta_index, + "node_meta_key": node_meta_key, + "node_meta_desc": node_meta_desc, + "node_meta_tags": node_meta_tags, + "node_meta_locks": node_meta_locks, "node_prototype_index": node_prototype_index} - EvMenu(caller, menudata, startnode='node_index', session=session, olc_metaproto=metaproto) + EvMenu(caller, menudata, startnode='node_meta_index', session=session, olc_metaproto=metaproto) # Testing From 7f8c5ea839dd50465fc02cf253abbb45d1a3d687 Mon Sep 17 00:00:00 2001 From: friarzen Date: Wed, 21 Mar 2018 18:35:48 +0000 Subject: [PATCH 257/466] Add user selected names to each new pane and some CSS --- .../static/webclient/css/webclient.css | 17 +++++ .../static/webclient/js/splithandler.js | 44 +++++------ .../static/webclient/js/webclient_gui.js | 73 ++++++++++++------- .../templates/webclient/webclient.html | 6 +- 4 files changed, 86 insertions(+), 54 deletions(-) diff --git a/evennia/web/webclient/static/webclient/css/webclient.css b/evennia/web/webclient/static/webclient/css/webclient.css index 94344386a1..1e9adb283b 100644 --- a/evennia/web/webclient/static/webclient/css/webclient.css +++ b/evennia/web/webclient/static/webclient/css/webclient.css @@ -147,6 +147,19 @@ div {margin:0px;} cursor: pointer; } +.button { + width: fit-content; + padding: 1em; + color: black; + border: 1px solid black; + background-color: darkgray; + margin: 0 auto; +} + +.splitbutton:hover { + cursor: pointer; +} + #optionsbutton { width: 2rem; font-size: 2rem; @@ -256,6 +269,10 @@ div {margin:0px;} overflow-x: hidden; } +.split-sub { + padding: .5rem; +} + .content { border: 1px solid #C0C0C0; box-shadow: inset 0 1px 2px #e4e4e4; diff --git a/evennia/web/webclient/static/webclient/js/splithandler.js b/evennia/web/webclient/static/webclient/js/splithandler.js index 7ba8e45e17..aa6ea4364a 100644 --- a/evennia/web/webclient/static/webclient/js/splithandler.js +++ b/evennia/web/webclient/static/webclient/js/splithandler.js @@ -1,50 +1,50 @@ // Use split.js to create a basic ui var SplitHandler = (function () { - var num_splits = 0; var split_panes = {}; var set_pane_types = function(splitpane, types) { split_panes[splitpane]['types'] = types; } - var dynamic_split = function(splitpane, direction, update_method1, update_method2) { - var first = ++num_splits; - var second = ++num_splits; - var first_div = $( '
' ) - var first_sub = $( '
' ) - var second_div = $( '
' ) - var second_sub = $( '
' ) + var dynamic_split = function(splitpane, direction, pane_name1, pane_name2, update_method1, update_method2, sizes) { + // find the sub-div of the pane we are being asked to split + splitpanesub = splitpane + '-sub'; - // check to see if this pane contains the primary message window. - contents = $('#'+splitpane).contents(); + // create the new div stack to replace the sub-div with. + var first_div = $( '
' ) + var first_sub = $( '
' ) + var second_div = $( '
' ) + var second_sub = $( '
' ) + + // check to see if this sub-pane contains anything + contents = $('#'+splitpanesub).contents(); if( contents ) { - // it does, so move it to the first new div (TODO -- selectable between first/second?) + // it does, so move it to the first new div-sub (TODO -- selectable between first/second?) contents.appendTo(first_sub); } - first_div.append( first_sub ); second_div.append( second_sub ); - // update the split_panes array to remove this split + // update the split_panes array to remove this pane name delete( split_panes[splitpane] ); // now vaporize the current split_N-sub placeholder and create two new panes. - $('#'+splitpane).parent().append(first_div); - $('#'+splitpane).parent().append(second_div); - $('#'+splitpane).remove(); + $('#'+splitpane).append(first_div); + $('#'+splitpane).append(second_div); + $('#'+splitpane+'-sub').remove(); // And split - Split(['#split_'+first,'#split_'+second], { + Split(['#'+pane_name1,'#'+pane_name2], { direction: direction, - sizes: [50,50], + sizes: sizes, gutterSize: 4, minSize: [50,50], }); - // store our new splits for future splits/uses by the main UI. - split_panes['split_'+first +'-sub'] = { 'types': [], 'update_method': update_method1 }; - split_panes['split_'+second+'-sub'] = { 'types': [], 'update_method': update_method2 }; + // store our new split sub-divs for future splits/uses by the main UI. + split_panes[pane_name1] = { 'types': [], 'update_method': update_method1 }; + split_panes[pane_name2] = { 'types': [], 'update_method': update_method2 }; } @@ -63,7 +63,7 @@ var SplitHandler = (function () { minSize: [50,50], }); - split_panes['main-sub'] = {'types': [], 'update_method': 'append'}; + split_panes['main'] = { 'types': [], 'update_method': 'append' }; var input_render = Mustache.render(input_template); $('[data-role-input]').html(input_render); diff --git a/evennia/web/webclient/static/webclient/js/webclient_gui.js b/evennia/web/webclient/static/webclient/js/webclient_gui.js index 4189e026fb..7a7c218921 100644 --- a/evennia/web/webclient/static/webclient/js/webclient_gui.js +++ b/evennia/web/webclient/static/webclient/js/webclient_gui.js @@ -15,6 +15,7 @@ (function () { "use strict" +var num_splits = 0; var options = {}; @@ -167,7 +168,7 @@ function onKeydown (event) { return; } - inputfield.focus(); + //inputfield.focus(); if (code === 13) { // Enter key sends text doSendText(); @@ -245,31 +246,23 @@ function onText(args, kwargs) { known_types.push(msgtype); } - if ( msgtype == 'help' ) { - if (("helppopup" in options) && (options["helppopup"])) { - openPopup("#helpdialog", args[0]); - return; - } - // fall through to the default output - - } else { - // pass this message to each pane that has this msgtype mapped - if( SplitHandler ) { - for ( var key in SplitHandler.split_panes) { - var pane = SplitHandler.split_panes[key]; - // is this message type mapped to this pane? - if ( (pane['types'].length > 0) && pane['types'].includes(msgtype) ) { - // yes, so append/replace this pane's inner div with this message - if ( pane['update_method'] == 'replace' ) { - $('#'+key).html(args[0]) - } else { - $('#'+key).append(args[0]); - var scrollHeight = $('#'+key).parent().prop("scrollHeight"); - $('#'+key).parent().animate({ scrollTop: scrollHeight }, 0); - } - // record sending this message to a pane, no need to update the default div - use_default_pane = false; + // pass this message to each pane that has this msgtype mapped + if( SplitHandler ) { + for ( var key in SplitHandler.split_panes) { + var pane = SplitHandler.split_panes[key]; + // is this message type mapped to this pane? + if ( (pane['types'].length > 0) && pane['types'].includes(msgtype) ) { + // yes, so append/replace this pane's inner div with this message + var text_div = $('#'+key+'-sub'); + if ( pane['update_method'] == 'replace' ) { + text_div.html(args[0]) + } else { + text_div.append(args[0]); + var scrollHeight = text_div.parent().prop("scrollHeight"); + text_div.parent().animate({ scrollTop: scrollHeight }, 0); } + // record sending this message to a pane, no need to update the default div + use_default_pane = false; } } } @@ -441,19 +434,39 @@ function doStartDragDialog(event) { $(document).bind("mouseup", undrag); } - function onSplitDialogClose() { var pane = $("input[name=pane]:checked").attr("value"); var direction = $("input[name=direction]:checked").attr("value"); + var new_pane1 = $("input[name=new_pane1]").val(); + var new_pane2 = $("input[name=new_pane2]").val(); var flow1 = $("input[name=flow1]:checked").attr("value"); var flow2 = $("input[name=flow2]:checked").attr("value"); - SplitHandler.dynamic_split( pane, direction, flow1, flow2 ); + if( new_pane1 == "" ) { + new_pane1 = 'pane_'+num_splits; + num_splits++; + } + + if( new_pane2 == "" ) { + new_pane2 = 'pane_'+num_splits; + num_splits++; + } + + if( document.getElementById(new_pane1) ) { + alert('An element: "' + new_pane1 + '" already exists'); + return; + } + + if( document.getElementById(new_pane2) ) { + alert('An element: "' + new_pane2 + '" already exists'); + return; + } + + SplitHandler.dynamic_split( pane, direction, new_pane1, new_pane2, flow1, flow2, [50,50] ); closePopup("#splitdialog"); } - function onSplitDialog() { var dialog = $("#splitdialogcontent"); dialog.empty(); @@ -467,6 +480,10 @@ function onSplitDialog() { dialog.append(''+ pane +'
'); } + dialog.append("

New Pane Names

"); + dialog.append(''); + dialog.append(''); + dialog.append("

New First Pane Flow

"); dialog.append('append
'); dialog.append('replace
'); diff --git a/evennia/web/webclient/templates/webclient/webclient.html b/evennia/web/webclient/templates/webclient/webclient.html index 2b138cb8bd..b750257048 100644 --- a/evennia/web/webclient/templates/webclient/webclient.html +++ b/evennia/web/webclient/templates/webclient/webclient.html @@ -16,14 +16,12 @@
-
+
- -
-
+
From 75582f23f98b60fbc21bb9b5ed0f15a4b5deb276 Mon Sep 17 00:00:00 2001 From: friarzen Date: Thu, 22 Mar 2018 00:52:19 +0000 Subject: [PATCH 258/466] adjust css to match existing toolbar and toggle split/pane popup --- .../static/webclient/css/webclient.css | 22 +++++++++++++------ .../static/webclient/js/webclient_gui.js | 4 ++-- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/evennia/web/webclient/static/webclient/css/webclient.css b/evennia/web/webclient/static/webclient/css/webclient.css index 1e9adb283b..75dd91ce2a 100644 --- a/evennia/web/webclient/static/webclient/css/webclient.css +++ b/evennia/web/webclient/static/webclient/css/webclient.css @@ -129,20 +129,28 @@ div {margin:0px;} max-height: 3rem; } -.splitbutton { - position: absolute; - right: 1%; - top: 1%; - z-index: 1; +#splitbutton { width: 2rem; - height: 2rem; font-size: 2rem; color: #a6a6a6; background-color: transparent; border: 0px; } -.splitbutton:hover { +#splitbutton:hover { + color: white; + cursor: pointer; +} + +#panebutton { + width: 2rem; + font-size: 2rem; + color: #a6a6a6; + background-color: transparent; + border: 0px; +} + +#panebutton:hover { color: white; cursor: pointer; } diff --git a/evennia/web/webclient/static/webclient/js/webclient_gui.js b/evennia/web/webclient/static/webclient/js/webclient_gui.js index 7a7c218921..b975ae7044 100644 --- a/evennia/web/webclient/static/webclient/js/webclient_gui.js +++ b/evennia/web/webclient/static/webclient/js/webclient_gui.js @@ -496,7 +496,7 @@ function onSplitDialog() { $("#splitclose").bind("click", onSplitDialogClose); - openPopup("#splitdialog"); + togglePopup("#splitdialog"); } function onPaneControlDialogClose() { @@ -530,7 +530,7 @@ function onPaneControlDialog() { $("#paneclose").bind("click", onPaneControlDialogClose); - openPopup("#splitdialog"); + togglePopup("#splitdialog"); } // From 5ed765d66463430ba9be177607a9aa8cd4b4f418 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 24 Mar 2018 17:28:56 +0100 Subject: [PATCH 259/466] Refactor spawner menu --- evennia/utils/evmenu.py | 17 ++- evennia/utils/spawner.py | 245 +++++++++++++++++++++++++-------------- 2 files changed, 171 insertions(+), 91 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index a6a77a871f..94c1467419 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -43,13 +43,18 @@ command definition too) with function definitions: def node_with_other_name(caller, input_string): # code return text, options + + def another_node(caller, input_string, **kwargs): + # code + return text, options ``` Where caller is the object using the menu and input_string is the command entered by the user on the *previous* node (the command entered to get to this node). The node function code will only be executed once per node-visit and the system will accept nodes with -both one or two arguments interchangeably. +both one or two arguments interchangeably. It also accepts nodes +that takes **kwargs. The menu tree itself is available on the caller as `caller.ndb._menutree`. This makes it a convenient place to store @@ -82,12 +87,14 @@ menu is immediately exited and the default "look" command is called. the callable. Those kwargs will also be passed into the next node if possible. Such a callable should return either a str or a (str, dict), where the string is the name of the next node to go to and the dict is the new, - (possibly modified) kwarg to pass into the next node. + (possibly modified) kwarg to pass into the next node. If the callable returns + None or the empty string, the current node will be revisited. - `exec` (str, callable or tuple, optional): This takes the same input as `goto` above and runs before it. If given a node name, the node will be executed but will not be considered the next node. If node/callback returns str or (str, dict), these will replace the `goto` step (`goto` callbacks will not fire), with the string being the next node name and the optional dict acting as the kwargs-input for the next node. + If an exec callable returns the empty string (only), the current node is re-run. If key is not given, the option will automatically be identified by its number 1..N. @@ -669,6 +676,9 @@ class EvMenu(object): if isinstance(ret, basestring): # only return a value if a string (a goto target), ignore all other returns + if not ret: + # an empty string - rerun the same node + return self.nodename return ret, kwargs return None @@ -718,6 +728,9 @@ class EvMenu(object): raise EvMenuError( "{}: goto callable must return str or (str, dict)".format(inp_nodename)) nodename, kwargs = nodename[:2] + if not nodename: + # no nodename return. Re-run current node + nodename = self.nodename try: # execute the found node, make use of the returns. nodetext, options = self._execute_node(nodename, raw_string, **kwargs) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 8ce91fc970..2f31da0f49 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -671,6 +671,10 @@ def _get_menu_metaprot(caller): return metaproto +def _is_new_prototype(caller): + return hasattr(caller.ndb._menutree, "olc_new") + + def _set_menu_metaprot(caller, field, value): metaprot = _get_menu_metaprot(caller) kwargs = dict(metaprot.__dict__) @@ -678,30 +682,121 @@ def _set_menu_metaprot(caller, field, value): caller.ndb._menutree.olc_metaprot = build_metaproto(**kwargs) -def node_meta_index(caller): - metaprot = _get_menu_metaprot(caller) - key = "|g{}|n".format( - crop(metaprot.key, _MENU_CROP_WIDTH)) if metaprot.key else "|rundefined, required|n" - desc = "|g{}|n".format( - crop(metaprot.desc, _MENU_CROP_WIDTH)) if metaprot.desc else "''" - tags = "|g{}|n".format( - crop(", ".join(metaprot.tags), _MENU_CROP_WIDTH)) if metaprot.tags else [] - locks = "|g{}|n".format( - crop(", ".join(metaprot.locks), _MENU_CROP_WIDTH)) if metaprot.tags else [] - prot = "|gdefined|n" if metaprot.prototype else "|rundefined, required|n" +def _format_property(key, required=False, metaprot=None, prototype=None): + key = key.lower() + if metaprot is not None: + prop = getattr(metaprot, key) or '' + elif prototype is not None: + prop = prototype.get(key, '') + + out = prop + if callable(prop): + if hasattr(prop, '__name__'): + out = "<{}>".format(prop.__name__) + else: + out = repr(prop) + if is_iter(prop): + out = ", ".join(str(pr) for pr in prop) + if not out and required: + out = "|rrequired" + return " ({}|n)".format(crop(out, _MENU_CROP_WIDTH)) + + +def _set_property(caller, raw_string, **kwargs): + """ + Update a property. To be called by the 'goto' option variable. + + Args: + caller (Object, Account): The user of the wizard. + raw_string (str): Input from user on given node - the new value to set. + Kwargs: + prop (str): Property name to edit with `raw_string`. + processor (callable): Converts `raw_string` to a form suitable for saving. + next_node (str): Where to redirect to after this has run. + Returns: + next_node (str): Next node to go to. + + """ + prop = kwargs.get("prop", "meta_key") + processor = kwargs.get("processor", None) + next_node = kwargs.get("next_node", "node_index") + + propname_low = prop.strip().lower() + meta = propname_low.startswith("meta_") + if meta: + propname_low = propname_low[5:] + raw_string = raw_string.strip() + + if callable(processor): + try: + value = processor(raw_string) + except Exception as err: + caller.msg("Could not set {prop} to {value} ({err})".format( + prop=prop.replace("_", "-").capitalize(), value=raw_string, err=str(err))) + # this means we'll re-run the current node. + return None + else: + value = raw_string + + if meta: + _set_menu_metaprot(caller, propname_low, value) + else: + metaprot = _get_menu_metaprot(caller) + prototype = metaprot.prototype + prototype[propname_low] = value + _set_menu_metaprot(caller, "prototype", prototype) + + caller.msg("Set {prop} to {value}.".format( + prop=prop.replace("_", "-").capitalize(), value=str(value))) + + return next_node + + +def _wizard_options(prev_node, next_node): + options = [{"desc": "forward ({})".format(next_node.replace("_", "-")), + "goto": "node_{}".format(next_node)}, + {"desc": "back ({})".format(prev_node.replace("_", "-")), + "goto": "node_{}".format(prev_node)}] + if "index" not in (prev_node, next_node): + options.append({"desc": "index", + "goto": "node_index"}) + return options + + +def node_index(caller): + metaprot = _get_menu_metaprot(caller) + prototype = metaprot.prototype + + text = ("|c --- Prototype wizard --- |n\n\n" + "Define properties of the prototype. All prototype values can be over-ridden at " + "the time of spawning an instance of the prototype, but some are required.\n\n" + "'Meta'-properties are not used in the prototype itself but are used to organize and " + "list prototypes. The 'Meta-Key' uniquely identifies the prototype and allows you to " + "edit an existing prototype or save a new one for use by you or others later.\n\n" + "(make choice; q to abort. If unsure, start from 1.)") + + options = [] + # The meta-key goes first + options.append( + {"desc": "|WMeta-Key|n|n{}".format(_format_property("Key", True, metaprot, None)), + "goto": "node_meta_key"}) + for key in ('Prototype', 'Typeclass', 'Key', 'Aliases', 'Home', 'Destination', + 'Permissions', 'Locks', 'Location', 'Tags', 'Attrs'): + req = False + if key in ("Prototype", "Typeclass"): + req = "prototype" not in prototype and "typeclass" not in prototype + options.append( + {"desc": "|w{}|n{}".format(key, _format_property(key, req, None, prototype)), + "goto": "node_{}".format(key.lower())}) + for key in ('Desc', 'Tags', 'Locks'): + options.append( + {"desc": "|WMeta-{}|n|n{}".format(key, _format_property(key, req, metaprot, None)), + "goto": "node_meta_{}".format(key.lower())}) - text = ("|c --- Prototype wizard --- |n\n" - "(make choice; q to abort, h for help)") - options = ( - {"desc": "Key ({})".format(key), "goto": "node_meta_key"}, - {"desc": "Description ({})".format(desc), "goto": "node_meta_desc"}, - {"desc": "Tags ({})".format(tags), "goto": "node_meta_tags"}, - {"desc": "Locks ({})".format(locks), "goto": "node_meta_locks"}, - {"desc": "Prototype[menu] ({})".format(prot), "goto": "node_prototype_index"}) return text, options -def _node_check_key(caller, key): +def _check_meta_key(caller, key): old_metaprot = search_prototype(key) olc_new = caller.ndb._menutree.olc_new key = key.strip().lower() @@ -719,15 +814,12 @@ def _node_check_key(caller, key): caller.msg("Prototype already exists. Reloading.") return "node_meta_index" - # continue on - _set_menu_metaprot(caller, 'key', key) - caller.msg("Key '{key}' was set.".format(key=key)) - return "node_meta_desc" + return _set_property(caller, key, prop='meta_key', next_node="node_meta_desc") def node_meta_key(caller): metaprot = _get_menu_metaprot(caller) - text = ["The prototype name, or |ckey|n, uniquely identifies the prototype. " + text = ["The prototype name, or |cmeta-key|n, uniquely identifies the prototype. " "It is used to find and use the prototype to spawn new entities. " "It is not case sensitive."] old_key = metaprot.key @@ -735,25 +827,14 @@ def node_meta_key(caller): text.append("Current key is '|y{key}|n'".format(key=old_key)) else: text.append("The key is currently unset.") - text.append("Enter text or make a choice (q for quit, h for help)") - text = "\n".join(text) - options = ({"desc": "forward (desc)", - "goto": "node_meta_desc"}, - {"desc": "back (index)", - "goto": "node_meta_index"}, - {"key": "_default", - "desc": "enter a key", - "goto": _node_check_key}) + text.append("Enter text or make a choice (q for quit)") + text = "\n\n".join(text) + options = _wizard_options("index", "meta_desc") + options.append({"key": "_default", + "goto": _check_meta_key}) return text, options -def _node_check_desc(caller, desc): - desc = desc.strip() - _set_menu_metaprot(caller, 'desc', desc) - caller.msg("Description was set to '{desc}'.".format(desc=desc)) - return "node_meta_tags" - - def node_meta_desc(caller): metaprot = _get_menu_metaprot(caller) @@ -764,25 +845,14 @@ def node_meta_desc(caller): text.append("The current meta desc is:\n\"|y{desc}|n\"".format(desc=desc)) else: text.append("Description is currently unset.") - text = "\n".join(text) - options = ({"desc": "forward (tags)", - "goto": "node_meta_tags"}, - {"desc": "back (key)", - "goto": "node_meta_key"}, - {"key": "_default", - "desc": "enter a description", - "goto": _node_check_desc}) - + text = "\n\n".join(text) + options = _wizard_options("meta_key", "meta_tags") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop='meta_desc', next_node="node_meta_tags"))}) return text, options -def _node_check_tags(caller, tags): - tags = [part.strip().lower() for part in tags.split(",")] - _set_menu_metaprot(caller, 'tags', tags) - caller.msg("Tags {tags} were set".format(tags=tags)) - return "node_meta_locks" - - def node_meta_tags(caller): metaprot = _get_menu_metaprot(caller) text = ["|cTags|n can be used to classify and find prototypes. Tags are case-insensitive. " @@ -793,24 +863,16 @@ def node_meta_tags(caller): text.append("The current tags are:\n|y{tags}|n".format(tags=tags)) else: text.append("No tags are currently set.") - text = "\n".join(text) - options = ({"desc": "forward (locks)", - "goto": "node_meta_locks"}, - {"desc": "back (desc)", - "goto": "node_meta_desc"}, - {"key": "_default", - "desc": "enter tags separated by commas", - "goto": _node_check_tags}) + text = "\n\n".join(text) + options = _wizard_options("meta_desc", "meta_locks") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="meta_tags", + processor=lambda s: [str(part.strip()) for part in s.split(",")], + next_node="node_meta_locks"))}) return text, options -def _node_check_locks(caller, lockstring): - # TODO - have a way to validate lock string here - _set_menu_metaprot(caller, 'locks', lockstring) - caller.msg("Set lockstring '{lockstring}'.".format(lockstring=lockstring)) - return "node_prototype_index" - - def node_meta_locks(caller): metaprot = _get_menu_metaprot(caller) text = ["Set |ylocks|n on the prototype. There are two valid lock types: " @@ -822,24 +884,30 @@ def node_meta_locks(caller): else: text.append("Lock unset - if not changed the default lockstring will be set as\n" " |y'use:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) - text = "\n".join(text) - options = ({"desc": "forward (prototype)", - "goto": "node_prototype_index"}, - {"desc": "back (tags)", - "goto": "node_meta_tags"}, - {"key": "_default", - "desc": "enter lockstring", - "goto": _node_check_locks}) - + text = "\n\n".join(text) + options = _wizard_options("meta_tags", "prototype") + options.append({"key": "_default", + "desc": "enter lockstring", + "goto": (_set_property, + dict(prop="meta_locks", + next_node="node_key"))}) return text, options -def node_prototype_index(caller): +def node_key(caller): metaprot = _get_menu_metaprot(caller) - text = [" |c--- Prototype menu --- |n" - ] + prot = metaprot.prototype + key = prot.get("key") - pass + text = ["Set the prototype's |ykey|n."] + if key: + text.append("Current key value is '|y{}|n'.") + else: + text.append("Key is currently unset.") + text = "\n\n".join(text) + options = _wizard_options("meta_locks", + + return "\n".join(text), options def start_olc(caller, session=None, metaproto=None): @@ -853,14 +921,13 @@ def start_olc(caller, session=None, metaproto=None): prototype rather than creating a new one. """ - - menudata = {"node_meta_index": node_meta_index, + menudata = {"node_index": node_index, "node_meta_key": node_meta_key, "node_meta_desc": node_meta_desc, "node_meta_tags": node_meta_tags, "node_meta_locks": node_meta_locks, - "node_prototype_index": node_prototype_index} - EvMenu(caller, menudata, startnode='node_meta_index', session=session, olc_metaproto=metaproto) + "node_key": node_key} + EvMenu(caller, menudata, startnode='node_index', session=session, olc_metaproto=metaproto) # Testing From 24f8290560ccb2d6575ce102f4b37b3646c50ad4 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 24 Mar 2018 20:45:17 +0100 Subject: [PATCH 260/466] Add all spawn-menu nodes; need better validation/choices for several nodes --- evennia/utils/spawner.py | 345 ++++++++++++++++++++++++++++++++------- 1 file changed, 286 insertions(+), 59 deletions(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 2f31da0f49..25298476c6 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -725,7 +725,6 @@ def _set_property(caller, raw_string, **kwargs): meta = propname_low.startswith("meta_") if meta: propname_low = propname_low[5:] - raw_string = raw_string.strip() if callable(processor): try: @@ -763,25 +762,27 @@ def _wizard_options(prev_node, next_node): return options +# menu nodes + def node_index(caller): metaprot = _get_menu_metaprot(caller) prototype = metaprot.prototype text = ("|c --- Prototype wizard --- |n\n\n" - "Define properties of the prototype. All prototype values can be over-ridden at " - "the time of spawning an instance of the prototype, but some are required.\n\n" - "'Meta'-properties are not used in the prototype itself but are used to organize and " - "list prototypes. The 'Meta-Key' uniquely identifies the prototype and allows you to " - "edit an existing prototype or save a new one for use by you or others later.\n\n" - "(make choice; q to abort. If unsure, start from 1.)") + "Define the |yproperties|n of the prototype. All prototype values can be " + "over-ridden at the time of spawning an instance of the prototype, but some are " + "required.\n\n'|wMeta'-properties|n are not used in the prototype itself but are used " + "to organize and list prototypes. The 'Meta-Key' uniquely identifies the prototype " + "and allows you to edit an existing prototype or save a new one for use by you or " + "others later.\n\n(make choice; q to abort. If unsure, start from 1.)") options = [] # The meta-key goes first options.append( {"desc": "|WMeta-Key|n|n{}".format(_format_property("Key", True, metaprot, None)), "goto": "node_meta_key"}) - for key in ('Prototype', 'Typeclass', 'Key', 'Aliases', 'Home', 'Destination', - 'Permissions', 'Locks', 'Location', 'Tags', 'Attrs'): + for key in ('Prototype', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', + 'Permissions', 'Location', 'Home', 'Destination'): req = False if key in ("Prototype", "Typeclass"): req = "prototype" not in prototype and "typeclass" not in prototype @@ -812,84 +813,66 @@ def _check_meta_key(caller, key): del caller.ndb._menutree.olc_new caller.ndb._menutree.olc_metaprot = old_metaprot caller.msg("Prototype already exists. Reloading.") - return "node_meta_index" + return "node_index" - return _set_property(caller, key, prop='meta_key', next_node="node_meta_desc") + return _set_property(caller, key, prop='meta_key', next_node="node_prototype") def node_meta_key(caller): metaprot = _get_menu_metaprot(caller) - text = ["The prototype name, or |cmeta-key|n, uniquely identifies the prototype. " + text = ["The prototype name, or |wMeta-Key|n, uniquely identifies the prototype. " "It is used to find and use the prototype to spawn new entities. " "It is not case sensitive."] old_key = metaprot.key if old_key: - text.append("Current key is '|y{key}|n'".format(key=old_key)) + text.append("Current key is '|w{key}|n'".format(key=old_key)) else: text.append("The key is currently unset.") text.append("Enter text or make a choice (q for quit)") text = "\n\n".join(text) - options = _wizard_options("index", "meta_desc") + options = _wizard_options("index", "prototype") options.append({"key": "_default", "goto": _check_meta_key}) return text, options -def node_meta_desc(caller): - +def node_prototype(caller): metaprot = _get_menu_metaprot(caller) - text = ["|cDescribe|n briefly the prototype for viewing in listings."] - desc = metaprot.desc + prot = metaprot.prototype + prototype = prot.get("prototype") - if desc: - text.append("The current meta desc is:\n\"|y{desc}|n\"".format(desc=desc)) + text = ["Set the prototype's parent |yPrototype|n. If this is unset, Typeclass will be used."] + if prototype: + text.append("Current prototype is |y{prototype}|n.".format(prototype=prototype)) else: - text.append("Description is currently unset.") + text.append("Parent prototype is not set") text = "\n\n".join(text) - options = _wizard_options("meta_key", "meta_tags") + options = _wizard_options("meta_key", "typeclass") options.append({"key": "_default", "goto": (_set_property, - dict(prop='meta_desc', next_node="node_meta_tags"))}) + dict(prop="prototype", + processor=lambda s: s.strip(), + next_node="node_typeclass"))}) return text, options -def node_meta_tags(caller): +def node_typeclass(caller): metaprot = _get_menu_metaprot(caller) - text = ["|cTags|n can be used to classify and find prototypes. Tags are case-insensitive. " - "Separate multiple by tags by commas."] - tags = metaprot.tags + prot = metaprot.prototype + typeclass = prot.get("typeclass") - if tags: - text.append("The current tags are:\n|y{tags}|n".format(tags=tags)) + text = ["Set the typeclass's parent |yTypeclass|n."] + if typeclass: + text.append("Current typeclass is |y{typeclass}|n.".format(typeclass=typeclass)) else: - text.append("No tags are currently set.") + text.append("Using default typeclass {typeclass}.".format( + typeclass=settings.BASE_OBJECT_TYPECLASS)) text = "\n\n".join(text) - options = _wizard_options("meta_desc", "meta_locks") + options = _wizard_options("prototype", "key") options.append({"key": "_default", "goto": (_set_property, - dict(prop="meta_tags", - processor=lambda s: [str(part.strip()) for part in s.split(",")], - next_node="node_meta_locks"))}) - return text, options - - -def node_meta_locks(caller): - metaprot = _get_menu_metaprot(caller) - text = ["Set |ylocks|n on the prototype. There are two valid lock types: " - "'edit' (who can edit the prototype) and 'use' (who can apply the prototype)\n" - "(If you are unsure, leave as default.)"] - locks = metaprot.locks - if locks: - text.append("Current lock is |y'{lockstring}'|n".format(lockstring=locks)) - else: - text.append("Lock unset - if not changed the default lockstring will be set as\n" - " |y'use:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) - text = "\n\n".join(text) - options = _wizard_options("meta_tags", "prototype") - options.append({"key": "_default", - "desc": "enter lockstring", - "goto": (_set_property, - dict(prop="meta_locks", + dict(prop="typeclass", + processor=lambda s: s.strip(), next_node="node_key"))}) return text, options @@ -899,15 +882,248 @@ def node_key(caller): prot = metaprot.prototype key = prot.get("key") - text = ["Set the prototype's |ykey|n."] + text = ["Set the prototype's |yKey|n. This will retain case sensitivity."] if key: - text.append("Current key value is '|y{}|n'.") + text.append("Current key value is '|y{key}|n'.".format(key=key)) else: text.append("Key is currently unset.") text = "\n\n".join(text) - options = _wizard_options("meta_locks", + options = _wizard_options("typeclass", "aliases") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="key", + processor=lambda s: s.strip(), + next_node="node_aliases"))}) + return text, options + + +def node_aliases(caller): + metaprot = _get_menu_metaprot(caller) + prot = metaprot.prototype + aliases = prot.get("aliases") + + text = ["Set the prototype's |yAliases|n. Separate multiple aliases with commas. " + "ill retain case sensitivity."] + if aliases: + text.append("Current aliases are '|y{aliases}|n'.".format(aliases=aliases)) + else: + text.append("No aliases are set.") + text = "\n\n".join(text) + options = _wizard_options("key", "attrs") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="aliases", + processor=lambda s: [part.strip() for part in s.split(",")], + next_node="node_attrs"))}) + return text, options + + +def node_attrs(caller): + metaprot = _get_menu_metaprot(caller) + prot = metaprot.prototype + attrs = prot.get("attrs") + + text = ["Set the prototype's |yAttributes|n. Separate multiple attrs with commas. " + "Will retain case sensitivity."] + if attrs: + text.append("Current attrs are '|y{attrs}|n'.".format(attrs=attrs)) + else: + text.append("No attrs are set.") + text = "\n\n".join(text) + options = _wizard_options("aliases", "tags") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="attrs", + processor=lambda s: [part.strip() for part in s.split(",")], + next_node="node_tags"))}) + return text, options + + +def node_tags(caller): + metaprot = _get_menu_metaprot(caller) + prot = metaprot.prototype + tags = prot.get("tags") + + text = ["Set the prototype's |yTags|n. Separate multiple tags with commas. " + "Will retain case sensitivity."] + if tags: + text.append("Current tags are '|y{tags}|n'.".format(tags=tags)) + else: + text.append("No tags are set.") + text = "\n\n".join(text) + options = _wizard_options("attrs", "locks") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="tags", + processor=lambda s: [part.strip() for part in s.split(",")], + next_node="node_locks"))}) + return text, options + + +def node_locks(caller): + metaprot = _get_menu_metaprot(caller) + prot = metaprot.prototype + locks = prot.get("locks") + + text = ["Set the prototype's |yLock string|n. Separate multiple locks with semi-colons. " + "Will retain case sensitivity."] + if locks: + text.append("Current locks are '|y{locks}|n'.".format(locks=locks)) + else: + text.append("No locks are set.") + text = "\n\n".join(text) + options = _wizard_options("tags", "permissions") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="locks", + processor=lambda s: s.strip(), + next_node="node_permissions"))}) + return text, options + + +def node_permissions(caller): + metaprot = _get_menu_metaprot(caller) + prot = metaprot.prototype + permissions = prot.get("permissions") + + text = ["Set the prototype's |yPermissions|n. Separate multiple permissions with commas. " + "Will retain case sensitivity."] + if permissions: + text.append("Current permissions are '|y{permissions}|n'.".format(permissions=permissions)) + else: + text.append("No permissions are set.") + text = "\n\n".join(text) + options = _wizard_options("destination", "location") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="permissions", + processor=lambda s: [part.strip() for part in s.split(",")], + next_node="node_location"))}) + return text, options + + +def node_location(caller): + metaprot = _get_menu_metaprot(caller) + prot = metaprot.prototype + location = prot.get("location") + + text = ["Set the prototype's |yLocation|n"] + if location: + text.append("Current location is |y{location}|n.".format(location=location)) + else: + text.append("Default location is {}'s inventory.".format(caller)) + text = "\n\n".join(text) + options = _wizard_options("permissions", "home") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="location", + processor=lambda s: s.strip(), + next_node="node_home"))}) + return text, options + + +def node_home(caller): + metaprot = _get_menu_metaprot(caller) + prot = metaprot.prototype + home = prot.get("home") + + text = ["Set the prototype's |yHome location|n"] + if home: + text.append("Current home location is |y{home}|n.".format(home=home)) + else: + text.append("Default home location (|y{home}|n) used.".format(home=settings.DEFAULT_HOME)) + text = "\n\n".join(text) + options = _wizard_options("aliases", "destination") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="home", + processor=lambda s: s.strip(), + next_node="node_destination"))}) + return text, options + + +def node_destination(caller): + metaprot = _get_menu_metaprot(caller) + prot = metaprot.prototype + dest = prot.get("dest") + + text = ["Set the prototype's |yDestination|n. This is usually only used for Exits."] + if dest: + text.append("Current destination is |y{dest}|n.".format(dest=dest)) + else: + text.append("No destination is set (default).") + text = "\n\n".join(text) + options = _wizard_options("home", "meta_desc") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="dest", + processor=lambda s: s.strip(), + next_node="node_meta_desc"))}) + return text, options + + +def node_meta_desc(caller): + + metaprot = _get_menu_metaprot(caller) + text = ["The |wMeta-Description|n briefly describes the prototype for viewing in listings."] + desc = metaprot.desc + + if desc: + text.append("The current meta desc is:\n\"|w{desc}|n\"".format(desc=desc)) + else: + text.append("Description is currently unset.") + text = "\n\n".join(text) + options = _wizard_options("meta_key", "meta_tags") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop='meta_desc', + processor=lambda s: s.strip(), + next_node="node_meta_tags"))}) + + return text, options + + +def node_meta_tags(caller): + metaprot = _get_menu_metaprot(caller) + text = ["|wMeta-Tags|n can be used to classify and find prototypes. Tags are case-insensitive. " + "Separate multiple by tags by commas."] + tags = metaprot.tags + + if tags: + text.append("The current tags are:\n|w{tags}|n".format(tags=tags)) + else: + text.append("No tags are currently set.") + text = "\n\n".join(text) + options = _wizard_options("meta_desc", "meta_locks") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="meta_tags", + processor=lambda s: [ + str(part.strip().lower()) for part in s.split(",")], + next_node="node_meta_locks"))}) + return text, options + + +def node_meta_locks(caller): + metaprot = _get_menu_metaprot(caller) + text = ["Set |wMeta-Locks|n on the prototype. There are two valid lock types: " + "'edit' (who can edit the prototype) and 'use' (who can apply the prototype)\n" + "(If you are unsure, leave as default.)"] + locks = metaprot.locks + if locks: + text.append("Current lock is |w'{lockstring}'|n".format(lockstring=locks)) + else: + text.append("Lock unset - if not changed the default lockstring will be set as\n" + " |w'use:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) + text = "\n\n".join(text) + options = _wizard_options("meta_tags", "index") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="meta_locks", + processor=lambda s: s.strip().lower(), + next_node="node_index"))}) + return text, options - return "\n".join(text), options def start_olc(caller, session=None, metaproto=None): @@ -923,10 +1139,21 @@ def start_olc(caller, session=None, metaproto=None): """ menudata = {"node_index": node_index, "node_meta_key": node_meta_key, + "node_prototype": node_prototype, + "node_typeclass": node_typeclass, + "node_key": node_key, + "node_aliases": node_aliases, + "node_attrs": node_attrs, + "node_tags": node_tags, + "node_locks": node_locks, + "node_permissions": node_permissions, + "node_location": node_location, + "node_home": node_home, + "node_destination": node_destination, "node_meta_desc": node_meta_desc, "node_meta_tags": node_meta_tags, "node_meta_locks": node_meta_locks, - "node_key": node_key} + } EvMenu(caller, menudata, startnode='node_index', session=session, olc_metaproto=metaproto) From 28960a1f8aa6e8c0d18eb397719666dfbda2003d Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Tue, 27 Mar 2018 14:04:53 +0200 Subject: [PATCH 261/466] Add the basis of building_menu with quit and persistence --- evennia/contrib/building_menu.py | 539 +++++++++++++++++++++++++++++++ 1 file changed, 539 insertions(+) create mode 100644 evennia/contrib/building_menu.py diff --git a/evennia/contrib/building_menu.py b/evennia/contrib/building_menu.py new file mode 100644 index 0000000000..b1151e1bb6 --- /dev/null +++ b/evennia/contrib/building_menu.py @@ -0,0 +1,539 @@ +""" +Module containing the building menu system. + +Evennia contributor: vincent-lg 2018 + +Building menus are similar to `EvMenu`, except that they have been specifically-designed to edit information as a builder. Creating a building menu in a command allows builders quick-editing of a given object, like a room. Here is an example of output you could obtain when editing the room: + +``` + Editing the room: Limbo + + [T]itle: the limbo room + [D]escription + This is the limbo room. You can easily change this default description, + either by using the |y@desc/edit|n command, or simply by selecting this + menu (enter |yd|n). + [E]xits: + north to A parking(#4) + [Q]uit this menu +``` + +From there, you can open the title sub-menu by pressing t. You can then change the room title by simply entering text, and go back to the main menu entering @ (all this is customizable). Press q to quit this menu. + +The first thing to do is to create a new module and place a class inheriting from `BuildingMenu` in it. + +```python +from evennia.contrib.building_menu import BuildingMenu + +class RoomMenu(BuildingMenu): + # ... to be ocmpleted ... +``` + +Next, override the `init` method. You can add choices (like the title, description, and exits sub-menus as seen above) by using the `add_choice` method. + +``` +class RoomMenu(BuildingMenu): + def init(self, room): + self.add_choice("Title", "t", attr="key") +``` + +That will create the first choice, the title sub-menu. If one opens your menu and enter t, she will be in the title sub-menu. She can change the title (it will write in the room's `key` attribute) and then go back to the main menu using `@`. + +`add_choice` has a lot of arguments and offer a great deal of flexibility. The most useful ones is probably the usage of callback, as you can set any argument in `add_choice` to be a callback, a function that you have defined above in your module. Here is a very short example of this: + +``` +def show_exits(menu +``` + +""" + +from inspect import getargspec + +from django.conf import settings +from evennia import Command, CmdSet +from evennia.commands import cmdhandler +from evennia.utils.logger import log_err, log_trace +from evennia.utils.ansi import strip_ansi +from evennia.utils.utils import class_from_module + +_MAX_TEXT_WIDTH = settings.CLIENT_DEFAULT_WIDTH +_CMD_NOMATCH = cmdhandler.CMD_NOMATCH +_CMD_NOINPUT = cmdhandler.CMD_NOINPUT + +def _call_or_get(value, menu=None, choice=None, string=None, obj=None, caller=None): + """ + Call the value, if appropriate, or just return it. + + Args: + value (any): the value to obtain. + + Kwargs: + menu (BuildingMenu, optional): the building menu to pass to value + choice (Choice, optional): the choice to pass to value if a callback. + string (str, optional): the raw string to pass to value if a callback. if a callback. + obj (any): the object to pass to value if a callback. + caller (Account or Character, optional): the caller. + + Returns: + The value itself. If the argument is a function, call it with specific + arguments, passing it the menu, choice, string, and object if supported. + + Note: + If `value` is a function, call it with varying arguments. The + list of arguments will depend on the argument names. + - An argument named `menu` will contain the building menu or None. + - The `choice` argument will contain the choice or None. + - The `string` argument will contain the raw string or None. + - The `obj` argument will contain the object or None. + - The `caller` argument will contain the caller or None. + - Any other argument will contain the object (`obj`). + + """ + if callable(value): + # Check the function arguments + kwargs = {} + spec = getargspec(value) + args = spec.args + if spec.keywords: + kwargs.update(dict(menu=menu, choice=choice, string=string, obj=obj, caller=caller)) + else: + if "menu" in args: + kwargs["menu"] = menu + if "choice" in args: + kwargs["choice"] = choice + if "string" in args: + kwargs["string"] = string + if "obj" in args: + kwargs["obj"] = obj + if "caller" in args: + kwargs["caller"] = caller + + # Fill missing arguments + for arg in args: + if arg not in kwargs: + kwargs[arg] = obj + + # Call the function and return its return value + return value(**kwargs) + + return value + + +class Choice(object): + + """A choice object, created by `add_choice`.""" + + def __init__(self, title, key=None, aliases=None, attr=None, callback=None, text=None, brief=None, menu=None, caller=None, obj=None): + """Constructor. + + Args: + title (str): the choice's title. + key (str, optional): the key of the letters to type to access + the sub-neu. If not set, try to guess it based on the title. + aliases (list of str, optional): the allowed aliases for this choice. + attr (str, optional): the name of the attribute of 'obj' to set. + callback (callable, optional): the function to call before the input + is set in `attr`. If `attr` is not set, you should + specify a function that both callback and set the value in `obj`. + text (str or callable, optional): a text to be displayed when + the menu is opened It can be a callable. + brief (str or callable, optional): a brief summary of the + sub-menu shown in the main menu. It can be set to + display the current value of the attribute in the + main menu itself. + menu (BuildingMenu, optional): the parent building menu. + caller (Account or Object, optional): the caller. + obj (Object, optional): the object to edit. + + """ + self.title = title + self.key = key + self.aliases = aliases + self.attr = attr + self.callback = callback + self.text = text + self.brief = brief + self.menu = menu + self.caller = caller + self.obj = obj + + def __repr__(self): + return "".format(self.title, self.key) + + def trigger(self, string): + """Call the trigger callback, is specified.""" + if self.callback: + _call_or_get(self.callback, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) + + +class BuildingMenu(object): + + """ + Class allowing to create and set builder menus. + + A builder menu is a kind of `EvMenu` designed to edit objects by + builders, although it can be used for players in some contexts. You + could, for instance, create a builder menu to edit a room with a + sub-menu for the room's key, another for the room's description, + another for the room's exits, and so on. + + To add choices (sub-menus), you should call `add_choice` (see the + full documentation of this method). With most arguments, you can + specify either a plain string or a callback. This callback will be + called when the operation is to be performed. + + """ + + def __init__(self, caller=None, obj=None, title="Building menu: {obj}", key=None): + """Constructor, you shouldn't override. See `init` instead. + + Args: + obj (Object): the object to be edited, like a room. + + """ + self.caller = caller + self.obj = obj + self.title = title + self.choices = [] + self.key = key + self.cmds = {} + + # Options (can be overridden in init) + self.min_shortcut = 1 + + if obj: + self.init(obj) + + # If choices have been added without keys, try to guess them + for choice in self.choices: + if choice.key is None: + title = strip_ansi(choice.title.strip()).lower() + length = self.min_shortcut + i = 0 + while length <= len(title): + while i < len(title) - length + 1: + guess = title[i:i + length] + if guess not in self.cmds: + choice.key = guess + break + + i += 1 + + if choice.key is not None: + break + + length += 1 + + if choice.key is None: + raise ValueError("Cannot guess the key for {}".format(choice)) + else: + self.cmds[chocie.key] = choice + + def init(self, obj): + """Create the sub-menu to edit the specified object. + + Args: + obj (Object): the object to edit. + + Note: + This method is probably to be overridden in your subclasses. Use `add_choice` and its variants to create sub-menus. + + """ + pass + + def add_choice(self, title, key=None, aliases=None, attr=None, callback=None, text=None, brief=None): + """Add a choice, a valid sub-menu, in the current builder menu. + + Args: + title (str): the choice's title. + key (str, optional): the key of the letters to type to access + the sub-neu. If not set, try to guess it based on the title. + aliases (list of str, optional): the allowed aliases for this choice. + attr (str, optional): the name of the attribute of 'obj' to set. + callback (callable, optional): the function to call before the input + is set in `attr`. If `attr` is not set, you should + specify a function that both callback and set the value in `obj`. + text (str or callable, optional): a text to be displayed when + the menu is opened It can be a callable. + brief (str or callable, optional): a brief summary of the + sub-menu shown in the main menu. It can be set to + display the current value of the attribute in the + main menu itself. + + Note: + All arguments can be a callable, like a function. This has the + advantage of allowing persistent building menus. If you specify + a callable in any of the arguments, the callable should return + the value expected by the argument (a str more often than + not) and can have the following arguments: + callable(menu) + callable(menu, user) + callable(menu, user, input) + + """ + key = key or "" + key = key.lower() + aliases = aliases or [] + aliases = [a.lower() for a in aliases] + if callback is None: + if attr is None: + raise ValueError("The choice {} has neither attr nor callback, specify one of these as arguments".format(title)) + + callback = menu_setattr + + if key and key in self.cmds: + raise ValueError("A conflict exists between {} and {}, both use key or alias {}".format(self.cmds[key], title, repr(key))) + + choice = Choice(title, key, aliases, attr, callback, text, brief, menu=self, caller=self.caller, obj=self.obj) + self.choices.append(choice) + if key: + self.cmds[key] = choice + + for alias in aliases: + self.cmds[alias] = choice + + def add_choice_quit(self, title="quit the menu", key="q", aliases=None): + """ + Add a simple choice just to quit the building menu. + + Args: + title (str, optional): the choice title. + key (str, optional): the choice key. + aliases (list of str, optional): the choice aliases. + + Note: + This is just a shortcut method, calling `add_choice`. + + """ + return self.add_choice(title, key=key, aliases=aliases, callback=menu_quit) + + def _generate_commands(self, cmdset): + """ + Generate commands for the menu, if any is needed. + + Args: + cmdset (CmdSet): the cmdset. + + """ + if self.key is None: + for choice in self.choices: + cmd = MenuCommand(key=choice.key, aliases=choice.aliases, building_menu=self, choice=choice) + cmd.get_help = lambda cmd, caller: _call_or_get(choice.text, menu=self, choice=choice, obj=self.obj, caller=self.caller) + cmdset.add(cmd) + + def _save(self): + """Save the menu in a persistent attribute on the caller.""" + self.caller.ndb._building_menu = self + self.caller.db._building_menu = { + "class": type(self).__module__ + "." + type(self).__name__, + "obj": self.obj, + "key": self.key, + } + + def open(self): + """Open the building menu for the caller.""" + caller = self.caller + self._save() + self.caller.cmdset.add(BuildingMenuCmdSet, permanent=True) + + # Try to find the newly added cmdset (a shortcut would be nice) + for cmdset in self.caller.cmdset.get(): + if isinstance(cmdset, BuildingMenuCmdSet): + self._generate_commands(cmdset) + self.display() + return + + # Display methods. Override for customization + def display_title(self): + """Return the menu title to be displayed.""" + return _call_or_get(self.title, menu=self, obj=self.obj, caller=self.caller).format(obj=self.obj) + + def display_choice(self, choice): + """Display the specified choice. + + Args: + choice (Choice): the menu choice. + + """ + title = _call_or_get(choice.title, menu=self, choice=choice, obj=self.obj, caller=self.caller) + clear_title = title.lower() + pos = clear_title.find(choice.key.lower()) + ret = " " + if pos >= 0: + ret += title[:pos] + "[|y" + choice.key.title() + "|n]" + title[pos + len(choice.key):] + else: + ret += "[|y" + choice.key.title() + "|n] " + title + + return ret + + def display(self): + """Display the entire menu.""" + menu = self.display_title() + "\n" + for choice in self.choices: + menu += "\n" + self.display_choice(choice) + + self.caller.msg(menu) + + @staticmethod + def restore(caller, cmdset): + """Restore the building menu for the caller. + + Args: + caller (Account or Character): the caller. + cmdset (CmdSet): the cmdset. + + Note: + This method should be automatically called if a menu is + saved in the caller, but the object itself cannot be found. + + """ + menu = caller.db._buildingmenu + if menu: + class_name = menu.get("class") + if not class_name: + log_err("BuildingMenu: on caller {}, a persistent attribute holds building menu data, but no class could be found to restore the menu".format(caller)) + return + + try: + menu_class = class_from_module(class_name) + except Exception: + log_trace("BuildingMenu: attempting to load class {} failed".format(repr(class_name))) + return False + + # Create the menu + obj = menu.get("obj") + try: + building_menu = menu_class(caller, obj) + except Exception: + log_trace("An error occurred while creating building menu {}".format(repr(class_name))) + return False + + # If there's no saved key, add the menu commands + building_menu._generate_commands(cmdset) + + return building_menu + + +class MenuCommand(Command): + + """An applicaiton-specific command.""" + + help_category = "Application-specific" + + def __init__(self, **kwargs): + self.menu = kwargs.pop("building_menu", None) + self.choice = kwargs.pop("choice", None) + super(MenuCommand, self).__init__(**kwargs) + + def func(self): + """Function body.""" + if self.choice is None: + log_err("Command: {}, no choice has been specified".format(self.key)) + self.msg("An unexpected error occurred. Closing the menu.") + self.caller.cmdset.delete(BuildingMenuCmdSet) + return + + self.choice.trigger(self.args) + + +class CmdNoInput(MenuCommand): + + """No input has been found.""" + + key = _CMD_NOINPUT + locks = "cmd:all()" + + def func(self): + """Redisplay the screen, if any.""" + if self.menu: + self.menu.display() + else: + log_err("When CMDNOMATCH was called, the building menu couldn't be found") + self.caller.msg("The building menu couldn't be found, remove the CmdSet") + self.caller.cmdset.delete(BuildingMenuCmdSet) + + +class CmdNoMatch(Command): + + """No input has been found.""" + + key = _CMD_NOMATCH + locks = "cmd:all()" + + def func(self): + """Redirect most inputs to the screen, if found.""" + raw_string = self.raw_string.rstrip() + self.msg("No match") + + +class BuildingMenuCmdSet(CmdSet): + + """ + Building menu CmdSet, adding commands specific to the menu. + """ + + key = "building_menu" + priority = 5 + + def at_cmdset_creation(self): + """Populates the cmdset with commands.""" + caller = self.cmdsetobj + + # The caller could recall the menu + menu = caller.ndb._building_menu + if menu: + menu._generate_commands(self) + else: + menu = caller.db._building_menu + if menu: + menu = BuildingMenu.restore(caller, self) + + cmds = [CmdNoInput, CmdNoMatch] + for cmd in cmds: + self.add(cmd(building_menu=menu, choice=None)) + + +# Helper functions +def menu_setattr(menu, choice, obj, string): + """ + Set the value at the specified attribute. + + Args: + menu (BuildingMenu): the menu object. + choice (Chocie): the specific choice. + obj (any): the object to modify. + string (str): the string with the new value. + + Note: + This function is supposed to be used as a default to + `BuildingMenu.add_choice`, when an attribute name is specified + but no function to callback the said value. + + """ + attr = getattr(choice, "attr", None) + if choice is None or string is None or attr is None or menu is None: + log_err("The `menu_setattr` function was called to set the attribute {} of object {} to {}, but the choice {} of menu {} or another information is missing.".format(attr, obj, repr(string), choice, menu)) + return + + for part in attr.split(".")[:-1]: + obj = getattr(obj, part) + + setattr(obj, attr.split(".")[-1], string) + menu.display() + +def menu_quit(caller): + """ + Quit the menu, closing the CmdSet. + + Args: + caller (Account or Object): the caller. + + """ + if caller is None: + log_err("The function `menu_quit` was called from a building menu without a caller") + + if caller.cmdset.has(BuildingMenuCmdSet): + caller.msg("Closing the building menu.") + caller.cmdset.remove(BuildingMenuCmdSet) + else: + caller.msg("It looks like the building menu has already been closed.") From bfe9dde655c28ee17cc75b54f0df0e36635d4baf Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Tue, 27 Mar 2018 20:42:25 +0200 Subject: [PATCH 262/466] Add the setattr choice building menu as a default --- evennia/contrib/building_menu.py | 111 +++++++++++++++++++++++-------- 1 file changed, 84 insertions(+), 27 deletions(-) diff --git a/evennia/contrib/building_menu.py b/evennia/contrib/building_menu.py index b1151e1bb6..ed77bb1cb3 100644 --- a/evennia/contrib/building_menu.py +++ b/evennia/contrib/building_menu.py @@ -48,6 +48,7 @@ def show_exits(menu """ from inspect import getargspec +from textwrap import dedent from django.conf import settings from evennia import Command, CmdSet @@ -123,7 +124,8 @@ class Choice(object): """A choice object, created by `add_choice`.""" - def __init__(self, title, key=None, aliases=None, attr=None, callback=None, text=None, brief=None, menu=None, caller=None, obj=None): + def __init__(self, title, key=None, aliases=None, attr=None, on_select=None, on_nomatch=None, text=None, brief=None, + menu=None, caller=None, obj=None): """Constructor. Args: @@ -132,9 +134,8 @@ class Choice(object): the sub-neu. If not set, try to guess it based on the title. aliases (list of str, optional): the allowed aliases for this choice. attr (str, optional): the name of the attribute of 'obj' to set. - callback (callable, optional): the function to call before the input - is set in `attr`. If `attr` is not set, you should - specify a function that both callback and set the value in `obj`. + on_select (callable, optional): a callable to call when the choice is selected. + on_nomatch (callable, optional): a callable to call when no match is entered in the choice. text (str or callable, optional): a text to be displayed when the menu is opened It can be a callable. brief (str or callable, optional): a brief summary of the @@ -150,7 +151,8 @@ class Choice(object): self.key = key self.aliases = aliases self.attr = attr - self.callback = callback + self.on_select = on_select + self.on_nomatch = on_nomatch self.text = text self.brief = brief self.menu = menu @@ -160,10 +162,30 @@ class Choice(object): def __repr__(self): return "".format(self.title, self.key) - def trigger(self, string): - """Call the trigger callback, is specified.""" - if self.callback: - _call_or_get(self.callback, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) + def select(self, string): + """Called when the user opens the choice.""" + if self.on_select: + _call_or_get(self.on_select, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) + + # Display the text if there is some + if self.text: + self.caller.msg(_call_or_get(self.text, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj)) + + + def nomatch(self, string): + """Called when the user entered something that wasn't a command in a given choice. + + Args: + string (str): the entered string. + + """ + if self.on_nomatch: + _call_or_get(self.on_nomatch, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) + + def display_text(self): + """Display the choice text to the caller.""" + text = _call_or_get(self.text, menu=self.menu, choice=self, string="", caller=self.caller, obj=self.obj) + return text.format(obj=self.obj, caller=self.caller) class BuildingMenu(object): @@ -241,7 +263,7 @@ class BuildingMenu(object): """ pass - def add_choice(self, title, key=None, aliases=None, attr=None, callback=None, text=None, brief=None): + def add_choice(self, title, key=None, aliases=None, attr=None, on_select=None, on_nomatch=None, text=None, brief=None): """Add a choice, a valid sub-menu, in the current builder menu. Args: @@ -250,7 +272,8 @@ class BuildingMenu(object): the sub-neu. If not set, try to guess it based on the title. aliases (list of str, optional): the allowed aliases for this choice. attr (str, optional): the name of the attribute of 'obj' to set. - callback (callable, optional): the function to call before the input + on_select (callable, optional): a callable to call when the choice is selected. + on_nomatch (callable, optional): a callable to call when no match is entered in the choice. is set in `attr`. If `attr` is not set, you should specify a function that both callback and set the value in `obj`. text (str or callable, optional): a text to be displayed when @@ -275,16 +298,21 @@ class BuildingMenu(object): key = key.lower() aliases = aliases or [] aliases = [a.lower() for a in aliases] - if callback is None: + if on_select is None and on_nomatch is None: if attr is None: raise ValueError("The choice {} has neither attr nor callback, specify one of these as arguments".format(title)) - callback = menu_setattr + if attr and on_nomatch is None: + on_nomatch = menu_setattr + + if isinstance(text, basestring): + text = dedent(text.strip("\n")) if key and key in self.cmds: raise ValueError("A conflict exists between {} and {}, both use key or alias {}".format(self.cmds[key], title, repr(key))) - choice = Choice(title, key, aliases, attr, callback, text, brief, menu=self, caller=self.caller, obj=self.obj) + choice = Choice(title, key=key, aliases=aliases, attr=attr, on_select=on_select, on_nomatch=on_nomatch, text=text, + brief=brief, menu=self, caller=self.caller, obj=self.obj) self.choices.append(choice) if key: self.cmds[key] = choice @@ -305,7 +333,7 @@ class BuildingMenu(object): This is just a shortcut method, calling `add_choice`. """ - return self.add_choice(title, key=key, aliases=aliases, callback=menu_quit) + return self.add_choice(title, key=key, aliases=aliases, on_select=menu_quit) def _generate_commands(self, cmdset): """ @@ -318,7 +346,7 @@ class BuildingMenu(object): if self.key is None: for choice in self.choices: cmd = MenuCommand(key=choice.key, aliases=choice.aliases, building_menu=self, choice=choice) - cmd.get_help = lambda cmd, caller: _call_or_get(choice.text, menu=self, choice=choice, obj=self.obj, caller=self.caller) + cmd.get_help = lambda cmd, caller: choice.display_text() cmdset.add(cmd) def _save(self): @@ -427,13 +455,20 @@ class MenuCommand(Command): def func(self): """Function body.""" - if self.choice is None: + if self.choice is None or self.menu is None: log_err("Command: {}, no choice has been specified".format(self.key)) - self.msg("An unexpected error occurred. Closing the menu.") + self.msg("|rAn unexpected error occurred. Closing the menu.|n") self.caller.cmdset.delete(BuildingMenuCmdSet) return - self.choice.trigger(self.args) + self.menu.key = self.choice.key + self.menu._save() + for cmdset in self.caller.cmdset.get(): + if isinstance(cmdset, BuildingMenuCmdSet): + for command in cmdset: + cmdset.remove(command) + break + self.choice.select(self.raw_string) class CmdNoInput(MenuCommand): @@ -444,16 +479,20 @@ class CmdNoInput(MenuCommand): locks = "cmd:all()" def func(self): - """Redisplay the screen, if any.""" + """Display the menu or choice text.""" if self.menu: - self.menu.display() + choice = self.menu.cmds.get(self.menu.key) + if self.menu.key and choice: + choice.display_text() + else: + self.menu.display() else: - log_err("When CMDNOMATCH was called, the building menu couldn't be found") - self.caller.msg("The building menu couldn't be found, remove the CmdSet") + log_err("When CMDNOINPUT was called, the building menu couldn't be found") + self.caller.msg("|rThe building menu couldn't be found, remove the CmdSet.|n") self.caller.cmdset.delete(BuildingMenuCmdSet) -class CmdNoMatch(Command): +class CmdNoMatch(MenuCommand): """No input has been found.""" @@ -463,7 +502,26 @@ class CmdNoMatch(Command): def func(self): """Redirect most inputs to the screen, if found.""" raw_string = self.raw_string.rstrip() - self.msg("No match") + choice = self.menu.cmds.get(self.menu.key) if self.menu else None + cmdset = None + for cset in self.caller.cmdset.get(): + if isinstance(cset, BuildingMenuCmdSet): + cmdset = cset + break + if self.menu is None: + log_err("When CMDNOMATCH was called, the building menu couldn't be found") + self.caller.msg("|rThe building menu couldn't be found, remove the CmdSet.|n") + self.caller.cmdset.delete(BuildingMenuCmdSet) + elif self.args == "/" and self.menu.key: + self.menu.key = None + self.menu._save() + self.menu._generate_commands(cmdset) + self.menu.display() + elif self.menu.key: + choice.nomatch(raw_string) + choice.display_text() + else: + self.menu.display() class BuildingMenuCmdSet(CmdSet): @@ -507,7 +565,7 @@ def menu_setattr(menu, choice, obj, string): Note: This function is supposed to be used as a default to `BuildingMenu.add_choice`, when an attribute name is specified - but no function to callback the said value. + but no function to call `on_nomatch` the said value. """ attr = getattr(choice, "attr", None) @@ -519,7 +577,6 @@ def menu_setattr(menu, choice, obj, string): obj = getattr(obj, part) setattr(obj, attr.split(".")[-1], string) - menu.display() def menu_quit(caller): """ From 0f17e73b8c879cc68295e0b5d406a6c031a2640c Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 28 Mar 2018 00:02:00 +0200 Subject: [PATCH 263/466] Start add list_node EvMenu node decorator --- evennia/utils/evmenu.py | 102 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 94c1467419..148ba4c0dc 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -166,6 +166,7 @@ evennia.utils.evmenu`. from __future__ import print_function import random from builtins import object, range +import re from textwrap import dedent from inspect import isfunction, getargspec @@ -972,6 +973,107 @@ class EvMenu(object): return separator1 + "|n" + nodetext + "|n" + separator2 + "|n" + optionstext +# ----------------------------------------------------------- +# +# List node +# +# ----------------------------------------------------------- + +def list_node(option_list, examine_processor, goto_processor, pagesize=10): + """ + Decorator for making an EvMenu node into a multi-page list node. Will add new options, + prepending those options added in the node. + + Args: + option_list (list): List of strings indicating the options. + examine_processor (callable): Will be called with the caller and the chosen option when + examining said option. Should return a text string to display in the node. + goto_processor (callable): Will be called with caller and + the chosen option from the optionlist. Should return the target node to goto after the + selection. + pagesize (int): How many options to show per page. + + Example: + + @list_node(['foo', 'bar'], examine_processor, goto_processor) + def node_index(caller): + text = "describing the list" + return text, [] + + """ + + def _rerouter(caller, raw_string): + "Parse which input was given, select from option_list" + + caller.ndb._menutree + + goto_processor + + + + def decorator(func): + + all_options = [{"desc": opt, "goto": _rerouter} for opt in option_list] + all_options = list(sorted(all_options, key=lambda d: d["desc"])) + + nall_options = len(all_options) + pages = [all_options[ind:ind + pagesize] for ind in range(0, nall_options, pagesize)] + npages = len(pages) + + def _examine_select(caller, raw_string, **kwargs): + + match = re.search(r"[0-9]+$", raw_string) + + + page_index = kwargs.get("optionpage_index", 0) + + + def _list_node(caller, raw_string, **kwargs): + + # update text with detail, if set + + + # dynamic, multi-page option list + page_index = max(0, min(npages - 1, kwargs.get("optionpage_index", 0))) + + options = pages[page_index] + + if options: + if npages > 1: + # if the goto callable returns None, the same node is rerun, and + # kwargs not used by the callable are passed on to the node. + if page_index > 0: + options.append({"desc": "prev", + "goto": (lambda caller: None, + {"optionpage_index": page_index - 1})}) + if page_index < npages - 1: + options.append({"desc": "next", + "goto": (lambda caller: None, + {"optionpage_index": page_index + 1})}) + options.append({"key": "_default", + "goto": (_examine_select, {"optionpage_index": page_index})}) + + # add data from the decorated node + + try: + text, extra_options = func(caller, raw_string) + except Exception: + logger.log_trace() + else: + if isinstance(extra_options, {}): + extra_options = [extra_options] + else: + extra_options = make_iter(extra_options) + options.append(extra_options) + + return text, options + + return _list_node + return decorator + + + + # ------------------------------------------------------------------------------------------------- # # Simple input shortcuts From ec359503ac8fd01d77b8b148fdf24798061a833c Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Wed, 28 Mar 2018 14:06:51 +0200 Subject: [PATCH 264/466] Simplify and expand building menus with proper callables and at-a-glance descriptions --- evennia/contrib/building_menu.py | 101 +++++++++++++++++-------------- 1 file changed, 54 insertions(+), 47 deletions(-) diff --git a/evennia/contrib/building_menu.py b/evennia/contrib/building_menu.py index ed77bb1cb3..af72d909a5 100644 --- a/evennia/contrib/building_menu.py +++ b/evennia/contrib/building_menu.py @@ -11,7 +11,7 @@ Building menus are similar to `EvMenu`, except that they have been specifically- [T]itle: the limbo room [D]escription This is the limbo room. You can easily change this default description, - either by using the |y@desc/edit|n command, or simply by selecting this + either by using the |y@desc/edit|n command, or simply by entering this menu (enter |yd|n). [E]xits: north to A parking(#4) @@ -124,7 +124,7 @@ class Choice(object): """A choice object, created by `add_choice`.""" - def __init__(self, title, key=None, aliases=None, attr=None, on_select=None, on_nomatch=None, text=None, brief=None, + def __init__(self, title, key=None, aliases=None, attr=None, text=None, glance=None, on_enter=None, on_nomatch=None, on_leave=None, menu=None, caller=None, obj=None): """Constructor. @@ -134,15 +134,16 @@ class Choice(object): the sub-neu. If not set, try to guess it based on the title. aliases (list of str, optional): the allowed aliases for this choice. attr (str, optional): the name of the attribute of 'obj' to set. - on_select (callable, optional): a callable to call when the choice is selected. - on_nomatch (callable, optional): a callable to call when no match is entered in the choice. text (str or callable, optional): a text to be displayed when the menu is opened It can be a callable. - brief (str or callable, optional): a brief summary of the + glance (str or callable, optional): an at-a-glance summary of the sub-menu shown in the main menu. It can be set to display the current value of the attribute in the main menu itself. menu (BuildingMenu, optional): the parent building menu. + on_enter (callable, optional): a callable to call when the choice is entered. + on_nomatch (callable, optional): a callable to call when no match is entered in the choice. + on_leave (callable, optional): a callable to call when the caller leaves the choice. caller (Account or Object, optional): the caller. obj (Object, optional): the object to edit. @@ -151,10 +152,11 @@ class Choice(object): self.key = key self.aliases = aliases self.attr = attr - self.on_select = on_select - self.on_nomatch = on_nomatch self.text = text - self.brief = brief + self.glance = glance + self.on_enter = on_enter + self.on_nomatch = on_nomatch + self.on_leave = on_leave self.menu = menu self.caller = caller self.obj = obj @@ -162,15 +164,13 @@ class Choice(object): def __repr__(self): return "".format(self.title, self.key) - def select(self, string): + def enter(self, string): """Called when the user opens the choice.""" - if self.on_select: - _call_or_get(self.on_select, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) + if self.on_enter: + _call_or_get(self.on_enter, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) # Display the text if there is some - if self.text: - self.caller.msg(_call_or_get(self.text, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj)) - + self.display_text() def nomatch(self, string): """Called when the user entered something that wasn't a command in a given choice. @@ -184,8 +184,9 @@ class Choice(object): def display_text(self): """Display the choice text to the caller.""" - text = _call_or_get(self.text, menu=self.menu, choice=self, string="", caller=self.caller, obj=self.obj) - return text.format(obj=self.obj, caller=self.caller) + if self.text: + text = _call_or_get(self.text, menu=self.menu, choice=self, string="", caller=self.caller, obj=self.obj) + self.caller.msg(text.format(obj=self.obj, caller=self.caller)) class BuildingMenu(object): @@ -206,6 +207,9 @@ class BuildingMenu(object): """ + keys_go_back = ["@"] + min_shortcut = 1 + def __init__(self, caller=None, obj=None, title="Building menu: {obj}", key=None): """Constructor, you shouldn't override. See `init` instead. @@ -220,9 +224,6 @@ class BuildingMenu(object): self.key = key self.cmds = {} - # Options (can be overridden in init) - self.min_shortcut = 1 - if obj: self.init(obj) @@ -263,7 +264,8 @@ class BuildingMenu(object): """ pass - def add_choice(self, title, key=None, aliases=None, attr=None, on_select=None, on_nomatch=None, text=None, brief=None): + def add_choice(self, title, key=None, aliases=None, attr=None, text=None, glance=None, + on_enter=None, on_nomatch=None, on_leave=None): """Add a choice, a valid sub-menu, in the current builder menu. Args: @@ -272,16 +274,17 @@ class BuildingMenu(object): the sub-neu. If not set, try to guess it based on the title. aliases (list of str, optional): the allowed aliases for this choice. attr (str, optional): the name of the attribute of 'obj' to set. - on_select (callable, optional): a callable to call when the choice is selected. - on_nomatch (callable, optional): a callable to call when no match is entered in the choice. - is set in `attr`. If `attr` is not set, you should - specify a function that both callback and set the value in `obj`. text (str or callable, optional): a text to be displayed when the menu is opened It can be a callable. - brief (str or callable, optional): a brief summary of the + glance (str or callable, optional): an at-a-glance summary of the sub-menu shown in the main menu. It can be set to display the current value of the attribute in the main menu itself. + on_enter (callable, optional): a callable to call when the choice is entered. + on_nomatch (callable, optional): a callable to call when no match is entered in the choice. + is set in `attr`. If `attr` is not set, you should + specify a function that both callback and set the value in `obj`. + on_leave (callable, optional): a callable to call when the caller leaves the choice. Note: All arguments can be a callable, like a function. This has the @@ -298,7 +301,7 @@ class BuildingMenu(object): key = key.lower() aliases = aliases or [] aliases = [a.lower() for a in aliases] - if on_select is None and on_nomatch is None: + if on_enter is None and on_nomatch is None: if attr is None: raise ValueError("The choice {} has neither attr nor callback, specify one of these as arguments".format(title)) @@ -311,8 +314,8 @@ class BuildingMenu(object): if key and key in self.cmds: raise ValueError("A conflict exists between {} and {}, both use key or alias {}".format(self.cmds[key], title, repr(key))) - choice = Choice(title, key=key, aliases=aliases, attr=attr, on_select=on_select, on_nomatch=on_nomatch, text=text, - brief=brief, menu=self, caller=self.caller, obj=self.obj) + choice = Choice(title, key=key, aliases=aliases, attr=attr, text=text, glance=glance, on_enter=on_enter, on_nomatch=on_nomatch, on_leave=on_leave, + menu=self, caller=self.caller, obj=self.obj) self.choices.append(choice) if key: self.cmds[key] = choice @@ -320,7 +323,7 @@ class BuildingMenu(object): for alias in aliases: self.cmds[alias] = choice - def add_choice_quit(self, title="quit the menu", key="q", aliases=None): + def add_choice_quit(self, title="quit the menu", key="q", aliases=None, on_enter=None): """ Add a simple choice just to quit the building menu. @@ -328,12 +331,18 @@ class BuildingMenu(object): title (str, optional): the choice title. key (str, optional): the choice key. aliases (list of str, optional): the choice aliases. + on_enter (callable, optional): a different callable to quit the building menu. Note: This is just a shortcut method, calling `add_choice`. + If `on_enter` is not set, use `menu_quit` which simply + closes the menu and displays a message. It also + removes the CmdSet from the caller. If you supply + another callable instead, make sure to do the same. """ - return self.add_choice(title, key=key, aliases=aliases, on_select=menu_quit) + on_enter = on_enter or menu_quit + return self.add_choice(title, key=key, aliases=aliases, on_enter=on_enter) def _generate_commands(self, cmdset): """ @@ -363,13 +372,7 @@ class BuildingMenu(object): caller = self.caller self._save() self.caller.cmdset.add(BuildingMenuCmdSet, permanent=True) - - # Try to find the newly added cmdset (a shortcut would be nice) - for cmdset in self.caller.cmdset.get(): - if isinstance(cmdset, BuildingMenuCmdSet): - self._generate_commands(cmdset) - self.display() - return + self.display() # Display methods. Override for customization def display_title(self): @@ -391,6 +394,10 @@ class BuildingMenu(object): ret += title[:pos] + "[|y" + choice.key.title() + "|n]" + title[pos + len(choice.key):] else: ret += "[|y" + choice.key.title() + "|n] " + title + if choice.glance: + glance = _call_or_get(choice.glance, menu=self, choice=choice, caller=self.caller, string="", obj=self.obj) + glance = glance.format(obj=self.obj, caller=self.caller) + ret += ": " + glance return ret @@ -415,7 +422,7 @@ class BuildingMenu(object): saved in the caller, but the object itself cannot be found. """ - menu = caller.db._buildingmenu + menu = caller.db._building_menu if menu: class_name = menu.get("class") if not class_name: @@ -426,10 +433,11 @@ class BuildingMenu(object): menu_class = class_from_module(class_name) except Exception: log_trace("BuildingMenu: attempting to load class {} failed".format(repr(class_name))) - return False + return # Create the menu obj = menu.get("obj") + key = menu.get("key") try: building_menu = menu_class(caller, obj) except Exception: @@ -437,6 +445,7 @@ class BuildingMenu(object): return False # If there's no saved key, add the menu commands + building_menu.key = key building_menu._generate_commands(cmdset) return building_menu @@ -463,12 +472,9 @@ class MenuCommand(Command): self.menu.key = self.choice.key self.menu._save() - for cmdset in self.caller.cmdset.get(): - if isinstance(cmdset, BuildingMenuCmdSet): - for command in cmdset: - cmdset.remove(command) - break - self.choice.select(self.raw_string) + self.caller.cmdset.delete(BuildingMenuCmdSet) + self.caller.cmdset.add(BuildingMenuCmdSet, permanent=True) + self.choice.enter(self.raw_string) class CmdNoInput(MenuCommand): @@ -512,10 +518,11 @@ class CmdNoMatch(MenuCommand): log_err("When CMDNOMATCH was called, the building menu couldn't be found") self.caller.msg("|rThe building menu couldn't be found, remove the CmdSet.|n") self.caller.cmdset.delete(BuildingMenuCmdSet) - elif self.args == "/" and self.menu.key: + elif raw_string in self.menu.keys_go_back and self.menu.key: self.menu.key = None self.menu._save() - self.menu._generate_commands(cmdset) + self.caller.cmdset.delete(BuildingMenuCmdSet) + self.caller.cmdset.add(BuildingMenuCmdSet, permanent=True) self.menu.display() elif self.menu.key: choice.nomatch(raw_string) From 70b1bd1ada3a697c6f49441ff5f2af3c97d9f649 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Wed, 28 Mar 2018 19:20:08 +0200 Subject: [PATCH 265/466] Add the link between building menus and the EvEditor --- evennia/contrib/building_menu.py | 77 +++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/evennia/contrib/building_menu.py b/evennia/contrib/building_menu.py index af72d909a5..e07e9e3141 100644 --- a/evennia/contrib/building_menu.py +++ b/evennia/contrib/building_menu.py @@ -53,8 +53,9 @@ from textwrap import dedent from django.conf import settings from evennia import Command, CmdSet from evennia.commands import cmdhandler -from evennia.utils.logger import log_err, log_trace from evennia.utils.ansi import strip_ansi +from evennia.utils.eveditor import EvEditor +from evennia.utils.logger import log_err, log_trace from evennia.utils.utils import class_from_module _MAX_TEXT_WIDTH = settings.CLIENT_DEFAULT_WIDTH @@ -323,6 +324,26 @@ class BuildingMenu(object): for alias in aliases: self.cmds[alias] = choice + def add_choice_edit(self, title="description", key="d", aliases=None, attr="db.desc", glance="\n {obj.db.desc}", on_enter=None): + """ + Add a simple choice to edit a given attribute in the EvEditor. + + Args: + title (str, optional): the choice title. + key (str, optional): the choice key. + aliases (list of str, optional): the choice aliases. + glance (str or callable, optional): the at-a-glance description. + on_enter (callable, optional): a different callable to edit the attribute. + + Note: + This is just a shortcut method, calling `add_choice`. + If `on_enter` is not set, use `menu_edit` which opens + an EvEditor to edit the specified attribute. + + """ + on_enter = on_enter or menu_edit + return self.add_choice(title, key=key, aliases=aliases, attr=attr, glance=glance, on_enter=on_enter) + def add_choice_quit(self, title="quit the menu", key="q", aliases=None, on_enter=None): """ Add a simple choice just to quit the building menu. @@ -601,3 +622,57 @@ def menu_quit(caller): caller.cmdset.remove(BuildingMenuCmdSet) else: caller.msg("It looks like the building menu has already been closed.") + +def menu_edit(caller, choice, obj): + """ + Open the EvEditor to edit a specified field. + + Args: + caller (Account or Object): the caller. + choice (Choice): the choice object. + obj (any): the object to edit. + + """ + attr = choice.attr + caller.db._building_menu_to_edit = (obj, attr) + caller.cmdset.remove(BuildingMenuCmdSet) + EvEditor(caller, loadfunc=_menu_loadfunc, savefunc=_menu_savefunc, quitfunc=_menu_quitfunc, key="editor", persistent=True) + +def _menu_loadfunc(caller): + obj, attr = caller.attributes.get("_building_menu_to_edit", [None, None]) + if obj and attr: + for part in attr.split(".")[:-1]: + obj = getattr(obj, part) + + return getattr(obj, attr.split(".")[-1]) if obj is not None else "" + +def _menu_savefunc(caller, buf): + obj, attr = caller.attributes.get("_building_menu_to_edit", [None, None]) + if obj and attr: + for part in attr.split(".")[:-1]: + obj = getattr(obj, part) + + setattr(obj, attr.split(".")[-1], buf) + + if caller.ndb._building_menu: + caller.ndb._building_menu.key = None + if caller.db._building_menu: + caller.db._building_menu["key"] = None + + caller.attributes.remove("_building_menu_to_edit") + caller.cmdset.add(BuildingMenuCmdSet) + if caller.ndb._building_menu: + caller.ndb._building_menu.display() + + return True + +def _menu_quitfunc(caller): + caller.attributes.remove("_building_menu_to_edit") + if caller.ndb._building_menu: + caller.ndb._building_menu.key = None + if caller.db._building_menu: + caller.db._building_menu["key"] = None + + caller.cmdset.add(BuildingMenuCmdSet) + if caller.ndb._building_menu: + caller.ndb._building_menu.display() From 3c5d00ac3db8ddfbdccdb37f78476cc64645185d Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 28 Mar 2018 23:56:23 +0200 Subject: [PATCH 266/466] Almost working list_node evmenu decorator --- evennia/utils/evmenu.py | 105 +++++++++++++++++++++++---------------- evennia/utils/spawner.py | 15 +++++- 2 files changed, 76 insertions(+), 44 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 148ba4c0dc..6c8729aee1 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1002,61 +1002,82 @@ def list_node(option_list, examine_processor, goto_processor, pagesize=10): """ - def _rerouter(caller, raw_string): - "Parse which input was given, select from option_list" - - caller.ndb._menutree - - goto_processor - - - def decorator(func): - all_options = [{"desc": opt, "goto": _rerouter} for opt in option_list] - all_options = list(sorted(all_options, key=lambda d: d["desc"])) + def _input_parser(caller, raw_string, **kwargs): + "Parse which input was given, select from option_list" - nall_options = len(all_options) - pages = [all_options[ind:ind + pagesize] for ind in range(0, nall_options, pagesize)] + available_choices = kwargs.get("available_choices", []) + processor = kwargs.get("selection_processor") + try: + match_ind = int(re.search(r"[0-9]+$", raw_string).group()) - 1 + selection = available_choices[match_ind] + except (AttributeError, KeyError, IndexError, ValueError): + return None + + if processor: + try: + return processor(caller, selection) + except Exception: + logger.log_trace() + return selection + + nall_options = len(option_list) + pages = [option_list[ind:ind + pagesize] for ind in range(0, nall_options, pagesize)] npages = len(pages) - def _examine_select(caller, raw_string, **kwargs): - - match = re.search(r"[0-9]+$", raw_string) - - - page_index = kwargs.get("optionpage_index", 0) - - def _list_node(caller, raw_string, **kwargs): - # update text with detail, if set - - - # dynamic, multi-page option list page_index = max(0, min(npages - 1, kwargs.get("optionpage_index", 0))) + page = pages[page_index] - options = pages[page_index] + # dynamic, multi-page option list. We use _input_parser as a goto-callable, + # with the `goto_processor` redirecting when we leave the node. + options = [{"desc": opt, + "goto": (_input_parser, + {"available_choices": page, + "selection_processor": goto_processor})} for opt in page] - if options: - if npages > 1: - # if the goto callable returns None, the same node is rerun, and - # kwargs not used by the callable are passed on to the node. - if page_index > 0: - options.append({"desc": "prev", - "goto": (lambda caller: None, - {"optionpage_index": page_index - 1})}) - if page_index < npages - 1: - options.append({"desc": "next", - "goto": (lambda caller: None, - {"optionpage_index": page_index + 1})}) - options.append({"key": "_default", - "goto": (_examine_select, {"optionpage_index": page_index})}) + if npages > 1: + # if the goto callable returns None, the same node is rerun, and + # kwargs not used by the callable are passed on to the node. This + # allows us to call ourselves over and over, using different kwargs. + if page_index > 0: + options.append({"key": ("|wb|Wack|n", "b"), + "goto": (lambda caller: None, + {"optionpage_index": page_index - 1})}) + if page_index < npages - 1: + options.append({"key": ("|wn|Wext|n", "n"), + "goto": (lambda caller: None, + {"optionpage_index": page_index + 1})}) + + options.append({"key": ("|Wcurrent|n", "c"), + "desc": "|W({}/{})|n".format(page_index + 1, npages), + "goto": (lambda caller: None, + {"optionpage_index": page_index})}) + options.append({"key": "_default", + "goto": (lambda caller: None, + {"show_detail": True, "optionpage_index": page_index})}) + + # update text with detail, if set. Here we call _input_parser like a normal function + text_detail = None + if raw_string and 'show_detail' in kwargs: + text_detail = _input_parser( + caller, raw_string, **{"available_choices": page, + "selection_processor": examine_processor}) + if text_detail is None: + text_detail = "|rThat's not a valid command or option.|n" # add data from the decorated node + text = '' try: text, extra_options = func(caller, raw_string) + except TypeError: + try: + text, extra_options = func(caller) + except Exception: + raise except Exception: logger.log_trace() else: @@ -1066,14 +1087,14 @@ def list_node(option_list, examine_processor, goto_processor, pagesize=10): extra_options = make_iter(extra_options) options.append(extra_options) + text = text + "\n\n" + text_detail if text_detail else text + return text, options return _list_node return decorator - - # ------------------------------------------------------------------------------------------------- # # Simple input shortcuts diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 25298476c6..3b2d6d4353 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -109,13 +109,14 @@ from django.conf import settings from random import randint import evennia from evennia.objects.models import ObjectDB -from evennia.utils.utils import make_iter, all_from_module, dbid_to_obj, is_iter, crop +from evennia.utils.utils import ( + make_iter, all_from_module, dbid_to_obj, is_iter, crop, get_all_typeclasses) from collections import namedtuple from evennia.scripts.scripts import DefaultScript from evennia.utils.create import create_script from evennia.utils.evtable import EvTable -from evennia.utils.evmenu import EvMenu +from evennia.utils.evmenu import EvMenu, list_node _CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") @@ -856,6 +857,16 @@ def node_prototype(caller): return text, options +def _typeclass_examine(caller, typeclass): + return "This is typeclass |y{}|n.".format(typeclass) + + +def _typeclass_select(caller, typeclass): + caller.msg("Selected typeclass |y{}|n.".format(typeclass)) + return None + + +@list_node(list(sorted(get_all_typeclasses().keys())), _typeclass_examine, _typeclass_select) def node_typeclass(caller): metaprot = _get_menu_metaprot(caller) prot = metaprot.prototype From 0ac83639e0509d840df7a913fbc28be3f6d85492 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Fri, 30 Mar 2018 13:32:30 +0200 Subject: [PATCH 267/466] Update building menus, removing MenuCommand --- evennia/contrib/building_menu.py | 363 ++++++++++++++++++++++--------- 1 file changed, 265 insertions(+), 98 deletions(-) diff --git a/evennia/contrib/building_menu.py b/evennia/contrib/building_menu.py index e07e9e3141..b8f6515b80 100644 --- a/evennia/contrib/building_menu.py +++ b/evennia/contrib/building_menu.py @@ -165,13 +165,19 @@ class Choice(object): def __repr__(self): return "".format(self.title, self.key) + @property + def keys(self): + """Return a tuple of keys separated by `sep_keys`.""" + return tuple(self.key.split(self.menu.sep_keys)) + def enter(self, string): """Called when the user opens the choice.""" if self.on_enter: _call_or_get(self.on_enter, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) # Display the text if there is some - self.display_text() + if self.caller: + self.caller.msg(self.format_text()) def nomatch(self, string): """Called when the user entered something that wasn't a command in a given choice. @@ -183,11 +189,14 @@ class Choice(object): if self.on_nomatch: _call_or_get(self.on_nomatch, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) - def display_text(self): - """Display the choice text to the caller.""" + def format_text(self): + """Format the choice text and return it, or an empty string.""" + text = "" if self.text: text = _call_or_get(self.text, menu=self.menu, choice=self, string="", caller=self.caller, obj=self.obj) - self.caller.msg(text.format(obj=self.obj, caller=self.caller)) + text = text.format(obj=self.obj, caller=self.caller) + + return text class BuildingMenu(object): @@ -209,9 +218,11 @@ class BuildingMenu(object): """ keys_go_back = ["@"] + sep_keys = "." + joker_key = "*" min_shortcut = 1 - def __init__(self, caller=None, obj=None, title="Building menu: {obj}", key=None): + def __init__(self, caller=None, obj=None, title="Building menu: {obj}", key="", parents=None): """Constructor, you shouldn't override. See `init` instead. Args: @@ -223,6 +234,7 @@ class BuildingMenu(object): self.title = title self.choices = [] self.key = key + self.parents = parents or () self.cmds = {} if obj: @@ -253,6 +265,72 @@ class BuildingMenu(object): else: self.cmds[chocie.key] = choice + @property + def keys(self): + """Return a tuple of keys separated by `sep_keys`.""" + if not self.key: + return () + + return tuple(self.key.split(self.sep_keys)) + + @property + def current_choice(self): + """Return the current choice or None.""" + menu_keys = self.keys + if not menu_keys: + return None + + for choice in self.choices: + choice_keys = choice.keys + if len(menu_keys) == len(choice_keys): + # Check all the intermediate keys + common = True + for menu_key, choice_key in zip(menu_keys, choice_keys): + if choice_key == self.joker_key: + continue + + if menu_key != choice_key: + common = False + break + + if common: + return choice + + return None + + @property + def relevant_choices(self): + """Only return the relevant choices according to the current meny key. + + The menu key is stored and will be used to determine the + actual position of the caller in the menu. Therefore, this + method compares the menu key (`self.key`) to all the choices' + keys. It also handles the joker key. + + """ + menu_keys = self.keys + relevant = [] + for choice in self.choices: + choice_keys = choice.keys + if not menu_keys and len(choice_keys) == 1: + # First level choice with the menu key empty, that's relevant + relevant.append(choice) + elif len(menu_keys) == len(choice_keys) - 1: + # Check all the intermediate keys + common = True + for menu_key, choice_key in zip(menu_keys, choice_keys): + if choice_key == self.joker_key: + continue + + if menu_key != choice_key: + common = False + break + + if common: + relevant.append(choice) + + return relevant + def init(self, obj): """Create the sub-menu to edit the specified object. @@ -365,20 +443,6 @@ class BuildingMenu(object): on_enter = on_enter or menu_quit return self.add_choice(title, key=key, aliases=aliases, on_enter=on_enter) - def _generate_commands(self, cmdset): - """ - Generate commands for the menu, if any is needed. - - Args: - cmdset (CmdSet): the cmdset. - - """ - if self.key is None: - for choice in self.choices: - cmd = MenuCommand(key=choice.key, aliases=choice.aliases, building_menu=self, choice=choice) - cmd.get_help = lambda cmd, caller: choice.display_text() - cmdset.add(cmd) - def _save(self): """Save the menu in a persistent attribute on the caller.""" self.caller.ndb._building_menu = self @@ -386,6 +450,7 @@ class BuildingMenu(object): "class": type(self).__module__ + "." + type(self).__name__, "obj": self.obj, "key": self.key, + "parents": self.parents, } def open(self): @@ -395,6 +460,121 @@ class BuildingMenu(object): self.caller.cmdset.add(BuildingMenuCmdSet, permanent=True) self.display() + def open_parent_menu(self): + """Open parent menu, using `self.parents`.""" + parents = list(self.parents) + if parents: + parent_class, parent_obj, parent_key = parents[-1] + del parents[-1] + + if self.caller.cmdset.has(BuildingMenuCmdSet): + self.caller.cmdset.remove(BuildingMenuCmdSet) + + try: + menu_class = class_from_module(parent_class) + except Exception: + log_trace("BuildingMenu: attempting to load class {} failed".format(repr(parent_class))) + return + + # Create the submenu + try: + building_menu = menu_class(self.caller, parent_obj, key=parent_key, parents=tuple(parents)) + except Exception: + log_trace("An error occurred while creating building menu {}".format(repr(parent_class))) + return + else: + return building_menu.open() + + def open_submenu(self, submenu_class, submenu_obj, parent_key): + """ + Open a sub-menu, closing the current menu and opening the new one. + + Args: + submenu_class (str): the submenu class as a Python path. + submenu_obj (any): the object to give to the submenu. + parent_key (str, optional): the parent key when the submenu is closed. + + Note: + When the user enters `@` in the submenu, she will go back to + the current menu, with the `parent_key` set as its key. + Therefore, you should set it on the key of the choice that + should be opened when the user leaves the submenu. + + Returns: + new_menu (BuildingMenu): the new building menu or None. + + """ + parents = list(self.parents) + parents.append((type(self).__module__ + "." + type(self).__name__, self.obj, parent_key)) + parents = tuple(parents) + if self.caller.cmdset.has(BuildingMenuCmdSet): + self.caller.cmdset.remove(BuildingMenuCmdSet) + + # Shift to the new menu + try: + menu_class = class_from_module(submenu_class) + except Exception: + log_trace("BuildingMenu: attempting to load class {} failed".format(repr(submenu_class))) + return + + # Create the submenu + try: + building_menu = menu_class(self.caller, submenu_obj, parents=parents) + except Exception: + log_trace("An error occurred while creating building menu {}".format(repr(submenu_class))) + return + else: + return building_menu.open() + + def move(self, key=None, back=False, quiet=False, string="" ): + """ + Move inside the menu. + + Args: + key (str): the portion of the key to add to the current + menu key, after a separator (`sep_keys`). If + you wish to go back in the menu tree, don't + provide a `key`, just set `back` to `True`. + back (bool, optional): go back in the menu (`False` by default). + quiet (bool, optional): should the menu or choice be displayed afterward? + + Note: + This method will need to be called directly should you + use more than two levels in your menu. For instance, + in your room menu, if you want to have an "exits" + option, and then be able to enter "north" in this + choice to edit an exit. The specific exit choice + could be a different menu (with a different class), but + it could also be an additional level in your original menu. + If that's the case, you will need to use this method. + + """ + choice = self.current_choice + if choice: + #choice.leave() + pass + + if not back: # Move forward + if not key: + raise ValueError("you are asking to move forward, you should specify a key.") + + if self.key: + self.key += self.sep_keys + self.key += key + else: # Move backward + if not self.keys: + raise ValueError("you already are at the top of the tree, you cannot move backward.") + + self.key = self.sep_keys.join(self.keys[:-1]) + + self._save() + choice = self.current_choice + if choice: + choice.enter(string) + + if not quiet: + self.display() + # Display methods. Override for customization def display_title(self): """Return the menu title to be displayed.""" @@ -423,12 +603,16 @@ class BuildingMenu(object): return ret def display(self): - """Display the entire menu.""" - menu = self.display_title() + "\n" - for choice in self.choices: - menu += "\n" + self.display_choice(choice) + """Display the entire menu or a single choice, depending on the current key..""" + choice = self.current_choice + if self.key and choice: + text = choice.format_text() + else: + text = self.display_title() + "\n" + for choice in self.choices: + text += "\n" + self.display_choice(choice) - self.caller.msg(menu) + self.caller.msg(text) @staticmethod def restore(caller, cmdset): @@ -459,97 +643,75 @@ class BuildingMenu(object): # Create the menu obj = menu.get("obj") key = menu.get("key") + parents = menu.get("parents") try: - building_menu = menu_class(caller, obj) + building_menu = menu_class(caller, obj, key=key, parents=parents) except Exception: log_trace("An error occurred while creating building menu {}".format(repr(class_name))) return False - # If there's no saved key, add the menu commands - building_menu.key = key - building_menu._generate_commands(cmdset) - return building_menu -class MenuCommand(Command): - - """An applicaiton-specific command.""" - - help_category = "Application-specific" - - def __init__(self, **kwargs): - self.menu = kwargs.pop("building_menu", None) - self.choice = kwargs.pop("choice", None) - super(MenuCommand, self).__init__(**kwargs) - - def func(self): - """Function body.""" - if self.choice is None or self.menu is None: - log_err("Command: {}, no choice has been specified".format(self.key)) - self.msg("|rAn unexpected error occurred. Closing the menu.|n") - self.caller.cmdset.delete(BuildingMenuCmdSet) - return - - self.menu.key = self.choice.key - self.menu._save() - self.caller.cmdset.delete(BuildingMenuCmdSet) - self.caller.cmdset.add(BuildingMenuCmdSet, permanent=True) - self.choice.enter(self.raw_string) - - -class CmdNoInput(MenuCommand): +class CmdNoInput(Command): """No input has been found.""" key = _CMD_NOINPUT locks = "cmd:all()" + def __init__(self, **kwargs): + self.menu = kwargs.pop("building_menu", None) + super(Command, self).__init__(**kwargs) + def func(self): """Display the menu or choice text.""" if self.menu: - choice = self.menu.cmds.get(self.menu.key) - if self.menu.key and choice: - choice.display_text() - else: - self.menu.display() + self.menu.display() else: log_err("When CMDNOINPUT was called, the building menu couldn't be found") self.caller.msg("|rThe building menu couldn't be found, remove the CmdSet.|n") self.caller.cmdset.delete(BuildingMenuCmdSet) -class CmdNoMatch(MenuCommand): +class CmdNoMatch(Command): """No input has been found.""" key = _CMD_NOMATCH locks = "cmd:all()" + def __init__(self, **kwargs): + self.menu = kwargs.pop("building_menu", None) + super(Command, self).__init__(**kwargs) + def func(self): - """Redirect most inputs to the screen, if found.""" + """Call the proper menu or redirect to nomatch.""" raw_string = self.raw_string.rstrip() - choice = self.menu.cmds.get(self.menu.key) if self.menu else None - cmdset = None - for cset in self.caller.cmdset.get(): - if isinstance(cset, BuildingMenuCmdSet): - cmdset = cset - break if self.menu is None: log_err("When CMDNOMATCH was called, the building menu couldn't be found") self.caller.msg("|rThe building menu couldn't be found, remove the CmdSet.|n") self.caller.cmdset.delete(BuildingMenuCmdSet) - elif raw_string in self.menu.keys_go_back and self.menu.key: - self.menu.key = None - self.menu._save() - self.caller.cmdset.delete(BuildingMenuCmdSet) - self.caller.cmdset.add(BuildingMenuCmdSet, permanent=True) - self.menu.display() - elif self.menu.key: + return + + choice = self.menu.current_choice + if raw_string in self.menu.keys_go_back: + if self.menu.key: + self.menu.move(back=True) + elif self.menu.parents: + self.menu.open_parent_menu() + else: + self.menu.display() + elif choice: choice.nomatch(raw_string) - choice.display_text() + self.caller.msg(choice.format_text()) else: - self.menu.display() + for choice in self.menu.relevant_choices: + if choice.key.lower() == raw_string.lower() or any(raw_string.lower() == alias for alias in choice.aliases): + self.menu.move(choice.key) + return + + self.msg("|rUnknown command: {}|n.".format(raw_string)) class BuildingMenuCmdSet(CmdSet): @@ -567,16 +729,14 @@ class BuildingMenuCmdSet(CmdSet): # The caller could recall the menu menu = caller.ndb._building_menu - if menu: - menu._generate_commands(self) - else: + if menu is None: menu = caller.db._building_menu if menu: menu = BuildingMenu.restore(caller, self) cmds = [CmdNoInput, CmdNoMatch] for cmd in cmds: - self.add(cmd(building_menu=menu, choice=None)) + self.add(cmd(building_menu=menu)) # Helper functions @@ -638,6 +798,28 @@ def menu_edit(caller, choice, obj): caller.cmdset.remove(BuildingMenuCmdSet) EvEditor(caller, loadfunc=_menu_loadfunc, savefunc=_menu_savefunc, quitfunc=_menu_quitfunc, key="editor", persistent=True) +def open_submenu(caller, menu, choice, obj, parent_key): + """ + Open a sub-menu, closing the current menu and opening the new one + with `parent` set. + + Args: + caller (Account or Object): the caller. + menu (Building): the selected choice. + choice (Chocie): the choice. + obj (any): the object to be edited. + parent_key (any): the parent menu key. + + Note: + You can easily call this function from a different callback to customize its + behavior. + + """ + parent_key = parent_key if isinstance(parent_key, basestring) else None + menu.open_submenu(choice.attr, obj, parent_key) + + +# Private functions for EvEditor def _menu_loadfunc(caller): obj, attr = caller.attributes.get("_building_menu_to_edit", [None, None]) if obj and attr: @@ -654,25 +836,10 @@ def _menu_savefunc(caller, buf): setattr(obj, attr.split(".")[-1], buf) - if caller.ndb._building_menu: - caller.ndb._building_menu.key = None - if caller.db._building_menu: - caller.db._building_menu["key"] = None - caller.attributes.remove("_building_menu_to_edit") - caller.cmdset.add(BuildingMenuCmdSet) - if caller.ndb._building_menu: - caller.ndb._building_menu.display() - return True def _menu_quitfunc(caller): - caller.attributes.remove("_building_menu_to_edit") - if caller.ndb._building_menu: - caller.ndb._building_menu.key = None - if caller.db._building_menu: - caller.db._building_menu["key"] = None - caller.cmdset.add(BuildingMenuCmdSet) if caller.ndb._building_menu: - caller.ndb._building_menu.display() + caller.ndb._building_menu.move(back=True) From 34b8c0dbcec9b2efa865258ba4902c18e10f2853 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 31 Mar 2018 21:10:20 +0200 Subject: [PATCH 268/466] New list_node decorator for evmenu. Tested with olc menu --- evennia/commands/default/building.py | 16 +-- evennia/utils/evmenu.py | 52 ++++++---- evennia/utils/spawner.py | 141 +++++++++++++++++++++------ evennia/utils/utils.py | 9 +- 4 files changed, 152 insertions(+), 66 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index dc70e52cea..759247acc7 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -15,7 +15,8 @@ from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore from evennia.utils.spawner import (spawn, search_prototype, list_prototypes, save_db_prototype, build_metaproto, validate_prototype, - delete_db_prototype, PermissionError, start_olc) + delete_db_prototype, PermissionError, start_olc, + metaproto_to_str) from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -2886,21 +2887,10 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): def _search_show_prototype(query, metaprots=None): # prototype detail - strings = [] if not metaprots: metaprots = search_prototype(key=query, return_meta=True) if metaprots: - for metaprot in metaprots: - header = ( - "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" - "|cdesc:|n {} \n|cprototype:|n ".format( - metaprot.key, ", ".join(metaprot.tags), - metaprot.locks, metaprot.desc)) - prototype = ("{{\n {} \n}}".format("\n ".join("{!r}: {!r},".format(key, value) - for key, value in - sorted(metaprot.prototype.items())).rstrip(","))) - strings.append(header + prototype) - return "\n".join(strings) + return "\n".join(metaproto_to_str(metaprot) for metaprot in metaprots) else: return False diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 6c8729aee1..cccda6798f 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -979,18 +979,20 @@ class EvMenu(object): # # ----------------------------------------------------------- -def list_node(option_list, examine_processor, goto_processor, pagesize=10): +def list_node(option_generator, examine_processor, goto_processor, pagesize=10): """ Decorator for making an EvMenu node into a multi-page list node. Will add new options, prepending those options added in the node. Args: - option_list (list): List of strings indicating the options. - examine_processor (callable): Will be called with the caller and the chosen option when - examining said option. Should return a text string to display in the node. - goto_processor (callable): Will be called with caller and - the chosen option from the optionlist. Should return the target node to goto after the - selection. + option_generator (callable or list): A list of strings indicating the options, or a callable + that is called without any arguments to produce such a list. + examine_processor (callable, optional): Will be called with the caller and the chosen option + when examining said option. Should return a text string to display in the node. + goto_processor (callable, optional): Will be called as goto_processor(caller, menuchoice) + where menuchoice is the chosen option as a string. Should return the target node to + goto after this selection. + pagesize (int): How many options to show per page. Example: @@ -1009,6 +1011,7 @@ def list_node(option_list, examine_processor, goto_processor, pagesize=10): available_choices = kwargs.get("available_choices", []) processor = kwargs.get("selection_processor") + try: match_ind = int(re.search(r"[0-9]+$", raw_string).group()) - 1 selection = available_choices[match_ind] @@ -1022,12 +1025,14 @@ def list_node(option_list, examine_processor, goto_processor, pagesize=10): logger.log_trace() return selection - nall_options = len(option_list) - pages = [option_list[ind:ind + pagesize] for ind in range(0, nall_options, pagesize)] - npages = len(pages) - def _list_node(caller, raw_string, **kwargs): + option_list = option_generator() if callable(option_generator) else option_generator + + nall_options = len(option_list) + pages = [option_list[ind:ind + pagesize] for ind in range(0, nall_options, pagesize)] + npages = len(pages) + page_index = max(0, min(npages - 1, kwargs.get("optionpage_index", 0))) page = pages[page_index] @@ -1042,19 +1047,21 @@ def list_node(option_list, examine_processor, goto_processor, pagesize=10): # if the goto callable returns None, the same node is rerun, and # kwargs not used by the callable are passed on to the node. This # allows us to call ourselves over and over, using different kwargs. - if page_index > 0: - options.append({"key": ("|wb|Wack|n", "b"), - "goto": (lambda caller: None, - {"optionpage_index": page_index - 1})}) - if page_index < npages - 1: - options.append({"key": ("|wn|Wext|n", "n"), - "goto": (lambda caller: None, - {"optionpage_index": page_index + 1})}) - options.append({"key": ("|Wcurrent|n", "c"), "desc": "|W({}/{})|n".format(page_index + 1, npages), "goto": (lambda caller: None, {"optionpage_index": page_index})}) + if page_index > 0: + options.append({"key": ("|wp|Wrevious page|n", "p"), + "goto": (lambda caller: None, + {"optionpage_index": page_index - 1})}) + if page_index < npages - 1: + options.append({"key": ("|wn|Wext page|n", "n"), + "goto": (lambda caller: None, + {"optionpage_index": page_index + 1})}) + + + # this catches arbitrary input, notably to examine entries ('look 4' or 'l4' etc) options.append({"key": "_default", "goto": (lambda caller: None, {"show_detail": True, "optionpage_index": page_index})}) @@ -1071,6 +1078,7 @@ def list_node(option_list, examine_processor, goto_processor, pagesize=10): # add data from the decorated node text = '' + extra_options = [] try: text, extra_options = func(caller, raw_string) except TypeError: @@ -1080,14 +1088,16 @@ def list_node(option_list, examine_processor, goto_processor, pagesize=10): raise except Exception: logger.log_trace() + print("extra_options:", extra_options) else: if isinstance(extra_options, {}): extra_options = [extra_options] else: extra_options = make_iter(extra_options) - options.append(extra_options) + options.extend(extra_options) text = text + "\n\n" + text_detail if text_detail else text + text += "\n\n(Make a choice or enter 'look ' to examine an option closer)" return text, options diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 3b2d6d4353..8caa9ae0c2 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -333,7 +333,7 @@ def search_module_prototype(key=None, tags=None): def search_prototype(key=None, tags=None, return_meta=True): """ - Find prototypes based on key and/or tags. + Find prototypes based on key and/or tags, or all prototypes. Kwargs: key (str): An exact or partial key to query for. @@ -344,7 +344,8 @@ def search_prototype(key=None, tags=None, return_meta=True): return MetaProto namedtuples including prototype meta info Return: - matches (list): All found prototype dicts or MetaProtos + matches (list): All found prototype dicts or MetaProtos. If no keys + or tags are given, all available prototypes/MetaProtos will be returned. Note: The available prototypes is a combination of those supplied in @@ -438,6 +439,25 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed table.reformat_column(3, width=20) return table + +def metaproto_to_str(metaproto): + """ + Format a metaproto to a nice string representation. + + Args: + metaproto (NamedTuple): Represents the prototype. + """ + header = ( + "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" + "|cdesc:|n {} \n|cprototype:|n ".format( + metaproto.key, ", ".join(metaproto.tags), + metaproto.locks, metaproto.desc)) + prototype = ("{{\n {} \n}}".format("\n ".join("{!r}: {!r},".format(key, value) + for key, value in + sorted(metaproto.prototype.items())).rstrip(","))) + return header + prototype + + # Spawner mechanism @@ -660,7 +680,13 @@ def spawn(*prototypes, **kwargs): return _batch_create_object(*objsparams) -# prototype design menu nodes +# ------------------------------------------------------------ +# +# OLC Prototype design menu +# +# ------------------------------------------------------------ + +# Helper functions def _get_menu_metaprot(caller): if hasattr(caller.ndb._menutree, "olc_metaprot"): @@ -683,7 +709,7 @@ def _set_menu_metaprot(caller, field, value): caller.ndb._menutree.olc_metaprot = build_metaproto(**kwargs) -def _format_property(key, required=False, metaprot=None, prototype=None): +def _format_property(key, required=False, metaprot=None, prototype=None, cropper=None): key = key.lower() if metaprot is not None: prop = getattr(metaprot, key) or '' @@ -700,7 +726,7 @@ def _format_property(key, required=False, metaprot=None, prototype=None): out = ", ".join(str(pr) for pr in prop) if not out and required: out = "|rrequired" - return " ({}|n)".format(crop(out, _MENU_CROP_WIDTH)) + return " ({}|n)".format(cropper(out) if cropper else crop(out, _MENU_CROP_WIDTH)) def _set_property(caller, raw_string, **kwargs): @@ -744,26 +770,43 @@ def _set_property(caller, raw_string, **kwargs): metaprot = _get_menu_metaprot(caller) prototype = metaprot.prototype prototype[propname_low] = value + + # typeclass and prototype can't co-exist + if propname_low == "typeclass": + prototype.pop("prototype", None) + if propname_low == "prototype": + prototype.pop("typeclass", None) + _set_menu_metaprot(caller, "prototype", prototype) + caller.msg("Set {prop} to {value}.".format( prop=prop.replace("_", "-").capitalize(), value=str(value))) return next_node -def _wizard_options(prev_node, next_node): - options = [{"desc": "forward ({})".format(next_node.replace("_", "-")), +def _wizard_options(prev_node, next_node, color="|W"): + options = [{"key": ("|wf|Worward", "f"), + "desc": "{color}({node})|n".format( + color=color, node=next_node.replace("_", "-")), "goto": "node_{}".format(next_node)}, - {"desc": "back ({})".format(prev_node.replace("_", "-")), + {"key": ("|wb|Wack", "b"), + "desc": "{color}({node})|n".format( + color=color, node=prev_node.replace("_", "-")), "goto": "node_{}".format(prev_node)}] if "index" not in (prev_node, next_node): - options.append({"desc": "index", + options.append({"key": ("|wi|Wndex", "i"), "goto": "node_index"}) return options -# menu nodes +def _path_cropper(pythonpath): + "Crop path to only the last component" + return pythonpath.split('.')[-1] + + +# Menu nodes def node_index(caller): metaprot = _get_menu_metaprot(caller) @@ -784,15 +827,20 @@ def node_index(caller): "goto": "node_meta_key"}) for key in ('Prototype', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', 'Permissions', 'Location', 'Home', 'Destination'): - req = False + required = False + cropper = None if key in ("Prototype", "Typeclass"): - req = "prototype" not in prototype and "typeclass" not in prototype + required = "prototype" not in prototype and "typeclass" not in prototype + if key == 'Typeclass': + cropper = _path_cropper options.append( - {"desc": "|w{}|n{}".format(key, _format_property(key, req, None, prototype)), + {"desc": "|w{}|n{}".format( + key, _format_property(key, required, None, prototype, cropper=cropper)), "goto": "node_{}".format(key.lower())}) + required = False for key in ('Desc', 'Tags', 'Locks'): options.append( - {"desc": "|WMeta-{}|n|n{}".format(key, _format_property(key, req, metaprot, None)), + {"desc": "|WMeta-{}|n|n{}".format(key, _format_property(key, required, metaprot, None)), "goto": "node_meta_{}".format(key.lower())}) return text, options @@ -837,6 +885,24 @@ def node_meta_key(caller): return text, options +def _all_prototypes(): + return [mproto.key for mproto in search_prototype()] + + +def _prototype_examine(caller, prototype_name): + metaprot = search_prototype(key=prototype_name) + if metaprot: + return metaproto_to_str(metaprot[0]) + return "Prototype not registered." + + +def _prototype_select(caller, prototype): + ret = _set_property(caller, prototype, prop="prototype", processor=str, next_node="node_key") + caller.msg("Selected prototype |y{}|n. Removed any set typeclass parent.".format(prototype)) + return ret + + +@list_node(_all_prototypes, _prototype_examine, _prototype_select) def node_prototype(caller): metaprot = _get_menu_metaprot(caller) prot = metaprot.prototype @@ -848,25 +914,43 @@ def node_prototype(caller): else: text.append("Parent prototype is not set") text = "\n\n".join(text) - options = _wizard_options("meta_key", "typeclass") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="prototype", - processor=lambda s: s.strip(), - next_node="node_typeclass"))}) + options = _wizard_options("meta_key", "typeclass", color="|W") return text, options -def _typeclass_examine(caller, typeclass): - return "This is typeclass |y{}|n.".format(typeclass) +def _all_typeclasses(): + return list(sorted(get_all_typeclasses().keys())) + # return list(sorted(get_all_typeclasses(parent="evennia.objects.objects.DefaultObject").keys())) + + +def _typeclass_examine(caller, typeclass_path): + if typeclass_path is None: + # this means we are exiting the listing + return "node_key" + + typeclass = get_all_typeclasses().get(typeclass_path) + if typeclass: + docstr = [] + for line in typeclass.__doc__.split("\n"): + if line.strip(): + docstr.append(line) + elif docstr: + break + docstr = '\n'.join(docstr) if docstr else "" + txt = "Typeclass |y{typeclass_path}|n; First paragraph of docstring:\n\n{docstring}".format( + typeclass_path=typeclass_path, docstring=docstr) + else: + txt = "This is typeclass |y{}|n.".format(typeclass) + return txt def _typeclass_select(caller, typeclass): - caller.msg("Selected typeclass |y{}|n.".format(typeclass)) - return None + ret = _set_property(caller, typeclass, prop='typeclass', processor=str, next_node="node_key") + caller.msg("Selected typeclass |y{}|n. Removed any set prototype parent.".format(typeclass)) + return ret -@list_node(list(sorted(get_all_typeclasses().keys())), _typeclass_examine, _typeclass_select) +@list_node(_all_typeclasses, _typeclass_examine, _typeclass_select) def node_typeclass(caller): metaprot = _get_menu_metaprot(caller) prot = metaprot.prototype @@ -879,12 +963,7 @@ def node_typeclass(caller): text.append("Using default typeclass {typeclass}.".format( typeclass=settings.BASE_OBJECT_TYPECLASS)) text = "\n\n".join(text) - options = _wizard_options("prototype", "key") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="typeclass", - processor=lambda s: s.strip(), - next_node="node_key"))}) + options = _wizard_options("prototype", "key", color="|W") return text, options diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index a8d2171f75..22d59a165f 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -1882,10 +1882,14 @@ def get_game_dir_path(): raise RuntimeError("server/conf/settings.py not found: Must start from inside game dir.") -def get_all_typeclasses(): +def get_all_typeclasses(parent=None): """ List available typeclasses from all available modules. + Args: + parent (str, optional): If given, only return typeclasses inheriting (at any distance) + from this parent. + Returns: typeclasses (dict): On the form {"typeclass.path": typeclass, ...} @@ -1898,4 +1902,7 @@ def get_all_typeclasses(): from evennia.typeclasses.models import TypedObject typeclasses = {"{}.{}".format(model.__module__, model.__name__): model for model in apps.get_models() if TypedObject in getmro(model)} + if parent: + typeclasses = {name: typeclass for name, typeclass in typeclasses.items() + if inherits_from(typeclass, parent)} return typeclasses From dea4ae40d2be83dc01babc1015c0c1fcea0856d7 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 31 Mar 2018 22:58:17 +0200 Subject: [PATCH 269/466] Custom OLCMenu class, validate prot from menu --- evennia/utils/spawner.py | 106 +++++++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 26 deletions(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 8caa9ae0c2..c9a68ea8ab 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -117,6 +117,7 @@ from evennia.scripts.scripts import DefaultScript from evennia.utils.create import create_script from evennia.utils.evtable import EvTable from evennia.utils.evmenu import EvMenu, list_node +from evennia.utils.ansi import strip_ansi _CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") @@ -779,25 +780,33 @@ def _set_property(caller, raw_string, **kwargs): _set_menu_metaprot(caller, "prototype", prototype) - caller.msg("Set {prop} to {value}.".format( prop=prop.replace("_", "-").capitalize(), value=str(value))) return next_node -def _wizard_options(prev_node, next_node, color="|W"): - options = [{"key": ("|wf|Worward", "f"), - "desc": "{color}({node})|n".format( - color=color, node=next_node.replace("_", "-")), - "goto": "node_{}".format(next_node)}, - {"key": ("|wb|Wack", "b"), - "desc": "{color}({node})|n".format( - color=color, node=prev_node.replace("_", "-")), - "goto": "node_{}".format(prev_node)}] +def _wizard_options(curr_node, prev_node, next_node, color="|W"): + options = [] + if prev_node: + options.append({"key": ("|wb|Wack", "b"), + "desc": "{color}({node})|n".format( + color=color, node=prev_node.replace("_", "-")), + "goto": "node_{}".format(prev_node)}) + if next_node: + options.append({"key": ("|wf|Worward", "f"), + "desc": "{color}({node})|n".format( + color=color, node=next_node.replace("_", "-")), + "goto": "node_{}".format(next_node)}) + if "index" not in (prev_node, next_node): options.append({"key": ("|wi|Wndex", "i"), "goto": "node_index"}) + + if curr_node: + options.append({"key": ("|wv|Walidate prototype", "v"), + "goto": ("node_validate_prototype", {"back": curr_node})}) + return options @@ -846,6 +855,23 @@ def node_index(caller): return text, options +def node_validate_prototype(caller, raw_string, **kwargs): + metaprot = _get_menu_metaprot(caller) + + txt = metaproto_to_str(metaprot) + errors = "\n\n|g No validation errors found.|n (but errors could still happen at spawn-time)" + try: + # validate, don't spawn + spawn(metaprot.prototype, return_prototypes=True) + except RuntimeError as err: + errors = "\n\n|rError: {}|n".format(err) + text = (txt + errors) + + options = _wizard_options(None, kwargs.get("back"), None) + + return text, options + + def _check_meta_key(caller, key): old_metaprot = search_prototype(key) olc_new = caller.ndb._menutree.olc_new @@ -879,7 +905,7 @@ def node_meta_key(caller): text.append("The key is currently unset.") text.append("Enter text or make a choice (q for quit)") text = "\n\n".join(text) - options = _wizard_options("index", "prototype") + options = _wizard_options("meta_key", "index", "prototype") options.append({"key": "_default", "goto": _check_meta_key}) return text, options @@ -914,7 +940,7 @@ def node_prototype(caller): else: text.append("Parent prototype is not set") text = "\n\n".join(text) - options = _wizard_options("meta_key", "typeclass", color="|W") + options = _wizard_options("prototype", "meta_key", "typeclass", color="|W") return text, options @@ -963,7 +989,7 @@ def node_typeclass(caller): text.append("Using default typeclass {typeclass}.".format( typeclass=settings.BASE_OBJECT_TYPECLASS)) text = "\n\n".join(text) - options = _wizard_options("prototype", "key", color="|W") + options = _wizard_options("typeclass", "prototype", "key", color="|W") return text, options @@ -978,7 +1004,7 @@ def node_key(caller): else: text.append("Key is currently unset.") text = "\n\n".join(text) - options = _wizard_options("typeclass", "aliases") + options = _wizard_options("key", "typeclass", "aliases") options.append({"key": "_default", "goto": (_set_property, dict(prop="key", @@ -999,7 +1025,7 @@ def node_aliases(caller): else: text.append("No aliases are set.") text = "\n\n".join(text) - options = _wizard_options("key", "attrs") + options = _wizard_options("aliases", "key", "attrs") options.append({"key": "_default", "goto": (_set_property, dict(prop="aliases", @@ -1020,7 +1046,7 @@ def node_attrs(caller): else: text.append("No attrs are set.") text = "\n\n".join(text) - options = _wizard_options("aliases", "tags") + options = _wizard_options("attrs", "aliases", "tags") options.append({"key": "_default", "goto": (_set_property, dict(prop="attrs", @@ -1041,7 +1067,7 @@ def node_tags(caller): else: text.append("No tags are set.") text = "\n\n".join(text) - options = _wizard_options("attrs", "locks") + options = _wizard_options("tags", "attrs", "locks") options.append({"key": "_default", "goto": (_set_property, dict(prop="tags", @@ -1062,7 +1088,7 @@ def node_locks(caller): else: text.append("No locks are set.") text = "\n\n".join(text) - options = _wizard_options("tags", "permissions") + options = _wizard_options("locks", "tags", "permissions") options.append({"key": "_default", "goto": (_set_property, dict(prop="locks", @@ -1083,7 +1109,7 @@ def node_permissions(caller): else: text.append("No permissions are set.") text = "\n\n".join(text) - options = _wizard_options("destination", "location") + options = _wizard_options("permissions", "destination", "location") options.append({"key": "_default", "goto": (_set_property, dict(prop="permissions", @@ -1103,7 +1129,7 @@ def node_location(caller): else: text.append("Default location is {}'s inventory.".format(caller)) text = "\n\n".join(text) - options = _wizard_options("permissions", "home") + options = _wizard_options("location", "permissions", "home") options.append({"key": "_default", "goto": (_set_property, dict(prop="location", @@ -1123,7 +1149,7 @@ def node_home(caller): else: text.append("Default home location (|y{home}|n) used.".format(home=settings.DEFAULT_HOME)) text = "\n\n".join(text) - options = _wizard_options("aliases", "destination") + options = _wizard_options("home", "aliases", "destination") options.append({"key": "_default", "goto": (_set_property, dict(prop="home", @@ -1143,7 +1169,7 @@ def node_destination(caller): else: text.append("No destination is set (default).") text = "\n\n".join(text) - options = _wizard_options("home", "meta_desc") + options = _wizard_options("destination", "home", "meta_desc") options.append({"key": "_default", "goto": (_set_property, dict(prop="dest", @@ -1163,7 +1189,7 @@ def node_meta_desc(caller): else: text.append("Description is currently unset.") text = "\n\n".join(text) - options = _wizard_options("meta_key", "meta_tags") + options = _wizard_options("meta_desc", "meta_key", "meta_tags") options.append({"key": "_default", "goto": (_set_property, dict(prop='meta_desc', @@ -1184,7 +1210,7 @@ def node_meta_tags(caller): else: text.append("No tags are currently set.") text = "\n\n".join(text) - options = _wizard_options("meta_desc", "meta_locks") + options = _wizard_options("meta_tags", "meta_desc", "meta_locks") options.append({"key": "_default", "goto": (_set_property, dict(prop="meta_tags", @@ -1206,7 +1232,7 @@ def node_meta_locks(caller): text.append("Lock unset - if not changed the default lockstring will be set as\n" " |w'use:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) text = "\n\n".join(text) - options = _wizard_options("meta_tags", "index") + options = _wizard_options("meta_locks", "meta_tags", "index") options.append({"key": "_default", "goto": (_set_property, dict(prop="meta_locks", @@ -1215,6 +1241,33 @@ def node_meta_locks(caller): return text, options +class OLCMenu(EvMenu): + """ + A custom EvMenu with a different formatting for the options. + + """ + def options_formatter(self, optionlist): + """ + Split the options into two blocks - olc options and normal options + + """ + olc_keys = ("index", "forward", "back", "previous", "next", "validate prototype") + olc_options = [] + other_options = [] + for key, desc in optionlist: + raw_key = strip_ansi(key) + if raw_key in olc_keys: + desc = " {}".format(desc) if desc else "" + olc_options.append("|lc{}|lt{}|le{}".format(raw_key, key, desc)) + else: + other_options.append((key, desc)) + + olc_options = " | ".join(olc_options) + " | " + "|wq|Wuit" if olc_options else "" + other_options = super(OLCMenu, self).options_formatter(other_options) + sep = "\n\n" if olc_options and other_options else "" + + return "{}{}{}".format(olc_options, sep, other_options) + def start_olc(caller, session=None, metaproto=None): """ @@ -1228,6 +1281,7 @@ def start_olc(caller, session=None, metaproto=None): """ menudata = {"node_index": node_index, + "node_validate_prototype": node_validate_prototype, "node_meta_key": node_meta_key, "node_prototype": node_prototype, "node_typeclass": node_typeclass, @@ -1244,7 +1298,7 @@ def start_olc(caller, session=None, metaproto=None): "node_meta_tags": node_meta_tags, "node_meta_locks": node_meta_locks, } - EvMenu(caller, menudata, startnode='node_index', session=session, olc_metaproto=metaproto) + OLCMenu(caller, menudata, startnode='node_index', session=session, olc_metaproto=metaproto) # Testing From 6ba995cf96cd7837a8d4039175ed01f9d3dc98d0 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 1 Apr 2018 08:29:35 +0200 Subject: [PATCH 270/466] Better handle logfile cycle while tailing --- evennia/server/evennia_launcher.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index c82b922ca0..2f9206b4c1 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -1037,9 +1037,11 @@ def tail_log_files(filename1, filename2, start_lines1=20, start_lines2=20, rate= new_linecount = sum(blck.count("\n") for blck in _block(filehandle)) if new_linecount < old_linecount: - # this could happen if the file was manually deleted or edited - print("Log file has shrunk. Restart log reader.") - sys.exit() + # this happens if the file was cycled or manually deleted/edited. + print(" ** Log file {filename} has cycled or been edited. " + "Restarting log. ".format(filehandle.name)) + new_linecount = 0 + old_linecount = 0 lines_to_get = max(0, new_linecount - old_linecount) From e28b26971433219272683b052bf48aede3da2a59 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 1 Apr 2018 09:26:55 +0200 Subject: [PATCH 271/466] Fix olc with existing prototype --- 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 759247acc7..77bdb619f1 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2909,7 +2909,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): elif metaprot: # one match metaprot = metaprot[0] - start_olc(caller, self.session, metaprot) + start_olc(caller, session=self.session, metaproto=metaprot) return if 'search' in self.switches: From 02b9654f1c0775e8027b74b66f047c730b04b5fe Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 1 Apr 2018 09:29:44 +0200 Subject: [PATCH 272/466] fix olc bug with single prototype --- evennia/utils/spawner.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index c9a68ea8ab..bab8cdc9a1 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -765,6 +765,9 @@ def _set_property(caller, raw_string, **kwargs): else: value = raw_string + if not value: + return next_node + if meta: _set_menu_metaprot(caller, propname_low, value) else: @@ -780,7 +783,7 @@ def _set_property(caller, raw_string, **kwargs): _set_menu_metaprot(caller, "prototype", prototype) - caller.msg("Set {prop} to {value}.".format( + caller.msg("Set {prop} to '{value}'.".format( prop=prop.replace("_", "-").capitalize(), value=str(value))) return next_node @@ -874,7 +877,7 @@ def node_validate_prototype(caller, raw_string, **kwargs): def _check_meta_key(caller, key): old_metaprot = search_prototype(key) - olc_new = caller.ndb._menutree.olc_new + olc_new = _is_new_prototype(caller) key = key.strip().lower() if old_metaprot: # we are starting a new prototype that matches an existing @@ -1298,7 +1301,7 @@ def start_olc(caller, session=None, metaproto=None): "node_meta_tags": node_meta_tags, "node_meta_locks": node_meta_locks, } - OLCMenu(caller, menudata, startnode='node_index', session=session, olc_metaproto=metaproto) + OLCMenu(caller, menudata, startnode='node_index', session=session, olc_metaprot=metaproto) # Testing From e26f04d5738ba34e2bc369061df9ba53de42ec98 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Sun, 1 Apr 2018 11:48:49 +0200 Subject: [PATCH 273/466] Add unittests for building menus, fixing some errors --- evennia/commands/cmdsethandler.py | 2 - evennia/contrib/building_menu.py | 1387 ++++++++++++++++------------- evennia/contrib/tests.py | 163 +++- 3 files changed, 916 insertions(+), 636 deletions(-) diff --git a/evennia/commands/cmdsethandler.py b/evennia/commands/cmdsethandler.py index 84eea1fdc5..14d195599c 100644 --- a/evennia/commands/cmdsethandler.py +++ b/evennia/commands/cmdsethandler.py @@ -586,11 +586,9 @@ class CmdSetHandler(object): """ if callable(cmdset) and hasattr(cmdset, 'path'): # try it as a callable - print "Try callable", cmdset if must_be_default: return self.cmdset_stack and (self.cmdset_stack[0].path == cmdset.path) else: - print [cset.path for cset in self.cmdset_stack], cmdset.path return any([cset for cset in self.cmdset_stack if cset.path == cmdset.path]) else: diff --git a/evennia/contrib/building_menu.py b/evennia/contrib/building_menu.py index b8f6515b80..0d95492039 100644 --- a/evennia/contrib/building_menu.py +++ b/evennia/contrib/building_menu.py @@ -3,10 +3,14 @@ Module containing the building menu system. Evennia contributor: vincent-lg 2018 -Building menus are similar to `EvMenu`, except that they have been specifically-designed to edit information as a builder. Creating a building menu in a command allows builders quick-editing of a given object, like a room. Here is an example of output you could obtain when editing the room: +Building menus are similar to `EvMenu`, except that they have been +specifically designed to edit information as a builder. Creating a +building menu in a command allows builders quick-editing of a +given object, like a room. Here is an example of output you could +obtain when editing the room: ``` - Editing the room: Limbo + Editing the room: Limbo(#2) [T]itle: the limbo room [D]escription @@ -18,32 +22,58 @@ Building menus are similar to `EvMenu`, except that they have been specifically- [Q]uit this menu ``` -From there, you can open the title sub-menu by pressing t. You can then change the room title by simply entering text, and go back to the main menu entering @ (all this is customizable). Press q to quit this menu. +From there, you can open the title choice by pressing t. You can then +change the room title by simply entering text, and go back to the +main menu entering @ (all this is customizable). Press q to quit this menu. -The first thing to do is to create a new module and place a class inheriting from `BuildingMenu` in it. +The first thing to do is to create a new module and place a class +inheriting from `BuildingMenu` in it. ```python from evennia.contrib.building_menu import BuildingMenu -class RoomMenu(BuildingMenu): - # ... to be ocmpleted ... +class RoomBuildingMenu(BuildingMenu): + # ... ``` -Next, override the `init` method. You can add choices (like the title, description, and exits sub-menus as seen above) by using the `add_choice` method. +Next, override the `init` method. You can add choices (like the title, +description, and exits choices as seen above) by using the `add_choice` +method. ``` -class RoomMenu(BuildingMenu): +class RoomBuildingMenu(BuildingMenu): def init(self, room): - self.add_choice("Title", "t", attr="key") + self.add_choice("title", "t", attr="key") ``` -That will create the first choice, the title sub-menu. If one opens your menu and enter t, she will be in the title sub-menu. She can change the title (it will write in the room's `key` attribute) and then go back to the main menu using `@`. +That will create the first choice, the title choice. If one opens your menu +and enter t, she will be in the title choice. She can change the title +(it will write in the room's `key` attribute) and then go back to the +main menu using `@`. -`add_choice` has a lot of arguments and offer a great deal of flexibility. The most useful ones is probably the usage of callback, as you can set any argument in `add_choice` to be a callback, a function that you have defined above in your module. Here is a very short example of this: +`add_choice` has a lot of arguments and offer a great deal of +flexibility. The most useful ones is probably the usage of callbacks, +as you can set almost any argument in `add_choice` to be a callback, a +function that you have defined above in your module. This function will be +called when the menu element is triggered. +When you wish to create a building menu, you just need to import your +class, create it specifying your intended caller and object to edit, +then call `open`: + +```python +from import RoomBuildingMenu + +class CmdEdit(Command): + + def func(self): + menu = RoomBuildingMenu(self.caller, self.caller.location) + menu.open() ``` -def show_exits(menu -``` + +This is a very short introduction. For more details, see the online tutorial +(https://github.com/evennia/evennia/wiki/Building-menus) or read the +heavily-documented code below. """ @@ -58,37 +88,69 @@ from evennia.utils.eveditor import EvEditor from evennia.utils.logger import log_err, log_trace from evennia.utils.utils import class_from_module +## Constants _MAX_TEXT_WIDTH = settings.CLIENT_DEFAULT_WIDTH _CMD_NOMATCH = cmdhandler.CMD_NOMATCH _CMD_NOINPUT = cmdhandler.CMD_NOINPUT +## Private functions +def _menu_loadfunc(caller): + obj, attr = caller.attributes.get("_building_menu_to_edit", [None, None]) + if obj and attr: + for part in attr.split(".")[:-1]: + obj = getattr(obj, part) + + return getattr(obj, attr.split(".")[-1]) if obj is not None else "" + +def _menu_savefunc(caller, buf): + obj, attr = caller.attributes.get("_building_menu_to_edit", [None, None]) + if obj and attr: + for part in attr.split(".")[:-1]: + obj = getattr(obj, part) + + setattr(obj, attr.split(".")[-1], buf) + + caller.attributes.remove("_building_menu_to_edit") + return True + +def _menu_quitfunc(caller): + caller.cmdset.add(BuildingMenuCmdSet) + if caller.ndb._building_menu: + caller.ndb._building_menu.move(back=True) + def _call_or_get(value, menu=None, choice=None, string=None, obj=None, caller=None): """ Call the value, if appropriate, or just return it. Args: - value (any): the value to obtain. + value (any): the value to obtain. It might be a callable (see note). Kwargs: menu (BuildingMenu, optional): the building menu to pass to value - choice (Choice, optional): the choice to pass to value if a callback. - string (str, optional): the raw string to pass to value if a callback. if a callback. - obj (any): the object to pass to value if a callback. - caller (Account or Character, optional): the caller. + if it is a callable. + choice (Choice, optional): the choice to pass to value if a callable. + string (str, optional): the raw string to pass to value if a callback. if a callable. + obj (Object): the object to pass to value if a callable. + caller (Account or Object, optional): the caller to pass to value + if a callable. Returns: - The value itself. If the argument is a function, call it with specific - arguments, passing it the menu, choice, string, and object if supported. + The value itself. If the argument is a function, call it with + specific arguments (see note). Note: If `value` is a function, call it with varying arguments. The - list of arguments will depend on the argument names. + list of arguments will depend on the argument names in your callable. - An argument named `menu` will contain the building menu or None. - The `choice` argument will contain the choice or None. - The `string` argument will contain the raw string or None. - The `obj` argument will contain the object or None. - The `caller` argument will contain the caller or None. - Any other argument will contain the object (`obj`). + Thus, you could define callbacks like this: + def on_enter(menu, caller, obj): + def on_nomatch(string, choice, menu): + def on_leave(caller, room): # note that room will contain `obj` """ if callable(value): @@ -120,538 +182,70 @@ def _call_or_get(value, menu=None, choice=None, string=None, obj=None, caller=No return value +## Helper functions, to be used in menu choices -class Choice(object): +def menu_setattr(menu, choice, obj, string): + """ + Set the value at the specified attribute. - """A choice object, created by `add_choice`.""" + Args: + menu (BuildingMenu): the menu object. + choice (Chocie): the specific choice. + obj (Object): the object to modify. + string (str): the string with the new value. - def __init__(self, title, key=None, aliases=None, attr=None, text=None, glance=None, on_enter=None, on_nomatch=None, on_leave=None, - menu=None, caller=None, obj=None): - """Constructor. - - Args: - title (str): the choice's title. - key (str, optional): the key of the letters to type to access - the sub-neu. If not set, try to guess it based on the title. - aliases (list of str, optional): the allowed aliases for this choice. - attr (str, optional): the name of the attribute of 'obj' to set. - text (str or callable, optional): a text to be displayed when - the menu is opened It can be a callable. - glance (str or callable, optional): an at-a-glance summary of the - sub-menu shown in the main menu. It can be set to - display the current value of the attribute in the - main menu itself. - menu (BuildingMenu, optional): the parent building menu. - on_enter (callable, optional): a callable to call when the choice is entered. - on_nomatch (callable, optional): a callable to call when no match is entered in the choice. - on_leave (callable, optional): a callable to call when the caller leaves the choice. - caller (Account or Object, optional): the caller. - obj (Object, optional): the object to edit. - - """ - self.title = title - self.key = key - self.aliases = aliases - self.attr = attr - self.text = text - self.glance = glance - self.on_enter = on_enter - self.on_nomatch = on_nomatch - self.on_leave = on_leave - self.menu = menu - self.caller = caller - self.obj = obj - - def __repr__(self): - return "".format(self.title, self.key) - - @property - def keys(self): - """Return a tuple of keys separated by `sep_keys`.""" - return tuple(self.key.split(self.menu.sep_keys)) - - def enter(self, string): - """Called when the user opens the choice.""" - if self.on_enter: - _call_or_get(self.on_enter, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) - - # Display the text if there is some - if self.caller: - self.caller.msg(self.format_text()) - - def nomatch(self, string): - """Called when the user entered something that wasn't a command in a given choice. - - Args: - string (str): the entered string. - - """ - if self.on_nomatch: - _call_or_get(self.on_nomatch, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) - - def format_text(self): - """Format the choice text and return it, or an empty string.""" - text = "" - if self.text: - text = _call_or_get(self.text, menu=self.menu, choice=self, string="", caller=self.caller, obj=self.obj) - text = text.format(obj=self.obj, caller=self.caller) - - return text - - -class BuildingMenu(object): + Note: + This function is supposed to be used as a default to + `BuildingMenu.add_choice`, when an attribute name is specified + (in the `attr` argument) but no function `on_nomatch` is defined. """ - Class allowing to create and set builder menus. + attr = getattr(choice, "attr", None) if choice else None + if choice is None or string is None or attr is None or menu is None: + log_err("The `menu_setattr` function was called to set the attribute {} of object {} to {}, but the choice {} of menu {} or another information is missing.".format(attr, obj, repr(string), choice, menu)) + return - A builder menu is a kind of `EvMenu` designed to edit objects by - builders, although it can be used for players in some contexts. You - could, for instance, create a builder menu to edit a room with a - sub-menu for the room's key, another for the room's description, - another for the room's exits, and so on. + for part in attr.split(".")[:-1]: + obj = getattr(obj, part) - To add choices (sub-menus), you should call `add_choice` (see the - full documentation of this method). With most arguments, you can - specify either a plain string or a callback. This callback will be - called when the operation is to be performed. + setattr(obj, attr.split(".")[-1], string) + return True + +def menu_quit(caller, menu): + """ + Quit the menu, closing the CmdSet. + + Args: + caller (Account or Object): the caller. + menu (BuildingMenu): the building menu to close. """ + if caller is None or menu is None: + log_err("The function `menu_quit` was called with missing arguments: caller={}, menu={}".format(caller, menu)) - keys_go_back = ["@"] - sep_keys = "." - joker_key = "*" - min_shortcut = 1 + if caller.cmdset.has(BuildingMenuCmdSet): + menu.close() + caller.msg("Closing the building menu.") + else: + caller.msg("It looks like the building menu has already been closed.") - def __init__(self, caller=None, obj=None, title="Building menu: {obj}", key="", parents=None): - """Constructor, you shouldn't override. See `init` instead. +def menu_edit(caller, choice, obj): + """ + Open the EvEditor to edit a specified field. - Args: - obj (Object): the object to be edited, like a room. + Args: + caller (Account or Object): the caller. + choice (Choice): the choice object. + obj (Object): the object to edit. - """ - self.caller = caller - self.obj = obj - self.title = title - self.choices = [] - self.key = key - self.parents = parents or () - self.cmds = {} + """ + attr = choice.attr + caller.db._building_menu_to_edit = (obj, attr) + caller.cmdset.remove(BuildingMenuCmdSet) + EvEditor(caller, loadfunc=_menu_loadfunc, savefunc=_menu_savefunc, quitfunc=_menu_quitfunc, key="editor", persistent=True) - if obj: - self.init(obj) - - # If choices have been added without keys, try to guess them - for choice in self.choices: - if choice.key is None: - title = strip_ansi(choice.title.strip()).lower() - length = self.min_shortcut - i = 0 - while length <= len(title): - while i < len(title) - length + 1: - guess = title[i:i + length] - if guess not in self.cmds: - choice.key = guess - break - - i += 1 - - if choice.key is not None: - break - - length += 1 - - if choice.key is None: - raise ValueError("Cannot guess the key for {}".format(choice)) - else: - self.cmds[chocie.key] = choice - - @property - def keys(self): - """Return a tuple of keys separated by `sep_keys`.""" - if not self.key: - return () - - return tuple(self.key.split(self.sep_keys)) - - @property - def current_choice(self): - """Return the current choice or None.""" - menu_keys = self.keys - if not menu_keys: - return None - - for choice in self.choices: - choice_keys = choice.keys - if len(menu_keys) == len(choice_keys): - # Check all the intermediate keys - common = True - for menu_key, choice_key in zip(menu_keys, choice_keys): - if choice_key == self.joker_key: - continue - - if menu_key != choice_key: - common = False - break - - if common: - return choice - - return None - - @property - def relevant_choices(self): - """Only return the relevant choices according to the current meny key. - - The menu key is stored and will be used to determine the - actual position of the caller in the menu. Therefore, this - method compares the menu key (`self.key`) to all the choices' - keys. It also handles the joker key. - - """ - menu_keys = self.keys - relevant = [] - for choice in self.choices: - choice_keys = choice.keys - if not menu_keys and len(choice_keys) == 1: - # First level choice with the menu key empty, that's relevant - relevant.append(choice) - elif len(menu_keys) == len(choice_keys) - 1: - # Check all the intermediate keys - common = True - for menu_key, choice_key in zip(menu_keys, choice_keys): - if choice_key == self.joker_key: - continue - - if menu_key != choice_key: - common = False - break - - if common: - relevant.append(choice) - - return relevant - - def init(self, obj): - """Create the sub-menu to edit the specified object. - - Args: - obj (Object): the object to edit. - - Note: - This method is probably to be overridden in your subclasses. Use `add_choice` and its variants to create sub-menus. - - """ - pass - - def add_choice(self, title, key=None, aliases=None, attr=None, text=None, glance=None, - on_enter=None, on_nomatch=None, on_leave=None): - """Add a choice, a valid sub-menu, in the current builder menu. - - Args: - title (str): the choice's title. - key (str, optional): the key of the letters to type to access - the sub-neu. If not set, try to guess it based on the title. - aliases (list of str, optional): the allowed aliases for this choice. - attr (str, optional): the name of the attribute of 'obj' to set. - text (str or callable, optional): a text to be displayed when - the menu is opened It can be a callable. - glance (str or callable, optional): an at-a-glance summary of the - sub-menu shown in the main menu. It can be set to - display the current value of the attribute in the - main menu itself. - on_enter (callable, optional): a callable to call when the choice is entered. - on_nomatch (callable, optional): a callable to call when no match is entered in the choice. - is set in `attr`. If `attr` is not set, you should - specify a function that both callback and set the value in `obj`. - on_leave (callable, optional): a callable to call when the caller leaves the choice. - - Note: - All arguments can be a callable, like a function. This has the - advantage of allowing persistent building menus. If you specify - a callable in any of the arguments, the callable should return - the value expected by the argument (a str more often than - not) and can have the following arguments: - callable(menu) - callable(menu, user) - callable(menu, user, input) - - """ - key = key or "" - key = key.lower() - aliases = aliases or [] - aliases = [a.lower() for a in aliases] - if on_enter is None and on_nomatch is None: - if attr is None: - raise ValueError("The choice {} has neither attr nor callback, specify one of these as arguments".format(title)) - - if attr and on_nomatch is None: - on_nomatch = menu_setattr - - if isinstance(text, basestring): - text = dedent(text.strip("\n")) - - if key and key in self.cmds: - raise ValueError("A conflict exists between {} and {}, both use key or alias {}".format(self.cmds[key], title, repr(key))) - - choice = Choice(title, key=key, aliases=aliases, attr=attr, text=text, glance=glance, on_enter=on_enter, on_nomatch=on_nomatch, on_leave=on_leave, - menu=self, caller=self.caller, obj=self.obj) - self.choices.append(choice) - if key: - self.cmds[key] = choice - - for alias in aliases: - self.cmds[alias] = choice - - def add_choice_edit(self, title="description", key="d", aliases=None, attr="db.desc", glance="\n {obj.db.desc}", on_enter=None): - """ - Add a simple choice to edit a given attribute in the EvEditor. - - Args: - title (str, optional): the choice title. - key (str, optional): the choice key. - aliases (list of str, optional): the choice aliases. - glance (str or callable, optional): the at-a-glance description. - on_enter (callable, optional): a different callable to edit the attribute. - - Note: - This is just a shortcut method, calling `add_choice`. - If `on_enter` is not set, use `menu_edit` which opens - an EvEditor to edit the specified attribute. - - """ - on_enter = on_enter or menu_edit - return self.add_choice(title, key=key, aliases=aliases, attr=attr, glance=glance, on_enter=on_enter) - - def add_choice_quit(self, title="quit the menu", key="q", aliases=None, on_enter=None): - """ - Add a simple choice just to quit the building menu. - - Args: - title (str, optional): the choice title. - key (str, optional): the choice key. - aliases (list of str, optional): the choice aliases. - on_enter (callable, optional): a different callable to quit the building menu. - - Note: - This is just a shortcut method, calling `add_choice`. - If `on_enter` is not set, use `menu_quit` which simply - closes the menu and displays a message. It also - removes the CmdSet from the caller. If you supply - another callable instead, make sure to do the same. - - """ - on_enter = on_enter or menu_quit - return self.add_choice(title, key=key, aliases=aliases, on_enter=on_enter) - - def _save(self): - """Save the menu in a persistent attribute on the caller.""" - self.caller.ndb._building_menu = self - self.caller.db._building_menu = { - "class": type(self).__module__ + "." + type(self).__name__, - "obj": self.obj, - "key": self.key, - "parents": self.parents, - } - - def open(self): - """Open the building menu for the caller.""" - caller = self.caller - self._save() - self.caller.cmdset.add(BuildingMenuCmdSet, permanent=True) - self.display() - - def open_parent_menu(self): - """Open parent menu, using `self.parents`.""" - parents = list(self.parents) - if parents: - parent_class, parent_obj, parent_key = parents[-1] - del parents[-1] - - if self.caller.cmdset.has(BuildingMenuCmdSet): - self.caller.cmdset.remove(BuildingMenuCmdSet) - - try: - menu_class = class_from_module(parent_class) - except Exception: - log_trace("BuildingMenu: attempting to load class {} failed".format(repr(parent_class))) - return - - # Create the submenu - try: - building_menu = menu_class(self.caller, parent_obj, key=parent_key, parents=tuple(parents)) - except Exception: - log_trace("An error occurred while creating building menu {}".format(repr(parent_class))) - return - else: - return building_menu.open() - - def open_submenu(self, submenu_class, submenu_obj, parent_key): - """ - Open a sub-menu, closing the current menu and opening the new one. - - Args: - submenu_class (str): the submenu class as a Python path. - submenu_obj (any): the object to give to the submenu. - parent_key (str, optional): the parent key when the submenu is closed. - - Note: - When the user enters `@` in the submenu, she will go back to - the current menu, with the `parent_key` set as its key. - Therefore, you should set it on the key of the choice that - should be opened when the user leaves the submenu. - - Returns: - new_menu (BuildingMenu): the new building menu or None. - - """ - parents = list(self.parents) - parents.append((type(self).__module__ + "." + type(self).__name__, self.obj, parent_key)) - parents = tuple(parents) - if self.caller.cmdset.has(BuildingMenuCmdSet): - self.caller.cmdset.remove(BuildingMenuCmdSet) - - # Shift to the new menu - try: - menu_class = class_from_module(submenu_class) - except Exception: - log_trace("BuildingMenu: attempting to load class {} failed".format(repr(submenu_class))) - return - - # Create the submenu - try: - building_menu = menu_class(self.caller, submenu_obj, parents=parents) - except Exception: - log_trace("An error occurred while creating building menu {}".format(repr(submenu_class))) - return - else: - return building_menu.open() - - def move(self, key=None, back=False, quiet=False, string="" ): - """ - Move inside the menu. - - Args: - key (str): the portion of the key to add to the current - menu key, after a separator (`sep_keys`). If - you wish to go back in the menu tree, don't - provide a `key`, just set `back` to `True`. - back (bool, optional): go back in the menu (`False` by default). - quiet (bool, optional): should the menu or choice be displayed afterward? - - Note: - This method will need to be called directly should you - use more than two levels in your menu. For instance, - in your room menu, if you want to have an "exits" - option, and then be able to enter "north" in this - choice to edit an exit. The specific exit choice - could be a different menu (with a different class), but - it could also be an additional level in your original menu. - If that's the case, you will need to use this method. - - """ - choice = self.current_choice - if choice: - #choice.leave() - pass - - if not back: # Move forward - if not key: - raise ValueError("you are asking to move forward, you should specify a key.") - - if self.key: - self.key += self.sep_keys - self.key += key - else: # Move backward - if not self.keys: - raise ValueError("you already are at the top of the tree, you cannot move backward.") - - self.key = self.sep_keys.join(self.keys[:-1]) - - self._save() - choice = self.current_choice - if choice: - choice.enter(string) - - if not quiet: - self.display() - - # Display methods. Override for customization - def display_title(self): - """Return the menu title to be displayed.""" - return _call_or_get(self.title, menu=self, obj=self.obj, caller=self.caller).format(obj=self.obj) - - def display_choice(self, choice): - """Display the specified choice. - - Args: - choice (Choice): the menu choice. - - """ - title = _call_or_get(choice.title, menu=self, choice=choice, obj=self.obj, caller=self.caller) - clear_title = title.lower() - pos = clear_title.find(choice.key.lower()) - ret = " " - if pos >= 0: - ret += title[:pos] + "[|y" + choice.key.title() + "|n]" + title[pos + len(choice.key):] - else: - ret += "[|y" + choice.key.title() + "|n] " + title - if choice.glance: - glance = _call_or_get(choice.glance, menu=self, choice=choice, caller=self.caller, string="", obj=self.obj) - glance = glance.format(obj=self.obj, caller=self.caller) - ret += ": " + glance - - return ret - - def display(self): - """Display the entire menu or a single choice, depending on the current key..""" - choice = self.current_choice - if self.key and choice: - text = choice.format_text() - else: - text = self.display_title() + "\n" - for choice in self.choices: - text += "\n" + self.display_choice(choice) - - self.caller.msg(text) - - @staticmethod - def restore(caller, cmdset): - """Restore the building menu for the caller. - - Args: - caller (Account or Character): the caller. - cmdset (CmdSet): the cmdset. - - Note: - This method should be automatically called if a menu is - saved in the caller, but the object itself cannot be found. - - """ - menu = caller.db._building_menu - if menu: - class_name = menu.get("class") - if not class_name: - log_err("BuildingMenu: on caller {}, a persistent attribute holds building menu data, but no class could be found to restore the menu".format(caller)) - return - - try: - menu_class = class_from_module(class_name) - except Exception: - log_trace("BuildingMenu: attempting to load class {} failed".format(repr(class_name))) - return - - # Create the menu - obj = menu.get("obj") - key = menu.get("key") - parents = menu.get("parents") - try: - building_menu = menu_class(caller, obj, key=key, parents=parents) - except Exception: - log_trace("An error occurred while creating building menu {}".format(repr(class_name))) - return False - - return building_menu +## Building menu commands and CmdSet class CmdNoInput(Command): @@ -687,7 +281,7 @@ class CmdNoMatch(Command): def func(self): """Call the proper menu or redirect to nomatch.""" - raw_string = self.raw_string.rstrip() + raw_string = self.args.rstrip() if self.menu is None: log_err("When CMDNOMATCH was called, the building menu couldn't be found") self.caller.msg("|rThe building menu couldn't be found, remove the CmdSet.|n") @@ -696,15 +290,15 @@ class CmdNoMatch(Command): choice = self.menu.current_choice if raw_string in self.menu.keys_go_back: - if self.menu.key: + if self.menu.keys: self.menu.move(back=True) elif self.menu.parents: self.menu.open_parent_menu() else: self.menu.display() elif choice: - choice.nomatch(raw_string) - self.caller.msg(choice.format_text()) + if choice.nomatch(raw_string): + self.caller.msg(choice.format_text()) else: for choice in self.menu.relevant_choices: if choice.key.lower() == raw_string.lower() or any(raw_string.lower() == alias for alias in choice.aliases): @@ -716,9 +310,7 @@ class CmdNoMatch(Command): class BuildingMenuCmdSet(CmdSet): - """ - Building menu CmdSet, adding commands specific to the menu. - """ + """Building menu CmdSet.""" key = "building_menu" priority = 5 @@ -732,114 +324,657 @@ class BuildingMenuCmdSet(CmdSet): if menu is None: menu = caller.db._building_menu if menu: - menu = BuildingMenu.restore(caller, self) + menu = BuildingMenu.restore(caller) cmds = [CmdNoInput, CmdNoMatch] for cmd in cmds: self.add(cmd(building_menu=menu)) +## Menu classes -# Helper functions -def menu_setattr(menu, choice, obj, string): - """ - Set the value at the specified attribute. +class Choice(object): - Args: - menu (BuildingMenu): the menu object. - choice (Chocie): the specific choice. - obj (any): the object to modify. - string (str): the string with the new value. + """A choice object, created by `add_choice`.""" - Note: - This function is supposed to be used as a default to - `BuildingMenu.add_choice`, when an attribute name is specified - but no function to call `on_nomatch` the said value. + def __init__(self, title, key=None, aliases=None, attr=None, text=None, glance=None, on_enter=None, on_nomatch=None, on_leave=None, + menu=None, caller=None, obj=None): + """Constructor. + + Args: + title (str): the choice's title. + key (str, optional): the key of the letters to type to access + the choice. If not set, try to guess it based on the title. + aliases (list of str, optional): the allowed aliases for this choice. + attr (str, optional): the name of the attribute of 'obj' to set. + text (str or callable, optional): a text to be displayed for this + choice. It can be a callable. + glance (str or callable, optional): an at-a-glance summary of the + sub-menu shown in the main menu. It can be set to + display the current value of the attribute in the + main menu itself. + menu (BuildingMenu, optional): the parent building menu. + on_enter (callable, optional): a callable to call when the + caller enters into the choice. + on_nomatch (callable, optional): a callable to call when no + match is entered in the choice. + on_leave (callable, optional): a callable to call when the caller + leaves the choice. + caller (Account or Object, optional): the caller. + obj (Object, optional): the object to edit. + + """ + self.title = title + self.key = key + self.aliases = aliases + self.attr = attr + self.text = text + self.glance = glance + self.on_enter = on_enter + self.on_nomatch = on_nomatch + self.on_leave = on_leave + self.menu = menu + self.caller = caller + self.obj = obj + + def __repr__(self): + return "".format(self.title, self.key) + + @property + def keys(self): + """Return a tuple of keys separated by `sep_keys`.""" + return tuple(self.key.split(self.menu.sep_keys)) + + def format_text(self): + """Format the choice text and return it, or an empty string.""" + text = "" + if self.text: + text = _call_or_get(self.text, menu=self.menu, choice=self, string="", caller=self.caller, obj=self.obj) + text = dedent(text.strip("\n")) + text = text.format(obj=self.obj, caller=self.caller) + + return text + + def enter(self, string): + """Called when the user opens the choice. + + Args: + string (str): the entered string. + + """ + if self.on_enter: + _call_or_get(self.on_enter, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) + + def nomatch(self, string): + """Called when the user entered something in the choice. + + Args: + string (str): the entered string. + + Returns: + to_display (bool): The return value of `nomatch` if set or + `True`. The rule is that if `no_match` returns `True`, + then the choice or menu is displayed. + + """ + if self.on_nomatch: + return _call_or_get(self.on_nomatch, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) + + return True + + def leave(self, string): + """Called when the user closes the choice. + + Args: + string (str): the entered string. + + """ + if self.on_leave: + _call_or_get(self.on_leave, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) + + +class BuildingMenu(object): """ - attr = getattr(choice, "attr", None) - if choice is None or string is None or attr is None or menu is None: - log_err("The `menu_setattr` function was called to set the attribute {} of object {} to {}, but the choice {} of menu {} or another information is missing.".format(attr, obj, repr(string), choice, menu)) - return + Class allowing to create and set building menus to edit specific objects. - for part in attr.split(".")[:-1]: - obj = getattr(obj, part) + A building menu is a kind of `EvMenu` designed to edit objects by + builders, although it can be used for players in some contexts. You + could, for instance, create a building menu to edit a room with a + sub-menu for the room's key, another for the room's description, + another for the room's exits, and so on. - setattr(obj, attr.split(".")[-1], string) + To add choices (sub-menus), you should call `add_choice` (see the + full documentation of this method). With most arguments, you can + specify either a plain string or a callback. This callback will be + called when the operation is to be performed. -def menu_quit(caller): - """ - Quit the menu, closing the CmdSet. - - Args: - caller (Account or Object): the caller. + Some methods are provided for frequent needs (see the `add_choice_*` + methods). Some helper functions are defined at the top of this + module in order to be used as arguments to `add_choice` + in frequent cases. """ - if caller is None: - log_err("The function `menu_quit` was called from a building menu without a caller") - if caller.cmdset.has(BuildingMenuCmdSet): - caller.msg("Closing the building menu.") - caller.cmdset.remove(BuildingMenuCmdSet) - else: - caller.msg("It looks like the building menu has already been closed.") + keys_go_back = ["@"] # The keys allowing to go back in the menu tree + sep_keys = "." # The key separator for menus with more than 2 levels + joker_key = "*" # The special key meaning "anything" in a choice key + min_shortcut = 1 # The minimum length of shorcuts when `key` is not set -def menu_edit(caller, choice, obj): - """ - Open the EvEditor to edit a specified field. + def __init__(self, caller=None, obj=None, title="Building menu: {obj}", keys=None, parents=None, persistent=False): + """Constructor, you shouldn't override. See `init` instead. - Args: - caller (Account or Object): the caller. - choice (Choice): the choice object. - obj (any): the object to edit. + Args: + caller (Account or Object): the caller. + obj (Object): the object to be edited, like a room. + title (str, optional): the menu title. + keys (list of str, optional): the starting menu keys (None + to start from the first level). + parents (tuple, optional): information for parent menus, + automatically supplied. + persistent (bool, optional): should this building menu + survive a reload/restart? - """ - attr = choice.attr - caller.db._building_menu_to_edit = (obj, attr) - caller.cmdset.remove(BuildingMenuCmdSet) - EvEditor(caller, loadfunc=_menu_loadfunc, savefunc=_menu_savefunc, quitfunc=_menu_quitfunc, key="editor", persistent=True) + Note: + If some of these options have to be changed, it is + preferable to do so in the `init` method and not to + override `__init__`. For instance: + class RoomBuildingMenu(BuildingMenu): + def init(self, room): + self.title = "Menu for room: {obj.key}(#{obj.id})" + # ... -def open_submenu(caller, menu, choice, obj, parent_key): - """ - Open a sub-menu, closing the current menu and opening the new one - with `parent` set. + """ + self.caller = caller + self.obj = obj + self.title = title + self.keys = keys or [] + self.parents = parents or () + self.persistent = persistent + self.choices = [] + self.cmds = {} - Args: - caller (Account or Object): the caller. - menu (Building): the selected choice. - choice (Chocie): the choice. - obj (any): the object to be edited. - parent_key (any): the parent menu key. + if obj: + self.init(obj) + self._add_keys_choice() - Note: - You can easily call this function from a different callback to customize its - behavior. + @property + def current_choice(self): + """Return the current choice or None. - """ - parent_key = parent_key if isinstance(parent_key, basestring) else None - menu.open_submenu(choice.attr, obj, parent_key) + Returns: + choice (Choice): the current choice or None. + Note: + We use the menu keys to identify the current position of + the caller in the menu. The menu `keys` hold a list of + keys that should match a choice to be usable. -# Private functions for EvEditor -def _menu_loadfunc(caller): - obj, attr = caller.attributes.get("_building_menu_to_edit", [None, None]) - if obj and attr: - for part in attr.split(".")[:-1]: - obj = getattr(obj, part) + """ + menu_keys = self.keys + if not menu_keys: + return None - return getattr(obj, attr.split(".")[-1]) if obj is not None else "" + for choice in self.choices: + choice_keys = choice.keys + if len(menu_keys) == len(choice_keys): + # Check all the intermediate keys + common = True + for menu_key, choice_key in zip(menu_keys, choice_keys): + if choice_key == self.joker_key: + continue -def _menu_savefunc(caller, buf): - obj, attr = caller.attributes.get("_building_menu_to_edit", [None, None]) - if obj and attr: - for part in attr.split(".")[:-1]: - obj = getattr(obj, part) + if not isinstance(menu_key, basestring) or menu_key != choice_key: + common = False + break - setattr(obj, attr.split(".")[-1], buf) + if common: + return choice - caller.attributes.remove("_building_menu_to_edit") - return True + return None -def _menu_quitfunc(caller): - caller.cmdset.add(BuildingMenuCmdSet) - if caller.ndb._building_menu: - caller.ndb._building_menu.move(back=True) + @property + def relevant_choices(self): + """Only return the relevant choices according to the current meny key. + + Returns: + relevant (list of Choice object): the relevant choices. + + Note: + We use the menu keys to identify the current position of + the caller in the menu. The menu `keys` hold a list of + keys that should match a choice to be usable. + + """ + menu_keys = self.keys + relevant = [] + for choice in self.choices: + choice_keys = choice.keys + if not menu_keys and len(choice_keys) == 1: + # First level choice with the menu key empty, that's relevant + relevant.append(choice) + elif len(menu_keys) == len(choice_keys) - 1: + # Check all the intermediate keys + common = True + for menu_key, choice_key in zip(menu_keys, choice_keys): + if choice_key == self.joker_key: + continue + + if not isinstance(menu_key, basestring) or menu_key != choice_key: + common = False + break + + if common: + relevant.append(choice) + + return relevant + + def _save(self): + """Save the menu in a attributes on the caller. + + If `persistent` is set to `True`, also save in a persistent attribute. + + """ + self.caller.ndb._building_menu = self + + if self.persistent: + self.caller.db._building_menu = { + "class": type(self).__module__ + "." + type(self).__name__, + "obj": self.obj, + "title": self.title, + "keys": self.keys, + "parents": self.parents, + "persistent": self.persistent, + } + + def _add_keys_choice(self): + """Add the choices' keys if some choices don't have valid keys.""" + # If choices have been added without keys, try to guess them + for choice in self.choices: + if not choice.key: + title = strip_ansi(choice.title.strip()).lower() + length = self.min_shortcut + while length <= len(title): + i = 0 + while i < len(title) - length + 1: + guess = title[i:i + length] + if guess not in self.cmds: + choice.key = guess + break + + i += 1 + + if choice.key: + break + + length += 1 + + if choice.key: + self.cmds[choice.key] = choice + else: + raise ValueError("Cannot guess the key for {}".format(choice)) + + def init(self, obj): + """Create the sub-menu to edit the specified object. + + Args: + obj (Object): the object to edit. + + Note: + This method is probably to be overridden in your subclasses. + Use `add_choice` and its variants to create menu choices. + + """ + pass + + def add_choice(self, title, key=None, aliases=None, attr=None, text=None, glance=None, + on_enter=None, on_nomatch=None, on_leave=None): + """ + Add a choice, a valid sub-menu, in the current builder menu. + + Args: + title (str): the choice's title. + key (str, optional): the key of the letters to type to access + the sub-neu. If not set, try to guess it based on the + choice title. + aliases (list of str, optional): the aliases for this choice. + attr (str, optional): the name of the attribute of 'obj' to set. + This is really useful if you want to edit an + attribute of the object (that's a frequent need). If + you don't want to do so, just use the `on_*` arguments. + text (str or callable, optional): a text to be displayed when + the menu is opened It can be a callable. + glance (str or callable, optional): an at-a-glance summary of the + sub-menu shown in the main menu. It can be set to + display the current value of the attribute in the + main menu itself. + on_enter (callable, optional): a callable to call when the + caller enters into this choice. + on_nomatch (callable, optional): a callable to call when + the caller enters something in this choice. If you + don't set this argument but you have specified + `attr`, then `obj`.`attr` will be set with the value + entered by the user. + on_leave (callable, optional): a callable to call when the + caller leaves the choice. + + Returns: + choice (Choice): the newly-created choice. + + Raises: + ValueError if the choice cannot be added. + + Note: + Most arguments can be callables, like functions. This has the + advantage of allowing great flexibility. If you specify + a callable in most of the arguments, the callable should return + the value expected by the argument (a str more often than + not). For instance, you could set a function to be called + to get the menu text, which allows for some filtering: + def text_exits(menu): + return "Some text to display" + class RoomBuildingMenu(BuildingMenu): + def init(self): + self.add_choice("exits", key="x", text=text_exits) + + The allowed arguments in a callable are specific to the + argument names (they are not sensitive to orders, not all + arguments have to be present). For more information, see + `_call_or_get`. + + """ + key = key or "" + key = key.lower() + aliases = aliases or [] + aliases = [a.lower() for a in aliases] + if on_enter is None and on_nomatch is None: + if attr is None: + raise ValueError("The choice {} has neither attr nor callback, specify one of these as arguments".format(title)) + + if attr and on_nomatch is None: + on_nomatch = menu_setattr + + if key and key in self.cmds: + raise ValueError("A conflict exists between {} and {}, both use key or alias {}".format(self.cmds[key], title, repr(key))) + + choice = Choice(title, key=key, aliases=aliases, attr=attr, text=text, glance=glance, on_enter=on_enter, on_nomatch=on_nomatch, on_leave=on_leave, + menu=self, caller=self.caller, obj=self.obj) + self.choices.append(choice) + if key: + self.cmds[key] = choice + + for alias in aliases: + self.cmds[alias] = choice + + return choice + + def add_choice_edit(self, title="description", key="d", aliases=None, attr="db.desc", glance="\n {obj.db.desc}", on_enter=None): + """ + Add a simple choice to edit a given attribute in the EvEditor. + + Args: + title (str, optional): the choice's title. + key (str, optional): the choice's key. + aliases (list of str, optional): the choice's aliases. + glance (str or callable, optional): the at-a-glance description. + on_enter (callable, optional): a different callable to edit + the attribute. + + Returns: + choice (Choice): the newly-created choice. + + Note: + This is just a shortcut method, calling `add_choice`. + If `on_enter` is not set, use `menu_edit` which opens + an EvEditor to edit the specified attribute. + When the caller closes the editor (with :q), the menu + will be re-opened. + + """ + on_enter = on_enter or menu_edit + return self.add_choice(title, key=key, aliases=aliases, attr=attr, glance=glance, on_enter=on_enter) + + def add_choice_quit(self, title="quit the menu", key="q", aliases=None, on_enter=None): + """ + Add a simple choice just to quit the building menu. + + Args: + title (str, optional): the choice's title. + key (str, optional): the choice's key. + aliases (list of str, optional): the choice's aliases. + on_enter (callable, optional): a different callable + to quit the building menu. + + Note: + This is just a shortcut method, calling `add_choice`. + If `on_enter` is not set, use `menu_quit` which simply + closes the menu and displays a message. It also + removes the CmdSet from the caller. If you supply + another callable instead, make sure to do the same. + + """ + on_enter = on_enter or menu_quit + return self.add_choice(title, key=key, aliases=aliases, on_enter=on_enter) + + def open(self): + """Open the building menu for the caller. + + Note: + This method should be called once when the building menu + has been instanciated. From there, the building menu will + be re-created automatically when the server + reloads/restarts, assuming `persistent` is set to `True`. + + """ + caller = self.caller + self._save() + self.caller.cmdset.add(BuildingMenuCmdSet, permanent=True) + self.display() + + def open_parent_menu(self): + """Open the parent menu, using `self.parents`. + + Note: + You probably don't need to call this method directly, + since the caller can go back to the parent menu using the + `keys_go_back` automatically. + + """ + parents = list(self.parents) + if parents: + parent_class, parent_obj, parent_keys = parents[-1] + del parents[-1] + + if self.caller.cmdset.has(BuildingMenuCmdSet): + self.caller.cmdset.remove(BuildingMenuCmdSet) + + try: + menu_class = class_from_module(parent_class) + except Exception: + log_trace("BuildingMenu: attempting to load class {} failed".format(repr(parent_class))) + return + + # Create the parent menu + try: + building_menu = menu_class(self.caller, parent_obj, keys=parent_keys, parents=tuple(parents)) + except Exception: + log_trace("An error occurred while creating building menu {}".format(repr(parent_class))) + return + else: + return building_menu.open() + + def open_submenu(self, submenu_class, submenu_obj, parent_keys=None): + """ + Open a sub-menu, closing the current menu and opening the new one. + + Args: + submenu_class (str): the submenu class as a Python path. + submenu_obj (Object): the object to give to the submenu. + parent_keys (list of str, optional): the parent keys when + the submenu is closed. + + Note: + When the user enters `@` in the submenu, she will go back to + the current menu, with the `parent_keys` set as its keys. + Therefore, you should set it on the keys of the choice that + should be opened when the user leaves the submenu. + + Returns: + new_menu (BuildingMenu): the new building menu or None. + + """ + parent_keys = parent_keys or [] + parents = list(self.parents) + parents.append((type(self).__module__ + "." + type(self).__name__, self.obj, parent_keys)) + if self.caller.cmdset.has(BuildingMenuCmdSet): + self.caller.cmdset.remove(BuildingMenuCmdSet) + + # Shift to the new menu + try: + menu_class = class_from_module(submenu_class) + except Exception: + log_trace("BuildingMenu: attempting to load class {} failed".format(repr(submenu_class))) + return + + # Create the submenu + try: + building_menu = menu_class(self.caller, submenu_obj, parents=parents) + except Exception: + log_trace("An error occurred while creating building menu {}".format(repr(submenu_class))) + return + else: + return building_menu.open() + + def move(self, key=None, back=False, quiet=False, string=""): + """ + Move inside the menu. + + Args: + key (any): the portion of the key to add to the current + menu keys. If you wish to go back in the menu + tree, don't provide a `key`, just set `back` to `True`. + back (bool, optional): go back in the menu (`False` by default). + quiet (bool, optional): should the menu or choice be + displayed afterward? + string (str, optional): the string sent by the caller to move. + + Note: + This method will need to be called directly should you + use more than two levels in your menu. For instance, + in your room menu, if you want to have an "exits" + option, and then be able to enter "north" in this + choice to edit an exit. The specific exit choice + could be a different menu (with a different class), but + it could also be an additional level in your original menu. + If that's the case, you will need to use this method. + + """ + choice = self.current_choice + if choice: + choice.leave("") + + if not back: # Move forward + if not key: + raise ValueError("you are asking to move forward, you should specify a key.") + + self.keys.append(key) + else: # Move backward + if not self.keys: + raise ValueError("you already are at the top of the tree, you cannot move backward.") + + del self.keys[-1] + + self._save() + choice = self.current_choice + if choice: + choice.enter(string) + + if not quiet: + self.display() + + def close(self): + """Close the building menu, removing the CmdSet.""" + if self.caller.cmdset.has(BuildingMenuCmdSet): + self.caller.cmdset.delete(BuildingMenuCmdSet) + if self.caller.attributes.has("_building_menu"): + self.caller.attributes.remove("_building_menu") + if self.caller.nattributes.has("_building_menu"): + self.caller.nattributes.remove("_building_menu") + + # Display methods. Override for customization + def display_title(self): + """Return the menu title to be displayed.""" + return _call_or_get(self.title, menu=self, obj=self.obj, caller=self.caller).format(obj=self.obj) + + def display_choice(self, choice): + """Display the specified choice. + + Args: + choice (Choice): the menu choice. + + """ + title = _call_or_get(choice.title, menu=self, choice=choice, obj=self.obj, caller=self.caller) + clear_title = title.lower() + pos = clear_title.find(choice.key.lower()) + ret = " " + if pos >= 0: + ret += title[:pos] + "[|y" + choice.key.title() + "|n]" + title[pos + len(choice.key):] + else: + ret += "[|y" + choice.key.title() + "|n] " + title + + if choice.glance: + glance = _call_or_get(choice.glance, menu=self, choice=choice, caller=self.caller, string="", obj=self.obj) + glance = glance.format(obj=self.obj, caller=self.caller) + ret += ": " + glance + + return ret + + def display(self): + """Display the entire menu or a single choice, depending on the keys.""" + choice = self.current_choice + if self.keys and choice: + text = choice.format_text() + else: + text = self.display_title() + "\n" + for choice in self.relevant_choices: + text += "\n" + self.display_choice(choice) + + self.caller.msg(text) + + @staticmethod + def restore(caller): + """Restore the building menu for the caller. + + Args: + caller (Account or Object): the caller. + + Note: + This method should be automatically called if a menu is + saved in the caller, but the object itself cannot be found. + + """ + menu = caller.db._building_menu + if menu: + class_name = menu.get("class") + if not class_name: + log_err("BuildingMenu: on caller {}, a persistent attribute holds building menu data, but no class could be found to restore the menu".format(caller)) + return + + try: + menu_class = class_from_module(class_name) + except Exception: + log_trace("BuildingMenu: attempting to load class {} failed".format(repr(class_name))) + return + + # Create the menu + obj = menu.get("obj") + keys = menu.get("keys") + title = menu.get("title", "") + parents = menu.get("parents") + persistent = menu.get("persistent", False) + try: + building_menu = menu_class(caller, obj, title=title, keys=keys, parents=parents, persistent=persistent) + except Exception: + log_trace("An error occurred while creating building menu {}".format(repr(class_name))) + return + + return building_menu diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 30bf71dcc8..69af89a3b6 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -670,7 +670,7 @@ 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 @@ -966,7 +966,7 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_basic.CmdPass(), "", "You can only do that in combat. (see: help fight)") self.call(tb_basic.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. @@ -984,7 +984,7 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_equip.CmdPass(), "", "You can only do that in combat. (see: help fight)") self.call(tb_equip.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. @@ -998,7 +998,7 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_range.CmdPass(), "", "You can only do that in combat. (see: help fight)") self.call(tb_range.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_range.CmdRest(), "", "Char rests to recover HP.") - + class TestTurnBattleFunc(EvenniaTest): @@ -1080,7 +1080,7 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) # Remove the script at the end turnhandler.stop() - + # Test the combat functions in tb_equip too. They work mostly the same. def test_tbequipfunc(self): attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker") @@ -1159,7 +1159,7 @@ 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") @@ -1264,7 +1264,7 @@ Bar -Qux""" class TestTreeSelectFunc(EvenniaTest): - + def test_tree_functions(self): # Dash counter self.assertTrue(tree_select.dashcount("--test") == 2) @@ -1279,7 +1279,7 @@ class TestTreeSelectFunc(EvenniaTest): # 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': 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) @@ -1414,3 +1414,150 @@ class TestRandomStringGenerator(EvenniaTest): # We can't generate one more with self.assertRaises(random_string_generator.ExhaustedGenerator): SIMPLE_GENERATOR.get() + + +# Tests for the building_menu contrib +from evennia.contrib.building_menu import BuildingMenu, CmdNoInput, CmdNoMatch + +class Submenu(BuildingMenu): + def init(self, exit): + self.add_choice("title", key="t", attr="key") + +class TestBuildingMenu(CommandTest): + + def setUp(self): + super(TestBuildingMenu, self).setUp() + self.menu = BuildingMenu(caller=self.char1, obj=self.room1, title="test") + self.menu.add_choice("title", key="t", attr="key") + self.menu.add_choice_quit() + + def test_quit(self): + """Try to quit the building menu.""" + self.assertFalse(self.char1.cmdset.has("building_menu")) + self.menu.open() + self.assertTrue(self.char1.cmdset.has("building_menu")) + self.call(CmdNoMatch(building_menu=self.menu), "q") + # char1 tries to quit the editor + self.assertFalse(self.char1.cmdset.has("building_menu")) + + def test_setattr(self): + """Test the simple setattr provided by building menus.""" + key = self.room1.key + self.menu.open() + self.call(CmdNoMatch(building_menu=self.menu), "t") + self.assertIsNotNone(self.menu.current_choice) + self.call(CmdNoMatch(building_menu=self.menu), "some new title") + self.call(CmdNoMatch(building_menu=self.menu), "@") + self.assertIsNone(self.menu.current_choice) + self.assertEqual(self.room1.key, "some new title") + self.call(CmdNoMatch(building_menu=self.menu), "q") + + def test_add_choice_without_key(self): + """Try to add choices without keys.""" + choices = [] + for i in range(20): + choices.append(self.menu.add_choice("choice", attr="test")) + self.menu._add_keys_choice() + keys = ["c", "h", "o", "i", "e", "ch", "ho", "oi", "ic", "ce", "cho", "hoi", "oic", "ice", "choi", "hoic", "oice", "choic", "hoice", "choice"] + for i in range(20): + self.assertEqual(choices[i].key, keys[i]) + + # Adding another key of the same title would break, no more available shortcut + self.menu.add_choice("choice", attr="test") + with self.assertRaises(ValueError): + self.menu._add_keys_choice() + + def test_callbacks(self): + """Test callbacks in menus.""" + self.room1.key = "room1" + def on_enter(caller, menu): + caller.msg("on_enter:{}".format(menu.title)) + def on_nomatch(caller, string, choice): + caller.msg("on_nomatch:{},{}".format(string, choice.key)) + def on_leave(caller, obj): + caller.msg("on_leave:{}".format(obj.key)) + self.menu.add_choice("test", key="e", on_enter=on_enter, on_nomatch=on_nomatch, on_leave=on_leave) + self.call(CmdNoMatch(building_menu=self.menu), "e", "on_enter:test") + self.call(CmdNoMatch(building_menu=self.menu), "ok", "on_nomatch:ok,e") + self.call(CmdNoMatch(building_menu=self.menu), "@", "on_leave:room1") + self.call(CmdNoMatch(building_menu=self.menu), "q") + + def test_multi_level(self): + """Test multi-level choices.""" + # Creaste three succeeding menu (t2 is contained in t1, t3 is contained in t2) + def on_nomatch_t1(caller, menu): + menu.move("whatever") # this will be valid since after t1 is a joker + + def on_nomatch_t2(caller, menu): + menu.move("t3") # this time the key matters + + t1 = self.menu.add_choice("what", key="t1", attr="t1", on_nomatch=on_nomatch_t1) + t2 = self.menu.add_choice("and", key="t1.*", attr="t2", on_nomatch=on_nomatch_t2) + t3 = self.menu.add_choice("why", key="t1.*.t3", attr="t3") + self.menu.open() + + # Move into t1 + self.assertIn(t1, self.menu.relevant_choices) + self.assertNotIn(t2, self.menu.relevant_choices) + self.assertNotIn(t3, self.menu.relevant_choices) + self.assertIsNone(self.menu.current_choice) + self.call(CmdNoMatch(building_menu=self.menu), "t1") + self.assertEqual(self.menu.current_choice, t1) + self.assertNotIn(t1, self.menu.relevant_choices) + self.assertIn(t2, self.menu.relevant_choices) + self.assertNotIn(t3, self.menu.relevant_choices) + + # Move into t2 + self.call(CmdNoMatch(building_menu=self.menu), "t2") + self.assertEqual(self.menu.current_choice, t2) + self.assertNotIn(t1, self.menu.relevant_choices) + self.assertNotIn(t2, self.menu.relevant_choices) + self.assertIn(t3, self.menu.relevant_choices) + + # Move into t3 + self.call(CmdNoMatch(building_menu=self.menu), "t3") + self.assertEqual(self.menu.current_choice, t3) + self.assertNotIn(t1, self.menu.relevant_choices) + self.assertNotIn(t2, self.menu.relevant_choices) + self.assertNotIn(t3, self.menu.relevant_choices) + + # Move back to t2 + self.call(CmdNoMatch(building_menu=self.menu), "@") + self.assertEqual(self.menu.current_choice, t2) + self.assertNotIn(t1, self.menu.relevant_choices) + self.assertNotIn(t2, self.menu.relevant_choices) + self.assertIn(t3, self.menu.relevant_choices) + + # Move back into t1 + self.call(CmdNoMatch(building_menu=self.menu), "@") + self.assertEqual(self.menu.current_choice, t1) + self.assertNotIn(t1, self.menu.relevant_choices) + self.assertIn(t2, self.menu.relevant_choices) + self.assertNotIn(t3, self.menu.relevant_choices) + + # Moves back to the main menu + self.call(CmdNoMatch(building_menu=self.menu), "@") + self.assertIn(t1, self.menu.relevant_choices) + self.assertNotIn(t2, self.menu.relevant_choices) + self.assertNotIn(t3, self.menu.relevant_choices) + self.assertIsNone(self.menu.current_choice) + self.call(CmdNoMatch(building_menu=self.menu), "q") + + def test_submenu(self): + """Test to add sub-menus.""" + def open_exit(menu): + menu.open_submenu("evennia.contrib.tests.Submenu", self.exit) + return False + + self.menu.add_choice("exit", key="x", on_enter=open_exit) + self.menu.open() + self.call(CmdNoMatch(building_menu=self.menu), "x") + self.menu = self.char1.ndb._building_menu + self.call(CmdNoMatch(building_menu=self.menu), "t") + self.call(CmdNoMatch(building_menu=self.menu), "in") + self.call(CmdNoMatch(building_menu=self.menu), "@") + self.call(CmdNoMatch(building_menu=self.menu), "@") + self.menu = self.char1.ndb._building_menu + self.assertEqual(self.char1.ndb._building_menu.obj, self.room1) + self.call(CmdNoMatch(building_menu=self.menu), "q") + self.assertEqual(self.exit.key, "in") From a7b4dc09e94adca873213b48feb0b93ad8420144 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Mon, 2 Apr 2018 14:53:34 +0200 Subject: [PATCH 274/466] [building menu] The BuildingMenuCmdSet is permanent only if the menu is persistent --- evennia/contrib/building_menu.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/building_menu.py b/evennia/contrib/building_menu.py index 0d95492039..4a2f204c6c 100644 --- a/evennia/contrib/building_menu.py +++ b/evennia/contrib/building_menu.py @@ -114,7 +114,7 @@ def _menu_savefunc(caller, buf): return True def _menu_quitfunc(caller): - caller.cmdset.add(BuildingMenuCmdSet) + caller.cmdset.add(BuildingMenuCmdSet, permanent=calelr.ndb._building_menu and caller.ndb._building_menu.persistent or False) if caller.ndb._building_menu: caller.ndb._building_menu.move(back=True) @@ -767,7 +767,7 @@ class BuildingMenu(object): """ caller = self.caller self._save() - self.caller.cmdset.add(BuildingMenuCmdSet, permanent=True) + self.caller.cmdset.add(BuildingMenuCmdSet, permanent=self.persistent) self.display() def open_parent_menu(self): From ed3e57edd030f2a4d7c00de01b2168fb3aaeb260 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 2 Apr 2018 18:46:55 +0200 Subject: [PATCH 275/466] Start add edit_node decorator (untested) --- evennia/utils/evmenu.py | 427 +++++++++++++++++++++++++++++---------- evennia/utils/spawner.py | 10 +- 2 files changed, 331 insertions(+), 106 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index cccda6798f..e9e3f1c8af 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -175,7 +175,7 @@ from evennia import Command, CmdSet from evennia.utils import logger from evennia.utils.evtable import EvTable from evennia.utils.ansi import strip_ansi -from evennia.utils.utils import mod_import, make_iter, pad, m_len +from evennia.utils.utils import mod_import, make_iter, pad, m_len, is_iter from evennia.commands import cmdhandler # read from protocol NAWS later? @@ -683,6 +683,43 @@ class EvMenu(object): return ret, kwargs return None + def extract_goto_exec(self, nodename, option_dict): + """ + Helper: Get callables and their eventual kwargs. + + Args: + nodename (str): The current node name (used for error reporting). + option_dict (dict): The seleted option's dict. + + Returns: + goto (str, callable or None): The goto directive in the option. + goto_kwargs (dict): Kwargs for `goto` if the former is callable, otherwise empty. + execute (callable or None): Executable given by the `exec` directive. + exec_kwargs (dict): Kwargs for `execute` if it's callable, otherwise empty. + + """ + goto_kwargs, exec_kwargs = {}, {} + goto, execute = option_dict.get("goto", None), option_dict.get("exec", None) + if goto and isinstance(goto, (tuple, list)): + if len(goto) > 1: + goto, goto_kwargs = goto[:2] # ignore any extra arguments + if not hasattr(goto_kwargs, "__getitem__"): + # not a dict-like structure + raise EvMenuError("EvMenu node {}: goto kwargs is not a dict: {}".format( + nodename, goto_kwargs)) + else: + goto = goto[0] + if execute and isinstance(execute, (tuple, list)): + if len(execute) > 1: + execute, exec_kwargs = execute[:2] # ignore any extra arguments + if not hasattr(exec_kwargs, "__getitem__"): + # not a dict-like structure + raise EvMenuError("EvMenu node {}: exec kwargs is not a dict: {}".format( + nodename, goto_kwargs)) + else: + execute = execute[0] + return goto, goto_kwargs, execute, exec_kwargs + def goto(self, nodename, raw_string, **kwargs): """ Run a node by name, optionally dynamically generating that name first. @@ -696,29 +733,6 @@ class EvMenu(object): argument) """ - def _extract_goto_exec(option_dict): - "Helper: Get callables and their eventual kwargs" - goto_kwargs, exec_kwargs = {}, {} - goto, execute = option_dict.get("goto", None), option_dict.get("exec", None) - if goto and isinstance(goto, (tuple, list)): - if len(goto) > 1: - goto, goto_kwargs = goto[:2] # ignore any extra arguments - if not hasattr(goto_kwargs, "__getitem__"): - # not a dict-like structure - raise EvMenuError("EvMenu node {}: goto kwargs is not a dict: {}".format( - nodename, goto_kwargs)) - else: - goto = goto[0] - if execute and isinstance(execute, (tuple, list)): - if len(execute) > 1: - execute, exec_kwargs = execute[:2] # ignore any extra arguments - if not hasattr(exec_kwargs, "__getitem__"): - # not a dict-like structure - raise EvMenuError("EvMenu node {}: exec kwargs is not a dict: {}".format( - nodename, goto_kwargs)) - else: - execute = execute[0] - return goto, goto_kwargs, execute, exec_kwargs if callable(nodename): # run the "goto" callable, if possible @@ -764,12 +778,12 @@ class EvMenu(object): desc = dic.get("desc", dic.get("text", None)) if "_default" in keys: keys = [key for key in keys if key != "_default"] - goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic) + goto, goto_kwargs, execute, exec_kwargs = self.extract_goto_exec(nodename, dic) self.default = (goto, goto_kwargs, execute, exec_kwargs) else: # use the key (only) if set, otherwise use the running number keys = list(make_iter(dic.get("key", str(inum + 1).strip()))) - goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic) + goto, goto_kwargs, execute, exec_kwargs = self.extract_goto_exec(nodename, dic) if keys: display_options.append((keys[0], desc)) for key in keys: @@ -975,11 +989,143 @@ class EvMenu(object): # ----------------------------------------------------------- # -# List node +# Edit node (decorator turning a node into an editing +# point for a given resource # # ----------------------------------------------------------- -def list_node(option_generator, examine_processor, goto_processor, pagesize=10): +def edit_node(edit_text, add_text, edit_callback, add_callback, get_choices=None): + """ + Decorator for turning an EvMenu node into an editing + page. Will add new options, prepending those options + added in the node. + + Args: + edit_text (str or callable): Will be used as text for the edit node. If + callable, it will be called as edittext(selection) + and should return the node text for the edit-node, probably listing + the current value of all editable propnames, if possible. + add_text (str) or callable: Gives text for node in add-mode. If a callable, + called as add_text() and should return the text for the node. + edit_callback (callable): Will be called as edit_callback(editable, raw_string) + and should return a boolean True/False if the setting of the property + succeeded or not. The value will always be a string and should be + converted as needed. + add_callback (callable): Will be called as add_callback(raw_string) and + should return a boolean True/False if the addition succeded. + + get_choices (callable): Produce the available editable choices. If this + is not given, the `goto` callable must have been provided with the + kwarg `available_choices` by the decorated node. + + """ + + def decorator(func): + + def _setter_goto(caller, raw_string, **kwargs): + editable = kwargs.get("editable") + mode = kwargs.get("edit_node_mode") + try: + if mode == 'edit': + is_ok = edit_callback(editable, raw_string) + else: + is_ok = add_callback(raw_string) + except Exception: + logger.log_trace() + if not is_ok: + caller.msg("|rValue could not be set.") + return None + + def _patch_goto(caller, raw_string, **kwargs): + + # parse incoming string to figure out if there is a match to edit/add + match = re.search(r"(^[a-zA-Z]*)\s*([0-9]*)$", raw_string) + cmd, number = match.groups() + edit_mode = None + available_choices = None + selection = None + + if get_choices: + available_choices = make_iter(get_choices(caller, raw_string, **kwargs)) + if not available_choices: + available_choices = kwargs.get("available_choices", []) + + if available_choices and cmd.startswith("e"): + try: + index = int(cmd) - 1 + selection = available_choices[index] + edit_mode = 'edit' + except (IndexError, TypeError): + caller.msg("|rNot a valid 'edit' command.") + + if cmd.startswith("a") and not number: + # add mode + edit_mode = "add" + + if edit_mode: + # replace with edit text/options + text = edit_text(selection) if edit_mode == "edit" else add_text() + options = ({"key": "_default", + "goto": (_setter_goto, + {"selection": selection, + "edit_node_mode": edit_mode})}) + return text, options + + # no matches - pass through to the original decorated goto instruction + + decorated_opt = kwargs.get("decorated_opt") + + if decorated_opt: + # use EvMenu's parser to get the goto/goto-kwargs out of + # the decorated option structure + dec_goto, dec_goto_kwargs, _, _ = \ + caller.ndb._menutree.extract_goto_exec("edit-node", decorated_opt) + + if callable(dec_goto): + try: + return dec_goto(caller, raw_string, + **{dec_goto_kwargs if dec_goto_kwargs else {}}) + except Exception: + caller.msg("|rThere was an error in the edit node.") + logger.log_trace() + return None + + def _edit_node(caller, raw_string, **kwargs): + + text, options = func(caller, raw_string, **kwargs) + + if options: + # find eventual _default in options and patch it with a handler for + # catching editing + + decorated_opt = None + iopt = 0 + for iopt, optdict in enumerate(options): + if optdict.get('key') == "_default": + decorated_opt = optdict + break + + if decorated_opt: + # inject our wrapper over the original goto instruction for the + # _default action (save the original) + options[iopt]["goto"] = (_patch_goto, + {"decorated_opt": decorated_opt}) + + return text, options + + return _edit_node + return decorator + + + +# ----------------------------------------------------------- +# +# List node (decorator turning a node into a list with +# look/edit/add functionality for the elements) +# +# ----------------------------------------------------------- + +def list_node(option_generator, select=None, examine=None, edit=None, add=None, pagesize=10): """ Decorator for making an EvMenu node into a multi-page list node. Will add new options, prepending those options added in the node. @@ -987,17 +1133,25 @@ def list_node(option_generator, examine_processor, goto_processor, pagesize=10): Args: option_generator (callable or list): A list of strings indicating the options, or a callable that is called without any arguments to produce such a list. - examine_processor (callable, optional): Will be called with the caller and the chosen option - when examining said option. Should return a text string to display in the node. - goto_processor (callable, optional): Will be called as goto_processor(caller, menuchoice) + select (callable, option): Will be called as select(caller, menuchoice) where menuchoice is the chosen option as a string. Should return the target node to - goto after this selection. + goto after this selection. Note that if this is not given, the decorated node must itself + provide a way to continue from the node! + examine (callable, optional): If given, allows for examining options in detail. Will + be called with examine(caller, menuchoice) and should return a text string to + display in-place in the node. + edit (callable, optional): If given, this callable will be called as edit(caller, menuchoice). + It should return the node-key to a node decorated with the `edit_node` decorator. The + menuchoice will automatically be stored on the menutree as `list_node_edit`. + add (tuple, optional): If given, this callable will be called as add(caller, menuchoice). + It should return the node-key to a node decorated with the `edit_node` decorator. The + menuchoice will automatically be stored on the menutree as `list_node_add`. pagesize (int): How many options to show per page. Example: - @list_node(['foo', 'bar'], examine_processor, goto_processor) + @list_node(['foo', 'bar'], examine, select) def node_index(caller): text = "describing the list" return text, [] @@ -1006,27 +1160,63 @@ def list_node(option_generator, examine_processor, goto_processor, pagesize=10): def decorator(func): - def _input_parser(caller, raw_string, **kwargs): - "Parse which input was given, select from option_list" - + def _select_parser(caller, raw_string, **kwargs): + """ + Parse the select action + """ available_choices = kwargs.get("available_choices", []) - processor = kwargs.get("selection_processor") try: - match_ind = int(re.search(r"[0-9]+$", raw_string).group()) - 1 - selection = available_choices[match_ind] - except (AttributeError, KeyError, IndexError, ValueError): - return None - - if processor: + index = int(raw_string.strip()) - 1 + selection = available_choices[index] + except Exception: + caller.msg("|rInvalid choice.|n") + else: try: - return processor(caller, selection) + return select(caller, selection) except Exception: logger.log_trace() - return selection + return None + + def _input_parser(caller, raw_string, **kwargs): + """ + Parse which input was given, select from option_list. + + Understood input is [cmd], where [cmd] is either empty (`select`) + or one of the supported actions `look`, `edit` or `add` depending on + which processors are available. + + """ + + available_choices = kwargs.get("available_choices", []) + match = re.search(r"(^[a-zA-Z]*)\s*([0-9]*)$", raw_string) + cmd, number = match.groups() + mode, selection = None, None + + if number: + number = int(number) - 1 + cmd = cmd.lower().strip() + if cmd.startswith("e") or cmd.startswith("a") and edit: + mode = "edit" + elif examine: + mode = "examine" + + try: + selection = available_choices[number] + except IndexError: + caller.msg("|rInvalid index") + mode = None + else: + caller.msg("|rMust supply a number.") + + return mode, selection + + def _relay_to_edit_or_add(caller, raw_string, **kwargs): + pass def _list_node(caller, raw_string, **kwargs): + mode = kwargs.get("list_mode", None) option_list = option_generator() if callable(option_generator) else option_generator nall_options = len(option_list) @@ -1035,71 +1225,104 @@ def list_node(option_generator, examine_processor, goto_processor, pagesize=10): page_index = max(0, min(npages - 1, kwargs.get("optionpage_index", 0))) page = pages[page_index] + entry = None + extra_text = None - # dynamic, multi-page option list. We use _input_parser as a goto-callable, - # with the `goto_processor` redirecting when we leave the node. - options = [{"desc": opt, - "goto": (_input_parser, - {"available_choices": page, - "selection_processor": goto_processor})} for opt in page] + if mode == "arbitrary": + # freeform input, we must parse it for the allowed commands (look/edit) + mode, entry = _input_parser(caller, raw_string, + **{"available_choices": page}) - if npages > 1: - # if the goto callable returns None, the same node is rerun, and - # kwargs not used by the callable are passed on to the node. This - # allows us to call ourselves over and over, using different kwargs. - options.append({"key": ("|Wcurrent|n", "c"), - "desc": "|W({}/{})|n".format(page_index + 1, npages), - "goto": (lambda caller: None, - {"optionpage_index": page_index})}) - if page_index > 0: - options.append({"key": ("|wp|Wrevious page|n", "p"), - "goto": (lambda caller: None, - {"optionpage_index": page_index - 1})}) - if page_index < npages - 1: - options.append({"key": ("|wn|Wext page|n", "n"), - "goto": (lambda caller: None, - {"optionpage_index": page_index + 1})}) - - - # this catches arbitrary input, notably to examine entries ('look 4' or 'l4' etc) - options.append({"key": "_default", - "goto": (lambda caller: None, - {"show_detail": True, "optionpage_index": page_index})}) - - # update text with detail, if set. Here we call _input_parser like a normal function - text_detail = None - if raw_string and 'show_detail' in kwargs: - text_detail = _input_parser( - caller, raw_string, **{"available_choices": page, - "selection_processor": examine_processor}) - if text_detail is None: - text_detail = "|rThat's not a valid command or option.|n" - - # add data from the decorated node - - text = '' - extra_options = [] - try: - text, extra_options = func(caller, raw_string) - except TypeError: + if examine and mode: # == "look": + # look mode - we are examining a given entry try: - text, extra_options = func(caller) + text = examine(caller, entry) except Exception: - raise - except Exception: - logger.log_trace() - print("extra_options:", extra_options) + logger.log_trace() + text = "|rCould not view." + options = [{"key": ("|wb|Wack|n", "b"), + "goto": (lambda caller: None, + {"optionpage_index": page_index})}, + {"key": "_default", + "goto": (lambda caller: None, + {"optionpage_index": page_index})}] + return text, options + + # if edit and mode == "edit": + # pass + # elif add and mode == "add": + # # add mode - we are adding a new entry + # pass + else: - if isinstance(extra_options, {}): - extra_options = [extra_options] + # normal mode - list + pass + + if select: + # We have a processor to handle selecting an entry + + # dynamic, multi-page option list. Each selection leads to the `select` + # callback being called with a result from the available choices + options = [{"desc": opt, + "goto": (_select_parser, + {"available_choices": page})} for opt in page] + + if add: + # We have a processor to handle adding a new entry. Re-run this node + # in the 'add' mode + options.append({"key": ("|wadd|Wdd new|n", "a"), + "goto": (lambda caller: None, + {"optionpage_index": page_index, + "list_mode": "add"})}) + if npages > 1: + # if the goto callable returns None, the same node is rerun, and + # kwargs not used by the callable are passed on to the node. This + # allows us to call ourselves over and over, using different kwargs. + options.append({"key": ("|Wcurrent|n", "c"), + "desc": "|W({}/{})|n".format(page_index + 1, npages), + "goto": (lambda caller: None, + {"optionpage_index": page_index})}) + if page_index > 0: + options.append({"key": ("|wp|Wrevious page|n", "p"), + "goto": (lambda caller: None, + {"optionpage_index": page_index - 1})}) + if page_index < npages - 1: + options.append({"key": ("|wn|Wext page|n", "n"), + "goto": (lambda caller: None, + {"optionpage_index": page_index + 1})}) + + # this catches arbitrary input and reruns this node with the 'arbitrary' mode + # this could mean input on the form 'look ' or 'edit ' + options.append({"key": "_default", + "goto": (lambda caller: None, + {"optionpage_index": page_index, + "available_choices": page, + "list_mode": "arbitrary"})}) + + # add data from the decorated node + + extra_options = [] + try: + text, extra_options = func(caller, raw_string) + except TypeError: + try: + text, extra_options = func(caller) + except Exception: + raise + except Exception: + logger.log_trace() + print("extra_options:", extra_options) else: - extra_options = make_iter(extra_options) + if isinstance(extra_options, {}): + extra_options = [extra_options] + else: + extra_options = make_iter(extra_options) - options.extend(extra_options) - text = text + "\n\n" + text_detail if text_detail else text - text += "\n\n(Make a choice or enter 'look ' to examine an option closer)" + options.extend(extra_options) + text = text + "\n\n" + extra_text if extra_text else text + text += "\n\n(Make a choice or enter 'look ' to examine an option closer)" - return text, options + return text, options return _list_node return decorator diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index bab8cdc9a1..eaab84b202 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -690,9 +690,11 @@ def spawn(*prototypes, **kwargs): # Helper functions def _get_menu_metaprot(caller): + + metaproto = None if hasattr(caller.ndb._menutree, "olc_metaprot"): - return caller.ndb._menutree.olc_metaprot - else: + metaproto = caller.ndb._menutree.olc_metaprot + if not metaproto: metaproto = build_metaproto(None, '', [], [], None) caller.ndb._menutree.olc_metaprot = metaproto caller.ndb._menutree.olc_new = True @@ -931,7 +933,7 @@ def _prototype_select(caller, prototype): return ret -@list_node(_all_prototypes, _prototype_examine, _prototype_select) +@list_node(_all_prototypes, _prototype_select, examine=_prototype_examine) def node_prototype(caller): metaprot = _get_menu_metaprot(caller) prot = metaprot.prototype @@ -979,7 +981,7 @@ def _typeclass_select(caller, typeclass): return ret -@list_node(_all_typeclasses, _typeclass_examine, _typeclass_select) +@list_node(_all_typeclasses, _typeclass_select, examine=_typeclass_examine) def node_typeclass(caller): metaprot = _get_menu_metaprot(caller) prot = metaprot.prototype From cbaa2c56e916ed2c898cbd78e85fb07b30e7b300 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 2 Apr 2018 19:55:31 +0200 Subject: [PATCH 276/466] Partial edit_node functionality --- evennia/utils/evmenu.py | 42 ++++++++++++++++++++++++---------------- evennia/utils/spawner.py | 24 +++++++++-------------- 2 files changed, 34 insertions(+), 32 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index e9e3f1c8af..6df9142bbb 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1132,7 +1132,7 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, Args: option_generator (callable or list): A list of strings indicating the options, or a callable - that is called without any arguments to produce such a list. + that is called as option_generator(caller) to produce such a list. select (callable, option): Will be called as select(caller, menuchoice) where menuchoice is the chosen option as a string. Should return the target node to goto after this selection. Note that if this is not given, the decorated node must itself @@ -1217,14 +1217,22 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, def _list_node(caller, raw_string, **kwargs): mode = kwargs.get("list_mode", None) - option_list = option_generator() if callable(option_generator) else option_generator + option_list = option_generator(caller) if callable(option_generator) else option_generator - nall_options = len(option_list) - pages = [option_list[ind:ind + pagesize] for ind in range(0, nall_options, pagesize)] - npages = len(pages) + npages = 0 + page_index = 0 + page = None + options = [] - page_index = max(0, min(npages - 1, kwargs.get("optionpage_index", 0))) - page = pages[page_index] + if option_list: + nall_options = len(option_list) + pages = [option_list[ind:ind + pagesize] for ind in range(0, nall_options, pagesize)] + npages = len(pages) + + page_index = max(0, min(npages - 1, kwargs.get("optionpage_index", 0))) + page = pages[page_index] + + text = "" entry = None extra_text = None @@ -1233,19 +1241,19 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, mode, entry = _input_parser(caller, raw_string, **{"available_choices": page}) - if examine and mode: # == "look": + if examine and mode: # == "look": # look mode - we are examining a given entry try: text = examine(caller, entry) except Exception: logger.log_trace() text = "|rCould not view." - options = [{"key": ("|wb|Wack|n", "b"), - "goto": (lambda caller: None, - {"optionpage_index": page_index})}, - {"key": "_default", - "goto": (lambda caller: None, - {"optionpage_index": page_index})}] + options.extend([{"key": ("|wb|Wack|n", "b"), + "goto": (lambda caller: None, + {"optionpage_index": page_index})}, + {"key": "_default", + "goto": (lambda caller: None, + {"optionpage_index": page_index})}]) return text, options # if edit and mode == "edit": @@ -1263,9 +1271,9 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, # dynamic, multi-page option list. Each selection leads to the `select` # callback being called with a result from the available choices - options = [{"desc": opt, - "goto": (_select_parser, - {"available_choices": page})} for opt in page] + options.extend([{"desc": opt, + "goto": (_select_parser, + {"available_choices": page})} for opt in page]) if add: # We have a processor to handle adding a new entry. Re-run this node diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index eaab84b202..ebf3ce5d67 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -916,7 +916,7 @@ def node_meta_key(caller): return text, options -def _all_prototypes(): +def _all_prototypes(caller): return [mproto.key for mproto in search_prototype()] @@ -949,7 +949,7 @@ def node_prototype(caller): return text, options -def _all_typeclasses(): +def _all_typeclasses(caller): return list(sorted(get_all_typeclasses().keys())) # return list(sorted(get_all_typeclasses(parent="evennia.objects.objects.DefaultObject").keys())) @@ -1060,24 +1060,17 @@ def node_attrs(caller): return text, options -def node_tags(caller): +def _caller_tags(caller): metaprot = _get_menu_metaprot(caller) prot = metaprot.prototype tags = prot.get("tags") + return tags - text = ["Set the prototype's |yTags|n. Separate multiple tags with commas. " - "Will retain case sensitivity."] - if tags: - text.append("Current tags are '|y{tags}|n'.".format(tags=tags)) - else: - text.append("No tags are set.") - text = "\n\n".join(text) + +@list_node(_caller_tags) +def node_tags(caller): + text = "Set the prototype's |yTags|n." options = _wizard_options("tags", "attrs", "locks") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="tags", - processor=lambda s: [part.strip() for part in s.split(",")], - next_node="node_locks"))}) return text, options @@ -1204,6 +1197,7 @@ def node_meta_desc(caller): return text, options + def node_meta_tags(caller): metaprot = _get_menu_metaprot(caller) text = ["|wMeta-Tags|n can be used to classify and find prototypes. Tags are case-insensitive. " From 2a135f16da9e6f27b6c0a4bc84162234cba138b3 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 2 Apr 2018 23:35:35 +0200 Subject: [PATCH 277/466] Progress on expanding list_node with edit/add instead --- evennia/utils/evmenu.py | 88 +++++++++++++++++++++------------------- evennia/utils/spawner.py | 24 ++++++++++- 2 files changed, 69 insertions(+), 43 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 6df9142bbb..c9f31b4688 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1135,17 +1135,15 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, that is called as option_generator(caller) to produce such a list. select (callable, option): Will be called as select(caller, menuchoice) where menuchoice is the chosen option as a string. Should return the target node to - goto after this selection. Note that if this is not given, the decorated node must itself - provide a way to continue from the node! + goto after this selection. Note that if this is not given, the decorated node must + itself provide a way to continue from the node! examine (callable, optional): If given, allows for examining options in detail. Will be called with examine(caller, menuchoice) and should return a text string to display in-place in the node. - edit (callable, optional): If given, this callable will be called as edit(caller, menuchoice). - It should return the node-key to a node decorated with the `edit_node` decorator. The - menuchoice will automatically be stored on the menutree as `list_node_edit`. - add (tuple, optional): If given, this callable will be called as add(caller, menuchoice). - It should return the node-key to a node decorated with the `edit_node` decorator. The - menuchoice will automatically be stored on the menutree as `list_node_add`. + edit (callable, optional): If given, this callable will be called as edit(caller, + menuchoice, **kwargs) and should return a complete (text, options) tuple (like a node). + add (callable optional): If given, this callable will be called as add(caller, menuchoice, + **kwargs) and should return a complete (text, options) tuple (like a node). pagesize (int): How many options to show per page. @@ -1189,25 +1187,28 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, """ available_choices = kwargs.get("available_choices", []) - match = re.search(r"(^[a-zA-Z]*)\s*([0-9]*)$", raw_string) - cmd, number = match.groups() + match = re.search(r"(^[a-zA-Z]*)\s*(.*?)$", raw_string) + cmd, args = match.groups() mode, selection = None, None + cmd = cmd.lower().strip() - if number: - number = int(number) - 1 - cmd = cmd.lower().strip() - if cmd.startswith("e") or cmd.startswith("a") and edit: - mode = "edit" - elif examine: - mode = "examine" - + if args: try: - selection = available_choices[number] - except IndexError: - caller.msg("|rInvalid index") - mode = None - else: - caller.msg("|rMust supply a number.") + number = int(args) - 1 + except ValueError: + if cmd.startswith("a") and add: + mode = "add" + selection = args + else: + if cmd.startswith("e") and edit: + mode = "edit" + elif examine: + mode = "look" + try: + selection = available_choices[number] + except IndexError: + caller.msg("|rInvalid index") + mode = None return mode, selection @@ -1233,18 +1234,18 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, page = pages[page_index] text = "" - entry = None + selection = None extra_text = None if mode == "arbitrary": # freeform input, we must parse it for the allowed commands (look/edit) - mode, entry = _input_parser(caller, raw_string, - **{"available_choices": page}) + mode, selection = _input_parser(caller, raw_string, + **{"available_choices": page}) - if examine and mode: # == "look": + if examine and mode == "look": # look mode - we are examining a given entry try: - text = examine(caller, entry) + text = examine(caller, selection) except Exception: logger.log_trace() text = "|rCould not view." @@ -1256,15 +1257,25 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, {"optionpage_index": page_index})}]) return text, options - # if edit and mode == "edit": - # pass - # elif add and mode == "add": - # # add mode - we are adding a new entry - # pass + elif add and mode == 'add': + # add mode - the selection is the new value + try: + text, options = add(caller, selection, **kwargs) + except Exception: + logger.log_trace() + text = "|rCould not add." + return text, options + + elif edit and mode == 'edit': + try: + text, options = edit(caller, selection, **kwargs) + except Exception: + logger.log_trace() + text = "|Could not edit." + return text, options else: # normal mode - list - pass if select: # We have a processor to handle selecting an entry @@ -1275,13 +1286,6 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, "goto": (_select_parser, {"available_choices": page})} for opt in page]) - if add: - # We have a processor to handle adding a new entry. Re-run this node - # in the 'add' mode - options.append({"key": ("|wadd|Wdd new|n", "a"), - "goto": (lambda caller: None, - {"optionpage_index": page_index, - "list_mode": "add"})}) if npages > 1: # if the goto callable returns None, the same node is rerun, and # kwargs not used by the callable are passed on to the node. This diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index ebf3ce5d67..2fbdbaed21 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -1066,8 +1066,30 @@ def _caller_tags(caller): tags = prot.get("tags") return tags +def _add_tags(caller, tag, **kwargs): + tag = tag.strip().lower() + metaprot = _get_menu_metaprot(caller) + tags = metaprot.tags + if tags: + if tag not in tags: + tags.append(tag) + else: + tags = [tag] + metaprot.tags = tags + text = kwargs.get("text") + if not text: + text = "Added tag {}. (return to continue)".format(tag) + options = {"key": "_default", + "goto": lambda caller: None} + return text, options -@list_node(_caller_tags) + +def _edit_tag(caller, tag, **kwargs): + tag = tag.strip().lower() + metaprot = _get_menu_metaprot(caller) + #TODO change in evmenu so one can do e 3 right away, parse & store value in kwarg + +@list_node(_caller_tags, edit=_edit_tags) def node_tags(caller): text = "Set the prototype's |yTags|n." options = _wizard_options("tags", "attrs", "locks") From dc7cd2e69ab18afe792de1d6ab3eba98003c5433 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 8 Apr 2018 13:21:51 +0200 Subject: [PATCH 278/466] Unworking commit for stashing --- evennia/utils/evmenu.py | 32 +++++++++++++++++--------------- evennia/utils/spawner.py | 1 - 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index c9f31b4688..2cb282a641 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1185,32 +1185,34 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, which processors are available. """ - + mode, selection, new_value = None, None, None available_choices = kwargs.get("available_choices", []) - match = re.search(r"(^[a-zA-Z]*)\s*(.*?)$", raw_string) - cmd, args = match.groups() - mode, selection = None, None - cmd = cmd.lower().strip() - if args: + cmd, args = re.search(r"(^[a-zA-Z]*)\s*(.*?)$", raw_string).groups() + + cmd = cmd.lower().strip() + if cmd.startswith('a') and add: + mode = "add" + new_value = args + else: + selection, new_value = re.search(r"(^[0-9]*)\s*(.*?)$", args).groups() try: - number = int(args) - 1 + selection = int(selection) - 1 except ValueError: - if cmd.startswith("a") and add: - mode = "add" - selection = args + caller.msg("|rInvalid input|n") else: + # edits are on the form 'edit if cmd.startswith("e") and edit: mode = "edit" elif examine: mode = "look" try: - selection = available_choices[number] + selection = available_choices[selection] except IndexError: - caller.msg("|rInvalid index") + caller.msg("|rInvalid index|n") mode = None - return mode, selection + return mode, selection, new_value def _relay_to_edit_or_add(caller, raw_string, **kwargs): pass @@ -1239,8 +1241,8 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, if mode == "arbitrary": # freeform input, we must parse it for the allowed commands (look/edit) - mode, selection = _input_parser(caller, raw_string, - **{"available_choices": page}) + mode, selection, new_value = _input_parser(caller, raw_string, + **{"available_choices": page}) if examine and mode == "look": # look mode - we are examining a given entry diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 2fbdbaed21..c4f732ebbb 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -1219,7 +1219,6 @@ def node_meta_desc(caller): return text, options - def node_meta_tags(caller): metaprot = _get_menu_metaprot(caller) text = ["|wMeta-Tags|n can be used to classify and find prototypes. Tags are case-insensitive. " From 9205fcced8a7e413fb8c6bbe555b76585ca4a46a Mon Sep 17 00:00:00 2001 From: Brenden Tuck Date: Sun, 8 Apr 2018 12:33:38 -0400 Subject: [PATCH 279/466] Added an undo button for multi-level undo of splits --- .../static/webclient/css/webclient.css | 13 ++++ .../static/webclient/js/splithandler.js | 68 ++++++++++++++++++- .../static/webclient/js/webclient_gui.js | 5 +- .../templates/webclient/webclient.html | 1 + 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/evennia/web/webclient/static/webclient/css/webclient.css b/evennia/web/webclient/static/webclient/css/webclient.css index 75dd91ce2a..7a33cfa207 100644 --- a/evennia/web/webclient/static/webclient/css/webclient.css +++ b/evennia/web/webclient/static/webclient/css/webclient.css @@ -155,6 +155,19 @@ div {margin:0px;} cursor: pointer; } +#undobutton { + width: 2rem; + font-size: 2rem; + color: #a6a6a6; + background-color: transparent; + border: 0px; +} + +#undobutton:hover { + color: white; + cursor: pointer; +} + .button { width: fit-content; padding: 1em; diff --git a/evennia/web/webclient/static/webclient/js/splithandler.js b/evennia/web/webclient/static/webclient/js/splithandler.js index aa6ea4364a..81210df854 100644 --- a/evennia/web/webclient/static/webclient/js/splithandler.js +++ b/evennia/web/webclient/static/webclient/js/splithandler.js @@ -1,6 +1,7 @@ // Use split.js to create a basic ui var SplitHandler = (function () { var split_panes = {}; + var backout_list = new Array; var set_pane_types = function(splitpane, types) { split_panes[splitpane]['types'] = types; @@ -26,7 +27,8 @@ var SplitHandler = (function () { first_div.append( first_sub ); second_div.append( second_sub ); - // update the split_panes array to remove this pane name + // update the split_panes array to remove this pane name, but store it for the backout stack + var backout_settings = split_panes[splitpane]; delete( split_panes[splitpane] ); // now vaporize the current split_N-sub placeholder and create two new panes. @@ -45,6 +47,69 @@ var SplitHandler = (function () { // store our new split sub-divs for future splits/uses by the main UI. split_panes[pane_name1] = { 'types': [], 'update_method': update_method1 }; split_panes[pane_name2] = { 'types': [], 'update_method': update_method2 }; + + // add our new split to the backout stack + backout_list.push( {'pane1': pane_name1, 'pane2': pane_name2, 'undo': backout_settings} ); + } + + + var undo_split = function() { + // pop off the last split pair + var back = backout_list.pop(); + if( !back ) { + return; + } + + // Collect all the divs/subs in play + var pane1 = back['pane1']; + var pane2 = back['pane2']; + var pane1_sub = $('#'+pane1+'-sub'); + var pane2_sub = $('#'+pane2+'-sub'); + var pane1_parent = $('#'+pane1).parent(); + var pane2_parent = $('#'+pane2).parent(); + + if( pane1_parent.attr('id') != pane2_parent.attr('id') ) { + // sanity check failed...somebody did something weird...bail out + console.log( pane1 ); + console.log( pane2 ); + console.log( pane1_parent ); + console.log( pane2_parent ); + return; + } + + // create a new sub-pane in the panes parent + var parent_sub = $( '
' ) + + // check to see if the special #messagewindow is in either of our sub-panes. + var msgwindow = pane1_sub.find('#messagewindow') + if( !msgwindow ) { + //didn't find it in pane 1, try pane 2 + msgwindow = pane2_sub.find('#messagewindow') + } + if( msgwindow ) { + // It is, so collect all contents into it instead of our parent_sub div + // then move it to parent sub div, this allows future #messagewindow divs to flow properly + msgwindow.append( pane1_sub.contents() ); + msgwindow.append( pane2_sub.contents() ); + parent_sub.append( msgwindow ); + } else { + //didn't find it, so move the contents of the two panes' sub-panes into the new sub-pane + parent_sub.append( pane1_sub.contents() ); + parent_sub.append( pane2_sub.contents() ); + } + + // clear the parent + pane1_parent.empty(); + + // add the new sub-pane back to the parent div + pane1_parent.append(parent_sub); + + // pull the sub-div's from split_panes + delete split_panes[pane1]; + delete split_panes[pane2]; + + // add our parent pane back into the split_panes list for future splitting + split_panes[pane1_parent.attr('id')] = back['undo']; } @@ -75,5 +140,6 @@ var SplitHandler = (function () { set_pane_types: set_pane_types, dynamic_split: dynamic_split, split_panes: split_panes, + undo_split: undo_split, } })(); diff --git a/evennia/web/webclient/static/webclient/js/webclient_gui.js b/evennia/web/webclient/static/webclient/js/webclient_gui.js index b975ae7044..8929a7529c 100644 --- a/evennia/web/webclient/static/webclient/js/webclient_gui.js +++ b/evennia/web/webclient/static/webclient/js/webclient_gui.js @@ -15,7 +15,7 @@ (function () { "use strict" -var num_splits = 0; +var num_splits = 0; //unique id counter for default split-panel names var options = {}; @@ -544,9 +544,12 @@ $(document).ready(function() { SplitHandler.init(); $("#splitbutton").bind("click", onSplitDialog); $("#panebutton").bind("click", onPaneControlDialog); + $("#undobutton").bind("click", SplitHandler.undo_split); + $("#optionsbutton").hide(); } else { $("#splitbutton").hide(); $("#panebutton").hide(); + $("#undobutton").hide(); } if ("Notification" in window) { diff --git a/evennia/web/webclient/templates/webclient/webclient.html b/evennia/web/webclient/templates/webclient/webclient.html index b750257048..74bef631cf 100644 --- a/evennia/web/webclient/templates/webclient/webclient.html +++ b/evennia/web/webclient/templates/webclient/webclient.html @@ -12,6 +12,7 @@ +
From c340c08adb1e53d9d58ac18111ff812d2137b525 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 9 Apr 2018 23:13:54 +0200 Subject: [PATCH 280/466] Add functioning, if primitive edit/add to decorator --- evennia/utils/evmenu.py | 41 ++++++++++++++++++++-------------------- evennia/utils/spawner.py | 31 +++++++++++++++++++++++------- 2 files changed, 45 insertions(+), 27 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 2cb282a641..edbf64755b 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1170,10 +1170,11 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, except Exception: caller.msg("|rInvalid choice.|n") else: - try: - return select(caller, selection) - except Exception: - logger.log_trace() + if select: + try: + return select(caller, selection) + except Exception: + logger.log_trace() return None def _input_parser(caller, raw_string, **kwargs): @@ -1185,7 +1186,7 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, which processors are available. """ - mode, selection, new_value = None, None, None + mode, selection, args = None, None, None available_choices = kwargs.get("available_choices", []) cmd, args = re.search(r"(^[a-zA-Z]*)\s*(.*?)$", raw_string).groups() @@ -1193,13 +1194,12 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, cmd = cmd.lower().strip() if cmd.startswith('a') and add: mode = "add" - new_value = args else: - selection, new_value = re.search(r"(^[0-9]*)\s*(.*?)$", args).groups() + selection, args = re.search(r"(^[0-9]*)\s*(.*?)$", args).groups() try: selection = int(selection) - 1 except ValueError: - caller.msg("|rInvalid input|n") + mode = "look" else: # edits are on the form 'edit if cmd.startswith("e") and edit: @@ -1212,7 +1212,7 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, caller.msg("|rInvalid index|n") mode = None - return mode, selection, new_value + return mode, selection, args def _relay_to_edit_or_add(caller, raw_string, **kwargs): pass @@ -1222,9 +1222,11 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, mode = kwargs.get("list_mode", None) option_list = option_generator(caller) if callable(option_generator) else option_generator + print("option_list: {}, {}".format(option_list, mode)) + npages = 0 page_index = 0 - page = None + page = [] options = [] if option_list: @@ -1241,7 +1243,7 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, if mode == "arbitrary": # freeform input, we must parse it for the allowed commands (look/edit) - mode, selection, new_value = _input_parser(caller, raw_string, + mode, selection, args = _input_parser(caller, raw_string, **{"available_choices": page}) if examine and mode == "look": @@ -1262,7 +1264,7 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, elif add and mode == 'add': # add mode - the selection is the new value try: - text, options = add(caller, selection, **kwargs) + text, options = add(caller, args) except Exception: logger.log_trace() text = "|rCould not add." @@ -1270,7 +1272,7 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, elif edit and mode == 'edit': try: - text, options = edit(caller, selection, **kwargs) + text, options = edit(caller, selection, args) except Exception: logger.log_trace() text = "|Could not edit." @@ -1279,14 +1281,13 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, else: # normal mode - list - if select: - # We have a processor to handle selecting an entry + # We have a processor to handle selecting an entry - # dynamic, multi-page option list. Each selection leads to the `select` - # callback being called with a result from the available choices - options.extend([{"desc": opt, - "goto": (_select_parser, - {"available_choices": page})} for opt in page]) + # dynamic, multi-page option list. Each selection leads to the `select` + # callback being called with a result from the available choices + options.extend([{"desc": opt, + "goto": (_select_parser, + {"available_choices": page})} for opt in page]) if npages > 1: # if the goto callable returns None, the same node is rerun, and diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index c4f732ebbb..d5750c1629 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -1066,16 +1066,19 @@ def _caller_tags(caller): tags = prot.get("tags") return tags -def _add_tags(caller, tag, **kwargs): + +def _add_tag(caller, tag, **kwargs): tag = tag.strip().lower() metaprot = _get_menu_metaprot(caller) - tags = metaprot.tags + prot = metaprot.prototype + tags = prot.get('tags', []) if tags: if tag not in tags: tags.append(tag) else: tags = [tag] - metaprot.tags = tags + prot['tags'] = tags + _set_menu_metaprot(caller, "prototype", prot) text = kwargs.get("text") if not text: text = "Added tag {}. (return to continue)".format(tag) @@ -1084,12 +1087,26 @@ def _add_tags(caller, tag, **kwargs): return text, options -def _edit_tag(caller, tag, **kwargs): - tag = tag.strip().lower() +def _edit_tag(caller, old_tag, new_tag, **kwargs): metaprot = _get_menu_metaprot(caller) - #TODO change in evmenu so one can do e 3 right away, parse & store value in kwarg + prototype = metaprot.prototype + tags = prototype.get('tags', []) -@list_node(_caller_tags, edit=_edit_tags) + old_tag = old_tag.strip().lower() + new_tag = new_tag.strip().lower() + tags[tags.index(old_tag)] = new_tag + prototype['tags'] = tags + _set_menu_metaprot(caller, 'prototype', prototype) + + text = kwargs.get('text') + if not text: + text = "Changed tag {} to {}.".format(old_tag, new_tag) + options = {"key": "_default", + "goto": lambda caller: None} + return text, options + + +@list_node(_caller_tags, edit=_edit_tag, add=_add_tag) def node_tags(caller): text = "Set the prototype's |yTags|n." options = _wizard_options("tags", "attrs", "locks") From a5e90de43e2ad7d2cc6a9a790581318a7dd66ab8 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 15 Apr 2018 00:03:36 +0200 Subject: [PATCH 281/466] Simplify list_node decorator --- evennia/utils/evmenu.py | 377 +++++++++++----------------------------- 1 file changed, 102 insertions(+), 275 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index edbf64755b..60373e3a5a 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -987,137 +987,6 @@ class EvMenu(object): return separator1 + "|n" + nodetext + "|n" + separator2 + "|n" + optionstext -# ----------------------------------------------------------- -# -# Edit node (decorator turning a node into an editing -# point for a given resource -# -# ----------------------------------------------------------- - -def edit_node(edit_text, add_text, edit_callback, add_callback, get_choices=None): - """ - Decorator for turning an EvMenu node into an editing - page. Will add new options, prepending those options - added in the node. - - Args: - edit_text (str or callable): Will be used as text for the edit node. If - callable, it will be called as edittext(selection) - and should return the node text for the edit-node, probably listing - the current value of all editable propnames, if possible. - add_text (str) or callable: Gives text for node in add-mode. If a callable, - called as add_text() and should return the text for the node. - edit_callback (callable): Will be called as edit_callback(editable, raw_string) - and should return a boolean True/False if the setting of the property - succeeded or not. The value will always be a string and should be - converted as needed. - add_callback (callable): Will be called as add_callback(raw_string) and - should return a boolean True/False if the addition succeded. - - get_choices (callable): Produce the available editable choices. If this - is not given, the `goto` callable must have been provided with the - kwarg `available_choices` by the decorated node. - - """ - - def decorator(func): - - def _setter_goto(caller, raw_string, **kwargs): - editable = kwargs.get("editable") - mode = kwargs.get("edit_node_mode") - try: - if mode == 'edit': - is_ok = edit_callback(editable, raw_string) - else: - is_ok = add_callback(raw_string) - except Exception: - logger.log_trace() - if not is_ok: - caller.msg("|rValue could not be set.") - return None - - def _patch_goto(caller, raw_string, **kwargs): - - # parse incoming string to figure out if there is a match to edit/add - match = re.search(r"(^[a-zA-Z]*)\s*([0-9]*)$", raw_string) - cmd, number = match.groups() - edit_mode = None - available_choices = None - selection = None - - if get_choices: - available_choices = make_iter(get_choices(caller, raw_string, **kwargs)) - if not available_choices: - available_choices = kwargs.get("available_choices", []) - - if available_choices and cmd.startswith("e"): - try: - index = int(cmd) - 1 - selection = available_choices[index] - edit_mode = 'edit' - except (IndexError, TypeError): - caller.msg("|rNot a valid 'edit' command.") - - if cmd.startswith("a") and not number: - # add mode - edit_mode = "add" - - if edit_mode: - # replace with edit text/options - text = edit_text(selection) if edit_mode == "edit" else add_text() - options = ({"key": "_default", - "goto": (_setter_goto, - {"selection": selection, - "edit_node_mode": edit_mode})}) - return text, options - - # no matches - pass through to the original decorated goto instruction - - decorated_opt = kwargs.get("decorated_opt") - - if decorated_opt: - # use EvMenu's parser to get the goto/goto-kwargs out of - # the decorated option structure - dec_goto, dec_goto_kwargs, _, _ = \ - caller.ndb._menutree.extract_goto_exec("edit-node", decorated_opt) - - if callable(dec_goto): - try: - return dec_goto(caller, raw_string, - **{dec_goto_kwargs if dec_goto_kwargs else {}}) - except Exception: - caller.msg("|rThere was an error in the edit node.") - logger.log_trace() - return None - - def _edit_node(caller, raw_string, **kwargs): - - text, options = func(caller, raw_string, **kwargs) - - if options: - # find eventual _default in options and patch it with a handler for - # catching editing - - decorated_opt = None - iopt = 0 - for iopt, optdict in enumerate(options): - if optdict.get('key') == "_default": - decorated_opt = optdict - break - - if decorated_opt: - # inject our wrapper over the original goto instruction for the - # _default action (save the original) - options[iopt]["goto"] = (_patch_goto, - {"decorated_opt": decorated_opt}) - - return text, options - - return _edit_node - return decorator - - - # ----------------------------------------------------------- # # List node (decorator turning a node into a list with @@ -1125,7 +994,7 @@ def edit_node(edit_text, add_text, edit_callback, add_callback, get_choices=None # # ----------------------------------------------------------- -def list_node(option_generator, select=None, examine=None, edit=None, add=None, pagesize=10): +def list_node(option_generator, select=None, pagesize=10): """ Decorator for making an EvMenu node into a multi-page list node. Will add new options, prepending those options added in the node. @@ -1135,25 +1004,22 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, that is called as option_generator(caller) to produce such a list. select (callable, option): Will be called as select(caller, menuchoice) where menuchoice is the chosen option as a string. Should return the target node to - goto after this selection. Note that if this is not given, the decorated node must - itself provide a way to continue from the node! - examine (callable, optional): If given, allows for examining options in detail. Will - be called with examine(caller, menuchoice) and should return a text string to - display in-place in the node. - edit (callable, optional): If given, this callable will be called as edit(caller, - menuchoice, **kwargs) and should return a complete (text, options) tuple (like a node). - add (callable optional): If given, this callable will be called as add(caller, menuchoice, - **kwargs) and should return a complete (text, options) tuple (like a node). - + goto after this selection (or None to repeat the list-node). Note that if this is not + given, the decorated node must itself provide a way to continue from the node! pagesize (int): How many options to show per page. Example: - @list_node(['foo', 'bar'], examine, select) + @list_node(['foo', 'bar'], select) def node_index(caller): text = "describing the list" return text, [] + Notes: + All normal `goto` or `exec` callables returned from the decorated nodes will, if they accept + **kwargs, get a new kwarg 'available_choices' injected. These are the ordered list of named + options (descs) visible on the current node page. + """ def decorator(func): @@ -1177,53 +1043,44 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, logger.log_trace() return None - def _input_parser(caller, raw_string, **kwargs): - """ - Parse which input was given, select from option_list. - - Understood input is [cmd], where [cmd] is either empty (`select`) - or one of the supported actions `look`, `edit` or `add` depending on - which processors are available. - - """ - mode, selection, args = None, None, None - available_choices = kwargs.get("available_choices", []) - - cmd, args = re.search(r"(^[a-zA-Z]*)\s*(.*?)$", raw_string).groups() - - cmd = cmd.lower().strip() - if cmd.startswith('a') and add: - mode = "add" - else: - selection, args = re.search(r"(^[0-9]*)\s*(.*?)$", args).groups() - try: - selection = int(selection) - 1 - except ValueError: - mode = "look" - else: - # edits are on the form 'edit - if cmd.startswith("e") and edit: - mode = "edit" - elif examine: - mode = "look" - try: - selection = available_choices[selection] - except IndexError: - caller.msg("|rInvalid index|n") - mode = None - - return mode, selection, args - - def _relay_to_edit_or_add(caller, raw_string, **kwargs): - pass +# def _input_parser(caller, raw_string, **kwargs): +# """ +# Parse which input was given, select from option_list. +# +# +# """ +# mode, selection, args = None, None, None +# available_choices = kwargs.get("available_choices", []) +# +# cmd, args = re.search(r"(^[a-zA-Z]*)\s*(.*?)$", raw_string).groups() +# +# cmd = cmd.lower().strip() +# if cmd.startswith('a') and add: +# mode = "add" +# else: +# selection, args = re.search(r"(^[0-9]*)\s*(.*?)$", args).groups() +# try: +# selection = int(selection) - 1 +# except ValueError: +# mode = "look" +# else: +# # edits are on the form 'edit +# if cmd.startswith("e") and edit: +# mode = "edit" +# elif examine: +# mode = "look" +# try: +# selection = available_choices[selection] +# except IndexError: +# caller.msg("|rInvalid index|n") +# mode = None +# +# return mode, selection, args def _list_node(caller, raw_string, **kwargs): - mode = kwargs.get("list_mode", None) option_list = option_generator(caller) if callable(option_generator) else option_generator - print("option_list: {}, {}".format(option_list, mode)) - npages = 0 page_index = 0 page = [] @@ -1231,113 +1088,83 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, if option_list: nall_options = len(option_list) - pages = [option_list[ind:ind + pagesize] for ind in range(0, nall_options, pagesize)] + pages = [option_list[ind:ind + pagesize] + for ind in range(0, nall_options, pagesize)] npages = len(pages) page_index = max(0, min(npages - 1, kwargs.get("optionpage_index", 0))) page = pages[page_index] text = "" - selection = None extra_text = None - if mode == "arbitrary": - # freeform input, we must parse it for the allowed commands (look/edit) - mode, selection, args = _input_parser(caller, raw_string, - **{"available_choices": page}) + # dynamic, multi-page option list. Each selection leads to the `select` + # callback being called with a result from the available choices + options.extend([{"desc": opt, + "goto": (_select_parser, + {"available_choices": page})} for opt in page]) - if examine and mode == "look": - # look mode - we are examining a given entry - try: - text = examine(caller, selection) - except Exception: - logger.log_trace() - text = "|rCould not view." - options.extend([{"key": ("|wb|Wack|n", "b"), - "goto": (lambda caller: None, - {"optionpage_index": page_index})}, - {"key": "_default", - "goto": (lambda caller: None, - {"optionpage_index": page_index})}]) - return text, options - - elif add and mode == 'add': - # add mode - the selection is the new value - try: - text, options = add(caller, args) - except Exception: - logger.log_trace() - text = "|rCould not add." - return text, options - - elif edit and mode == 'edit': - try: - text, options = edit(caller, selection, args) - except Exception: - logger.log_trace() - text = "|Could not edit." - return text, options - - else: - # normal mode - list - - # We have a processor to handle selecting an entry - - # dynamic, multi-page option list. Each selection leads to the `select` - # callback being called with a result from the available choices - options.extend([{"desc": opt, - "goto": (_select_parser, - {"available_choices": page})} for opt in page]) - - if npages > 1: - # if the goto callable returns None, the same node is rerun, and - # kwargs not used by the callable are passed on to the node. This - # allows us to call ourselves over and over, using different kwargs. - options.append({"key": ("|Wcurrent|n", "c"), - "desc": "|W({}/{})|n".format(page_index + 1, npages), - "goto": (lambda caller: None, - {"optionpage_index": page_index})}) - if page_index > 0: - options.append({"key": ("|wp|Wrevious page|n", "p"), - "goto": (lambda caller: None, - {"optionpage_index": page_index - 1})}) - if page_index < npages - 1: - options.append({"key": ("|wn|Wext page|n", "n"), - "goto": (lambda caller: None, - {"optionpage_index": page_index + 1})}) - - # this catches arbitrary input and reruns this node with the 'arbitrary' mode - # this could mean input on the form 'look ' or 'edit ' - options.append({"key": "_default", + if npages > 1: + # if the goto callable returns None, the same node is rerun, and + # kwargs not used by the callable are passed on to the node. This + # allows us to call ourselves over and over, using different kwargs. + options.append({"key": ("|Wcurrent|n", "c"), + "desc": "|W({}/{})|n".format(page_index + 1, npages), "goto": (lambda caller: None, - {"optionpage_index": page_index, - "available_choices": page, - "list_mode": "arbitrary"})}) + {"optionpage_index": page_index})}) + if page_index > 0: + options.append({"key": ("|wp|Wrevious page|n", "p"), + "goto": (lambda caller: None, + {"optionpage_index": page_index - 1})}) + if page_index < npages - 1: + options.append({"key": ("|wn|Wext page|n", "n"), + "goto": (lambda caller: None, + {"optionpage_index": page_index + 1})}) - # add data from the decorated node + # add data from the decorated node - extra_options = [] + decorated_options = [] + try: + text, decorated_options = func(caller, raw_string) + except TypeError: try: - text, extra_options = func(caller, raw_string) - except TypeError: - try: - text, extra_options = func(caller) - except Exception: - raise + text, decorated_options = func(caller) except Exception: - logger.log_trace() - print("extra_options:", extra_options) + raise + except Exception: + logger.log_trace() + else: + if isinstance(decorated_options, {}): + decorated_options = [decorated_options] else: - if isinstance(extra_options, {}): - extra_options = [extra_options] - else: - extra_options = make_iter(extra_options) + decorated_options = make_iter(decorated_options) - options.extend(extra_options) - text = text + "\n\n" + extra_text if extra_text else text - text += "\n\n(Make a choice or enter 'look ' to examine an option closer)" + extra_options = [] + for eopt in decorated_options: + cback = ("goto" in eopt and "goto") or ("exec" in eopt and "exec") or None + if cback: + signature = eopt[cback] + if callable(signature): + # callable with no kwargs defined + eopt[cback] = (signature, {"available_choices": page}) + elif is_iter(signature): + if len(signature) > 1 and isinstance(signature[1], dict): + signature[1]["available_choices"] = page + eopt[cback] = signature + elif signature: + # a callable alone in a tuple (i.e. no previous kwargs) + eopt[cback] = (signature[0], {"available_choices": page}) + else: + # malformed input. + logger.log_err("EvMenu @list_node decorator found " + "malformed option to decorate: {}".format(eopt)) + extra_options.append(eopt) - return text, options + options.extend(extra_options) + text = text + "\n\n" + extra_text if extra_text else text + text += "\n\n(Make a choice or enter 'look ' to examine an option closer)" + + return text, options return _list_node return decorator From d3f63ef68990235cb67030fce3d48248636d6a79 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 15 Apr 2018 20:13:38 +0200 Subject: [PATCH 282/466] non-functioning spawner --- evennia/utils/spawner.py | 68 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index d5750c1629..acc8cb2457 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -105,6 +105,7 @@ prototype, override its name with an empty dict. from __future__ import print_function import copy +from ast import literal_eval from django.conf import settings from random import randint import evennia @@ -125,6 +126,11 @@ _MODULE_PROTOTYPES = {} _MODULE_PROTOTYPE_MODULES = {} _MENU_CROP_WIDTH = 15 +_MENU_ATTR_LITERAL_EVAL_ERROR = ( + "|rCritical Python syntax error in your value. Only primitive Python structures are allowed.\n" + "You also need to use correct Python syntax. Remember especially to put quotes around all " + "strings inside lists and dicts.|n") + class PermissionError(RuntimeError): pass @@ -933,7 +939,7 @@ def _prototype_select(caller, prototype): return ret -@list_node(_all_prototypes, _prototype_select, examine=_prototype_examine) +@list_node(_all_prototypes, select=_prototype_select, examine=_prototype_examine) def node_prototype(caller): metaprot = _get_menu_metaprot(caller) prot = metaprot.prototype @@ -1039,6 +1045,66 @@ def node_aliases(caller): return text, options +def _caller_attrs(caller): + metaprot = _get_menu_metaprot(caller) + attrs = metaprot.prototype.get("attrs", []) + return attrs + + +def _attrparse(caller, attr_string): + "attr is entering on the form 'attr = value'" + + if '=' in attr_string: + attrname, value = (part.strip() for part in attr_string.split('=', 1)) + attrname = attrname.lower() + if attrname: + try: + value = literal_eval(value) + except SyntaxError: + caller.msg(_MENU_ATTR_LITERAL_EVAL_ERROR) + else: + return attrname, value + else: + return None, None + + +def _add_attr(caller, attr_string, **kwargs): + attrname, value = _attrparse(caller, attr_string) + if attrname: + metaprot = _get_menu_metaprot(caller) + prot = metaprot.prototype + prot['attrs'][attrname] = value + _set_menu_metaprot(caller, "prototype", prot) + text = "Added" + else: + text = "Attribute must be given as 'attrname = ' where uses valid Python." + options = {"key": "_default", + "goto": lambda caller: None} + return text, options + + +def _edit_attr(caller, attrname, new_value, **kwargs): + attrname, value = _attrparse("{}={}".format(caller, attrname, new_value)) + if attrname: + metaprot = _get_menu_metaprot(caller) + prot = metaprot.prototype + prot['attrs'][attrname] = value + text = "Edited Attribute {} = {}".format(attrname, value) + else: + text = "Attribute value must be valid Python." + options = {"key": "_default", + "goto": lambda caller: None} + return text, options + + +def _examine_attr(caller, selection): + metaprot = _get_menu_metaprot(caller) + prot = metaprot.prototype + value = prot['attrs'][selection] + return "Attribute {} = {}".format(selection, value) + + +@list_node(_caller_attrs, edit=_edit_attr, add=_add_attr, examine=_examine_attr) def node_attrs(caller): metaprot = _get_menu_metaprot(caller) prot = metaprot.prototype From e01ffa1eafa9483f9a57c21a138b8609d589cb93 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 15 Apr 2018 22:06:15 +0200 Subject: [PATCH 283/466] Made code run without traceback; in future, use select action to enter edit node, separate add command to enter add mode --- evennia/utils/spawner.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index acc8cb2457..bbed685b5c 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -929,8 +929,9 @@ def _all_prototypes(caller): def _prototype_examine(caller, prototype_name): metaprot = search_prototype(key=prototype_name) if metaprot: - return metaproto_to_str(metaprot[0]) - return "Prototype not registered." + caller.msg(metaproto_to_str(metaprot[0])) + caller.msg("Prototype not registered.") + return None def _prototype_select(caller, prototype): @@ -939,7 +940,7 @@ def _prototype_select(caller, prototype): return ret -@list_node(_all_prototypes, select=_prototype_select, examine=_prototype_examine) +@list_node(_all_prototypes, _prototype_select) def node_prototype(caller): metaprot = _get_menu_metaprot(caller) prot = metaprot.prototype @@ -952,6 +953,9 @@ def node_prototype(caller): text.append("Parent prototype is not set") text = "\n\n".join(text) options = _wizard_options("prototype", "meta_key", "typeclass", color="|W") + options.append({"key": "_default", + "goto": _prototype_examine}) + return text, options @@ -978,7 +982,8 @@ def _typeclass_examine(caller, typeclass_path): typeclass_path=typeclass_path, docstring=docstr) else: txt = "This is typeclass |y{}|n.".format(typeclass) - return txt + caller.msg(txt) + return None def _typeclass_select(caller, typeclass): @@ -987,7 +992,7 @@ def _typeclass_select(caller, typeclass): return ret -@list_node(_all_typeclasses, _typeclass_select, examine=_typeclass_examine) +@list_node(_all_typeclasses, _typeclass_select) def node_typeclass(caller): metaprot = _get_menu_metaprot(caller) prot = metaprot.prototype @@ -1001,6 +1006,8 @@ def node_typeclass(caller): typeclass=settings.BASE_OBJECT_TYPECLASS)) text = "\n\n".join(text) options = _wizard_options("typeclass", "prototype", "key", color="|W") + options.append({"key": "_default", + "goto": _typeclass_examine}) return text, options @@ -1104,7 +1111,7 @@ def _examine_attr(caller, selection): return "Attribute {} = {}".format(selection, value) -@list_node(_caller_attrs, edit=_edit_attr, add=_add_attr, examine=_examine_attr) +@list_node(_caller_attrs) def node_attrs(caller): metaprot = _get_menu_metaprot(caller) prot = metaprot.prototype @@ -1172,7 +1179,7 @@ def _edit_tag(caller, old_tag, new_tag, **kwargs): return text, options -@list_node(_caller_tags, edit=_edit_tag, add=_add_tag) +@list_node(_caller_tags) def node_tags(caller): text = "Set the prototype's |yTags|n." options = _wizard_options("tags", "attrs", "locks") From 4f1a47e23e899a1b7b1ff3c155c37be6264823cf Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 16 Apr 2018 20:18:21 +0200 Subject: [PATCH 284/466] Add test_spawner --- evennia/utils/tests/test_spawner.py | 63 +++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 evennia/utils/tests/test_spawner.py diff --git a/evennia/utils/tests/test_spawner.py b/evennia/utils/tests/test_spawner.py new file mode 100644 index 0000000000..e29ee8c151 --- /dev/null +++ b/evennia/utils/tests/test_spawner.py @@ -0,0 +1,63 @@ +""" +Unit test for the spawner + +""" + +from evennia.utils.test_resources import EvenniaTest +from evennia.utils import spawner + + +class TestPrototypeStorage(EvenniaTest): + + def setUp(self): + super(TestPrototypeStorage, self).setUp() + self.prot1 = {"key": "testprototype"} + self.prot2 = {"key": "testprototype2"} + self.prot3 = {"key": "testprototype3"} + + def _get_metaproto( + self, key='testprototype', desc='testprototype', locks=['edit:id(6) or perm(Admin)', 'use:all()'], + tags=[], prototype={"key": "testprototype"}): + return spawner.build_metaproto(key, desc, locks, tags, prototype) + + def _to_metaproto(self, db_prototype): + return spawner.build_metaproto( + db_prototype.key, db_prototype.desc, db_prototype.locks.all(), + db_prototype.tags.get(category="db_prototype", return_list=True), + db_prototype.attributes.get("prototype")) + + def test_prototype_storage(self): + + prot = spawner.save_db_prototype(self.char1, "testprot", self.prot1, desc='testdesc0', tags=["foo"]) + + self.assertTrue(bool(prot)) + self.assertEqual(prot.db.prototype, self.prot1) + self.assertEqual(prot.desc, "testdesc0") + + prot = spawner.save_db_prototype(self.char1, "testprot", self.prot1, desc='testdesc', tags=["fooB"]) + self.assertEqual(prot.db.prototype, self.prot1) + self.assertEqual(prot.desc, "testdesc") + self.assertTrue(bool(prot.tags.get("fooB", "db_prototype"))) + + self.assertEqual(list(prot.__class__.objects.get_by_tag("foo", "db_prototype")), [prot]) + + prot2 = spawner.save_db_prototype(self.char1, "testprot2", self.prot2, desc='testdesc2b', tags=["foo"]) + self.assertEqual(list(prot.__class__.objects.get_by_tag("foo", "db_prototype")), [prot, prot2]) + + prot3 = spawner.save_db_prototype(self.char1, "testprot2", self.prot3, desc='testdesc2') + self.assertEqual(prot2.id, prot3.id) + self.assertEqual(list(prot.__class__.objects.get_by_tag("foo", "db_prototype")), [prot, prot2]) + + # returns DBPrototype + self.assertEqual(list(spawner.search_db_prototype("testprot")), [prot]) + + # returns metaprotos + prot = self._to_metaproto(prot) + prot3 = self._to_metaproto(prot3) + self.assertEqual(list(spawner.search_prototype("testprot")), [prot]) + self.assertEqual(list(spawner.search_prototype("testprot", return_meta=False)), [self.prot1]) + # partial match + self.assertEqual(list(spawner.search_prototype("prot")), [prot, prot3]) + self.assertEqual(list(spawner.search_prototype(tags="foo")), [prot, prot3]) + + self.assertTrue(str(unicode(spawner.list_prototypes(self.char1)))) From 6fd72f573bd5f5c6c988a98653afff744c0889fd Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 16 Apr 2018 20:34:52 +0200 Subject: [PATCH 285/466] Fix unittest --- evennia/commands/default/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 6847afe8c4..7f0ed17924 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -502,5 +502,5 @@ class TestUnconnectedCommand(CommandTest): expected = "## BEGIN INFO 1.1\nName: %s\nUptime: %s\nConnected: %d\nVersion: Evennia %s\n## END INFO" % ( settings.SERVERNAME, datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(), - SESSIONS.account_count(), utils.get_evennia_version()) + SESSIONS.account_count(), utils.get_evennia_version().replace("-","")) self.call(unloggedin.CmdUnconnectedInfo(), "", expected) From 0350b6c3c6228ff5b0a8f6781d1fcad276e30579 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 17 Apr 2018 22:49:01 +0200 Subject: [PATCH 286/466] style fix --- evennia/commands/default/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 7f0ed17924..b2d0f58870 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -502,5 +502,5 @@ class TestUnconnectedCommand(CommandTest): expected = "## BEGIN INFO 1.1\nName: %s\nUptime: %s\nConnected: %d\nVersion: Evennia %s\n## END INFO" % ( settings.SERVERNAME, datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(), - SESSIONS.account_count(), utils.get_evennia_version().replace("-","")) + SESSIONS.account_count(), utils.get_evennia_version().replace("-", "")) self.call(unloggedin.CmdUnconnectedInfo(), "", expected) From 9df0096640db786f2e377f12245eb9ff122f65aa Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 18 Apr 2018 07:07:03 +0200 Subject: [PATCH 287/466] Cleanup --- evennia/utils/evmenu.py | 40 ++-------------------------------------- 1 file changed, 2 insertions(+), 38 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 60373e3a5a..a2a92f5e34 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -166,7 +166,6 @@ evennia.utils.evmenu`. from __future__ import print_function import random from builtins import object, range -import re from textwrap import dedent from inspect import isfunction, getargspec @@ -1009,7 +1008,6 @@ def list_node(option_generator, select=None, pagesize=10): pagesize (int): How many options to show per page. Example: - @list_node(['foo', 'bar'], select) def node_index(caller): text = "describing the list" @@ -1043,43 +1041,10 @@ def list_node(option_generator, select=None, pagesize=10): logger.log_trace() return None -# def _input_parser(caller, raw_string, **kwargs): -# """ -# Parse which input was given, select from option_list. -# -# -# """ -# mode, selection, args = None, None, None -# available_choices = kwargs.get("available_choices", []) -# -# cmd, args = re.search(r"(^[a-zA-Z]*)\s*(.*?)$", raw_string).groups() -# -# cmd = cmd.lower().strip() -# if cmd.startswith('a') and add: -# mode = "add" -# else: -# selection, args = re.search(r"(^[0-9]*)\s*(.*?)$", args).groups() -# try: -# selection = int(selection) - 1 -# except ValueError: -# mode = "look" -# else: -# # edits are on the form 'edit -# if cmd.startswith("e") and edit: -# mode = "edit" -# elif examine: -# mode = "look" -# try: -# selection = available_choices[selection] -# except IndexError: -# caller.msg("|rInvalid index|n") -# mode = None -# -# return mode, selection, args - def _list_node(caller, raw_string, **kwargs): - option_list = option_generator(caller) if callable(option_generator) else option_generator + option_list = option_generator(caller) \ + if callable(option_generator) else option_generator npages = 0 page_index = 0 @@ -1162,7 +1127,6 @@ def list_node(option_generator, select=None, pagesize=10): options.extend(extra_options) text = text + "\n\n" + extra_text if extra_text else text - text += "\n\n(Make a choice or enter 'look ' to examine an option closer)" return text, options From c4d20c359b1de74a3d36664265d5eccafb0f70e5 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 18 Apr 2018 23:50:12 +0200 Subject: [PATCH 288/466] Refactor spawner to use prototype instead of metaprots --- evennia/utils/spawner.py | 454 +++++++++++++++++++-------------------- 1 file changed, 221 insertions(+), 233 deletions(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index bbed685b5c..0bce7addd8 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -122,6 +122,8 @@ from evennia.utils.ansi import strip_ansi _CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") +_PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") +_NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES _MODULE_PROTOTYPES = {} _MODULE_PROTOTYPE_MODULES = {} _MENU_CROP_WIDTH = 15 @@ -135,24 +137,24 @@ _MENU_ATTR_LITERAL_EVAL_ERROR = ( class PermissionError(RuntimeError): pass -# storage of meta info about the prototype -MetaProto = namedtuple('MetaProto', ['key', 'desc', 'locks', 'tags', 'prototype']) for mod in settings.PROTOTYPE_MODULES: # to remove a default prototype, override it with an empty dict. # internally we store as (key, desc, locks, tags, prototype_dict) - prots = [(key, prot) for key, prot in all_from_module(mod).items() + prots = [(prototype_key, prot) for prototype_key, prot in all_from_module(mod).items() if prot and isinstance(prot, dict)] - _MODULE_PROTOTYPES.update( - {key.lower(): MetaProto( - key.lower(), - prot['prototype_desc'] if 'prototype_desc' in prot else mod, - prot['prototype_lock'] if 'prototype_lock' in prot else "use:all()", - set(make_iter( - prot['prototype_tags']) if 'prototype_tags' in prot else ["base-prototype"]), - prot) - for key, prot in prots}) + # assign module path to each prototype_key for easy reference _MODULE_PROTOTYPE_MODULES.update({tup[0]: mod for tup in prots}) + # make sure the prototype contains all meta info + for prototype_key, prot in prots: + prot.update({ + "prototype_key": prototype_key.lower(), + "prototype_desc": prot['prototype_desc'] if 'prototype_desc' in prot else mod, + "prototype_locks": prot['prototype_locks'] if 'prototype_locks' in prot else "use:all()", + "prototype_tags": set(make_iter(prot['prototype_tags']) + if 'prototype_tags' in prot else ["base-prototype"])}) + _MODULE_PROTOTYPES.update(prot) + # Prototype storage mechanisms @@ -162,24 +164,11 @@ class DbPrototype(DefaultScript): This stores a single prototype """ def at_script_creation(self): - self.key = "empty prototype" - self.desc = "A prototype" + self.key = "empty prototype" # prototype_key + self.desc = "A prototype" # prototype_desc -def build_metaproto(key='', desc='', locks='', tags=None, prototype=None): - """ - Create a metaproto from combinant parts. - - """ - if locks: - locks = (";".join(locks) if is_iter(locks) else locks) - else: - locks = [] - prototype = dict(prototype) if prototype else {} - return MetaProto(key, desc, locks, tags, dict(prototype)) - - -def save_db_prototype(caller, key, prototype, desc="", tags=None, locks="", delete=False): +def save_db_prototype(caller, prototype, key=None, desc=None, tags=None, locks="", delete=False): """ Store a prototype persistently. @@ -187,13 +176,14 @@ def save_db_prototype(caller, key, prototype, desc="", tags=None, locks="", dele caller (Account or Object): Caller aiming to store prototype. At this point the caller should have permission to 'add' new prototypes, but to edit an existing prototype, the 'edit' lock must be passed on that prototype. - key (str): Name of prototype to store. prototype (dict): Prototype dict. - desc (str, optional): Description of prototype, to use in listing. + key (str): Name of prototype to store. Will be inserted as `prototype_key` in the prototype. + desc (str, optional): Description of prototype, to use in listing. Will be inserted + as `prototype_desc` in the prototype. tags (list, optional): Tag-strings to apply to prototype. These are always - applied with the 'db_prototype' category. + applied with the 'db_prototype' category. Will be inserted as `prototype_tags`. locks (str, optional): Locks to apply to this prototype. Used locks - are 'use' and 'edit' + are 'use' and 'edit'. Will be inserted as `prototype_locks` in the prototype. delete (bool, optional): Delete an existing prototype identified by 'key'. This requires `caller` to pass the 'edit' lock of the prototype. Returns: @@ -204,21 +194,40 @@ def save_db_prototype(caller, key, prototype, desc="", tags=None, locks="", dele """ - key_orig = key - key = key.lower() - locks = locks if locks else "use:all();edit:id({}) or perm(Admin)".format(caller.id) + key_orig = key or prototype.get('prototype_key', None) + if not key_orig: + caller.msg("This prototype requires a prototype_key.") + return False + key = str(key).lower() + + # we can't edit a prototype defined in a module + if key in _MODULE_PROTOTYPES: + mod = _MODULE_PROTOTYPE_MODULES.get(key, "N/A") + raise PermissionError("{} is a read-only prototype " + "(defined as code in {}).".format(key_orig, mod)) + + prototype['prototype_key'] = key + + if desc: + desc = prototype['prototype_desc'] = desc + else: + desc = prototype.get('prototype_desc', '') + + # set up locks and check they are on a valid form + locks = locks or prototype.get( + "prototype_locks", "use:all();edit:id({}) or perm(Admin)".format(caller.id)) + prototype['prototype_locks'] = locks is_valid, err = caller.locks.validate(locks) if not is_valid: caller.msg("Lock error: {}".format(err)) return False - tags = [(tag, "db_prototype") for tag in make_iter(tags)] - - if key in _MODULE_PROTOTYPES: - mod = _MODULE_PROTOTYPE_MODULES.get(key, "N/A") - raise PermissionError("{} is a read-only prototype " - "(defined as code in {}).".format(key_orig, mod)) + if tags: + tags = [(tag, "db_prototype") for tag in make_iter(tags)] + else: + tags = prototype.get('prototype_tags', []) + prototype['prototype_tags'] = tags stored_prototype = DbPrototype.objects.filter(db_key=key) @@ -269,7 +278,7 @@ def delete_db_prototype(caller, key): return save_db_prototype(caller, key, None, delete=True) -def search_db_prototype(key=None, tags=None, return_metaprotos=False): +def search_db_prototype(key=None, tags=None, return_queryset=False): """ Find persistent (database-stored) prototypes based on key and/or tags. @@ -278,13 +287,14 @@ def search_db_prototype(key=None, tags=None, return_metaprotos=False): tags (str or list): Tag key or keys to query for. These will always be applied with the 'db_protototype' tag category. - return_metaproto (bool): Return results as metaprotos. + return_queryset (bool): Return the database queryset. Return: - matches (queryset or list): All found DbPrototypes. If `return_metaprotos` - is set, return a list of MetaProtos. + matches (queryset or list): All found DbPrototypes. If `return_queryset` + is not set, this is a list of prototype dicts. Note: - This will not include read-only prototypes defined in modules. + This does not include read-only prototypes defined in modules; use + `search_module_prototype` for those. """ if tags: @@ -297,11 +307,9 @@ def search_db_prototype(key=None, tags=None, return_metaprotos=False): if key: # exact or partial match on key matches = matches.filter(db_key=key) or matches.filter(db_key__icontains=key) - if return_metaprotos: - return [build_metaproto(match.key, match.desc, match.locks.all(), - match.tags.get(category="db_prototype", return_list=True), - match.attributes.get("prototype")) - for match in matches] + if not return_queryset: + # return prototype + return [dbprot.attributes.get("prototype", {}) for dbprot in matches] return matches @@ -314,16 +322,16 @@ def search_module_prototype(key=None, tags=None): tags (str or list): Tag key to query for. Return: - matches (list): List of MetaProto tuples that includes - prototype metadata, + matches (list): List of prototypes matching the search criterion. """ matches = {} if tags: # use tags to limit selection tagset = set(tags) - matches = {key: metaproto for key, metaproto in _MODULE_PROTOTYPES.items() - if tagset.intersection(metaproto.tags)} + matches = {prototype_key: prototype + for prototype_key, prototype in _MODULE_PROTOTYPES.items() + if tagset.intersection(prototype.get("prototype_tags", []))} else: matches = _MODULE_PROTOTYPES @@ -333,12 +341,13 @@ def search_module_prototype(key=None, tags=None): return [matches[key]] else: # fuzzy matching - return [metaproto for pkey, metaproto in matches.items() if key in pkey] + return [prototype for prototype_key, prototype in matches.items() + if key in prototype_key] else: return [match for match in matches.values()] -def search_prototype(key=None, tags=None, return_meta=True): +def search_prototype(key=None, tags=None): """ Find prototypes based on key and/or tags, or all prototypes. @@ -347,12 +356,10 @@ def search_prototype(key=None, tags=None, return_meta=True): tags (str or list): Tag key or keys to query for. These will always be applied with the 'db_protototype' tag category. - return_meta (bool): If False, only return prototype dicts, if True - return MetaProto namedtuples including prototype meta info Return: - matches (list): All found prototype dicts or MetaProtos. If no keys - or tags are given, all available prototypes/MetaProtos will be returned. + matches (list): All found prototype dicts. If no keys + or tags are given, all available prototypes will be returned. Note: The available prototypes is a combination of those supplied in @@ -363,32 +370,29 @@ def search_prototype(key=None, tags=None, return_meta=True): """ module_prototypes = search_module_prototype(key, tags) - db_prototypes = search_db_prototype(key, tags, return_metaprotos=True) + db_prototypes = search_db_prototype(key, tags) matches = db_prototypes + module_prototypes if len(matches) > 1 and key: key = key.lower() # avoid duplicates if an exact match exist between the two types - filter_matches = [mta for mta in matches if mta.key == key] + filter_matches = [mta for mta in matches + if mta.get('prototype_key') and mta['prototype_key'] == key] if filter_matches and len(filter_matches) < len(matches): matches = filter_matches - if not return_meta: - matches = [mta.prototype for mta in matches] - return matches -def get_protparents(): +def get_protparent_dict(): """ - Get prototype parents. These are a combination of meta-key and prototype-dict and are used when - a prototype refers to another parent-prototype. + Get prototype parents. + + Returns: + parent_dict (dict): A mapping {prototype_key: prototype} for all available prototypes. """ - # get all prototypes - metaprotos = search_prototype(return_meta=True) - # organize by key - return {metaproto.key: metaproto.prototype for metaproto in metaprotos} + return {prototype['prototype_key']: prototype for prototype in search_prototype()} def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): @@ -410,35 +414,29 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed tags = [tag for tag in make_iter(tags) if tag] # get metaprotos for readonly and db-based prototypes - metaprotos = search_module_prototype(key, tags) - metaprotos += search_db_prototype(key, tags, return_metaprotos=True) + prototypes = search_prototype(key, tags) # get use-permissions of readonly attributes (edit is always False) - prototypes = [ - (metaproto.key, - metaproto.desc, - ("{}/N".format('Y' - if caller.locks.check_lockstring( - caller, - metaproto.locks, - access_type='use') else 'N')), - ",".join(metaproto.tags)) - for metaproto in sorted(metaprotos, key=lambda o: o.key)] + display_tuples = [] + for prototype in sorted(prototypes, key=lambda d: d['prototype_key']): + lock_use = caller.locks.check_lockstring(caller, prototype['locks'], access_type='use') + if not show_non_use and not lock_use: + continue + lock_edit = caller.locks.check_lockstring(caller, prototype['locks'], access_type='edit') + if not show_non_edit and not lock_edit: + continue + display_tuples.append( + (prototype.get('prototype_key', '', + prototype['prototype_desc', ''], + "{}/{}".format('Y' if lock_use else 'N', 'Y' if lock_edit else 'N'), + ",".join(prototype.get('prototype_tags', []))))) - if not prototypes: - return None - - if not show_non_use: - prototypes = [metaproto for metaproto in prototypes if metaproto[2].split("/", 1)[0] == 'Y'] - if not show_non_edit: - prototypes = [metaproto for metaproto in prototypes if metaproto[2].split("/", 1)[1] == 'Y'] - - if not prototypes: + if not display_tuples: return None table = [] - for i in range(len(prototypes[0])): - table.append([str(metaproto[i]) for metaproto in prototypes]) + for i in range(len(display_tuples[0])): + table.append([str(display_tuple[i]) for display_tuple in display_tuples]) table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=78) table.reformat_column(0, width=28) table.reformat_column(1, width=40) @@ -447,22 +445,26 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed return table -def metaproto_to_str(metaproto): +def prototype_to_str(prototype): """ - Format a metaproto to a nice string representation. + Format a prototype to a nice string representation. Args: metaproto (NamedTuple): Represents the prototype. """ + header = ( "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" "|cdesc:|n {} \n|cprototype:|n ".format( - metaproto.key, ", ".join(metaproto.tags), - metaproto.locks, metaproto.desc)) - prototype = ("{{\n {} \n}}".format("\n ".join("{!r}: {!r},".format(key, value) - for key, value in - sorted(metaproto.prototype.items())).rstrip(","))) - return header + prototype + prototype['prototype_key'], + ", ".join(prototype['prototype_tags']), + prototype['prototype_locks'], + prototype['prototype_desc'])) + proto = ("{{\n {} \n}}".format( + "\n ".join( + "{!r}: {!r},".format(key, value) for key, value in + sorted(prototype.items()) if key not in _PROTOTYPE_META_NAMES)).rstrip(",")) + return header + proto # Spawner mechanism @@ -487,10 +489,12 @@ def validate_prototype(prototype, protkey=None, protparents=None, _visited=None) """ if not protparents: - protparents = get_protparents() + protparents = get_protparent_dict() if _visited is None: _visited = [] - protkey = protkey.lower() if protkey is not None else None + protkey = protkey or prototype.get('prototype_key', None) + + protkey = protkey.lower() or prototype.get('prototype_key', None) assert isinstance(prototype, dict) @@ -537,8 +541,8 @@ def _batch_create_object(*objparams): so make sure the spawned Typeclass works before using this! Args: - objsparams (tuple): Parameters for the respective creation/add - handlers in the following order: + objsparams (tuple): Each paremter tuple will create one object instance using the parameters within. + The parameters should be given in the following order: - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`. - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`. - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`. @@ -555,9 +559,6 @@ def _batch_create_object(*objparams): (the newly created object) available in the namespace. Execution will happend after all other properties have been assigned and is intended for calling custom handlers etc. - for the respective creation/add handlers in the following - order: (create_kwargs, permissions, locks, aliases, nattributes, - attributes, tags, execs) Returns: objects (list): A list of created objects @@ -664,6 +665,10 @@ def spawn(*prototypes, **kwargs): alias_string = aliasval() if callable(aliasval) else aliasval tagval = prot.pop("tags", []) tags = tagval() if callable(tagval) else tagval + + # we make sure to add a tag identifying which prototype created this object + # tags.append(()) + attrval = prot.pop("attrs", []) attributes = attrval() if callable(tagval) else attrval @@ -676,9 +681,9 @@ def spawn(*prototypes, **kwargs): # the rest are attributes simple_attributes = [(key, value()) if callable(value) else (key, value) - for key, value in prot.items() if not key.startswith("ndb_")] + for key, value in prot.items() if not (key.startswith("ndb_"))] attributes = attributes + simple_attributes - attributes = [tup for tup in attributes if not tup[0] in _CREATE_OBJECT_KWARGS] + attributes = [tup for tup in attributes if not tup[0] in _NON_CREATE_KWARGS] # pack for call into _batch_create_object objsparams.append((create_kwargs, permission_string, lock_string, @@ -695,34 +700,30 @@ def spawn(*prototypes, **kwargs): # Helper functions -def _get_menu_metaprot(caller): +def _get_menu_prototype(caller): - metaproto = None - if hasattr(caller.ndb._menutree, "olc_metaprot"): - metaproto = caller.ndb._menutree.olc_metaprot - if not metaproto: - metaproto = build_metaproto(None, '', [], [], None) - caller.ndb._menutree.olc_metaprot = metaproto + prototype = None + if hasattr(caller.ndb._menutree, "olc_prototype"): + prototype = caller.ndb._menutree.olc_prototype + if not prototype: + caller.ndb._menutree.olc_prototype = {} caller.ndb._menutree.olc_new = True - return metaproto + return prototype def _is_new_prototype(caller): return hasattr(caller.ndb._menutree, "olc_new") -def _set_menu_metaprot(caller, field, value): - metaprot = _get_menu_metaprot(caller) - kwargs = dict(metaprot.__dict__) - kwargs[field] = value - caller.ndb._menutree.olc_metaprot = build_metaproto(**kwargs) +def _set_menu_prototype(caller, field, value): + prototype = _get_menu_prototype(caller) + prototype[field] = value + caller.ndb._menutree.olc_prototype = prototype -def _format_property(key, required=False, metaprot=None, prototype=None, cropper=None): +def _format_property(key, required=False, prototype=None, cropper=None): key = key.lower() - if metaprot is not None: - prop = getattr(metaprot, key) or '' - elif prototype is not None: + if prototype is not None: prop = prototype.get(key, '') out = prop @@ -753,14 +754,11 @@ def _set_property(caller, raw_string, **kwargs): next_node (str): Next node to go to. """ - prop = kwargs.get("prop", "meta_key") + prop = kwargs.get("prop", "prototype_key") processor = kwargs.get("processor", None) next_node = kwargs.get("next_node", "node_index") propname_low = prop.strip().lower() - meta = propname_low.startswith("meta_") - if meta: - propname_low = propname_low[5:] if callable(processor): try: @@ -776,23 +774,17 @@ def _set_property(caller, raw_string, **kwargs): if not value: return next_node - if meta: - _set_menu_metaprot(caller, propname_low, value) - else: - metaprot = _get_menu_metaprot(caller) - prototype = metaprot.prototype - prototype[propname_low] = value + prototype = _get_menu_prototype(caller) - # typeclass and prototype can't co-exist - if propname_low == "typeclass": - prototype.pop("prototype", None) - if propname_low == "prototype": - prototype.pop("typeclass", None) + # typeclass and prototype can't co-exist + if propname_low == "typeclass": + prototype.pop("prototype", None) + if propname_low == "prototype": + prototype.pop("typeclass", None) - _set_menu_metaprot(caller, "prototype", prototype) + caller.ndb._menutree.olc_prototype = prototype - caller.msg("Set {prop} to '{value}'.".format( - prop=prop.replace("_", "-").capitalize(), value=str(value))) + caller.msg("Set {prop} to '{value}'.".format(prop, value=str(value))) return next_node @@ -829,8 +821,7 @@ def _path_cropper(pythonpath): # Menu nodes def node_index(caller): - metaprot = _get_menu_metaprot(caller) - prototype = metaprot.prototype + prototype = _get_menu_prototype(caller) text = ("|c --- Prototype wizard --- |n\n\n" "Define the |yproperties|n of the prototype. All prototype values can be " @@ -841,10 +832,9 @@ def node_index(caller): "others later.\n\n(make choice; q to abort. If unsure, start from 1.)") options = [] - # The meta-key goes first options.append( - {"desc": "|WMeta-Key|n|n{}".format(_format_property("Key", True, metaprot, None)), - "goto": "node_meta_key"}) + {"desc": "|WPrototype-Key|n|n{}".format(_format_property("Key", True, prototype, None)), + "goto": "node_prototype_key"}) for key in ('Prototype', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', 'Permissions', 'Location', 'Home', 'Destination'): required = False @@ -860,20 +850,20 @@ def node_index(caller): required = False for key in ('Desc', 'Tags', 'Locks'): options.append( - {"desc": "|WMeta-{}|n|n{}".format(key, _format_property(key, required, metaprot, None)), - "goto": "node_meta_{}".format(key.lower())}) + {"desc": "|WPrototype-{}|n|n{}".format(key, _format_property(key, required, prototype, None)), + "goto": "node_prototype_{}".format(key.lower())}) return text, options def node_validate_prototype(caller, raw_string, **kwargs): - metaprot = _get_menu_metaprot(caller) + prototype = _get_menu_prototype(caller) - txt = metaproto_to_str(metaprot) + txt = prototype_to_str(prototype) errors = "\n\n|g No validation errors found.|n (but errors could still happen at spawn-time)" try: # validate, don't spawn - spawn(metaprot.prototype, return_prototypes=True) + spawn(prototype, return_prototypes=True) except RuntimeError as err: errors = "\n\n|rError: {}|n".format(err) text = (txt + errors) @@ -883,42 +873,43 @@ def node_validate_prototype(caller, raw_string, **kwargs): return text, options -def _check_meta_key(caller, key): - old_metaprot = search_prototype(key) +def _check_prototype_key(caller, key): + old_prototype = search_prototype(key) olc_new = _is_new_prototype(caller) key = key.strip().lower() - if old_metaprot: + if old_prototype: # we are starting a new prototype that matches an existing - if not caller.locks.check_lockstring(caller, old_metaprot.locks, access_type='edit'): - # return to the node_meta_key to try another key + if not caller.locks.check_lockstring( + caller, old_prototype['prototype_locks'], access_type='edit'): + # return to the node_prototype_key to try another key caller.msg("Prototype '{key}' already exists and you don't " "have permission to edit it.".format(key=key)) - return "node_meta_key" + return "node_prototype_key" elif olc_new: # we are selecting an existing prototype to edit. Reset to index. del caller.ndb._menutree.olc_new - caller.ndb._menutree.olc_metaprot = old_metaprot + caller.ndb._menutree.olc_prototype = old_prototype caller.msg("Prototype already exists. Reloading.") return "node_index" - return _set_property(caller, key, prop='meta_key', next_node="node_prototype") + return _set_property(caller, key, prop='prototype_key', next_node="node_prototype") -def node_meta_key(caller): - metaprot = _get_menu_metaprot(caller) +def node_prototype_key(caller): + prototype = _get_menu_prototype(caller) text = ["The prototype name, or |wMeta-Key|n, uniquely identifies the prototype. " "It is used to find and use the prototype to spawn new entities. " "It is not case sensitive."] - old_key = metaprot.key + old_key = prototype['prototype_key'] if old_key: text.append("Current key is '|w{key}|n'".format(key=old_key)) else: text.append("The key is currently unset.") text.append("Enter text or make a choice (q for quit)") text = "\n\n".join(text) - options = _wizard_options("meta_key", "index", "prototype") + options = _wizard_options("prototype_key", "index", "prototype") options.append({"key": "_default", - "goto": _check_meta_key}) + "goto": _check_prototype_key}) return text, options @@ -927,9 +918,9 @@ def _all_prototypes(caller): def _prototype_examine(caller, prototype_name): - metaprot = search_prototype(key=prototype_name) - if metaprot: - caller.msg(metaproto_to_str(metaprot[0])) + prototypes = search_prototype(key=prototype_name) + if prototypes: + caller.msg(prototype_to_str(prototypes[0])) caller.msg("Prototype not registered.") return None @@ -942,17 +933,22 @@ def _prototype_select(caller, prototype): @list_node(_all_prototypes, _prototype_select) def node_prototype(caller): - metaprot = _get_menu_metaprot(caller) - prot = metaprot.prototype - prototype = prot.get("prototype") + prototype = _get_menu_prototype(caller) - text = ["Set the prototype's parent |yPrototype|n. If this is unset, Typeclass will be used."] - if prototype: - text.append("Current prototype is |y{prototype}|n.".format(prototype=prototype)) + prot_parent_key = prototype.get('prototype') + + text = ["Set the prototype's |yParent Prototype|n. If this is unset, Typeclass will be used."] + if prot_parent_key: + prot_parent = search_prototype(prot_parent_key) + if prot_parent: + text.append("Current parent prototype is {}:\n{}".format(prototype_to_str(prot_parent))) + else: + text.append("Current parent prototype |r{prototype}|n " + "does not appear to exist.".format(prot_parent_key)) else: text.append("Parent prototype is not set") text = "\n\n".join(text) - options = _wizard_options("prototype", "meta_key", "typeclass", color="|W") + options = _wizard_options("prototype", "prototype_key", "typeclass", color="|W") options.append({"key": "_default", "goto": _prototype_examine}) @@ -961,7 +957,6 @@ def node_prototype(caller): def _all_typeclasses(caller): return list(sorted(get_all_typeclasses().keys())) - # return list(sorted(get_all_typeclasses(parent="evennia.objects.objects.DefaultObject").keys())) def _typeclass_examine(caller, typeclass_path): @@ -994,9 +989,8 @@ def _typeclass_select(caller, typeclass): @list_node(_all_typeclasses, _typeclass_select) def node_typeclass(caller): - metaprot = _get_menu_metaprot(caller) - prot = metaprot.prototype - typeclass = prot.get("typeclass") + prototype = _get_menu_prototype(caller) + typeclass = prototype.get("typeclass") text = ["Set the typeclass's parent |yTypeclass|n."] if typeclass: @@ -1012,9 +1006,8 @@ def node_typeclass(caller): def node_key(caller): - metaprot = _get_menu_metaprot(caller) - prot = metaprot.prototype - key = prot.get("key") + prototype = _get_menu_prototype(caller) + key = prototype.get("key") text = ["Set the prototype's |yKey|n. This will retain case sensitivity."] if key: @@ -1032,9 +1025,8 @@ def node_key(caller): def node_aliases(caller): - metaprot = _get_menu_metaprot(caller) - prot = metaprot.prototype - aliases = prot.get("aliases") + prototype = _get_menu_prototype(caller) + aliases = prototype.get("aliases") text = ["Set the prototype's |yAliases|n. Separate multiple aliases with commas. " "ill retain case sensitivity."] @@ -1053,8 +1045,8 @@ def node_aliases(caller): def _caller_attrs(caller): - metaprot = _get_menu_metaprot(caller) - attrs = metaprot.prototype.get("attrs", []) + prototype = _get_menu_prototype(caller) + attrs = prototype.get("attrs", []) return attrs @@ -1078,10 +1070,9 @@ def _attrparse(caller, attr_string): def _add_attr(caller, attr_string, **kwargs): attrname, value = _attrparse(caller, attr_string) if attrname: - metaprot = _get_menu_metaprot(caller) - prot = metaprot.prototype + prot = _get_menu_prototype(caller) prot['attrs'][attrname] = value - _set_menu_metaprot(caller, "prototype", prot) + _set_menu_prototype(caller, "prototype", prot) text = "Added" else: text = "Attribute must be given as 'attrname = ' where uses valid Python." @@ -1093,8 +1084,7 @@ def _add_attr(caller, attr_string, **kwargs): def _edit_attr(caller, attrname, new_value, **kwargs): attrname, value = _attrparse("{}={}".format(caller, attrname, new_value)) if attrname: - metaprot = _get_menu_metaprot(caller) - prot = metaprot.prototype + prot = _get_menu_prototype(caller) prot['attrs'][attrname] = value text = "Edited Attribute {} = {}".format(attrname, value) else: @@ -1105,16 +1095,14 @@ def _edit_attr(caller, attrname, new_value, **kwargs): def _examine_attr(caller, selection): - metaprot = _get_menu_metaprot(caller) - prot = metaprot.prototype + prot = _get_menu_prototype(caller) value = prot['attrs'][selection] return "Attribute {} = {}".format(selection, value) @list_node(_caller_attrs) def node_attrs(caller): - metaprot = _get_menu_metaprot(caller) - prot = metaprot.prototype + prot = _get_menu_prototype(caller) attrs = prot.get("attrs") text = ["Set the prototype's |yAttributes|n. Separate multiple attrs with commas. " @@ -1134,7 +1122,7 @@ def node_attrs(caller): def _caller_tags(caller): - metaprot = _get_menu_metaprot(caller) + metaprot = _get_menu_prototype(caller) prot = metaprot.prototype tags = prot.get("tags") return tags @@ -1142,7 +1130,7 @@ def _caller_tags(caller): def _add_tag(caller, tag, **kwargs): tag = tag.strip().lower() - metaprot = _get_menu_metaprot(caller) + metaprot = _get_menu_prototype(caller) prot = metaprot.prototype tags = prot.get('tags', []) if tags: @@ -1151,7 +1139,7 @@ def _add_tag(caller, tag, **kwargs): else: tags = [tag] prot['tags'] = tags - _set_menu_metaprot(caller, "prototype", prot) + _set_menu_prototype(caller, "prototype", prot) text = kwargs.get("text") if not text: text = "Added tag {}. (return to continue)".format(tag) @@ -1161,7 +1149,7 @@ def _add_tag(caller, tag, **kwargs): def _edit_tag(caller, old_tag, new_tag, **kwargs): - metaprot = _get_menu_metaprot(caller) + metaprot = _get_menu_prototype(caller) prototype = metaprot.prototype tags = prototype.get('tags', []) @@ -1169,7 +1157,7 @@ def _edit_tag(caller, old_tag, new_tag, **kwargs): new_tag = new_tag.strip().lower() tags[tags.index(old_tag)] = new_tag prototype['tags'] = tags - _set_menu_metaprot(caller, 'prototype', prototype) + _set_menu_prototype(caller, 'prototype', prototype) text = kwargs.get('text') if not text: @@ -1187,7 +1175,7 @@ def node_tags(caller): def node_locks(caller): - metaprot = _get_menu_metaprot(caller) + metaprot = _get_menu_prototype(caller) prot = metaprot.prototype locks = prot.get("locks") @@ -1208,7 +1196,7 @@ def node_locks(caller): def node_permissions(caller): - metaprot = _get_menu_metaprot(caller) + metaprot = _get_menu_prototype(caller) prot = metaprot.prototype permissions = prot.get("permissions") @@ -1229,7 +1217,7 @@ def node_permissions(caller): def node_location(caller): - metaprot = _get_menu_metaprot(caller) + metaprot = _get_menu_prototype(caller) prot = metaprot.prototype location = prot.get("location") @@ -1249,7 +1237,7 @@ def node_location(caller): def node_home(caller): - metaprot = _get_menu_metaprot(caller) + metaprot = _get_menu_prototype(caller) prot = metaprot.prototype home = prot.get("home") @@ -1269,7 +1257,7 @@ def node_home(caller): def node_destination(caller): - metaprot = _get_menu_metaprot(caller) + metaprot = _get_menu_prototype(caller) prot = metaprot.prototype dest = prot.get("dest") @@ -1279,18 +1267,18 @@ def node_destination(caller): else: text.append("No destination is set (default).") text = "\n\n".join(text) - options = _wizard_options("destination", "home", "meta_desc") + options = _wizard_options("destination", "home", "prototype_desc") options.append({"key": "_default", "goto": (_set_property, dict(prop="dest", processor=lambda s: s.strip(), - next_node="node_meta_desc"))}) + next_node="node_prototype_desc"))}) return text, options -def node_meta_desc(caller): +def node_prototype_desc(caller): - metaprot = _get_menu_metaprot(caller) + metaprot = _get_menu_prototype(caller) text = ["The |wMeta-Description|n briefly describes the prototype for viewing in listings."] desc = metaprot.desc @@ -1299,18 +1287,18 @@ def node_meta_desc(caller): else: text.append("Description is currently unset.") text = "\n\n".join(text) - options = _wizard_options("meta_desc", "meta_key", "meta_tags") + options = _wizard_options("prototype_desc", "prototype_key", "prototype_tags") options.append({"key": "_default", "goto": (_set_property, - dict(prop='meta_desc', + dict(prop='prototype_desc', processor=lambda s: s.strip(), - next_node="node_meta_tags"))}) + next_node="node_prototype_tags"))}) return text, options -def node_meta_tags(caller): - metaprot = _get_menu_metaprot(caller) +def node_prototype_tags(caller): + metaprot = _get_menu_prototype(caller) text = ["|wMeta-Tags|n can be used to classify and find prototypes. Tags are case-insensitive. " "Separate multiple by tags by commas."] tags = metaprot.tags @@ -1320,18 +1308,18 @@ def node_meta_tags(caller): else: text.append("No tags are currently set.") text = "\n\n".join(text) - options = _wizard_options("meta_tags", "meta_desc", "meta_locks") + options = _wizard_options("prototype_tags", "prototype_desc", "prototype_locks") options.append({"key": "_default", "goto": (_set_property, - dict(prop="meta_tags", + dict(prop="prototype_tags", processor=lambda s: [ str(part.strip().lower()) for part in s.split(",")], - next_node="node_meta_locks"))}) + next_node="node_prototype_locks"))}) return text, options -def node_meta_locks(caller): - metaprot = _get_menu_metaprot(caller) +def node_prototype_locks(caller): + metaprot = _get_menu_prototype(caller) text = ["Set |wMeta-Locks|n on the prototype. There are two valid lock types: " "'edit' (who can edit the prototype) and 'use' (who can apply the prototype)\n" "(If you are unsure, leave as default.)"] @@ -1342,10 +1330,10 @@ def node_meta_locks(caller): text.append("Lock unset - if not changed the default lockstring will be set as\n" " |w'use:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) text = "\n\n".join(text) - options = _wizard_options("meta_locks", "meta_tags", "index") + options = _wizard_options("prototype_locks", "prototype_tags", "index") options.append({"key": "_default", "goto": (_set_property, - dict(prop="meta_locks", + dict(prop="prototype_locks", processor=lambda s: s.strip().lower(), next_node="node_index"))}) return text, options @@ -1392,7 +1380,7 @@ def start_olc(caller, session=None, metaproto=None): """ menudata = {"node_index": node_index, "node_validate_prototype": node_validate_prototype, - "node_meta_key": node_meta_key, + "node_prototype_key": node_prototype_key, "node_prototype": node_prototype, "node_typeclass": node_typeclass, "node_key": node_key, @@ -1404,11 +1392,11 @@ def start_olc(caller, session=None, metaproto=None): "node_location": node_location, "node_home": node_home, "node_destination": node_destination, - "node_meta_desc": node_meta_desc, - "node_meta_tags": node_meta_tags, - "node_meta_locks": node_meta_locks, + "node_prototype_desc": node_prototype_desc, + "node_prototype_tags": node_prototype_tags, + "node_prototype_locks": node_prototype_locks, } - OLCMenu(caller, menudata, startnode='node_index', session=session, olc_metaprot=metaproto) + OLCMenu(caller, menudata, startnode='node_index', session=session, olc_prototype=metaproto) # Testing From b009e0d33a1f7c442a5d700d53b626324f569b3e Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 19 Apr 2018 22:23:24 +0200 Subject: [PATCH 289/466] Clean out metaprots, only use prototypes --- evennia/commands/default/building.py | 47 +++++++------- evennia/utils/spawner.py | 93 +++++++++++++--------------- 2 files changed, 67 insertions(+), 73 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 77bdb619f1..c593a6376d 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -14,9 +14,9 @@ from evennia.utils.utils import inherits_from, class_from_module, get_all_typecl from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore from evennia.utils.spawner import (spawn, search_prototype, list_prototypes, - save_db_prototype, build_metaproto, validate_prototype, + save_db_prototype, validate_prototype, delete_db_prototype, PermissionError, start_olc, - metaproto_to_str) + prototype_to_str) from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -2885,12 +2885,12 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): return return prototype - def _search_show_prototype(query, metaprots=None): + def _search_show_prototype(query, prototypes=None): # prototype detail - if not metaprots: - metaprots = search_prototype(key=query, return_meta=True) - if metaprots: - return "\n".join(metaproto_to_str(metaprot) for metaprot in metaprots) + if not prototypes: + prototypes = search_prototype(key=query) + if prototypes: + return "\n".join(prototype_to_str(prot) for prot in prototypes) else: return False @@ -2898,18 +2898,18 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if self.cmdstring == "olc" or 'menu' in self.switches or 'olc' in self.switches: # OLC menu mode - metaprot = None + prototype = None if self.lhs: key = self.lhs - metaprot = search_prototype(key=key, return_meta=True) - if len(metaprot) > 1: + prototype = search_prototype(key=key, return_meta=True) + if len(prototype) > 1: caller.msg("More than one match for {}:\n{}".format( - key, "\n".join(mproto.key for mproto in metaprot))) + key, "\n".join(proto.get('prototype_key', '') for proto in prototype))) return - elif metaprot: + elif prototype: # one match - metaprot = metaprot[0] - start_olc(caller, session=self.session, metaproto=metaprot) + prototype = prototype[0] + start_olc(caller, session=self.session, prototype=prototype) return if 'search' in self.switches: @@ -3005,8 +3005,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): return # present prototype to save - new_matchstring = _search_show_prototype( - "", metaprots=[build_metaproto(key, desc, [lockstring], tags, prototype)]) + new_matchstring = _search_show_prototype("", prototypes=[prototype]) string = "|yCreating new prototype:|n\n{}".format(new_matchstring) question = "\nDo you want to continue saving? [Y]/N" @@ -3056,21 +3055,21 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if isinstance(prototype, basestring): # A prototype key we are looking to apply key = prototype - metaprotos = search_prototype(prototype) - nprots = len(metaprotos) - if not metaprotos: + prototypes = search_prototype(prototype) + nprots = len(prototypes) + if not prototypes: caller.msg("No prototype named '%s'." % prototype) return elif nprots > 1: caller.msg("Found {} prototypes matching '{}':\n {}".format( - nprots, prototype, ", ".join(metaproto.key for metaproto in metaprotos))) + nprots, prototype, ", ".join(prot.get('prototype_key', '') + for proto in prototypes))) return - # we have a metaprot, check access - metaproto = metaprotos[0] - if not caller.locks.check_lockstring(caller, metaproto.locks, access_type='use'): + # we have a prototype, check access + prototype = prototypes[0] + if not caller.locks.check_lockstring(caller, prototype.get('prototype_locks', ''), access_type='use'): caller.msg("You don't have access to use this prototype.") return - prototype = metaproto.prototype if "noloc" not in self.switches and "location" not in prototype: prototype["location"] = self.caller.location diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 0bce7addd8..1916c2210e 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -144,7 +144,7 @@ for mod in settings.PROTOTYPE_MODULES: prots = [(prototype_key, prot) for prototype_key, prot in all_from_module(mod).items() if prot and isinstance(prot, dict)] # assign module path to each prototype_key for easy reference - _MODULE_PROTOTYPE_MODULES.update({tup[0]: mod for tup in prots}) + _MODULE_PROTOTYPE_MODULES.update({prototype_key: mod for prototype_key, _ in prots}) # make sure the prototype contains all meta info for prototype_key, prot in prots: prot.update({ @@ -153,7 +153,7 @@ for mod in settings.PROTOTYPE_MODULES: "prototype_locks": prot['prototype_locks'] if 'prototype_locks' in prot else "use:all()", "prototype_tags": set(make_iter(prot['prototype_tags']) if 'prototype_tags' in prot else ["base-prototype"])}) - _MODULE_PROTOTYPES.update(prot) + _MODULE_PROTOTYPES[prototype_key] = prot # Prototype storage mechanisms @@ -413,23 +413,25 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed # this allows us to pass lists of empty strings tags = [tag for tag in make_iter(tags) if tag] - # get metaprotos for readonly and db-based prototypes + # get prototypes for readonly and db-based prototypes prototypes = search_prototype(key, tags) # get use-permissions of readonly attributes (edit is always False) display_tuples = [] - for prototype in sorted(prototypes, key=lambda d: d['prototype_key']): - lock_use = caller.locks.check_lockstring(caller, prototype['locks'], access_type='use') + for prototype in sorted(prototypes, key=lambda d: d.get('prototype_key', '')): + lock_use = caller.locks.check_lockstring( + caller, prototype.get('prototype_locks', ''), access_type='use') if not show_non_use and not lock_use: continue - lock_edit = caller.locks.check_lockstring(caller, prototype['locks'], access_type='edit') + lock_edit = caller.locks.check_lockstring( + caller, prototype.get('prototype_locks', ''), access_type='edit') if not show_non_edit and not lock_edit: continue display_tuples.append( - (prototype.get('prototype_key', '', - prototype['prototype_desc', ''], + (prototype.get('prototype_key', ''), + prototype.get('prototype_desc', ''), "{}/{}".format('Y' if lock_use else 'N', 'Y' if lock_edit else 'N'), - ",".join(prototype.get('prototype_tags', []))))) + ",".join(prototype.get('prototype_tags', [])))) if not display_tuples: return None @@ -450,7 +452,7 @@ def prototype_to_str(prototype): Format a prototype to a nice string representation. Args: - metaproto (NamedTuple): Represents the prototype. + prototype (dict): The prototype. """ header = ( @@ -706,7 +708,7 @@ def _get_menu_prototype(caller): if hasattr(caller.ndb._menutree, "olc_prototype"): prototype = caller.ndb._menutree.olc_prototype if not prototype: - caller.ndb._menutree.olc_prototype = {} + caller.ndb._menutree.olc_prototype = prototype = {} caller.ndb._menutree.olc_new = True return prototype @@ -721,10 +723,10 @@ def _set_menu_prototype(caller, field, value): caller.ndb._menutree.olc_prototype = prototype -def _format_property(key, required=False, prototype=None, cropper=None): - key = key.lower() +def _format_property(prop, required=False, prototype=None, cropper=None): + if prototype is not None: - prop = prototype.get(key, '') + prop = prototype.get(prop, '') out = prop if callable(prop): @@ -845,7 +847,7 @@ def node_index(caller): cropper = _path_cropper options.append( {"desc": "|w{}|n{}".format( - key, _format_property(key, required, None, prototype, cropper=cropper)), + key, _format_property(key, required, prototype, cropper=cropper)), "goto": "node_{}".format(key.lower())}) required = False for key in ('Desc', 'Tags', 'Locks'): @@ -900,7 +902,7 @@ def node_prototype_key(caller): text = ["The prototype name, or |wMeta-Key|n, uniquely identifies the prototype. " "It is used to find and use the prototype to spawn new entities. " "It is not case sensitive."] - old_key = prototype['prototype_key'] + old_key = prototype.get('prototype_key', None) if old_key: text.append("Current key is '|w{key}|n'".format(key=old_key)) else: @@ -914,7 +916,8 @@ def node_prototype_key(caller): def _all_prototypes(caller): - return [mproto.key for mproto in search_prototype()] + return [prototype["prototype_key"] + for prototype in search_prototype() if "prototype_key" in prototype] def _prototype_examine(caller, prototype_name): @@ -1122,17 +1125,15 @@ def node_attrs(caller): def _caller_tags(caller): - metaprot = _get_menu_prototype(caller) - prot = metaprot.prototype - tags = prot.get("tags") + prototype = _get_menu_prototype(caller) + tags = prototype.get("tags") return tags def _add_tag(caller, tag, **kwargs): tag = tag.strip().lower() - metaprot = _get_menu_prototype(caller) - prot = metaprot.prototype - tags = prot.get('tags', []) + prototype = _get_menu_prototype(caller) + tags = prototype.get('tags', []) if tags: if tag not in tags: tags.append(tag) @@ -1149,8 +1150,7 @@ def _add_tag(caller, tag, **kwargs): def _edit_tag(caller, old_tag, new_tag, **kwargs): - metaprot = _get_menu_prototype(caller) - prototype = metaprot.prototype + prototype = _get_menu_prototype(caller) tags = prototype.get('tags', []) old_tag = old_tag.strip().lower() @@ -1175,9 +1175,8 @@ def node_tags(caller): def node_locks(caller): - metaprot = _get_menu_prototype(caller) - prot = metaprot.prototype - locks = prot.get("locks") + prototype = _get_menu_prototype(caller) + locks = prototype.get("locks") text = ["Set the prototype's |yLock string|n. Separate multiple locks with semi-colons. " "Will retain case sensitivity."] @@ -1196,9 +1195,8 @@ def node_locks(caller): def node_permissions(caller): - metaprot = _get_menu_prototype(caller) - prot = metaprot.prototype - permissions = prot.get("permissions") + prototype = _get_menu_prototype(caller) + permissions = prototype.get("permissions") text = ["Set the prototype's |yPermissions|n. Separate multiple permissions with commas. " "Will retain case sensitivity."] @@ -1217,9 +1215,8 @@ def node_permissions(caller): def node_location(caller): - metaprot = _get_menu_prototype(caller) - prot = metaprot.prototype - location = prot.get("location") + prototype = _get_menu_prototype(caller) + location = prototype.get("location") text = ["Set the prototype's |yLocation|n"] if location: @@ -1237,9 +1234,8 @@ def node_location(caller): def node_home(caller): - metaprot = _get_menu_prototype(caller) - prot = metaprot.prototype - home = prot.get("home") + prototype = _get_menu_prototype(caller) + home = prototype.get("home") text = ["Set the prototype's |yHome location|n"] if home: @@ -1257,9 +1253,8 @@ def node_home(caller): def node_destination(caller): - metaprot = _get_menu_prototype(caller) - prot = metaprot.prototype - dest = prot.get("dest") + prototype = _get_menu_prototype(caller) + dest = prototype.get("dest") text = ["Set the prototype's |yDestination|n. This is usually only used for Exits."] if dest: @@ -1278,9 +1273,9 @@ def node_destination(caller): def node_prototype_desc(caller): - metaprot = _get_menu_prototype(caller) + prototype = _get_menu_prototype(caller) text = ["The |wMeta-Description|n briefly describes the prototype for viewing in listings."] - desc = metaprot.desc + desc = prototype.get("prototype_desc", None) if desc: text.append("The current meta desc is:\n\"|w{desc}|n\"".format(desc=desc)) @@ -1298,10 +1293,10 @@ def node_prototype_desc(caller): def node_prototype_tags(caller): - metaprot = _get_menu_prototype(caller) + prototype = _get_menu_prototype(caller) text = ["|wMeta-Tags|n can be used to classify and find prototypes. Tags are case-insensitive. " "Separate multiple by tags by commas."] - tags = metaprot.tags + tags = prototype.get('prototype_tags', []) if tags: text.append("The current tags are:\n|w{tags}|n".format(tags=tags)) @@ -1319,11 +1314,11 @@ def node_prototype_tags(caller): def node_prototype_locks(caller): - metaprot = _get_menu_prototype(caller) + prototype = _get_menu_prototype(caller) text = ["Set |wMeta-Locks|n on the prototype. There are two valid lock types: " "'edit' (who can edit the prototype) and 'use' (who can apply the prototype)\n" "(If you are unsure, leave as default.)"] - locks = metaprot.locks + locks = prototype.get('prototype_locks', '') if locks: text.append("Current lock is |w'{lockstring}'|n".format(lockstring=locks)) else: @@ -1367,14 +1362,14 @@ class OLCMenu(EvMenu): return "{}{}{}".format(olc_options, sep, other_options) -def start_olc(caller, session=None, metaproto=None): +def start_olc(caller, session=None, prototype=None): """ Start menu-driven olc system for prototypes. Args: caller (Object or Account): The entity starting the menu. session (Session, optional): The individual session to get data. - metaproto (MetaProto, optional): Given when editing an existing + prototype (dict, optional): Given when editing an existing prototype rather than creating a new one. """ @@ -1396,7 +1391,7 @@ def start_olc(caller, session=None, metaproto=None): "node_prototype_tags": node_prototype_tags, "node_prototype_locks": node_prototype_locks, } - OLCMenu(caller, menudata, startnode='node_index', session=session, olc_prototype=metaproto) + OLCMenu(caller, menudata, startnode='node_index', session=session, olc_prototype=prototype) # Testing From bee7fa174d1df01c1f541168f39b8e846787bbbb Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 19 Apr 2018 22:47:13 +0200 Subject: [PATCH 290/466] Cherry-pick EvMenu list_node decorator from olc branch --- evennia/utils/evmenu.py | 241 +++++++++++++++++++++++++++++++++++----- 1 file changed, 211 insertions(+), 30 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 3d8fb6b789..a2a92f5e34 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -43,13 +43,18 @@ command definition too) with function definitions: def node_with_other_name(caller, input_string): # code return text, options + + def another_node(caller, input_string, **kwargs): + # code + return text, options ``` Where caller is the object using the menu and input_string is the command entered by the user on the *previous* node (the command entered to get to this node). The node function code will only be executed once per node-visit and the system will accept nodes with -both one or two arguments interchangeably. +both one or two arguments interchangeably. It also accepts nodes +that takes **kwargs. The menu tree itself is available on the caller as `caller.ndb._menutree`. This makes it a convenient place to store @@ -82,12 +87,14 @@ menu is immediately exited and the default "look" command is called. the callable. Those kwargs will also be passed into the next node if possible. Such a callable should return either a str or a (str, dict), where the string is the name of the next node to go to and the dict is the new, - (possibly modified) kwarg to pass into the next node. + (possibly modified) kwarg to pass into the next node. If the callable returns + None or the empty string, the current node will be revisited. - `exec` (str, callable or tuple, optional): This takes the same input as `goto` above and runs before it. If given a node name, the node will be executed but will not be considered the next node. If node/callback returns str or (str, dict), these will replace the `goto` step (`goto` callbacks will not fire), with the string being the next node name and the optional dict acting as the kwargs-input for the next node. + If an exec callable returns the empty string (only), the current node is re-run. If key is not given, the option will automatically be identified by its number 1..N. @@ -167,7 +174,7 @@ from evennia import Command, CmdSet from evennia.utils import logger from evennia.utils.evtable import EvTable from evennia.utils.ansi import strip_ansi -from evennia.utils.utils import mod_import, make_iter, pad, m_len +from evennia.utils.utils import mod_import, make_iter, pad, m_len, is_iter from evennia.commands import cmdhandler # read from protocol NAWS later? @@ -182,7 +189,8 @@ _CMD_NOINPUT = cmdhandler.CMD_NOINPUT # i18n from django.utils.translation import ugettext as _ -_ERR_NOT_IMPLEMENTED = _("Menu node '{nodename}' is not implemented. Make another choice.") +_ERR_NOT_IMPLEMENTED = _("Menu node '{nodename}' is either not implemented or " + "caused an error. Make another choice.") _ERR_GENERAL = _("Error in menu node '{nodename}'.") _ERR_NO_OPTION_DESC = _("No description.") _HELP_FULL = _("Commands: , help, quit") @@ -573,6 +581,7 @@ class EvMenu(object): except EvMenuError: errmsg = _ERR_GENERAL.format(nodename=callback) self.caller.msg(errmsg, self._session) + logger.log_trace() raise return ret @@ -606,9 +615,11 @@ class EvMenu(object): nodetext, options = ret, None except KeyError: self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session) + logger.log_trace() raise EvMenuError except Exception: self.caller.msg(_ERR_GENERAL.format(nodename=nodename), session=self._session) + logger.log_trace() raise # store options to make them easier to test @@ -665,9 +676,49 @@ class EvMenu(object): if isinstance(ret, basestring): # only return a value if a string (a goto target), ignore all other returns + if not ret: + # an empty string - rerun the same node + return self.nodename return ret, kwargs return None + def extract_goto_exec(self, nodename, option_dict): + """ + Helper: Get callables and their eventual kwargs. + + Args: + nodename (str): The current node name (used for error reporting). + option_dict (dict): The seleted option's dict. + + Returns: + goto (str, callable or None): The goto directive in the option. + goto_kwargs (dict): Kwargs for `goto` if the former is callable, otherwise empty. + execute (callable or None): Executable given by the `exec` directive. + exec_kwargs (dict): Kwargs for `execute` if it's callable, otherwise empty. + + """ + goto_kwargs, exec_kwargs = {}, {} + goto, execute = option_dict.get("goto", None), option_dict.get("exec", None) + if goto and isinstance(goto, (tuple, list)): + if len(goto) > 1: + goto, goto_kwargs = goto[:2] # ignore any extra arguments + if not hasattr(goto_kwargs, "__getitem__"): + # not a dict-like structure + raise EvMenuError("EvMenu node {}: goto kwargs is not a dict: {}".format( + nodename, goto_kwargs)) + else: + goto = goto[0] + if execute and isinstance(execute, (tuple, list)): + if len(execute) > 1: + execute, exec_kwargs = execute[:2] # ignore any extra arguments + if not hasattr(exec_kwargs, "__getitem__"): + # not a dict-like structure + raise EvMenuError("EvMenu node {}: exec kwargs is not a dict: {}".format( + nodename, goto_kwargs)) + else: + execute = execute[0] + return goto, goto_kwargs, execute, exec_kwargs + def goto(self, nodename, raw_string, **kwargs): """ Run a node by name, optionally dynamically generating that name first. @@ -681,29 +732,6 @@ class EvMenu(object): argument) """ - def _extract_goto_exec(option_dict): - "Helper: Get callables and their eventual kwargs" - goto_kwargs, exec_kwargs = {}, {} - goto, execute = option_dict.get("goto", None), option_dict.get("exec", None) - if goto and isinstance(goto, (tuple, list)): - if len(goto) > 1: - goto, goto_kwargs = goto[:2] # ignore any extra arguments - if not hasattr(goto_kwargs, "__getitem__"): - # not a dict-like structure - raise EvMenuError("EvMenu node {}: goto kwargs is not a dict: {}".format( - nodename, goto_kwargs)) - else: - goto = goto[0] - if execute and isinstance(execute, (tuple, list)): - if len(execute) > 1: - execute, exec_kwargs = execute[:2] # ignore any extra arguments - if not hasattr(exec_kwargs, "__getitem__"): - # not a dict-like structure - raise EvMenuError("EvMenu node {}: exec kwargs is not a dict: {}".format( - nodename, goto_kwargs)) - else: - execute = execute[0] - return goto, goto_kwargs, execute, exec_kwargs if callable(nodename): # run the "goto" callable, if possible @@ -714,6 +742,9 @@ class EvMenu(object): raise EvMenuError( "{}: goto callable must return str or (str, dict)".format(inp_nodename)) nodename, kwargs = nodename[:2] + if not nodename: + # no nodename return. Re-run current node + nodename = self.nodename try: # execute the found node, make use of the returns. nodetext, options = self._execute_node(nodename, raw_string, **kwargs) @@ -746,12 +777,12 @@ class EvMenu(object): desc = dic.get("desc", dic.get("text", None)) if "_default" in keys: keys = [key for key in keys if key != "_default"] - goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic) + goto, goto_kwargs, execute, exec_kwargs = self.extract_goto_exec(nodename, dic) self.default = (goto, goto_kwargs, execute, exec_kwargs) else: # use the key (only) if set, otherwise use the running number keys = list(make_iter(dic.get("key", str(inum + 1).strip()))) - goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic) + goto, goto_kwargs, execute, exec_kwargs = self.extract_goto_exec(nodename, dic) if keys: display_options.append((keys[0], desc)) for key in keys: @@ -945,14 +976,164 @@ class EvMenu(object): node (str): The formatted node to display. """ + screen_width = self._session.protocol_flags.get("SCREENWIDTH", {0: 78})[0] + nodetext_width_max = max(m_len(line) for line in nodetext.split("\n")) options_width_max = max(m_len(line) for line in optionstext.split("\n")) - total_width = max(options_width_max, nodetext_width_max) + total_width = min(screen_width, max(options_width_max, nodetext_width_max)) separator1 = "_" * total_width + "\n\n" if nodetext_width_max else "" separator2 = "\n" + "_" * total_width + "\n\n" if total_width else "" return separator1 + "|n" + nodetext + "|n" + separator2 + "|n" + optionstext +# ----------------------------------------------------------- +# +# List node (decorator turning a node into a list with +# look/edit/add functionality for the elements) +# +# ----------------------------------------------------------- + +def list_node(option_generator, select=None, pagesize=10): + """ + Decorator for making an EvMenu node into a multi-page list node. Will add new options, + prepending those options added in the node. + + Args: + option_generator (callable or list): A list of strings indicating the options, or a callable + that is called as option_generator(caller) to produce such a list. + select (callable, option): Will be called as select(caller, menuchoice) + where menuchoice is the chosen option as a string. Should return the target node to + goto after this selection (or None to repeat the list-node). Note that if this is not + given, the decorated node must itself provide a way to continue from the node! + pagesize (int): How many options to show per page. + + Example: + @list_node(['foo', 'bar'], select) + def node_index(caller): + text = "describing the list" + return text, [] + + Notes: + All normal `goto` or `exec` callables returned from the decorated nodes will, if they accept + **kwargs, get a new kwarg 'available_choices' injected. These are the ordered list of named + options (descs) visible on the current node page. + + """ + + def decorator(func): + + def _select_parser(caller, raw_string, **kwargs): + """ + Parse the select action + """ + available_choices = kwargs.get("available_choices", []) + + try: + index = int(raw_string.strip()) - 1 + selection = available_choices[index] + except Exception: + caller.msg("|rInvalid choice.|n") + else: + if select: + try: + return select(caller, selection) + except Exception: + logger.log_trace() + return None + + def _list_node(caller, raw_string, **kwargs): + + option_list = option_generator(caller) \ + if callable(option_generator) else option_generator + + npages = 0 + page_index = 0 + page = [] + options = [] + + if option_list: + nall_options = len(option_list) + pages = [option_list[ind:ind + pagesize] + for ind in range(0, nall_options, pagesize)] + npages = len(pages) + + page_index = max(0, min(npages - 1, kwargs.get("optionpage_index", 0))) + page = pages[page_index] + + text = "" + extra_text = None + + # dynamic, multi-page option list. Each selection leads to the `select` + # callback being called with a result from the available choices + options.extend([{"desc": opt, + "goto": (_select_parser, + {"available_choices": page})} for opt in page]) + + if npages > 1: + # if the goto callable returns None, the same node is rerun, and + # kwargs not used by the callable are passed on to the node. This + # allows us to call ourselves over and over, using different kwargs. + options.append({"key": ("|Wcurrent|n", "c"), + "desc": "|W({}/{})|n".format(page_index + 1, npages), + "goto": (lambda caller: None, + {"optionpage_index": page_index})}) + if page_index > 0: + options.append({"key": ("|wp|Wrevious page|n", "p"), + "goto": (lambda caller: None, + {"optionpage_index": page_index - 1})}) + if page_index < npages - 1: + options.append({"key": ("|wn|Wext page|n", "n"), + "goto": (lambda caller: None, + {"optionpage_index": page_index + 1})}) + + # add data from the decorated node + + decorated_options = [] + try: + text, decorated_options = func(caller, raw_string) + except TypeError: + try: + text, decorated_options = func(caller) + except Exception: + raise + except Exception: + logger.log_trace() + else: + if isinstance(decorated_options, {}): + decorated_options = [decorated_options] + else: + decorated_options = make_iter(decorated_options) + + extra_options = [] + for eopt in decorated_options: + cback = ("goto" in eopt and "goto") or ("exec" in eopt and "exec") or None + if cback: + signature = eopt[cback] + if callable(signature): + # callable with no kwargs defined + eopt[cback] = (signature, {"available_choices": page}) + elif is_iter(signature): + if len(signature) > 1 and isinstance(signature[1], dict): + signature[1]["available_choices"] = page + eopt[cback] = signature + elif signature: + # a callable alone in a tuple (i.e. no previous kwargs) + eopt[cback] = (signature[0], {"available_choices": page}) + else: + # malformed input. + logger.log_err("EvMenu @list_node decorator found " + "malformed option to decorate: {}".format(eopt)) + extra_options.append(eopt) + + options.extend(extra_options) + text = text + "\n\n" + extra_text if extra_text else text + + return text, options + + return _list_node + return decorator + + # ------------------------------------------------------------------------------------------------- # # Simple input shortcuts From b571d6fdd46dc3ccfcf99b17fa9f03e9fd53e45c Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 20 Apr 2018 19:51:12 +0200 Subject: [PATCH 291/466] Fix unittests --- evennia/contrib/tests.py | 18 +++++++++--------- evennia/utils/evmenu.py | 6 +++++- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 30bf71dcc8..be5921ff67 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -670,7 +670,7 @@ 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 @@ -798,7 +798,7 @@ from evennia.contrib import talking_npc class TestTalkingNPC(CommandTest): def test_talkingnpc(self): npc = create_object(talking_npc.TalkingNPC, key="npctalker", location=self.room1) - self.call(talking_npc.CmdTalk(), "", "(You walk up and talk to Char.)|") + self.call(talking_npc.CmdTalk(), "", "(You walk up and talk to Char.)") npc.delete() @@ -966,7 +966,7 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_basic.CmdPass(), "", "You can only do that in combat. (see: help fight)") self.call(tb_basic.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. @@ -984,7 +984,7 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_equip.CmdPass(), "", "You can only do that in combat. (see: help fight)") self.call(tb_equip.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. @@ -998,7 +998,7 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_range.CmdPass(), "", "You can only do that in combat. (see: help fight)") self.call(tb_range.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_range.CmdRest(), "", "Char rests to recover HP.") - + class TestTurnBattleFunc(EvenniaTest): @@ -1080,7 +1080,7 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) # Remove the script at the end turnhandler.stop() - + # Test the combat functions in tb_equip too. They work mostly the same. def test_tbequipfunc(self): attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker") @@ -1159,7 +1159,7 @@ 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") @@ -1264,7 +1264,7 @@ Bar -Qux""" class TestTreeSelectFunc(EvenniaTest): - + def test_tree_functions(self): # Dash counter self.assertTrue(tree_select.dashcount("--test") == 2) @@ -1279,7 +1279,7 @@ class TestTreeSelectFunc(EvenniaTest): # 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': 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) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index a2a92f5e34..0e494ca3e0 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -976,7 +976,11 @@ class EvMenu(object): node (str): The formatted node to display. """ - screen_width = self._session.protocol_flags.get("SCREENWIDTH", {0: 78})[0] + if self._session: + screen_width = self._session.protocol_flags.get( + "SCREENWIDTH", {0: _MAX_TEXT_WIDTH})[0] + else: + screen_width = _MAX_TEXT_WIDTH nodetext_width_max = max(m_len(line) for line in nodetext.split("\n")) options_width_max = max(m_len(line) for line in optionstext.split("\n")) From acc651b2feb744f6a77b169bb60038c51e64fb33 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 21 Apr 2018 15:31:30 +0200 Subject: [PATCH 292/466] Inject selection in list_node decorator if select kwarg is a string --- evennia/utils/evmenu.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 0e494ca3e0..f6806c06b8 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1005,10 +1005,12 @@ def list_node(option_generator, select=None, pagesize=10): Args: option_generator (callable or list): A list of strings indicating the options, or a callable that is called as option_generator(caller) to produce such a list. - select (callable, option): Will be called as select(caller, menuchoice) - where menuchoice is the chosen option as a string. Should return the target node to - goto after this selection (or None to repeat the list-node). Note that if this is not - given, the decorated node must itself provide a way to continue from the node! + select (callable or str, optional): Node to redirect a selection to. Its `**kwargs` will + contain the `available_choices` list and `selection` will hold one of the elements in + that list. If a callable, it will be called as select(caller, menuchoice) where + menuchoice is the chosen option as a string. Should return the target node to goto after + this selection (or None to repeat the list-node). Note that if this is not given, the + decorated node must itself provide a way to continue from the node! pagesize (int): How many options to show per page. Example: @@ -1038,11 +1040,16 @@ def list_node(option_generator, select=None, pagesize=10): except Exception: caller.msg("|rInvalid choice.|n") else: - if select: + if callable(select): try: return select(caller, selection) except Exception: logger.log_trace() + else: + # we assume a string was given, we inject the result into the kwargs + # to pass on to the next node + kwargs['selection'] = selection + return str(select) return None def _list_node(caller, raw_string, **kwargs): From a99c1ed74c98dc93fb173f7b01ba0aa883c7fe1b Mon Sep 17 00:00:00 2001 From: friarzen Date: Sat, 21 Apr 2018 17:03:01 +0000 Subject: [PATCH 293/466] Attempt to make append/replace dialog text more clear --- .../webclient/static/webclient/js/webclient_gui.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/evennia/web/webclient/static/webclient/js/webclient_gui.js b/evennia/web/webclient/static/webclient/js/webclient_gui.js index 8929a7529c..e1ed4d31fd 100644 --- a/evennia/web/webclient/static/webclient/js/webclient_gui.js +++ b/evennia/web/webclient/static/webclient/js/webclient_gui.js @@ -484,13 +484,13 @@ function onSplitDialog() { dialog.append(''); dialog.append(''); - dialog.append("

New First Pane Flow

"); - dialog.append('append
'); - dialog.append('replace
'); + dialog.append("

New First Pane

"); + dialog.append('append new incoming messages
'); + dialog.append('replace old messages with new ones
'); - dialog.append("

New Second Pane Flow

"); - dialog.append('append
'); - dialog.append('replace
'); + dialog.append("

New Second Pane

"); + dialog.append('append new incoming messages
'); + dialog.append('replace old messages with new ones
'); dialog.append('
Split It
'); From f57192f9f1ce81eda9059335bfd56e0877ea071a Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 22 Apr 2018 13:39:47 +0200 Subject: [PATCH 294/466] Fix error if sending string to list_node select callback --- evennia/utils/evmenu.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index f6806c06b8..0de33de348 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1045,11 +1045,12 @@ def list_node(option_generator, select=None, pagesize=10): return select(caller, selection) except Exception: logger.log_trace() - else: + elif select: # we assume a string was given, we inject the result into the kwargs # to pass on to the next node kwargs['selection'] = selection return str(select) + # this means the previous node will be re-run with these same kwargs return None def _list_node(caller, raw_string, **kwargs): From 04db9292ce10d430f3fc3cd8281cdf1c0aea10cf Mon Sep 17 00:00:00 2001 From: Rachel Blackman Date: Sun, 8 Apr 2018 13:11:35 -0700 Subject: [PATCH 295/466] Add /contains switch to find. --- evennia/commands/default/building.py | 15 ++++++++++----- evennia/commands/default/tests.py | 1 + 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 702f2e241a..a111e2c878 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2270,11 +2270,12 @@ class CmdFind(COMMAND_DEFAULT_CLASS): @locate - this is a shorthand for using the /loc switch. Switches: - room - only look for rooms (location=None) - exit - only look for exits (destination!=None) - char - only look for characters (BASE_CHARACTER_TYPECLASS) - exact- only exact matches are returned. - loc - display object location if exists and match has one result + room - only look for rooms (location=None) + exit - only look for exits (destination!=None) + char - only look for characters (BASE_CHARACTER_TYPECLASS) + exact - only exact matches are returned. + loc - display object location if exists and match has one result + contains- search for names containing the string, rather than starting with. Searches the database for an object of a particular name or exact #dbref. Use *accountname to search for an account. The switches allows for @@ -2359,6 +2360,10 @@ class CmdFind(COMMAND_DEFAULT_CLASS): keyquery = Q(db_key__iexact=searchstring, id__gte=low, id__lte=high) aliasquery = Q(db_tags__db_key__iexact=searchstring, db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high) + elif "contains" in switches: + keyquery = Q(db_key__icontains=searchstring, id__gte=low, id__lte=high) + aliasquery = Q(db_tags__db_key__icontains=searchstring, + db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high) else: keyquery = Q(db_key__istartswith=searchstring, id__gte=low, id__lte=high) aliasquery = Q(db_tags__db_key__istartswith=searchstring, diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 8e73a8bf5d..37e4b07b03 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -339,6 +339,7 @@ class TestBuilding(CommandTest): self.call(building.CmdFind(), "Char2", expect, cmdstring="@locate") self.call(building.CmdFind(), "/l Char2", expect, cmdstring="find") # /l switch is abbreviated form of /loc self.call(building.CmdFind(), "Char2", "One Match", cmdstring="@find") + self.call(building.CmdFind(), "/contains om2", "One Match") def test_script(self): self.call(building.CmdScript(), "Obj = scripts.Script", "Script scripts.Script successfully added") From 32ea0075e6bd631164225a28e6bdbb640dbd15c3 Mon Sep 17 00:00:00 2001 From: Rachel Blackman Date: Sun, 8 Apr 2018 13:30:01 -0700 Subject: [PATCH 296/466] 0.8 has switches defined in the command, need to make the change from the 0.7 changeset this originated from. --- 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 a111e2c878..dee9cd737e 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2286,7 +2286,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS): key = "@find" aliases = "@search, @locate" - switch_options = ("room", "exit", "char", "exact", "loc") + switch_options = ("room", "exit", "char", "exact", "loc", "contains") locks = "cmd:perm(find) or perm(Builder)" help_category = "Building" From bde11edaf0e3f4e5380915b1bb3ec447ceac34b7 Mon Sep 17 00:00:00 2001 From: Rachel Blackman Date: Sat, 21 Apr 2018 17:00:38 -0700 Subject: [PATCH 297/466] Switch /contains to default, add /startswith switch instead. --- evennia/commands/default/building.py | 24 ++++++++++++------------ evennia/commands/default/tests.py | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index dee9cd737e..3cda726881 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2270,12 +2270,12 @@ class CmdFind(COMMAND_DEFAULT_CLASS): @locate - this is a shorthand for using the /loc switch. Switches: - room - only look for rooms (location=None) - exit - only look for exits (destination!=None) - char - only look for characters (BASE_CHARACTER_TYPECLASS) - exact - only exact matches are returned. - loc - display object location if exists and match has one result - contains- search for names containing the string, rather than starting with. + room - only look for rooms (location=None) + exit - only look for exits (destination!=None) + char - only look for characters (BASE_CHARACTER_TYPECLASS) + exact - only exact matches are returned. + loc - display object location if exists and match has one result + startswith - search for names starting with the string, rather than containing Searches the database for an object of a particular name or exact #dbref. Use *accountname to search for an account. The switches allows for @@ -2286,7 +2286,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS): key = "@find" aliases = "@search, @locate" - switch_options = ("room", "exit", "char", "exact", "loc", "contains") + switch_options = ("room", "exit", "char", "exact", "loc", "startswith") locks = "cmd:perm(find) or perm(Builder)" help_category = "Building" @@ -2360,14 +2360,14 @@ class CmdFind(COMMAND_DEFAULT_CLASS): keyquery = Q(db_key__iexact=searchstring, id__gte=low, id__lte=high) aliasquery = Q(db_tags__db_key__iexact=searchstring, db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high) - elif "contains" in switches: - keyquery = Q(db_key__icontains=searchstring, id__gte=low, id__lte=high) - aliasquery = Q(db_tags__db_key__icontains=searchstring, - db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high) - else: + elif "startswith" in switches: keyquery = Q(db_key__istartswith=searchstring, id__gte=low, id__lte=high) aliasquery = Q(db_tags__db_key__istartswith=searchstring, db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high) + else: + keyquery = Q(db_key__icontains=searchstring, id__gte=low, id__lte=high) + aliasquery = Q(db_tags__db_key__icontains=searchstring, + db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high) results = ObjectDB.objects.filter(keyquery | aliasquery).distinct() nresults = results.count() diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 37e4b07b03..f296ca61b6 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -329,7 +329,7 @@ class TestBuilding(CommandTest): self.call(building.CmdLock(), "Obj = test:perm(Developer)", "Added lock 'test:perm(Developer)' to Obj.") def test_find(self): - self.call(building.CmdFind(), "Room2", "One Match") + self.call(building.CmdFind(), "oom2", "One Match") expect = "One Match(#1#7, loc):\n " +\ "Char2(#7) evennia.objects.objects.DefaultCharacter (location: Room(#1))" self.call(building.CmdFind(), "Char2", expect, cmdstring="locate") @@ -339,7 +339,7 @@ class TestBuilding(CommandTest): self.call(building.CmdFind(), "Char2", expect, cmdstring="@locate") self.call(building.CmdFind(), "/l Char2", expect, cmdstring="find") # /l switch is abbreviated form of /loc self.call(building.CmdFind(), "Char2", "One Match", cmdstring="@find") - self.call(building.CmdFind(), "/contains om2", "One Match") + self.call(building.CmdFind(), "/startswith Room2", "One Match") def test_script(self): self.call(building.CmdScript(), "Obj = scripts.Script", "Script scripts.Script successfully added") From c9ecd3b9970566f3ace8c1b1efbaa34107e93f17 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 22 Apr 2018 22:28:43 +0200 Subject: [PATCH 298/466] Add check_lockstring as a function in locks/lockhandler.py --- evennia/locks/lockhandler.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py index b8801f9655..14ac34a989 100644 --- a/evennia/locks/lockhandler.py +++ b/evennia/locks/lockhandler.py @@ -541,6 +541,42 @@ class LockHandler(object): return all(self._eval_access_type(accessing_obj, locks, access_type) for access_type in locks) +# convenience access function + +# dummy to be able to call check_lockstring from the outside +_LOCK_HANDLER = LockHandler() + + +def check_lockstring(self, accessing_obj, lockstring, no_superuser_bypass=False, + default=False, access_type=None): + """ + Do a direct check against a lockstring ('atype:func()..'), + without any intermediary storage on the accessed object. + + Args: + accessing_obj (object or None): The object seeking access. + Importantly, this can be left unset if the lock functions + don't access it, no updating or storage of locks are made + against this object in this method. + lockstring (str): Lock string to check, on the form + `"access_type:lock_definition"` where the `access_type` + part can potentially be set to a dummy value to just check + a lock condition. + no_superuser_bypass (bool, optional): Force superusers to heed lock. + default (bool, optional): Fallback result to use if `access_type` is set + but no such `access_type` is found in the given `lockstring`. + access_type (str, bool): If set, only this access_type will be looked up + among the locks defined by `lockstring`. + + Return: + access (bool): If check is passed or not. + + """ + return _LOCK_HANDLER.check_lockstring( + accessing_obj, lockstring, no_superuser_bypass=no_superuser_bypass, + default=default, access_type=access_type) + + def _test(): # testing From 2f985c882b926ddda2d9ac8ef1e915e38b771c60 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 25 Apr 2018 22:34:33 +0200 Subject: [PATCH 299/466] Fix lockhandler singleton implmentation with a dummyobj --- evennia/locks/lockhandler.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py index 14ac34a989..14556579d7 100644 --- a/evennia/locks/lockhandler.py +++ b/evennia/locks/lockhandler.py @@ -544,7 +544,11 @@ class LockHandler(object): # convenience access function # dummy to be able to call check_lockstring from the outside -_LOCK_HANDLER = LockHandler() + +class _ObjDummy: + lock_storage = '' + +_LOCK_HANDLER = LockHandler(_ObjDummy()) def check_lockstring(self, accessing_obj, lockstring, no_superuser_bypass=False, From b12b466fe259e7ebc290e0253fa3b13d13de42fc Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 10 May 2018 20:37:21 +0200 Subject: [PATCH 300/466] Auto-tag spawned objects. Clean up unit tests --- evennia/settings_default.py | 6 +- evennia/utils/inlinefuncs.py | 11 +- evennia/utils/spawner.py | 272 +++++++++++++++++++++------- evennia/utils/tests/test_spawner.py | 48 +++-- 4 files changed, 251 insertions(+), 86 deletions(-) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index a5c4b7255d..1d7adb4375 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -513,7 +513,7 @@ TIME_GAME_EPOCH = None TIME_IGNORE_DOWNTIMES = False ###################################################################### -# Inlinefunc +# Inlinefunc & PrototypeFuncs ###################################################################### # Evennia supports inline function preprocessing. This allows users # to supply inline calls on the form $func(arg, arg, ...) to do @@ -525,6 +525,10 @@ INLINEFUNC_ENABLED = False # is loaded from left-to-right, same-named functions will overload INLINEFUNC_MODULES = ["evennia.utils.inlinefuncs", "server.conf.inlinefuncs"] +# Module holding handlers for OLCFuncs. These allow for embedding +# functional code in prototypes +PROTOTYPEFUNC_MODULES = ["evennia.utils.prototypefuncs", + "server.conf.prototypefuncs"] ###################################################################### # Default Account setup and access diff --git a/evennia/utils/inlinefuncs.py b/evennia/utils/inlinefuncs.py index e103e217d7..2646fb3991 100644 --- a/evennia/utils/inlinefuncs.py +++ b/evennia/utils/inlinefuncs.py @@ -257,7 +257,7 @@ class InlinefuncError(RuntimeError): pass -def parse_inlinefunc(string, strip=False, **kwargs): +def parse_inlinefunc(string, strip=False, _available_funcs=None, **kwargs): """ Parse the incoming string. @@ -265,6 +265,8 @@ def parse_inlinefunc(string, strip=False, **kwargs): string (str): The incoming string to parse. strip (bool, optional): Whether to strip function calls rather than execute them. + _available_funcs(dict, optional): Define an alterinative source of functions to parse for. + If unset, use the functions found through `settings.INLINEFUNC_MODULES`. Kwargs: session (Session): This is sent to this function by Evennia when triggering it. It is passed to the inlinefunc. @@ -273,6 +275,9 @@ def parse_inlinefunc(string, strip=False, **kwargs): """ global _PARSING_CACHE + + _available_funcs = _INLINE_FUNCS if _available_funcs is None else _available_funcs + if string in _PARSING_CACHE: # stack is already cached stack = _PARSING_CACHE[string] @@ -309,9 +314,9 @@ def parse_inlinefunc(string, strip=False, **kwargs): funcname = _RE_STARTTOKEN.match(gdict["start"]).group(1) try: # try to fetch the matching inlinefunc from storage - stack.append(_INLINE_FUNCS[funcname]) + stack.append(_available_funcs[funcname]) except KeyError: - stack.append(_INLINE_FUNCS["nomatch"]) + stack.append(_available_funcs["nomatch"]) stack.append(funcname) ncallable += 1 elif gdict["escaped"]: diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 1916c2210e..daf4b23c3f 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -22,28 +22,41 @@ GOBLIN = { ``` Possible keywords are: - prototype - string parent prototype - key - string, the main object identifier - typeclass - string, if not set, will use `settings.BASE_OBJECT_TYPECLASS` - location - this should be a valid object or #dbref - home - valid object or #dbref - destination - only valid for exits (object or dbref) + prototype_key (str): name of this prototype. This is used when storing prototypes and should + be unique. This should always be defined but for prototypes defined in modules, the + variable holding the prototype dict will become the prototype_key if it's not explicitly + given. + prototype_desc (str, optional): describes prototype in listings + prototype_locks (str, optional): locks for restricting access to this prototype. Locktypes + supported are 'edit' and 'use'. + prototype_tags(list, optional): List of tags or tuples (tag, category) used to group prototype + in listings - permissions - string or list of permission strings - locks - a lock-string - aliases - string or list of strings - exec - this is a string of python code to execute or a list of such codes. - This can be used e.g. to trigger custom handlers on the object. The - execution namespace contains 'evennia' for the library and 'obj' - tags - string or list of strings or tuples `(tagstr, category)`. Plain - strings will be result in tags with no category (default tags). - attrs - tuple or list of tuples of Attributes to add. This form allows - more complex Attributes to be set. Tuples at least specify `(key, value)` - but can also specify up to `(key, value, category, lockstring)`. If - you want to specify a lockstring but not a category, set the category - to `None`. - ndb_ - value of a nattribute (ndb_ is stripped) - other - any other name is interpreted as the key of an Attribute with + prototype (str or callable, optional): bame (prototype_key) of eventual parent prototype + typeclass (str or callable, optional): if not set, will use typeclass of parent prototype or use + `settings.BASE_OBJECT_TYPECLASS` + key (str or callable, optional): the name of the spawned object. If not given this will set to a + random hash + location (obj, str or callable, optional): location of the object - a valid object or #dbref + home (obj, str or callable, optional): valid object or #dbref + destination (obj, str or callable, optional): only valid for exits (object or #dbref) + + permissions (str, list or callable, optional): which permissions for spawned object to have + locks (str or callable, optional): lock-string for the spawned object + aliases (str, list or callable, optional): Aliases for the spawned object + exec (str or callable, optional): this is a string of python code to execute or a list of such + codes. This can be used e.g. to trigger custom handlers on the object. The execution + namespace contains 'evennia' for the library and 'obj'. All default spawn commands limit + this functionality to Developer/superusers. Usually it's better to use callables or + prototypefuncs instead of this. + tags (str, tuple, list or callable, optional): string or list of strings or tuples + `(tagstr, category)`. Plain strings will be result in tags with no category (default tags). + attrs (tuple, list or callable, optional): tuple or list of tuples of Attributes to add. This + form allows more complex Attributes to be set. Tuples at least specify `(key, value)` + but can also specify up to `(key, value, category, lockstring)`. If you want to specify a + lockstring but not a category, set the category to `None`. + ndb_ (any): value of a nattribute (ndb_ is stripped) + other (any): any other name is interpreted as the key of an Attribute with its value. Such Attributes have no categories. Each value can also be a callable that takes no arguments. It should @@ -56,6 +69,9 @@ that prototype, inheritng all prototype slots it does not explicitly define itself, while overloading those that it does specify. ```python +import random + + GOBLIN_WIZARD = { "prototype": GOBLIN, "key": "goblin wizard", @@ -65,6 +81,7 @@ GOBLIN_WIZARD = { GOBLIN_ARCHER = { "prototype": GOBLIN, "key": "goblin archer", + "attack_skill": (random, (5, 10))" "attacks": ["short bow"] } ``` @@ -105,15 +122,18 @@ prototype, override its name with an empty dict. from __future__ import print_function import copy +import hashlib +import time from ast import literal_eval from django.conf import settings from random import randint import evennia from evennia.objects.models import ObjectDB from evennia.utils.utils import ( - make_iter, all_from_module, dbid_to_obj, is_iter, crop, get_all_typeclasses) + make_iter, all_from_module, callables_from_module, dbid_to_obj, + is_iter, crop, get_all_typeclasses) +from evennia.utils import inlinefuncs -from collections import namedtuple from evennia.scripts.scripts import DefaultScript from evennia.utils.create import create_script from evennia.utils.evtable import EvTable @@ -126,7 +146,9 @@ _PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "p _NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES _MODULE_PROTOTYPES = {} _MODULE_PROTOTYPE_MODULES = {} +_PROTOTYPEFUNCS = {} _MENU_CROP_WIDTH = 15 +_PROTOTYPE_TAG_CATEGORY = "spawned_by_prototype" _MENU_ATTR_LITERAL_EVAL_ERROR = ( "|rCritical Python syntax error in your value. Only primitive Python structures are allowed.\n" @@ -138,6 +160,9 @@ class PermissionError(RuntimeError): pass +# load resources + + for mod in settings.PROTOTYPE_MODULES: # to remove a default prototype, override it with an empty dict. # internally we store as (key, desc, locks, tags, prototype_dict) @@ -148,7 +173,7 @@ for mod in settings.PROTOTYPE_MODULES: # make sure the prototype contains all meta info for prototype_key, prot in prots: prot.update({ - "prototype_key": prototype_key.lower(), + "prototype_key": prot.get('prototype_key', prototype_key.lower()), "prototype_desc": prot['prototype_desc'] if 'prototype_desc' in prot else mod, "prototype_locks": prot['prototype_locks'] if 'prototype_locks' in prot else "use:all()", "prototype_tags": set(make_iter(prot['prototype_tags']) @@ -156,6 +181,81 @@ for mod in settings.PROTOTYPE_MODULES: _MODULE_PROTOTYPES[prototype_key] = prot +for mod in settings.PROTOTYPEFUNC_MODULES: + try: + _PROTOTYPEFUNCS.update(callables_from_module(mod)) + except ImportError: + pass + + +# Helper functions + + +def olcfunc_parser(value, available_functions=None, **kwargs): + """ + This is intended to be used by the in-game olc mechanism. It will parse the prototype + value for function tokens like `$olcfunc(arg, arg, ...)`. These functions behave all the + parameters of `inlinefuncs` but they are *not* passed a Session since this is not guaranteed to + be available at the time of spawning. They may also return other structures than strings. + + Available olcfuncs are specified as callables in one of the modules of + `settings.PROTOTYPEFUNC_MODULES`, or specified on the command line. + + Args: + value (string): The value to test for a parseable olcfunc. + available_functions (dict, optional): Mapping of name:olcfunction to use for this parsing. + + Kwargs: + any (any): Passed on to the inlinefunc. + + Returns: + any (any): A structure to replace the string on the prototype level. If this is a + callable or a (callable, (args,)) structure, it will be executed as if one had supplied + it to the prototype directly. + + """ + if not isinstance(basestring, value): + return value + available_functions = _PROTOTYPEFUNCS if available_functions is None else available_functions + return inlinefuncs.parse_inlinefunc(value, _available_funcs=available_functions) + + +def _to_obj(value, force=True): + return dbid_to_obj(value, ObjectDB) + + +def _to_obj_or_any(value): + obj = dbid_to_obj(value, ObjectDB) + return obj if obj is not None else value + + +def validate_spawn_value(value, validator=None): + """ + Analyze the value and produce a value for use at the point of spawning. + + Args: + value (any): This can be:j + callable - will be called as callable() + (callable, (args,)) - will be called as callable(*args) + other - will be assigned depending on the variable type + validator (callable, optional): If given, this will be called with the value to + check and guarantee the outcome is of a given type. + + Returns: + any (any): The (potentially pre-processed value to use for this prototype key) + + """ + validator = validator if validator else lambda o: o + if callable(value): + return validator(value()) + elif value and is_iter(value) and callable(value[0]): + # a structure (callable, (args, )) + args = value[1:] + return validator(value[0](*make_iter(args))) + else: + return validator(value) + + # Prototype storage mechanisms @@ -384,6 +484,20 @@ def search_prototype(key=None, tags=None): return matches +def search_objects_with_prototype(prototype_key): + """ + Retrieve all object instances created by a given prototype. + + Args: + prototype_key (str): The exact (and unique) prototype identifier to query for. + + Returns: + matches (Queryset): All matching objects spawned from this prototype. + + """ + return ObjectDB.objects.get_by_tag(key=prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + + def get_protparent_dict(): """ Get prototype parents. @@ -401,7 +515,7 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed Args: caller (Account or Object): The object requesting the list. - key (str, optional): Exact or partial key to query for. + key (str, optional): Exact or partial prototype key to query for. tags (str or list, optional): Tag key or keys to query for. show_non_use (bool, optional): Show also prototypes the caller may not use. show_non_edit (bool, optional): Show also prototypes the caller may not edit. @@ -427,23 +541,34 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed caller, prototype.get('prototype_locks', ''), access_type='edit') if not show_non_edit and not lock_edit: continue + ptags = [] + for ptag in prototype.get('prototype_tags', []): + if is_iter(ptag): + if len(ptag) > 1: + ptags.append("{} (category: {}".format(ptag[0], ptag[1])) + else: + ptags.append(ptag[0]) + else: + ptags.append(str(ptag)) + display_tuples.append( (prototype.get('prototype_key', ''), prototype.get('prototype_desc', ''), "{}/{}".format('Y' if lock_use else 'N', 'Y' if lock_edit else 'N'), - ",".join(prototype.get('prototype_tags', [])))) + ",".join(ptags))) if not display_tuples: return None table = [] + width = 78 for i in range(len(display_tuples[0])): table.append([str(display_tuple[i]) for display_tuple in display_tuples]) - table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=78) - table.reformat_column(0, width=28) - table.reformat_column(1, width=40) - table.reformat_column(2, width=11, align='r') - table.reformat_column(3, width=20) + table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=width) + table.reformat_column(0, width=22) + table.reformat_column(1, width=31) + table.reformat_column(2, width=9, align='r') + table.reformat_column(3, width=16) return table @@ -472,17 +597,14 @@ def prototype_to_str(prototype): # Spawner mechanism -def _handle_dbref(inp): - return dbid_to_obj(inp, ObjectDB) - - def validate_prototype(prototype, protkey=None, protparents=None, _visited=None): """ Run validation on a prototype, checking for inifinite regress. Args: prototype (dict): Prototype to validate. - protkey (str, optional): The name of the prototype definition, if any. + protkey (str, optional): The name of the prototype definition. If not given, the prototype + dict needs to have the `prototype_key` field set. protpartents (dict, optional): The available prototype parent library. If note given this will be determined from settings/database. _visited (list, optional): This is an internal work array and should not be set manually. @@ -494,9 +616,8 @@ def validate_prototype(prototype, protkey=None, protparents=None, _visited=None) protparents = get_protparent_dict() if _visited is None: _visited = [] - protkey = protkey or prototype.get('prototype_key', None) - protkey = protkey.lower() or prototype.get('prototype_key', None) + protkey = protkey and protkey.lower() or prototype.get('prototype_key', "") assert isinstance(prototype, dict) @@ -619,9 +740,12 @@ def spawn(*prototypes, **kwargs): return_prototypes (bool): Only return a list of the prototype-parents (no object creation happens) + Returns: + object (Object): Spawned object. + """ # get available protparents - protparents = get_protparents() + protparents = get_protparent_dict() # overload module's protparents with specifically given protparents protparents.update(kwargs.get("prototype_parents", {})) @@ -643,47 +767,61 @@ def spawn(*prototypes, **kwargs): # extract the keyword args we need to create the object itself. If we get a callable, # call that to get the value (don't catch errors) create_kwargs = {} - keyval = prot.pop("key", "Spawned Object %06i" % randint(1, 100000)) - create_kwargs["db_key"] = keyval() if callable(keyval) else keyval + # we must always add a key, so if not given we use a shortened md5 hash. There is a (small) + # chance this is not unique but it should usually not be a problem. + val = prot.pop("key", "Spawned-{}".format( + hashlib.md5(str(time.time())).hexdigest()[:6])) + create_kwargs["db_key"] = validate_spawn_value(val, str) - locval = prot.pop("location", None) - create_kwargs["db_location"] = locval() if callable(locval) else _handle_dbref(locval) + val = prot.pop("location", None) + create_kwargs["db_location"] = validate_spawn_value(val, _to_obj) - homval = prot.pop("home", settings.DEFAULT_HOME) - create_kwargs["db_home"] = homval() if callable(homval) else _handle_dbref(homval) + val = prot.pop("home", settings.DEFAULT_HOME) + create_kwargs["db_home"] = validate_spawn_value(val, _to_obj) - destval = prot.pop("destination", None) - create_kwargs["db_destination"] = destval() if callable(destval) else _handle_dbref(destval) + val = prot.pop("destination", None) + create_kwargs["db_destination"] = validate_spawn_value(val, _to_obj) - typval = prot.pop("typeclass", settings.BASE_OBJECT_TYPECLASS) - create_kwargs["db_typeclass_path"] = typval() if callable(typval) else typval + val = prot.pop("typeclass", settings.BASE_OBJECT_TYPECLASS) + create_kwargs["db_typeclass_path"] = validate_spawn_value(val, str) # extract calls to handlers - permval = prot.pop("permissions", []) - permission_string = permval() if callable(permval) else permval - lockval = prot.pop("locks", "") - lock_string = lockval() if callable(lockval) else lockval - aliasval = prot.pop("aliases", "") - alias_string = aliasval() if callable(aliasval) else aliasval - tagval = prot.pop("tags", []) - tags = tagval() if callable(tagval) else tagval + val = prot.pop("permissions", []) + permission_string = validate_spawn_value(val, make_iter) + val = prot.pop("locks", "") + lock_string = validate_spawn_value(val, str) + val = prot.pop("aliases", []) + alias_string = validate_spawn_value(val, make_iter) + + val = prot.pop("tags", []) + tags = validate_spawn_value(val, make_iter) # we make sure to add a tag identifying which prototype created this object - # tags.append(()) + tags.append((prototype['prototype_key'], _PROTOTYPE_TAG_CATEGORY)) - attrval = prot.pop("attrs", []) - attributes = attrval() if callable(tagval) else attrval - - exval = prot.pop("exec", "") - execs = make_iter(exval() if callable(exval) else exval) + val = prot.pop("exec", "") + execs = validate_spawn_value(val, make_iter) # extract ndb assignments - nattributes = dict((key.split("_", 1)[1], value() if callable(value) else value) - for key, value in prot.items() if key.startswith("ndb_")) + nattributes = dict((key.split("_", 1)[1], validate_spawn_value(val, _to_obj)) + for key, val in prot.items() if key.startswith("ndb_")) # the rest are attributes - simple_attributes = [(key, value()) if callable(value) else (key, value) - for key, value in prot.items() if not (key.startswith("ndb_"))] + val = prot.pop("attrs", []) + attributes = validate_spawn_value(val, list) + + simple_attributes = [] + for key, value in ((key, value) for key, value in prot.items() + if not (key.startswith("ndb_"))): + if is_iter(value) and len(value) > 1: + # (value, category) + simple_attributes.append((key, + validate_spawn_value(value[0], _to_obj_or_any), + validate_spawn_value(value[1], str))) + else: + simple_attributes.append((key, + validate_spawn_value(value, _to_obj_or_any))) + attributes = attributes + simple_attributes attributes = [tup for tup in attributes if not tup[0] in _NON_CREATE_KWARGS] diff --git a/evennia/utils/tests/test_spawner.py b/evennia/utils/tests/test_spawner.py index e29ee8c151..4d680a9e8a 100644 --- a/evennia/utils/tests/test_spawner.py +++ b/evennia/utils/tests/test_spawner.py @@ -7,16 +7,29 @@ from evennia.utils.test_resources import EvenniaTest from evennia.utils import spawner +class TestSpawner(EvenniaTest): + + def setUp(self): + super(TestSpawner, self).setUp() + self.prot1 = {"prototype_key": "testprototype"} + + def test_spawn(self): + obj1 = spawner.spawn(self.prot1) + # check spawned objects have the right tag + self.assertEqual(list(spawner.search_objects_with_prototype("testprototype")), obj1) + + class TestPrototypeStorage(EvenniaTest): def setUp(self): super(TestPrototypeStorage, self).setUp() - self.prot1 = {"key": "testprototype"} - self.prot2 = {"key": "testprototype2"} - self.prot3 = {"key": "testprototype3"} + self.prot1 = {"prototype_key": "testprototype"} + self.prot2 = {"prototype_key": "testprototype2"} + self.prot3 = {"prototype_key": "testprototype3"} def _get_metaproto( - self, key='testprototype', desc='testprototype', locks=['edit:id(6) or perm(Admin)', 'use:all()'], + self, key='testprototype', desc='testprototype', + locks=['edit:id(6) or perm(Admin)', 'use:all()'], tags=[], prototype={"key": "testprototype"}): return spawner.build_metaproto(key, desc, locks, tags, prototype) @@ -28,34 +41,39 @@ class TestPrototypeStorage(EvenniaTest): def test_prototype_storage(self): - prot = spawner.save_db_prototype(self.char1, "testprot", self.prot1, desc='testdesc0', tags=["foo"]) + prot = spawner.save_db_prototype(self.char1, self.prot1, "testprot", + desc='testdesc0', tags=["foo"]) self.assertTrue(bool(prot)) self.assertEqual(prot.db.prototype, self.prot1) self.assertEqual(prot.desc, "testdesc0") - prot = spawner.save_db_prototype(self.char1, "testprot", self.prot1, desc='testdesc', tags=["fooB"]) + prot = spawner.save_db_prototype(self.char1, self.prot1, "testprot", + desc='testdesc', tags=["fooB"]) self.assertEqual(prot.db.prototype, self.prot1) self.assertEqual(prot.desc, "testdesc") self.assertTrue(bool(prot.tags.get("fooB", "db_prototype"))) self.assertEqual(list(prot.__class__.objects.get_by_tag("foo", "db_prototype")), [prot]) - prot2 = spawner.save_db_prototype(self.char1, "testprot2", self.prot2, desc='testdesc2b', tags=["foo"]) - self.assertEqual(list(prot.__class__.objects.get_by_tag("foo", "db_prototype")), [prot, prot2]) + prot2 = spawner.save_db_prototype(self.char1, self.prot2, "testprot2", + desc='testdesc2b', tags=["foo"]) + self.assertEqual( + list(prot.__class__.objects.get_by_tag("foo", "db_prototype")), [prot, prot2]) - prot3 = spawner.save_db_prototype(self.char1, "testprot2", self.prot3, desc='testdesc2') + prot3 = spawner.save_db_prototype(self.char1, self.prot3, "testprot2", desc='testdesc2') self.assertEqual(prot2.id, prot3.id) - self.assertEqual(list(prot.__class__.objects.get_by_tag("foo", "db_prototype")), [prot, prot2]) + self.assertEqual( + list(prot.__class__.objects.get_by_tag("foo", "db_prototype")), [prot, prot2]) # returns DBPrototype - self.assertEqual(list(spawner.search_db_prototype("testprot")), [prot]) + self.assertEqual(list(spawner.search_db_prototype("testprot", return_queryset=True)), [prot]) - # returns metaprotos - prot = self._to_metaproto(prot) - prot3 = self._to_metaproto(prot3) + prot = prot.db.prototype + prot3 = prot3.db.prototype self.assertEqual(list(spawner.search_prototype("testprot")), [prot]) - self.assertEqual(list(spawner.search_prototype("testprot", return_meta=False)), [self.prot1]) + self.assertEqual( + list(spawner.search_prototype("testprot")), [self.prot1]) # partial match self.assertEqual(list(spawner.search_prototype("prot")), [prot, prot3]) self.assertEqual(list(spawner.search_prototype(tags="foo")), [prot, prot3]) From b5a1e8ddea462836bd0fe0e1191be3e0b32fd361 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 10 May 2018 22:28:16 +0200 Subject: [PATCH 301/466] Work to test functionality --- evennia/commands/default/building.py | 8 +++++++- evennia/commands/default/tests.py | 5 +++-- evennia/utils/spawner.py | 30 +++++++++++++++++----------- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 8616b7dafa..b41b5c40e9 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2840,8 +2840,14 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): |wdestination|n - only valid for exits (object or dbref) |wpermissions|n - string or list of permission strings |wlocks |n - a lock-string - |waliases |n - string or list of strings + |waliases |n - string or list of strings. |wndb_|n - value of a nattribute (ndb_ is stripped) + + |wprototype_key|n - name of this prototype. Used to store/retrieve from db + |wprototype_desc|n - desc of this prototype. Used in listings + |wprototype_locks|n - locks of this prototype. Limits who may use prototype + |wprototype_tags|n - tags of this prototype. Used to find prototype + any other keywords are interpreted as Attributes and their values. The available prototypes are defined globally in modules set in diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 950b934125..ffb877c3e3 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -369,7 +369,8 @@ class TestBuilding(CommandTest): # Tests "@spawn " without specifying location. self.call(building.CmdSpawn(), - "{'key':'goblin', 'typeclass':'evennia.DefaultCharacter'}", "Spawned goblin") + "{'prototype_key': 'testprot', 'key':'goblin', " + "'typeclass':'evennia.DefaultCharacter'}", "Spawned goblin") goblin = getObject(self, "goblin") # Tests that the spawned object's type is a DefaultCharacter. @@ -394,7 +395,7 @@ class TestBuilding(CommandTest): self.assertEqual(goblin.location, spawnLoc) goblin.delete() - spawner.save_db_prototype(self.char1, "ball", {'key': 'Ball', 'prototype': 'GOBLIN'}) + spawner.save_db_prototype(self.char1, {'key': 'Ball', 'prototype': 'GOBLIN'}, 'ball') # Tests "@spawn " self.call(building.CmdSpawn(), "ball", "Spawned Ball") diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index daf4b23c3f..335eea9341 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -172,13 +172,14 @@ for mod in settings.PROTOTYPE_MODULES: _MODULE_PROTOTYPE_MODULES.update({prototype_key: mod for prototype_key, _ in prots}) # make sure the prototype contains all meta info for prototype_key, prot in prots: + actual_prot_key = prot.get('prototype_key', prototype_key).lower() prot.update({ - "prototype_key": prot.get('prototype_key', prototype_key.lower()), + "prototype_key": actual_prot_key, "prototype_desc": prot['prototype_desc'] if 'prototype_desc' in prot else mod, - "prototype_locks": prot['prototype_locks'] if 'prototype_locks' in prot else "use:all()", - "prototype_tags": set(make_iter(prot['prototype_tags']) - if 'prototype_tags' in prot else ["base-prototype"])}) - _MODULE_PROTOTYPES[prototype_key] = prot + "prototype_locks": (prot['prototype_locks'] + if 'prototype_locks' in prot else "use:all();edit:false()"), + "prototype_tags": list(set(make_iter(prot.get('prototype_tags', [])) + ["module"]))}) + _MODULE_PROTOTYPES[actual_prot_key] = prot for mod in settings.PROTOTYPEFUNC_MODULES: @@ -537,8 +538,11 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed caller, prototype.get('prototype_locks', ''), access_type='use') if not show_non_use and not lock_use: continue - lock_edit = caller.locks.check_lockstring( - caller, prototype.get('prototype_locks', ''), access_type='edit') + if prototype.get('prototype_key', '') in _MODULE_PROTOTYPES: + lock_edit = False + else: + lock_edit = caller.locks.check_lockstring( + caller, prototype.get('prototype_locks', ''), access_type='edit') if not show_non_edit and not lock_edit: continue ptags = [] @@ -566,8 +570,8 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed table.append([str(display_tuple[i]) for display_tuple in display_tuples]) table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=width) table.reformat_column(0, width=22) - table.reformat_column(1, width=31) - table.reformat_column(2, width=9, align='r') + table.reformat_column(1, width=29) + table.reformat_column(2, width=11, align='c') table.reformat_column(3, width=16) return table @@ -617,7 +621,7 @@ def validate_prototype(prototype, protkey=None, protparents=None, _visited=None) if _visited is None: _visited = [] - protkey = protkey and protkey.lower() or prototype.get('prototype_key', "") + protkey = protkey and protkey.lower() or prototype.get('prototype_key', None) assert isinstance(prototype, dict) @@ -796,8 +800,10 @@ def spawn(*prototypes, **kwargs): val = prot.pop("tags", []) tags = validate_spawn_value(val, make_iter) - # we make sure to add a tag identifying which prototype created this object - tags.append((prototype['prototype_key'], _PROTOTYPE_TAG_CATEGORY)) + prototype_key = prototype.get('prototype_key', None) + if prototype_key: + # we make sure to add a tag identifying which prototype created this object + tags.append((prototype_key, _PROTOTYPE_TAG_CATEGORY)) val = prot.pop("exec", "") execs = validate_spawn_value(val, make_iter) From 7adb4c7da4436e8b0d8a353aad39eb94c73f8280 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 12 May 2018 12:19:47 +0200 Subject: [PATCH 302/466] Fix unit tests --- evennia/utils/spawner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 335eea9341..c63ddaf868 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -388,7 +388,7 @@ def search_db_prototype(key=None, tags=None, return_queryset=False): tags (str or list): Tag key or keys to query for. These will always be applied with the 'db_protototype' tag category. - return_queryset (bool): Return the database queryset. + return_queryset (bool, optional): Return the database queryset. Return: matches (queryset or list): All found DbPrototypes. If `return_queryset` is not set, this is a list of prototype dicts. @@ -410,7 +410,7 @@ def search_db_prototype(key=None, tags=None, return_queryset=False): matches = matches.filter(db_key=key) or matches.filter(db_key__icontains=key) if not return_queryset: # return prototype - return [dbprot.attributes.get("prototype", {}) for dbprot in matches] + matches = [dict(dbprot.attributes.get("prototype", {})) for dbprot in matches] return matches From 45578d0106d9415c0d721e1e1bcb9a0f9da03478 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 13 May 2018 14:50:48 +0200 Subject: [PATCH 303/466] Unittests pass --- evennia/utils/spawner.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index c63ddaf868..aa62a1dcad 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -192,19 +192,19 @@ for mod in settings.PROTOTYPEFUNC_MODULES: # Helper functions -def olcfunc_parser(value, available_functions=None, **kwargs): +def protfunc_parser(value, available_functions=None, **kwargs): """ This is intended to be used by the in-game olc mechanism. It will parse the prototype - value for function tokens like `$olcfunc(arg, arg, ...)`. These functions behave all the + value for function tokens like `$protfunc(arg, arg, ...)`. These functions behave all the parameters of `inlinefuncs` but they are *not* passed a Session since this is not guaranteed to be available at the time of spawning. They may also return other structures than strings. - Available olcfuncs are specified as callables in one of the modules of + Available protfuncs are specified as callables in one of the modules of `settings.PROTOTYPEFUNC_MODULES`, or specified on the command line. Args: - value (string): The value to test for a parseable olcfunc. - available_functions (dict, optional): Mapping of name:olcfunction to use for this parsing. + value (string): The value to test for a parseable protfunc. + available_functions (dict, optional): Mapping of name:protfunction to use for this parsing. Kwargs: any (any): Passed on to the inlinefunc. @@ -215,7 +215,7 @@ def olcfunc_parser(value, available_functions=None, **kwargs): it to the prototype directly. """ - if not isinstance(basestring, value): + if not isinstance(value, basestring): return value available_functions = _PROTOTYPEFUNCS if available_functions is None else available_functions return inlinefuncs.parse_inlinefunc(value, _available_funcs=available_functions) @@ -246,6 +246,7 @@ def validate_spawn_value(value, validator=None): any (any): The (potentially pre-processed value to use for this prototype key) """ + value = protfunc_parser(value) validator = validator if validator else lambda o: o if callable(value): return validator(value()) From 571f68ae99dde967d4d4c6a8fb9c063894f9c1a0 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 15 May 2018 15:42:04 +0200 Subject: [PATCH 304/466] Start work on prototype updating --- evennia/commands/default/building.py | 123 +++++++++++++++++---------- evennia/utils/spawner.py | 25 ++++++ 2 files changed, 105 insertions(+), 43 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index b41b5c40e9..4aabc861b1 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -13,10 +13,7 @@ from evennia.utils import create, utils, search from evennia.utils.utils import inherits_from, class_from_module, get_all_typeclasses from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore -from evennia.utils.spawner import (spawn, search_prototype, list_prototypes, - save_db_prototype, validate_prototype, - delete_db_prototype, PermissionError, start_olc, - prototype_to_str) +from evennia.utils import spawner from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -2792,12 +2789,6 @@ class CmdTag(COMMAND_DEFAULT_CLASS): string = "No tags attached to %s." % obj self.caller.msg(string) -# -# To use the prototypes with the @spawn function set -# PROTOTYPE_MODULES = ["commands.prototypes"] -# Reload the server and the prototypes should be available. -# - class CmdSpawn(COMMAND_DEFAULT_CLASS): """ @@ -2810,6 +2801,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): @spawn/search [key][;tag[,tag]] @spawn/list [tag, tag] @spawn/show [] + @spawn/update @spawn/save [;desc[;tag,tag[,...][;lockstring]]] = @spawn/menu [] @@ -2823,6 +2815,10 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): show, examine - inspect prototype by key. If not given, acts like list. save - save a prototype to the database. It will be listable by /list. delete - remove a prototype from database, if allowed to. + update - find existing objects with the same prototype_key and update + them with latest version of given prototype. If given with /save, + will auto-update all objects with the old version of the prototype + without asking first. menu, olc - create/manipulate prototype in a menu interface. Example: @@ -2843,7 +2839,8 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): |waliases |n - string or list of strings. |wndb_|n - value of a nattribute (ndb_ is stripped) - |wprototype_key|n - name of this prototype. Used to store/retrieve from db + |wprototype_key|n - name of this prototype. Unique. Used to store/retrieve from db + and update existing prototyped objects if desired. |wprototype_desc|n - desc of this prototype. Used in listings |wprototype_locks|n - locks of this prototype. Limits who may use prototype |wprototype_tags|n - tags of this prototype. Used to find prototype @@ -2858,7 +2855,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): key = "@spawn" aliases = ["@olc"] - switch_options = ("noloc", "search", "list", "show", "save", "delete", "menu", "olc") + switch_options = ("noloc", "search", "list", "show", "save", "delete", "menu", "olc", "update") locks = "cmd:perm(spawn) or perm(Builder)" help_category = "Building" @@ -2890,7 +2887,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): "use the 'exec' prototype key.") return None try: - validate_prototype(prototype) + spawner.validate_prototype(prototype) except RuntimeError as err: self.caller.msg(str(err)) return @@ -2899,9 +2896,9 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): def _search_show_prototype(query, prototypes=None): # prototype detail if not prototypes: - prototypes = search_prototype(key=query) + prototypes = spawner.search_prototype(key=query) if prototypes: - return "\n".join(prototype_to_str(prot) for prot in prototypes) + return "\n".join(spawner.prototype_to_str(prot) for prot in prototypes) else: return False @@ -2912,7 +2909,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): prototype = None if self.lhs: key = self.lhs - prototype = search_prototype(key=key, return_meta=True) + prototype = spawner.search_prototype(key=key, return_meta=True) if len(prototype) > 1: caller.msg("More than one match for {}:\n{}".format( key, "\n".join(proto.get('prototype_key', '') for proto in prototype))) @@ -2920,7 +2917,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): elif prototype: # one match prototype = prototype[0] - start_olc(caller, session=self.session, prototype=prototype) + spawner.start_olc(caller, session=self.session, prototype=prototype) return if 'search' in self.switches: @@ -2932,7 +2929,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if ';' in self.args: key, tags = (part.strip().lower() for part in self.args.split(";", 1)) tags = [tag.strip() for tag in tags.split(",")] if tags else None - EvMore(caller, unicode(list_prototypes(caller, key=key, tags=tags)), + EvMore(caller, unicode(spawner.list_prototypes(caller, key=key, tags=tags)), exit_on_lastpage=True) return @@ -2950,30 +2947,10 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if 'list' in self.switches: # for list, all optional arguments are tags - EvMore(caller, unicode(list_prototypes(caller, + EvMore(caller, unicode(spawner.list_prototypes(caller, tags=self.lhslist)), exit_on_lastpage=True) return - if 'delete' in self.switches: - # remove db-based prototype - matchstring = _search_show_prototype(self.args) - if matchstring: - question = "\nDo you want to continue deleting? [Y]/N" - string = "|rDeleting prototype:|n\n{}".format(matchstring) - answer = yield(string + question) - if answer.lower() in ["n", "no"]: - caller.msg("|rDeletion cancelled.|n") - return - try: - success = delete_db_prototype(caller, self.args) - except PermissionError as err: - caller.msg("|rError deleting:|R {}|n".format(err)) - caller.msg("Deletion {}.".format( - 'successful' if success else 'failed (does the prototype exist?)')) - return - else: - caller.msg("Could not find prototype '{}'".format(key)) - if 'save' in self.switches: # store a prototype to the database store if not self.args or not self.rhs: @@ -3015,6 +2992,12 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if not prototype: return + # inject the prototype_* keys into the prototype to save + prototype['prototype_key'] = prototype.get('prototype_key', key) + prototype['prototype_desc'] = prototype.get('prototype_desc', desc) + prototype['prototype_tags'] = prototype.get('prototype_tags', tags) + prototype['prototype_locks'] = prototype.get('prototype_locks', lockstring) + # present prototype to save new_matchstring = _search_show_prototype("", prototypes=[prototype]) string = "|yCreating new prototype:|n\n{}".format(new_matchstring) @@ -3034,7 +3017,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # all seems ok. Try to save. try: - prot = save_db_prototype( + prot = spawner.save_db_prototype( caller, key, prototype, desc=desc, tags=tags, locks=lockstring) if not prot: caller.msg("|rError saving:|R {}.|n".format(key)) @@ -3046,14 +3029,68 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): caller.msg("|rError saving:|R {}|n".format(err)) return caller.msg("|gSaved prototype:|n {}".format(key)) + + # check if we want to update existing objects + existing_objects = spawner.search_objects_with_prototype(key) + if existing_objects: + if 'update' not in self.switches: + n_existing = len(existing_objects) + slow = " (note that this may be slow)" if n_existing > 10 else "" + string = ("There are {} objects already created with an older version " + "of prototype {}. Should it be re-applied to them{}? [Y]/N".format( + n_existing, key, slow)) + answer = yield(string) + if answer.lower() in ["n", "no"]: + caller.msg("|rNo update was done of existing objects. " + "Use @spawn/update to apply later as needed.|n") + return + n_updated = spawner.batch_update_objects_with_prototype(existing_objects, key) + caller.msg("{} objects were updated.".format(n_updated)) return if not self.args: - ncount = len(search_prototype()) + ncount = len(spawner.search_prototype()) caller.msg("Usage: @spawn or {{key: value, ...}}" "\n ({} existing prototypes. Use /list to inspect)".format(ncount)) return + if 'delete' in self.switches: + # remove db-based prototype + matchstring = _search_show_prototype(self.args) + if matchstring: + string = "|rDeleting prototype:|n\n{}".format(matchstring) + question = "\nDo you want to continue deleting? [Y]/N" + answer = yield(string + question) + if answer.lower() in ["n", "no"]: + caller.msg("|rDeletion cancelled.|n") + return + try: + success = spawner.delete_db_prototype(caller, self.args) + except PermissionError as err: + caller.msg("|rError deleting:|R {}|n".format(err)) + caller.msg("Deletion {}.".format( + 'successful' if success else 'failed (does the prototype exist?)')) + return + else: + caller.msg("Could not find prototype '{}'".format(key)) + + if 'update' in self.switches: + # update existing prototypes + key = self.args.strip().lower() + existing_objects = spawner.search_objects_with_prototype(key) + if existing_objects: + n_existing = len(existing_objects) + slow = " (note that this may be slow)" if n_existing > 10 else "" + string = ("There are {} objects already created with an older version " + "of prototype {}. Should it be re-applied to them{}? [Y]/N".format( + n_existing, key, slow)) + answer = yield(string) + if answer.lower() in ["n", "no"]: + caller.msg("|rUpdate cancelled.") + return + n_updated = spawner.batch_update_objects_with_prototype(existing_objects, key) + caller.msg("{} objects were updated.".format(n_updated)) + # A direct creation of an object from a given prototype prototype = _parse_prototype( @@ -3066,7 +3103,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if isinstance(prototype, basestring): # A prototype key we are looking to apply key = prototype - prototypes = search_prototype(prototype) + prototypes = spawner.search_prototype(prototype) nprots = len(prototypes) if not prototypes: caller.msg("No prototype named '%s'." % prototype) @@ -3087,7 +3124,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # proceed to spawning try: - for obj in spawn(prototype): + for obj in spawner.spawn(prototype): self.caller.msg("Spawned %s." % obj.get_display_name(self.caller)) except RuntimeError as err: caller.msg(err) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index aa62a1dcad..06cb59c178 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -270,6 +270,9 @@ class DbPrototype(DefaultScript): self.desc = "A prototype" # prototype_desc + + + def save_db_prototype(caller, prototype, key=None, desc=None, tags=None, locks="", delete=False): """ Store a prototype persistently. @@ -662,6 +665,28 @@ def _get_prototype(dic, prot, protparents): return prot +def batch_update_objects_with_prototype(prototype, objects=None): + """ + Update existing objects with the latest version of the prototype. + + Args: + prototype (str or dict): Either the `prototype_key` to use or the + prototype dict itself. + objects (list): List of objects to update. If not given, query for these + objects using the prototype's `prototype_key`. + Returns: + changed (int): The number of objects that had changes applied to them. + """ + prototype_key = prototype if isinstance(prototype, basestring) else prototype['prototype_key'] + prototype_obj = search_db_prototype(prototype_key, return_queryset=True) + prototype_obj = prototype_obj[0] if prototype_obj else None + new_prototype = prototype_obj.db.prototype + + + + return 0 + + def _batch_create_object(*objparams): """ This is a cut-down version of the create_object() function, From bf66bb744038d21fb7e2379d7f9fea4f4f4b1e86 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Mon, 21 May 2018 18:06:29 -0700 Subject: [PATCH 305/466] Proper formatting for comments and notes --- evennia/contrib/turnbattle/tb_magic.py | 33 ++++++++++++-------------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_magic.py b/evennia/contrib/turnbattle/tb_magic.py index 01101837b3..7bf87d70be 100644 --- a/evennia/contrib/turnbattle/tb_magic.py +++ b/evennia/contrib/turnbattle/tb_magic.py @@ -326,19 +326,19 @@ class TBMagicCharacter(DefaultCharacter): """ 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.spells_known = [] # Set empty spells known list - self.db.max_mp = 20 # Set maximum MP to 20 - self.db.mp = self.db.max_mp # Set current MP to maximum - """ + Adds attributes for a character's current and maximum HP. We're just going to set this value at '100' by default. You may want to expand this to include various 'stats' that can be changed at creation and factor into combat calculations. """ + self.db.max_hp = 100 # Set maximum HP to 100 + self.db.hp = self.db.max_hp # Set current HP to maximum + self.db.spells_known = [] # Set empty spells known list + self.db.max_mp = 20 # Set maximum MP to 20 + self.db.mp = self.db.max_mp # Set current MP to maximum + def at_before_move(self, destination): """ @@ -795,6 +795,13 @@ class CmdCast(MuxCommand): def func(self): """ This performs the actual command. + + Note: This is a quite long command, since it has to cope with all + the different circumstances in which you may or may not be able + to cast a spell. None of the spell's effects are handled by the + command - all the command does is verify that the player's input + is valid for the spell being cast and then call the spell's + function. """ caller = self.caller @@ -945,14 +952,6 @@ class CmdCast(MuxCommand): spelldata["spellfunc"](caller, spell_to_cast, spell_targets, spelldata["cost"], **kwargs) except Exception: log_trace("Error in callback for spell: %s." % spell_to_cast) - """ - Note: This is a quite long command, since it has to cope with all - the different circumstances in which you may or may not be able - to cast a spell. None of the spell's effects are handled by the - command - all the command does is verify that the player's input - is valid for the spell being cast and then call the spell's - function. - """ class CmdRest(Command): @@ -979,9 +978,7 @@ class CmdRest(Command): self.caller.db.hp = self.caller.db.max_hp # Set current HP to maximum self.caller.db.mp = self.caller.db.max_mp # Set current MP to maximum self.caller.location.msg_contents("%s rests to recover HP and MP." % self.caller) - """ - You'll probably want to replace this with your own system for recovering HP and MP. - """ + # You'll probably want to replace this with your own system for recovering HP and MP. class CmdStatus(Command): """ From 1125dc92fc4bce36d4489f51606f810f32aca51d Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Mon, 21 May 2018 18:27:04 -0700 Subject: [PATCH 306/466] Start splitting unit tests, add setUp/tearDown --- evennia/contrib/tests.py | 131 +++++++++++++++++++++++---------------- 1 file changed, 76 insertions(+), 55 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 5203354fff..2de8f2156e 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -987,87 +987,101 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_magic.CmdRest(), "", "Char rests to recover HP and MP.") -class TestTurnBattleFunc(EvenniaTest): +class TestTurnBattleBasicFunc(EvenniaTest): + def setUp(self): + super(TestTurnBattleBasicFunc, self).setUp() + self.attacker = create_object(tb_basic.TBBasicCharacter, key="Attacker") + self.defender = create_object(tb_basic.TBBasicCharacter, key="Defender") + self.joiner = create_object(tb_basic.TBBasicCharacter, key="Joiner") + self.testroom = create_object(DefaultRoom, key="Test Room") + self.attacker.location = self.testroom + self.defender.loaction = self.testroom + self.joiner.loaction = None + + def tearDown(self): + super(TestTurnBattleBasicFunc, self).tearDown() + self.attacker.delete() + self.defender.delete() + self.joiner.delete() + self.testroom.delete() + self.turnhandler.stop() + # Test combat functions def test_tbbasicfunc(self): - attacker = create_object(tb_basic.TBBasicCharacter, key="Attacker") - defender = create_object(tb_basic.TBBasicCharacter, key="Defender") - testroom = create_object(DefaultRoom, key="Test Room") - attacker.location = testroom - defender.loaction = testroom # Initiative roll - initiative = tb_basic.roll_init(attacker) + initiative = tb_basic.roll_init(self.attacker) self.assertTrue(initiative >= 0 and initiative <= 1000) # Attack roll - attack_roll = tb_basic.get_attack(attacker, defender) + attack_roll = tb_basic.get_attack(self.attacker, self.defender) self.assertTrue(attack_roll >= 0 and attack_roll <= 100) # Defense roll - defense_roll = tb_basic.get_defense(attacker, defender) + defense_roll = tb_basic.get_defense(self.attacker, self.defender) self.assertTrue(defense_roll == 50) # Damage roll - damage_roll = tb_basic.get_damage(attacker, defender) + damage_roll = tb_basic.get_damage(self.attacker, self.defender) self.assertTrue(damage_roll >= 15 and damage_roll <= 25) # Apply damage - defender.db.hp = 10 - tb_basic.apply_damage(defender, 3) - self.assertTrue(defender.db.hp == 7) + self.defender.db.hp = 10 + tb_basic.apply_damage(self.defender, 3) + self.assertTrue(self.defender.db.hp == 7) # Resolve attack - defender.db.hp = 40 - tb_basic.resolve_attack(attacker, defender, attack_value=20, defense_value=10) - self.assertTrue(defender.db.hp < 40) + self.defender.db.hp = 40 + tb_basic.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10) + self.assertTrue(self.defender.db.hp < 40) # Combat cleanup - attacker.db.Combat_attribute = True - tb_basic.combat_cleanup(attacker) - self.assertFalse(attacker.db.combat_attribute) + self.attacker.db.Combat_attribute = True + tb_basic.combat_cleanup(self.attacker) + self.assertFalse(self.attacker.db.combat_attribute) # Is in combat - self.assertFalse(tb_basic.is_in_combat(attacker)) + self.assertFalse(tb_basic.is_in_combat(self.attacker)) # Set up turn handler script for further tests - attacker.location.scripts.add(tb_basic.TBBasicTurnHandler) - turnhandler = attacker.db.combat_TurnHandler - self.assertTrue(attacker.db.combat_TurnHandler) + self.attacker.location.scripts.add(tb_basic.TBBasicTurnHandler) + self.turnhandler = self.attacker.db.combat_TurnHandler + self.assertTrue(self.attacker.db.combat_TurnHandler) # Set the turn handler's interval very high to keep it from repeating during tests. - turnhandler.interval = 10000 + self.turnhandler.interval = 10000 # Force turn order - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 # Test is turn - self.assertTrue(tb_basic.is_turn(attacker)) + self.assertTrue(tb_basic.is_turn(self.attacker)) # Spend actions - attacker.db.Combat_ActionsLeft = 1 - tb_basic.spend_action(attacker, 1, action_name="Test") - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "Test") + self.attacker.db.Combat_ActionsLeft = 1 + tb_basic.spend_action(self.attacker, 1, action_name="Test") + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "Test") # Initialize for combat - attacker.db.Combat_ActionsLeft = 983 - turnhandler.initialize_for_combat(attacker) - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "null") + self.attacker.db.Combat_ActionsLeft = 983 + self.turnhandler.initialize_for_combat(self.attacker) + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "null") # Start turn - defender.db.Combat_ActionsLeft = 0 - turnhandler.start_turn(defender) - self.assertTrue(defender.db.Combat_ActionsLeft == 1) + self.defender.db.Combat_ActionsLeft = 0 + self.turnhandler.start_turn(self.defender) + self.assertTrue(self.defender.db.Combat_ActionsLeft == 1) # Next turn - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - turnhandler.next_turn() - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.next_turn() + self.assertTrue(self.turnhandler.db.turn == 1) # Turn end check - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - attacker.db.Combat_ActionsLeft = 0 - turnhandler.turn_end_check(attacker) - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.attacker.db.Combat_ActionsLeft = 0 + self.turnhandler.turn_end_check(self.attacker) + self.assertTrue(self.turnhandler.db.turn == 1) # Join fight - joiner = create_object(tb_basic.TBBasicCharacter, key="Joiner") - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - turnhandler.join_fight(joiner) - self.assertTrue(turnhandler.db.turn == 1) - self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) - # Remove the script at the end - turnhandler.stop() + self.joiner.location = self.testroom + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.join_fight(self.joiner) + self.assertTrue(self.turnhandler.db.turn == 1) + self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender]) + +class TestTurnBattleEquipFunc(EvenniaTest): + # Test the combat functions in tb_equip too. They work mostly the same. def test_tbequipfunc(self): attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker") @@ -1147,6 +1161,8 @@ class TestTurnBattleFunc(EvenniaTest): # Remove the script at the end turnhandler.stop() +class TestTurnBattleRangeFunc(EvenniaTest): + # Test combat functions in tb_range too. def test_tbrangefunc(self): testroom = create_object(DefaultRoom, key="Test Room") @@ -1239,7 +1255,10 @@ class TestTurnBattleFunc(EvenniaTest): # Remove the script at the end turnhandler.stop() +class TestTurnBattleItemsFunc(EvenniaTest): + # Test functions in tb_items. + def test_tbitemsfunc(self): attacker = create_object(tb_items.TBItemsCharacterTest, key="Attacker") defender = create_object(tb_items.TBItemsCharacterTest, key="Defender") @@ -1352,6 +1371,8 @@ class TestTurnBattleFunc(EvenniaTest): # Delete the test character user.delete() +class TestTurnBattleMagicFunc(EvenniaTest): + # Test combat functions in tb_magic. def test_tbbasicfunc(self): attacker = create_object(tb_magic.TBMagicCharacter, key="Attacker") From 17cedbfde7ad80b355ec07d102a1ef4fe8e840e8 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Mon, 21 May 2018 18:45:07 -0700 Subject: [PATCH 307/466] More setUp/tearDown --- evennia/contrib/tests.py | 118 +++++++++++++++++++++------------------ 1 file changed, 64 insertions(+), 54 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 2de8f2156e..721fd43801 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1081,85 +1081,95 @@ class TestTurnBattleBasicFunc(EvenniaTest): class TestTurnBattleEquipFunc(EvenniaTest): + + def setUp(self): + super(TestTurnBattleEquipFunc, self).setUp() + self.attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker") + self.defender = create_object(tb_equip.TBEquipCharacter, key="Defender") + self.testroom = create_object(DefaultRoom, key="Test Room") + self.joiner = create_object(tb_equip.TBEquipCharacter, key="Joiner") + self.attacker.location = self.testroom + self.defender.loaction = self.testroom + self.joiner.loaction = None + + def tearDown(self): + super(TestTurnBattleEquipFunc, self).tearDown() + self.attacker.delete() + self.defender.delete() + self.joiner.delete() + self.testroom.delete() + self.turnhandler.stop() # Test the combat functions in tb_equip too. They work mostly the same. def test_tbequipfunc(self): - attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker") - defender = create_object(tb_equip.TBEquipCharacter, key="Defender") - testroom = create_object(DefaultRoom, key="Test Room") - attacker.location = testroom - defender.loaction = testroom # Initiative roll - initiative = tb_equip.roll_init(attacker) + initiative = tb_equip.roll_init(self.attacker) self.assertTrue(initiative >= 0 and initiative <= 1000) # Attack roll - attack_roll = tb_equip.get_attack(attacker, defender) + attack_roll = tb_equip.get_attack(self.attacker, self.defender) self.assertTrue(attack_roll >= -50 and attack_roll <= 150) # Defense roll - defense_roll = tb_equip.get_defense(attacker, defender) + defense_roll = tb_equip.get_defense(self.attacker, self.defender) self.assertTrue(defense_roll == 50) # Damage roll - damage_roll = tb_equip.get_damage(attacker, defender) + damage_roll = tb_equip.get_damage(self.attacker, self.defender) self.assertTrue(damage_roll >= 0 and damage_roll <= 50) # Apply damage - defender.db.hp = 10 - tb_equip.apply_damage(defender, 3) - self.assertTrue(defender.db.hp == 7) + self.defender.db.hp = 10 + tb_equip.apply_damage(self.defender, 3) + self.assertTrue(self.defender.db.hp == 7) # Resolve attack - defender.db.hp = 40 - tb_equip.resolve_attack(attacker, defender, attack_value=20, defense_value=10) - self.assertTrue(defender.db.hp < 40) + self.defender.db.hp = 40 + tb_equip.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10) + self.assertTrue(self.defender.db.hp < 40) # Combat cleanup - attacker.db.Combat_attribute = True - tb_equip.combat_cleanup(attacker) - self.assertFalse(attacker.db.combat_attribute) + self.attacker.db.Combat_attribute = True + tb_equip.combat_cleanup(self.attacker) + self.assertFalse(self.attacker.db.combat_attribute) # Is in combat - self.assertFalse(tb_equip.is_in_combat(attacker)) + self.assertFalse(tb_equip.is_in_combat(self.attacker)) # Set up turn handler script for further tests - attacker.location.scripts.add(tb_equip.TBEquipTurnHandler) - turnhandler = attacker.db.combat_TurnHandler - self.assertTrue(attacker.db.combat_TurnHandler) + self.attacker.location.scripts.add(tb_equip.TBEquipTurnHandler) + self.turnhandler = self.attacker.db.combat_TurnHandler + self.assertTrue(self.attacker.db.combat_TurnHandler) # Set the turn handler's interval very high to keep it from repeating during tests. - turnhandler.interval = 10000 + self.turnhandler.interval = 10000 # Force turn order - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 # Test is turn - self.assertTrue(tb_equip.is_turn(attacker)) + self.assertTrue(tb_equip.is_turn(self.attacker)) # Spend actions - attacker.db.Combat_ActionsLeft = 1 - tb_equip.spend_action(attacker, 1, action_name="Test") - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "Test") + self.attacker.db.Combat_ActionsLeft = 1 + tb_equip.spend_action(self.attacker, 1, action_name="Test") + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "Test") # Initialize for combat - attacker.db.Combat_ActionsLeft = 983 - turnhandler.initialize_for_combat(attacker) - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "null") + self.attacker.db.Combat_ActionsLeft = 983 + self.turnhandler.initialize_for_combat(self.attacker) + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "null") # Start turn - defender.db.Combat_ActionsLeft = 0 - turnhandler.start_turn(defender) - self.assertTrue(defender.db.Combat_ActionsLeft == 1) + self.defender.db.Combat_ActionsLeft = 0 + self.turnhandler.start_turn(self.defender) + self.assertTrue(self.defender.db.Combat_ActionsLeft == 1) # Next turn - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - turnhandler.next_turn() - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.next_turn() + self.assertTrue(self.turnhandler.db.turn == 1) # Turn end check - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - attacker.db.Combat_ActionsLeft = 0 - turnhandler.turn_end_check(attacker) - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.attacker.db.Combat_ActionsLeft = 0 + self.turnhandler.turn_end_check(self.attacker) + self.assertTrue(self.turnhandler.db.turn == 1) # Join fight - joiner = create_object(tb_equip.TBEquipCharacter, key="Joiner") - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - turnhandler.join_fight(joiner) - self.assertTrue(turnhandler.db.turn == 1) - self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) - # Remove the script at the end - turnhandler.stop() + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.join_fight(self.joiner) + self.assertTrue(self.turnhandler.db.turn == 1) + self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender]) class TestTurnBattleRangeFunc(EvenniaTest): From a97383bc38ed7d94be72dc8ed1ea204dc93fdc7f Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Mon, 21 May 2018 19:27:43 -0700 Subject: [PATCH 308/466] Finish splitting TB test classes + adding setUp/tearDown --- evennia/contrib/tests.py | 465 +++++++++++++++++++++------------------ 1 file changed, 246 insertions(+), 219 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 721fd43801..c0721b3ed6 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -920,23 +920,28 @@ from evennia.contrib.turnbattle import tb_basic, tb_equip, tb_range, tb_items, t from evennia.objects.objects import DefaultRoom -class TestTurnBattleCmd(CommandTest): +class TestTurnBattleBasicCmd(CommandTest): - # Test combat commands + # Test basic combat commands def test_turnbattlecmd(self): self.call(tb_basic.CmdFight(), "", "You can't start a fight if you've been defeated!") self.call(tb_basic.CmdAttack(), "", "You can only do that in combat. (see: help fight)") 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 TestTurnBattleEquipCmd(CommandTest): + + def setUp(self): + super(TestTurnBattleEquipCmd, self).setUp() + self.testweapon = create_object(tb_equip.TBEWeapon, key="test weapon") + self.testarmor = create_object(tb_equip.TBEArmor, key="test armor") + self.testweapon.move_to(self.char1) + self.testarmor.move_to(self.char1) + # Test equipment commands def test_turnbattleequipcmd(self): # Start with equip module specific commands. - testweapon = create_object(tb_equip.TBEWeapon, key="test weapon") - testarmor = create_object(tb_equip.TBEArmor, key="test armor") - testweapon.move_to(self.char1) - testarmor.move_to(self.char1) self.call(tb_equip.CmdWield(), "weapon", "Char wields test weapon.") self.call(tb_equip.CmdUnwield(), "", "Char lowers test weapon.") self.call(tb_equip.CmdDon(), "armor", "Char dons test armor.") @@ -947,6 +952,8 @@ class TestTurnBattleCmd(CommandTest): 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 TestTurnBattleRangeCmd(CommandTest): # Test range commands def test_turnbattlerangecmd(self): @@ -961,11 +968,16 @@ class TestTurnBattleCmd(CommandTest): 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 TestTurnBattleItemsCmd(CommandTest): + + def setUp(self): + super(TestTurnBattleItemsCmd, self).setUp() + self.testitem = create_object(key="test item") + self.testitem.move_to(self.char1) + # Test item commands def test_turnbattleitemcmd(self): - testitem = create_object(key="test item") - testitem.move_to(self.char1) self.call(tb_items.CmdUse(), "item", "'Test item' is not a usable item.") # Also test the commands that are the same in the basic module self.call(tb_items.CmdFight(), "", "You can't start a fight if you've been defeated!") @@ -974,6 +986,8 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_items.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_items.CmdRest(), "", "Char rests to recover HP.") +class TestTurnBattleMagicCmd(CommandTest): + # Test magic commands def test_turnbattlemagiccmd(self): self.call(tb_magic.CmdStatus(), "", "You have 100 / 100 HP and 20 / 20 MP.") @@ -991,13 +1005,10 @@ class TestTurnBattleBasicFunc(EvenniaTest): def setUp(self): super(TestTurnBattleBasicFunc, self).setUp() - self.attacker = create_object(tb_basic.TBBasicCharacter, key="Attacker") - self.defender = create_object(tb_basic.TBBasicCharacter, key="Defender") - self.joiner = create_object(tb_basic.TBBasicCharacter, key="Joiner") self.testroom = create_object(DefaultRoom, key="Test Room") - self.attacker.location = self.testroom - self.defender.loaction = self.testroom - self.joiner.loaction = None + self.attacker = create_object(tb_basic.TBBasicCharacter, key="Attacker", location=self.testroom) + self.defender = create_object(tb_basic.TBBasicCharacter, key="Defender", location=self.testroom) + self.joiner = create_object(tb_basic.TBBasicCharacter, key="Joiner", location=None) def tearDown(self): super(TestTurnBattleBasicFunc, self).tearDown() @@ -1084,13 +1095,10 @@ class TestTurnBattleEquipFunc(EvenniaTest): def setUp(self): super(TestTurnBattleEquipFunc, self).setUp() - self.attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker") - self.defender = create_object(tb_equip.TBEquipCharacter, key="Defender") self.testroom = create_object(DefaultRoom, key="Test Room") - self.joiner = create_object(tb_equip.TBEquipCharacter, key="Joiner") - self.attacker.location = self.testroom - self.defender.loaction = self.testroom - self.joiner.loaction = None + self.attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker", location=self.testroom) + self.defender = create_object(tb_equip.TBEquipCharacter, key="Defender", location=self.testroom) + self.joiner = create_object(tb_equip.TBEquipCharacter, key="Joiner", location=None) def tearDown(self): super(TestTurnBattleEquipFunc, self).tearDown() @@ -1173,294 +1181,313 @@ class TestTurnBattleEquipFunc(EvenniaTest): class TestTurnBattleRangeFunc(EvenniaTest): + def setUp(self): + super(TestTurnBattleRangeFunc, self).setUp() + self.testroom = create_object(DefaultRoom, key="Test Room") + self.attacker = create_object(tb_range.TBRangeCharacter, key="Attacker", location=self.testroom) + self.defender = create_object(tb_range.TBRangeCharacter, key="Defender", location=self.testroom) + self.joiner = create_object(tb_range.TBRangeCharacter, key="Joiner", location=self.testroom) + + def tearDown(self): + super(TestTurnBattleRangeFunc, self).tearDown() + self.attacker.delete() + self.defender.delete() + self.joiner.delete() + self.testroom.delete() + self.turnhandler.stop() + # Test combat functions in tb_range too. def test_tbrangefunc(self): - testroom = create_object(DefaultRoom, key="Test Room") - attacker = create_object(tb_range.TBRangeCharacter, key="Attacker", location=testroom) - defender = create_object(tb_range.TBRangeCharacter, key="Defender", location=testroom) # Initiative roll - initiative = tb_range.roll_init(attacker) + initiative = tb_range.roll_init(self.attacker) self.assertTrue(initiative >= 0 and initiative <= 1000) # Attack roll - attack_roll = tb_range.get_attack(attacker, defender, "test") + attack_roll = tb_range.get_attack(self.attacker, self.defender, "test") self.assertTrue(attack_roll >= 0 and attack_roll <= 100) # Defense roll - defense_roll = tb_range.get_defense(attacker, defender, "test") + defense_roll = tb_range.get_defense(self.attacker, self.defender, "test") self.assertTrue(defense_roll == 50) # Damage roll - damage_roll = tb_range.get_damage(attacker, defender) + damage_roll = tb_range.get_damage(self.attacker, self.defender) self.assertTrue(damage_roll >= 15 and damage_roll <= 25) # Apply damage - defender.db.hp = 10 - tb_range.apply_damage(defender, 3) - self.assertTrue(defender.db.hp == 7) + self.defender.db.hp = 10 + tb_range.apply_damage(self.defender, 3) + self.assertTrue(self.defender.db.hp == 7) # Resolve attack - defender.db.hp = 40 - tb_range.resolve_attack(attacker, defender, "test", attack_value=20, defense_value=10) - self.assertTrue(defender.db.hp < 40) + self.defender.db.hp = 40 + tb_range.resolve_attack(self.attacker, self.defender, "test", attack_value=20, defense_value=10) + self.assertTrue(self.defender.db.hp < 40) # Combat cleanup - attacker.db.Combat_attribute = True - tb_range.combat_cleanup(attacker) - self.assertFalse(attacker.db.combat_attribute) + self.attacker.db.Combat_attribute = True + tb_range.combat_cleanup(self.attacker) + self.assertFalse(self.attacker.db.combat_attribute) # Is in combat - self.assertFalse(tb_range.is_in_combat(attacker)) + self.assertFalse(tb_range.is_in_combat(self.attacker)) # Set up turn handler script for further tests - attacker.location.scripts.add(tb_range.TBRangeTurnHandler) - turnhandler = attacker.db.combat_TurnHandler - self.assertTrue(attacker.db.combat_TurnHandler) + self.attacker.location.scripts.add(tb_range.TBRangeTurnHandler) + self.turnhandler = self.attacker.db.combat_TurnHandler + self.assertTrue(self.attacker.db.combat_TurnHandler) # Set the turn handler's interval very high to keep it from repeating during tests. - turnhandler.interval = 10000 + self.turnhandler.interval = 10000 # Force turn order - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 # Test is turn - self.assertTrue(tb_range.is_turn(attacker)) + self.assertTrue(tb_range.is_turn(self.attacker)) # Spend actions - attacker.db.Combat_ActionsLeft = 1 - tb_range.spend_action(attacker, 1, action_name="Test") - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "Test") + self.attacker.db.Combat_ActionsLeft = 1 + tb_range.spend_action(self.attacker, 1, action_name="Test") + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "Test") # Initialize for combat - attacker.db.Combat_ActionsLeft = 983 - turnhandler.initialize_for_combat(attacker) - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "null") + self.attacker.db.Combat_ActionsLeft = 983 + self.turnhandler.initialize_for_combat(self.attacker) + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "null") # Set up ranges again, since initialize_for_combat clears them - attacker.db.combat_range = {} - attacker.db.combat_range[attacker] = 0 - attacker.db.combat_range[defender] = 1 - defender.db.combat_range = {} - defender.db.combat_range[defender] = 0 - defender.db.combat_range[attacker] = 1 + self.attacker.db.combat_range = {} + self.attacker.db.combat_range[self.attacker] = 0 + self.attacker.db.combat_range[self.defender] = 1 + self.defender.db.combat_range = {} + self.defender.db.combat_range[self.defender] = 0 + self.defender.db.combat_range[self.attacker] = 1 # Start turn - defender.db.Combat_ActionsLeft = 0 - turnhandler.start_turn(defender) - self.assertTrue(defender.db.Combat_ActionsLeft == 2) + self.defender.db.Combat_ActionsLeft = 0 + self.turnhandler.start_turn(self.defender) + self.assertTrue(self.defender.db.Combat_ActionsLeft == 2) # Next turn - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - turnhandler.next_turn() - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.next_turn() + self.assertTrue(self.turnhandler.db.turn == 1) # Turn end check - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - attacker.db.Combat_ActionsLeft = 0 - turnhandler.turn_end_check(attacker) - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.attacker.db.Combat_ActionsLeft = 0 + self.turnhandler.turn_end_check(self.attacker) + self.assertTrue(self.turnhandler.db.turn == 1) # Join fight - joiner = create_object(tb_range.TBRangeCharacter, key="Joiner", location=testroom) - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - turnhandler.join_fight(joiner) - self.assertTrue(turnhandler.db.turn == 1) - self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.join_fight(self.joiner) + self.assertTrue(self.turnhandler.db.turn == 1) + self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender]) # Now, test for approach/withdraw functions - self.assertTrue(tb_range.get_range(attacker, defender) == 1) + self.assertTrue(tb_range.get_range(self.attacker, self.defender) == 1) # Approach - tb_range.approach(attacker, defender) - self.assertTrue(tb_range.get_range(attacker, defender) == 0) + tb_range.approach(self.attacker, self.defender) + self.assertTrue(tb_range.get_range(self.attacker, self.defender) == 0) # Withdraw - tb_range.withdraw(attacker, defender) - self.assertTrue(tb_range.get_range(attacker, defender) == 1) - # Remove the script at the end - turnhandler.stop() + tb_range.withdraw(self.attacker, self.defender) + self.assertTrue(tb_range.get_range(self.attacker, self.defender) == 1) class TestTurnBattleItemsFunc(EvenniaTest): + def setUp(self): + super(TestTurnBattleItemsFunc, self).setUp() + self.testroom = create_object(DefaultRoom, key="Test Room") + self.attacker = create_object(tb_items.TBItemsCharacter, key="Attacker", location=self.testroom) + self.defender = create_object(tb_items.TBItemsCharacter, key="Defender", location=self.testroom) + self.joiner = create_object(tb_items.TBItemsCharacter, key="Joiner", location=self.testroom) + self.user = create_object(tb_items.TBItemsCharacter, key="User", location=self.testroom) + self.test_healpotion = create_object(key="healing potion") + self.test_healpotion.db.item_func = "heal" + self.test_healpotion.db.item_uses = 3 + + def tearDown(self): + super(TestTurnBattleItemsFunc, self).tearDown() + self.attacker.delete() + self.defender.delete() + self.joiner.delete() + self.user.delete() + self.testroom.delete() + self.turnhandler.stop() + # Test functions in tb_items. - def test_tbitemsfunc(self): - attacker = create_object(tb_items.TBItemsCharacterTest, key="Attacker") - defender = create_object(tb_items.TBItemsCharacterTest, key="Defender") - testroom = create_object(DefaultRoom, key="Test Room") - attacker.location = testroom - defender.loaction = testroom # Initiative roll - initiative = tb_items.roll_init(attacker) + initiative = tb_items.roll_init(self.attacker) self.assertTrue(initiative >= 0 and initiative <= 1000) # Attack roll - attack_roll = tb_items.get_attack(attacker, defender) + attack_roll = tb_items.get_attack(self.attacker, self.defender) self.assertTrue(attack_roll >= 0 and attack_roll <= 100) # Defense roll - defense_roll = tb_items.get_defense(attacker, defender) + defense_roll = tb_items.get_defense(self.attacker, self.defender) self.assertTrue(defense_roll == 50) # Damage roll - damage_roll = tb_items.get_damage(attacker, defender) + damage_roll = tb_items.get_damage(self.attacker, self.defender) self.assertTrue(damage_roll >= 15 and damage_roll <= 25) # Apply damage - defender.db.hp = 10 - tb_items.apply_damage(defender, 3) - self.assertTrue(defender.db.hp == 7) + self.defender.db.hp = 10 + tb_items.apply_damage(self.defender, 3) + self.assertTrue(self.defender.db.hp == 7) # Resolve attack - defender.db.hp = 40 - tb_items.resolve_attack(attacker, defender, attack_value=20, defense_value=10) - self.assertTrue(defender.db.hp < 40) + self.defender.db.hp = 40 + tb_items.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10) + self.assertTrue(self.defender.db.hp < 40) # Combat cleanup - attacker.db.Combat_attribute = True - tb_items.combat_cleanup(attacker) - self.assertFalse(attacker.db.combat_attribute) + self.attacker.db.Combat_attribute = True + tb_items.combat_cleanup(self.attacker) + self.assertFalse(self.attacker.db.combat_attribute) # Is in combat - self.assertFalse(tb_items.is_in_combat(attacker)) + self.assertFalse(tb_items.is_in_combat(self.attacker)) # Set up turn handler script for further tests - attacker.location.scripts.add(tb_items.TBItemsTurnHandler) - turnhandler = attacker.db.combat_TurnHandler - self.assertTrue(attacker.db.combat_TurnHandler) + self.attacker.location.scripts.add(tb_items.TBItemsTurnHandler) + self.turnhandler = self.attacker.db.combat_TurnHandler + self.assertTrue(self.attacker.db.combat_TurnHandler) # Set the turn handler's interval very high to keep it from repeating during tests. - turnhandler.interval = 10000 + self.turnhandler.interval = 10000 # Force turn order - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 # Test is turn - self.assertTrue(tb_items.is_turn(attacker)) + self.assertTrue(tb_items.is_turn(self.attacker)) # Spend actions - attacker.db.Combat_ActionsLeft = 1 - tb_items.spend_action(attacker, 1, action_name="Test") - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "Test") + self.attacker.db.Combat_ActionsLeft = 1 + tb_items.spend_action(self.attacker, 1, action_name="Test") + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "Test") # Initialize for combat - attacker.db.Combat_ActionsLeft = 983 - turnhandler.initialize_for_combat(attacker) - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "null") + self.attacker.db.Combat_ActionsLeft = 983 + self.turnhandler.initialize_for_combat(self.attacker) + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "null") # Start turn - defender.db.Combat_ActionsLeft = 0 - turnhandler.start_turn(defender) - self.assertTrue(defender.db.Combat_ActionsLeft == 1) + self.defender.db.Combat_ActionsLeft = 0 + self.turnhandler.start_turn(self.defender) + self.assertTrue(self.defender.db.Combat_ActionsLeft == 1) # Next turn - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - turnhandler.next_turn() - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.next_turn() + self.assertTrue(self.turnhandler.db.turn == 1) # Turn end check - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - attacker.db.Combat_ActionsLeft = 0 - turnhandler.turn_end_check(attacker) - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.attacker.db.Combat_ActionsLeft = 0 + self.turnhandler.turn_end_check(self.attacker) + self.assertTrue(self.turnhandler.db.turn == 1) # Join fight - joiner = create_object(tb_items.TBItemsCharacterTest, key="Joiner") - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - turnhandler.join_fight(joiner) - self.assertTrue(turnhandler.db.turn == 1) - self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) - # Remove the script at the end - turnhandler.stop() + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.join_fight(self.joiner) + self.assertTrue(self.turnhandler.db.turn == 1) + self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender]) # Now time to test item stuff. - user = create_object(tb_items.TBItemsCharacterTest, key="User") - testroom = create_object(DefaultRoom, key="Test Room") - user.location = testroom - test_healpotion = create_object(key="healing potion") - test_healpotion.db.item_func = "heal" - test_healpotion.db.item_uses = 3 # Spend item use - tb_items.spend_item_use(test_healpotion, user) - self.assertTrue(test_healpotion.db.item_uses == 2) + tb_items.spend_item_use(self.test_healpotion, self.user) + self.assertTrue(self.test_healpotion.db.item_uses == 2) # Use item - user.db.hp = 2 - tb_items.use_item(user, test_healpotion, user) - self.assertTrue(user.db.hp > 2) + self.user.db.hp = 2 + tb_items.use_item(self.user, self.test_healpotion, self.user) + self.assertTrue(self.user.db.hp > 2) # Add contition - tb_items.add_condition(user, user, "Test", 5) - self.assertTrue(user.db.conditions == {"Test":[5, user]}) + tb_items.add_condition(self.user, self.user, "Test", 5) + self.assertTrue(self.user.db.conditions == {"Test":[5, self.user]}) # Condition tickdown - tb_items.condition_tickdown(user, user) - self.assertTrue(user.db.conditions == {"Test":[4, user]}) + tb_items.condition_tickdown(self.user, self.user) + self.assertTrue(self.user.db.conditions == {"Test":[4, self.user]}) # Test item functions now! # Item heal - user.db.hp = 2 - tb_items.itemfunc_heal(test_healpotion, user, user) + self.user.db.hp = 2 + tb_items.itemfunc_heal(self.test_healpotion, self.user, self.user) # Item add condition - user.db.conditions = {} - tb_items.itemfunc_add_condition(test_healpotion, user, user) - self.assertTrue(user.db.conditions == {"Regeneration":[5, user]}) + self.user.db.conditions = {} + tb_items.itemfunc_add_condition(self.test_healpotion, self.user, self.user) + self.assertTrue(self.user.db.conditions == {"Regeneration":[5, self.user]}) # Item cure condition - user.db.conditions = {"Poisoned":[5, user]} - tb_items.itemfunc_cure_condition(test_healpotion, user, user) - self.assertTrue(user.db.conditions == {}) - # Delete the test character - user.delete() + self.user.db.conditions = {"Poisoned":[5, self.user]} + tb_items.itemfunc_cure_condition(self.test_healpotion, self.user, self.user) + self.assertTrue(self.user.db.conditions == {}) class TestTurnBattleMagicFunc(EvenniaTest): + + def setUp(self): + super(TestTurnBattleMagicFunc, self).setUp() + self.testroom = create_object(DefaultRoom, key="Test Room") + self.attacker = create_object(tb_magic.TBMagicCharacter, key="Attacker", location=self.testroom) + self.defender = create_object(tb_magic.TBMagicCharacter, key="Defender", location=self.testroom) + self.joiner = create_object(tb_magic.TBMagicCharacter, key="Joiner", location=self.testroom) + def tearDown(self): + super(TestTurnBattleMagicFunc, self).tearDown() + self.attacker.delete() + self.defender.delete() + self.joiner.delete() + self.testroom.delete() + self.turnhandler.stop() + # Test combat functions in tb_magic. def test_tbbasicfunc(self): - attacker = create_object(tb_magic.TBMagicCharacter, key="Attacker") - defender = create_object(tb_magic.TBMagicCharacter, key="Defender") - testroom = create_object(DefaultRoom, key="Test Room") - attacker.location = testroom - defender.loaction = testroom # Initiative roll - initiative = tb_magic.roll_init(attacker) + initiative = tb_magic.roll_init(self.attacker) self.assertTrue(initiative >= 0 and initiative <= 1000) # Attack roll - attack_roll = tb_magic.get_attack(attacker, defender) + attack_roll = tb_magic.get_attack(self.attacker, self.defender) self.assertTrue(attack_roll >= 0 and attack_roll <= 100) # Defense roll - defense_roll = tb_magic.get_defense(attacker, defender) + defense_roll = tb_magic.get_defense(self.attacker, self.defender) self.assertTrue(defense_roll == 50) # Damage roll - damage_roll = tb_magic.get_damage(attacker, defender) + damage_roll = tb_magic.get_damage(self.attacker, self.defender) self.assertTrue(damage_roll >= 15 and damage_roll <= 25) # Apply damage - defender.db.hp = 10 - tb_magic.apply_damage(defender, 3) - self.assertTrue(defender.db.hp == 7) + self.defender.db.hp = 10 + tb_magic.apply_damage(self.defender, 3) + self.assertTrue(self.defender.db.hp == 7) # Resolve attack - defender.db.hp = 40 - tb_magic.resolve_attack(attacker, defender, attack_value=20, defense_value=10) - self.assertTrue(defender.db.hp < 40) + self.defender.db.hp = 40 + tb_magic.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10) + self.assertTrue(self.defender.db.hp < 40) # Combat cleanup - attacker.db.Combat_attribute = True - tb_magic.combat_cleanup(attacker) - self.assertFalse(attacker.db.combat_attribute) + self.attacker.db.Combat_attribute = True + tb_magic.combat_cleanup(self.attacker) + self.assertFalse(self.attacker.db.combat_attribute) # Is in combat - self.assertFalse(tb_magic.is_in_combat(attacker)) + self.assertFalse(tb_magic.is_in_combat(self.attacker)) # Set up turn handler script for further tests - attacker.location.scripts.add(tb_magic.TBMagicTurnHandler) - turnhandler = attacker.db.combat_TurnHandler - self.assertTrue(attacker.db.combat_TurnHandler) + self.attacker.location.scripts.add(tb_magic.TBMagicTurnHandler) + self.turnhandler = self.attacker.db.combat_TurnHandler + self.assertTrue(self.attacker.db.combat_TurnHandler) # Set the turn handler's interval very high to keep it from repeating during tests. - turnhandler.interval = 10000 + self.turnhandler.interval = 10000 # Force turn order - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 # Test is turn - self.assertTrue(tb_magic.is_turn(attacker)) + self.assertTrue(tb_magic.is_turn(self.attacker)) # Spend actions - attacker.db.Combat_ActionsLeft = 1 - tb_magic.spend_action(attacker, 1, action_name="Test") - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "Test") + self.attacker.db.Combat_ActionsLeft = 1 + tb_magic.spend_action(self.attacker, 1, action_name="Test") + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "Test") # Initialize for combat - attacker.db.Combat_ActionsLeft = 983 - turnhandler.initialize_for_combat(attacker) - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "null") + self.attacker.db.Combat_ActionsLeft = 983 + self.turnhandler.initialize_for_combat(self.attacker) + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "null") # Start turn - defender.db.Combat_ActionsLeft = 0 - turnhandler.start_turn(defender) - self.assertTrue(defender.db.Combat_ActionsLeft == 1) + self.defender.db.Combat_ActionsLeft = 0 + self.turnhandler.start_turn(self.defender) + self.assertTrue(self.defender.db.Combat_ActionsLeft == 1) # Next turn - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - turnhandler.next_turn() - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.next_turn() + self.assertTrue(self.turnhandler.db.turn == 1) # Turn end check - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - attacker.db.Combat_ActionsLeft = 0 - turnhandler.turn_end_check(attacker) - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.attacker.db.Combat_ActionsLeft = 0 + self.turnhandler.turn_end_check(self.attacker) + self.assertTrue(self.turnhandler.db.turn == 1) # Join fight - joiner = create_object(tb_magic.TBMagicCharacter, key="Joiner") - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - turnhandler.join_fight(joiner) - self.assertTrue(turnhandler.db.turn == 1) - self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) - # Remove the script at the end - turnhandler.stop() + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.join_fight(self.joiner) + self.assertTrue(self.turnhandler.db.turn == 1) + self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender]) # Test tree select From 01acdccd6d31ce75661c3084440988a65f7df9e5 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 22 May 2018 22:28:03 +0200 Subject: [PATCH 309/466] Update-objs with prototype, first version, no testing yet --- evennia/typeclasses/attributes.py | 1 + evennia/utils/spawner.py | 159 +++++++++++++++++++++++++++++- 2 files changed, 155 insertions(+), 5 deletions(-) diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index 4ed68a1fe8..eb698e6f0e 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -435,6 +435,7 @@ class AttributeHandler(object): def __init__(self): self.key = None self.value = default + self.category = None self.strvalue = str(default) if default is not None else None ret = [] diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 06cb59c178..3c269ca742 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -235,7 +235,7 @@ def validate_spawn_value(value, validator=None): Analyze the value and produce a value for use at the point of spawning. Args: - value (any): This can be:j + value (any): This can be: callable - will be called as callable() (callable, (args,)) - will be called as callable(*args) other - will be assigned depending on the variable type @@ -602,6 +602,44 @@ def prototype_to_str(prototype): return header + proto +def prototype_from_object(obj): + """ + Guess a minimal prototype from an existing object. + + Args: + obj (Object): An object to analyze. + + Returns: + prototype (dict): A prototype estimating the current state of the object. + + """ + # first, check if this object already has a prototype + + prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) + prot = search_prototype(prot) + if not prot or len(prot) > 1: + # no unambiguous prototype found - build new prototype + prot = {} + prot['prototype_key'] = "From-Object-{}-{}".format( + obj.key, hashlib.md5(str(time.time())).hexdigest()[:6]) + prot['prototype_desc'] = "Built from {}".format(str(obj)) + prot['prototype_locks'] = "use:all();edit:all()" + + prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6] + prot['location'] = obj.db_location + prot['home'] = obj.db_home + prot['destination'] = obj.db_destination + prot['typeclass'] = obj.db_typeclass_path + prot['locks'] = obj.locks.all() + prot['permissions'] = obj.permissions.get() + prot['aliases'] = obj.aliases.get() + prot['tags'] = [(tag.key, tag.category, tag.data) + for tag in obj.tags.get(return_tagobj=True, return_list=True)] + prot['attrs'] = [(attr.key, attr.value, attr.category, attr.locks) + for attr in obj.attributes.get(return_obj=True, return_list=True)] + + return prot + # Spawner mechanism @@ -665,26 +703,137 @@ def _get_prototype(dic, prot, protparents): return prot -def batch_update_objects_with_prototype(prototype, objects=None): +def prototype_diff_from_object(prototype, obj): + """ + Get a simple diff for a prototype compared to an object which may or may not already have a + prototype (or has one but changed locally). For more complex migratations a manual diff may be + needed. + + Args: + prototype (dict): Prototype. + obj (Object): Object to + + Returns: + diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...} + + """ + prot1 = prototype + prot2 = prototype_from_object(obj) + + diff = {} + for key, value in prot1.items(): + diff[key] = "KEEP" + if key in prot2: + if callable(prot2[key]) or value != prot2[key]: + diff[key] = "UPDATE" + elif key not in prot2: + diff[key] = "REMOVE" + + return diff + + +def batch_update_objects_with_prototype(prototype, diff=None, objects=None): """ Update existing objects with the latest version of the prototype. Args: prototype (str or dict): Either the `prototype_key` to use or the prototype dict itself. - objects (list): List of objects to update. If not given, query for these + diff (dict, optional): This a diff structure that describes how to update the protototype. If + not given this will be constructed from the first object found. + objects (list, optional): List of objects to update. If not given, query for these objects using the prototype's `prototype_key`. Returns: changed (int): The number of objects that had changes applied to them. + """ prototype_key = prototype if isinstance(prototype, basestring) else prototype['prototype_key'] prototype_obj = search_db_prototype(prototype_key, return_queryset=True) prototype_obj = prototype_obj[0] if prototype_obj else None new_prototype = prototype_obj.db.prototype + objs = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + if not objs: + return 0 + if not diff: + diff = prototype_diff_from_object(new_prototype, objs[0]) - return 0 + changed = 0 + for obj in objs: + do_save = False + for key, directive in diff.items(): + val = new_prototype[key] + if directive in ('UPDATE', 'REPLACE'): + do_save = True + if key == 'key': + obj.db_key = validate_spawn_value(val, str) + elif key == 'typeclass': + obj.db_typeclass_path = validate_spawn_value(val, str) + elif key == 'location': + obj.db_location = validate_spawn_value(val, _to_obj) + elif key == 'home': + obj.db_home = validate_spawn_value(val, _to_obj) + elif key == 'destination': + obj.db_destination = validate_spawn_value(val, _to_obj) + elif key == 'locks': + if directive == 'REPLACE': + obj.locks.clear() + obj.locks.add(validate_spawn_value(val, str)) + elif key == 'permissions': + if directive == 'REPLACE': + obj.permissions.clear() + obj.permissions.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'aliases': + if directive == 'REPLACE': + obj.aliases.clear() + obj.aliases.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'tags': + if directive == 'REPLACE': + obj.tags.clear() + obj.tags.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'attrs': + if directive == 'REPLACE': + obj.attributes.clear() + obj.attributes.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'exec': + # we don't auto-rerun exec statements, it would be huge security risk! + pass + else: + obj.attributes.add(key, validate_spawn_value(val, _to_obj)) + elif directive == 'REMOVE': + do_save = True + if key == 'key': + obj.db_key = '' + elif key == 'typeclass': + # fall back to default + obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS + elif key == 'location': + obj.db_location = None + elif key == 'home': + obj.db_home = None + elif key == 'destination': + obj.db_destination = None + elif key == 'locks': + obj.locks.clear() + elif key == 'permissions': + obj.permissions.clear() + elif key == 'aliases': + obj.aliases.clear() + elif key == 'tags': + obj.tags.clear() + elif key == 'attrs': + obj.attributes.clear() + elif key == 'exec': + # we don't auto-rerun exec statements, it would be huge security risk! + pass + else: + obj.attributes.remove(key) + if do_save: + changed += 1 + obj.save() + + return changed def _batch_create_object(*objparams): @@ -835,7 +984,7 @@ def spawn(*prototypes, **kwargs): execs = validate_spawn_value(val, make_iter) # extract ndb assignments - nattributes = dict((key.split("_", 1)[1], validate_spawn_value(val, _to_obj)) + nattribute = dict((key.split("_", 1)[1], validate_spawn_value(val, _to_obj)) for key, val in prot.items() if key.startswith("ndb_")) # the rest are attributes From fca1edbb3842a65c0ae524cf2980f2c0017e153f Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Wed, 30 May 2018 22:59:45 -0700 Subject: [PATCH 310/466] Add fieldfill.py --- evennia/contrib/fieldfill.py | 100 +++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 evennia/contrib/fieldfill.py diff --git a/evennia/contrib/fieldfill.py b/evennia/contrib/fieldfill.py new file mode 100644 index 0000000000..91a91fc31d --- /dev/null +++ b/evennia/contrib/fieldfill.py @@ -0,0 +1,100 @@ +""" +Fyield Fyill +""" + +from evennia.utils import evmenu, evtable +from evennia import Command + +""" +Complete field data is sent to the given callable as a dictionary (field:value pairs) + +FORM LIST/DICTIONARY VALUES: +Required: + fieldname - Name of the field as presented to the player + fieldtype - Type of field, either 'text' or 'number' + +Optional: + max - Maximum character length (if text) or value (if number) + min - Minimum charater length (if text) or value (if number) + default - Initial value (blank if not given) + blankmsg - Message to show when field is blank +""" + +SAMPLE_FORM = [ +{"fieldname":"Player", "fieldtype":"text", "max":30, "default":"Ashley"}, +{"fieldname":"Delay", "fieldtype":"number", "min":3, "max":30, "default":10}, +{"fieldname":"Message", "fieldtype":"text", "min":3, "max":200, +"default": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non urna ante. Etiam maximus orci ut commodo lobortis. Sed sodales sed libero quis fermentum. Nunc vel semper ante. Donec mattis nisl eget condimentum mattis. Pellentesque ac semper lorem. Sed augue." +} +] + +def init_fill_field(form, caller, callback): + """ + Presents a player with a fillable form. + """ + + # Pass kwargs to store data needed in the menu + kwargs = { + "formdata":form_template_to_dict(form_template) + } + + # Initialize menu of selections + evmenu.EvMenu(caller, "evennia.contrib.fieldfill", startnode="menunode_fieldfill", **kwargs) + + +def menunode_fieldfill(caller, raw_string, **kwargs): + """ + Repeating node to fill a menu field + """ + # Retrieve menu info + formdata = caller.ndb._menutree.formdata + + +def form_template_to_dict(formtemplate): + """ + Returns dictionary of field name:value pairs from form template + """ + formdict = {} + + for field in formtemplate: + fieldvalue = "" + if "default" in field: + fieldvalue = field["default"] + formdict.update({field["fieldname"]:fieldvalue}) + + return formdict + +def display_formdata(formtemplate, formdata): + """ + Displays a form's current data as a table + """ + formtable = evtable.EvTable(border="cells") + field_name_width = 3 + + for field in formtemplate: + new_fieldname = "" + new_fieldvalue = "" + # Get field name + new_fieldname = "|w" + field["fieldname"] + ":|n" + if len(field["fieldname"]) + 5 > field_name_width: + field_name_width = len(field["fieldname"]) + 5 + # Get field value + new_fieldvalue = str(formdata[field["fieldname"]]) + # Add name and value to table + formtable.add_row(new_fieldname, new_fieldvalue) + + formtable.reformat_column(0, align="r", width=field_name_width) + formtable.reformat(valign="t", width=80) + + return formtable + +class CmdTest(Command): + """ + Test stuff + """ + + key = "test" + + def func(self): + SAMPLE_FORM_DATA = form_template_to_dict(SAMPLE_FORM) + self.caller.msg(display_formdata(SAMPLE_FORM, SAMPLE_FORM_DATA)) \ No newline at end of file From 36643a7ecdd722bb766738af9d4f4bf2fe93e9b6 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Thu, 31 May 2018 00:45:57 -0700 Subject: [PATCH 311/466] Further developments --- evennia/contrib/fieldfill.py | 58 +++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/evennia/contrib/fieldfill.py b/evennia/contrib/fieldfill.py index 91a91fc31d..4a8110e62e 100644 --- a/evennia/contrib/fieldfill.py +++ b/evennia/contrib/fieldfill.py @@ -18,24 +18,28 @@ Optional: min - Minimum charater length (if text) or value (if number) default - Initial value (blank if not given) blankmsg - Message to show when field is blank + verifyfunc - Name of a callable used to verify input """ SAMPLE_FORM = [ -{"fieldname":"Player", "fieldtype":"text", "max":30, "default":"Ashley"}, +{"fieldname":"Player", "fieldtype":"text", "max":30, "blankmsg":"(Name of an online player)"}, {"fieldname":"Delay", "fieldtype":"number", "min":3, "max":30, "default":10}, {"fieldname":"Message", "fieldtype":"text", "min":3, "max":200, -"default": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non urna ante. Etiam maximus orci ut commodo lobortis. Sed sodales sed libero quis fermentum. Nunc vel semper ante. Donec mattis nisl eget condimentum mattis. Pellentesque ac semper lorem. Sed augue." +"default": "Lorem ipsum dolor sit amet" } ] -def init_fill_field(form, caller, callback): +def init_fill_field(formtemplate, caller, callback): """ Presents a player with a fillable form. """ + # Initialize form data from the template + blank_formdata = form_template_to_dict(formtemplate) # Pass kwargs to store data needed in the menu kwargs = { - "formdata":form_template_to_dict(form_template) + "formdata":blank_formdata, + "formtemplate": formtemplate } # Initialize menu of selections @@ -48,6 +52,28 @@ def menunode_fieldfill(caller, raw_string, **kwargs): """ # Retrieve menu info formdata = caller.ndb._menutree.formdata + formtemplate = caller.ndb._menutree.formtemplate + + # Display current form data + text = display_formdata(formtemplate, formdata) + options = ({"key": "_default", + "goto":"menunode_fieldfill"}) + + if raw_string: + if raw_string.lower().strip() == "show": + return text, options + elif "=" not in raw_string: + text = None + caller.msg("NO!") + return text, options + else: + entry = raw_string.split("=", 1) + fieldname = entry[0].strip() + newvalue = entry[1].strip() + caller.msg("Setting %s to %s!" % (fieldname, newvalue)) + text = None + + return text, options def form_template_to_dict(formtemplate): @@ -68,8 +94,8 @@ def display_formdata(formtemplate, formdata): """ Displays a form's current data as a table """ - formtable = evtable.EvTable(border="cells") - field_name_width = 3 + formtable = evtable.EvTable(border="rows", valign="t", maxwidth=80) + field_name_width = 5 for field in formtemplate: new_fieldname = "" @@ -80,11 +106,14 @@ def display_formdata(formtemplate, formdata): field_name_width = len(field["fieldname"]) + 5 # Get field value new_fieldvalue = str(formdata[field["fieldname"]]) + # Use blank message if field is blank and once is present + if new_fieldvalue == "" and "blankmsg" in field: + new_fieldvalue = "|x" + str(field["blankmsg"]) + "|n" # Add name and value to table formtable.add_row(new_fieldname, new_fieldvalue) formtable.reformat_column(0, align="r", width=field_name_width) - formtable.reformat(valign="t", width=80) + formtable.reformat_column(1, pad_left=0) return formtable @@ -97,4 +126,17 @@ class CmdTest(Command): def func(self): SAMPLE_FORM_DATA = form_template_to_dict(SAMPLE_FORM) - self.caller.msg(display_formdata(SAMPLE_FORM, SAMPLE_FORM_DATA)) \ No newline at end of file + self.caller.msg(display_formdata(SAMPLE_FORM, SAMPLE_FORM_DATA)) + +class CmdTestMenu(Command): + """ + Test stuff + """ + + key = "testmenu" + + def func(self): + init_fill_field(SAMPLE_FORM, self.caller, Placeholder) + +def Placeholder(): + return \ No newline at end of file From 3768624a0904cb095a27d56a81f240a457b0bfdc Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Thu, 31 May 2018 17:45:00 -0700 Subject: [PATCH 312/466] Basic filling functionality implemented --- evennia/contrib/fieldfill.py | 164 ++++++++++++++++++++++++++++++----- 1 file changed, 144 insertions(+), 20 deletions(-) diff --git a/evennia/contrib/fieldfill.py b/evennia/contrib/fieldfill.py index 4a8110e62e..ffc555bf88 100644 --- a/evennia/contrib/fieldfill.py +++ b/evennia/contrib/fieldfill.py @@ -19,16 +19,39 @@ Optional: default - Initial value (blank if not given) blankmsg - Message to show when field is blank verifyfunc - Name of a callable used to verify input + preformtxt - Text to put before the whole form table. Can be put in any field. + postformtxt - Text to put after the whole form table. Can be put in any field. """ SAMPLE_FORM = [ -{"fieldname":"Player", "fieldtype":"text", "max":30, "blankmsg":"(Name of an online player)"}, +{"fieldname":"Player", "fieldtype":"text", "max":30, "blankmsg":"(Name of an online player)", + "preformtxt":"Send a delayed message to another player:", "postformtxt":"Syntax: = |/Or: clear , help, show, quit"}, {"fieldname":"Delay", "fieldtype":"number", "min":3, "max":30, "default":10}, -{"fieldname":"Message", "fieldtype":"text", "min":3, "max":200, -"default": "Lorem ipsum dolor sit amet" -} +{"fieldname":"Message", "fieldtype":"text", "min":3, "max":200, "blankmsg":"(Message up to 200 characters)"} ] +class FieldEvMenu(evmenu.EvMenu): + """ + Custom EvMenu type with its own node formatter - removes extraneous lines + """ + + def node_formatter(self, nodetext, optionstext): + """ + Formats the entirety of the node. + + Args: + nodetext (str): The node text as returned by `self.nodetext_formatter`. + optionstext (str): The options display as returned by `self.options_formatter`. + caller (Object, Account or None, optional): The caller of the node. + + Returns: + node (str): The formatted node to display. + + """ + # Only return node text, no options or separators + return nodetext + + def init_fill_field(formtemplate, caller, callback): """ Presents a player with a fillable form. @@ -43,13 +66,16 @@ def init_fill_field(formtemplate, caller, callback): } # Initialize menu of selections - evmenu.EvMenu(caller, "evennia.contrib.fieldfill", startnode="menunode_fieldfill", **kwargs) + FieldEvMenu(caller, "evennia.contrib.fieldfill", startnode="menunode_fieldfill", **kwargs) def menunode_fieldfill(caller, raw_string, **kwargs): """ Repeating node to fill a menu field """ + # Syntax error goes here + syntax_err = "Syntax: = |/Or: clear , help, show, quit" + # Retrieve menu info formdata = caller.ndb._menutree.formdata formtemplate = caller.ndb._menutree.formtemplate @@ -60,17 +86,103 @@ def menunode_fieldfill(caller, raw_string, **kwargs): "goto":"menunode_fieldfill"}) if raw_string: + # Test for 'show' command if raw_string.lower().strip() == "show": return text, options - elif "=" not in raw_string: + # Test for 'clear' command + cleartest = raw_string.lower().strip().split(" ", 1) + if cleartest[0].lower() == "clear": text = None - caller.msg("NO!") + if len(cleartest) < 2: + caller.msg(syntax_err) + return text, options + matched_field = None + + for key in formdata.keys(): + if cleartest[1].lower() in key.lower(): + matched_field = key + + if not matched_field: + caller.msg("Field '%s' does not exist!" % cleartest[1]) + text = None + return text, options + + formdata.update({matched_field:None}) + caller.ndb._menutree.formdata = formdata + caller.msg("Field '%s' cleared." % matched_field) return text, options - else: - entry = raw_string.split("=", 1) - fieldname = entry[0].strip() - newvalue = entry[1].strip() - caller.msg("Setting %s to %s!" % (fieldname, newvalue)) + + if "=" not in raw_string: + text = None + caller.msg(syntax_err) + return text, options + + # Extract field name and new field value + entry = raw_string.split("=", 1) + fieldname = entry[0].strip() + newvalue = entry[1].strip() + + # Syntax error of field name is too short or blank + if len(fieldname) < 3: + caller.msg(syntax_err) + text = None + return text, options + + # Attempt to match field name to field in form data + matched_field = None + for key in formdata.keys(): + if fieldname.lower() in key.lower(): + matched_field = key + + # No matched field + if matched_field == None: + caller.msg("Field '%s' does not exist!" % fieldname) + text = None + return text, options + + # Set new field value if match + # Get data from template + fieldtype = None + max_value = None + min_value = None + for field in formtemplate: + if field["fieldname"] == matched_field: + fieldtype = field["fieldtype"] + if "max" in field.keys(): + max_value = field["max"] + if "min" in field.keys(): + min_value = field["min"] + + # Field type text update + if fieldtype == "text": + # Test for max/min + if max_value != None: + if len(newvalue) > max_value: + caller.msg("Field '%s' has a maximum length of %i characters." % (matched_field, max_value)) + text = None + return text, options + if min_value != None: + if len(newvalue) < min_value: + caller.msg("Field '%s' reqiures a minimum length of %i characters." % (matched_field, min_value)) + text = None + return text, options + # Update form data + formdata.update({matched_field:newvalue}) + caller.ndb._menutree.formdata = formdata + caller.msg("Field '%s' set to: %s" % (matched_field, newvalue)) + text = None + + # Field type number update + if fieldtype == "number": + try: + newvalue = int(newvalue) + except: + caller.msg("Field '%s' requires a number." % matched_field) + text = None + return text, options + formdata.update({matched_field:newvalue}) + caller.ndb._menutree.formdata = formdata + caller.msg("Field '%s' set to: %i" % (matched_field, newvalue)) text = None return text, options @@ -83,7 +195,7 @@ def form_template_to_dict(formtemplate): formdict = {} for field in formtemplate: - fieldvalue = "" + fieldvalue = None if "default" in field: fieldvalue = field["default"] formdict.update({field["fieldname"]:fieldvalue}) @@ -94,28 +206,40 @@ def display_formdata(formtemplate, formdata): """ Displays a form's current data as a table """ - formtable = evtable.EvTable(border="rows", valign="t", maxwidth=80) + formtable = evtable.EvTable(border="cells", valign="t", maxwidth=80) field_name_width = 5 for field in formtemplate: - new_fieldname = "" - new_fieldvalue = "" + new_fieldname = None + new_fieldvalue = None # Get field name new_fieldname = "|w" + field["fieldname"] + ":|n" if len(field["fieldname"]) + 5 > field_name_width: field_name_width = len(field["fieldname"]) + 5 # Get field value - new_fieldvalue = str(formdata[field["fieldname"]]) + if formdata[field["fieldname"]] != None: + new_fieldvalue = str(formdata[field["fieldname"]]) # Use blank message if field is blank and once is present - if new_fieldvalue == "" and "blankmsg" in field: + if new_fieldvalue == None and "blankmsg" in field: new_fieldvalue = "|x" + str(field["blankmsg"]) + "|n" + elif new_fieldvalue == None: + new_fieldvalue = " " # Add name and value to table formtable.add_row(new_fieldname, new_fieldvalue) formtable.reformat_column(0, align="r", width=field_name_width) - formtable.reformat_column(1, pad_left=0) + # formtable.reformat_column(1, pad_left=0) + + # Get pre-text and/or post-text + pretext = "" + posttext = "" + for field in formtemplate: + if "preformtxt" in field: + pretext = field["preformtxt"] + "|/" + if "postformtxt" in field: + posttext = "|/" + field["postformtxt"] - return formtable + return pretext + str(formtable) + posttext class CmdTest(Command): """ From 8df904d30eeb810aa08192c92cfd1d279a387c21 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Fri, 1 Jun 2018 19:49:46 -0700 Subject: [PATCH 313/466] All basic functionality + example working --- evennia/contrib/fieldfill.py | 105 +++++++++++++++++++++++++++-------- 1 file changed, 83 insertions(+), 22 deletions(-) diff --git a/evennia/contrib/fieldfill.py b/evennia/contrib/fieldfill.py index ffc555bf88..c1fdb9c10f 100644 --- a/evennia/contrib/fieldfill.py +++ b/evennia/contrib/fieldfill.py @@ -2,8 +2,9 @@ Fyield Fyill """ -from evennia.utils import evmenu, evtable +from evennia.utils import evmenu, evtable, delay from evennia import Command +from evennia.server.sessionhandler import SESSIONS """ Complete field data is sent to the given callable as a dictionary (field:value pairs) @@ -18,17 +19,12 @@ Optional: min - Minimum charater length (if text) or value (if number) default - Initial value (blank if not given) blankmsg - Message to show when field is blank + cantclear - Field can't be cleared if True verifyfunc - Name of a callable used to verify input preformtxt - Text to put before the whole form table. Can be put in any field. postformtxt - Text to put after the whole form table. Can be put in any field. """ -SAMPLE_FORM = [ -{"fieldname":"Player", "fieldtype":"text", "max":30, "blankmsg":"(Name of an online player)", - "preformtxt":"Send a delayed message to another player:", "postformtxt":"Syntax: = |/Or: clear , help, show, quit"}, -{"fieldname":"Delay", "fieldtype":"number", "min":3, "max":30, "default":10}, -{"fieldname":"Message", "fieldtype":"text", "min":3, "max":200, "blankmsg":"(Message up to 200 characters)"} -] class FieldEvMenu(evmenu.EvMenu): """ @@ -62,7 +58,8 @@ def init_fill_field(formtemplate, caller, callback): # Pass kwargs to store data needed in the menu kwargs = { "formdata":blank_formdata, - "formtemplate": formtemplate + "formtemplate": formtemplate, + "callback": callback } # Initialize menu of selections @@ -79,6 +76,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): # Retrieve menu info formdata = caller.ndb._menutree.formdata formtemplate = caller.ndb._menutree.formtemplate + callback = caller.ndb._menutree.callback # Display current form data text = display_formdata(formtemplate, formdata) @@ -86,6 +84,10 @@ def menunode_fieldfill(caller, raw_string, **kwargs): "goto":"menunode_fieldfill"}) if raw_string: + # Test for 'submit' command + if raw_string.lower().strip() == "submit": + callback(caller, formdata) + return None, None # Test for 'show' command if raw_string.lower().strip() == "show": return text, options @@ -106,7 +108,17 @@ def menunode_fieldfill(caller, raw_string, **kwargs): caller.msg("Field '%s' does not exist!" % cleartest[1]) text = None return text, options - + + # Test to see if field can be cleared + for field in formtemplate: + if field["fieldname"] == matched_field and "cantclear" in field.keys(): + if field["cantclear"] == True: + caller.msg("Field '%s' can't be cleared!" % matched_field) + text = None + return text, options + + + # Clear the field formdata.update({matched_field:None}) caller.ndb._menutree.formdata = formdata caller.msg("Field '%s' cleared." % matched_field) @@ -145,6 +157,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): fieldtype = None max_value = None min_value = None + verifyfunc = None for field in formtemplate: if field["fieldname"] == matched_field: fieldtype = field["fieldtype"] @@ -152,6 +165,9 @@ def menunode_fieldfill(caller, raw_string, **kwargs): max_value = field["max"] if "min" in field.keys(): min_value = field["min"] + if "verifyfunc" in field.keys(): + verifyfunc = field["verifyfunc"] + # Field type text update if fieldtype == "text": @@ -166,11 +182,6 @@ def menunode_fieldfill(caller, raw_string, **kwargs): caller.msg("Field '%s' reqiures a minimum length of %i characters." % (matched_field, min_value)) text = None return text, options - # Update form data - formdata.update({matched_field:newvalue}) - caller.ndb._menutree.formdata = formdata - caller.msg("Field '%s' set to: %s" % (matched_field, newvalue)) - text = None # Field type number update if fieldtype == "number": @@ -180,10 +191,20 @@ def menunode_fieldfill(caller, raw_string, **kwargs): caller.msg("Field '%s' requires a number." % matched_field) text = None return text, options - formdata.update({matched_field:newvalue}) - caller.ndb._menutree.formdata = formdata - caller.msg("Field '%s' set to: %i" % (matched_field, newvalue)) - text = None + + # Call verify function if present + if verifyfunc: + if verifyfunc(caller, newvalue) == False: + text = None + return text, options + elif verifyfunc(caller, newvalue) != True: + newvalue = verifyfunc(caller, newvalue) + + # If everything checks out, update form!! + formdata.update({matched_field:newvalue}) + caller.ndb._menutree.formdata = formdata + caller.msg("Field '%s' set to: %s" % (matched_field, str(newvalue))) + text = None return text, options @@ -241,6 +262,36 @@ def display_formdata(formtemplate, formdata): return pretext + str(formtable) + posttext + + + +# PLACEHOLDER / EXAMPLE STUFF STARTS HEEEERE + +def verify_online_player(caller, value): + # Get a list of sessions + session_list = SESSIONS.get_sessions() + char_list = [] + matched_character = None + for session in session_list: + if not session.logged_in: + continue + char_list.append(session.get_puppet()) + print char_list + for character in char_list: + if value.lower() in character.key.lower(): + matched_character = character + if not matched_character: + caller.msg("No character matching '%s' is online." % value) + return False + return matched_character + +SAMPLE_FORM = [ +{"fieldname":"Player", "fieldtype":"text", "max":30, "blankmsg":"(Name of an online player)", "verifyfunc":verify_online_player, + "preformtxt":"Send a delayed message to another player:", "postformtxt":"Syntax: = |/Or: clear , help, show, quit"}, +{"fieldname":"Delay", "fieldtype":"number", "min":3, "max":30, "default":10, "cantclear":True}, +{"fieldname":"Message", "fieldtype":"text", "min":3, "max":200, "blankmsg":"(Message up to 200 characters)"} +] + class CmdTest(Command): """ Test stuff @@ -260,7 +311,17 @@ class CmdTestMenu(Command): key = "testmenu" def func(self): - init_fill_field(SAMPLE_FORM, self.caller, Placeholder) - -def Placeholder(): - return \ No newline at end of file + init_fill_field(SAMPLE_FORM, self.caller, init_delayed_message) + +def sendmessage(obj, text): + obj.msg(text) + +def init_delayed_message(caller, formdata): + player_to_message = formdata["Player"] + message_delay = formdata["Delay"] + message = ("Message from %s: " % caller) + formdata["Message"] + + deferred = delay(message_delay, sendmessage, player_to_message, message) + + return + From b0402e47dfb2c76393ccbdcc9a1629d5c9e557a8 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 3 Jun 2018 02:52:16 -0700 Subject: [PATCH 314/466] More functionality + cleanup --- evennia/contrib/fieldfill.py | 128 ++++++++++++++++++++++++----------- 1 file changed, 89 insertions(+), 39 deletions(-) diff --git a/evennia/contrib/fieldfill.py b/evennia/contrib/fieldfill.py index c1fdb9c10f..071558c88c 100644 --- a/evennia/contrib/fieldfill.py +++ b/evennia/contrib/fieldfill.py @@ -2,7 +2,7 @@ Fyield Fyill """ -from evennia.utils import evmenu, evtable, delay +from evennia.utils import evmenu, evtable, delay, list_to_string from evennia import Command from evennia.server.sessionhandler import SESSIONS @@ -20,9 +20,8 @@ Optional: default - Initial value (blank if not given) blankmsg - Message to show when field is blank cantclear - Field can't be cleared if True + required - If True, form cannot be submitted while field is blank verifyfunc - Name of a callable used to verify input - preformtxt - Text to put before the whole form table. Can be put in any field. - postformtxt - Text to put after the whole form table. Can be put in any field. """ @@ -48,7 +47,7 @@ class FieldEvMenu(evmenu.EvMenu): return nodetext -def init_fill_field(formtemplate, caller, callback): +def init_fill_field(formtemplate, caller, callback, pretext="", posttext="", submitcmd="submit", borderstyle="cells"): """ Presents a player with a fillable form. """ @@ -59,7 +58,11 @@ def init_fill_field(formtemplate, caller, callback): kwargs = { "formdata":blank_formdata, "formtemplate": formtemplate, - "callback": callback + "callback": callback, + "pretext": pretext, + "posttext": posttext, + "submitcmd": submitcmd, + "borderstyle": borderstyle } # Initialize menu of selections @@ -70,31 +73,62 @@ def menunode_fieldfill(caller, raw_string, **kwargs): """ Repeating node to fill a menu field """ - # Syntax error goes here - syntax_err = "Syntax: = |/Or: clear , help, show, quit" # Retrieve menu info formdata = caller.ndb._menutree.formdata formtemplate = caller.ndb._menutree.formtemplate callback = caller.ndb._menutree.callback + pretext = caller.ndb._menutree.pretext + posttext = caller.ndb._menutree.posttext + submitcmd = caller.ndb._menutree.submitcmd + borderstyle = caller.ndb._menutree.borderstyle + + # Syntax error + syntax_err = "Syntax: = |/Or: clear , help, show, quit|/'%s' to submit form" % submitcmd + + # Set help text, including listing the 'submit' command + help_text = """Available commands: +|w = :|n Set given field to new value, replacing the old value +|wclear :|n Clear the value in the given field, making it blank +|wshow|n: Show the form's current values +|whelp|n: Display this help screen +|wquit|n: Quit the form menu without submitting +|w%s|n: Submit this form and quit the menu""" % submitcmd # Display current form data - text = display_formdata(formtemplate, formdata) + text = (display_formdata(formtemplate, formdata, pretext=pretext, + posttext=posttext, borderstyle=borderstyle), help_text) options = ({"key": "_default", "goto":"menunode_fieldfill"}) if raw_string: - # Test for 'submit' command - if raw_string.lower().strip() == "submit": + # Test for given 'submit' command + if raw_string.lower().strip() == submitcmd: + # Test to see if any blank fields are required + blank_and_required = [] + for field in formtemplate: + if "required" in field.keys(): + # If field is required but current form data for field is blank + if field["required"] == True and formdata[field["fieldname"]] == None: + # Add to blank and required fields + blank_and_required.append(field["fieldname"]) + if len(blank_and_required) > 0: + caller.msg("The following blank fields require a value: %s" % list_to_string(blank_and_required)) + text = (None, help_text) + return text, options + + # If everything checks out, pass form data to the callback and end the menu! callback(caller, formdata) return None, None + # Test for 'show' command if raw_string.lower().strip() == "show": return text, options + # Test for 'clear' command cleartest = raw_string.lower().strip().split(" ", 1) if cleartest[0].lower() == "clear": - text = None + text = (None, help_text) if len(cleartest) < 2: caller.msg(syntax_err) return text, options @@ -106,7 +140,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): if not matched_field: caller.msg("Field '%s' does not exist!" % cleartest[1]) - text = None + text = (None, help_text) return text, options # Test to see if field can be cleared @@ -114,7 +148,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): if field["fieldname"] == matched_field and "cantclear" in field.keys(): if field["cantclear"] == True: caller.msg("Field '%s' can't be cleared!" % matched_field) - text = None + text = (None, help_text) return text, options @@ -125,7 +159,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): return text, options if "=" not in raw_string: - text = None + text = (None, help_text) caller.msg(syntax_err) return text, options @@ -137,7 +171,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): # Syntax error of field name is too short or blank if len(fieldname) < 3: caller.msg(syntax_err) - text = None + text = (None, help_text) return text, options # Attempt to match field name to field in form data @@ -149,7 +183,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): # No matched field if matched_field == None: caller.msg("Field '%s' does not exist!" % fieldname) - text = None + text = (None, help_text) return text, options # Set new field value if match @@ -175,12 +209,12 @@ def menunode_fieldfill(caller, raw_string, **kwargs): if max_value != None: if len(newvalue) > max_value: caller.msg("Field '%s' has a maximum length of %i characters." % (matched_field, max_value)) - text = None + text = (None, help_text) return text, options if min_value != None: if len(newvalue) < min_value: caller.msg("Field '%s' reqiures a minimum length of %i characters." % (matched_field, min_value)) - text = None + text = (None, help_text) return text, options # Field type number update @@ -189,13 +223,13 @@ def menunode_fieldfill(caller, raw_string, **kwargs): newvalue = int(newvalue) except: caller.msg("Field '%s' requires a number." % matched_field) - text = None + text = (None, help_text) return text, options # Call verify function if present if verifyfunc: if verifyfunc(caller, newvalue) == False: - text = None + text = (None, help_text) return text, options elif verifyfunc(caller, newvalue) != True: newvalue = verifyfunc(caller, newvalue) @@ -204,7 +238,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): formdata.update({matched_field:newvalue}) caller.ndb._menutree.formdata = formdata caller.msg("Field '%s' set to: %s" % (matched_field, str(newvalue))) - text = None + text = (None, help_text) return text, options @@ -223,11 +257,13 @@ def form_template_to_dict(formtemplate): return formdict -def display_formdata(formtemplate, formdata): +def display_formdata(formtemplate, formdata, + pretext="", posttext="", borderstyle="cells"): """ Displays a form's current data as a table """ - formtable = evtable.EvTable(border="cells", valign="t", maxwidth=80) + + formtable = evtable.EvTable(border=borderstyle, valign="t", maxwidth=80) field_name_width = 5 for field in formtemplate: @@ -250,17 +286,8 @@ def display_formdata(formtemplate, formdata): formtable.reformat_column(0, align="r", width=field_name_width) # formtable.reformat_column(1, pad_left=0) - - # Get pre-text and/or post-text - pretext = "" - posttext = "" - for field in formtemplate: - if "preformtxt" in field: - pretext = field["preformtxt"] + "|/" - if "postformtxt" in field: - posttext = "|/" + field["postformtxt"] - return pretext + str(formtable) + posttext + return pretext + "|/" + str(formtable) + "|/" + posttext @@ -272,22 +299,35 @@ def verify_online_player(caller, value): session_list = SESSIONS.get_sessions() char_list = [] matched_character = None + + # Get a list of online characters for session in session_list: if not session.logged_in: + # Skip over logged out characters continue + # Append to our list of online characters otherwise char_list.append(session.get_puppet()) - print char_list + + # Match player input to a character name for character in char_list: - if value.lower() in character.key.lower(): + if value.lower() == character.key.lower(): matched_character = character + + # If input didn't match to a character if not matched_character: + # Send the player an error message unique to this function caller.msg("No character matching '%s' is online." % value) + # Returning False indicates the new value is not valid return False + + # Returning anything besides True or False will replace the player's input with the returned value + # In this case, the value becomes a reference to the character object + # You can store data besides strings and integers in the 'formdata' dictionary this way! return matched_character SAMPLE_FORM = [ -{"fieldname":"Player", "fieldtype":"text", "max":30, "blankmsg":"(Name of an online player)", "verifyfunc":verify_online_player, - "preformtxt":"Send a delayed message to another player:", "postformtxt":"Syntax: = |/Or: clear , help, show, quit"}, +{"fieldname":"Character", "fieldtype":"text", "max":30, "blankmsg":"(Name of an online player)", + "required":True, "verifyfunc":verify_online_player}, {"fieldname":"Delay", "fieldtype":"number", "min":3, "max":30, "default":10, "cantclear":True}, {"fieldname":"Message", "fieldtype":"text", "min":3, "max":200, "blankmsg":"(Message up to 200 characters)"} ] @@ -311,16 +351,26 @@ class CmdTestMenu(Command): key = "testmenu" def func(self): - init_fill_field(SAMPLE_FORM, self.caller, init_delayed_message) + + pretext = "|cSend a delayed message to another player ---------------------------------------|n" + posttext = ("|c--------------------------------------------------------------------------------|n|/" + "Syntax: type |c = |n to change the values of the form. Given|/" + "player must be currently logged in, delay is given in seconds. When you are|/" + "finished, type '|csend|n' to send the message.|/") + + init_fill_field(SAMPLE_FORM, self.caller, init_delayed_message, + pretext=pretext, posttext=posttext, + submitcmd="send", borderstyle="none") def sendmessage(obj, text): obj.msg(text) def init_delayed_message(caller, formdata): - player_to_message = formdata["Player"] + player_to_message = formdata["Character"] message_delay = formdata["Delay"] message = ("Message from %s: " % caller) + formdata["Message"] + caller.msg("Message sent to %s!" % player_to_message) deferred = delay(message_delay, sendmessage, player_to_message, message) return From 054cba42bf834bc8fc3e1a00ac543f8e9028abb3 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 6 Jun 2018 19:15:20 +0200 Subject: [PATCH 315/466] Refactor prototype-functionality into its own package --- evennia/locks/lockhandler.py | 16 + evennia/prototypes/README.md | 145 +++ evennia/prototypes/__init__.py | 0 evennia/prototypes/menus.py | 709 ++++++++++++ evennia/prototypes/protfuncs.py | 78 ++ evennia/prototypes/prototypes.py | 280 +++++ evennia/prototypes/spawner.py | 600 ++++++++++ evennia/prototypes/utils.py | 150 +++ evennia/utils/spawner.py | 1752 ------------------------------ 9 files changed, 1978 insertions(+), 1752 deletions(-) create mode 100644 evennia/prototypes/README.md create mode 100644 evennia/prototypes/__init__.py create mode 100644 evennia/prototypes/menus.py create mode 100644 evennia/prototypes/protfuncs.py create mode 100644 evennia/prototypes/prototypes.py create mode 100644 evennia/prototypes/spawner.py create mode 100644 evennia/prototypes/utils.py delete mode 100644 evennia/utils/spawner.py diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py index c65b30c131..4822dde1b6 100644 --- a/evennia/locks/lockhandler.py +++ b/evennia/locks/lockhandler.py @@ -647,6 +647,22 @@ def check_lockstring(self, accessing_obj, lockstring, no_superuser_bypass=False, default=default, access_type=access_type) +def validate_lockstring(lockstring): + """ + Validate so lockstring is on a valid form. + + Args: + lockstring (str): Lockstring to validate. + + Returns: + is_valid (bool): If the lockstring is valid or not. + error (str or None): A string describing the error, or None + if no error was found. + + """ + return _LOCK_HANDLER.valdate(lockstring) + + def _test(): # testing diff --git a/evennia/prototypes/README.md b/evennia/prototypes/README.md new file mode 100644 index 0000000000..0f4139aa3e --- /dev/null +++ b/evennia/prototypes/README.md @@ -0,0 +1,145 @@ +# Prototypes + +A 'Prototype' is a normal Python dictionary describing unique features of individual instance of a +Typeclass. The prototype is used to 'spawn' a new instance with custom features detailed by said +prototype. This allows for creating variations without having to create a large number of actual +Typeclasses. It is a good way to allow Builders more freedom of creation without giving them full +Python access to create Typeclasses. + +For example, if a Typeclass 'Cat' describes all the coded differences between a Cat and +other types of animals, then prototypes could be used to quickly create unique individual cats with +different Attributes/properties (like different colors, stats, names etc) without having to make a new +Typeclass for each. Prototypes have inheritance and can be scripted when they are applied to create +a new instance of a typeclass - a common example would be to randomize stats and name. + +The prototype is a normal dictionary with specific keys. Almost all values can be callables +triggered when the prototype is used to spawn a new instance. Below is an example: + +``` +{ +# meta-keys - these are used only when listing prototypes in-game. Only prototype_key is mandatory, +# but it must be globally unique. + + "prototype_key": "base_goblin", + "prototype_desc": "A basic goblin", + "prototype_locks": "edit:all();spawn:all()", + "prototype_tags": "mobs", + +# fixed-meaning keys, modifying the spawned instance. 'typeclass' may be +# replaced by 'parent', referring to the prototype_key of an existing prototype +# to inherit from. + + "typeclass": "types.objects.Monster", + "key": "goblin grunt", + "tags": ["mob", "evil", ('greenskin','mob')] # tags as well as tags with category etc + "attrs": [("weapon", "sword")] # this allows to set Attributes with categories etc + +# non-fixed keys are interpreted as Attributes and their + + "health": lambda: randint(20,30), + "resists": ["cold", "poison"], + "attacks": ["fists"], + "weaknesses": ["fire", "light"] + } + +``` +## Using prototypes + +Prototypes are generally used as inputs to the `spawn` command: + + @spawn prototype_key + +This will spawn a new instance of the prototype in the caller's current location unless the +`location` key of the prototype was set (see below). The caller must pass the prototype's 'spawn' +lock to be able to use it. + + @spawn/list [prototype_key] + +will show all available prototypes along with meta info, or look at a specific prototype in detail. + + +## Creating prototypes + +The `spawn` command can also be used to directly create/update prototypes from in-game. + + spawn/save {"prototype_key: "goblin", ... } + +but it is probably more convenient to use the menu-driven prototype wizard: + + spawn/menu goblin + +In code: + +```python + +from evennia import prototypes + +goblin = {"prototype_key": "goblin:, ... } + +prototype = prototypes.save_prototype(caller, **goblin) + +``` + +Prototypes will normally be stored in the database (internally this is done using a Script, holding +the meta-info and the prototype). One can also define prototypes outside of the game by assigning +the prototype dictionary to a global variable in a module defined by `settings.PROTOTYPE_MODULES`: + +```python +# in e.g. mygame/world/prototypes.py + +GOBLIN = { + "prototype_key": "goblin", + ... + } + +``` + +Such prototypes cannot be modified from inside the game no matter what `edit` lock they are given +(we refer to them as 'readonly') but can be a fast and efficient way to give builders a starting +library of prototypes to inherit from. + +## Valid Prototype keys + +Every prototype key also accepts a callable (taking no arguments) for producing its value or a +string with an $protfunc definition. That callable/protfunc must then return a value on a form the +prototype key expects. + + - `prototype_key` (str): name of this prototype. This is used when storing prototypes and should + be unique. This should always be defined but for prototypes defined in modules, the + variable holding the prototype dict will become the prototype_key if it's not explicitly + given. + - `prototype_desc` (str, optional): describes prototype in listings + - `prototype_locks` (str, optional): locks for restricting access to this prototype. Locktypes + supported are 'edit' and 'use'. + - `prototype_tags` (list, optional): List of tags or tuples (tag, category) used to group prototype + in listings + + - `parent` (str or tuple, optional): name (`prototype_key`) of eventual parent prototype, or a + list of parents for multiple left-to-right inheritance. + - `prototype`: Deprecated. Same meaning as 'parent'. + - `typeclass` (str, optional): if not set, will use typeclass of parent prototype or use + `settings.BASE_OBJECT_TYPECLASS` + - `key` (str, optional): the name of the spawned object. If not given this will set to a + random hash + - `location` (obj, optional): location of the object - a valid object or #dbref + - `home` (obj or str, optional): valid object or #dbref + - `destination` (obj or str, optional): only valid for exits (object or #dbref) + + - `permissions` (str or list, optional): which permissions for spawned object to have + - `locks` (str, optional): lock-string for the spawned object + - `aliases` (str or list, optional): Aliases for the spawned object. + - `exec` (str, optional): this is a string of python code to execute or a list of such + codes. This can be used e.g. to trigger custom handlers on the object. The execution + namespace contains 'evennia' for the library and 'obj'. All default spawn commands limit + this functionality to Developer/superusers. Usually it's better to use callables or + prototypefuncs instead of this. + - `tags` (str, tuple or list, optional): string or list of strings or tuples + `(tagstr, category)`. Plain strings will be result in tags with no category (default tags). + - `attrs` (tuple or list, optional): tuple or list of tuples of Attributes to add. This + form allows more complex Attributes to be set. Tuples at least specify `(key, value)` + but can also specify up to `(key, value, category, lockstring)`. If you want to specify a + lockstring but not a category, set the category to `None`. + - `ndb_` (any): value of a nattribute (`ndb_` is stripped). This is usually not useful to + put in a prototype unless the NAttribute is used immediately upon spawning. + - `other` (any): any other name is interpreted as the key of an Attribute with + its value. Such Attributes have no categories. diff --git a/evennia/prototypes/__init__.py b/evennia/prototypes/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py new file mode 100644 index 0000000000..85e7f3f574 --- /dev/null +++ b/evennia/prototypes/menus.py @@ -0,0 +1,709 @@ +""" + +OLC Prototype menu nodes + +""" + +from evennia.utils.evmenu import EvMenu, list_node +from evennia.utils.ansi import strip_ansi + +# ------------------------------------------------------------ +# +# OLC Prototype design menu +# +# ------------------------------------------------------------ + +# Helper functions + + +def _get_menu_prototype(caller): + + prototype = None + if hasattr(caller.ndb._menutree, "olc_prototype"): + prototype = caller.ndb._menutree.olc_prototype + if not prototype: + caller.ndb._menutree.olc_prototype = prototype = {} + caller.ndb._menutree.olc_new = True + return prototype + + +def _is_new_prototype(caller): + return hasattr(caller.ndb._menutree, "olc_new") + + +def _set_menu_prototype(caller, field, value): + prototype = _get_menu_prototype(caller) + prototype[field] = value + caller.ndb._menutree.olc_prototype = prototype + + +def _format_property(prop, required=False, prototype=None, cropper=None): + + if prototype is not None: + prop = prototype.get(prop, '') + + out = prop + if callable(prop): + if hasattr(prop, '__name__'): + out = "<{}>".format(prop.__name__) + else: + out = repr(prop) + if is_iter(prop): + out = ", ".join(str(pr) for pr in prop) + if not out and required: + out = "|rrequired" + return " ({}|n)".format(cropper(out) if cropper else crop(out, _MENU_CROP_WIDTH)) + + +def _set_property(caller, raw_string, **kwargs): + """ + Update a property. To be called by the 'goto' option variable. + + Args: + caller (Object, Account): The user of the wizard. + raw_string (str): Input from user on given node - the new value to set. + Kwargs: + prop (str): Property name to edit with `raw_string`. + processor (callable): Converts `raw_string` to a form suitable for saving. + next_node (str): Where to redirect to after this has run. + Returns: + next_node (str): Next node to go to. + + """ + prop = kwargs.get("prop", "prototype_key") + processor = kwargs.get("processor", None) + next_node = kwargs.get("next_node", "node_index") + + propname_low = prop.strip().lower() + + if callable(processor): + try: + value = processor(raw_string) + except Exception as err: + caller.msg("Could not set {prop} to {value} ({err})".format( + prop=prop.replace("_", "-").capitalize(), value=raw_string, err=str(err))) + # this means we'll re-run the current node. + return None + else: + value = raw_string + + if not value: + return next_node + + prototype = _get_menu_prototype(caller) + + # typeclass and prototype can't co-exist + if propname_low == "typeclass": + prototype.pop("prototype", None) + if propname_low == "prototype": + prototype.pop("typeclass", None) + + caller.ndb._menutree.olc_prototype = prototype + + caller.msg("Set {prop} to '{value}'.".format(prop, value=str(value))) + + return next_node + + +def _wizard_options(curr_node, prev_node, next_node, color="|W"): + options = [] + if prev_node: + options.append({"key": ("|wb|Wack", "b"), + "desc": "{color}({node})|n".format( + color=color, node=prev_node.replace("_", "-")), + "goto": "node_{}".format(prev_node)}) + if next_node: + options.append({"key": ("|wf|Worward", "f"), + "desc": "{color}({node})|n".format( + color=color, node=next_node.replace("_", "-")), + "goto": "node_{}".format(next_node)}) + + if "index" not in (prev_node, next_node): + options.append({"key": ("|wi|Wndex", "i"), + "goto": "node_index"}) + + if curr_node: + options.append({"key": ("|wv|Walidate prototype", "v"), + "goto": ("node_validate_prototype", {"back": curr_node})}) + + return options + + +def _path_cropper(pythonpath): + "Crop path to only the last component" + return pythonpath.split('.')[-1] + + +# Menu nodes + +def node_index(caller): + prototype = _get_menu_prototype(caller) + + text = ("|c --- Prototype wizard --- |n\n\n" + "Define the |yproperties|n of the prototype. All prototype values can be " + "over-ridden at the time of spawning an instance of the prototype, but some are " + "required.\n\n'|wMeta'-properties|n are not used in the prototype itself but are used " + "to organize and list prototypes. The 'Meta-Key' uniquely identifies the prototype " + "and allows you to edit an existing prototype or save a new one for use by you or " + "others later.\n\n(make choice; q to abort. If unsure, start from 1.)") + + options = [] + options.append( + {"desc": "|WPrototype-Key|n|n{}".format(_format_property("Key", True, prototype, None)), + "goto": "node_prototype_key"}) + for key in ('Prototype', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', + 'Permissions', 'Location', 'Home', 'Destination'): + required = False + cropper = None + if key in ("Prototype", "Typeclass"): + required = "prototype" not in prototype and "typeclass" not in prototype + if key == 'Typeclass': + cropper = _path_cropper + options.append( + {"desc": "|w{}|n{}".format( + key, _format_property(key, required, prototype, cropper=cropper)), + "goto": "node_{}".format(key.lower())}) + required = False + for key in ('Desc', 'Tags', 'Locks'): + options.append( + {"desc": "|WPrototype-{}|n|n{}".format(key, _format_property(key, required, prototype, None)), + "goto": "node_prototype_{}".format(key.lower())}) + + return text, options + + +def node_validate_prototype(caller, raw_string, **kwargs): + prototype = _get_menu_prototype(caller) + + txt = prototype_to_str(prototype) + errors = "\n\n|g No validation errors found.|n (but errors could still happen at spawn-time)" + try: + # validate, don't spawn + spawn(prototype, return_prototypes=True) + except RuntimeError as err: + errors = "\n\n|rError: {}|n".format(err) + text = (txt + errors) + + options = _wizard_options(None, kwargs.get("back"), None) + + return text, options + + +def _check_prototype_key(caller, key): + old_prototype = search_prototype(key) + olc_new = _is_new_prototype(caller) + key = key.strip().lower() + if old_prototype: + # we are starting a new prototype that matches an existing + if not caller.locks.check_lockstring( + caller, old_prototype['prototype_locks'], access_type='edit'): + # return to the node_prototype_key to try another key + caller.msg("Prototype '{key}' already exists and you don't " + "have permission to edit it.".format(key=key)) + return "node_prototype_key" + elif olc_new: + # we are selecting an existing prototype to edit. Reset to index. + del caller.ndb._menutree.olc_new + caller.ndb._menutree.olc_prototype = old_prototype + caller.msg("Prototype already exists. Reloading.") + return "node_index" + + return _set_property(caller, key, prop='prototype_key', next_node="node_prototype") + + +def node_prototype_key(caller): + prototype = _get_menu_prototype(caller) + text = ["The prototype name, or |wMeta-Key|n, uniquely identifies the prototype. " + "It is used to find and use the prototype to spawn new entities. " + "It is not case sensitive."] + old_key = prototype.get('prototype_key', None) + if old_key: + text.append("Current key is '|w{key}|n'".format(key=old_key)) + else: + text.append("The key is currently unset.") + text.append("Enter text or make a choice (q for quit)") + text = "\n\n".join(text) + options = _wizard_options("prototype_key", "index", "prototype") + options.append({"key": "_default", + "goto": _check_prototype_key}) + return text, options + + +def _all_prototypes(caller): + return [prototype["prototype_key"] + for prototype in search_prototype() if "prototype_key" in prototype] + + +def _prototype_examine(caller, prototype_name): + prototypes = search_prototype(key=prototype_name) + if prototypes: + caller.msg(prototype_to_str(prototypes[0])) + caller.msg("Prototype not registered.") + return None + + +def _prototype_select(caller, prototype): + ret = _set_property(caller, prototype, prop="prototype", processor=str, next_node="node_key") + caller.msg("Selected prototype |y{}|n. Removed any set typeclass parent.".format(prototype)) + return ret + + +@list_node(_all_prototypes, _prototype_select) +def node_prototype(caller): + prototype = _get_menu_prototype(caller) + + prot_parent_key = prototype.get('prototype') + + text = ["Set the prototype's |yParent Prototype|n. If this is unset, Typeclass will be used."] + if prot_parent_key: + prot_parent = search_prototype(prot_parent_key) + if prot_parent: + text.append("Current parent prototype is {}:\n{}".format(prototype_to_str(prot_parent))) + else: + text.append("Current parent prototype |r{prototype}|n " + "does not appear to exist.".format(prot_parent_key)) + else: + text.append("Parent prototype is not set") + text = "\n\n".join(text) + options = _wizard_options("prototype", "prototype_key", "typeclass", color="|W") + options.append({"key": "_default", + "goto": _prototype_examine}) + + return text, options + + +def _all_typeclasses(caller): + return list(sorted(get_all_typeclasses().keys())) + + +def _typeclass_examine(caller, typeclass_path): + if typeclass_path is None: + # this means we are exiting the listing + return "node_key" + + typeclass = get_all_typeclasses().get(typeclass_path) + if typeclass: + docstr = [] + for line in typeclass.__doc__.split("\n"): + if line.strip(): + docstr.append(line) + elif docstr: + break + docstr = '\n'.join(docstr) if docstr else "" + txt = "Typeclass |y{typeclass_path}|n; First paragraph of docstring:\n\n{docstring}".format( + typeclass_path=typeclass_path, docstring=docstr) + else: + txt = "This is typeclass |y{}|n.".format(typeclass) + caller.msg(txt) + return None + + +def _typeclass_select(caller, typeclass): + ret = _set_property(caller, typeclass, prop='typeclass', processor=str, next_node="node_key") + caller.msg("Selected typeclass |y{}|n. Removed any set prototype parent.".format(typeclass)) + return ret + + +@list_node(_all_typeclasses, _typeclass_select) +def node_typeclass(caller): + prototype = _get_menu_prototype(caller) + typeclass = prototype.get("typeclass") + + text = ["Set the typeclass's parent |yTypeclass|n."] + if typeclass: + text.append("Current typeclass is |y{typeclass}|n.".format(typeclass=typeclass)) + else: + text.append("Using default typeclass {typeclass}.".format( + typeclass=settings.BASE_OBJECT_TYPECLASS)) + text = "\n\n".join(text) + options = _wizard_options("typeclass", "prototype", "key", color="|W") + options.append({"key": "_default", + "goto": _typeclass_examine}) + return text, options + + +def node_key(caller): + prototype = _get_menu_prototype(caller) + key = prototype.get("key") + + text = ["Set the prototype's |yKey|n. This will retain case sensitivity."] + if key: + text.append("Current key value is '|y{key}|n'.".format(key=key)) + else: + text.append("Key is currently unset.") + text = "\n\n".join(text) + options = _wizard_options("key", "typeclass", "aliases") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="key", + processor=lambda s: s.strip(), + next_node="node_aliases"))}) + return text, options + + +def node_aliases(caller): + prototype = _get_menu_prototype(caller) + aliases = prototype.get("aliases") + + text = ["Set the prototype's |yAliases|n. Separate multiple aliases with commas. " + "ill retain case sensitivity."] + if aliases: + text.append("Current aliases are '|y{aliases}|n'.".format(aliases=aliases)) + else: + text.append("No aliases are set.") + text = "\n\n".join(text) + options = _wizard_options("aliases", "key", "attrs") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="aliases", + processor=lambda s: [part.strip() for part in s.split(",")], + next_node="node_attrs"))}) + return text, options + + +def _caller_attrs(caller): + prototype = _get_menu_prototype(caller) + attrs = prototype.get("attrs", []) + return attrs + + +def _attrparse(caller, attr_string): + "attr is entering on the form 'attr = value'" + + if '=' in attr_string: + attrname, value = (part.strip() for part in attr_string.split('=', 1)) + attrname = attrname.lower() + if attrname: + try: + value = literal_eval(value) + except SyntaxError: + caller.msg(_MENU_ATTR_LITERAL_EVAL_ERROR) + else: + return attrname, value + else: + return None, None + + +def _add_attr(caller, attr_string, **kwargs): + attrname, value = _attrparse(caller, attr_string) + if attrname: + prot = _get_menu_prototype(caller) + prot['attrs'][attrname] = value + _set_menu_prototype(caller, "prototype", prot) + text = "Added" + else: + text = "Attribute must be given as 'attrname = ' where uses valid Python." + options = {"key": "_default", + "goto": lambda caller: None} + return text, options + + +def _edit_attr(caller, attrname, new_value, **kwargs): + attrname, value = _attrparse("{}={}".format(caller, attrname, new_value)) + if attrname: + prot = _get_menu_prototype(caller) + prot['attrs'][attrname] = value + text = "Edited Attribute {} = {}".format(attrname, value) + else: + text = "Attribute value must be valid Python." + options = {"key": "_default", + "goto": lambda caller: None} + return text, options + + +def _examine_attr(caller, selection): + prot = _get_menu_prototype(caller) + value = prot['attrs'][selection] + return "Attribute {} = {}".format(selection, value) + + +@list_node(_caller_attrs) +def node_attrs(caller): + prot = _get_menu_prototype(caller) + attrs = prot.get("attrs") + + text = ["Set the prototype's |yAttributes|n. Separate multiple attrs with commas. " + "Will retain case sensitivity."] + if attrs: + text.append("Current attrs are '|y{attrs}|n'.".format(attrs=attrs)) + else: + text.append("No attrs are set.") + text = "\n\n".join(text) + options = _wizard_options("attrs", "aliases", "tags") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="attrs", + processor=lambda s: [part.strip() for part in s.split(",")], + next_node="node_tags"))}) + return text, options + + +def _caller_tags(caller): + prototype = _get_menu_prototype(caller) + tags = prototype.get("tags") + return tags + + +def _add_tag(caller, tag, **kwargs): + tag = tag.strip().lower() + prototype = _get_menu_prototype(caller) + tags = prototype.get('tags', []) + if tags: + if tag not in tags: + tags.append(tag) + else: + tags = [tag] + prot['tags'] = tags + _set_menu_prototype(caller, "prototype", prot) + text = kwargs.get("text") + if not text: + text = "Added tag {}. (return to continue)".format(tag) + options = {"key": "_default", + "goto": lambda caller: None} + return text, options + + +def _edit_tag(caller, old_tag, new_tag, **kwargs): + prototype = _get_menu_prototype(caller) + tags = prototype.get('tags', []) + + old_tag = old_tag.strip().lower() + new_tag = new_tag.strip().lower() + tags[tags.index(old_tag)] = new_tag + prototype['tags'] = tags + _set_menu_prototype(caller, 'prototype', prototype) + + text = kwargs.get('text') + if not text: + text = "Changed tag {} to {}.".format(old_tag, new_tag) + options = {"key": "_default", + "goto": lambda caller: None} + return text, options + + +@list_node(_caller_tags) +def node_tags(caller): + text = "Set the prototype's |yTags|n." + options = _wizard_options("tags", "attrs", "locks") + return text, options + + +def node_locks(caller): + prototype = _get_menu_prototype(caller) + locks = prototype.get("locks") + + text = ["Set the prototype's |yLock string|n. Separate multiple locks with semi-colons. " + "Will retain case sensitivity."] + if locks: + text.append("Current locks are '|y{locks}|n'.".format(locks=locks)) + else: + text.append("No locks are set.") + text = "\n\n".join(text) + options = _wizard_options("locks", "tags", "permissions") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="locks", + processor=lambda s: s.strip(), + next_node="node_permissions"))}) + return text, options + + +def node_permissions(caller): + prototype = _get_menu_prototype(caller) + permissions = prototype.get("permissions") + + text = ["Set the prototype's |yPermissions|n. Separate multiple permissions with commas. " + "Will retain case sensitivity."] + if permissions: + text.append("Current permissions are '|y{permissions}|n'.".format(permissions=permissions)) + else: + text.append("No permissions are set.") + text = "\n\n".join(text) + options = _wizard_options("permissions", "destination", "location") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="permissions", + processor=lambda s: [part.strip() for part in s.split(",")], + next_node="node_location"))}) + return text, options + + +def node_location(caller): + prototype = _get_menu_prototype(caller) + location = prototype.get("location") + + text = ["Set the prototype's |yLocation|n"] + if location: + text.append("Current location is |y{location}|n.".format(location=location)) + else: + text.append("Default location is {}'s inventory.".format(caller)) + text = "\n\n".join(text) + options = _wizard_options("location", "permissions", "home") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="location", + processor=lambda s: s.strip(), + next_node="node_home"))}) + return text, options + + +def node_home(caller): + prototype = _get_menu_prototype(caller) + home = prototype.get("home") + + text = ["Set the prototype's |yHome location|n"] + if home: + text.append("Current home location is |y{home}|n.".format(home=home)) + else: + text.append("Default home location (|y{home}|n) used.".format(home=settings.DEFAULT_HOME)) + text = "\n\n".join(text) + options = _wizard_options("home", "aliases", "destination") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="home", + processor=lambda s: s.strip(), + next_node="node_destination"))}) + return text, options + + +def node_destination(caller): + prototype = _get_menu_prototype(caller) + dest = prototype.get("dest") + + text = ["Set the prototype's |yDestination|n. This is usually only used for Exits."] + if dest: + text.append("Current destination is |y{dest}|n.".format(dest=dest)) + else: + text.append("No destination is set (default).") + text = "\n\n".join(text) + options = _wizard_options("destination", "home", "prototype_desc") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="dest", + processor=lambda s: s.strip(), + next_node="node_prototype_desc"))}) + return text, options + + +def node_prototype_desc(caller): + + prototype = _get_menu_prototype(caller) + text = ["The |wMeta-Description|n briefly describes the prototype for viewing in listings."] + desc = prototype.get("prototype_desc", None) + + if desc: + text.append("The current meta desc is:\n\"|w{desc}|n\"".format(desc=desc)) + else: + text.append("Description is currently unset.") + text = "\n\n".join(text) + options = _wizard_options("prototype_desc", "prototype_key", "prototype_tags") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop='prototype_desc', + processor=lambda s: s.strip(), + next_node="node_prototype_tags"))}) + + return text, options + + +def node_prototype_tags(caller): + prototype = _get_menu_prototype(caller) + text = ["|wMeta-Tags|n can be used to classify and find prototypes. Tags are case-insensitive. " + "Separate multiple by tags by commas."] + tags = prototype.get('prototype_tags', []) + + if tags: + text.append("The current tags are:\n|w{tags}|n".format(tags=tags)) + else: + text.append("No tags are currently set.") + text = "\n\n".join(text) + options = _wizard_options("prototype_tags", "prototype_desc", "prototype_locks") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="prototype_tags", + processor=lambda s: [ + str(part.strip().lower()) for part in s.split(",")], + next_node="node_prototype_locks"))}) + return text, options + + +def node_prototype_locks(caller): + prototype = _get_menu_prototype(caller) + text = ["Set |wMeta-Locks|n on the prototype. There are two valid lock types: " + "'edit' (who can edit the prototype) and 'use' (who can apply the prototype)\n" + "(If you are unsure, leave as default.)"] + locks = prototype.get('prototype_locks', '') + if locks: + text.append("Current lock is |w'{lockstring}'|n".format(lockstring=locks)) + else: + text.append("Lock unset - if not changed the default lockstring will be set as\n" + " |w'use:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) + text = "\n\n".join(text) + options = _wizard_options("prototype_locks", "prototype_tags", "index") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="prototype_locks", + processor=lambda s: s.strip().lower(), + next_node="node_index"))}) + return text, options + + +class OLCMenu(EvMenu): + """ + A custom EvMenu with a different formatting for the options. + + """ + def options_formatter(self, optionlist): + """ + Split the options into two blocks - olc options and normal options + + """ + olc_keys = ("index", "forward", "back", "previous", "next", "validate prototype") + olc_options = [] + other_options = [] + for key, desc in optionlist: + raw_key = strip_ansi(key) + if raw_key in olc_keys: + desc = " {}".format(desc) if desc else "" + olc_options.append("|lc{}|lt{}|le{}".format(raw_key, key, desc)) + else: + other_options.append((key, desc)) + + olc_options = " | ".join(olc_options) + " | " + "|wq|Wuit" if olc_options else "" + other_options = super(OLCMenu, self).options_formatter(other_options) + sep = "\n\n" if olc_options and other_options else "" + + return "{}{}{}".format(olc_options, sep, other_options) + + +def start_olc(caller, session=None, prototype=None): + """ + Start menu-driven olc system for prototypes. + + Args: + caller (Object or Account): The entity starting the menu. + session (Session, optional): The individual session to get data. + prototype (dict, optional): Given when editing an existing + prototype rather than creating a new one. + + """ + menudata = {"node_index": node_index, + "node_validate_prototype": node_validate_prototype, + "node_prototype_key": node_prototype_key, + "node_prototype": node_prototype, + "node_typeclass": node_typeclass, + "node_key": node_key, + "node_aliases": node_aliases, + "node_attrs": node_attrs, + "node_tags": node_tags, + "node_locks": node_locks, + "node_permissions": node_permissions, + "node_location": node_location, + "node_home": node_home, + "node_destination": node_destination, + "node_prototype_desc": node_prototype_desc, + "node_prototype_tags": node_prototype_tags, + "node_prototype_locks": node_prototype_locks, + } + OLCMenu(caller, menudata, startnode='node_index', session=session, olc_prototype=prototype) + diff --git a/evennia/prototypes/protfuncs.py b/evennia/prototypes/protfuncs.py new file mode 100644 index 0000000000..057f5f770f --- /dev/null +++ b/evennia/prototypes/protfuncs.py @@ -0,0 +1,78 @@ +""" +Protfuncs are function-strings embedded in a prototype and allows for a builder to create a +prototype with custom logics without having access to Python. The Protfunc is parsed using the +inlinefunc parser but is fired at the moment the spawning happens, using the creating object's +session as input. + +In the prototype dict, the protfunc is specified as a string inside the prototype, e.g.: + + { ... + + "key": "$funcname(arg1, arg2, ...)" + + ... } + +and multiple functions can be nested (no keyword args are supported). The result will be used as the +value for that prototype key for that individual spawn. + +Available protfuncs are callables in one of the modules of `settings.PROTOTYPEFUNC_MODULES`. They +are specified as functions + + def funcname (*args, **kwargs) + +where *args are the arguments given in the prototype, and **kwargs are inserted by Evennia: + + - session (Session): The Session of the entity spawning using this prototype. + - prototype_key (str): The currently spawning prototype-key. + - prototype (dict): The dict this protfunc is a part of. + +Any traceback raised by this function will be handled at the time of spawning and abort the spawn +before any object is created/updated. It must otherwise return the value to store for the specified +prototype key (this value must be possible to serialize in an Attribute). + +""" + +from django.conf import settings +from evennia.utils import inlinefuncs +from evennia.utils.utils import callables_from_module + + +_PROTOTYPEFUNCS = {} + +for mod in settings.PROTOTYPEFUNC_MODULES: + try: + callables = callables_from_module(mod) + if mod == __name__: + callables.pop("protfunc_parser") + _PROTOTYPEFUNCS.update(callables) + except ImportError: + pass + + +def protfunc_parser(value, available_functions=None, **kwargs): + """ + Parse a prototype value string for a protfunc and process it. + + Available protfuncs are specified as callables in one of the modules of + `settings.PROTOTYPEFUNC_MODULES`, or specified on the command line. + + Args: + value (string): The value to test for a parseable protfunc. + available_functions (dict, optional): Mapping of name:protfunction to use for this parsing. + + Kwargs: + any (any): Passed on to the inlinefunc. + + Returns: + any (any): A structure to replace the string on the prototype level. If this is a + callable or a (callable, (args,)) structure, it will be executed as if one had supplied + it to the prototype directly. + + """ + if not isinstance(value, basestring): + return value + available_functions = _PROTOTYPEFUNCS if available_functions is None else available_functions + return inlinefuncs.parse_inlinefunc(value, _available_funcs=available_functions, **kwargs) + + +# default protfuncs diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py new file mode 100644 index 0000000000..60e194861b --- /dev/null +++ b/evennia/prototypes/prototypes.py @@ -0,0 +1,280 @@ +""" + +Handling storage of prototypes, both database-based ones (DBPrototypes) and those defined in modules +(Read-only prototypes). + +""" + +from django.conf import settings +from evennia.scripts.scripts import DefaultScript +from evennia.objects.models import ObjectDB +from evennia.utils.create import create_script +from evennia.utils.utils import all_from_module, make_iter, callables_from_module, is_iter +from evennia.locks.lockhandler import validate_lockstring, check_lockstring +from evennia.utils import logger + + +_MODULE_PROTOTYPE_MODULES = {} +_MODULE_PROTOTYPES = {} + + +class ValidationError(RuntimeError): + """ + Raised on prototype validation errors + """ + pass + + +# module-based prototypes + +for mod in settings.PROTOTYPE_MODULES: + # to remove a default prototype, override it with an empty dict. + # internally we store as (key, desc, locks, tags, prototype_dict) + prots = [(prototype_key, prot) for prototype_key, prot in all_from_module(mod).items() + if prot and isinstance(prot, dict)] + # assign module path to each prototype_key for easy reference + _MODULE_PROTOTYPE_MODULES.update({prototype_key: mod for prototype_key, _ in prots}) + # make sure the prototype contains all meta info + for prototype_key, prot in prots: + actual_prot_key = prot.get('prototype_key', prototype_key).lower() + prot.update({ + "prototype_key": actual_prot_key, + "prototype_desc": prot['prototype_desc'] if 'prototype_desc' in prot else mod, + "prototype_locks": (prot['prototype_locks'] + if 'prototype_locks' in prot else "use:all();edit:false()"), + "prototype_tags": list(set(make_iter(prot.get('prototype_tags', [])) + ["module"]))}) + _MODULE_PROTOTYPES[actual_prot_key] = prot + + +# Db-based prototypes + + +class DbPrototype(DefaultScript): + """ + This stores a single prototype, in an Attribute `prototype`. + """ + def at_script_creation(self): + self.key = "empty prototype" # prototype_key + self.desc = "A prototype" # prototype_desc + self.db.prototype = {} # actual prototype + + +# General prototype functions + +def check_permission(prototype_key, action, default=True): + """ + Helper function to check access to actions on given prototype. + + Args: + prototype_key (str): The prototype to affect. + action (str): One of "spawn" or "edit". + default (str): If action is unknown or prototype has no locks + + Returns: + passes (bool): If permission for action is granted or not. + + """ + if action == 'edit': + if prototype_key in _MODULE_PROTOTYPES: + mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key, "N/A") + logger.log_err("{} is a read-only prototype " + "(defined as code in {}).".format(prototype_key, mod)) + return False + + prototype = search_prototype(key=prototype_key) + if not prototype: + logger.log_err("Prototype {} not found.".format(prototype_key)) + return False + + lockstring = prototype.get("prototype_locks") + + if lockstring: + return check_lockstring(None, lockstring, default=default, access_type=action) + return default + + +def create_prototype(**kwargs): + """ + Store a prototype persistently. + + Kwargs: + prototype_key (str): This is required for any storage. + All other kwargs are considered part of the new prototype dict. + + Returns: + prototype (dict or None): The prototype stored using the given kwargs, None if deleting. + + Raises: + prototypes.ValidationError: If prototype does not validate. + + Note: + No edit/spawn locks will be checked here - if this function is called the caller + is expected to have valid permissions. + + """ + + def _to_batchtuple(inp, *args): + "build tuple suitable for batch-creation" + if is_iter(inp): + # already a tuple/list, use as-is + return inp + return (inp, ) + args + + prototype_key = kwargs.get("prototype_key") + if not prototype_key: + raise ValidationError("Prototype requires a prototype_key") + + prototype_key = str(prototype_key).lower() + + # we can't edit a prototype defined in a module + if prototype_key in _MODULE_PROTOTYPES: + mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key, "N/A") + raise PermissionError("{} is a read-only prototype " + "(defined as code in {}).".format(prototype_key, mod)) + + # want to create- or edit + prototype = kwargs + + # make sure meta properties are included with defaults + prototype['prototype_desc'] = prototype.get('prototype_desc', '') + locks = prototype.get('prototype_locks', "spawn:all();edit:perm(Admin)") + is_valid, err = validate_lockstring(locks) + if not is_valid: + raise ValidationError("Lock error: {}".format(err)) + prototype["prototype_locks"] = locks + prototype["prototype_tags"] = [ + _to_batchtuple(tag, "db_prototype") + for tag in make_iter(prototype.get("prototype_tags", []))] + + stored_prototype = DbPrototype.objects.filter(db_key=prototype_key) + + if stored_prototype: + # edit existing prototype + stored_prototype = stored_prototype[0] + + stored_prototype.desc = prototype['prototype_desc'] + stored_prototype.tags.batch_add(*prototype['prototype_tags']) + stored_prototype.locks.add(prototype['prototype_locks']) + stored_prototype.attributes.add('prototype', prototype) + else: + # create a new prototype + stored_prototype = create_script( + DbPrototype, key=prototype_key, desc=prototype['prototype_desc'], persistent=True, + locks=locks, tags=prototype['prototype_tags'], attributes=[("prototype", prototype)]) + return stored_prototype + + +def delete_prototype(key, caller=None): + """ + Delete a stored prototype + + Args: + key (str): The persistent prototype to delete. + caller (Account or Object, optionsl): Caller aiming to delete a prototype. + Note that no locks will be checked if`caller` is not passed. + Returns: + success (bool): If deletion worked or not. + Raises: + PermissionError: If 'edit' lock was not passed or deletion failed for some other reason. + + """ + if prototype_key in _MODULE_PROTOTYPES: + mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key, "N/A") + raise PermissionError("{} is a read-only prototype " + "(defined as code in {}).".format(prototype_key, mod)) + + stored_prototype = DbPrototype.objects.filter(db_key=prototype_key) + + if not stored_prototype: + raise PermissionError("Prototype {} was not found.".format(prototype_key)) + if caller: + if not stored_prototype.access(caller, 'edit'): + raise PermissionError("{} does not have permission to " + "delete prototype {}.".format(caller, prototype_key)) + stored_prototype.delete() + return True + + +def search_prototype(key=None, tags=None): + """ + Find prototypes based on key and/or tags, or all prototypes. + + Kwargs: + key (str): An exact or partial key to query for. + tags (str or list): Tag key or keys to query for. These + will always be applied with the 'db_protototype' + tag category. + + Return: + matches (list): All found prototype dicts. If no keys + or tags are given, all available prototypes will be returned. + + Note: + The available prototypes is a combination of those supplied in + PROTOTYPE_MODULES and those stored in the database. Note that if + tags are given and the prototype has no tags defined, it will not + be found as a match. + + """ + # search module prototypes + + mod_matches = {} + if tags: + # use tags to limit selection + tagset = set(tags) + mod_matches = {prototype_key: prototype + for prototype_key, prototype in _MODULE_PROTOTYPES.items() + if tagset.intersection(prototype.get("prototype_tags", []))} + else: + mod_matches = _MODULE_PROTOTYPES + if key: + if key in mod_matches: + # exact match + module_prototypes = [mod_matches[key]] + else: + # fuzzy matching + module_prototypes = [prototype for prototype_key, prototype in mod_matches.items() + if key in prototype_key] + else: + module_prototypes = [match for match in mod_matches.values()] + + # search db-stored prototypes + + if tags: + # exact match on tag(s) + tags = make_iter(tags) + tag_categories = ["db_prototype" for _ in tags] + db_matches = DbPrototype.objects.get_by_tag(tags, tag_categories) + else: + db_matches = DbPrototype.objects.all() + if key: + # exact or partial match on key + db_matches = db_matches.filter(db_key=key) or db_matches.filter(db_key__icontains=key) + # return prototype + db_prototypes = [dict(dbprot.attributes.get("prototype", {})) for dbprot in db_matches] + + matches = db_prototypes + module_prototypes + nmatches = len(matches) + if nmatches > 1 and key: + key = key.lower() + # avoid duplicates if an exact match exist between the two types + filter_matches = [mta for mta in matches + if mta.get('prototype_key') and mta['prototype_key'] == key] + if filter_matches and len(filter_matches) < nmatches: + matches = filter_matches + + return matches + + +def search_objects_with_prototype(prototype_key): + """ + Retrieve all object instances created by a given prototype. + + Args: + prototype_key (str): The exact (and unique) prototype identifier to query for. + + Returns: + matches (Queryset): All matching objects spawned from this prototype. + + """ + return ObjectDB.objects.get_by_tag(key=prototype_key, category=_PROTOTYPE_TAG_CATEGORY) diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py new file mode 100644 index 0000000000..062e15ee92 --- /dev/null +++ b/evennia/prototypes/spawner.py @@ -0,0 +1,600 @@ +""" +Spawner + +The spawner takes input files containing object definitions in +dictionary forms. These use a prototype architecture to define +unique objects without having to make a Typeclass for each. + +The main function is `spawn(*prototype)`, where the `prototype` +is a dictionary like this: + +```python +GOBLIN = { + "typeclass": "types.objects.Monster", + "key": "goblin grunt", + "health": lambda: randint(20,30), + "resists": ["cold", "poison"], + "attacks": ["fists"], + "weaknesses": ["fire", "light"] + "tags": ["mob", "evil", ('greenskin','mob')] + "attrs": [("weapon", "sword")] + } +``` + +Possible keywords are: + prototype_key (str): name of this prototype. This is used when storing prototypes and should + be unique. This should always be defined but for prototypes defined in modules, the + variable holding the prototype dict will become the prototype_key if it's not explicitly + given. + prototype_desc (str, optional): describes prototype in listings + prototype_locks (str, optional): locks for restricting access to this prototype. Locktypes + supported are 'edit' and 'use'. + prototype_tags(list, optional): List of tags or tuples (tag, category) used to group prototype + in listings + + parent (str, tuple or callable, optional): name (prototype_key) of eventual parent prototype, or + a list of parents, for multiple left-to-right inheritance. + prototype: Deprecated. Same meaning as 'parent'. + typeclass (str or callable, optional): if not set, will use typeclass of parent prototype or use + `settings.BASE_OBJECT_TYPECLASS` + key (str or callable, optional): the name of the spawned object. If not given this will set to a + random hash + location (obj, str or callable, optional): location of the object - a valid object or #dbref + home (obj, str or callable, optional): valid object or #dbref + destination (obj, str or callable, optional): only valid for exits (object or #dbref) + + permissions (str, list or callable, optional): which permissions for spawned object to have + locks (str or callable, optional): lock-string for the spawned object + aliases (str, list or callable, optional): Aliases for the spawned object + exec (str or callable, optional): this is a string of python code to execute or a list of such + codes. This can be used e.g. to trigger custom handlers on the object. The execution + namespace contains 'evennia' for the library and 'obj'. All default spawn commands limit + this functionality to Developer/superusers. Usually it's better to use callables or + prototypefuncs instead of this. + tags (str, tuple, list or callable, optional): string or list of strings or tuples + `(tagstr, category)`. Plain strings will be result in tags with no category (default tags). + attrs (tuple, list or callable, optional): tuple or list of tuples of Attributes to add. This + form allows more complex Attributes to be set. Tuples at least specify `(key, value)` + but can also specify up to `(key, value, category, lockstring)`. If you want to specify a + lockstring but not a category, set the category to `None`. + ndb_ (any): value of a nattribute (ndb_ is stripped) + other (any): any other name is interpreted as the key of an Attribute with + its value. Such Attributes have no categories. + +Each value can also be a callable that takes no arguments. It should +return the value to enter into the field and will be called every time +the prototype is used to spawn an object. Note, if you want to store +a callable in an Attribute, embed it in a tuple to the `args` keyword. + +By specifying the "prototype" key, the prototype becomes a child of +that prototype, inheritng all prototype slots it does not explicitly +define itself, while overloading those that it does specify. + +```python +import random + + +GOBLIN_WIZARD = { + "parent": GOBLIN, + "key": "goblin wizard", + "spells": ["fire ball", "lighting bolt"] + } + +GOBLIN_ARCHER = { + "parent": GOBLIN, + "key": "goblin archer", + "attack_skill": (random, (5, 10))" + "attacks": ["short bow"] +} +``` + +One can also have multiple prototypes. These are inherited from the +left, with the ones further to the right taking precedence. + +```python +ARCHWIZARD = { + "attack": ["archwizard staff", "eye of doom"] + +GOBLIN_ARCHWIZARD = { + "key" : "goblin archwizard" + "parent": (GOBLIN_WIZARD, ARCHWIZARD), +} +``` + +The *goblin archwizard* will have some different attacks, but will +otherwise have the same spells as a *goblin wizard* who in turn shares +many traits with a normal *goblin*. + + +Storage mechanism: + +This sets up a central storage for prototypes. The idea is to make these +available in a repository for buildiers to use. Each prototype is stored +in a Script so that it can be tagged for quick sorting/finding and locked for limiting +access. + +This system also takes into consideration prototypes defined and stored in modules. +Such prototypes are considered 'read-only' to the system and can only be modified +in code. To replace a default prototype, add the same-name prototype in a +custom module read later in the settings.PROTOTYPE_MODULES list. To remove a default +prototype, override its name with an empty dict. + + +""" +from __future__ import print_function + +import copy +import hashlib +import time +from ast import literal_eval +from django.conf import settings +from random import randint +import evennia +from evennia.objects.models import ObjectDB +from evennia.utils.utils import ( + make_iter, dbid_to_obj, + is_iter, crop, get_all_typeclasses) + +from evennia.utils.evtable import EvTable + + +_CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") +_PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") +_NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES +_MENU_CROP_WIDTH = 15 +_PROTOTYPE_TAG_CATEGORY = "spawned_by_prototype" + +_MENU_ATTR_LITERAL_EVAL_ERROR = ( + "|rCritical Python syntax error in your value. Only primitive Python structures are allowed.\n" + "You also need to use correct Python syntax. Remember especially to put quotes around all " + "strings inside lists and dicts.|n") + + +# Helper functions + +def _to_obj(value, force=True): + return dbid_to_obj(value, ObjectDB) + + +def _to_obj_or_any(value): + obj = dbid_to_obj(value, ObjectDB) + return obj if obj is not None else value + + +def validate_spawn_value(value, validator=None): + """ + Analyze the value and produce a value for use at the point of spawning. + + Args: + value (any): This can be: + callable - will be called as callable() + (callable, (args,)) - will be called as callable(*args) + other - will be assigned depending on the variable type + validator (callable, optional): If given, this will be called with the value to + check and guarantee the outcome is of a given type. + + Returns: + any (any): The (potentially pre-processed value to use for this prototype key) + + """ + value = protfunc_parser(value) + validator = validator if validator else lambda o: o + if callable(value): + return validator(value()) + elif value and is_iter(value) and callable(value[0]): + # a structure (callable, (args, )) + args = value[1:] + return validator(value[0](*make_iter(args))) + else: + return validator(value) + +# Spawner mechanism + + +def validate_prototype(prototype, protkey=None, protparents=None, _visited=None): + """ + Run validation on a prototype, checking for inifinite regress. + + Args: + prototype (dict): Prototype to validate. + protkey (str, optional): The name of the prototype definition. If not given, the prototype + dict needs to have the `prototype_key` field set. + protpartents (dict, optional): The available prototype parent library. If + note given this will be determined from settings/database. + _visited (list, optional): This is an internal work array and should not be set manually. + Raises: + RuntimeError: If prototype has invalid structure. + + """ + if not protparents: + protparents = get_protparent_dict() + if _visited is None: + _visited = [] + + protkey = protkey and protkey.lower() or prototype.get('prototype_key', None) + + assert isinstance(prototype, dict) + + if id(prototype) in _visited: + raise RuntimeError("%s has infinite nesting of prototypes." % protkey or prototype) + + _visited.append(id(prototype)) + protstrings = prototype.get("prototype") + + if protstrings: + for protstring in make_iter(protstrings): + protstring = protstring.lower() + if protkey is not None and protstring == protkey: + raise RuntimeError("%s tries to prototype itself." % protkey or prototype) + protparent = protparents.get(protstring) + if not protparent: + raise RuntimeError( + "%s's prototype '%s' was not found." % (protkey or prototype, protstring)) + validate_prototype(protparent, protstring, protparents, _visited) + + +def _get_prototype(dic, prot, protparents): + """ + Recursively traverse a prototype dictionary, including multiple + inheritance. Use validate_prototype before this, we don't check + for infinite recursion here. + + """ + if "prototype" in dic: + # move backwards through the inheritance + for prototype in make_iter(dic["prototype"]): + # Build the prot dictionary in reverse order, overloading + new_prot = _get_prototype(protparents.get(prototype.lower(), {}), prot, protparents) + prot.update(new_prot) + prot.update(dic) + prot.pop("prototype", None) # we don't need this anymore + return prot + + +def prototype_diff_from_object(prototype, obj): + """ + Get a simple diff for a prototype compared to an object which may or may not already have a + prototype (or has one but changed locally). For more complex migratations a manual diff may be + needed. + + Args: + prototype (dict): Prototype. + obj (Object): Object to + + Returns: + diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...} + + """ + prot1 = prototype + prot2 = prototype_from_object(obj) + + diff = {} + for key, value in prot1.items(): + diff[key] = "KEEP" + if key in prot2: + if callable(prot2[key]) or value != prot2[key]: + diff[key] = "UPDATE" + elif key not in prot2: + diff[key] = "REMOVE" + + return diff + + +def batch_update_objects_with_prototype(prototype, diff=None, objects=None): + """ + Update existing objects with the latest version of the prototype. + + Args: + prototype (str or dict): Either the `prototype_key` to use or the + prototype dict itself. + diff (dict, optional): This a diff structure that describes how to update the protototype. + If not given this will be constructed from the first object found. + objects (list, optional): List of objects to update. If not given, query for these + objects using the prototype's `prototype_key`. + Returns: + changed (int): The number of objects that had changes applied to them. + + """ + prototype_key = prototype if isinstance(prototype, basestring) else prototype['prototype_key'] + prototype_obj = search_db_prototype(prototype_key, return_queryset=True) + prototype_obj = prototype_obj[0] if prototype_obj else None + new_prototype = prototype_obj.db.prototype + objs = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + + if not objs: + return 0 + + if not diff: + diff = prototype_diff_from_object(new_prototype, objs[0]) + + changed = 0 + for obj in objs: + do_save = False + for key, directive in diff.items(): + val = new_prototype[key] + if directive in ('UPDATE', 'REPLACE'): + do_save = True + if key == 'key': + obj.db_key = validate_spawn_value(val, str) + elif key == 'typeclass': + obj.db_typeclass_path = validate_spawn_value(val, str) + elif key == 'location': + obj.db_location = validate_spawn_value(val, _to_obj) + elif key == 'home': + obj.db_home = validate_spawn_value(val, _to_obj) + elif key == 'destination': + obj.db_destination = validate_spawn_value(val, _to_obj) + elif key == 'locks': + if directive == 'REPLACE': + obj.locks.clear() + obj.locks.add(validate_spawn_value(val, str)) + elif key == 'permissions': + if directive == 'REPLACE': + obj.permissions.clear() + obj.permissions.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'aliases': + if directive == 'REPLACE': + obj.aliases.clear() + obj.aliases.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'tags': + if directive == 'REPLACE': + obj.tags.clear() + obj.tags.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'attrs': + if directive == 'REPLACE': + obj.attributes.clear() + obj.attributes.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'exec': + # we don't auto-rerun exec statements, it would be huge security risk! + pass + else: + obj.attributes.add(key, validate_spawn_value(val, _to_obj)) + elif directive == 'REMOVE': + do_save = True + if key == 'key': + obj.db_key = '' + elif key == 'typeclass': + # fall back to default + obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS + elif key == 'location': + obj.db_location = None + elif key == 'home': + obj.db_home = None + elif key == 'destination': + obj.db_destination = None + elif key == 'locks': + obj.locks.clear() + elif key == 'permissions': + obj.permissions.clear() + elif key == 'aliases': + obj.aliases.clear() + elif key == 'tags': + obj.tags.clear() + elif key == 'attrs': + obj.attributes.clear() + elif key == 'exec': + # we don't auto-rerun exec statements, it would be huge security risk! + pass + else: + obj.attributes.remove(key) + if do_save: + changed += 1 + obj.save() + + return changed + + +def _batch_create_object(*objparams): + """ + This is a cut-down version of the create_object() function, + optimized for speed. It does NOT check and convert various input + so make sure the spawned Typeclass works before using this! + + Args: + objsparams (tuple): Each paremter tuple will create one object instance using the parameters within. + The parameters should be given in the following order: + - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`. + - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`. + - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`. + - `aliases` (list): A list of alias strings for + adding with `new_object.aliases.batch_add(*aliases)`. + - `nattributes` (list): list of tuples `(key, value)` to be loop-added to + add with `new_obj.nattributes.add(*tuple)`. + - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for + adding with `new_obj.attributes.batch_add(*attributes)`. + - `tags` (list): list of tuples `(key, category)` for adding + with `new_obj.tags.batch_add(*tags)`. + - `execs` (list): Code strings to execute together with the creation + of each object. They will be executed with `evennia` and `obj` + (the newly created object) available in the namespace. Execution + will happend after all other properties have been assigned and + is intended for calling custom handlers etc. + + Returns: + objects (list): A list of created objects + + Notes: + The `exec` list will execute arbitrary python code so don't allow this to be available to + unprivileged users! + + """ + + # bulk create all objects in one go + + # unfortunately this doesn't work since bulk_create doesn't creates pks; + # the result would be duplicate objects at the next stage, so we comment + # it out for now: + # dbobjs = _ObjectDB.objects.bulk_create(dbobjs) + + dbobjs = [ObjectDB(**objparam[0]) for objparam in objparams] + objs = [] + for iobj, obj in enumerate(dbobjs): + # call all setup hooks on each object + objparam = objparams[iobj] + # setup + obj._createdict = {"permissions": make_iter(objparam[1]), + "locks": objparam[2], + "aliases": make_iter(objparam[3]), + "nattributes": objparam[4], + "attributes": objparam[5], + "tags": make_iter(objparam[6])} + # this triggers all hooks + obj.save() + # run eventual extra code + for code in objparam[7]: + if code: + exec(code, {}, {"evennia": evennia, "obj": obj}) + objs.append(obj) + return objs + + +def spawn(*prototypes, **kwargs): + """ + Spawn a number of prototyped objects. + + Args: + prototypes (dict): Each argument should be a prototype + dictionary. + Kwargs: + prototype_modules (str or list): A python-path to a prototype + module, or a list of such paths. These will be used to build + the global protparents dictionary accessible by the input + prototypes. If not given, it will instead look for modules + defined by settings.PROTOTYPE_MODULES. + prototype_parents (dict): A dictionary holding a custom + prototype-parent dictionary. Will overload same-named + prototypes from prototype_modules. + return_prototypes (bool): Only return a list of the + prototype-parents (no object creation happens) + + Returns: + object (Object): Spawned object. + + """ + # get available protparents + protparents = get_protparent_dict() + + # overload module's protparents with specifically given protparents + protparents.update(kwargs.get("prototype_parents", {})) + for key, prototype in protparents.items(): + validate_prototype(prototype, key.lower(), protparents) + + if "return_prototypes" in kwargs: + # only return the parents + return copy.deepcopy(protparents) + + objsparams = [] + for prototype in prototypes: + + validate_prototype(prototype, None, protparents) + prot = _get_prototype(prototype, {}, protparents) + if not prot: + continue + + # extract the keyword args we need to create the object itself. If we get a callable, + # call that to get the value (don't catch errors) + create_kwargs = {} + # we must always add a key, so if not given we use a shortened md5 hash. There is a (small) + # chance this is not unique but it should usually not be a problem. + val = prot.pop("key", "Spawned-{}".format( + hashlib.md5(str(time.time())).hexdigest()[:6])) + create_kwargs["db_key"] = validate_spawn_value(val, str) + + val = prot.pop("location", None) + create_kwargs["db_location"] = validate_spawn_value(val, _to_obj) + + val = prot.pop("home", settings.DEFAULT_HOME) + create_kwargs["db_home"] = validate_spawn_value(val, _to_obj) + + val = prot.pop("destination", None) + create_kwargs["db_destination"] = validate_spawn_value(val, _to_obj) + + val = prot.pop("typeclass", settings.BASE_OBJECT_TYPECLASS) + create_kwargs["db_typeclass_path"] = validate_spawn_value(val, str) + + # extract calls to handlers + val = prot.pop("permissions", []) + permission_string = validate_spawn_value(val, make_iter) + val = prot.pop("locks", "") + lock_string = validate_spawn_value(val, str) + val = prot.pop("aliases", []) + alias_string = validate_spawn_value(val, make_iter) + + val = prot.pop("tags", []) + tags = validate_spawn_value(val, make_iter) + + prototype_key = prototype.get('prototype_key', None) + if prototype_key: + # we make sure to add a tag identifying which prototype created this object + tags.append((prototype_key, _PROTOTYPE_TAG_CATEGORY)) + + val = prot.pop("exec", "") + execs = validate_spawn_value(val, make_iter) + + # extract ndb assignments + nattribute = dict((key.split("_", 1)[1], validate_spawn_value(val, _to_obj)) + for key, val in prot.items() if key.startswith("ndb_")) + + # the rest are attributes + val = prot.pop("attrs", []) + attributes = validate_spawn_value(val, list) + + simple_attributes = [] + for key, value in ((key, value) for key, value in prot.items() + if not (key.startswith("ndb_"))): + if is_iter(value) and len(value) > 1: + # (value, category) + simple_attributes.append((key, + validate_spawn_value(value[0], _to_obj_or_any), + validate_spawn_value(value[1], str))) + else: + simple_attributes.append((key, + validate_spawn_value(value, _to_obj_or_any))) + + attributes = attributes + simple_attributes + attributes = [tup for tup in attributes if not tup[0] in _NON_CREATE_KWARGS] + + # pack for call into _batch_create_object + objsparams.append((create_kwargs, permission_string, lock_string, + alias_string, nattributes, attributes, tags, execs)) + + return _batch_create_object(*objsparams) + + +# Testing + +if __name__ == "__main__": + protparents = { + "NOBODY": {}, + # "INFINITE" : { + # "prototype":"INFINITE" + # }, + "GOBLIN": { + "key": "goblin grunt", + "health": lambda: randint(20, 30), + "resists": ["cold", "poison"], + "attacks": ["fists"], + "weaknesses": ["fire", "light"] + }, + "GOBLIN_WIZARD": { + "prototype": "GOBLIN", + "key": "goblin wizard", + "spells": ["fire ball", "lighting bolt"] + }, + "GOBLIN_ARCHER": { + "prototype": "GOBLIN", + "key": "goblin archer", + "attacks": ["short bow"] + }, + "ARCHWIZARD": { + "attacks": ["archwizard staff"], + }, + "GOBLIN_ARCHWIZARD": { + "key": "goblin archwizard", + "prototype": ("GOBLIN_WIZARD", "ARCHWIZARD") + } + } + # test + print([o.key for o in spawn(protparents["GOBLIN"], + protparents["GOBLIN_ARCHWIZARD"], + prototype_parents=protparents)]) diff --git a/evennia/prototypes/utils.py b/evennia/prototypes/utils.py new file mode 100644 index 0000000000..74eaef169f --- /dev/null +++ b/evennia/prototypes/utils.py @@ -0,0 +1,150 @@ +""" + +Prototype utilities + +""" + + +class PermissionError(RuntimeError): + pass + + + + + +def get_protparent_dict(): + """ + Get prototype parents. + + Returns: + parent_dict (dict): A mapping {prototype_key: prototype} for all available prototypes. + + """ + return {prototype['prototype_key']: prototype for prototype in search_prototype()} + + +def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): + """ + Collate a list of found prototypes based on search criteria and access. + + Args: + caller (Account or Object): The object requesting the list. + key (str, optional): Exact or partial prototype key to query for. + tags (str or list, optional): Tag key or keys to query for. + show_non_use (bool, optional): Show also prototypes the caller may not use. + show_non_edit (bool, optional): Show also prototypes the caller may not edit. + Returns: + table (EvTable or None): An EvTable representation of the prototypes. None + if no prototypes were found. + + """ + # this allows us to pass lists of empty strings + tags = [tag for tag in make_iter(tags) if tag] + + # get prototypes for readonly and db-based prototypes + prototypes = search_prototype(key, tags) + + # get use-permissions of readonly attributes (edit is always False) + display_tuples = [] + for prototype in sorted(prototypes, key=lambda d: d.get('prototype_key', '')): + lock_use = caller.locks.check_lockstring( + caller, prototype.get('prototype_locks', ''), access_type='use') + if not show_non_use and not lock_use: + continue + if prototype.get('prototype_key', '') in _MODULE_PROTOTYPES: + lock_edit = False + else: + lock_edit = caller.locks.check_lockstring( + caller, prototype.get('prototype_locks', ''), access_type='edit') + if not show_non_edit and not lock_edit: + continue + ptags = [] + for ptag in prototype.get('prototype_tags', []): + if is_iter(ptag): + if len(ptag) > 1: + ptags.append("{} (category: {}".format(ptag[0], ptag[1])) + else: + ptags.append(ptag[0]) + else: + ptags.append(str(ptag)) + + display_tuples.append( + (prototype.get('prototype_key', ''), + prototype.get('prototype_desc', ''), + "{}/{}".format('Y' if lock_use else 'N', 'Y' if lock_edit else 'N'), + ",".join(ptags))) + + if not display_tuples: + return None + + table = [] + width = 78 + for i in range(len(display_tuples[0])): + table.append([str(display_tuple[i]) for display_tuple in display_tuples]) + table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=width) + table.reformat_column(0, width=22) + table.reformat_column(1, width=29) + table.reformat_column(2, width=11, align='c') + table.reformat_column(3, width=16) + return table + + +def prototype_to_str(prototype): + """ + Format a prototype to a nice string representation. + + Args: + prototype (dict): The prototype. + """ + + header = ( + "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" + "|cdesc:|n {} \n|cprototype:|n ".format( + prototype['prototype_key'], + ", ".join(prototype['prototype_tags']), + prototype['prototype_locks'], + prototype['prototype_desc'])) + proto = ("{{\n {} \n}}".format( + "\n ".join( + "{!r}: {!r},".format(key, value) for key, value in + sorted(prototype.items()) if key not in _PROTOTYPE_META_NAMES)).rstrip(",")) + return header + proto + + +def prototype_from_object(obj): + """ + Guess a minimal prototype from an existing object. + + Args: + obj (Object): An object to analyze. + + Returns: + prototype (dict): A prototype estimating the current state of the object. + + """ + # first, check if this object already has a prototype + + prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) + prot = search_prototype(prot) + if not prot or len(prot) > 1: + # no unambiguous prototype found - build new prototype + prot = {} + prot['prototype_key'] = "From-Object-{}-{}".format( + obj.key, hashlib.md5(str(time.time())).hexdigest()[:6]) + prot['prototype_desc'] = "Built from {}".format(str(obj)) + prot['prototype_locks'] = "use:all();edit:all()" + + prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6] + prot['location'] = obj.db_location + prot['home'] = obj.db_home + prot['destination'] = obj.db_destination + prot['typeclass'] = obj.db_typeclass_path + prot['locks'] = obj.locks.all() + prot['permissions'] = obj.permissions.get() + prot['aliases'] = obj.aliases.get() + prot['tags'] = [(tag.key, tag.category, tag.data) + for tag in obj.tags.get(return_tagobj=True, return_list=True)] + prot['attrs'] = [(attr.key, attr.value, attr.category, attr.locks) + for attr in obj.attributes.get(return_obj=True, return_list=True)] + + return prot diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py deleted file mode 100644 index 3c269ca742..0000000000 --- a/evennia/utils/spawner.py +++ /dev/null @@ -1,1752 +0,0 @@ -""" -Spawner - -The spawner takes input files containing object definitions in -dictionary forms. These use a prototype architecture to define -unique objects without having to make a Typeclass for each. - -The main function is `spawn(*prototype)`, where the `prototype` -is a dictionary like this: - -```python -GOBLIN = { - "typeclass": "types.objects.Monster", - "key": "goblin grunt", - "health": lambda: randint(20,30), - "resists": ["cold", "poison"], - "attacks": ["fists"], - "weaknesses": ["fire", "light"] - "tags": ["mob", "evil", ('greenskin','mob')] - "args": [("weapon", "sword")] - } -``` - -Possible keywords are: - prototype_key (str): name of this prototype. This is used when storing prototypes and should - be unique. This should always be defined but for prototypes defined in modules, the - variable holding the prototype dict will become the prototype_key if it's not explicitly - given. - prototype_desc (str, optional): describes prototype in listings - prototype_locks (str, optional): locks for restricting access to this prototype. Locktypes - supported are 'edit' and 'use'. - prototype_tags(list, optional): List of tags or tuples (tag, category) used to group prototype - in listings - - prototype (str or callable, optional): bame (prototype_key) of eventual parent prototype - typeclass (str or callable, optional): if not set, will use typeclass of parent prototype or use - `settings.BASE_OBJECT_TYPECLASS` - key (str or callable, optional): the name of the spawned object. If not given this will set to a - random hash - location (obj, str or callable, optional): location of the object - a valid object or #dbref - home (obj, str or callable, optional): valid object or #dbref - destination (obj, str or callable, optional): only valid for exits (object or #dbref) - - permissions (str, list or callable, optional): which permissions for spawned object to have - locks (str or callable, optional): lock-string for the spawned object - aliases (str, list or callable, optional): Aliases for the spawned object - exec (str or callable, optional): this is a string of python code to execute or a list of such - codes. This can be used e.g. to trigger custom handlers on the object. The execution - namespace contains 'evennia' for the library and 'obj'. All default spawn commands limit - this functionality to Developer/superusers. Usually it's better to use callables or - prototypefuncs instead of this. - tags (str, tuple, list or callable, optional): string or list of strings or tuples - `(tagstr, category)`. Plain strings will be result in tags with no category (default tags). - attrs (tuple, list or callable, optional): tuple or list of tuples of Attributes to add. This - form allows more complex Attributes to be set. Tuples at least specify `(key, value)` - but can also specify up to `(key, value, category, lockstring)`. If you want to specify a - lockstring but not a category, set the category to `None`. - ndb_ (any): value of a nattribute (ndb_ is stripped) - other (any): any other name is interpreted as the key of an Attribute with - its value. Such Attributes have no categories. - -Each value can also be a callable that takes no arguments. It should -return the value to enter into the field and will be called every time -the prototype is used to spawn an object. Note, if you want to store -a callable in an Attribute, embed it in a tuple to the `args` keyword. - -By specifying the "prototype" key, the prototype becomes a child of -that prototype, inheritng all prototype slots it does not explicitly -define itself, while overloading those that it does specify. - -```python -import random - - -GOBLIN_WIZARD = { - "prototype": GOBLIN, - "key": "goblin wizard", - "spells": ["fire ball", "lighting bolt"] - } - -GOBLIN_ARCHER = { - "prototype": GOBLIN, - "key": "goblin archer", - "attack_skill": (random, (5, 10))" - "attacks": ["short bow"] -} -``` - -One can also have multiple prototypes. These are inherited from the -left, with the ones further to the right taking precedence. - -```python -ARCHWIZARD = { - "attack": ["archwizard staff", "eye of doom"] - -GOBLIN_ARCHWIZARD = { - "key" : "goblin archwizard" - "prototype": (GOBLIN_WIZARD, ARCHWIZARD), -} -``` - -The *goblin archwizard* will have some different attacks, but will -otherwise have the same spells as a *goblin wizard* who in turn shares -many traits with a normal *goblin*. - - -Storage mechanism: - -This sets up a central storage for prototypes. The idea is to make these -available in a repository for buildiers to use. Each prototype is stored -in a Script so that it can be tagged for quick sorting/finding and locked for limiting -access. - -This system also takes into consideration prototypes defined and stored in modules. -Such prototypes are considered 'read-only' to the system and can only be modified -in code. To replace a default prototype, add the same-name prototype in a -custom module read later in the settings.PROTOTYPE_MODULES list. To remove a default -prototype, override its name with an empty dict. - - -""" -from __future__ import print_function - -import copy -import hashlib -import time -from ast import literal_eval -from django.conf import settings -from random import randint -import evennia -from evennia.objects.models import ObjectDB -from evennia.utils.utils import ( - make_iter, all_from_module, callables_from_module, dbid_to_obj, - is_iter, crop, get_all_typeclasses) -from evennia.utils import inlinefuncs - -from evennia.scripts.scripts import DefaultScript -from evennia.utils.create import create_script -from evennia.utils.evtable import EvTable -from evennia.utils.evmenu import EvMenu, list_node -from evennia.utils.ansi import strip_ansi - - -_CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") -_PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") -_NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES -_MODULE_PROTOTYPES = {} -_MODULE_PROTOTYPE_MODULES = {} -_PROTOTYPEFUNCS = {} -_MENU_CROP_WIDTH = 15 -_PROTOTYPE_TAG_CATEGORY = "spawned_by_prototype" - -_MENU_ATTR_LITERAL_EVAL_ERROR = ( - "|rCritical Python syntax error in your value. Only primitive Python structures are allowed.\n" - "You also need to use correct Python syntax. Remember especially to put quotes around all " - "strings inside lists and dicts.|n") - - -class PermissionError(RuntimeError): - pass - - -# load resources - - -for mod in settings.PROTOTYPE_MODULES: - # to remove a default prototype, override it with an empty dict. - # internally we store as (key, desc, locks, tags, prototype_dict) - prots = [(prototype_key, prot) for prototype_key, prot in all_from_module(mod).items() - if prot and isinstance(prot, dict)] - # assign module path to each prototype_key for easy reference - _MODULE_PROTOTYPE_MODULES.update({prototype_key: mod for prototype_key, _ in prots}) - # make sure the prototype contains all meta info - for prototype_key, prot in prots: - actual_prot_key = prot.get('prototype_key', prototype_key).lower() - prot.update({ - "prototype_key": actual_prot_key, - "prototype_desc": prot['prototype_desc'] if 'prototype_desc' in prot else mod, - "prototype_locks": (prot['prototype_locks'] - if 'prototype_locks' in prot else "use:all();edit:false()"), - "prototype_tags": list(set(make_iter(prot.get('prototype_tags', [])) + ["module"]))}) - _MODULE_PROTOTYPES[actual_prot_key] = prot - - -for mod in settings.PROTOTYPEFUNC_MODULES: - try: - _PROTOTYPEFUNCS.update(callables_from_module(mod)) - except ImportError: - pass - - -# Helper functions - - -def protfunc_parser(value, available_functions=None, **kwargs): - """ - This is intended to be used by the in-game olc mechanism. It will parse the prototype - value for function tokens like `$protfunc(arg, arg, ...)`. These functions behave all the - parameters of `inlinefuncs` but they are *not* passed a Session since this is not guaranteed to - be available at the time of spawning. They may also return other structures than strings. - - Available protfuncs are specified as callables in one of the modules of - `settings.PROTOTYPEFUNC_MODULES`, or specified on the command line. - - Args: - value (string): The value to test for a parseable protfunc. - available_functions (dict, optional): Mapping of name:protfunction to use for this parsing. - - Kwargs: - any (any): Passed on to the inlinefunc. - - Returns: - any (any): A structure to replace the string on the prototype level. If this is a - callable or a (callable, (args,)) structure, it will be executed as if one had supplied - it to the prototype directly. - - """ - if not isinstance(value, basestring): - return value - available_functions = _PROTOTYPEFUNCS if available_functions is None else available_functions - return inlinefuncs.parse_inlinefunc(value, _available_funcs=available_functions) - - -def _to_obj(value, force=True): - return dbid_to_obj(value, ObjectDB) - - -def _to_obj_or_any(value): - obj = dbid_to_obj(value, ObjectDB) - return obj if obj is not None else value - - -def validate_spawn_value(value, validator=None): - """ - Analyze the value and produce a value for use at the point of spawning. - - Args: - value (any): This can be: - callable - will be called as callable() - (callable, (args,)) - will be called as callable(*args) - other - will be assigned depending on the variable type - validator (callable, optional): If given, this will be called with the value to - check and guarantee the outcome is of a given type. - - Returns: - any (any): The (potentially pre-processed value to use for this prototype key) - - """ - value = protfunc_parser(value) - validator = validator if validator else lambda o: o - if callable(value): - return validator(value()) - elif value and is_iter(value) and callable(value[0]): - # a structure (callable, (args, )) - args = value[1:] - return validator(value[0](*make_iter(args))) - else: - return validator(value) - - -# Prototype storage mechanisms - - -class DbPrototype(DefaultScript): - """ - This stores a single prototype - """ - def at_script_creation(self): - self.key = "empty prototype" # prototype_key - self.desc = "A prototype" # prototype_desc - - - - - -def save_db_prototype(caller, prototype, key=None, desc=None, tags=None, locks="", delete=False): - """ - Store a prototype persistently. - - Args: - caller (Account or Object): Caller aiming to store prototype. At this point - the caller should have permission to 'add' new prototypes, but to edit - an existing prototype, the 'edit' lock must be passed on that prototype. - prototype (dict): Prototype dict. - key (str): Name of prototype to store. Will be inserted as `prototype_key` in the prototype. - desc (str, optional): Description of prototype, to use in listing. Will be inserted - as `prototype_desc` in the prototype. - tags (list, optional): Tag-strings to apply to prototype. These are always - applied with the 'db_prototype' category. Will be inserted as `prototype_tags`. - locks (str, optional): Locks to apply to this prototype. Used locks - are 'use' and 'edit'. Will be inserted as `prototype_locks` in the prototype. - delete (bool, optional): Delete an existing prototype identified by 'key'. - This requires `caller` to pass the 'edit' lock of the prototype. - Returns: - stored (StoredPrototype or None): The resulting prototype (new or edited), - or None if deleting. - Raises: - PermissionError: If edit lock was not passed by caller. - - - """ - key_orig = key or prototype.get('prototype_key', None) - if not key_orig: - caller.msg("This prototype requires a prototype_key.") - return False - key = str(key).lower() - - # we can't edit a prototype defined in a module - if key in _MODULE_PROTOTYPES: - mod = _MODULE_PROTOTYPE_MODULES.get(key, "N/A") - raise PermissionError("{} is a read-only prototype " - "(defined as code in {}).".format(key_orig, mod)) - - prototype['prototype_key'] = key - - if desc: - desc = prototype['prototype_desc'] = desc - else: - desc = prototype.get('prototype_desc', '') - - # set up locks and check they are on a valid form - locks = locks or prototype.get( - "prototype_locks", "use:all();edit:id({}) or perm(Admin)".format(caller.id)) - prototype['prototype_locks'] = locks - - is_valid, err = caller.locks.validate(locks) - if not is_valid: - caller.msg("Lock error: {}".format(err)) - return False - - if tags: - tags = [(tag, "db_prototype") for tag in make_iter(tags)] - else: - tags = prototype.get('prototype_tags', []) - prototype['prototype_tags'] = tags - - stored_prototype = DbPrototype.objects.filter(db_key=key) - - if stored_prototype: - # edit existing prototype - stored_prototype = stored_prototype[0] - if not stored_prototype.access(caller, 'edit'): - raise PermissionError("{} does not have permission to " - "edit prototype {}.".format(caller, key)) - - if delete: - # delete prototype - stored_prototype.delete() - return True - - if desc: - stored_prototype.desc = desc - if tags: - stored_prototype.tags.batch_add(*tags) - if locks: - stored_prototype.locks.add(locks) - if prototype: - stored_prototype.attributes.add("prototype", prototype) - elif delete: - # didn't find what to delete - return False - else: - # create a new prototype - stored_prototype = create_script( - DbPrototype, key=key, desc=desc, persistent=True, - locks=locks, tags=tags, attributes=[("prototype", prototype)]) - return stored_prototype - - -def delete_db_prototype(caller, key): - """ - Delete a stored prototype - - Args: - caller (Account or Object): Caller aiming to delete a prototype. - key (str): The persistent prototype to delete. - Returns: - success (bool): If deletion worked or not. - Raises: - PermissionError: If 'edit' lock was not passed. - - """ - return save_db_prototype(caller, key, None, delete=True) - - -def search_db_prototype(key=None, tags=None, return_queryset=False): - """ - Find persistent (database-stored) prototypes based on key and/or tags. - - Kwargs: - key (str): An exact or partial key to query for. - tags (str or list): Tag key or keys to query for. These - will always be applied with the 'db_protototype' - tag category. - return_queryset (bool, optional): Return the database queryset. - Return: - matches (queryset or list): All found DbPrototypes. If `return_queryset` - is not set, this is a list of prototype dicts. - - Note: - This does not include read-only prototypes defined in modules; use - `search_module_prototype` for those. - - """ - if tags: - # exact match on tag(s) - tags = make_iter(tags) - tag_categories = ["db_prototype" for _ in tags] - matches = DbPrototype.objects.get_by_tag(tags, tag_categories) - else: - matches = DbPrototype.objects.all() - if key: - # exact or partial match on key - matches = matches.filter(db_key=key) or matches.filter(db_key__icontains=key) - if not return_queryset: - # return prototype - matches = [dict(dbprot.attributes.get("prototype", {})) for dbprot in matches] - return matches - - -def search_module_prototype(key=None, tags=None): - """ - Find read-only prototypes, defined in modules. - - Kwargs: - key (str): An exact or partial key to query for. - tags (str or list): Tag key to query for. - - Return: - matches (list): List of prototypes matching the search criterion. - - """ - matches = {} - if tags: - # use tags to limit selection - tagset = set(tags) - matches = {prototype_key: prototype - for prototype_key, prototype in _MODULE_PROTOTYPES.items() - if tagset.intersection(prototype.get("prototype_tags", []))} - else: - matches = _MODULE_PROTOTYPES - - if key: - if key in matches: - # exact match - return [matches[key]] - else: - # fuzzy matching - return [prototype for prototype_key, prototype in matches.items() - if key in prototype_key] - else: - return [match for match in matches.values()] - - -def search_prototype(key=None, tags=None): - """ - Find prototypes based on key and/or tags, or all prototypes. - - Kwargs: - key (str): An exact or partial key to query for. - tags (str or list): Tag key or keys to query for. These - will always be applied with the 'db_protototype' - tag category. - - Return: - matches (list): All found prototype dicts. If no keys - or tags are given, all available prototypes will be returned. - - Note: - The available prototypes is a combination of those supplied in - PROTOTYPE_MODULES and those stored from in-game. For the latter, - this will use the tags to make a subselection before attempting - to match on the key. So if key/tags don't match up nothing will - be found. - - """ - module_prototypes = search_module_prototype(key, tags) - db_prototypes = search_db_prototype(key, tags) - - matches = db_prototypes + module_prototypes - if len(matches) > 1 and key: - key = key.lower() - # avoid duplicates if an exact match exist between the two types - filter_matches = [mta for mta in matches - if mta.get('prototype_key') and mta['prototype_key'] == key] - if filter_matches and len(filter_matches) < len(matches): - matches = filter_matches - - return matches - - -def search_objects_with_prototype(prototype_key): - """ - Retrieve all object instances created by a given prototype. - - Args: - prototype_key (str): The exact (and unique) prototype identifier to query for. - - Returns: - matches (Queryset): All matching objects spawned from this prototype. - - """ - return ObjectDB.objects.get_by_tag(key=prototype_key, category=_PROTOTYPE_TAG_CATEGORY) - - -def get_protparent_dict(): - """ - Get prototype parents. - - Returns: - parent_dict (dict): A mapping {prototype_key: prototype} for all available prototypes. - - """ - return {prototype['prototype_key']: prototype for prototype in search_prototype()} - - -def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): - """ - Collate a list of found prototypes based on search criteria and access. - - Args: - caller (Account or Object): The object requesting the list. - key (str, optional): Exact or partial prototype key to query for. - tags (str or list, optional): Tag key or keys to query for. - show_non_use (bool, optional): Show also prototypes the caller may not use. - show_non_edit (bool, optional): Show also prototypes the caller may not edit. - Returns: - table (EvTable or None): An EvTable representation of the prototypes. None - if no prototypes were found. - - """ - # this allows us to pass lists of empty strings - tags = [tag for tag in make_iter(tags) if tag] - - # get prototypes for readonly and db-based prototypes - prototypes = search_prototype(key, tags) - - # get use-permissions of readonly attributes (edit is always False) - display_tuples = [] - for prototype in sorted(prototypes, key=lambda d: d.get('prototype_key', '')): - lock_use = caller.locks.check_lockstring( - caller, prototype.get('prototype_locks', ''), access_type='use') - if not show_non_use and not lock_use: - continue - if prototype.get('prototype_key', '') in _MODULE_PROTOTYPES: - lock_edit = False - else: - lock_edit = caller.locks.check_lockstring( - caller, prototype.get('prototype_locks', ''), access_type='edit') - if not show_non_edit and not lock_edit: - continue - ptags = [] - for ptag in prototype.get('prototype_tags', []): - if is_iter(ptag): - if len(ptag) > 1: - ptags.append("{} (category: {}".format(ptag[0], ptag[1])) - else: - ptags.append(ptag[0]) - else: - ptags.append(str(ptag)) - - display_tuples.append( - (prototype.get('prototype_key', ''), - prototype.get('prototype_desc', ''), - "{}/{}".format('Y' if lock_use else 'N', 'Y' if lock_edit else 'N'), - ",".join(ptags))) - - if not display_tuples: - return None - - table = [] - width = 78 - for i in range(len(display_tuples[0])): - table.append([str(display_tuple[i]) for display_tuple in display_tuples]) - table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=width) - table.reformat_column(0, width=22) - table.reformat_column(1, width=29) - table.reformat_column(2, width=11, align='c') - table.reformat_column(3, width=16) - return table - - -def prototype_to_str(prototype): - """ - Format a prototype to a nice string representation. - - Args: - prototype (dict): The prototype. - """ - - header = ( - "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" - "|cdesc:|n {} \n|cprototype:|n ".format( - prototype['prototype_key'], - ", ".join(prototype['prototype_tags']), - prototype['prototype_locks'], - prototype['prototype_desc'])) - proto = ("{{\n {} \n}}".format( - "\n ".join( - "{!r}: {!r},".format(key, value) for key, value in - sorted(prototype.items()) if key not in _PROTOTYPE_META_NAMES)).rstrip(",")) - return header + proto - - -def prototype_from_object(obj): - """ - Guess a minimal prototype from an existing object. - - Args: - obj (Object): An object to analyze. - - Returns: - prototype (dict): A prototype estimating the current state of the object. - - """ - # first, check if this object already has a prototype - - prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) - prot = search_prototype(prot) - if not prot or len(prot) > 1: - # no unambiguous prototype found - build new prototype - prot = {} - prot['prototype_key'] = "From-Object-{}-{}".format( - obj.key, hashlib.md5(str(time.time())).hexdigest()[:6]) - prot['prototype_desc'] = "Built from {}".format(str(obj)) - prot['prototype_locks'] = "use:all();edit:all()" - - prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6] - prot['location'] = obj.db_location - prot['home'] = obj.db_home - prot['destination'] = obj.db_destination - prot['typeclass'] = obj.db_typeclass_path - prot['locks'] = obj.locks.all() - prot['permissions'] = obj.permissions.get() - prot['aliases'] = obj.aliases.get() - prot['tags'] = [(tag.key, tag.category, tag.data) - for tag in obj.tags.get(return_tagobj=True, return_list=True)] - prot['attrs'] = [(attr.key, attr.value, attr.category, attr.locks) - for attr in obj.attributes.get(return_obj=True, return_list=True)] - - return prot - -# Spawner mechanism - - -def validate_prototype(prototype, protkey=None, protparents=None, _visited=None): - """ - Run validation on a prototype, checking for inifinite regress. - - Args: - prototype (dict): Prototype to validate. - protkey (str, optional): The name of the prototype definition. If not given, the prototype - dict needs to have the `prototype_key` field set. - protpartents (dict, optional): The available prototype parent library. If - note given this will be determined from settings/database. - _visited (list, optional): This is an internal work array and should not be set manually. - Raises: - RuntimeError: If prototype has invalid structure. - - """ - if not protparents: - protparents = get_protparent_dict() - if _visited is None: - _visited = [] - - protkey = protkey and protkey.lower() or prototype.get('prototype_key', None) - - assert isinstance(prototype, dict) - - if id(prototype) in _visited: - raise RuntimeError("%s has infinite nesting of prototypes." % protkey or prototype) - - _visited.append(id(prototype)) - protstrings = prototype.get("prototype") - - if protstrings: - for protstring in make_iter(protstrings): - protstring = protstring.lower() - if protkey is not None and protstring == protkey: - raise RuntimeError("%s tries to prototype itself." % protkey or prototype) - protparent = protparents.get(protstring) - if not protparent: - raise RuntimeError( - "%s's prototype '%s' was not found." % (protkey or prototype, protstring)) - validate_prototype(protparent, protstring, protparents, _visited) - - -def _get_prototype(dic, prot, protparents): - """ - Recursively traverse a prototype dictionary, including multiple - inheritance. Use validate_prototype before this, we don't check - for infinite recursion here. - - """ - if "prototype" in dic: - # move backwards through the inheritance - for prototype in make_iter(dic["prototype"]): - # Build the prot dictionary in reverse order, overloading - new_prot = _get_prototype(protparents.get(prototype.lower(), {}), prot, protparents) - prot.update(new_prot) - prot.update(dic) - prot.pop("prototype", None) # we don't need this anymore - return prot - - -def prototype_diff_from_object(prototype, obj): - """ - Get a simple diff for a prototype compared to an object which may or may not already have a - prototype (or has one but changed locally). For more complex migratations a manual diff may be - needed. - - Args: - prototype (dict): Prototype. - obj (Object): Object to - - Returns: - diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...} - - """ - prot1 = prototype - prot2 = prototype_from_object(obj) - - diff = {} - for key, value in prot1.items(): - diff[key] = "KEEP" - if key in prot2: - if callable(prot2[key]) or value != prot2[key]: - diff[key] = "UPDATE" - elif key not in prot2: - diff[key] = "REMOVE" - - return diff - - -def batch_update_objects_with_prototype(prototype, diff=None, objects=None): - """ - Update existing objects with the latest version of the prototype. - - Args: - prototype (str or dict): Either the `prototype_key` to use or the - prototype dict itself. - diff (dict, optional): This a diff structure that describes how to update the protototype. If - not given this will be constructed from the first object found. - objects (list, optional): List of objects to update. If not given, query for these - objects using the prototype's `prototype_key`. - Returns: - changed (int): The number of objects that had changes applied to them. - - """ - prototype_key = prototype if isinstance(prototype, basestring) else prototype['prototype_key'] - prototype_obj = search_db_prototype(prototype_key, return_queryset=True) - prototype_obj = prototype_obj[0] if prototype_obj else None - new_prototype = prototype_obj.db.prototype - objs = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) - - if not objs: - return 0 - - if not diff: - diff = prototype_diff_from_object(new_prototype, objs[0]) - - changed = 0 - for obj in objs: - do_save = False - for key, directive in diff.items(): - val = new_prototype[key] - if directive in ('UPDATE', 'REPLACE'): - do_save = True - if key == 'key': - obj.db_key = validate_spawn_value(val, str) - elif key == 'typeclass': - obj.db_typeclass_path = validate_spawn_value(val, str) - elif key == 'location': - obj.db_location = validate_spawn_value(val, _to_obj) - elif key == 'home': - obj.db_home = validate_spawn_value(val, _to_obj) - elif key == 'destination': - obj.db_destination = validate_spawn_value(val, _to_obj) - elif key == 'locks': - if directive == 'REPLACE': - obj.locks.clear() - obj.locks.add(validate_spawn_value(val, str)) - elif key == 'permissions': - if directive == 'REPLACE': - obj.permissions.clear() - obj.permissions.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'aliases': - if directive == 'REPLACE': - obj.aliases.clear() - obj.aliases.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'tags': - if directive == 'REPLACE': - obj.tags.clear() - obj.tags.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'attrs': - if directive == 'REPLACE': - obj.attributes.clear() - obj.attributes.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'exec': - # we don't auto-rerun exec statements, it would be huge security risk! - pass - else: - obj.attributes.add(key, validate_spawn_value(val, _to_obj)) - elif directive == 'REMOVE': - do_save = True - if key == 'key': - obj.db_key = '' - elif key == 'typeclass': - # fall back to default - obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS - elif key == 'location': - obj.db_location = None - elif key == 'home': - obj.db_home = None - elif key == 'destination': - obj.db_destination = None - elif key == 'locks': - obj.locks.clear() - elif key == 'permissions': - obj.permissions.clear() - elif key == 'aliases': - obj.aliases.clear() - elif key == 'tags': - obj.tags.clear() - elif key == 'attrs': - obj.attributes.clear() - elif key == 'exec': - # we don't auto-rerun exec statements, it would be huge security risk! - pass - else: - obj.attributes.remove(key) - if do_save: - changed += 1 - obj.save() - - return changed - - -def _batch_create_object(*objparams): - """ - This is a cut-down version of the create_object() function, - optimized for speed. It does NOT check and convert various input - so make sure the spawned Typeclass works before using this! - - Args: - objsparams (tuple): Each paremter tuple will create one object instance using the parameters within. - The parameters should be given in the following order: - - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`. - - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`. - - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`. - - `aliases` (list): A list of alias strings for - adding with `new_object.aliases.batch_add(*aliases)`. - - `nattributes` (list): list of tuples `(key, value)` to be loop-added to - add with `new_obj.nattributes.add(*tuple)`. - - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for - adding with `new_obj.attributes.batch_add(*attributes)`. - - `tags` (list): list of tuples `(key, category)` for adding - with `new_obj.tags.batch_add(*tags)`. - - `execs` (list): Code strings to execute together with the creation - of each object. They will be executed with `evennia` and `obj` - (the newly created object) available in the namespace. Execution - will happend after all other properties have been assigned and - is intended for calling custom handlers etc. - - Returns: - objects (list): A list of created objects - - Notes: - The `exec` list will execute arbitrary python code so don't allow this to be available to - unprivileged users! - - """ - - # bulk create all objects in one go - - # unfortunately this doesn't work since bulk_create doesn't creates pks; - # the result would be duplicate objects at the next stage, so we comment - # it out for now: - # dbobjs = _ObjectDB.objects.bulk_create(dbobjs) - - dbobjs = [ObjectDB(**objparam[0]) for objparam in objparams] - objs = [] - for iobj, obj in enumerate(dbobjs): - # call all setup hooks on each object - objparam = objparams[iobj] - # setup - obj._createdict = {"permissions": make_iter(objparam[1]), - "locks": objparam[2], - "aliases": make_iter(objparam[3]), - "nattributes": objparam[4], - "attributes": objparam[5], - "tags": make_iter(objparam[6])} - # this triggers all hooks - obj.save() - # run eventual extra code - for code in objparam[7]: - if code: - exec(code, {}, {"evennia": evennia, "obj": obj}) - objs.append(obj) - return objs - - -def spawn(*prototypes, **kwargs): - """ - Spawn a number of prototyped objects. - - Args: - prototypes (dict): Each argument should be a prototype - dictionary. - Kwargs: - prototype_modules (str or list): A python-path to a prototype - module, or a list of such paths. These will be used to build - the global protparents dictionary accessible by the input - prototypes. If not given, it will instead look for modules - defined by settings.PROTOTYPE_MODULES. - prototype_parents (dict): A dictionary holding a custom - prototype-parent dictionary. Will overload same-named - prototypes from prototype_modules. - return_prototypes (bool): Only return a list of the - prototype-parents (no object creation happens) - - Returns: - object (Object): Spawned object. - - """ - # get available protparents - protparents = get_protparent_dict() - - # overload module's protparents with specifically given protparents - protparents.update(kwargs.get("prototype_parents", {})) - for key, prototype in protparents.items(): - validate_prototype(prototype, key.lower(), protparents) - - if "return_prototypes" in kwargs: - # only return the parents - return copy.deepcopy(protparents) - - objsparams = [] - for prototype in prototypes: - - validate_prototype(prototype, None, protparents) - prot = _get_prototype(prototype, {}, protparents) - if not prot: - continue - - # extract the keyword args we need to create the object itself. If we get a callable, - # call that to get the value (don't catch errors) - create_kwargs = {} - # we must always add a key, so if not given we use a shortened md5 hash. There is a (small) - # chance this is not unique but it should usually not be a problem. - val = prot.pop("key", "Spawned-{}".format( - hashlib.md5(str(time.time())).hexdigest()[:6])) - create_kwargs["db_key"] = validate_spawn_value(val, str) - - val = prot.pop("location", None) - create_kwargs["db_location"] = validate_spawn_value(val, _to_obj) - - val = prot.pop("home", settings.DEFAULT_HOME) - create_kwargs["db_home"] = validate_spawn_value(val, _to_obj) - - val = prot.pop("destination", None) - create_kwargs["db_destination"] = validate_spawn_value(val, _to_obj) - - val = prot.pop("typeclass", settings.BASE_OBJECT_TYPECLASS) - create_kwargs["db_typeclass_path"] = validate_spawn_value(val, str) - - # extract calls to handlers - val = prot.pop("permissions", []) - permission_string = validate_spawn_value(val, make_iter) - val = prot.pop("locks", "") - lock_string = validate_spawn_value(val, str) - val = prot.pop("aliases", []) - alias_string = validate_spawn_value(val, make_iter) - - val = prot.pop("tags", []) - tags = validate_spawn_value(val, make_iter) - - prototype_key = prototype.get('prototype_key', None) - if prototype_key: - # we make sure to add a tag identifying which prototype created this object - tags.append((prototype_key, _PROTOTYPE_TAG_CATEGORY)) - - val = prot.pop("exec", "") - execs = validate_spawn_value(val, make_iter) - - # extract ndb assignments - nattribute = dict((key.split("_", 1)[1], validate_spawn_value(val, _to_obj)) - for key, val in prot.items() if key.startswith("ndb_")) - - # the rest are attributes - val = prot.pop("attrs", []) - attributes = validate_spawn_value(val, list) - - simple_attributes = [] - for key, value in ((key, value) for key, value in prot.items() - if not (key.startswith("ndb_"))): - if is_iter(value) and len(value) > 1: - # (value, category) - simple_attributes.append((key, - validate_spawn_value(value[0], _to_obj_or_any), - validate_spawn_value(value[1], str))) - else: - simple_attributes.append((key, - validate_spawn_value(value, _to_obj_or_any))) - - attributes = attributes + simple_attributes - attributes = [tup for tup in attributes if not tup[0] in _NON_CREATE_KWARGS] - - # pack for call into _batch_create_object - objsparams.append((create_kwargs, permission_string, lock_string, - alias_string, nattributes, attributes, tags, execs)) - - return _batch_create_object(*objsparams) - - -# ------------------------------------------------------------ -# -# OLC Prototype design menu -# -# ------------------------------------------------------------ - -# Helper functions - -def _get_menu_prototype(caller): - - prototype = None - if hasattr(caller.ndb._menutree, "olc_prototype"): - prototype = caller.ndb._menutree.olc_prototype - if not prototype: - caller.ndb._menutree.olc_prototype = prototype = {} - caller.ndb._menutree.olc_new = True - return prototype - - -def _is_new_prototype(caller): - return hasattr(caller.ndb._menutree, "olc_new") - - -def _set_menu_prototype(caller, field, value): - prototype = _get_menu_prototype(caller) - prototype[field] = value - caller.ndb._menutree.olc_prototype = prototype - - -def _format_property(prop, required=False, prototype=None, cropper=None): - - if prototype is not None: - prop = prototype.get(prop, '') - - out = prop - if callable(prop): - if hasattr(prop, '__name__'): - out = "<{}>".format(prop.__name__) - else: - out = repr(prop) - if is_iter(prop): - out = ", ".join(str(pr) for pr in prop) - if not out and required: - out = "|rrequired" - return " ({}|n)".format(cropper(out) if cropper else crop(out, _MENU_CROP_WIDTH)) - - -def _set_property(caller, raw_string, **kwargs): - """ - Update a property. To be called by the 'goto' option variable. - - Args: - caller (Object, Account): The user of the wizard. - raw_string (str): Input from user on given node - the new value to set. - Kwargs: - prop (str): Property name to edit with `raw_string`. - processor (callable): Converts `raw_string` to a form suitable for saving. - next_node (str): Where to redirect to after this has run. - Returns: - next_node (str): Next node to go to. - - """ - prop = kwargs.get("prop", "prototype_key") - processor = kwargs.get("processor", None) - next_node = kwargs.get("next_node", "node_index") - - propname_low = prop.strip().lower() - - if callable(processor): - try: - value = processor(raw_string) - except Exception as err: - caller.msg("Could not set {prop} to {value} ({err})".format( - prop=prop.replace("_", "-").capitalize(), value=raw_string, err=str(err))) - # this means we'll re-run the current node. - return None - else: - value = raw_string - - if not value: - return next_node - - prototype = _get_menu_prototype(caller) - - # typeclass and prototype can't co-exist - if propname_low == "typeclass": - prototype.pop("prototype", None) - if propname_low == "prototype": - prototype.pop("typeclass", None) - - caller.ndb._menutree.olc_prototype = prototype - - caller.msg("Set {prop} to '{value}'.".format(prop, value=str(value))) - - return next_node - - -def _wizard_options(curr_node, prev_node, next_node, color="|W"): - options = [] - if prev_node: - options.append({"key": ("|wb|Wack", "b"), - "desc": "{color}({node})|n".format( - color=color, node=prev_node.replace("_", "-")), - "goto": "node_{}".format(prev_node)}) - if next_node: - options.append({"key": ("|wf|Worward", "f"), - "desc": "{color}({node})|n".format( - color=color, node=next_node.replace("_", "-")), - "goto": "node_{}".format(next_node)}) - - if "index" not in (prev_node, next_node): - options.append({"key": ("|wi|Wndex", "i"), - "goto": "node_index"}) - - if curr_node: - options.append({"key": ("|wv|Walidate prototype", "v"), - "goto": ("node_validate_prototype", {"back": curr_node})}) - - return options - - -def _path_cropper(pythonpath): - "Crop path to only the last component" - return pythonpath.split('.')[-1] - - -# Menu nodes - -def node_index(caller): - prototype = _get_menu_prototype(caller) - - text = ("|c --- Prototype wizard --- |n\n\n" - "Define the |yproperties|n of the prototype. All prototype values can be " - "over-ridden at the time of spawning an instance of the prototype, but some are " - "required.\n\n'|wMeta'-properties|n are not used in the prototype itself but are used " - "to organize and list prototypes. The 'Meta-Key' uniquely identifies the prototype " - "and allows you to edit an existing prototype or save a new one for use by you or " - "others later.\n\n(make choice; q to abort. If unsure, start from 1.)") - - options = [] - options.append( - {"desc": "|WPrototype-Key|n|n{}".format(_format_property("Key", True, prototype, None)), - "goto": "node_prototype_key"}) - for key in ('Prototype', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', - 'Permissions', 'Location', 'Home', 'Destination'): - required = False - cropper = None - if key in ("Prototype", "Typeclass"): - required = "prototype" not in prototype and "typeclass" not in prototype - if key == 'Typeclass': - cropper = _path_cropper - options.append( - {"desc": "|w{}|n{}".format( - key, _format_property(key, required, prototype, cropper=cropper)), - "goto": "node_{}".format(key.lower())}) - required = False - for key in ('Desc', 'Tags', 'Locks'): - options.append( - {"desc": "|WPrototype-{}|n|n{}".format(key, _format_property(key, required, prototype, None)), - "goto": "node_prototype_{}".format(key.lower())}) - - return text, options - - -def node_validate_prototype(caller, raw_string, **kwargs): - prototype = _get_menu_prototype(caller) - - txt = prototype_to_str(prototype) - errors = "\n\n|g No validation errors found.|n (but errors could still happen at spawn-time)" - try: - # validate, don't spawn - spawn(prototype, return_prototypes=True) - except RuntimeError as err: - errors = "\n\n|rError: {}|n".format(err) - text = (txt + errors) - - options = _wizard_options(None, kwargs.get("back"), None) - - return text, options - - -def _check_prototype_key(caller, key): - old_prototype = search_prototype(key) - olc_new = _is_new_prototype(caller) - key = key.strip().lower() - if old_prototype: - # we are starting a new prototype that matches an existing - if not caller.locks.check_lockstring( - caller, old_prototype['prototype_locks'], access_type='edit'): - # return to the node_prototype_key to try another key - caller.msg("Prototype '{key}' already exists and you don't " - "have permission to edit it.".format(key=key)) - return "node_prototype_key" - elif olc_new: - # we are selecting an existing prototype to edit. Reset to index. - del caller.ndb._menutree.olc_new - caller.ndb._menutree.olc_prototype = old_prototype - caller.msg("Prototype already exists. Reloading.") - return "node_index" - - return _set_property(caller, key, prop='prototype_key', next_node="node_prototype") - - -def node_prototype_key(caller): - prototype = _get_menu_prototype(caller) - text = ["The prototype name, or |wMeta-Key|n, uniquely identifies the prototype. " - "It is used to find and use the prototype to spawn new entities. " - "It is not case sensitive."] - old_key = prototype.get('prototype_key', None) - if old_key: - text.append("Current key is '|w{key}|n'".format(key=old_key)) - else: - text.append("The key is currently unset.") - text.append("Enter text or make a choice (q for quit)") - text = "\n\n".join(text) - options = _wizard_options("prototype_key", "index", "prototype") - options.append({"key": "_default", - "goto": _check_prototype_key}) - return text, options - - -def _all_prototypes(caller): - return [prototype["prototype_key"] - for prototype in search_prototype() if "prototype_key" in prototype] - - -def _prototype_examine(caller, prototype_name): - prototypes = search_prototype(key=prototype_name) - if prototypes: - caller.msg(prototype_to_str(prototypes[0])) - caller.msg("Prototype not registered.") - return None - - -def _prototype_select(caller, prototype): - ret = _set_property(caller, prototype, prop="prototype", processor=str, next_node="node_key") - caller.msg("Selected prototype |y{}|n. Removed any set typeclass parent.".format(prototype)) - return ret - - -@list_node(_all_prototypes, _prototype_select) -def node_prototype(caller): - prototype = _get_menu_prototype(caller) - - prot_parent_key = prototype.get('prototype') - - text = ["Set the prototype's |yParent Prototype|n. If this is unset, Typeclass will be used."] - if prot_parent_key: - prot_parent = search_prototype(prot_parent_key) - if prot_parent: - text.append("Current parent prototype is {}:\n{}".format(prototype_to_str(prot_parent))) - else: - text.append("Current parent prototype |r{prototype}|n " - "does not appear to exist.".format(prot_parent_key)) - else: - text.append("Parent prototype is not set") - text = "\n\n".join(text) - options = _wizard_options("prototype", "prototype_key", "typeclass", color="|W") - options.append({"key": "_default", - "goto": _prototype_examine}) - - return text, options - - -def _all_typeclasses(caller): - return list(sorted(get_all_typeclasses().keys())) - - -def _typeclass_examine(caller, typeclass_path): - if typeclass_path is None: - # this means we are exiting the listing - return "node_key" - - typeclass = get_all_typeclasses().get(typeclass_path) - if typeclass: - docstr = [] - for line in typeclass.__doc__.split("\n"): - if line.strip(): - docstr.append(line) - elif docstr: - break - docstr = '\n'.join(docstr) if docstr else "" - txt = "Typeclass |y{typeclass_path}|n; First paragraph of docstring:\n\n{docstring}".format( - typeclass_path=typeclass_path, docstring=docstr) - else: - txt = "This is typeclass |y{}|n.".format(typeclass) - caller.msg(txt) - return None - - -def _typeclass_select(caller, typeclass): - ret = _set_property(caller, typeclass, prop='typeclass', processor=str, next_node="node_key") - caller.msg("Selected typeclass |y{}|n. Removed any set prototype parent.".format(typeclass)) - return ret - - -@list_node(_all_typeclasses, _typeclass_select) -def node_typeclass(caller): - prototype = _get_menu_prototype(caller) - typeclass = prototype.get("typeclass") - - text = ["Set the typeclass's parent |yTypeclass|n."] - if typeclass: - text.append("Current typeclass is |y{typeclass}|n.".format(typeclass=typeclass)) - else: - text.append("Using default typeclass {typeclass}.".format( - typeclass=settings.BASE_OBJECT_TYPECLASS)) - text = "\n\n".join(text) - options = _wizard_options("typeclass", "prototype", "key", color="|W") - options.append({"key": "_default", - "goto": _typeclass_examine}) - return text, options - - -def node_key(caller): - prototype = _get_menu_prototype(caller) - key = prototype.get("key") - - text = ["Set the prototype's |yKey|n. This will retain case sensitivity."] - if key: - text.append("Current key value is '|y{key}|n'.".format(key=key)) - else: - text.append("Key is currently unset.") - text = "\n\n".join(text) - options = _wizard_options("key", "typeclass", "aliases") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="key", - processor=lambda s: s.strip(), - next_node="node_aliases"))}) - return text, options - - -def node_aliases(caller): - prototype = _get_menu_prototype(caller) - aliases = prototype.get("aliases") - - text = ["Set the prototype's |yAliases|n. Separate multiple aliases with commas. " - "ill retain case sensitivity."] - if aliases: - text.append("Current aliases are '|y{aliases}|n'.".format(aliases=aliases)) - else: - text.append("No aliases are set.") - text = "\n\n".join(text) - options = _wizard_options("aliases", "key", "attrs") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="aliases", - processor=lambda s: [part.strip() for part in s.split(",")], - next_node="node_attrs"))}) - return text, options - - -def _caller_attrs(caller): - prototype = _get_menu_prototype(caller) - attrs = prototype.get("attrs", []) - return attrs - - -def _attrparse(caller, attr_string): - "attr is entering on the form 'attr = value'" - - if '=' in attr_string: - attrname, value = (part.strip() for part in attr_string.split('=', 1)) - attrname = attrname.lower() - if attrname: - try: - value = literal_eval(value) - except SyntaxError: - caller.msg(_MENU_ATTR_LITERAL_EVAL_ERROR) - else: - return attrname, value - else: - return None, None - - -def _add_attr(caller, attr_string, **kwargs): - attrname, value = _attrparse(caller, attr_string) - if attrname: - prot = _get_menu_prototype(caller) - prot['attrs'][attrname] = value - _set_menu_prototype(caller, "prototype", prot) - text = "Added" - else: - text = "Attribute must be given as 'attrname = ' where uses valid Python." - options = {"key": "_default", - "goto": lambda caller: None} - return text, options - - -def _edit_attr(caller, attrname, new_value, **kwargs): - attrname, value = _attrparse("{}={}".format(caller, attrname, new_value)) - if attrname: - prot = _get_menu_prototype(caller) - prot['attrs'][attrname] = value - text = "Edited Attribute {} = {}".format(attrname, value) - else: - text = "Attribute value must be valid Python." - options = {"key": "_default", - "goto": lambda caller: None} - return text, options - - -def _examine_attr(caller, selection): - prot = _get_menu_prototype(caller) - value = prot['attrs'][selection] - return "Attribute {} = {}".format(selection, value) - - -@list_node(_caller_attrs) -def node_attrs(caller): - prot = _get_menu_prototype(caller) - attrs = prot.get("attrs") - - text = ["Set the prototype's |yAttributes|n. Separate multiple attrs with commas. " - "Will retain case sensitivity."] - if attrs: - text.append("Current attrs are '|y{attrs}|n'.".format(attrs=attrs)) - else: - text.append("No attrs are set.") - text = "\n\n".join(text) - options = _wizard_options("attrs", "aliases", "tags") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="attrs", - processor=lambda s: [part.strip() for part in s.split(",")], - next_node="node_tags"))}) - return text, options - - -def _caller_tags(caller): - prototype = _get_menu_prototype(caller) - tags = prototype.get("tags") - return tags - - -def _add_tag(caller, tag, **kwargs): - tag = tag.strip().lower() - prototype = _get_menu_prototype(caller) - tags = prototype.get('tags', []) - if tags: - if tag not in tags: - tags.append(tag) - else: - tags = [tag] - prot['tags'] = tags - _set_menu_prototype(caller, "prototype", prot) - text = kwargs.get("text") - if not text: - text = "Added tag {}. (return to continue)".format(tag) - options = {"key": "_default", - "goto": lambda caller: None} - return text, options - - -def _edit_tag(caller, old_tag, new_tag, **kwargs): - prototype = _get_menu_prototype(caller) - tags = prototype.get('tags', []) - - old_tag = old_tag.strip().lower() - new_tag = new_tag.strip().lower() - tags[tags.index(old_tag)] = new_tag - prototype['tags'] = tags - _set_menu_prototype(caller, 'prototype', prototype) - - text = kwargs.get('text') - if not text: - text = "Changed tag {} to {}.".format(old_tag, new_tag) - options = {"key": "_default", - "goto": lambda caller: None} - return text, options - - -@list_node(_caller_tags) -def node_tags(caller): - text = "Set the prototype's |yTags|n." - options = _wizard_options("tags", "attrs", "locks") - return text, options - - -def node_locks(caller): - prototype = _get_menu_prototype(caller) - locks = prototype.get("locks") - - text = ["Set the prototype's |yLock string|n. Separate multiple locks with semi-colons. " - "Will retain case sensitivity."] - if locks: - text.append("Current locks are '|y{locks}|n'.".format(locks=locks)) - else: - text.append("No locks are set.") - text = "\n\n".join(text) - options = _wizard_options("locks", "tags", "permissions") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="locks", - processor=lambda s: s.strip(), - next_node="node_permissions"))}) - return text, options - - -def node_permissions(caller): - prototype = _get_menu_prototype(caller) - permissions = prototype.get("permissions") - - text = ["Set the prototype's |yPermissions|n. Separate multiple permissions with commas. " - "Will retain case sensitivity."] - if permissions: - text.append("Current permissions are '|y{permissions}|n'.".format(permissions=permissions)) - else: - text.append("No permissions are set.") - text = "\n\n".join(text) - options = _wizard_options("permissions", "destination", "location") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="permissions", - processor=lambda s: [part.strip() for part in s.split(",")], - next_node="node_location"))}) - return text, options - - -def node_location(caller): - prototype = _get_menu_prototype(caller) - location = prototype.get("location") - - text = ["Set the prototype's |yLocation|n"] - if location: - text.append("Current location is |y{location}|n.".format(location=location)) - else: - text.append("Default location is {}'s inventory.".format(caller)) - text = "\n\n".join(text) - options = _wizard_options("location", "permissions", "home") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="location", - processor=lambda s: s.strip(), - next_node="node_home"))}) - return text, options - - -def node_home(caller): - prototype = _get_menu_prototype(caller) - home = prototype.get("home") - - text = ["Set the prototype's |yHome location|n"] - if home: - text.append("Current home location is |y{home}|n.".format(home=home)) - else: - text.append("Default home location (|y{home}|n) used.".format(home=settings.DEFAULT_HOME)) - text = "\n\n".join(text) - options = _wizard_options("home", "aliases", "destination") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="home", - processor=lambda s: s.strip(), - next_node="node_destination"))}) - return text, options - - -def node_destination(caller): - prototype = _get_menu_prototype(caller) - dest = prototype.get("dest") - - text = ["Set the prototype's |yDestination|n. This is usually only used for Exits."] - if dest: - text.append("Current destination is |y{dest}|n.".format(dest=dest)) - else: - text.append("No destination is set (default).") - text = "\n\n".join(text) - options = _wizard_options("destination", "home", "prototype_desc") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="dest", - processor=lambda s: s.strip(), - next_node="node_prototype_desc"))}) - return text, options - - -def node_prototype_desc(caller): - - prototype = _get_menu_prototype(caller) - text = ["The |wMeta-Description|n briefly describes the prototype for viewing in listings."] - desc = prototype.get("prototype_desc", None) - - if desc: - text.append("The current meta desc is:\n\"|w{desc}|n\"".format(desc=desc)) - else: - text.append("Description is currently unset.") - text = "\n\n".join(text) - options = _wizard_options("prototype_desc", "prototype_key", "prototype_tags") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop='prototype_desc', - processor=lambda s: s.strip(), - next_node="node_prototype_tags"))}) - - return text, options - - -def node_prototype_tags(caller): - prototype = _get_menu_prototype(caller) - text = ["|wMeta-Tags|n can be used to classify and find prototypes. Tags are case-insensitive. " - "Separate multiple by tags by commas."] - tags = prototype.get('prototype_tags', []) - - if tags: - text.append("The current tags are:\n|w{tags}|n".format(tags=tags)) - else: - text.append("No tags are currently set.") - text = "\n\n".join(text) - options = _wizard_options("prototype_tags", "prototype_desc", "prototype_locks") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="prototype_tags", - processor=lambda s: [ - str(part.strip().lower()) for part in s.split(",")], - next_node="node_prototype_locks"))}) - return text, options - - -def node_prototype_locks(caller): - prototype = _get_menu_prototype(caller) - text = ["Set |wMeta-Locks|n on the prototype. There are two valid lock types: " - "'edit' (who can edit the prototype) and 'use' (who can apply the prototype)\n" - "(If you are unsure, leave as default.)"] - locks = prototype.get('prototype_locks', '') - if locks: - text.append("Current lock is |w'{lockstring}'|n".format(lockstring=locks)) - else: - text.append("Lock unset - if not changed the default lockstring will be set as\n" - " |w'use:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) - text = "\n\n".join(text) - options = _wizard_options("prototype_locks", "prototype_tags", "index") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="prototype_locks", - processor=lambda s: s.strip().lower(), - next_node="node_index"))}) - return text, options - - -class OLCMenu(EvMenu): - """ - A custom EvMenu with a different formatting for the options. - - """ - def options_formatter(self, optionlist): - """ - Split the options into two blocks - olc options and normal options - - """ - olc_keys = ("index", "forward", "back", "previous", "next", "validate prototype") - olc_options = [] - other_options = [] - for key, desc in optionlist: - raw_key = strip_ansi(key) - if raw_key in olc_keys: - desc = " {}".format(desc) if desc else "" - olc_options.append("|lc{}|lt{}|le{}".format(raw_key, key, desc)) - else: - other_options.append((key, desc)) - - olc_options = " | ".join(olc_options) + " | " + "|wq|Wuit" if olc_options else "" - other_options = super(OLCMenu, self).options_formatter(other_options) - sep = "\n\n" if olc_options and other_options else "" - - return "{}{}{}".format(olc_options, sep, other_options) - - -def start_olc(caller, session=None, prototype=None): - """ - Start menu-driven olc system for prototypes. - - Args: - caller (Object or Account): The entity starting the menu. - session (Session, optional): The individual session to get data. - prototype (dict, optional): Given when editing an existing - prototype rather than creating a new one. - - """ - menudata = {"node_index": node_index, - "node_validate_prototype": node_validate_prototype, - "node_prototype_key": node_prototype_key, - "node_prototype": node_prototype, - "node_typeclass": node_typeclass, - "node_key": node_key, - "node_aliases": node_aliases, - "node_attrs": node_attrs, - "node_tags": node_tags, - "node_locks": node_locks, - "node_permissions": node_permissions, - "node_location": node_location, - "node_home": node_home, - "node_destination": node_destination, - "node_prototype_desc": node_prototype_desc, - "node_prototype_tags": node_prototype_tags, - "node_prototype_locks": node_prototype_locks, - } - OLCMenu(caller, menudata, startnode='node_index', session=session, olc_prototype=prototype) - - -# Testing - -if __name__ == "__main__": - protparents = { - "NOBODY": {}, - # "INFINITE" : { - # "prototype":"INFINITE" - # }, - "GOBLIN": { - "key": "goblin grunt", - "health": lambda: randint(20, 30), - "resists": ["cold", "poison"], - "attacks": ["fists"], - "weaknesses": ["fire", "light"] - }, - "GOBLIN_WIZARD": { - "prototype": "GOBLIN", - "key": "goblin wizard", - "spells": ["fire ball", "lighting bolt"] - }, - "GOBLIN_ARCHER": { - "prototype": "GOBLIN", - "key": "goblin archer", - "attacks": ["short bow"] - }, - "ARCHWIZARD": { - "attacks": ["archwizard staff"], - }, - "GOBLIN_ARCHWIZARD": { - "key": "goblin archwizard", - "prototype": ("GOBLIN_WIZARD", "ARCHWIZARD") - } - } - # test - print([o.key for o in spawn(protparents["GOBLIN"], - protparents["GOBLIN_ARCHWIZARD"], - prototype_parents=protparents)]) From 1a8651f18b10d337c3b90dee9ab5a1474201a2a1 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 6 Jun 2018 22:11:32 +0200 Subject: [PATCH 316/466] Continued refactoring --- evennia/prototypes/prototypes.py | 274 +++++++++++++++++++++++++++++++ evennia/prototypes/spawner.py | 240 +-------------------------- evennia/prototypes/utils.py | 128 +++------------ 3 files changed, 295 insertions(+), 347 deletions(-) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 60e194861b..e3d26fd87e 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -61,6 +61,7 @@ class DbPrototype(DefaultScript): # General prototype functions + def check_permission(prototype_key, action, default=True): """ Helper function to check access to actions on given prototype. @@ -278,3 +279,276 @@ def search_objects_with_prototype(prototype_key): """ return ObjectDB.objects.get_by_tag(key=prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + + +def prototype_from_object(obj): + """ + Guess a minimal prototype from an existing object. + + Args: + obj (Object): An object to analyze. + + Returns: + prototype (dict): A prototype estimating the current state of the object. + + """ + # first, check if this object already has a prototype + + prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) + prot = search_prototype(prot) + if not prot or len(prot) > 1: + # no unambiguous prototype found - build new prototype + prot = {} + prot['prototype_key'] = "From-Object-{}-{}".format( + obj.key, hashlib.md5(str(time.time())).hexdigest()[:6]) + prot['prototype_desc'] = "Built from {}".format(str(obj)) + prot['prototype_locks'] = "spawn:all();edit:all()" + + prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6] + prot['location'] = obj.db_location + prot['home'] = obj.db_home + prot['destination'] = obj.db_destination + prot['typeclass'] = obj.db_typeclass_path + prot['locks'] = obj.locks.all() + prot['permissions'] = obj.permissions.get() + prot['aliases'] = obj.aliases.get() + prot['tags'] = [(tag.key, tag.category, tag.data) + for tag in obj.tags.get(return_tagobj=True, return_list=True)] + prot['attrs'] = [(attr.key, attr.value, attr.category, attr.locks) + for attr in obj.attributes.get(return_obj=True, return_list=True)] + + return prot + + +def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): + """ + Collate a list of found prototypes based on search criteria and access. + + Args: + caller (Account or Object): The object requesting the list. + key (str, optional): Exact or partial prototype key to query for. + tags (str or list, optional): Tag key or keys to query for. + show_non_use (bool, optional): Show also prototypes the caller may not use. + show_non_edit (bool, optional): Show also prototypes the caller may not edit. + Returns: + table (EvTable or None): An EvTable representation of the prototypes. None + if no prototypes were found. + + """ + # this allows us to pass lists of empty strings + tags = [tag for tag in make_iter(tags) if tag] + + # get prototypes for readonly and db-based prototypes + prototypes = search_prototype(key, tags) + + # get use-permissions of readonly attributes (edit is always False) + display_tuples = [] + for prototype in sorted(prototypes, key=lambda d: d.get('prototype_key', '')): + lock_use = caller.locks.check_lockstring( + caller, prototype.get('prototype_locks', ''), access_type='spawn') + if not show_non_use and not lock_use: + continue + if prototype.get('prototype_key', '') in _MODULE_PROTOTYPES: + lock_edit = False + else: + lock_edit = caller.locks.check_lockstring( + caller, prototype.get('prototype_locks', ''), access_type='edit') + if not show_non_edit and not lock_edit: + continue + ptags = [] + for ptag in prototype.get('prototype_tags', []): + if is_iter(ptag): + if len(ptag) > 1: + ptags.append("{} (category: {}".format(ptag[0], ptag[1])) + else: + ptags.append(ptag[0]) + else: + ptags.append(str(ptag)) + + display_tuples.append( + (prototype.get('prototype_key', ''), + prototype.get('prototype_desc', ''), + "{}/{}".format('Y' if lock_use else 'N', 'Y' if lock_edit else 'N'), + ",".join(ptags))) + + if not display_tuples: + return None + + table = [] + width = 78 + for i in range(len(display_tuples[0])): + table.append([str(display_tuple[i]) for display_tuple in display_tuples]) + table = EvTable("Key", "Desc", "Spawn/Edit", "Tags", table=table, crop=True, width=width) + table.reformat_column(0, width=22) + table.reformat_column(1, width=29) + table.reformat_column(2, width=11, align='c') + table.reformat_column(3, width=16) + return table + + + +def batch_update_objects_with_prototype(prototype, diff=None, objects=None): + """ + Update existing objects with the latest version of the prototype. + + Args: + prototype (str or dict): Either the `prototype_key` to use or the + prototype dict itself. + diff (dict, optional): This a diff structure that describes how to update the protototype. + If not given this will be constructed from the first object found. + objects (list, optional): List of objects to update. If not given, query for these + objects using the prototype's `prototype_key`. + Returns: + changed (int): The number of objects that had changes applied to them. + + """ + prototype_key = prototype if isinstance(prototype, basestring) else prototype['prototype_key'] + prototype_obj = search_db_prototype(prototype_key, return_queryset=True) + prototype_obj = prototype_obj[0] if prototype_obj else None + new_prototype = prototype_obj.db.prototype + objs = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + + if not objs: + return 0 + + if not diff: + diff = prototype_diff_from_object(new_prototype, objs[0]) + + changed = 0 + for obj in objs: + do_save = False + for key, directive in diff.items(): + val = new_prototype[key] + if directive in ('UPDATE', 'REPLACE'): + do_save = True + if key == 'key': + obj.db_key = validate_spawn_value(val, str) + elif key == 'typeclass': + obj.db_typeclass_path = validate_spawn_value(val, str) + elif key == 'location': + obj.db_location = validate_spawn_value(val, _to_obj) + elif key == 'home': + obj.db_home = validate_spawn_value(val, _to_obj) + elif key == 'destination': + obj.db_destination = validate_spawn_value(val, _to_obj) + elif key == 'locks': + if directive == 'REPLACE': + obj.locks.clear() + obj.locks.add(validate_spawn_value(val, str)) + elif key == 'permissions': + if directive == 'REPLACE': + obj.permissions.clear() + obj.permissions.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'aliases': + if directive == 'REPLACE': + obj.aliases.clear() + obj.aliases.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'tags': + if directive == 'REPLACE': + obj.tags.clear() + obj.tags.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'attrs': + if directive == 'REPLACE': + obj.attributes.clear() + obj.attributes.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'exec': + # we don't auto-rerun exec statements, it would be huge security risk! + pass + else: + obj.attributes.add(key, validate_spawn_value(val, _to_obj)) + elif directive == 'REMOVE': + do_save = True + if key == 'key': + obj.db_key = '' + elif key == 'typeclass': + # fall back to default + obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS + elif key == 'location': + obj.db_location = None + elif key == 'home': + obj.db_home = None + elif key == 'destination': + obj.db_destination = None + elif key == 'locks': + obj.locks.clear() + elif key == 'permissions': + obj.permissions.clear() + elif key == 'aliases': + obj.aliases.clear() + elif key == 'tags': + obj.tags.clear() + elif key == 'attrs': + obj.attributes.clear() + elif key == 'exec': + # we don't auto-rerun exec statements, it would be huge security risk! + pass + else: + obj.attributes.remove(key) + if do_save: + changed += 1 + obj.save() + + return changed + +def batch_create_object(*objparams): + """ + This is a cut-down version of the create_object() function, + optimized for speed. It does NOT check and convert various input + so make sure the spawned Typeclass works before using this! + + Args: + objsparams (tuple): Each paremter tuple will create one object instance using the parameters within. + The parameters should be given in the following order: + - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`. + - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`. + - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`. + - `aliases` (list): A list of alias strings for + adding with `new_object.aliases.batch_add(*aliases)`. + - `nattributes` (list): list of tuples `(key, value)` to be loop-added to + add with `new_obj.nattributes.add(*tuple)`. + - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for + adding with `new_obj.attributes.batch_add(*attributes)`. + - `tags` (list): list of tuples `(key, category)` for adding + with `new_obj.tags.batch_add(*tags)`. + - `execs` (list): Code strings to execute together with the creation + of each object. They will be executed with `evennia` and `obj` + (the newly created object) available in the namespace. Execution + will happend after all other properties have been assigned and + is intended for calling custom handlers etc. + + Returns: + objects (list): A list of created objects + + Notes: + The `exec` list will execute arbitrary python code so don't allow this to be available to + unprivileged users! + + """ + + # bulk create all objects in one go + + # unfortunately this doesn't work since bulk_create doesn't creates pks; + # the result would be duplicate objects at the next stage, so we comment + # it out for now: + # dbobjs = _ObjectDB.objects.bulk_create(dbobjs) + + dbobjs = [ObjectDB(**objparam[0]) for objparam in objparams] + objs = [] + for iobj, obj in enumerate(dbobjs): + # call all setup hooks on each object + objparam = objparams[iobj] + # setup + obj._createdict = {"permissions": make_iter(objparam[1]), + "locks": objparam[2], + "aliases": make_iter(objparam[3]), + "nattributes": objparam[4], + "attributes": objparam[5], + "tags": make_iter(objparam[6])} + # this triggers all hooks + obj.save() + # run eventual extra code + for code in objparam[7]: + if code: + exec(code, {}, {"evennia": evennia, "obj": obj}) + objs.append(obj) + return objs diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 062e15ee92..15ef8afb4d 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -191,48 +191,6 @@ def validate_spawn_value(value, validator=None): # Spawner mechanism -def validate_prototype(prototype, protkey=None, protparents=None, _visited=None): - """ - Run validation on a prototype, checking for inifinite regress. - - Args: - prototype (dict): Prototype to validate. - protkey (str, optional): The name of the prototype definition. If not given, the prototype - dict needs to have the `prototype_key` field set. - protpartents (dict, optional): The available prototype parent library. If - note given this will be determined from settings/database. - _visited (list, optional): This is an internal work array and should not be set manually. - Raises: - RuntimeError: If prototype has invalid structure. - - """ - if not protparents: - protparents = get_protparent_dict() - if _visited is None: - _visited = [] - - protkey = protkey and protkey.lower() or prototype.get('prototype_key', None) - - assert isinstance(prototype, dict) - - if id(prototype) in _visited: - raise RuntimeError("%s has infinite nesting of prototypes." % protkey or prototype) - - _visited.append(id(prototype)) - protstrings = prototype.get("prototype") - - if protstrings: - for protstring in make_iter(protstrings): - protstring = protstring.lower() - if protkey is not None and protstring == protkey: - raise RuntimeError("%s tries to prototype itself." % protkey or prototype) - protparent = protparents.get(protstring) - if not protparent: - raise RuntimeError( - "%s's prototype '%s' was not found." % (protkey or prototype, protstring)) - validate_prototype(protparent, protstring, protparents, _visited) - - def _get_prototype(dic, prot, protparents): """ Recursively traverse a prototype dictionary, including multiple @@ -251,202 +209,6 @@ def _get_prototype(dic, prot, protparents): return prot -def prototype_diff_from_object(prototype, obj): - """ - Get a simple diff for a prototype compared to an object which may or may not already have a - prototype (or has one but changed locally). For more complex migratations a manual diff may be - needed. - - Args: - prototype (dict): Prototype. - obj (Object): Object to - - Returns: - diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...} - - """ - prot1 = prototype - prot2 = prototype_from_object(obj) - - diff = {} - for key, value in prot1.items(): - diff[key] = "KEEP" - if key in prot2: - if callable(prot2[key]) or value != prot2[key]: - diff[key] = "UPDATE" - elif key not in prot2: - diff[key] = "REMOVE" - - return diff - - -def batch_update_objects_with_prototype(prototype, diff=None, objects=None): - """ - Update existing objects with the latest version of the prototype. - - Args: - prototype (str or dict): Either the `prototype_key` to use or the - prototype dict itself. - diff (dict, optional): This a diff structure that describes how to update the protototype. - If not given this will be constructed from the first object found. - objects (list, optional): List of objects to update. If not given, query for these - objects using the prototype's `prototype_key`. - Returns: - changed (int): The number of objects that had changes applied to them. - - """ - prototype_key = prototype if isinstance(prototype, basestring) else prototype['prototype_key'] - prototype_obj = search_db_prototype(prototype_key, return_queryset=True) - prototype_obj = prototype_obj[0] if prototype_obj else None - new_prototype = prototype_obj.db.prototype - objs = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) - - if not objs: - return 0 - - if not diff: - diff = prototype_diff_from_object(new_prototype, objs[0]) - - changed = 0 - for obj in objs: - do_save = False - for key, directive in diff.items(): - val = new_prototype[key] - if directive in ('UPDATE', 'REPLACE'): - do_save = True - if key == 'key': - obj.db_key = validate_spawn_value(val, str) - elif key == 'typeclass': - obj.db_typeclass_path = validate_spawn_value(val, str) - elif key == 'location': - obj.db_location = validate_spawn_value(val, _to_obj) - elif key == 'home': - obj.db_home = validate_spawn_value(val, _to_obj) - elif key == 'destination': - obj.db_destination = validate_spawn_value(val, _to_obj) - elif key == 'locks': - if directive == 'REPLACE': - obj.locks.clear() - obj.locks.add(validate_spawn_value(val, str)) - elif key == 'permissions': - if directive == 'REPLACE': - obj.permissions.clear() - obj.permissions.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'aliases': - if directive == 'REPLACE': - obj.aliases.clear() - obj.aliases.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'tags': - if directive == 'REPLACE': - obj.tags.clear() - obj.tags.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'attrs': - if directive == 'REPLACE': - obj.attributes.clear() - obj.attributes.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'exec': - # we don't auto-rerun exec statements, it would be huge security risk! - pass - else: - obj.attributes.add(key, validate_spawn_value(val, _to_obj)) - elif directive == 'REMOVE': - do_save = True - if key == 'key': - obj.db_key = '' - elif key == 'typeclass': - # fall back to default - obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS - elif key == 'location': - obj.db_location = None - elif key == 'home': - obj.db_home = None - elif key == 'destination': - obj.db_destination = None - elif key == 'locks': - obj.locks.clear() - elif key == 'permissions': - obj.permissions.clear() - elif key == 'aliases': - obj.aliases.clear() - elif key == 'tags': - obj.tags.clear() - elif key == 'attrs': - obj.attributes.clear() - elif key == 'exec': - # we don't auto-rerun exec statements, it would be huge security risk! - pass - else: - obj.attributes.remove(key) - if do_save: - changed += 1 - obj.save() - - return changed - - -def _batch_create_object(*objparams): - """ - This is a cut-down version of the create_object() function, - optimized for speed. It does NOT check and convert various input - so make sure the spawned Typeclass works before using this! - - Args: - objsparams (tuple): Each paremter tuple will create one object instance using the parameters within. - The parameters should be given in the following order: - - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`. - - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`. - - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`. - - `aliases` (list): A list of alias strings for - adding with `new_object.aliases.batch_add(*aliases)`. - - `nattributes` (list): list of tuples `(key, value)` to be loop-added to - add with `new_obj.nattributes.add(*tuple)`. - - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for - adding with `new_obj.attributes.batch_add(*attributes)`. - - `tags` (list): list of tuples `(key, category)` for adding - with `new_obj.tags.batch_add(*tags)`. - - `execs` (list): Code strings to execute together with the creation - of each object. They will be executed with `evennia` and `obj` - (the newly created object) available in the namespace. Execution - will happend after all other properties have been assigned and - is intended for calling custom handlers etc. - - Returns: - objects (list): A list of created objects - - Notes: - The `exec` list will execute arbitrary python code so don't allow this to be available to - unprivileged users! - - """ - - # bulk create all objects in one go - - # unfortunately this doesn't work since bulk_create doesn't creates pks; - # the result would be duplicate objects at the next stage, so we comment - # it out for now: - # dbobjs = _ObjectDB.objects.bulk_create(dbobjs) - - dbobjs = [ObjectDB(**objparam[0]) for objparam in objparams] - objs = [] - for iobj, obj in enumerate(dbobjs): - # call all setup hooks on each object - objparam = objparams[iobj] - # setup - obj._createdict = {"permissions": make_iter(objparam[1]), - "locks": objparam[2], - "aliases": make_iter(objparam[3]), - "nattributes": objparam[4], - "attributes": objparam[5], - "tags": make_iter(objparam[6])} - # this triggers all hooks - obj.save() - # run eventual extra code - for code in objparam[7]: - if code: - exec(code, {}, {"evennia": evennia, "obj": obj}) - objs.append(obj) - return objs - def spawn(*prototypes, **kwargs): """ @@ -472,7 +234,7 @@ def spawn(*prototypes, **kwargs): """ # get available protparents - protparents = get_protparent_dict() + protparents = {prot['prototype_key']: prot for prot in search_prototype()} # overload module's protparents with specifically given protparents protparents.update(kwargs.get("prototype_parents", {})) diff --git a/evennia/prototypes/utils.py b/evennia/prototypes/utils.py index 74eaef169f..6fe87d172c 100644 --- a/evennia/prototypes/utils.py +++ b/evennia/prototypes/utils.py @@ -4,91 +4,13 @@ Prototype utilities """ +_PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") + class PermissionError(RuntimeError): pass - - - -def get_protparent_dict(): - """ - Get prototype parents. - - Returns: - parent_dict (dict): A mapping {prototype_key: prototype} for all available prototypes. - - """ - return {prototype['prototype_key']: prototype for prototype in search_prototype()} - - -def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): - """ - Collate a list of found prototypes based on search criteria and access. - - Args: - caller (Account or Object): The object requesting the list. - key (str, optional): Exact or partial prototype key to query for. - tags (str or list, optional): Tag key or keys to query for. - show_non_use (bool, optional): Show also prototypes the caller may not use. - show_non_edit (bool, optional): Show also prototypes the caller may not edit. - Returns: - table (EvTable or None): An EvTable representation of the prototypes. None - if no prototypes were found. - - """ - # this allows us to pass lists of empty strings - tags = [tag for tag in make_iter(tags) if tag] - - # get prototypes for readonly and db-based prototypes - prototypes = search_prototype(key, tags) - - # get use-permissions of readonly attributes (edit is always False) - display_tuples = [] - for prototype in sorted(prototypes, key=lambda d: d.get('prototype_key', '')): - lock_use = caller.locks.check_lockstring( - caller, prototype.get('prototype_locks', ''), access_type='use') - if not show_non_use and not lock_use: - continue - if prototype.get('prototype_key', '') in _MODULE_PROTOTYPES: - lock_edit = False - else: - lock_edit = caller.locks.check_lockstring( - caller, prototype.get('prototype_locks', ''), access_type='edit') - if not show_non_edit and not lock_edit: - continue - ptags = [] - for ptag in prototype.get('prototype_tags', []): - if is_iter(ptag): - if len(ptag) > 1: - ptags.append("{} (category: {}".format(ptag[0], ptag[1])) - else: - ptags.append(ptag[0]) - else: - ptags.append(str(ptag)) - - display_tuples.append( - (prototype.get('prototype_key', ''), - prototype.get('prototype_desc', ''), - "{}/{}".format('Y' if lock_use else 'N', 'Y' if lock_edit else 'N'), - ",".join(ptags))) - - if not display_tuples: - return None - - table = [] - width = 78 - for i in range(len(display_tuples[0])): - table.append([str(display_tuple[i]) for display_tuple in display_tuples]) - table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=width) - table.reformat_column(0, width=22) - table.reformat_column(1, width=29) - table.reformat_column(2, width=11, align='c') - table.reformat_column(3, width=16) - return table - - def prototype_to_str(prototype): """ Format a prototype to a nice string representation. @@ -111,40 +33,30 @@ def prototype_to_str(prototype): return header + proto -def prototype_from_object(obj): +def prototype_diff_from_object(prototype, obj): """ - Guess a minimal prototype from an existing object. + Get a simple diff for a prototype compared to an object which may or may not already have a + prototype (or has one but changed locally). For more complex migratations a manual diff may be + needed. Args: - obj (Object): An object to analyze. + prototype (dict): Prototype. + obj (Object): Object to Returns: - prototype (dict): A prototype estimating the current state of the object. + diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...} """ - # first, check if this object already has a prototype + prot1 = prototype + prot2 = prototype_from_object(obj) - prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) - prot = search_prototype(prot) - if not prot or len(prot) > 1: - # no unambiguous prototype found - build new prototype - prot = {} - prot['prototype_key'] = "From-Object-{}-{}".format( - obj.key, hashlib.md5(str(time.time())).hexdigest()[:6]) - prot['prototype_desc'] = "Built from {}".format(str(obj)) - prot['prototype_locks'] = "use:all();edit:all()" + diff = {} + for key, value in prot1.items(): + diff[key] = "KEEP" + if key in prot2: + if callable(prot2[key]) or value != prot2[key]: + diff[key] = "UPDATE" + elif key not in prot2: + diff[key] = "REMOVE" - prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6] - prot['location'] = obj.db_location - prot['home'] = obj.db_home - prot['destination'] = obj.db_destination - prot['typeclass'] = obj.db_typeclass_path - prot['locks'] = obj.locks.all() - prot['permissions'] = obj.permissions.get() - prot['aliases'] = obj.aliases.get() - prot['tags'] = [(tag.key, tag.category, tag.data) - for tag in obj.tags.get(return_tagobj=True, return_list=True)] - prot['attrs'] = [(attr.key, attr.value, attr.category, attr.locks) - for attr in obj.attributes.get(return_obj=True, return_list=True)] - - return prot + return diff From 20eda1e88fa1817f4c3905c0500782591d1e5f41 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Wed, 6 Jun 2018 17:14:50 -0700 Subject: [PATCH 317/466] Polish and such --- evennia/contrib/fieldfill.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/contrib/fieldfill.py b/evennia/contrib/fieldfill.py index 071558c88c..312ddc658b 100644 --- a/evennia/contrib/fieldfill.py +++ b/evennia/contrib/fieldfill.py @@ -169,7 +169,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): newvalue = entry[1].strip() # Syntax error of field name is too short or blank - if len(fieldname) < 3: + if len(fieldname) < 1: caller.msg(syntax_err) text = (None, help_text) return text, options From e3b562e2100c60bc0993490dcf5a423927020800 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Wed, 6 Jun 2018 18:05:26 -0700 Subject: [PATCH 318/466] Documentation, min/max actually works on numbers now --- evennia/contrib/fieldfill.py | 174 +++++++++++++++++++++++++++++------ 1 file changed, 145 insertions(+), 29 deletions(-) diff --git a/evennia/contrib/fieldfill.py b/evennia/contrib/fieldfill.py index 312ddc658b..7a41e52025 100644 --- a/evennia/contrib/fieldfill.py +++ b/evennia/contrib/fieldfill.py @@ -1,30 +1,134 @@ """ -Fyield Fyill +Easy fillable form + +Contrib - Tim Ashley Jenkins 2018 + +This module contains a function that calls an easily customizable EvMenu - this +menu presents the player with a fillable form, with fields that can be filled +out in any order. Each field's value can be verified, with the function +allowing easy checks for text and integer input, minimum and maximum values / +character lengths, or can even be verified by a custom function. Once the form +is submitted, the form's data is submitted as a dictionary to any callable of +your choice. + +Form templates are defined as a list of dictionaries - each dictionary +represents a field in the form, and contains the data for the field's name and +behavior. For example, this basic form template will allow a player to fill out +a brief character profile: + + PROFILE_TEMPLATE = [ + {"fieldname":"Name", "fieldtype":"text"}, + {"fieldname":"Age", "fieldtype":"number"}, + {"fieldname":"History", "fieldtype":"text"} + ] + +This will present the player with an EvMenu showing this basic form: + + Name: + Age: + History: + +While in this menu, the player can assign a new value to any field with the +syntax = , like so: + + > name = Ashley + Field 'Name' set to: Ashley + +Typing 'show' by itself will show the form and its current values. + + > show + + Name: Ashley + Age: + History: + +Number fields require an integer input, and will reject any text that can't +be converted into an integer. + + > age = youthful + Field 'Age' requires a number. + > age = 31 + Field 'Age' set to: 31 + +Form data is presented as an EvTable, so text of any length will wrap cleanly. + + > history = EVERY MORNING I WAKE UP AND OPEN PALM SLAM[...] + Field 'History' set to: EVERY MORNING I WAKE UP AND[...] + > show + + Name: Ashley + Age: 31 + History: EVERY MORNING I WAKE UP AND OPEN PALM SLAM A VHS INTO THE SLOT. + IT'S CHRONICLES OF RIDDICK AND RIGHT THEN AND THERE I START DOING + THE MOVES ALONGSIDE WITH THE MAIN CHARACTER, RIDDICK. I DO EVERY + MOVE AND I DO EVERY MOVE HARD. + +When the player types 'submit' (or your specified submit command), the menu +quits and the form's data is passed to your specified function as a dictionary, +like so: + + formdata = {"Name":"Ashley", "Age":31, "History":"EVERY MORNING I[...]"} + +You can do whatever you like with this data in your function - forms can be used +to set data on a character, to help builders create objects, or for players to +craft items or perform other complicated actions with many variables involved. + +The data that your form will accept can also be specified in your form template - +let's say, for example, that you won't accept ages under 18 or over 100. You can +do this by specifying "min" and "max" values in your field's dictionary: + + PROFILE_TEMPLATE = [ + {"fieldname":"Name", "fieldtype":"text"}, + {"fieldname":"Age", "fieldtype":"number", "min":18, "max":100}, + {"fieldname":"History", "fieldtype":"text"} + ] + +Now if the player tries to enter a value out of range, the form will not acept the +given value. + + > age = 10 + Field 'Age' reqiures a minimum value of 18. + > age = 900 + Field 'Age' has a maximum value of 100. + +Setting 'min' and 'max' for a text field will instead act as a minimum or +maximum character length for the player's input. + +There are lots of ways to present the form to the player - fields can have default +values or show a custom message in place of a blank value, and player input can be +verified by a custom function, allowing for a great deal of flexibility. + +This module contains a simple example form that demonstrates all of the included +functionality - a command that allows a player to compose a message to another +online character and have it send after a custom delay. You can test it by +importing this module in your game's default_cmdsets.py module and adding +CmdTestMenu to your default character's command set. + +FIELD TEMPLATE KEYS: +Required: + fieldname (str): Name of the field, as presented to the player + fieldtype (str):Type of value required, either 'text' or 'number' + +Optional: + max (int): Maximum character length (if text) or value (if number) + min (int): Minimum charater length (if text) or value (if number) + default (str): Initial value (blank if not given) + blankmsg (str): Message to show in place of value when field is blank + cantclear (bool): Field can't be cleared if True + required (bool): If True, form cannot be submitted while field is blank + verifyfunc (callable): Name of a callable used to verify input - takes + (caller, value) as arguments. If the function returns True, + the player's input is considered valid - if it returns False, + the input is rejected. Any other value returned will act as + the field's new value, replacing the player's input. This + allows for values that aren't strings or integers (such as + object dbrefs). """ from evennia.utils import evmenu, evtable, delay, list_to_string from evennia import Command from evennia.server.sessionhandler import SESSIONS -""" -Complete field data is sent to the given callable as a dictionary (field:value pairs) - -FORM LIST/DICTIONARY VALUES: -Required: - fieldname - Name of the field as presented to the player - fieldtype - Type of field, either 'text' or 'number' - -Optional: - max - Maximum character length (if text) or value (if number) - min - Minimum charater length (if text) or value (if number) - default - Initial value (blank if not given) - blankmsg - Message to show when field is blank - cantclear - Field can't be cleared if True - required - If True, form cannot be submitted while field is blank - verifyfunc - Name of a callable used to verify input -""" - - class FieldEvMenu(evmenu.EvMenu): """ Custom EvMenu type with its own node formatter - removes extraneous lines @@ -47,7 +151,8 @@ class FieldEvMenu(evmenu.EvMenu): return nodetext -def init_fill_field(formtemplate, caller, callback, pretext="", posttext="", submitcmd="submit", borderstyle="cells"): +def init_fill_field(formtemplate, caller, callback, pretext="", posttext="", + submitcmd="submit", borderstyle="cells"): """ Presents a player with a fillable form. """ @@ -86,14 +191,14 @@ def menunode_fieldfill(caller, raw_string, **kwargs): # Syntax error syntax_err = "Syntax: = |/Or: clear , help, show, quit|/'%s' to submit form" % submitcmd - # Set help text, including listing the 'submit' command - help_text = """Available commands: -|w = :|n Set given field to new value, replacing the old value -|wclear :|n Clear the value in the given field, making it blank -|wshow|n: Show the form's current values -|whelp|n: Display this help screen -|wquit|n: Quit the form menu without submitting -|w%s|n: Submit this form and quit the menu""" % submitcmd + # Set help text, including listing the given 'submit' command + help_text = ("Available commands:|/" + "|w = :|n Set given field to new value, replacing the old value|/" + "|wclear :|n Clear the value in the given field, making it blank|/" + "|wshow|n: Show the form's current values|/" + "|whelp|n: Display this help screen|/" + "|wquit|n: Quit the form menu without submitting|/" + "|w%s|n: Submit this form and quit the menu" % submitcmd) # Display current form data text = (display_formdata(formtemplate, formdata, pretext=pretext, @@ -225,6 +330,17 @@ def menunode_fieldfill(caller, raw_string, **kwargs): caller.msg("Field '%s' requires a number." % matched_field) text = (None, help_text) return text, options + # Test for max/min + if max_value != None: + if newvalue > max_value: + caller.msg("Field '%s' has a maximum value of %i." % (matched_field, max_value)) + text = (None, help_text) + return text, options + if min_value != None: + if newvalue < min_value: + caller.msg("Field '%s' reqiures a minimum value of %i." % (matched_field, min_value)) + text = (None, help_text) + return text, options # Call verify function if present if verifyfunc: From 07403352af36adcd208cd87376863d2f22cc6a5f Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Thu, 7 Jun 2018 00:13:35 -0700 Subject: [PATCH 319/466] Finished documentation --- evennia/contrib/fieldfill.py | 180 ++++++++++++++++++++++++----------- 1 file changed, 124 insertions(+), 56 deletions(-) diff --git a/evennia/contrib/fieldfill.py b/evennia/contrib/fieldfill.py index 7a41e52025..e88dc94139 100644 --- a/evennia/contrib/fieldfill.py +++ b/evennia/contrib/fieldfill.py @@ -152,13 +152,37 @@ class FieldEvMenu(evmenu.EvMenu): def init_fill_field(formtemplate, caller, callback, pretext="", posttext="", - submitcmd="submit", borderstyle="cells"): + submitcmd="submit", borderstyle="cells", helptext=None): """ - Presents a player with a fillable form. + Initializes a menu presenting a player with a fillable form - once the form + is submitted, the data will be passed as a dictionary to your chosen + function. + + Args: + formtemplate (list of dicts): The template for the form's fields + caller (obj): Player who will be filling out the form + callback (callable): Function to pass the completed form's data to + + Options: + pretext (str): Text to put before the form in the menu + posttext (str): Text to put after the form in the menu + submitcmd (str): Command used to submit the form + borderstyle (str): Form's EvTable border style + helptext (str): Help text for the form menu (or default is provided) """ # Initialize form data from the template blank_formdata = form_template_to_dict(formtemplate) + # Provide default help text if none given + if helptext == None: + helptext = ("Available commands:|/" + "|w = :|n Set given field to new value, replacing the old value|/" + "|wclear :|n Clear the value in the given field, making it blank|/" + "|wshow|n: Show the form's current values|/" + "|whelp|n: Display this help screen|/" + "|wquit|n: Quit the form menu without submitting|/" + "|w%s|n: Submit this form and quit the menu" % submitcmd) + # Pass kwargs to store data needed in the menu kwargs = { "formdata":blank_formdata, @@ -168,6 +192,7 @@ def init_fill_field(formtemplate, caller, callback, pretext="", posttext="", "posttext": posttext, "submitcmd": submitcmd, "borderstyle": borderstyle + "helptext": helptext } # Initialize menu of selections @@ -176,7 +201,9 @@ def init_fill_field(formtemplate, caller, callback, pretext="", posttext="", def menunode_fieldfill(caller, raw_string, **kwargs): """ - Repeating node to fill a menu field + This is an EvMenu node, which calls itself over and over in order to + allow a player to enter values into a fillable form. When the form is + submitted, the form data is passed to a callback as a dictionary. """ # Retrieve menu info @@ -187,22 +214,14 @@ def menunode_fieldfill(caller, raw_string, **kwargs): posttext = caller.ndb._menutree.posttext submitcmd = caller.ndb._menutree.submitcmd borderstyle = caller.ndb._menutree.borderstyle + helptext = caller.ndb._menutree.helptext # Syntax error syntax_err = "Syntax: = |/Or: clear , help, show, quit|/'%s' to submit form" % submitcmd - # Set help text, including listing the given 'submit' command - help_text = ("Available commands:|/" - "|w = :|n Set given field to new value, replacing the old value|/" - "|wclear :|n Clear the value in the given field, making it blank|/" - "|wshow|n: Show the form's current values|/" - "|whelp|n: Display this help screen|/" - "|wquit|n: Quit the form menu without submitting|/" - "|w%s|n: Submit this form and quit the menu" % submitcmd) - # Display current form data text = (display_formdata(formtemplate, formdata, pretext=pretext, - posttext=posttext, borderstyle=borderstyle), help_text) + posttext=posttext, borderstyle=borderstyle), helptext) options = ({"key": "_default", "goto":"menunode_fieldfill"}) @@ -219,7 +238,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): blank_and_required.append(field["fieldname"]) if len(blank_and_required) > 0: caller.msg("The following blank fields require a value: %s" % list_to_string(blank_and_required)) - text = (None, help_text) + text = (None, helptext) return text, options # If everything checks out, pass form data to the callback and end the menu! @@ -233,7 +252,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): # Test for 'clear' command cleartest = raw_string.lower().strip().split(" ", 1) if cleartest[0].lower() == "clear": - text = (None, help_text) + text = (None, helptext) if len(cleartest) < 2: caller.msg(syntax_err) return text, options @@ -245,7 +264,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): if not matched_field: caller.msg("Field '%s' does not exist!" % cleartest[1]) - text = (None, help_text) + text = (None, helptext) return text, options # Test to see if field can be cleared @@ -253,9 +272,8 @@ def menunode_fieldfill(caller, raw_string, **kwargs): if field["fieldname"] == matched_field and "cantclear" in field.keys(): if field["cantclear"] == True: caller.msg("Field '%s' can't be cleared!" % matched_field) - text = (None, help_text) + text = (None, helptext) return text, options - # Clear the field formdata.update({matched_field:None}) @@ -264,7 +282,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): return text, options if "=" not in raw_string: - text = (None, help_text) + text = (None, helptext) caller.msg(syntax_err) return text, options @@ -276,7 +294,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): # Syntax error of field name is too short or blank if len(fieldname) < 1: caller.msg(syntax_err) - text = (None, help_text) + text = (None, helptext) return text, options # Attempt to match field name to field in form data @@ -288,7 +306,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): # No matched field if matched_field == None: caller.msg("Field '%s' does not exist!" % fieldname) - text = (None, help_text) + text = (None, helptext) return text, options # Set new field value if match @@ -306,7 +324,6 @@ def menunode_fieldfill(caller, raw_string, **kwargs): min_value = field["min"] if "verifyfunc" in field.keys(): verifyfunc = field["verifyfunc"] - # Field type text update if fieldtype == "text": @@ -314,38 +331,39 @@ def menunode_fieldfill(caller, raw_string, **kwargs): if max_value != None: if len(newvalue) > max_value: caller.msg("Field '%s' has a maximum length of %i characters." % (matched_field, max_value)) - text = (None, help_text) + text = (None, helptext) return text, options if min_value != None: if len(newvalue) < min_value: caller.msg("Field '%s' reqiures a minimum length of %i characters." % (matched_field, min_value)) - text = (None, help_text) + text = (None, helptext) return text, options - + # Field type number update if fieldtype == "number": try: newvalue = int(newvalue) except: caller.msg("Field '%s' requires a number." % matched_field) - text = (None, help_text) + text = (None, helptext) return text, options # Test for max/min if max_value != None: if newvalue > max_value: caller.msg("Field '%s' has a maximum value of %i." % (matched_field, max_value)) - text = (None, help_text) + text = (None, helptext) return text, options if min_value != None: if newvalue < min_value: caller.msg("Field '%s' reqiures a minimum value of %i." % (matched_field, min_value)) - text = (None, help_text) + text = (None, helptext) return text, options - + # Call verify function if present if verifyfunc: if verifyfunc(caller, newvalue) == False: - text = (None, help_text) + # No error message is given - should be provided by verifyfunc + text = (None, helptext) return text, options elif verifyfunc(caller, newvalue) != True: newvalue = verifyfunc(caller, newvalue) @@ -354,29 +372,46 @@ def menunode_fieldfill(caller, raw_string, **kwargs): formdata.update({matched_field:newvalue}) caller.ndb._menutree.formdata = formdata caller.msg("Field '%s' set to: %s" % (matched_field, str(newvalue))) - text = (None, help_text) + text = (None, helptext) return text, options - def form_template_to_dict(formtemplate): """ - Returns dictionary of field name:value pairs from form template + Initializes a dictionary of form data from the given list-of-dictionaries + form template, as formatted above. + + Args: + formtemplate (list of dicts): Tempate for the form to be initialized + + Returns: + formdata (dict): Dictionary of initalized form data """ - formdict = {} + formdata = {} for field in formtemplate: + # Value is blank by default fieldvalue = None if "default" in field: + # Add in default value if present fieldvalue = field["default"] - formdict.update({field["fieldname"]:fieldvalue}) + formdata.update({field["fieldname"]:fieldvalue}) - return formdict + return formdata def display_formdata(formtemplate, formdata, pretext="", posttext="", borderstyle="cells"): """ - Displays a form's current data as a table + Displays a form's current data as a table. Used in the form menu. + + Args: + formtemplate (list of dicts): Template for the form + formdata (dict): Form's current data + + Options: + pretext (str): Text to put before the form table + posttext (str): Text to put after the form table + borderstyle (str): EvTable's border style """ formtable = evtable.EvTable(border=borderstyle, valign="t", maxwidth=80) @@ -404,13 +439,26 @@ def display_formdata(formtemplate, formdata, # formtable.reformat_column(1, pad_left=0) return pretext + "|/" + str(formtable) + "|/" + posttext - - - - -# PLACEHOLDER / EXAMPLE STUFF STARTS HEEEERE + +""" +EXAMPLE FUNCTIONS / COMMAND STARTS HERE +""" def verify_online_player(caller, value): + """ + Example 'verify function' that matches player input to an online character + or else rejects their input as invalid. + + Args: + caller (obj): Player entering the form data + value (str): String player entered into the form, to be verified + + Returns: + matched_character (obj or False): dbref to a currently logged in + character object - reference to the object will be stored in + the form instead of a string. Returns False if no match is + made. + """ # Get a list of sessions session_list = SESSIONS.get_sessions() char_list = [] @@ -441,33 +489,36 @@ def verify_online_player(caller, value): # You can store data besides strings and integers in the 'formdata' dictionary this way! return matched_character +# Form template for the example 'delayed message' form SAMPLE_FORM = [ {"fieldname":"Character", "fieldtype":"text", "max":30, "blankmsg":"(Name of an online player)", "required":True, "verifyfunc":verify_online_player}, {"fieldname":"Delay", "fieldtype":"number", "min":3, "max":30, "default":10, "cantclear":True}, {"fieldname":"Message", "fieldtype":"text", "min":3, "max":200, "blankmsg":"(Message up to 200 characters)"} ] - -class CmdTest(Command): - """ - Test stuff - """ - - key = "test" - - def func(self): - SAMPLE_FORM_DATA = form_template_to_dict(SAMPLE_FORM) - self.caller.msg(display_formdata(SAMPLE_FORM, SAMPLE_FORM_DATA)) class CmdTestMenu(Command): """ - Test stuff + This test command will initialize a menu that presents you with a form. + You can fill out the fields of this form in any order, and then type in + 'send' to send a message to another online player, which will reach them + after a delay you specify. + + Usage: + = + clear + help + show + quit + send """ key = "testmenu" def func(self): - + """ + This performs the actual command. + """ pretext = "|cSend a delayed message to another player ---------------------------------------|n" posttext = ("|c--------------------------------------------------------------------------------|n|/" "Syntax: type |c = |n to change the values of the form. Given|/" @@ -479,15 +530,32 @@ class CmdTestMenu(Command): submitcmd="send", borderstyle="none") def sendmessage(obj, text): + """ + Callback to send a message to a player. + + Args: + obj (obj): Player to message + text (str): Message + """ obj.msg(text) def init_delayed_message(caller, formdata): + """ + Initializes a delayed message, using data from the example form. + + Args: + caller (obj): Character submitting the message + formdata (dict): Data from submitted form + """ + # Retrieve data from the filled out form. + # We stored the character to message as an object ref using a verifyfunc + # So we don't have to do any more searching or matching here! player_to_message = formdata["Character"] message_delay = formdata["Delay"] message = ("Message from %s: " % caller) + formdata["Message"] caller.msg("Message sent to %s!" % player_to_message) + # Make a deferred call to 'sendmessage' above. deferred = delay(message_delay, sendmessage, player_to_message, message) - return From 55f8e58c43fd4d383c7ec1b624cbd6fa59c382ef Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 7 Jun 2018 22:40:03 +0200 Subject: [PATCH 320/466] Finish refactor prototypes/spawner/menus --- evennia/prototypes/menus.py | 45 ++-- evennia/prototypes/prototypes.py | 346 ++++++++++--------------------- evennia/prototypes/spawner.py | 335 +++++++++++++++++++++++------- evennia/prototypes/utils.py | 62 ------ 4 files changed, 399 insertions(+), 389 deletions(-) delete mode 100644 evennia/prototypes/utils.py diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 85e7f3f574..bebc6d00bd 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -4,8 +4,13 @@ OLC Prototype menu nodes """ +from ast import literal_eval +from django.conf import settings from evennia.utils.evmenu import EvMenu, list_node from evennia.utils.ansi import strip_ansi +from evennia.utils import utils +from evennia.utils.prototypes import prototypes as protlib +from evennia.utils.prototypes import spawner # ------------------------------------------------------------ # @@ -13,6 +18,13 @@ from evennia.utils.ansi import strip_ansi # # ------------------------------------------------------------ +_MENU_CROP_WIDTH = 15 +_MENU_ATTR_LITERAL_EVAL_ERROR = ( + "|rCritical Python syntax error in your value. Only primitive Python structures are allowed.\n" + "You also need to use correct Python syntax. Remember especially to put quotes around all " + "strings inside lists and dicts.|n") + + # Helper functions @@ -48,11 +60,11 @@ def _format_property(prop, required=False, prototype=None, cropper=None): out = "<{}>".format(prop.__name__) else: out = repr(prop) - if is_iter(prop): + if utils.is_iter(prop): out = ", ".join(str(pr) for pr in prop) if not out and required: out = "|rrequired" - return " ({}|n)".format(cropper(out) if cropper else crop(out, _MENU_CROP_WIDTH)) + return " ({}|n)".format(cropper(out) if cropper else utils.crop(out, _MENU_CROP_WIDTH)) def _set_property(caller, raw_string, **kwargs): @@ -166,7 +178,8 @@ def node_index(caller): required = False for key in ('Desc', 'Tags', 'Locks'): options.append( - {"desc": "|WPrototype-{}|n|n{}".format(key, _format_property(key, required, prototype, None)), + {"desc": "|WPrototype-{}|n|n{}".format( + key, _format_property(key, required, prototype, None)), "goto": "node_prototype_{}".format(key.lower())}) return text, options @@ -175,11 +188,11 @@ def node_index(caller): def node_validate_prototype(caller, raw_string, **kwargs): prototype = _get_menu_prototype(caller) - txt = prototype_to_str(prototype) + txt = protlib.prototype_to_str(prototype) errors = "\n\n|g No validation errors found.|n (but errors could still happen at spawn-time)" try: # validate, don't spawn - spawn(prototype, return_prototypes=True) + spawner.spawn(prototype, return_prototypes=True) except RuntimeError as err: errors = "\n\n|rError: {}|n".format(err) text = (txt + errors) @@ -190,7 +203,7 @@ def node_validate_prototype(caller, raw_string, **kwargs): def _check_prototype_key(caller, key): - old_prototype = search_prototype(key) + old_prototype = protlib.search_prototype(key) olc_new = _is_new_prototype(caller) key = key.strip().lower() if old_prototype: @@ -231,13 +244,13 @@ def node_prototype_key(caller): def _all_prototypes(caller): return [prototype["prototype_key"] - for prototype in search_prototype() if "prototype_key" in prototype] + for prototype in protlib.search_prototype() if "prototype_key" in prototype] def _prototype_examine(caller, prototype_name): - prototypes = search_prototype(key=prototype_name) + prototypes = protlib.search_prototype(key=prototype_name) if prototypes: - caller.msg(prototype_to_str(prototypes[0])) + caller.msg(protlib.prototype_to_str(prototypes[0])) caller.msg("Prototype not registered.") return None @@ -256,9 +269,10 @@ def node_prototype(caller): text = ["Set the prototype's |yParent Prototype|n. If this is unset, Typeclass will be used."] if prot_parent_key: - prot_parent = search_prototype(prot_parent_key) + prot_parent = protlib.search_prototype(prot_parent_key) if prot_parent: - text.append("Current parent prototype is {}:\n{}".format(prototype_to_str(prot_parent))) + text.append( + "Current parent prototype is {}:\n{}".format(protlib.prototype_to_str(prot_parent))) else: text.append("Current parent prototype |r{prototype}|n " "does not appear to exist.".format(prot_parent_key)) @@ -273,7 +287,7 @@ def node_prototype(caller): def _all_typeclasses(caller): - return list(sorted(get_all_typeclasses().keys())) + return list(sorted(utils.get_all_typeclasses().keys())) def _typeclass_examine(caller, typeclass_path): @@ -281,7 +295,7 @@ def _typeclass_examine(caller, typeclass_path): # this means we are exiting the listing return "node_key" - typeclass = get_all_typeclasses().get(typeclass_path) + typeclass = utils.get_all_typeclasses().get(typeclass_path) if typeclass: docstr = [] for line in typeclass.__doc__.split("\n"): @@ -453,8 +467,8 @@ def _add_tag(caller, tag, **kwargs): tags.append(tag) else: tags = [tag] - prot['tags'] = tags - _set_menu_prototype(caller, "prototype", prot) + prototype['tags'] = tags + _set_menu_prototype(caller, "prototype", prototype) text = kwargs.get("text") if not text: text = "Added tag {}. (return to continue)".format(tag) @@ -706,4 +720,3 @@ def start_olc(caller, session=None, prototype=None): "node_prototype_locks": node_prototype_locks, } OLCMenu(caller, menudata, startnode='node_index', session=session, olc_prototype=prototype) - diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index e3d26fd87e..37fd83f846 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -6,16 +6,26 @@ Handling storage of prototypes, both database-based ones (DBPrototypes) and thos """ from django.conf import settings + from evennia.scripts.scripts import DefaultScript from evennia.objects.models import ObjectDB from evennia.utils.create import create_script -from evennia.utils.utils import all_from_module, make_iter, callables_from_module, is_iter +from evennia.utils.utils import ( + all_from_module, make_iter, is_iter, dbid_to_obj) from evennia.locks.lockhandler import validate_lockstring, check_lockstring from evennia.utils import logger +from evennia.utils.evtable import EvTable +from evennia.utils.prototypes.protfuncs import protfunc_parser _MODULE_PROTOTYPE_MODULES = {} _MODULE_PROTOTYPES = {} +_PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") +_PROTOTYPE_TAG_CATEGORY = "spawned_by_prototype" + + +class PermissionError(RuntimeError): + pass class ValidationError(RuntimeError): @@ -25,6 +35,99 @@ class ValidationError(RuntimeError): pass +# helper functions + +def value_to_obj(value, force=True): + return dbid_to_obj(value, ObjectDB) + + +def value_to_obj_or_any(value): + obj = dbid_to_obj(value, ObjectDB) + return obj if obj is not None else value + + +def prototype_to_str(prototype): + """ + Format a prototype to a nice string representation. + + Args: + prototype (dict): The prototype. + """ + + header = ( + "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" + "|cdesc:|n {} \n|cprototype:|n ".format( + prototype['prototype_key'], + ", ".join(prototype['prototype_tags']), + prototype['prototype_locks'], + prototype['prototype_desc'])) + proto = ("{{\n {} \n}}".format( + "\n ".join( + "{!r}: {!r},".format(key, value) for key, value in + sorted(prototype.items()) if key not in _PROTOTYPE_META_NAMES)).rstrip(",")) + return header + proto + + +def check_permission(prototype_key, action, default=True): + """ + Helper function to check access to actions on given prototype. + + Args: + prototype_key (str): The prototype to affect. + action (str): One of "spawn" or "edit". + default (str): If action is unknown or prototype has no locks + + Returns: + passes (bool): If permission for action is granted or not. + + """ + if action == 'edit': + if prototype_key in _MODULE_PROTOTYPES: + mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key, "N/A") + logger.log_err("{} is a read-only prototype " + "(defined as code in {}).".format(prototype_key, mod)) + return False + + prototype = search_prototype(key=prototype_key) + if not prototype: + logger.log_err("Prototype {} not found.".format(prototype_key)) + return False + + lockstring = prototype.get("prototype_locks") + + if lockstring: + return check_lockstring(None, lockstring, default=default, access_type=action) + return default + + +def init_spawn_value(value, validator=None): + """ + Analyze the prototype value and produce a value useful at the point of spawning. + + Args: + value (any): This can be: + callable - will be called as callable() + (callable, (args,)) - will be called as callable(*args) + other - will be assigned depending on the variable type + validator (callable, optional): If given, this will be called with the value to + check and guarantee the outcome is of a given type. + + Returns: + any (any): The (potentially pre-processed value to use for this prototype key) + + """ + value = protfunc_parser(value) + validator = validator if validator else lambda o: o + if callable(value): + return validator(value()) + elif value and is_iter(value) and callable(value[0]): + # a structure (callable, (args, )) + args = value[1:] + return validator(value[0](*make_iter(args))) + else: + return validator(value) + + # module-based prototypes for mod in settings.PROTOTYPE_MODULES: @@ -59,39 +162,7 @@ class DbPrototype(DefaultScript): self.db.prototype = {} # actual prototype -# General prototype functions - - -def check_permission(prototype_key, action, default=True): - """ - Helper function to check access to actions on given prototype. - - Args: - prototype_key (str): The prototype to affect. - action (str): One of "spawn" or "edit". - default (str): If action is unknown or prototype has no locks - - Returns: - passes (bool): If permission for action is granted or not. - - """ - if action == 'edit': - if prototype_key in _MODULE_PROTOTYPES: - mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key, "N/A") - logger.log_err("{} is a read-only prototype " - "(defined as code in {}).".format(prototype_key, mod)) - return False - - prototype = search_prototype(key=prototype_key) - if not prototype: - logger.log_err("Prototype {} not found.".format(prototype_key)) - return False - - lockstring = prototype.get("prototype_locks") - - if lockstring: - return check_lockstring(None, lockstring, default=default, access_type=action) - return default +# Prototype manager functions def create_prototype(**kwargs): @@ -281,45 +352,6 @@ def search_objects_with_prototype(prototype_key): return ObjectDB.objects.get_by_tag(key=prototype_key, category=_PROTOTYPE_TAG_CATEGORY) -def prototype_from_object(obj): - """ - Guess a minimal prototype from an existing object. - - Args: - obj (Object): An object to analyze. - - Returns: - prototype (dict): A prototype estimating the current state of the object. - - """ - # first, check if this object already has a prototype - - prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) - prot = search_prototype(prot) - if not prot or len(prot) > 1: - # no unambiguous prototype found - build new prototype - prot = {} - prot['prototype_key'] = "From-Object-{}-{}".format( - obj.key, hashlib.md5(str(time.time())).hexdigest()[:6]) - prot['prototype_desc'] = "Built from {}".format(str(obj)) - prot['prototype_locks'] = "spawn:all();edit:all()" - - prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6] - prot['location'] = obj.db_location - prot['home'] = obj.db_home - prot['destination'] = obj.db_destination - prot['typeclass'] = obj.db_typeclass_path - prot['locks'] = obj.locks.all() - prot['permissions'] = obj.permissions.get() - prot['aliases'] = obj.aliases.get() - prot['tags'] = [(tag.key, tag.category, tag.data) - for tag in obj.tags.get(return_tagobj=True, return_list=True)] - prot['attrs'] = [(attr.key, attr.value, attr.category, attr.locks) - for attr in obj.attributes.get(return_obj=True, return_list=True)] - - return prot - - def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): """ Collate a list of found prototypes based on search criteria and access. @@ -384,171 +416,3 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed table.reformat_column(2, width=11, align='c') table.reformat_column(3, width=16) return table - - - -def batch_update_objects_with_prototype(prototype, diff=None, objects=None): - """ - Update existing objects with the latest version of the prototype. - - Args: - prototype (str or dict): Either the `prototype_key` to use or the - prototype dict itself. - diff (dict, optional): This a diff structure that describes how to update the protototype. - If not given this will be constructed from the first object found. - objects (list, optional): List of objects to update. If not given, query for these - objects using the prototype's `prototype_key`. - Returns: - changed (int): The number of objects that had changes applied to them. - - """ - prototype_key = prototype if isinstance(prototype, basestring) else prototype['prototype_key'] - prototype_obj = search_db_prototype(prototype_key, return_queryset=True) - prototype_obj = prototype_obj[0] if prototype_obj else None - new_prototype = prototype_obj.db.prototype - objs = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) - - if not objs: - return 0 - - if not diff: - diff = prototype_diff_from_object(new_prototype, objs[0]) - - changed = 0 - for obj in objs: - do_save = False - for key, directive in diff.items(): - val = new_prototype[key] - if directive in ('UPDATE', 'REPLACE'): - do_save = True - if key == 'key': - obj.db_key = validate_spawn_value(val, str) - elif key == 'typeclass': - obj.db_typeclass_path = validate_spawn_value(val, str) - elif key == 'location': - obj.db_location = validate_spawn_value(val, _to_obj) - elif key == 'home': - obj.db_home = validate_spawn_value(val, _to_obj) - elif key == 'destination': - obj.db_destination = validate_spawn_value(val, _to_obj) - elif key == 'locks': - if directive == 'REPLACE': - obj.locks.clear() - obj.locks.add(validate_spawn_value(val, str)) - elif key == 'permissions': - if directive == 'REPLACE': - obj.permissions.clear() - obj.permissions.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'aliases': - if directive == 'REPLACE': - obj.aliases.clear() - obj.aliases.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'tags': - if directive == 'REPLACE': - obj.tags.clear() - obj.tags.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'attrs': - if directive == 'REPLACE': - obj.attributes.clear() - obj.attributes.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'exec': - # we don't auto-rerun exec statements, it would be huge security risk! - pass - else: - obj.attributes.add(key, validate_spawn_value(val, _to_obj)) - elif directive == 'REMOVE': - do_save = True - if key == 'key': - obj.db_key = '' - elif key == 'typeclass': - # fall back to default - obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS - elif key == 'location': - obj.db_location = None - elif key == 'home': - obj.db_home = None - elif key == 'destination': - obj.db_destination = None - elif key == 'locks': - obj.locks.clear() - elif key == 'permissions': - obj.permissions.clear() - elif key == 'aliases': - obj.aliases.clear() - elif key == 'tags': - obj.tags.clear() - elif key == 'attrs': - obj.attributes.clear() - elif key == 'exec': - # we don't auto-rerun exec statements, it would be huge security risk! - pass - else: - obj.attributes.remove(key) - if do_save: - changed += 1 - obj.save() - - return changed - -def batch_create_object(*objparams): - """ - This is a cut-down version of the create_object() function, - optimized for speed. It does NOT check and convert various input - so make sure the spawned Typeclass works before using this! - - Args: - objsparams (tuple): Each paremter tuple will create one object instance using the parameters within. - The parameters should be given in the following order: - - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`. - - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`. - - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`. - - `aliases` (list): A list of alias strings for - adding with `new_object.aliases.batch_add(*aliases)`. - - `nattributes` (list): list of tuples `(key, value)` to be loop-added to - add with `new_obj.nattributes.add(*tuple)`. - - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for - adding with `new_obj.attributes.batch_add(*attributes)`. - - `tags` (list): list of tuples `(key, category)` for adding - with `new_obj.tags.batch_add(*tags)`. - - `execs` (list): Code strings to execute together with the creation - of each object. They will be executed with `evennia` and `obj` - (the newly created object) available in the namespace. Execution - will happend after all other properties have been assigned and - is intended for calling custom handlers etc. - - Returns: - objects (list): A list of created objects - - Notes: - The `exec` list will execute arbitrary python code so don't allow this to be available to - unprivileged users! - - """ - - # bulk create all objects in one go - - # unfortunately this doesn't work since bulk_create doesn't creates pks; - # the result would be duplicate objects at the next stage, so we comment - # it out for now: - # dbobjs = _ObjectDB.objects.bulk_create(dbobjs) - - dbobjs = [ObjectDB(**objparam[0]) for objparam in objparams] - objs = [] - for iobj, obj in enumerate(dbobjs): - # call all setup hooks on each object - objparam = objparams[iobj] - # setup - obj._createdict = {"permissions": make_iter(objparam[1]), - "locks": objparam[2], - "aliases": make_iter(objparam[3]), - "nattributes": objparam[4], - "attributes": objparam[5], - "tags": make_iter(objparam[6])} - # this triggers all hooks - obj.save() - # run eventual extra code - for code in objparam[7]: - if code: - exec(code, {}, {"evennia": evennia, "obj": obj}) - objs.append(obj) - return objs diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 15ef8afb4d..995cea6e52 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -126,70 +126,25 @@ from __future__ import print_function import copy import hashlib import time -from ast import literal_eval + from django.conf import settings -from random import randint import evennia +from random import randint from evennia.objects.models import ObjectDB from evennia.utils.utils import ( make_iter, dbid_to_obj, - is_iter, crop, get_all_typeclasses) - -from evennia.utils.evtable import EvTable + is_iter, get_all_typeclasses) +from evennia.prototypes import prototypes as protlib +from evennia.prototypes.prototypes import value_to_obj, value_to_obj_or_any, init_spawn_value _CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") _PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") _NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES -_MENU_CROP_WIDTH = 15 _PROTOTYPE_TAG_CATEGORY = "spawned_by_prototype" -_MENU_ATTR_LITERAL_EVAL_ERROR = ( - "|rCritical Python syntax error in your value. Only primitive Python structures are allowed.\n" - "You also need to use correct Python syntax. Remember especially to put quotes around all " - "strings inside lists and dicts.|n") - - -# Helper functions - -def _to_obj(value, force=True): - return dbid_to_obj(value, ObjectDB) - - -def _to_obj_or_any(value): - obj = dbid_to_obj(value, ObjectDB) - return obj if obj is not None else value - - -def validate_spawn_value(value, validator=None): - """ - Analyze the value and produce a value for use at the point of spawning. - - Args: - value (any): This can be: - callable - will be called as callable() - (callable, (args,)) - will be called as callable(*args) - other - will be assigned depending on the variable type - validator (callable, optional): If given, this will be called with the value to - check and guarantee the outcome is of a given type. - - Returns: - any (any): The (potentially pre-processed value to use for this prototype key) - - """ - value = protfunc_parser(value) - validator = validator if validator else lambda o: o - if callable(value): - return validator(value()) - elif value and is_iter(value) and callable(value[0]): - # a structure (callable, (args, )) - args = value[1:] - return validator(value[0](*make_iter(args))) - else: - return validator(value) - -# Spawner mechanism +# Helper def _get_prototype(dic, prot, protparents): """ @@ -209,6 +164,246 @@ def _get_prototype(dic, prot, protparents): return prot +# obj-related prototype functions + +def prototype_from_object(obj): + """ + Guess a minimal prototype from an existing object. + + Args: + obj (Object): An object to analyze. + + Returns: + prototype (dict): A prototype estimating the current state of the object. + + """ + # first, check if this object already has a prototype + + prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) + prot = protlib.search_prototype(prot) + if not prot or len(prot) > 1: + # no unambiguous prototype found - build new prototype + prot = {} + prot['prototype_key'] = "From-Object-{}-{}".format( + obj.key, hashlib.md5(str(time.time())).hexdigest()[:6]) + prot['prototype_desc'] = "Built from {}".format(str(obj)) + prot['prototype_locks'] = "spawn:all();edit:all()" + + prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6] + prot['location'] = obj.db_location + prot['home'] = obj.db_home + prot['destination'] = obj.db_destination + prot['typeclass'] = obj.db_typeclass_path + prot['locks'] = obj.locks.all() + prot['permissions'] = obj.permissions.get() + prot['aliases'] = obj.aliases.get() + prot['tags'] = [(tag.key, tag.category, tag.data) + for tag in obj.tags.get(return_tagobj=True, return_list=True)] + prot['attrs'] = [(attr.key, attr.value, attr.category, attr.locks) + for attr in obj.attributes.get(return_obj=True, return_list=True)] + + return prot + + +def prototype_diff_from_object(prototype, obj): + """ + Get a simple diff for a prototype compared to an object which may or may not already have a + prototype (or has one but changed locally). For more complex migratations a manual diff may be + needed. + + Args: + prototype (dict): Prototype. + obj (Object): Object to + + Returns: + diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...} + + """ + prot1 = prototype + prot2 = prototype_from_object(obj) + + diff = {} + for key, value in prot1.items(): + diff[key] = "KEEP" + if key in prot2: + if callable(prot2[key]) or value != prot2[key]: + diff[key] = "UPDATE" + elif key not in prot2: + diff[key] = "REMOVE" + + return diff + + +def batch_update_objects_with_prototype(prototype, diff=None, objects=None): + """ + Update existing objects with the latest version of the prototype. + + Args: + prototype (str or dict): Either the `prototype_key` to use or the + prototype dict itself. + diff (dict, optional): This a diff structure that describes how to update the protototype. + If not given this will be constructed from the first object found. + objects (list, optional): List of objects to update. If not given, query for these + objects using the prototype's `prototype_key`. + Returns: + changed (int): The number of objects that had changes applied to them. + + """ + prototype_key = prototype if isinstance(prototype, basestring) else prototype['prototype_key'] + prototype_obj = protlib.DbPrototype.objects.filter(db_key=prototype_key) + prototype_obj = prototype_obj[0] if prototype_obj else None + new_prototype = prototype_obj.db.prototype + objs = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + + if not objs: + return 0 + + if not diff: + diff = prototype_diff_from_object(new_prototype, objs[0]) + + changed = 0 + for obj in objs: + do_save = False + for key, directive in diff.items(): + val = new_prototype[key] + if directive in ('UPDATE', 'REPLACE'): + do_save = True + if key == 'key': + obj.db_key = init_spawn_value(val, str) + elif key == 'typeclass': + obj.db_typeclass_path = init_spawn_value(val, str) + elif key == 'location': + obj.db_location = init_spawn_value(val, value_to_obj) + elif key == 'home': + obj.db_home = init_spawn_value(val, value_to_obj) + elif key == 'destination': + obj.db_destination = init_spawn_value(val, value_to_obj) + elif key == 'locks': + if directive == 'REPLACE': + obj.locks.clear() + obj.locks.add(init_spawn_value(val, str)) + elif key == 'permissions': + if directive == 'REPLACE': + obj.permissions.clear() + obj.permissions.batch_add(init_spawn_value(val, make_iter)) + elif key == 'aliases': + if directive == 'REPLACE': + obj.aliases.clear() + obj.aliases.batch_add(init_spawn_value(val, make_iter)) + elif key == 'tags': + if directive == 'REPLACE': + obj.tags.clear() + obj.tags.batch_add(init_spawn_value(val, make_iter)) + elif key == 'attrs': + if directive == 'REPLACE': + obj.attributes.clear() + obj.attributes.batch_add(init_spawn_value(val, make_iter)) + elif key == 'exec': + # we don't auto-rerun exec statements, it would be huge security risk! + pass + else: + obj.attributes.add(key, init_spawn_value(val, value_to_obj)) + elif directive == 'REMOVE': + do_save = True + if key == 'key': + obj.db_key = '' + elif key == 'typeclass': + # fall back to default + obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS + elif key == 'location': + obj.db_location = None + elif key == 'home': + obj.db_home = None + elif key == 'destination': + obj.db_destination = None + elif key == 'locks': + obj.locks.clear() + elif key == 'permissions': + obj.permissions.clear() + elif key == 'aliases': + obj.aliases.clear() + elif key == 'tags': + obj.tags.clear() + elif key == 'attrs': + obj.attributes.clear() + elif key == 'exec': + # we don't auto-rerun exec statements, it would be huge security risk! + pass + else: + obj.attributes.remove(key) + if do_save: + changed += 1 + obj.save() + + return changed + + +def batch_create_object(*objparams): + """ + This is a cut-down version of the create_object() function, + optimized for speed. It does NOT check and convert various input + so make sure the spawned Typeclass works before using this! + + Args: + objsparams (tuple): Each paremter tuple will create one object instance using the parameters + within. + The parameters should be given in the following order: + - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`. + - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`. + - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`. + - `aliases` (list): A list of alias strings for + adding with `new_object.aliases.batch_add(*aliases)`. + - `nattributes` (list): list of tuples `(key, value)` to be loop-added to + add with `new_obj.nattributes.add(*tuple)`. + - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for + adding with `new_obj.attributes.batch_add(*attributes)`. + - `tags` (list): list of tuples `(key, category)` for adding + with `new_obj.tags.batch_add(*tags)`. + - `execs` (list): Code strings to execute together with the creation + of each object. They will be executed with `evennia` and `obj` + (the newly created object) available in the namespace. Execution + will happend after all other properties have been assigned and + is intended for calling custom handlers etc. + + Returns: + objects (list): A list of created objects + + Notes: + The `exec` list will execute arbitrary python code so don't allow this to be available to + unprivileged users! + + """ + + # bulk create all objects in one go + + # unfortunately this doesn't work since bulk_create doesn't creates pks; + # the result would be duplicate objects at the next stage, so we comment + # it out for now: + # dbobjs = _ObjectDB.objects.bulk_create(dbobjs) + + dbobjs = [ObjectDB(**objparam[0]) for objparam in objparams] + objs = [] + for iobj, obj in enumerate(dbobjs): + # call all setup hooks on each object + objparam = objparams[iobj] + # setup + obj._createdict = {"permissions": make_iter(objparam[1]), + "locks": objparam[2], + "aliases": make_iter(objparam[3]), + "nattributes": objparam[4], + "attributes": objparam[5], + "tags": make_iter(objparam[6])} + # this triggers all hooks + obj.save() + # run eventual extra code + for code in objparam[7]: + if code: + exec(code, {}, {"evennia": evennia, "obj": obj}) + objs.append(obj) + return objs + + +# Spawner mechanism def spawn(*prototypes, **kwargs): """ @@ -234,12 +429,12 @@ def spawn(*prototypes, **kwargs): """ # get available protparents - protparents = {prot['prototype_key']: prot for prot in search_prototype()} + protparents = {prot['prototype_key']: prot for prot in protlib.search_prototype()} # overload module's protparents with specifically given protparents protparents.update(kwargs.get("prototype_parents", {})) for key, prototype in protparents.items(): - validate_prototype(prototype, key.lower(), protparents) + protlib.validate_prototype(prototype, key.lower(), protparents) if "return_prototypes" in kwargs: # only return the parents @@ -248,7 +443,7 @@ def spawn(*prototypes, **kwargs): objsparams = [] for prototype in prototypes: - validate_prototype(prototype, None, protparents) + protlib.validate_prototype(prototype, None, protparents) prot = _get_prototype(prototype, {}, protparents) if not prot: continue @@ -260,30 +455,30 @@ def spawn(*prototypes, **kwargs): # chance this is not unique but it should usually not be a problem. val = prot.pop("key", "Spawned-{}".format( hashlib.md5(str(time.time())).hexdigest()[:6])) - create_kwargs["db_key"] = validate_spawn_value(val, str) + create_kwargs["db_key"] = init_spawn_value(val, str) val = prot.pop("location", None) - create_kwargs["db_location"] = validate_spawn_value(val, _to_obj) + create_kwargs["db_location"] = init_spawn_value(val, value_to_obj) val = prot.pop("home", settings.DEFAULT_HOME) - create_kwargs["db_home"] = validate_spawn_value(val, _to_obj) + create_kwargs["db_home"] = init_spawn_value(val, value_to_obj) val = prot.pop("destination", None) - create_kwargs["db_destination"] = validate_spawn_value(val, _to_obj) + create_kwargs["db_destination"] = init_spawn_value(val, value_to_obj) val = prot.pop("typeclass", settings.BASE_OBJECT_TYPECLASS) - create_kwargs["db_typeclass_path"] = validate_spawn_value(val, str) + create_kwargs["db_typeclass_path"] = init_spawn_value(val, str) # extract calls to handlers val = prot.pop("permissions", []) - permission_string = validate_spawn_value(val, make_iter) + permission_string = init_spawn_value(val, make_iter) val = prot.pop("locks", "") - lock_string = validate_spawn_value(val, str) + lock_string = init_spawn_value(val, str) val = prot.pop("aliases", []) - alias_string = validate_spawn_value(val, make_iter) + alias_string = init_spawn_value(val, make_iter) val = prot.pop("tags", []) - tags = validate_spawn_value(val, make_iter) + tags = init_spawn_value(val, make_iter) prototype_key = prototype.get('prototype_key', None) if prototype_key: @@ -291,15 +486,15 @@ def spawn(*prototypes, **kwargs): tags.append((prototype_key, _PROTOTYPE_TAG_CATEGORY)) val = prot.pop("exec", "") - execs = validate_spawn_value(val, make_iter) + execs = init_spawn_value(val, make_iter) # extract ndb assignments - nattribute = dict((key.split("_", 1)[1], validate_spawn_value(val, _to_obj)) + nattributes = dict((key.split("_", 1)[1], init_spawn_value(val, value_to_obj)) for key, val in prot.items() if key.startswith("ndb_")) # the rest are attributes val = prot.pop("attrs", []) - attributes = validate_spawn_value(val, list) + attributes = init_spawn_value(val, list) simple_attributes = [] for key, value in ((key, value) for key, value in prot.items() @@ -307,11 +502,11 @@ def spawn(*prototypes, **kwargs): if is_iter(value) and len(value) > 1: # (value, category) simple_attributes.append((key, - validate_spawn_value(value[0], _to_obj_or_any), - validate_spawn_value(value[1], str))) + init_spawn_value(value[0], value_to_obj_or_any), + init_spawn_value(value[1], str))) else: simple_attributes.append((key, - validate_spawn_value(value, _to_obj_or_any))) + init_spawn_value(value, value_to_obj_or_any))) attributes = attributes + simple_attributes attributes = [tup for tup in attributes if not tup[0] in _NON_CREATE_KWARGS] @@ -320,7 +515,7 @@ def spawn(*prototypes, **kwargs): objsparams.append((create_kwargs, permission_string, lock_string, alias_string, nattributes, attributes, tags, execs)) - return _batch_create_object(*objsparams) + return batch_create_object(*objsparams) # Testing diff --git a/evennia/prototypes/utils.py b/evennia/prototypes/utils.py deleted file mode 100644 index 6fe87d172c..0000000000 --- a/evennia/prototypes/utils.py +++ /dev/null @@ -1,62 +0,0 @@ -""" - -Prototype utilities - -""" - -_PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") - - -class PermissionError(RuntimeError): - pass - - -def prototype_to_str(prototype): - """ - Format a prototype to a nice string representation. - - Args: - prototype (dict): The prototype. - """ - - header = ( - "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" - "|cdesc:|n {} \n|cprototype:|n ".format( - prototype['prototype_key'], - ", ".join(prototype['prototype_tags']), - prototype['prototype_locks'], - prototype['prototype_desc'])) - proto = ("{{\n {} \n}}".format( - "\n ".join( - "{!r}: {!r},".format(key, value) for key, value in - sorted(prototype.items()) if key not in _PROTOTYPE_META_NAMES)).rstrip(",")) - return header + proto - - -def prototype_diff_from_object(prototype, obj): - """ - Get a simple diff for a prototype compared to an object which may or may not already have a - prototype (or has one but changed locally). For more complex migratations a manual diff may be - needed. - - Args: - prototype (dict): Prototype. - obj (Object): Object to - - Returns: - diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...} - - """ - prot1 = prototype - prot2 = prototype_from_object(obj) - - diff = {} - for key, value in prot1.items(): - diff[key] = "KEEP" - if key in prot2: - if callable(prot2[key]) or value != prot2[key]: - diff[key] = "UPDATE" - elif key not in prot2: - diff[key] = "REMOVE" - - return diff From a96a896b5ba2c7ffe0a406f16415169826c21da3 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Thu, 7 Jun 2018 14:27:39 -0700 Subject: [PATCH 321/466] 'show' changed to 'look' --- evennia/contrib/fieldfill.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/evennia/contrib/fieldfill.py b/evennia/contrib/fieldfill.py index e88dc94139..3090e4fbd1 100644 --- a/evennia/contrib/fieldfill.py +++ b/evennia/contrib/fieldfill.py @@ -34,9 +34,9 @@ syntax = , like so: > name = Ashley Field 'Name' set to: Ashley -Typing 'show' by itself will show the form and its current values. +Typing 'look' by itself will show the form and its current values. - > show + > look Name: Ashley Age: @@ -54,7 +54,7 @@ Form data is presented as an EvTable, so text of any length will wrap cleanly. > history = EVERY MORNING I WAKE UP AND OPEN PALM SLAM[...] Field 'History' set to: EVERY MORNING I WAKE UP AND[...] - > show + > look Name: Ashley Age: 31 @@ -178,7 +178,7 @@ def init_fill_field(formtemplate, caller, callback, pretext="", posttext="", helptext = ("Available commands:|/" "|w = :|n Set given field to new value, replacing the old value|/" "|wclear :|n Clear the value in the given field, making it blank|/" - "|wshow|n: Show the form's current values|/" + "|wlook|n: Show the form's current values|/" "|whelp|n: Display this help screen|/" "|wquit|n: Quit the form menu without submitting|/" "|w%s|n: Submit this form and quit the menu" % submitcmd) @@ -217,7 +217,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): helptext = caller.ndb._menutree.helptext # Syntax error - syntax_err = "Syntax: = |/Or: clear , help, show, quit|/'%s' to submit form" % submitcmd + syntax_err = "Syntax: = |/Or: clear , help, look, quit|/'%s' to submit form" % submitcmd # Display current form data text = (display_formdata(formtemplate, formdata, pretext=pretext, @@ -245,8 +245,8 @@ def menunode_fieldfill(caller, raw_string, **kwargs): callback(caller, formdata) return None, None - # Test for 'show' command - if raw_string.lower().strip() == "show": + # Test for 'look' command + if raw_string.lower().strip() == "look" or raw_string.lower().strip() == "l": return text, options # Test for 'clear' command @@ -508,7 +508,7 @@ class CmdTestMenu(Command): = clear help - show + look quit send """ From dcf2cd778c1f9634d174bad64537ae41c663eb5f Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Thu, 7 Jun 2018 14:53:11 -0700 Subject: [PATCH 322/466] Start unit tests, fixed syntax errors --- evennia/contrib/fieldfill.py | 2 +- evennia/contrib/tests.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/evennia/contrib/fieldfill.py b/evennia/contrib/fieldfill.py index 3090e4fbd1..770591c1eb 100644 --- a/evennia/contrib/fieldfill.py +++ b/evennia/contrib/fieldfill.py @@ -191,7 +191,7 @@ def init_fill_field(formtemplate, caller, callback, pretext="", posttext="", "pretext": pretext, "posttext": posttext, "submitcmd": submitcmd, - "borderstyle": borderstyle + "borderstyle": borderstyle, "helptext": helptext } diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 60ac50b64d..72b19c2d9e 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1283,6 +1283,25 @@ class TestTreeSelectFunc(EvenniaTest): {'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 field fill + +from evennia.contrib import fieldfill + +FIELD_TEST_TEMPLATE = [ +{"fieldname":"TextTest", "fieldtype":"text"}, +{"fieldname":"NumberTest", "fieldtype":"number", "blankmsg":"Number here!"}, +{"fieldname":"DefaultText", "fieldtype":"text", "default":"Test"}, +{"fieldname":"DefaultNum", "fieldtype":"number", "default":3} +] + +FIELD_TEST_DATA = {"TextTest":None, "NumberTest":None, "DefaultText":"Test", "DefaultNum":3} + +class TestFieldFillFunc(EvenniaTest): + + def test_field_functions(self): + # Template to dictionary + self.assertTrue(fieldfill.form_template_to_dict(FIELD_TEST_TEMPLATE) == FIELD_TEST_DATA) + # Test of the unixcommand module from evennia.contrib.unixcommand import UnixCommand From 9f9e882de2a50bc103199f63e8b49d56d02cbc8d Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Thu, 7 Jun 2018 15:11:15 -0700 Subject: [PATCH 323/466] Finishing touches --- evennia/contrib/README.md | 3 +++ evennia/contrib/tests.py | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/evennia/contrib/README.md b/evennia/contrib/README.md index 5ca11b1799..e15344d87c 100644 --- a/evennia/contrib/README.md +++ b/evennia/contrib/README.md @@ -29,6 +29,9 @@ things you want from here into your game folder and change them there. that requires an email to login rather then just name+password. * Extended Room (Griatch 2012) - An expanded Room typeclass with multiple descriptions for time and season as well as details. +* Field Fill (FlutterSprite 2018) - A simple system for creating an + EvMenu that presents a player with a highly customizable fillable + form * GenderSub (Griatch 2015) - Simple example (only) of storing gender on a character and access it in an emote with a custom marker. * Mail (grungies1138 2016) - An in-game mail system for communication. diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 72b19c2d9e..e4ed875466 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1299,7 +1299,6 @@ FIELD_TEST_DATA = {"TextTest":None, "NumberTest":None, "DefaultText":"Test", "De class TestFieldFillFunc(EvenniaTest): def test_field_functions(self): - # Template to dictionary self.assertTrue(fieldfill.form_template_to_dict(FIELD_TEST_TEMPLATE) == FIELD_TEST_DATA) # Test of the unixcommand module From 7f250cdec493e6ba71bcba08fcf275381bb49fa9 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 9 Jun 2018 12:10:32 +0200 Subject: [PATCH 324/466] Move spawner tests into prototypes folder --- evennia/__init__.py | 2 +- evennia/commands/default/building.py | 2 +- evennia/prototypes/prototypes.py | 2 +- evennia/prototypes/spawner.py | 44 +------------------ .../test_spawner.py => prototypes/tests.py} | 41 ++++++++++++++++- 5 files changed, 43 insertions(+), 48 deletions(-) rename evennia/{utils/tests/test_spawner.py => prototypes/tests.py} (74%) diff --git a/evennia/__init__.py b/evennia/__init__.py index 6fdc4aaece..fc916351ad 100644 --- a/evennia/__init__.py +++ b/evennia/__init__.py @@ -174,7 +174,7 @@ def _init(): from .utils import logger from .utils import gametime from .utils import ansi - from .utils.spawner import spawn + from .prototypes.spawner import spawn from . import contrib from .utils.evmenu import EvMenu from .utils.evtable import EvTable diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 4aabc861b1..692dd2aac6 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -13,7 +13,7 @@ from evennia.utils import create, utils, search from evennia.utils.utils import inherits_from, class_from_module, get_all_typeclasses from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore -from evennia.utils import spawner +from evennia.prototypes import spawner from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 37fd83f846..0020f807c1 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -15,7 +15,7 @@ from evennia.utils.utils import ( from evennia.locks.lockhandler import validate_lockstring, check_lockstring from evennia.utils import logger from evennia.utils.evtable import EvTable -from evennia.utils.prototypes.protfuncs import protfunc_parser +from evennia.prototypes.protfuncs import protfunc_parser _MODULE_PROTOTYPE_MODULES = {} diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 995cea6e52..8cadd43656 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -129,11 +129,8 @@ import time from django.conf import settings import evennia -from random import randint from evennia.objects.models import ObjectDB -from evennia.utils.utils import ( - make_iter, dbid_to_obj, - is_iter, get_all_typeclasses) +from evennia.utils.utils import make_iter, is_iter from evennia.prototypes import prototypes as protlib from evennia.prototypes.prototypes import value_to_obj, value_to_obj_or_any, init_spawn_value @@ -516,42 +513,3 @@ def spawn(*prototypes, **kwargs): alias_string, nattributes, attributes, tags, execs)) return batch_create_object(*objsparams) - - -# Testing - -if __name__ == "__main__": - protparents = { - "NOBODY": {}, - # "INFINITE" : { - # "prototype":"INFINITE" - # }, - "GOBLIN": { - "key": "goblin grunt", - "health": lambda: randint(20, 30), - "resists": ["cold", "poison"], - "attacks": ["fists"], - "weaknesses": ["fire", "light"] - }, - "GOBLIN_WIZARD": { - "prototype": "GOBLIN", - "key": "goblin wizard", - "spells": ["fire ball", "lighting bolt"] - }, - "GOBLIN_ARCHER": { - "prototype": "GOBLIN", - "key": "goblin archer", - "attacks": ["short bow"] - }, - "ARCHWIZARD": { - "attacks": ["archwizard staff"], - }, - "GOBLIN_ARCHWIZARD": { - "key": "goblin archwizard", - "prototype": ("GOBLIN_WIZARD", "ARCHWIZARD") - } - } - # test - print([o.key for o in spawn(protparents["GOBLIN"], - protparents["GOBLIN_ARCHWIZARD"], - prototype_parents=protparents)]) diff --git a/evennia/utils/tests/test_spawner.py b/evennia/prototypes/tests.py similarity index 74% rename from evennia/utils/tests/test_spawner.py rename to evennia/prototypes/tests.py index 4d680a9e8a..1b8e340377 100644 --- a/evennia/utils/tests/test_spawner.py +++ b/evennia/prototypes/tests.py @@ -1,10 +1,44 @@ """ -Unit test for the spawner +Unit tests for the prototypes and spawner """ +from random import randint from evennia.utils.test_resources import EvenniaTest -from evennia.utils import spawner +from evennia.prototypes import spawner, prototypes as protlib + + +_PROTPARENTS = { + "NOBODY": {}, + "GOBLIN": { + "key": "goblin grunt", + "health": lambda: randint(1, 1), + "resists": ["cold", "poison"], + "attacks": ["fists"], + "weaknesses": ["fire", "light"] + }, + "GOBLIN_WIZARD": { + "prototype": "GOBLIN", + "key": "goblin wizard", + "spells": ["fire ball", "lighting bolt"] + }, + "GOBLIN_ARCHER": { + "prototype": "GOBLIN", + "key": "goblin archer", + "attacks": ["short bow"] + }, + "ARCHWIZARD": { + "attacks": ["archwizard staff"], + }, + "GOBLIN_ARCHWIZARD": { + "key": "goblin archwizard", + "prototype": ("GOBLIN_WIZARD", "ARCHWIZARD") + } +} + + +class TestPrototypes(EvenniaTest): + pass class TestSpawner(EvenniaTest): @@ -17,6 +51,9 @@ class TestSpawner(EvenniaTest): obj1 = spawner.spawn(self.prot1) # check spawned objects have the right tag self.assertEqual(list(spawner.search_objects_with_prototype("testprototype")), obj1) + self.assertEqual([o.key for o in spawner.spawn( + _PROTPARENTS["GOBLIN"], _PROTPARENTS["GOBLIN_ARCHWIZARD"], + prototype_parents=_PROTPARENTS)], []) class TestPrototypeStorage(EvenniaTest): From e533ceacfe5c70279862ab8f97c2d63977bde04f Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 9 Jun 2018 23:57:46 +0200 Subject: [PATCH 325/466] Start adding unittests for prototypes --- evennia/prototypes/prototypes.py | 42 ++++++++++++++++++++++++++++++++ evennia/prototypes/spawner.py | 6 +++-- evennia/prototypes/tests.py | 4 +-- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 0020f807c1..bb917a8dc4 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -416,3 +416,45 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed table.reformat_column(2, width=11, align='c') table.reformat_column(3, width=16) return table + + +def validate_prototype(prototype, protkey=None, protparents=None, _visited=None): + """ + Run validation on a prototype, checking for inifinite regress. + + Args: + prototype (dict): Prototype to validate. + protkey (str, optional): The name of the prototype definition. If not given, the prototype + dict needs to have the `prototype_key` field set. + protpartents (dict, optional): The available prototype parent library. If + note given this will be determined from settings/database. + _visited (list, optional): This is an internal work array and should not be set manually. + Raises: + RuntimeError: If prototype has invalid structure. + + """ + if not protparents: + protparents = {prototype['prototype_key']: prototype for prototype in search_prototype()} + if _visited is None: + _visited = [] + + protkey = protkey and protkey.lower() or prototype.get('prototype_key', None) + + assert isinstance(prototype, dict) + + if id(prototype) in _visited: + raise RuntimeError("%s has infinite nesting of prototypes." % protkey or prototype) + + _visited.append(id(prototype)) + protstrings = prototype.get("prototype") + + if protstrings: + for protstring in make_iter(protstrings): + protstring = protstring.lower() + if protkey is not None and protstring == protkey: + raise RuntimeError("%s tries to prototype itself." % protkey or prototype) + protparent = protparents.get(protstring) + if not protparent: + raise RuntimeError( + "%s's prototype '%s' was not found." % (protkey or prototype, protstring)) + validate_prototype(protparent, protstring, protparents, _visited) diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 8cadd43656..5a1196513a 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -426,10 +426,12 @@ def spawn(*prototypes, **kwargs): """ # get available protparents - protparents = {prot['prototype_key']: prot for prot in protlib.search_prototype()} + protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()} # overload module's protparents with specifically given protparents - protparents.update(kwargs.get("prototype_parents", {})) + protparents.update( + {key.lower(): value for key, value in kwargs.get("prototype_parents", {}).items()}) + for key, prototype in protparents.items(): protlib.validate_prototype(prototype, key.lower(), protparents) diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 1b8e340377..e9ef4bce9f 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -50,10 +50,10 @@ class TestSpawner(EvenniaTest): def test_spawn(self): obj1 = spawner.spawn(self.prot1) # check spawned objects have the right tag - self.assertEqual(list(spawner.search_objects_with_prototype("testprototype")), obj1) + self.assertEqual(list(protlib.search_objects_with_prototype("testprototype")), obj1) self.assertEqual([o.key for o in spawner.spawn( _PROTPARENTS["GOBLIN"], _PROTPARENTS["GOBLIN_ARCHWIZARD"], - prototype_parents=_PROTPARENTS)], []) + prototype_parents=_PROTPARENTS)], ['goblin grunt', 'goblin archwizard']) class TestPrototypeStorage(EvenniaTest): From e52503b7107279c8a8c054bcbb0b61cc680a0abe Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 10 Jun 2018 14:27:34 +0200 Subject: [PATCH 326/466] Add unittests, fix bugs --- evennia/prototypes/spawner.py | 99 +++++++++++++++++++++++++---------- evennia/prototypes/tests.py | 95 +++++++++++++++++++++++++++++++++ requirements.txt | 4 +- 3 files changed, 168 insertions(+), 30 deletions(-) diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 5a1196513a..f34fe8c854 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -177,27 +177,45 @@ def prototype_from_object(obj): # first, check if this object already has a prototype prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) - prot = protlib.search_prototype(prot) + if prot: + prot = protlib.search_prototype(prot[0]) if not prot or len(prot) > 1: # no unambiguous prototype found - build new prototype prot = {} prot['prototype_key'] = "From-Object-{}-{}".format( - obj.key, hashlib.md5(str(time.time())).hexdigest()[:6]) + obj.key, hashlib.md5(str(time.time())).hexdigest()[:7]) prot['prototype_desc'] = "Built from {}".format(str(obj)) prot['prototype_locks'] = "spawn:all();edit:all()" prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6] - prot['location'] = obj.db_location - prot['home'] = obj.db_home - prot['destination'] = obj.db_destination prot['typeclass'] = obj.db_typeclass_path - prot['locks'] = obj.locks.all() - prot['permissions'] = obj.permissions.get() - prot['aliases'] = obj.aliases.get() - prot['tags'] = [(tag.key, tag.category, tag.data) - for tag in obj.tags.get(return_tagobj=True, return_list=True)] - prot['attrs'] = [(attr.key, attr.value, attr.category, attr.locks) - for attr in obj.attributes.get(return_obj=True, return_list=True)] + + location = obj.db_location + if location: + prot['location'] = location + home = obj.db_home + if home: + prot['home'] = home + destination = obj.db_destination + if destination: + prot['destination'] = destination + locks = obj.locks.all() + if locks: + prot['locks'] = locks + perms = obj.permissions.get() + if perms: + prot['permissions'] = perms + aliases = obj.aliases.get() + if aliases: + prot['aliases'] = aliases + tags = [(tag.db_key, tag.db_category, tag.db_data) + for tag in obj.tags.get(return_tagobj=True, return_list=True) if tag] + if tags: + prot['tags'] = tags + attrs = [(attr.key, attr.value, attr.category, attr.locks.all()) + for attr in obj.attributes.get(return_obj=True, return_list=True) if attr] + if attrs: + prot['attrs'] = attrs return prot @@ -224,8 +242,14 @@ def prototype_diff_from_object(prototype, obj): diff[key] = "KEEP" if key in prot2: if callable(prot2[key]) or value != prot2[key]: - diff[key] = "UPDATE" + if key in ('attrs', 'tags', 'permissions', 'locks', 'aliases'): + diff[key] = 'REPLACE' + else: + diff[key] = "UPDATE" elif key not in prot2: + diff[key] = "UPDATE" + for key in prot2: + if key not in diff and key not in prot1: diff[key] = "REMOVE" return diff @@ -246,25 +270,42 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None): changed (int): The number of objects that had changes applied to them. """ - prototype_key = prototype if isinstance(prototype, basestring) else prototype['prototype_key'] - prototype_obj = protlib.DbPrototype.objects.filter(db_key=prototype_key) - prototype_obj = prototype_obj[0] if prototype_obj else None - new_prototype = prototype_obj.db.prototype - objs = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + if isinstance(prototype, basestring): + new_prototype = protlib.search_prototype(prototype) + else: + new_prototype = prototype - if not objs: + prototype_key = new_prototype['prototype_key'] + + if not objects: + objects = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + + if not objects: return 0 if not diff: - diff = prototype_diff_from_object(new_prototype, objs[0]) + diff = prototype_diff_from_object(new_prototype, objects[0]) changed = 0 - for obj in objs: + for obj in objects: do_save = False + + old_prot_key = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) + old_prot_key = old_prot_key[0] if old_prot_key else None + if prototype_key != old_prot_key: + obj.tags.clear(category=_PROTOTYPE_TAG_CATEGORY) + obj.tags.add(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + for key, directive in diff.items(): - val = new_prototype[key] if directive in ('UPDATE', 'REPLACE'): + + if key in _PROTOTYPE_META_NAMES: + # prototype meta keys are not stored on-object + continue + + val = new_prototype[key] do_save = True + if key == 'key': obj.db_key = init_spawn_value(val, str) elif key == 'typeclass': @@ -282,19 +323,19 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None): elif key == 'permissions': if directive == 'REPLACE': obj.permissions.clear() - obj.permissions.batch_add(init_spawn_value(val, make_iter)) + obj.permissions.batch_add(*init_spawn_value(val, make_iter)) elif key == 'aliases': if directive == 'REPLACE': obj.aliases.clear() - obj.aliases.batch_add(init_spawn_value(val, make_iter)) + obj.aliases.batch_add(*init_spawn_value(val, make_iter)) elif key == 'tags': if directive == 'REPLACE': obj.tags.clear() - obj.tags.batch_add(init_spawn_value(val, make_iter)) + obj.tags.batch_add(*init_spawn_value(val, make_iter)) elif key == 'attrs': if directive == 'REPLACE': obj.attributes.clear() - obj.attributes.batch_add(init_spawn_value(val, make_iter)) + obj.attributes.batch_add(*init_spawn_value(val, make_iter)) elif key == 'exec': # we don't auto-rerun exec statements, it would be huge security risk! pass @@ -328,9 +369,9 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None): pass else: obj.attributes.remove(key) - if do_save: - changed += 1 - obj.save() + if do_save: + changed += 1 + obj.save() return changed diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index e9ef4bce9f..b358043e9d 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -4,6 +4,8 @@ Unit tests for the prototypes and spawner """ from random import randint +import mock +from anything import Anything, Something from evennia.utils.test_resources import EvenniaTest from evennia.prototypes import spawner, prototypes as protlib @@ -56,6 +58,99 @@ class TestSpawner(EvenniaTest): prototype_parents=_PROTPARENTS)], ['goblin grunt', 'goblin archwizard']) +class TestUtils(EvenniaTest): + + def test_prototype_from_object(self): + self.maxDiff = None + self.obj1.attributes.add("test", "testval") + self.obj1.tags.add('foo') + new_prot = spawner.prototype_from_object(self.obj1) + self.assertEqual( + {'attrs': [('test', 'testval', None, [''])], + 'home': Something, + 'key': 'Obj', + 'location': Something, + 'locks': ['call:true()', + 'control:perm(Developer)', + 'delete:perm(Admin)', + 'edit:perm(Admin)', + 'examine:perm(Builder)', + 'get:all()', + 'puppet:pperm(Developer)', + 'tell:perm(Admin)', + 'view:all()'], + 'prototype_desc': 'Built from Obj', + 'prototype_key': Something, + 'prototype_locks': 'spawn:all();edit:all()', + 'tags': [(u'foo', None, None)], + 'typeclass': 'evennia.objects.objects.DefaultObject'}, new_prot) + + def test_update_objects_from_prototypes(self): + + self.maxDiff = None + self.obj1.attributes.add('oldtest', 'to_remove') + + old_prot = spawner.prototype_from_object(self.obj1) + + # modify object away from prototype + self.obj1.attributes.add('test', 'testval') + self.obj1.aliases.add('foo') + self.obj1.key = 'NewObj' + + # modify prototype + old_prot['new'] = 'new_val' + old_prot['test'] = 'testval_changed' + old_prot['permissions'] = 'Builder' + # this will not update, since we don't update the prototype on-disk + old_prot['prototype_desc'] = 'New version of prototype' + + # diff obj/prototype + pdiff = spawner.prototype_diff_from_object(old_prot, self.obj1) + + self.assertEqual( + pdiff, + {'aliases': 'REMOVE', + 'attrs': 'REPLACE', + 'home': 'KEEP', + 'key': 'UPDATE', + 'location': 'KEEP', + 'locks': 'KEEP', + 'new': 'UPDATE', + 'permissions': 'UPDATE', + 'prototype_desc': 'UPDATE', + 'prototype_key': 'UPDATE', + 'prototype_locks': 'KEEP', + 'test': 'UPDATE', + 'typeclass': 'KEEP'}) + + # apply diff + count = spawner.batch_update_objects_with_prototype( + old_prot, diff=pdiff, objects=[self.obj1]) + self.assertEqual(count, 1) + + new_prot = spawner.prototype_from_object(self.obj1) + self.assertEqual({'attrs': [('test', 'testval_changed', None, ['']), + ('new', 'new_val', None, [''])], + 'home': Something, + 'key': 'Obj', + 'location': Something, + 'locks': ['call:true()', + 'control:perm(Developer)', + 'delete:perm(Admin)', + 'edit:perm(Admin)', + 'examine:perm(Builder)', + 'get:all()', + 'puppet:pperm(Developer)', + 'tell:perm(Admin)', + 'view:all()'], + 'permissions': 'builder', + 'prototype_desc': 'Built from Obj', + 'prototype_key': Something, + 'prototype_locks': 'spawn:all();edit:all()', + 'typeclass': 'evennia.objects.objects.DefaultObject'}, + new_prot) + + class TestPrototypeStorage(EvenniaTest): def setUp(self): diff --git a/requirements.txt b/requirements.txt index 7f4b94726f..72df29b9d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,9 +2,11 @@ django > 1.10, < 2.0 twisted == 16.0.0 -mock >= 1.0.1 pillow == 2.9.0 pytz future >= 0.15.2 django-sekizai inflect + +mock >= 1.0.1 +anything From 116f6e7505d26c7887f3231c4478a3d54171ff47 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 10 Jun 2018 20:00:35 +0200 Subject: [PATCH 327/466] Fix unittests, resolve bugs --- evennia/locks/lockhandler.py | 2 +- evennia/prototypes/prototypes.py | 44 ++++++++------ evennia/prototypes/spawner.py | 1 + evennia/prototypes/tests.py | 98 +++++++++++++++++++------------- 4 files changed, 87 insertions(+), 58 deletions(-) diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py index 4822dde1b6..9e27ca2fad 100644 --- a/evennia/locks/lockhandler.py +++ b/evennia/locks/lockhandler.py @@ -660,7 +660,7 @@ def validate_lockstring(lockstring): if no error was found. """ - return _LOCK_HANDLER.valdate(lockstring) + return _LOCK_HANDLER.validate(lockstring) def _test(): diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index bb917a8dc4..2e96af99c9 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -21,7 +21,8 @@ from evennia.prototypes.protfuncs import protfunc_parser _MODULE_PROTOTYPE_MODULES = {} _MODULE_PROTOTYPES = {} _PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") -_PROTOTYPE_TAG_CATEGORY = "spawned_by_prototype" +_PROTOTYPE_TAG_CATEGORY = "from_prototype" +_PROTOTYPE_TAG_META_CATEGORY = "db_prototype" class PermissionError(RuntimeError): @@ -167,7 +168,7 @@ class DbPrototype(DefaultScript): def create_prototype(**kwargs): """ - Store a prototype persistently. + Create/Store a prototype persistently. Kwargs: prototype_key (str): This is required for any storage. @@ -204,36 +205,45 @@ def create_prototype(**kwargs): raise PermissionError("{} is a read-only prototype " "(defined as code in {}).".format(prototype_key, mod)) - # want to create- or edit - prototype = kwargs - # make sure meta properties are included with defaults - prototype['prototype_desc'] = prototype.get('prototype_desc', '') - locks = prototype.get('prototype_locks', "spawn:all();edit:perm(Admin)") - is_valid, err = validate_lockstring(locks) + stored_prototype = DbPrototype.objects.filter(db_key=prototype_key) + prototype = dict(stored_prototype[0].db.prototype) if stored_prototype else {} + + kwargs['prototype_desc'] = kwargs.get("prototype_desc", prototype.get("prototype_desc", "")) + prototype_locks = kwargs.get( + "prototype_locks", prototype.get('prototype_locks', "spawn:all();edit:perm(Admin)")) + is_valid, err = validate_lockstring(prototype_locks) if not is_valid: raise ValidationError("Lock error: {}".format(err)) - prototype["prototype_locks"] = locks - prototype["prototype_tags"] = [ - _to_batchtuple(tag, "db_prototype") - for tag in make_iter(prototype.get("prototype_tags", []))] + kwargs['prototype_locks'] = prototype_locks - stored_prototype = DbPrototype.objects.filter(db_key=prototype_key) + prototype_tags = [ + _to_batchtuple(tag, _PROTOTYPE_TAG_META_CATEGORY) + for tag in make_iter(kwargs.get("prototype_tags", + prototype.get('prototype_tags', [])))] + kwargs["prototype_tags"] = prototype_tags + + prototype.update(kwargs) if stored_prototype: # edit existing prototype stored_prototype = stored_prototype[0] - stored_prototype.desc = prototype['prototype_desc'] - stored_prototype.tags.batch_add(*prototype['prototype_tags']) + if prototype_tags: + stored_prototype.tags.clear(category=_PROTOTYPE_TAG_CATEGORY) + stored_prototype.tags.batch_add(*prototype['prototype_tags']) stored_prototype.locks.add(prototype['prototype_locks']) stored_prototype.attributes.add('prototype', prototype) else: # create a new prototype stored_prototype = create_script( DbPrototype, key=prototype_key, desc=prototype['prototype_desc'], persistent=True, - locks=locks, tags=prototype['prototype_tags'], attributes=[("prototype", prototype)]) - return stored_prototype + locks=prototype_locks, tags=prototype['prototype_tags'], + attributes=[("prototype", prototype)]) + return stored_prototype.db.prototype + +# alias +save_prototype = create_prototype def delete_prototype(key, caller=None): diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index f34fe8c854..22add7830a 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -186,6 +186,7 @@ def prototype_from_object(obj): obj.key, hashlib.md5(str(time.time())).hexdigest()[:7]) prot['prototype_desc'] = "Built from {}".format(str(obj)) prot['prototype_locks'] = "spawn:all();edit:all()" + prot['prototype_tags'] = [] prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6] prot['typeclass'] = obj.db_typeclass_path diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index b358043e9d..88650caa7b 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -9,6 +9,7 @@ from anything import Anything, Something from evennia.utils.test_resources import EvenniaTest from evennia.prototypes import spawner, prototypes as protlib +from evennia.prototypes.prototypes import _PROTOTYPE_TAG_META_CATEGORY _PROTPARENTS = { "NOBODY": {}, @@ -151,63 +152,80 @@ class TestUtils(EvenniaTest): new_prot) +class TestProtLib(EvenniaTest): + + def setUp(self): + super(TestProtLib, self).setUp() + self.obj1.attributes.add("testattr", "testval") + self.prot = spawner.prototype_from_object(self.obj1) + + def test_prototype_to_str(self): + prstr = protlib.prototype_to_str(self.prot) + self.assertTrue(prstr.startswith("|cprototype key:|n")) + + def test_check_permission(self): + pass + class TestPrototypeStorage(EvenniaTest): def setUp(self): super(TestPrototypeStorage, self).setUp() - self.prot1 = {"prototype_key": "testprototype"} - self.prot2 = {"prototype_key": "testprototype2"} - self.prot3 = {"prototype_key": "testprototype3"} + self.maxDiff = None - def _get_metaproto( - self, key='testprototype', desc='testprototype', - locks=['edit:id(6) or perm(Admin)', 'use:all()'], - tags=[], prototype={"key": "testprototype"}): - return spawner.build_metaproto(key, desc, locks, tags, prototype) + self.prot1 = spawner.prototype_from_object(self.obj1) + self.prot1['prototype_key'] = 'testprototype1' + self.prot1['prototype_desc'] = 'testdesc1' + self.prot1['prototype_tags'] = [('foo1', _PROTOTYPE_TAG_META_CATEGORY)] - def _to_metaproto(self, db_prototype): - return spawner.build_metaproto( - db_prototype.key, db_prototype.desc, db_prototype.locks.all(), - db_prototype.tags.get(category="db_prototype", return_list=True), - db_prototype.attributes.get("prototype")) + self.prot2 = self.prot1.copy() + self.prot2['prototype_key'] = 'testprototype2' + self.prot2['prototype_desc'] = 'testdesc2' + self.prot2['prototype_tags'] = [('foo1', _PROTOTYPE_TAG_META_CATEGORY)] + + self.prot3 = self.prot2.copy() + self.prot3['prototype_key'] = 'testprototype3' + self.prot3['prototype_desc'] = 'testdesc3' + self.prot3['prototype_tags'] = [('foo1', _PROTOTYPE_TAG_META_CATEGORY)] def test_prototype_storage(self): - prot = spawner.save_db_prototype(self.char1, self.prot1, "testprot", - desc='testdesc0', tags=["foo"]) + prot1 = protlib.create_prototype(**self.prot1) - self.assertTrue(bool(prot)) - self.assertEqual(prot.db.prototype, self.prot1) - self.assertEqual(prot.desc, "testdesc0") + self.assertTrue(bool(prot1)) + self.assertEqual(prot1, self.prot1) - prot = spawner.save_db_prototype(self.char1, self.prot1, "testprot", - desc='testdesc', tags=["fooB"]) - self.assertEqual(prot.db.prototype, self.prot1) - self.assertEqual(prot.desc, "testdesc") - self.assertTrue(bool(prot.tags.get("fooB", "db_prototype"))) + self.assertEqual(prot1['prototype_desc'], "testdesc1") - self.assertEqual(list(prot.__class__.objects.get_by_tag("foo", "db_prototype")), [prot]) - - prot2 = spawner.save_db_prototype(self.char1, self.prot2, "testprot2", - desc='testdesc2b', tags=["foo"]) + self.assertEqual(prot1['prototype_tags'], [("foo1", _PROTOTYPE_TAG_META_CATEGORY)]) self.assertEqual( - list(prot.__class__.objects.get_by_tag("foo", "db_prototype")), [prot, prot2]) + protlib.DbPrototype.objects.get_by_tag( + "foo1", _PROTOTYPE_TAG_META_CATEGORY)[0].db.prototype, prot1) - prot3 = spawner.save_db_prototype(self.char1, self.prot3, "testprot2", desc='testdesc2') - self.assertEqual(prot2.id, prot3.id) + prot2 = protlib.create_prototype(**self.prot2) self.assertEqual( - list(prot.__class__.objects.get_by_tag("foo", "db_prototype")), [prot, prot2]) + [pobj.db.prototype + for pobj in protlib.DbPrototype.objects.get_by_tag( + "foo1", _PROTOTYPE_TAG_META_CATEGORY)], + [prot1, prot2]) - # returns DBPrototype - self.assertEqual(list(spawner.search_db_prototype("testprot", return_queryset=True)), [prot]) + # add to existing prototype + prot1b = protlib.create_prototype( + prototype_key='testprototype1', foo='bar', prototype_tags=['foo2']) - prot = prot.db.prototype - prot3 = prot3.db.prototype - self.assertEqual(list(spawner.search_prototype("testprot")), [prot]) self.assertEqual( - list(spawner.search_prototype("testprot")), [self.prot1]) + [pobj.db.prototype + for pobj in protlib.DbPrototype.objects.get_by_tag( + "foo2", _PROTOTYPE_TAG_META_CATEGORY)], + [prot1b]) + + self.assertEqual(list(protlib.search_prototype("testprototype2")), [prot2]) + self.assertNotEqual(list(protlib.search_prototype("testprototype1")), [prot1]) + self.assertEqual(list(protlib.search_prototype("testprototype1")), [prot1b]) + + prot3 = protlib.create_prototype(**self.prot3) + # partial match - self.assertEqual(list(spawner.search_prototype("prot")), [prot, prot3]) - self.assertEqual(list(spawner.search_prototype(tags="foo")), [prot, prot3]) + self.assertEqual(list(protlib.search_prototype("prot")), [prot1b, prot2, prot3]) + self.assertEqual(list(protlib.search_prototype(tags="foo1")), [prot1b, prot2, prot3]) - self.assertTrue(str(unicode(spawner.list_prototypes(self.char1)))) + self.assertTrue(str(unicode(protlib.list_prototypes(self.char1)))) From d47834f28af48d4eafc33d331c7b9a5f1f6fd027 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 10 Jun 2018 21:02:09 +0200 Subject: [PATCH 328/466] Fix unittests; still missing protfunc tests and menus --- evennia/commands/default/building.py | 18 +++++++++--------- evennia/commands/default/tests.py | 10 +++++----- evennia/contrib/tutorial_world/objects.py | 2 +- evennia/prototypes/spawner.py | 4 ++-- evennia/prototypes/tests.py | 3 +++ 5 files changed, 20 insertions(+), 17 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 692dd2aac6..301bd03761 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -13,7 +13,7 @@ from evennia.utils import create, utils, search from evennia.utils.utils import inherits_from, class_from_module, get_all_typeclasses from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore -from evennia.prototypes import spawner +from evennia.prototypes import spawner, prototypes as protlib from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -2887,7 +2887,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): "use the 'exec' prototype key.") return None try: - spawner.validate_prototype(prototype) + protlib.validate_prototype(prototype) except RuntimeError as err: self.caller.msg(str(err)) return @@ -2929,7 +2929,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if ';' in self.args: key, tags = (part.strip().lower() for part in self.args.split(";", 1)) tags = [tag.strip() for tag in tags.split(",")] if tags else None - EvMore(caller, unicode(spawner.list_prototypes(caller, key=key, tags=tags)), + EvMore(caller, unicode(protlib.list_prototypes(caller, key=key, tags=tags)), exit_on_lastpage=True) return @@ -2947,7 +2947,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if 'list' in self.switches: # for list, all optional arguments are tags - EvMore(caller, unicode(spawner.list_prototypes(caller, + EvMore(caller, unicode(protlib.list_prototypes(caller, tags=self.lhslist)), exit_on_lastpage=True) return @@ -3049,7 +3049,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): return if not self.args: - ncount = len(spawner.search_prototype()) + ncount = len(protlib.search_prototype()) caller.msg("Usage: @spawn or {{key: value, ...}}" "\n ({} existing prototypes. Use /list to inspect)".format(ncount)) return @@ -3065,7 +3065,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): caller.msg("|rDeletion cancelled.|n") return try: - success = spawner.delete_db_prototype(caller, self.args) + success = protlib.delete_db_prototype(caller, self.args) except PermissionError as err: caller.msg("|rError deleting:|R {}|n".format(err)) caller.msg("Deletion {}.".format( @@ -3077,7 +3077,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if 'update' in self.switches: # update existing prototypes key = self.args.strip().lower() - existing_objects = spawner.search_objects_with_prototype(key) + existing_objects = protlib.search_objects_with_prototype(key) if existing_objects: n_existing = len(existing_objects) slow = " (note that this may be slow)" if n_existing > 10 else "" @@ -3103,7 +3103,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if isinstance(prototype, basestring): # A prototype key we are looking to apply key = prototype - prototypes = spawner.search_prototype(prototype) + prototypes = protlib.search_prototype(prototype) nprots = len(prototypes) if not prototypes: caller.msg("No prototype named '%s'." % prototype) @@ -3115,7 +3115,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): return # we have a prototype, check access prototype = prototypes[0] - if not caller.locks.check_lockstring(caller, prototype.get('prototype_locks', ''), access_type='use'): + if not caller.locks.check_lockstring(caller, prototype.get('prototype_locks', ''), access_type='spawn'): caller.msg("You don't have access to use this prototype.") return diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index ffb877c3e3..e1688cdb48 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -28,7 +28,7 @@ from evennia.utils import ansi, utils, gametime from evennia.server.sessionhandler import SESSIONS from evennia import search_object from evennia import DefaultObject, DefaultCharacter -from evennia.utils import spawner +from evennia.prototypes import spawner, prototypes as protlib # set up signal here since we are not starting the server @@ -389,16 +389,16 @@ class TestBuilding(CommandTest): spawnLoc = self.room1 self.call(building.CmdSpawn(), - "{'prototype':'GOBLIN', 'key':'goblin', 'location':'%s'}" + "{'prototype_key':'GOBLIN', 'key':'goblin', 'location':'%s'}" % spawnLoc.dbref, "Spawned goblin") goblin = getObject(self, "goblin") self.assertEqual(goblin.location, spawnLoc) goblin.delete() - spawner.save_db_prototype(self.char1, {'key': 'Ball', 'prototype': 'GOBLIN'}, 'ball') + protlib.create_prototype(**{'key': 'Ball', 'prototype': 'GOBLIN', 'prototype_key': 'testball'}) # Tests "@spawn " - self.call(building.CmdSpawn(), "ball", "Spawned Ball") + self.call(building.CmdSpawn(), "testball", "Spawned Ball") ball = getObject(self, "Ball") self.assertEqual(ball.location, self.char1.location) self.assertIsInstance(ball, DefaultObject) @@ -414,7 +414,7 @@ class TestBuilding(CommandTest): # Tests "@spawn/noloc ...", but DO specify a location. # Location should be the specified location. self.call(building.CmdSpawn(), - "/noloc {'prototype':'BALL', 'location':'%s'}" + "/noloc {'prototype':'TESTBALL', 'location':'%s'}" % spawnLoc.dbref, "Spawned Ball") ball = getObject(self, "Ball") self.assertEqual(ball.location, spawnLoc) diff --git a/evennia/contrib/tutorial_world/objects.py b/evennia/contrib/tutorial_world/objects.py index b260770577..807b4d5e09 100644 --- a/evennia/contrib/tutorial_world/objects.py +++ b/evennia/contrib/tutorial_world/objects.py @@ -24,7 +24,7 @@ import random from evennia import DefaultObject, DefaultExit, Command, CmdSet from evennia.utils import search, delay -from evennia.utils.spawner import spawn +from evennia.prototypes.spawner import spawn # ------------------------------------------------------------- # diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 22add7830a..da4d69eeb4 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -132,13 +132,13 @@ import evennia from evennia.objects.models import ObjectDB from evennia.utils.utils import make_iter, is_iter from evennia.prototypes import prototypes as protlib -from evennia.prototypes.prototypes import value_to_obj, value_to_obj_or_any, init_spawn_value +from evennia.prototypes.prototypes import ( + value_to_obj, value_to_obj_or_any, init_spawn_value, _PROTOTYPE_TAG_CATEGORY) _CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") _PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") _NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES -_PROTOTYPE_TAG_CATEGORY = "spawned_by_prototype" # Helper diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 88650caa7b..94bce1f946 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -83,6 +83,7 @@ class TestUtils(EvenniaTest): 'prototype_desc': 'Built from Obj', 'prototype_key': Something, 'prototype_locks': 'spawn:all();edit:all()', + 'prototype_tags': [], 'tags': [(u'foo', None, None)], 'typeclass': 'evennia.objects.objects.DefaultObject'}, new_prot) @@ -121,6 +122,7 @@ class TestUtils(EvenniaTest): 'prototype_desc': 'UPDATE', 'prototype_key': 'UPDATE', 'prototype_locks': 'KEEP', + 'prototype_tags': 'KEEP', 'test': 'UPDATE', 'typeclass': 'KEEP'}) @@ -148,6 +150,7 @@ class TestUtils(EvenniaTest): 'prototype_desc': 'Built from Obj', 'prototype_key': Something, 'prototype_locks': 'spawn:all();edit:all()', + 'prototype_tags': [], 'typeclass': 'evennia.objects.objects.DefaultObject'}, new_prot) From e26ea4f386c350a8463f1d67c04a7c4db1d26ba0 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 12 Jun 2018 00:08:14 +0200 Subject: [PATCH 329/466] Add a selection of default protfuncs --- evennia/prototypes/protfuncs.py | 181 +++++++++++++++++++++++++++++++- 1 file changed, 178 insertions(+), 3 deletions(-) diff --git a/evennia/prototypes/protfuncs.py b/evennia/prototypes/protfuncs.py index 057f5f770f..01859452b7 100644 --- a/evennia/prototypes/protfuncs.py +++ b/evennia/prototypes/protfuncs.py @@ -25,6 +25,9 @@ where *args are the arguments given in the prototype, and **kwargs are inserted - session (Session): The Session of the entity spawning using this prototype. - prototype_key (str): The currently spawning prototype-key. - prototype (dict): The dict this protfunc is a part of. + - testing (bool): This is set if this function is called as part of the prototype validation; if + set, the protfunc should take care not to perform any persistent actions, such as operate on + objects or add things to the database. Any traceback raised by this function will be handled at the time of spawning and abort the spawn before any object is created/updated. It must otherwise return the value to store for the specified @@ -32,9 +35,14 @@ prototype key (this value must be possible to serialize in an Attribute). """ +from ast import literal_eval +from random import randint as base_randint, random as base_random + from django.conf import settings from evennia.utils import inlinefuncs from evennia.utils.utils import callables_from_module +from evennia.utils.utils import justify as base_justify, is_iter +from evennia.prototypes.prototypes import value_to_obj_or_any _PROTOTYPEFUNCS = {} @@ -57,7 +65,8 @@ def protfunc_parser(value, available_functions=None, **kwargs): `settings.PROTOTYPEFUNC_MODULES`, or specified on the command line. Args: - value (string): The value to test for a parseable protfunc. + value (any): The value to test for a parseable protfunc. Only strings will be parsed for + protfuncs, all other types are returned as-is. available_functions (dict, optional): Mapping of name:protfunction to use for this parsing. Kwargs: @@ -66,13 +75,179 @@ def protfunc_parser(value, available_functions=None, **kwargs): Returns: any (any): A structure to replace the string on the prototype level. If this is a callable or a (callable, (args,)) structure, it will be executed as if one had supplied - it to the prototype directly. + it to the prototype directly. This structure is also passed through literal_eval so one + can get actual Python primitives out of it (not just strings). It will also identify + eventual object #dbrefs in the output from the protfunc. + """ if not isinstance(value, basestring): return value available_functions = _PROTOTYPEFUNCS if available_functions is None else available_functions - return inlinefuncs.parse_inlinefunc(value, _available_funcs=available_functions, **kwargs) + result = inlinefuncs.parse_inlinefunc(value, _available_funcs=available_functions, **kwargs) + result = value_to_obj_or_any(result) + try: + return literal_eval(result) + except ValueError: + return result + # default protfuncs + +def random(*args, **kwargs): + """ + Usage: $random() + Returns a random value in the interval [0, 1) + + """ + return base_random() + + +def randint(*args, **kwargs): + """ + Usage: $randint(start, end) + Returns random integer in interval [start, end] + + """ + if len(args) != 2: + raise TypeError("$randint needs two arguments - start and end.") + start, end = int(args[0]), int(args[1]) + return base_randint(start, end) + + +def left_justify(*args, **kwargs): + """ + Usage: $left_justify() + Returns left-justified. + + """ + if args: + return base_justify(args[0], align='l') + return "" + + +def right_justify(*args, **kwargs): + """ + Usage: $right_justify() + Returns right-justified across screen width. + + """ + if args: + return base_justify(args[0], align='r') + return "" + + +def center_justify(*args, **kwargs): + + """ + Usage: $center_justify() + Returns centered in screen width. + + """ + if args: + return base_justify(args[0], align='c') + return "" + + +def full_justify(*args, **kwargs): + + """ + Usage: $full_justify() + Returns filling up screen width by adding extra space. + + """ + if args: + return base_justify(args[0], align='f') + return "" + + +def protkey(*args, **kwargs): + """ + Usage: $protkey() + Returns the value of another key in this prototoype. Will raise an error if + the key is not found in this prototype. + + """ + if args: + prototype = kwargs['prototype'] + return prototype[args[0]] + + +def add(*args, **kwargs): + """ + Usage: $add(val1, val2) + Returns the result of val1 + val2. Values must be + valid simple Python structures possible to add, + such as numbers, lists etc. + + """ + if len(args) > 1: + val1, val2 = args[0], args[1] + return literal_eval(val1) + literal_eval(val2) + raise ValueError("$add requires two arguments.") + + +def sub(*args, **kwargs): + """ + Usage: $del(val1, val2) + Returns the value of val1 - val2. Values must be + valid simple Python structures possible to + subtract. + + """ + if len(args) > 1: + val1, val2 = args[0], args[1] + return literal_eval(val1) - literal_eval(val2) + raise ValueError("$sub requires two arguments.") + + +def mul(*args, **kwargs): + """ + Usage: $mul(val1, val2) + Returns the value of val1 * val2. The values must be + valid simple Python structures possible to + multiply, like strings and/or numbers. + + """ + if len(args) > 1: + val1, val2 = args[0], args[1] + return literal_eval(val1) * literal_eval(val2) + raise ValueError("$mul requires two arguments.") + + +def div(*args, **kwargs): + """ + Usage: $div(val1, val2) + Returns the value of val1 / val2. Values must be numbers and + the result is always a float. + + """ + if len(args) > 1: + val1, val2 = args[0], args[1] + return literal_eval(val1) / float(literal_eval(val2)) + raise ValueError("$mult requires two arguments.") + + +def eval(*args, **kwargs): + """ + Usage $eval() + Returns evaluation of a simple Python expression. The string may *only* consist of the following + Python literal structures: strings, numbers, tuples, lists, dicts, booleans, + and None. The strings can also contain #dbrefs. Escape embedded protfuncs as $$protfunc(..) + - those will then be evaluated *after* $eval. + + """ + string = args[0] if args else '' + struct = literal_eval(string) + + def _recursive_parse(val): + # an extra round of recursive parsing, to catch any escaped $$profuncs + if is_iter(val): + stype = type(val) + if stype == dict: + return {_recursive_parse(key): _recursive_parse(v) for key, v in val.items()} + return stype((_recursive_parse(v) for v in val)) + return protfunc_parser(val) + + return _recursive_parse(struct) From 8d7a7490a98e81ef121be9d4efd5140328bd5095 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 12 Jun 2018 20:10:20 +0200 Subject: [PATCH 330/466] Start protfunc tests (unworking) --- evennia/prototypes/protfuncs.py | 24 +++++++++++++----------- evennia/prototypes/tests.py | 24 +++++++++++++++++++----- evennia/settings_default.py | 3 +++ 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/evennia/prototypes/protfuncs.py b/evennia/prototypes/protfuncs.py index 01859452b7..853634d9f6 100644 --- a/evennia/prototypes/protfuncs.py +++ b/evennia/prototypes/protfuncs.py @@ -15,7 +15,7 @@ In the prototype dict, the protfunc is specified as a string inside the prototyp and multiple functions can be nested (no keyword args are supported). The result will be used as the value for that prototype key for that individual spawn. -Available protfuncs are callables in one of the modules of `settings.PROTOTYPEFUNC_MODULES`. They +Available protfuncs are callables in one of the modules of `settings.PROT_FUNC_MODULES`. They are specified as functions def funcname (*args, **kwargs) @@ -42,17 +42,16 @@ from django.conf import settings from evennia.utils import inlinefuncs from evennia.utils.utils import callables_from_module from evennia.utils.utils import justify as base_justify, is_iter -from evennia.prototypes.prototypes import value_to_obj_or_any +_PROTLIB = None +_PROT_FUNCS = {} -_PROTOTYPEFUNCS = {} - -for mod in settings.PROTOTYPEFUNC_MODULES: +for mod in settings.PROT_FUNC_MODULES: try: callables = callables_from_module(mod) if mod == __name__: - callables.pop("protfunc_parser") - _PROTOTYPEFUNCS.update(callables) + callables.pop("protfunc_parser", None) + _PROT_FUNCS.update(callables) except ImportError: pass @@ -62,7 +61,7 @@ def protfunc_parser(value, available_functions=None, **kwargs): Parse a prototype value string for a protfunc and process it. Available protfuncs are specified as callables in one of the modules of - `settings.PROTOTYPEFUNC_MODULES`, or specified on the command line. + `settings.PROTFUNC_MODULES`, or specified on the command line. Args: value (any): The value to test for a parseable protfunc. Only strings will be parsed for @@ -81,18 +80,21 @@ def protfunc_parser(value, available_functions=None, **kwargs): """ + global _PROTLIB + if not _PROTLIB: + from evennia.prototypes import prototypes as _PROTLIB + if not isinstance(value, basestring): return value - available_functions = _PROTOTYPEFUNCS if available_functions is None else available_functions + available_functions = _PROT_FUNCS if available_functions is None else available_functions result = inlinefuncs.parse_inlinefunc(value, _available_funcs=available_functions, **kwargs) - result = value_to_obj_or_any(result) + result = _PROTLIB.value_to_obj_or_any(result) try: return literal_eval(result) except ValueError: return result - # default protfuncs def random(*args, **kwargs): diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 94bce1f946..fa7eeca246 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -6,8 +6,9 @@ Unit tests for the prototypes and spawner from random import randint import mock from anything import Anything, Something +from django.test.utils import override_settings from evennia.utils.test_resources import EvenniaTest -from evennia.prototypes import spawner, prototypes as protlib +from evennia.prototypes import spawner, prototypes as protlib, protfuncs from evennia.prototypes.prototypes import _PROTOTYPE_TAG_META_CATEGORY @@ -40,10 +41,6 @@ _PROTPARENTS = { } -class TestPrototypes(EvenniaTest): - pass - - class TestSpawner(EvenniaTest): def setUp(self): @@ -169,6 +166,23 @@ class TestProtLib(EvenniaTest): def test_check_permission(self): pass + +@override_settings(PROT_FUNC_MODULES=['evennia.prototypes.protfuncs']) +class TestProtFuncs(EvenniaTest): + + def setUp(self): + super(TestProtFuncs, self).setUp() + self.prot = {"prototype_key": "test_prototype", + "prototype_desc": "testing prot", + "key": "ExampleObj"} + + @mock.patch("random.random", new=mock.MagicMock(return_value=0.5)) + @mock.patch("random.randint", new=mock.MagicMock(return_value=5)) + def test_protfuncs(self): + self.assertEqual(protfuncs.protfunc_parser("$random()", 0.5)) + self.assertEqual(protfuncs.protfunc_parser("$randint(1, 10)", 5)) + + class TestPrototypeStorage(EvenniaTest): def setUp(self): diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 1d7adb4375..172fee8922 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -354,6 +354,9 @@ LOCK_FUNC_MODULES = ("evennia.locks.lockfuncs", "server.conf.lockfuncs",) INPUT_FUNC_MODULES = ["evennia.server.inputfuncs", "server.conf.inputfuncs"] # Modules that contain prototypes for use with the spawner mechanism. PROTOTYPE_MODULES = ["world.prototypes"] +# Modules containining Prototype functions able to be embedded in prototype +# definitions from in-game. +PROT_FUNC_MODULES = ["evennia.prototypes.protfuncs"] # Module holding settings/actions for the dummyrunner program (see the # dummyrunner for more information) DUMMYRUNNER_SETTINGS_MODULE = "evennia.server.profiling.dummyrunner_settings" From 41a1652fdc91ae570ad16cda3c127086c8f0bf01 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 12 Jun 2018 20:20:20 +0200 Subject: [PATCH 331/466] Fix unittests after merge --- evennia/commands/default/tests.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 941e247f0a..625af4b5ac 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -333,8 +333,8 @@ class TestBuilding(CommandTest): def test_find(self): self.call(building.CmdFind(), "oom2", "One Match") - expect = "One Match(#1#7, loc):\n " +\ - "Char2(#7) evennia.objects.objects.DefaultCharacter (location: Room(#1))" + 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, @@ -350,7 +350,7 @@ class TestBuilding(CommandTest): def test_teleport(self): self.call(building.CmdTeleport(), "/quiet Room2", "Room2(#2)\n|Teleported to Room2.") self.call(building.CmdTeleport(), "/t", # /t switch is abbreviated form of /tonone - "Cannot teleport a puppeted object (Char, puppeted by TestAccount(account 1)) to a Nonelocation.") + "Cannot teleport a puppeted object (Char, puppeted by TestAccount(account 1)) to a None-location.") self.call(building.CmdTeleport(), "/l Room2", # /l switch is abbreviated form of /loc "Destination has no location.") self.call(building.CmdTeleport(), "/q me to Room2", # /q switch is abbreviated form of /quiet @@ -507,5 +507,5 @@ class TestUnconnectedCommand(CommandTest): expected = "## BEGIN INFO 1.1\nName: %s\nUptime: %s\nConnected: %d\nVersion: Evennia %s\n## END INFO" % ( settings.SERVERNAME, datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(), - SESSIONS.account_count(), utils.get_evennia_version().replace("-", "")) + SESSIONS.account_count(), utils.get_evennia_version()) self.call(unloggedin.CmdUnconnectedInfo(), "", expected) From a31ac6b68f6a6e7208db28aa591be129461952ac Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 12 Jun 2018 23:50:50 +0200 Subject: [PATCH 332/466] Patch out tickerhandler to avoid reactor testing issues --- evennia/contrib/tests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index ba319e3974..e3577997cf 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1325,6 +1325,7 @@ class TestTurnBattleRangeFunc(EvenniaTest): class TestTurnBattleItemsFunc(EvenniaTest): + @patch("evennia.contrib.turnbattle.tb_items.tickerhandler", new=MagicMock()) def setUp(self): super(TestTurnBattleItemsFunc, self).setUp() self.testroom = create_object(DefaultRoom, key="Test Room") From 39ec7c4fd199a935293c32085218243db14704ee Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Tue, 12 Jun 2018 21:19:17 -0700 Subject: [PATCH 333/466] I'm not falling for that again! Remembered that "== True" and "is True" are different. --- evennia/contrib/fieldfill.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/evennia/contrib/fieldfill.py b/evennia/contrib/fieldfill.py index 770591c1eb..b7e10c63cc 100644 --- a/evennia/contrib/fieldfill.py +++ b/evennia/contrib/fieldfill.py @@ -233,7 +233,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): for field in formtemplate: if "required" in field.keys(): # If field is required but current form data for field is blank - if field["required"] == True and formdata[field["fieldname"]] == None: + if field["required"] is True and formdata[field["fieldname"]] is None: # Add to blank and required fields blank_and_required.append(field["fieldname"]) if len(blank_and_required) > 0: @@ -270,7 +270,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): # Test to see if field can be cleared for field in formtemplate: if field["fieldname"] == matched_field and "cantclear" in field.keys(): - if field["cantclear"] == True: + if field["cantclear"] is True: caller.msg("Field '%s' can't be cleared!" % matched_field) text = (None, helptext) return text, options @@ -361,11 +361,11 @@ def menunode_fieldfill(caller, raw_string, **kwargs): # Call verify function if present if verifyfunc: - if verifyfunc(caller, newvalue) == False: + if verifyfunc(caller, newvalue) is False: # No error message is given - should be provided by verifyfunc text = (None, helptext) return text, options - elif verifyfunc(caller, newvalue) != True: + elif verifyfunc(caller, newvalue) is not True: newvalue = verifyfunc(caller, newvalue) # If everything checks out, update form!! From 7502065c13125eb40f0cd34e8cc6c1974999c8a2 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Wed, 13 Jun 2018 18:59:09 -0700 Subject: [PATCH 334/466] Documentation and initial formdata - 'look' broken? --- evennia/contrib/fieldfill.py | 128 ++++++++++++++++++++--------------- 1 file changed, 72 insertions(+), 56 deletions(-) diff --git a/evennia/contrib/fieldfill.py b/evennia/contrib/fieldfill.py index b7e10c63cc..0dea4448f9 100644 --- a/evennia/contrib/fieldfill.py +++ b/evennia/contrib/fieldfill.py @@ -11,6 +11,12 @@ character lengths, or can even be verified by a custom function. Once the form is submitted, the form's data is submitted as a dictionary to any callable of your choice. +The function that initializes the fillable form menu is fairly simple, and +includes the caller, the template for the form, and the callback which the form +data will be sent to upon submission: + + init_fill_field(formtemplate, caller, formcallback) + Form templates are defined as a list of dictionaries - each dictionary represents a field in the form, and contains the data for the field's name and behavior. For example, this basic form template will allow a player to fill out @@ -106,16 +112,16 @@ CmdTestMenu to your default character's command set. FIELD TEMPLATE KEYS: Required: - fieldname (str): Name of the field, as presented to the player - fieldtype (str):Type of value required, either 'text' or 'number' + fieldname (str): Name of the field, as presented to the player. + fieldtype (str):Type of value required, either 'text' or 'number'. Optional: - max (int): Maximum character length (if text) or value (if number) - min (int): Minimum charater length (if text) or value (if number) - default (str): Initial value (blank if not given) - blankmsg (str): Message to show in place of value when field is blank - cantclear (bool): Field can't be cleared if True - required (bool): If True, form cannot be submitted while field is blank + max (int): Maximum character length (if text) or value (if number). + min (int): Minimum charater length (if text) or value (if number). + default (str): Initial value (blank if not given). + blankmsg (str): Message to show in place of value when field is blank. + cantclear (bool): Field can't be cleared if True. + required (bool): If True, form cannot be submitted while field is blank. verifyfunc (callable): Name of a callable used to verify input - takes (caller, value) as arguments. If the function returns True, the player's input is considered valid - if it returns False, @@ -151,31 +157,40 @@ class FieldEvMenu(evmenu.EvMenu): return nodetext -def init_fill_field(formtemplate, caller, callback, pretext="", posttext="", - submitcmd="submit", borderstyle="cells", helptext=None): +def init_fill_field(formtemplate, caller, formcallback, pretext="", posttext="", + submitcmd="submit", borderstyle="cells", formhelptext=None, + initial_formdata=None): """ Initializes a menu presenting a player with a fillable form - once the form is submitted, the data will be passed as a dictionary to your chosen function. Args: - formtemplate (list of dicts): The template for the form's fields - caller (obj): Player who will be filling out the form - callback (callable): Function to pass the completed form's data to + formtemplate (list of dicts): The template for the form's fields. + caller (obj): Player who will be filling out the form. + formcallback (callable): Function to pass the completed form's data to. Options: - pretext (str): Text to put before the form in the menu - posttext (str): Text to put after the form in the menu - submitcmd (str): Command used to submit the form - borderstyle (str): Form's EvTable border style - helptext (str): Help text for the form menu (or default is provided) + pretext (str): Text to put before the form in the menu. + posttext (str): Text to put after the form in the menu. + submitcmd (str): Command used to submit the form. + borderstyle (str): Form's EvTable border style. + formhelptext (str): Help text for the form menu (or default is provided). + initial_formdata (dict): Initial data for the form - a blank form with + defaults specified in the template will be generated otherwise. + In the case of a form used to edit properties on an object or a + similar application, you may want to generate the initial form + data dynamically before calling init_fill_field. """ - # Initialize form data from the template - blank_formdata = form_template_to_dict(formtemplate) + + # Initialize form data from the template if none provided + formdata = form_template_to_dict(formtemplate) + if initial_formdata: + formdata = initial_formdata # Provide default help text if none given - if helptext == None: - helptext = ("Available commands:|/" + if formhelptext == None: + formhelptext = ("Available commands:|/" "|w = :|n Set given field to new value, replacing the old value|/" "|wclear :|n Clear the value in the given field, making it blank|/" "|wlook|n: Show the form's current values|/" @@ -185,14 +200,14 @@ def init_fill_field(formtemplate, caller, callback, pretext="", posttext="", # Pass kwargs to store data needed in the menu kwargs = { - "formdata":blank_formdata, + "formdata":formdata, "formtemplate": formtemplate, - "callback": callback, + "formcallback": formcallback, "pretext": pretext, "posttext": posttext, "submitcmd": submitcmd, "borderstyle": borderstyle, - "helptext": helptext + "formhelptext": formhelptext } # Initialize menu of selections @@ -209,19 +224,19 @@ def menunode_fieldfill(caller, raw_string, **kwargs): # Retrieve menu info formdata = caller.ndb._menutree.formdata formtemplate = caller.ndb._menutree.formtemplate - callback = caller.ndb._menutree.callback + formcallback = caller.ndb._menutree.formcallback pretext = caller.ndb._menutree.pretext posttext = caller.ndb._menutree.posttext submitcmd = caller.ndb._menutree.submitcmd borderstyle = caller.ndb._menutree.borderstyle - helptext = caller.ndb._menutree.helptext + formhelptext = caller.ndb._menutree.formhelptext # Syntax error syntax_err = "Syntax: = |/Or: clear , help, look, quit|/'%s' to submit form" % submitcmd # Display current form data text = (display_formdata(formtemplate, formdata, pretext=pretext, - posttext=posttext, borderstyle=borderstyle), helptext) + posttext=posttext, borderstyle=borderstyle), formhelptext) options = ({"key": "_default", "goto":"menunode_fieldfill"}) @@ -238,21 +253,22 @@ def menunode_fieldfill(caller, raw_string, **kwargs): blank_and_required.append(field["fieldname"]) if len(blank_and_required) > 0: caller.msg("The following blank fields require a value: %s" % list_to_string(blank_and_required)) - text = (None, helptext) + text = (None, formhelptext) return text, options # If everything checks out, pass form data to the callback and end the menu! - callback(caller, formdata) + formcallback(caller, formdata) return None, None # Test for 'look' command - if raw_string.lower().strip() == "look" or raw_string.lower().strip() == "l": + if raw_string.lower().strip() == "look": + caller.msg(syntax_err) return text, options # Test for 'clear' command cleartest = raw_string.lower().strip().split(" ", 1) if cleartest[0].lower() == "clear": - text = (None, helptext) + text = (None, formhelptext) if len(cleartest) < 2: caller.msg(syntax_err) return text, options @@ -264,7 +280,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): if not matched_field: caller.msg("Field '%s' does not exist!" % cleartest[1]) - text = (None, helptext) + text = (None, formhelptext) return text, options # Test to see if field can be cleared @@ -272,7 +288,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): if field["fieldname"] == matched_field and "cantclear" in field.keys(): if field["cantclear"] is True: caller.msg("Field '%s' can't be cleared!" % matched_field) - text = (None, helptext) + text = (None, formhelptext) return text, options # Clear the field @@ -282,7 +298,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): return text, options if "=" not in raw_string: - text = (None, helptext) + text = (None, formhelptext) caller.msg(syntax_err) return text, options @@ -294,7 +310,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): # Syntax error of field name is too short or blank if len(fieldname) < 1: caller.msg(syntax_err) - text = (None, helptext) + text = (None, formhelptext) return text, options # Attempt to match field name to field in form data @@ -306,7 +322,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): # No matched field if matched_field == None: caller.msg("Field '%s' does not exist!" % fieldname) - text = (None, helptext) + text = (None, formhelptext) return text, options # Set new field value if match @@ -331,12 +347,12 @@ def menunode_fieldfill(caller, raw_string, **kwargs): if max_value != None: if len(newvalue) > max_value: caller.msg("Field '%s' has a maximum length of %i characters." % (matched_field, max_value)) - text = (None, helptext) + text = (None, formhelptext) return text, options if min_value != None: if len(newvalue) < min_value: caller.msg("Field '%s' reqiures a minimum length of %i characters." % (matched_field, min_value)) - text = (None, helptext) + text = (None, formhelptext) return text, options # Field type number update @@ -345,25 +361,25 @@ def menunode_fieldfill(caller, raw_string, **kwargs): newvalue = int(newvalue) except: caller.msg("Field '%s' requires a number." % matched_field) - text = (None, helptext) + text = (None, formhelptext) return text, options # Test for max/min if max_value != None: if newvalue > max_value: caller.msg("Field '%s' has a maximum value of %i." % (matched_field, max_value)) - text = (None, helptext) + text = (None, formhelptext) return text, options if min_value != None: if newvalue < min_value: caller.msg("Field '%s' reqiures a minimum value of %i." % (matched_field, min_value)) - text = (None, helptext) + text = (None, formhelptext) return text, options # Call verify function if present if verifyfunc: if verifyfunc(caller, newvalue) is False: # No error message is given - should be provided by verifyfunc - text = (None, helptext) + text = (None, formhelptext) return text, options elif verifyfunc(caller, newvalue) is not True: newvalue = verifyfunc(caller, newvalue) @@ -372,7 +388,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): formdata.update({matched_field:newvalue}) caller.ndb._menutree.formdata = formdata caller.msg("Field '%s' set to: %s" % (matched_field, str(newvalue))) - text = (None, helptext) + text = (None, formhelptext) return text, options @@ -382,10 +398,10 @@ def form_template_to_dict(formtemplate): form template, as formatted above. Args: - formtemplate (list of dicts): Tempate for the form to be initialized + formtemplate (list of dicts): Tempate for the form to be initialized. Returns: - formdata (dict): Dictionary of initalized form data + formdata (dict): Dictionary of initalized form data. """ formdata = {} @@ -409,9 +425,9 @@ def display_formdata(formtemplate, formdata, formdata (dict): Form's current data Options: - pretext (str): Text to put before the form table - posttext (str): Text to put after the form table - borderstyle (str): EvTable's border style + pretext (str): Text to put before the form table. + posttext (str): Text to put after the form table. + borderstyle (str): EvTable's border style. """ formtable = evtable.EvTable(border=borderstyle, valign="t", maxwidth=80) @@ -450,8 +466,8 @@ def verify_online_player(caller, value): or else rejects their input as invalid. Args: - caller (obj): Player entering the form data - value (str): String player entered into the form, to be verified + caller (obj): Player entering the form data. + value (str): String player entered into the form, to be verified. Returns: matched_character (obj or False): dbref to a currently logged in @@ -534,8 +550,8 @@ def sendmessage(obj, text): Callback to send a message to a player. Args: - obj (obj): Player to message - text (str): Message + obj (obj): Player to message. + text (str): Message. """ obj.msg(text) @@ -544,15 +560,15 @@ def init_delayed_message(caller, formdata): Initializes a delayed message, using data from the example form. Args: - caller (obj): Character submitting the message - formdata (dict): Data from submitted form + caller (obj): Character submitting the message. + formdata (dict): Data from submitted form. """ # Retrieve data from the filled out form. # We stored the character to message as an object ref using a verifyfunc # So we don't have to do any more searching or matching here! player_to_message = formdata["Character"] message_delay = formdata["Delay"] - message = ("Message from %s: " % caller) + formdata["Message"] + message = ("Message from %s: " % caller) + str(formdata["Message"]) caller.msg("Message sent to %s!" % player_to_message) # Make a deferred call to 'sendmessage' above. From 6490267701b4b8852a694212b2abc5f2597c3911 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Wed, 13 Jun 2018 19:03:47 -0700 Subject: [PATCH 335/466] Removing debug stuff --- evennia/contrib/fieldfill.py | 1 - 1 file changed, 1 deletion(-) diff --git a/evennia/contrib/fieldfill.py b/evennia/contrib/fieldfill.py index 0dea4448f9..09343dbf6f 100644 --- a/evennia/contrib/fieldfill.py +++ b/evennia/contrib/fieldfill.py @@ -262,7 +262,6 @@ def menunode_fieldfill(caller, raw_string, **kwargs): # Test for 'look' command if raw_string.lower().strip() == "look": - caller.msg(syntax_err) return text, options # Test for 'clear' command From 5155f543d2d56b1d144fccb2c4a48a59e5c6b356 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Wed, 13 Jun 2018 19:12:02 -0700 Subject: [PATCH 336/466] 'look' works proper now! --- evennia/contrib/fieldfill.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/fieldfill.py b/evennia/contrib/fieldfill.py index 09343dbf6f..c019f6db9a 100644 --- a/evennia/contrib/fieldfill.py +++ b/evennia/contrib/fieldfill.py @@ -211,7 +211,7 @@ def init_fill_field(formtemplate, caller, formcallback, pretext="", posttext="", } # Initialize menu of selections - FieldEvMenu(caller, "evennia.contrib.fieldfill", startnode="menunode_fieldfill", **kwargs) + FieldEvMenu(caller, "evennia.contrib.fieldfill", startnode="menunode_fieldfill", auto_look=False, **kwargs) def menunode_fieldfill(caller, raw_string, **kwargs): @@ -261,7 +261,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): return None, None # Test for 'look' command - if raw_string.lower().strip() == "look": + if raw_string.lower().strip() == "look" or raw_string.lower().strip() == "l": return text, options # Test for 'clear' command From c678db3bc90c7b37bf0d35029340d61fcde5a0d3 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Thu, 14 Jun 2018 13:40:53 -0700 Subject: [PATCH 337/466] Support for persistent EvMenu added --- evennia/contrib/fieldfill.py | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/evennia/contrib/fieldfill.py b/evennia/contrib/fieldfill.py index c019f6db9a..2233b66414 100644 --- a/evennia/contrib/fieldfill.py +++ b/evennia/contrib/fieldfill.py @@ -159,7 +159,7 @@ class FieldEvMenu(evmenu.EvMenu): def init_fill_field(formtemplate, caller, formcallback, pretext="", posttext="", submitcmd="submit", borderstyle="cells", formhelptext=None, - initial_formdata=None): + persistent=False, initial_formdata=None): """ Initializes a menu presenting a player with a fillable form - once the form is submitted, the data will be passed as a dictionary to your chosen @@ -176,6 +176,7 @@ def init_fill_field(formtemplate, caller, formcallback, pretext="", posttext="", submitcmd (str): Command used to submit the form. borderstyle (str): Form's EvTable border style. formhelptext (str): Help text for the form menu (or default is provided). + persistent (bool): Whether to make the EvMenu persistent across reboots. initial_formdata (dict): Initial data for the form - a blank form with defaults specified in the template will be generated otherwise. In the case of a form used to edit properties on an object or a @@ -211,7 +212,8 @@ def init_fill_field(formtemplate, caller, formcallback, pretext="", posttext="", } # Initialize menu of selections - FieldEvMenu(caller, "evennia.contrib.fieldfill", startnode="menunode_fieldfill", auto_look=False, **kwargs) + FieldEvMenu(caller, "evennia.contrib.fieldfill", startnode="menunode_fieldfill", + auto_look=False, persistent=persistent, **kwargs) def menunode_fieldfill(caller, raw_string, **kwargs): @@ -221,15 +223,25 @@ def menunode_fieldfill(caller, raw_string, **kwargs): submitted, the form data is passed to a callback as a dictionary. """ - # Retrieve menu info - formdata = caller.ndb._menutree.formdata - formtemplate = caller.ndb._menutree.formtemplate - formcallback = caller.ndb._menutree.formcallback - pretext = caller.ndb._menutree.pretext - posttext = caller.ndb._menutree.posttext - submitcmd = caller.ndb._menutree.submitcmd - borderstyle = caller.ndb._menutree.borderstyle - formhelptext = caller.ndb._menutree.formhelptext + # Retrieve menu info - taken from ndb if not persistent or db if persistent + if not caller.db._menutree: + formdata = caller.ndb._menutree.formdata + formtemplate = caller.ndb._menutree.formtemplate + formcallback = caller.ndb._menutree.formcallback + pretext = caller.ndb._menutree.pretext + posttext = caller.ndb._menutree.posttext + submitcmd = caller.ndb._menutree.submitcmd + borderstyle = caller.ndb._menutree.borderstyle + formhelptext = caller.ndb._menutree.formhelptext + else: + formdata = caller.db._menutree.formdata + formtemplate = caller.db._menutree.formtemplate + formcallback = caller.db._menutree.formcallback + pretext = caller.db._menutree.pretext + posttext = caller.db._menutree.posttext + submitcmd = caller.db._menutree.submitcmd + borderstyle = caller.db._menutree.borderstyle + formhelptext = caller.db._menutree.formhelptext # Syntax error syntax_err = "Syntax: = |/Or: clear , help, look, quit|/'%s' to submit form" % submitcmd From 7baa87854b54bd473bd7fa3a187335048a36a30f Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Thu, 14 Jun 2018 15:33:48 -0700 Subject: [PATCH 338/466] Add boolean field support --- evennia/contrib/fieldfill.py | 103 ++++++++++++++++++++++++++++++----- 1 file changed, 88 insertions(+), 15 deletions(-) diff --git a/evennia/contrib/fieldfill.py b/evennia/contrib/fieldfill.py index 2233b66414..2595f4eaf9 100644 --- a/evennia/contrib/fieldfill.py +++ b/evennia/contrib/fieldfill.py @@ -25,7 +25,7 @@ a brief character profile: PROFILE_TEMPLATE = [ {"fieldname":"Name", "fieldtype":"text"}, {"fieldname":"Age", "fieldtype":"number"}, - {"fieldname":"History", "fieldtype":"text"} + {"fieldname":"History", "fieldtype":"text"}, ] This will present the player with an EvMenu showing this basic form: @@ -102,7 +102,10 @@ maximum character length for the player's input. There are lots of ways to present the form to the player - fields can have default values or show a custom message in place of a blank value, and player input can be -verified by a custom function, allowing for a great deal of flexibility. +verified by a custom function, allowing for a great deal of flexibility. There +is also an option for 'bool' fields, which accept only a True / False input and +can be customized to represent the choice to the player however you like (E.G. +Yes/No, On/Off, Enabled/Disabled, etc.) This module contains a simple example form that demonstrates all of the included functionality - a command that allows a player to compose a message to another @@ -113,11 +116,15 @@ CmdTestMenu to your default character's command set. FIELD TEMPLATE KEYS: Required: fieldname (str): Name of the field, as presented to the player. - fieldtype (str):Type of value required, either 'text' or 'number'. + fieldtype (str): Type of value required: 'text', 'number', or 'bool'. Optional: max (int): Maximum character length (if text) or value (if number). min (int): Minimum charater length (if text) or value (if number). + truestr (str): String for a 'True' value in a bool field. + (E.G. 'On', 'Enabled', 'Yes') + falsestr (str): String for a 'False' value in a bool field. + (E.G. 'Off', 'Disabled', 'No') default (str): Initial value (blank if not given). blankmsg (str): Message to show in place of value when field is blank. cantclear (bool): Field can't be cleared if True. @@ -128,7 +135,8 @@ Optional: the input is rejected. Any other value returned will act as the field's new value, replacing the player's input. This allows for values that aren't strings or integers (such as - object dbrefs). + object dbrefs). For boolean fields, return '0' or '1' to set + the field to False or True. """ from evennia.utils import evmenu, evtable, delay, list_to_string @@ -264,12 +272,16 @@ def menunode_fieldfill(caller, raw_string, **kwargs): # Add to blank and required fields blank_and_required.append(field["fieldname"]) if len(blank_and_required) > 0: + # List the required fields left empty to the player caller.msg("The following blank fields require a value: %s" % list_to_string(blank_and_required)) text = (None, formhelptext) return text, options # If everything checks out, pass form data to the callback and end the menu! - formcallback(caller, formdata) + try: + formcallback(caller, formdata) + except Exception: + log_trace("Error in fillable form callback.") return None, None # Test for 'look' command @@ -318,7 +330,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): fieldname = entry[0].strip() newvalue = entry[1].strip() - # Syntax error of field name is too short or blank + # Syntax error if field name is too short or blank if len(fieldname) < 1: caller.msg(syntax_err) text = (None, formhelptext) @@ -341,6 +353,8 @@ def menunode_fieldfill(caller, raw_string, **kwargs): fieldtype = None max_value = None min_value = None + truestr = "True" + falsestr = "False" verifyfunc = None for field in formtemplate: if field["fieldname"] == matched_field: @@ -349,10 +363,14 @@ def menunode_fieldfill(caller, raw_string, **kwargs): max_value = field["max"] if "min" in field.keys(): min_value = field["min"] + if "truestr" in field.keys(): + truestr = field["truestr"] + if "falsestr" in field.keys(): + falsestr = field["falsestr"] if "verifyfunc" in field.keys(): verifyfunc = field["verifyfunc"] - # Field type text update + # Field type text verification if fieldtype == "text": # Test for max/min if max_value != None: @@ -366,7 +384,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): text = (None, formhelptext) return text, options - # Field type number update + # Field type number verification if fieldtype == "number": try: newvalue = int(newvalue) @@ -385,6 +403,17 @@ def menunode_fieldfill(caller, raw_string, **kwargs): caller.msg("Field '%s' reqiures a minimum value of %i." % (matched_field, min_value)) text = (None, formhelptext) return text, options + + # Field type bool verification + if fieldtype == "bool": + if newvalue.lower() != truestr.lower() and newvalue.lower() != falsestr.lower(): + caller.msg("Please enter '%s' or '%s' for field '%s'." % (truestr, falsestr, matched_field)) + text = (None, formhelptext) + return text, options + if newvalue.lower() == truestr.lower(): + newvalue = True + elif newvalue.lower() == falsestr.lower(): + newvalue = False # Call verify function if present if verifyfunc: @@ -394,11 +423,26 @@ def menunode_fieldfill(caller, raw_string, **kwargs): return text, options elif verifyfunc(caller, newvalue) is not True: newvalue = verifyfunc(caller, newvalue) + # Set '0' or '1' to True or False if the field type is bool + if fieldtype == "bool": + if newvalue == 0: + newvalue = False + elif newvalue == 1: + newvalue = True # If everything checks out, update form!! formdata.update({matched_field:newvalue}) caller.ndb._menutree.formdata = formdata - caller.msg("Field '%s' set to: %s" % (matched_field, str(newvalue))) + + # Account for truestr and falsestr when updating a boolean form + announced_newvalue = newvalue + if newvalue is True: + announced_newvalue = truestr + elif newvalue is False: + announced_newvalue = falsestr + + # Announce the new value to the player + caller.msg("Field '%s' set to: %s" % (matched_field, str(announced_newvalue))) text = (None, formhelptext) return text, options @@ -459,11 +503,15 @@ def display_formdata(formtemplate, formdata, new_fieldvalue = "|x" + str(field["blankmsg"]) + "|n" elif new_fieldvalue == None: new_fieldvalue = " " + # Replace True and False values with truestr and falsestr from template + if formdata[field["fieldname"]] is True and "truestr" in field: + new_fieldvalue = field["truestr"] + elif formdata[field["fieldname"]] is False and "falsestr" in field: + new_fieldvalue = field["falsestr"] # Add name and value to table formtable.add_row(new_fieldname, new_fieldvalue) formtable.reformat_column(0, align="r", width=field_name_width) - # formtable.reformat_column(1, pad_left=0) return pretext + "|/" + str(formtable) + "|/" + posttext @@ -518,10 +566,32 @@ def verify_online_player(caller, value): # Form template for the example 'delayed message' form SAMPLE_FORM = [ -{"fieldname":"Character", "fieldtype":"text", "max":30, "blankmsg":"(Name of an online player)", - "required":True, "verifyfunc":verify_online_player}, -{"fieldname":"Delay", "fieldtype":"number", "min":3, "max":30, "default":10, "cantclear":True}, -{"fieldname":"Message", "fieldtype":"text", "min":3, "max":200, "blankmsg":"(Message up to 200 characters)"} +{"fieldname":"Character", + "fieldtype":"text", + "max":30, + "blankmsg":"(Name of an online player)", + "required":True, + "verifyfunc":verify_online_player + }, +{"fieldname":"Delay", + "fieldtype":"number", + "min":3, + "max":30, + "default":10, + "cantclear":True + }, +{"fieldname":"Message", + "fieldtype":"text", + "min":3, + "max":200, + "blankmsg":"(Message up to 200 characters)" + }, +{"fieldname":"Anonymous", + "fieldtype":"bool", + "truestr":"Yes", + "falsestr":"No", + "default":False + } ] class CmdTestMenu(Command): @@ -579,7 +649,10 @@ def init_delayed_message(caller, formdata): # So we don't have to do any more searching or matching here! player_to_message = formdata["Character"] message_delay = formdata["Delay"] - message = ("Message from %s: " % caller) + str(formdata["Message"]) + sender = str(caller) + if formdata["Anonymous"] is True: + sender = "anonymous" + message = ("Message from %s: " % sender) + str(formdata["Message"]) caller.msg("Message sent to %s!" % player_to_message) # Make a deferred call to 'sendmessage' above. From 2eaae6ac483b717e9c75819d2e68abf881593ae0 Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 15 Jun 2018 23:45:55 +0200 Subject: [PATCH 339/466] Work on resolving inlinefunc errors #1498 --- evennia/prototypes/protfuncs.py | 9 ++++++++- evennia/utils/inlinefuncs.py | 32 +++++++++++++++++--------------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/evennia/prototypes/protfuncs.py b/evennia/prototypes/protfuncs.py index 853634d9f6..6e9c7e5679 100644 --- a/evennia/prototypes/protfuncs.py +++ b/evennia/prototypes/protfuncs.py @@ -87,7 +87,14 @@ def protfunc_parser(value, available_functions=None, **kwargs): if not isinstance(value, basestring): return value available_functions = _PROT_FUNCS if available_functions is None else available_functions - result = inlinefuncs.parse_inlinefunc(value, _available_funcs=available_functions, **kwargs) + result = inlinefuncs.parse_inlinefunc(value, available_funcs=available_functions, **kwargs) + # at this point we have a string where all procfuncs were parsed + try: + result = literal_eval(result) + except ValueError: + # this is due to the string not being valid for literal_eval - keep it a string + pass + result = _PROTLIB.value_to_obj_or_any(result) try: return literal_eval(result) diff --git a/evennia/utils/inlinefuncs.py b/evennia/utils/inlinefuncs.py index 2646fb3991..575baf281f 100644 --- a/evennia/utils/inlinefuncs.py +++ b/evennia/utils/inlinefuncs.py @@ -157,6 +157,9 @@ def clr(*args, **kwargs): return text +def null(*args, **kwargs): + return args[0] if args else '' + # we specify a default nomatch function to use if no matching func was # found. This will be overloaded by any nomatch function defined in # the imported modules. @@ -177,10 +180,6 @@ for module in utils.make_iter(settings.INLINEFUNC_MODULES): raise -# remove the core function if we include examples in this module itself -#_INLINE_FUNCS.pop("inline_func_parse", None) - - # The stack size is a security measure. Set to <=0 to disable. try: _STACK_MAXSIZE = settings.INLINEFUNC_STACK_MAXSIZE @@ -198,7 +197,7 @@ _RE_TOKEN = re.compile(r""" (?P(?(?\\'|\\"|\\\)|\\$\w+\()| # escaped tokens should re-appear in text - (?P[\w\s.-\/#!%\^&\*;:=\-_`~\|\(}{\[\]]+|\"{1}|\'{1}) # everything else should also be included""", + (?P[\w\s.-\/#@$\>\ 0 and _STACK_MAXSIZE < len(stack): # if stack is larger than limit, throw away parsing return string + gdict["stackfull"](*args, **kwargs) - else: - # cache the stack + elif usecache: + # cache the stack - we do this also if we don't check the cache above _PARSING_CACHE[string] = stack # run the stack recursively @@ -368,8 +370,8 @@ def parse_inlinefunc(string, strip=False, _available_funcs=None, **kwargs): retval = "" if strip else func(*args, **kwargs) return utils.to_str(retval, force_string=True) - # execute the stack from the cache - return "".join(_run_stack(item) for item in _PARSING_CACHE[string]) + # execute the stack + return "".join(_run_stack(item) for item in stack) # # Nick templating From d047f2b91971eaccb641a65ca364277ba62079d9 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 16 Jun 2018 22:14:28 +0200 Subject: [PATCH 340/466] Handle missing characters in inlinefunc as per #1498 --- evennia/utils/inlinefuncs.py | 942 +++++++++++++++++------------------ 1 file changed, 471 insertions(+), 471 deletions(-) diff --git a/evennia/utils/inlinefuncs.py b/evennia/utils/inlinefuncs.py index 575baf281f..de03e13c2d 100644 --- a/evennia/utils/inlinefuncs.py +++ b/evennia/utils/inlinefuncs.py @@ -1,471 +1,471 @@ -""" -Inline functions (nested form). - -This parser accepts nested inlinefunctions on the form - -``` -$funcname(arg, arg, ...) -``` - -embedded in any text where any arg can be another $funcname{} call. -This functionality is turned off by default - to activate, -`settings.INLINEFUNC_ENABLED` must be set to `True`. - -Each token starts with "$funcname(" where there must be no space -between the $funcname and (. It ends with a matched ending parentesis. -")". - -Inside the inlinefunc definition, one can use `\` to escape. This is -mainly needed for escaping commas in flowing text (which would -otherwise be interpreted as an argument separator), or to escape `}` -when not intended to close the function block. Enclosing text in -matched `\"\"\"` (triple quotes) or `'''` (triple single-quotes) will -also escape *everything* within without needing to escape individual -characters. - -The available inlinefuncs are defined as global-level functions in -modules defined by `settings.INLINEFUNC_MODULES`. They are identified -by their function name (and ignored if this name starts with `_`). They -should be on the following form: - -```python -def funcname (*args, **kwargs): - # ... -``` - -Here, the arguments given to `$funcname(arg1,arg2)` will appear as the -`*args` tuple. This will be populated by the arguments given to the -inlinefunc in-game - the only part that will be available from -in-game. `**kwargs` are not supported from in-game but are only used -internally by Evennia to make details about the caller available to -the function. The kwarg passed to all functions is `session`, the -Sessionobject for the object seeing the string. This may be `None` if -the string is sent to a non-puppetable object. The inlinefunc should -never raise an exception. - -There are two reserved function names: -- "nomatch": This is called if the user uses a functionname that is - not registered. The nomatch function will get the name of the - not-found function as its first argument followed by the normal - arguments to the given function. If not defined the default effect is - to print `` to replace the unknown function. -- "stackfull": This is called when the maximum nested function stack is reached. - When this happens, the original parsed string is returned and the result of - the `stackfull` inlinefunc is appended to the end. By default this is an - error message. - -Error handling: - Syntax errors, notably not completely closing all inlinefunc - blocks, will lead to the entire string remaining unparsed. - -""" - -import re -from django.conf import settings -from evennia.utils import utils - - -# example/testing inline functions - -def pad(*args, **kwargs): - """ - Inlinefunc. Pads text to given width. - - Args: - text (str, optional): Text to pad. - width (str, optional): Will be converted to integer. Width - of padding. - align (str, optional): Alignment of padding; one of 'c', 'l' or 'r'. - fillchar (str, optional): Character used for padding. Defaults to a space. - - Kwargs: - session (Session): Session performing the pad. - - Example: - `$pad(text, width, align, fillchar)` - - """ - text, width, align, fillchar = "", 78, 'c', ' ' - nargs = len(args) - if nargs > 0: - text = args[0] - if nargs > 1: - width = int(args[1]) if args[1].strip().isdigit() else 78 - if nargs > 2: - align = args[2] if args[2] in ('c', 'l', 'r') else 'c' - if nargs > 3: - fillchar = args[3] - return utils.pad(text, width=width, align=align, fillchar=fillchar) - - -def crop(*args, **kwargs): - """ - Inlinefunc. Crops ingoing text to given widths. - - Args: - text (str, optional): Text to crop. - width (str, optional): Will be converted to an integer. Width of - crop in characters. - suffix (str, optional): End string to mark the fact that a part - of the string was cropped. Defaults to `[...]`. - Kwargs: - session (Session): Session performing the crop. - - Example: - `$crop(text, width=78, suffix='[...]')` - - """ - text, width, suffix = "", 78, "[...]" - nargs = len(args) - if nargs > 0: - text = args[0] - if nargs > 1: - width = int(args[1]) if args[1].strip().isdigit() else 78 - if nargs > 2: - suffix = args[2] - return utils.crop(text, width=width, suffix=suffix) - - -def clr(*args, **kwargs): - """ - Inlinefunc. Colorizes nested text. - - Args: - startclr (str, optional): An ANSI color abbreviation without the - prefix `|`, such as `r` (red foreground) or `[r` (red background). - text (str, optional): Text - endclr (str, optional): The color to use at the end of the string. Defaults - to `|n` (reset-color). - Kwargs: - session (Session): Session object triggering inlinefunc. - - Example: - `$clr(startclr, text, endclr)` - - """ - text = "" - nargs = len(args) - if nargs > 0: - color = args[0].strip() - if nargs > 1: - text = args[1] - text = "|" + color + text - if nargs > 2: - text += "|" + args[2].strip() - else: - text += "|n" - return text - - -def null(*args, **kwargs): - return args[0] if args else '' - -# we specify a default nomatch function to use if no matching func was -# found. This will be overloaded by any nomatch function defined in -# the imported modules. -_INLINE_FUNCS = {"nomatch": lambda *args, **kwargs: "", - "stackfull": lambda *args, **kwargs: "\n (not parsed: inlinefunc stack size exceeded.)"} - - -# load custom inline func modules. -for module in utils.make_iter(settings.INLINEFUNC_MODULES): - try: - _INLINE_FUNCS.update(utils.callables_from_module(module)) - except ImportError as err: - if module == "server.conf.inlinefuncs": - # a temporary warning since the default module changed name - raise ImportError("Error: %s\nPossible reason: mygame/server/conf/inlinefunc.py should " - "be renamed to mygame/server/conf/inlinefuncs.py (note the S at the end)." % err) - else: - raise - - -# The stack size is a security measure. Set to <=0 to disable. -try: - _STACK_MAXSIZE = settings.INLINEFUNC_STACK_MAXSIZE -except AttributeError: - _STACK_MAXSIZE = 20 - -# regex definitions - -_RE_STARTTOKEN = re.compile(r"(?.*?)(?.*?)(?(?(?(?\\'|\\"|\\\)|\\$\w+\()| # escaped tokens should re-appear in text - (?P[\w\s.-\/#@$\>\ 0: - # commas outside strings and inside a callable are - # used to mark argument separation - we use None - # in the stack to indicate such a separation. - stack.append(None) - else: - # no callable active - just a string - stack.append(",") - else: - # the rest - stack.append(gdict["rest"]) - - if ncallable > 0: - # this means not all inlinefuncs were complete - return string - - if _STACK_MAXSIZE > 0 and _STACK_MAXSIZE < len(stack): - # if stack is larger than limit, throw away parsing - return string + gdict["stackfull"](*args, **kwargs) - elif usecache: - # cache the stack - we do this also if we don't check the cache above - _PARSING_CACHE[string] = stack - - # run the stack recursively - def _run_stack(item, depth=0): - retval = item - if isinstance(item, tuple): - if strip: - return "" - else: - func, arglist = item - args = [""] - for arg in arglist: - if arg is None: - # an argument-separating comma - start a new arg - args.append("") - else: - # all other args should merge into one string - args[-1] += _run_stack(arg, depth=depth + 1) - # execute the inlinefunc at this point or strip it. - kwargs["inlinefunc_stack_depth"] = depth - retval = "" if strip else func(*args, **kwargs) - return utils.to_str(retval, force_string=True) - - # execute the stack - return "".join(_run_stack(item) for item in stack) - -# -# Nick templating -# - - -""" -This supports the use of replacement templates in nicks: - -This happens in two steps: - -1) The user supplies a template that is converted to a regex according - to the unix-like templating language. -2) This regex is tested against nicks depending on which nick replacement - strategy is considered (most commonly inputline). -3) If there is a template match and there are templating markers, - these are replaced with the arguments actually given. - -@desc $1 $2 $3 - -This will be converted to the following regex: - -\@desc (?P<1>\w+) (?P<2>\w+) $(?P<3>\w+) - -Supported template markers (through fnmatch) - * matches anything (non-greedy) -> .*? - ? matches any single character -> - [seq] matches any entry in sequence - [!seq] matches entries not in sequence -Custom arg markers - $N argument position (1-99) - -""" -import fnmatch -_RE_NICK_ARG = re.compile(r"\\(\$)([1-9][0-9]?)") -_RE_NICK_TEMPLATE_ARG = re.compile(r"(\$)([1-9][0-9]?)") -_RE_NICK_SPACE = re.compile(r"\\ ") - - -class NickTemplateInvalid(ValueError): - pass - - -def initialize_nick_templates(in_template, out_template): - """ - Initialize the nick templates for matching and remapping a string. - - Args: - in_template (str): The template to be used for nick recognition. - out_template (str): The template to be used to replace the string - matched by the in_template. - - Returns: - regex (regex): Regex to match against strings - template (str): Template with markers {arg1}, {arg2}, etc for - replacement using the standard .format method. - - Raises: - NickTemplateInvalid: If the in/out template does not have a matching - number of $args. - - """ - # create the regex for in_template - regex_string = fnmatch.translate(in_template) - n_inargs = len(_RE_NICK_ARG.findall(regex_string)) - regex_string = _RE_NICK_SPACE.sub("\s+", regex_string) - regex_string = _RE_NICK_ARG.sub(lambda m: "(?P.+?)" % m.group(2), regex_string) - - # create the out_template - template_string = _RE_NICK_TEMPLATE_ARG.sub(lambda m: "{arg%s}" % m.group(2), out_template) - - # validate the tempaltes - they should at least have the same number of args - n_outargs = len(_RE_NICK_TEMPLATE_ARG.findall(out_template)) - if n_inargs != n_outargs: - print n_inargs, n_outargs - raise NickTemplateInvalid - - return re.compile(regex_string), template_string - - -def parse_nick_template(string, template_regex, outtemplate): - """ - Parse a text using a template and map it to another template - - Args: - string (str): The input string to processj - template_regex (regex): A template regex created with - initialize_nick_template. - outtemplate (str): The template to which to map the matches - produced by the template_regex. This should have $1, $2, - etc to match the regex. - - """ - match = template_regex.match(string) - if match: - return outtemplate.format(**match.groupdict()) - return string +""" +Inline functions (nested form). + +This parser accepts nested inlinefunctions on the form + +``` +$funcname(arg, arg, ...) +``` + +embedded in any text where any arg can be another $funcname{} call. +This functionality is turned off by default - to activate, +`settings.INLINEFUNC_ENABLED` must be set to `True`. + +Each token starts with "$funcname(" where there must be no space +between the $funcname and (. It ends with a matched ending parentesis. +")". + +Inside the inlinefunc definition, one can use `\` to escape. This is +mainly needed for escaping commas in flowing text (which would +otherwise be interpreted as an argument separator), or to escape `}` +when not intended to close the function block. Enclosing text in +matched `\"\"\"` (triple quotes) or `'''` (triple single-quotes) will +also escape *everything* within without needing to escape individual +characters. + +The available inlinefuncs are defined as global-level functions in +modules defined by `settings.INLINEFUNC_MODULES`. They are identified +by their function name (and ignored if this name starts with `_`). They +should be on the following form: + +```python +def funcname (*args, **kwargs): + # ... +``` + +Here, the arguments given to `$funcname(arg1,arg2)` will appear as the +`*args` tuple. This will be populated by the arguments given to the +inlinefunc in-game - the only part that will be available from +in-game. `**kwargs` are not supported from in-game but are only used +internally by Evennia to make details about the caller available to +the function. The kwarg passed to all functions is `session`, the +Sessionobject for the object seeing the string. This may be `None` if +the string is sent to a non-puppetable object. The inlinefunc should +never raise an exception. + +There are two reserved function names: +- "nomatch": This is called if the user uses a functionname that is + not registered. The nomatch function will get the name of the + not-found function as its first argument followed by the normal + arguments to the given function. If not defined the default effect is + to print `` to replace the unknown function. +- "stackfull": This is called when the maximum nested function stack is reached. + When this happens, the original parsed string is returned and the result of + the `stackfull` inlinefunc is appended to the end. By default this is an + error message. + +Error handling: + Syntax errors, notably not completely closing all inlinefunc + blocks, will lead to the entire string remaining unparsed. + +""" + +import re +from django.conf import settings +from evennia.utils import utils + + +# example/testing inline functions + +def pad(*args, **kwargs): + """ + Inlinefunc. Pads text to given width. + + Args: + text (str, optional): Text to pad. + width (str, optional): Will be converted to integer. Width + of padding. + align (str, optional): Alignment of padding; one of 'c', 'l' or 'r'. + fillchar (str, optional): Character used for padding. Defaults to a space. + + Kwargs: + session (Session): Session performing the pad. + + Example: + `$pad(text, width, align, fillchar)` + + """ + text, width, align, fillchar = "", 78, 'c', ' ' + nargs = len(args) + if nargs > 0: + text = args[0] + if nargs > 1: + width = int(args[1]) if args[1].strip().isdigit() else 78 + if nargs > 2: + align = args[2] if args[2] in ('c', 'l', 'r') else 'c' + if nargs > 3: + fillchar = args[3] + return utils.pad(text, width=width, align=align, fillchar=fillchar) + + +def crop(*args, **kwargs): + """ + Inlinefunc. Crops ingoing text to given widths. + + Args: + text (str, optional): Text to crop. + width (str, optional): Will be converted to an integer. Width of + crop in characters. + suffix (str, optional): End string to mark the fact that a part + of the string was cropped. Defaults to `[...]`. + Kwargs: + session (Session): Session performing the crop. + + Example: + `$crop(text, width=78, suffix='[...]')` + + """ + text, width, suffix = "", 78, "[...]" + nargs = len(args) + if nargs > 0: + text = args[0] + if nargs > 1: + width = int(args[1]) if args[1].strip().isdigit() else 78 + if nargs > 2: + suffix = args[2] + return utils.crop(text, width=width, suffix=suffix) + + +def clr(*args, **kwargs): + """ + Inlinefunc. Colorizes nested text. + + Args: + startclr (str, optional): An ANSI color abbreviation without the + prefix `|`, such as `r` (red foreground) or `[r` (red background). + text (str, optional): Text + endclr (str, optional): The color to use at the end of the string. Defaults + to `|n` (reset-color). + Kwargs: + session (Session): Session object triggering inlinefunc. + + Example: + `$clr(startclr, text, endclr)` + + """ + text = "" + nargs = len(args) + if nargs > 0: + color = args[0].strip() + if nargs > 1: + text = args[1] + text = "|" + color + text + if nargs > 2: + text += "|" + args[2].strip() + else: + text += "|n" + return text + + +def null(*args, **kwargs): + return args[0] if args else '' + +# we specify a default nomatch function to use if no matching func was +# found. This will be overloaded by any nomatch function defined in +# the imported modules. +_INLINE_FUNCS = {"nomatch": lambda *args, **kwargs: "", + "stackfull": lambda *args, **kwargs: "\n (not parsed: inlinefunc stack size exceeded.)"} + + +# load custom inline func modules. +for module in utils.make_iter(settings.INLINEFUNC_MODULES): + try: + _INLINE_FUNCS.update(utils.callables_from_module(module)) + except ImportError as err: + if module == "server.conf.inlinefuncs": + # a temporary warning since the default module changed name + raise ImportError("Error: %s\nPossible reason: mygame/server/conf/inlinefunc.py should " + "be renamed to mygame/server/conf/inlinefuncs.py (note the S at the end)." % err) + else: + raise + + +# The stack size is a security measure. Set to <=0 to disable. +try: + _STACK_MAXSIZE = settings.INLINEFUNC_STACK_MAXSIZE +except AttributeError: + _STACK_MAXSIZE = 20 + +# regex definitions + +_RE_STARTTOKEN = re.compile(r"(?.*?)(?.*?)(?(?(?(?\\'|\\"|\\\)|\\$\w+\()| # escaped tokens should re-appear in text + (?P[\w\s.-\/#!%\^&\*;:=\-_`~\|\(}{\[\]@\$\\\+\<\>?]+|\"{1}|\'{1}) # everything else """, + re.UNICODE + re.IGNORECASE + re.VERBOSE + re.DOTALL) + +# Cache for function lookups. +_PARSING_CACHE = utils.LimitedSizeOrderedDict(size_limit=1000) + + +class ParseStack(list): + """ + Custom stack that always concatenates strings together when the + strings are added next to one another. Tuples are stored + separately and None is used to mark that a string should be broken + up into a new chunk. Below is the resulting stack after separately + appending 3 strings, None, 2 strings, a tuple and finally 2 + strings: + + [string + string + string, + None + string + string, + tuple, + string + string] + + """ + + def __init__(self, *args, **kwargs): + super(ParseStack, self).__init__(*args, **kwargs) + # always start stack with the empty string + list.append(self, "") + # indicates if the top of the stack is a string or not + self._string_last = True + + def __eq__(self, other): + return (super(ParseStack).__eq__(other) and + hasattr(other, "_string_last") and self._string_last == other._string_last) + + def __ne__(self, other): + return not self.__eq__(other) + + def append(self, item): + """ + The stack will merge strings, add other things as normal + """ + if isinstance(item, basestring): + if self._string_last: + self[-1] += item + else: + list.append(self, item) + self._string_last = True + else: + # everything else is added as normal + list.append(self, item) + self._string_last = False + + +class InlinefuncError(RuntimeError): + pass + + +def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs): + """ + Parse the incoming string. + + Args: + string (str): The incoming string to parse. + strip (bool, optional): Whether to strip function calls rather than + execute them. + available_funcs (dict, optional): Define an alternative source of functions to parse for. + If unset, use the functions found through `settings.INLINEFUNC_MODULES`. + Kwargs: + session (Session): This is sent to this function by Evennia when triggering + it. It is passed to the inlinefunc. + kwargs (any): All other kwargs are also passed on to the inlinefunc. + + + """ + global _PARSING_CACHE + + usecache = False + if not available_funcs: + available_funcs = _INLINE_FUNCS + usecache = True + + if usecache and string in _PARSING_CACHE: + # stack is already cached + stack = _PARSING_CACHE[string] + elif not _RE_STARTTOKEN.search(string): + # if there are no unescaped start tokens at all, return immediately. + return string + else: + # no cached stack; build a new stack and continue + stack = ParseStack() + + # process string on stack + ncallable = 0 + for match in _RE_TOKEN.finditer(string): + gdict = match.groupdict() + if gdict["singlequote"]: + stack.append(gdict["singlequote"]) + elif gdict["doublequote"]: + stack.append(gdict["doublequote"]) + elif gdict["end"]: + if ncallable <= 0: + stack.append(")") + continue + args = [] + while stack: + operation = stack.pop() + if callable(operation): + if not strip: + stack.append((operation, [arg for arg in reversed(args)])) + ncallable -= 1 + break + else: + args.append(operation) + elif gdict["start"]: + funcname = _RE_STARTTOKEN.match(gdict["start"]).group(1) + try: + # try to fetch the matching inlinefunc from storage + stack.append(available_funcs[funcname]) + except KeyError: + stack.append(available_funcs["nomatch"]) + stack.append(funcname) + ncallable += 1 + elif gdict["escaped"]: + # escaped tokens + token = gdict["escaped"].lstrip("\\") + stack.append(token) + elif gdict["comma"]: + if ncallable > 0: + # commas outside strings and inside a callable are + # used to mark argument separation - we use None + # in the stack to indicate such a separation. + stack.append(None) + else: + # no callable active - just a string + stack.append(",") + else: + # the rest + stack.append(gdict["rest"]) + + if ncallable > 0: + # this means not all inlinefuncs were complete + return string + + if _STACK_MAXSIZE > 0 and _STACK_MAXSIZE < len(stack): + # if stack is larger than limit, throw away parsing + return string + gdict["stackfull"](*args, **kwargs) + elif usecache: + # cache the stack - we do this also if we don't check the cache above + _PARSING_CACHE[string] = stack + + # run the stack recursively + def _run_stack(item, depth=0): + retval = item + if isinstance(item, tuple): + if strip: + return "" + else: + func, arglist = item + args = [""] + for arg in arglist: + if arg is None: + # an argument-separating comma - start a new arg + args.append("") + else: + # all other args should merge into one string + args[-1] += _run_stack(arg, depth=depth + 1) + # execute the inlinefunc at this point or strip it. + kwargs["inlinefunc_stack_depth"] = depth + retval = "" if strip else func(*args, **kwargs) + return utils.to_str(retval, force_string=True) + + print("STACK:\n{}".format(stack)) + # execute the stack + return "".join(_run_stack(item) for item in stack) + +# +# Nick templating +# + + +""" +This supports the use of replacement templates in nicks: + +This happens in two steps: + +1) The user supplies a template that is converted to a regex according + to the unix-like templating language. +2) This regex is tested against nicks depending on which nick replacement + strategy is considered (most commonly inputline). +3) If there is a template match and there are templating markers, + these are replaced with the arguments actually given. + +@desc $1 $2 $3 + +This will be converted to the following regex: + +\@desc (?P<1>\w+) (?P<2>\w+) $(?P<3>\w+) + +Supported template markers (through fnmatch) + * matches anything (non-greedy) -> .*? + ? matches any single character -> + [seq] matches any entry in sequence + [!seq] matches entries not in sequence +Custom arg markers + $N argument position (1-99) + +""" + +import fnmatch +_RE_NICK_ARG = re.compile(r"\\(\$)([1-9][0-9]?)") +_RE_NICK_TEMPLATE_ARG = re.compile(r"(\$)([1-9][0-9]?)") +_RE_NICK_SPACE = re.compile(r"\\ ") + + +class NickTemplateInvalid(ValueError): + pass + + +def initialize_nick_templates(in_template, out_template): + """ + Initialize the nick templates for matching and remapping a string. + + Args: + in_template (str): The template to be used for nick recognition. + out_template (str): The template to be used to replace the string + matched by the in_template. + + Returns: + regex (regex): Regex to match against strings + template (str): Template with markers {arg1}, {arg2}, etc for + replacement using the standard .format method. + + Raises: + NickTemplateInvalid: If the in/out template does not have a matching + number of $args. + + """ + # create the regex for in_template + regex_string = fnmatch.translate(in_template) + n_inargs = len(_RE_NICK_ARG.findall(regex_string)) + regex_string = _RE_NICK_SPACE.sub("\s+", regex_string) + regex_string = _RE_NICK_ARG.sub(lambda m: "(?P.+?)" % m.group(2), regex_string) + + # create the out_template + template_string = _RE_NICK_TEMPLATE_ARG.sub(lambda m: "{arg%s}" % m.group(2), out_template) + + # validate the tempaltes - they should at least have the same number of args + n_outargs = len(_RE_NICK_TEMPLATE_ARG.findall(out_template)) + if n_inargs != n_outargs: + raise NickTemplateInvalid + + return re.compile(regex_string), template_string + + +def parse_nick_template(string, template_regex, outtemplate): + """ + Parse a text using a template and map it to another template + + Args: + string (str): The input string to processj + template_regex (regex): A template regex created with + initialize_nick_template. + outtemplate (str): The template to which to map the matches + produced by the template_regex. This should have $1, $2, + etc to match the regex. + + """ + match = template_regex.match(string) + if match: + return outtemplate.format(**match.groupdict()) + return string From c5f1ea5781059f2138c8b29eaf0c9f1124a6ae5c Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 17 Jun 2018 01:22:24 +0200 Subject: [PATCH 341/466] Much improved inlinefunc regex; resolving #1498 --- evennia/utils/inlinefuncs.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/evennia/utils/inlinefuncs.py b/evennia/utils/inlinefuncs.py index de03e13c2d..a0ec65e001 100644 --- a/evennia/utils/inlinefuncs.py +++ b/evennia/utils/inlinefuncs.py @@ -188,17 +188,20 @@ except AttributeError: # regex definitions -_RE_STARTTOKEN = re.compile(r"(?.*?)(?.*?)(?(?(?(?\\'|\\"|\\\)|\\$\w+\()| # escaped tokens should re-appear in text - (?P[\w\s.-\/#!%\^&\*;:=\-_`~\|\(}{\[\]@\$\\\+\<\>?]+|\"{1}|\'{1}) # everything else """, - re.UNICODE + re.IGNORECASE + re.VERBOSE + re.DOTALL) + (?.*?)(?.*?)(?(?(?(? # escaped tokens to re-insert sans backslash + \\\'|\\\"|\\\)|\\\$\w+\()| + (?P # everything else to re-insert verbatim + \$(?!\w+\()|\'{1}|\"{1}|\\{1}|[^),$\'\"\\]+)""", + re.UNICODE | re.IGNORECASE | re.VERBOSE | re.DOTALL) # Cache for function lookups. _PARSING_CACHE = utils.LimitedSizeOrderedDict(size_limit=1000) @@ -293,6 +296,7 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs): ncallable = 0 for match in _RE_TOKEN.finditer(string): gdict = match.groupdict() + print("match: {}".format({key: val for key, val in gdict.items() if val})) if gdict["singlequote"]: stack.append(gdict["singlequote"]) elif gdict["doublequote"]: From 646b73e8729a4a13f9862fe0d709417006547abe Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 17 Jun 2018 08:37:29 +0200 Subject: [PATCH 342/466] Handle lone left-parents within inlinefunc --- evennia/utils/inlinefuncs.py | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/evennia/utils/inlinefuncs.py b/evennia/utils/inlinefuncs.py index a0ec65e001..4becfb7b01 100644 --- a/evennia/utils/inlinefuncs.py +++ b/evennia/utils/inlinefuncs.py @@ -61,6 +61,7 @@ Error handling: """ import re +import fnmatch from django.conf import settings from evennia.utils import utils @@ -164,7 +165,8 @@ def null(*args, **kwargs): # found. This will be overloaded by any nomatch function defined in # the imported modules. _INLINE_FUNCS = {"nomatch": lambda *args, **kwargs: "", - "stackfull": lambda *args, **kwargs: "\n (not parsed: inlinefunc stack size exceeded.)"} + "stackfull": lambda *args, **kwargs: "\n (not parsed: " + "inlinefunc stack size exceeded.)"} # load custom inline func modules. @@ -175,7 +177,8 @@ for module in utils.make_iter(settings.INLINEFUNC_MODULES): if module == "server.conf.inlinefuncs": # a temporary warning since the default module changed name raise ImportError("Error: %s\nPossible reason: mygame/server/conf/inlinefunc.py should " - "be renamed to mygame/server/conf/inlinefuncs.py (note the S at the end)." % err) + "be renamed to mygame/server/conf/inlinefuncs.py (note " + "the S at the end)." % err) else: raise @@ -190,17 +193,18 @@ except AttributeError: _RE_STARTTOKEN = re.compile(r"(?.*?)(?.*?)(?(?(?(?(?(? # escaped tokens to re-insert sans backslash - \\\'|\\\"|\\\)|\\\$\w+\()| + \\\'|\\\"|\\\)|\\\$\w+\(|\\\()| (?P # everything else to re-insert verbatim - \$(?!\w+\()|\'{1}|\"{1}|\\{1}|[^),$\'\"\\]+)""", + \$(?!\w+\()|\'|\"|\\|[^),$\'\"\\\(]+)""", re.UNICODE | re.IGNORECASE | re.VERBOSE | re.DOTALL) # Cache for function lookups. @@ -294,14 +298,24 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs): # process string on stack ncallable = 0 + nlparens = 0 for match in _RE_TOKEN.finditer(string): gdict = match.groupdict() - print("match: {}".format({key: val for key, val in gdict.items() if val})) + # print("match: {}".format({key: val for key, val in gdict.items() if val})) if gdict["singlequote"]: stack.append(gdict["singlequote"]) elif gdict["doublequote"]: stack.append(gdict["doublequote"]) + elif gdict["leftparens"]: + # we have a left-parens inside a callable + if ncallable: + nlparens += 1 + stack.append("(") elif gdict["end"]: + if nlparens > 0: + nlparens -= 1 + stack.append(")") + continue if ncallable <= 0: stack.append(")") continue @@ -373,7 +387,7 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs): retval = "" if strip else func(*args, **kwargs) return utils.to_str(retval, force_string=True) - print("STACK:\n{}".format(stack)) + # print("STACK:\n{}".format(stack)) # execute the stack return "".join(_run_stack(item) for item in stack) @@ -410,7 +424,6 @@ Custom arg markers """ -import fnmatch _RE_NICK_ARG = re.compile(r"\\(\$)([1-9][0-9]?)") _RE_NICK_TEMPLATE_ARG = re.compile(r"(\$)([1-9][0-9]?)") _RE_NICK_SPACE = re.compile(r"\\ ") From 721cdb5ae0f6284d725c35eff8e65d1881e38e2c Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 17 Jun 2018 23:42:53 +0200 Subject: [PATCH 343/466] Many more tests, debugging of protfuncs/inlinefuncs --- evennia/prototypes/protfuncs.py | 195 ++++++++++++++++++++----------- evennia/prototypes/prototypes.py | 69 ++++++++++- evennia/prototypes/tests.py | 54 ++++++++- evennia/utils/inlinefuncs.py | 26 +++-- evennia/utils/utils.py | 15 +-- 5 files changed, 267 insertions(+), 92 deletions(-) diff --git a/evennia/prototypes/protfuncs.py b/evennia/prototypes/protfuncs.py index 6e9c7e5679..5ecb4b5e7d 100644 --- a/evennia/prototypes/protfuncs.py +++ b/evennia/prototypes/protfuncs.py @@ -23,8 +23,8 @@ are specified as functions where *args are the arguments given in the prototype, and **kwargs are inserted by Evennia: - session (Session): The Session of the entity spawning using this prototype. - - prototype_key (str): The currently spawning prototype-key. - prototype (dict): The dict this protfunc is a part of. + - current_key (str): The active key this value belongs to in the prototype. - testing (bool): This is set if this function is called as part of the prototype validation; if set, the protfunc should take care not to perform any persistent actions, such as operate on objects or add things to the database. @@ -38,68 +38,10 @@ prototype key (this value must be possible to serialize in an Attribute). from ast import literal_eval from random import randint as base_randint, random as base_random -from django.conf import settings -from evennia.utils import inlinefuncs -from evennia.utils.utils import callables_from_module -from evennia.utils.utils import justify as base_justify, is_iter +from evennia.utils import search +from evennia.utils.utils import justify as base_justify, is_iter, to_str _PROTLIB = None -_PROT_FUNCS = {} - -for mod in settings.PROT_FUNC_MODULES: - try: - callables = callables_from_module(mod) - if mod == __name__: - callables.pop("protfunc_parser", None) - _PROT_FUNCS.update(callables) - except ImportError: - pass - - -def protfunc_parser(value, available_functions=None, **kwargs): - """ - Parse a prototype value string for a protfunc and process it. - - Available protfuncs are specified as callables in one of the modules of - `settings.PROTFUNC_MODULES`, or specified on the command line. - - Args: - value (any): The value to test for a parseable protfunc. Only strings will be parsed for - protfuncs, all other types are returned as-is. - available_functions (dict, optional): Mapping of name:protfunction to use for this parsing. - - Kwargs: - any (any): Passed on to the inlinefunc. - - Returns: - any (any): A structure to replace the string on the prototype level. If this is a - callable or a (callable, (args,)) structure, it will be executed as if one had supplied - it to the prototype directly. This structure is also passed through literal_eval so one - can get actual Python primitives out of it (not just strings). It will also identify - eventual object #dbrefs in the output from the protfunc. - - - """ - global _PROTLIB - if not _PROTLIB: - from evennia.prototypes import prototypes as _PROTLIB - - if not isinstance(value, basestring): - return value - available_functions = _PROT_FUNCS if available_functions is None else available_functions - result = inlinefuncs.parse_inlinefunc(value, available_funcs=available_functions, **kwargs) - # at this point we have a string where all procfuncs were parsed - try: - result = literal_eval(result) - except ValueError: - # this is due to the string not being valid for literal_eval - keep it a string - pass - - result = _PROTLIB.value_to_obj_or_any(result) - try: - return literal_eval(result) - except ValueError: - return result # default protfuncs @@ -180,7 +122,7 @@ def protkey(*args, **kwargs): """ if args: prototype = kwargs['prototype'] - return prototype[args[0]] + return prototype[args[0].strip()] def add(*args, **kwargs): @@ -193,7 +135,16 @@ def add(*args, **kwargs): """ if len(args) > 1: val1, val2 = args[0], args[1] - return literal_eval(val1) + literal_eval(val2) + # try to convert to python structures, otherwise, keep as strings + try: + val1 = literal_eval(val1.strip()) + except Exception: + pass + try: + val2 = literal_eval(val2.strip()) + except Exception: + pass + return val1 + val2 raise ValueError("$add requires two arguments.") @@ -207,11 +158,20 @@ def sub(*args, **kwargs): """ if len(args) > 1: val1, val2 = args[0], args[1] - return literal_eval(val1) - literal_eval(val2) + # try to convert to python structures, otherwise, keep as strings + try: + val1 = literal_eval(val1.strip()) + except Exception: + pass + try: + val2 = literal_eval(val2.strip()) + except Exception: + pass + return val1 - val2 raise ValueError("$sub requires two arguments.") -def mul(*args, **kwargs): +def mult(*args, **kwargs): """ Usage: $mul(val1, val2) Returns the value of val1 * val2. The values must be @@ -221,7 +181,16 @@ def mul(*args, **kwargs): """ if len(args) > 1: val1, val2 = args[0], args[1] - return literal_eval(val1) * literal_eval(val2) + # try to convert to python structures, otherwise, keep as strings + try: + val1 = literal_eval(val1.strip()) + except Exception: + pass + try: + val2 = literal_eval(val2.strip()) + except Exception: + pass + return val1 * val2 raise ValueError("$mul requires two arguments.") @@ -234,10 +203,33 @@ def div(*args, **kwargs): """ if len(args) > 1: val1, val2 = args[0], args[1] - return literal_eval(val1) / float(literal_eval(val2)) + # try to convert to python structures, otherwise, keep as strings + try: + val1 = literal_eval(val1.strip()) + except Exception: + pass + try: + val2 = literal_eval(val2.strip()) + except Exception: + pass + return val1 / float(val2) raise ValueError("$mult requires two arguments.") +def toint(*args, **kwargs): + """ + Usage: $toint() + Returns as an integer. + """ + if args: + val = args[0] + try: + return int(literal_eval(val.strip())) + except ValueError: + return val + raise ValueError("$toint requires one argument.") + + def eval(*args, **kwargs): """ Usage $eval() @@ -247,16 +239,79 @@ def eval(*args, **kwargs): - those will then be evaluated *after* $eval. """ - string = args[0] if args else '' + global _PROTLIB + if not _PROTLIB: + from evennia.prototypes import prototypes as _PROTLIB + + string = ",".join(args) struct = literal_eval(string) + if isinstance(struct, basestring): + # we must shield the string, otherwise it will be merged as a string and future + # literal_evals will pick up e.g. '2' as something that should be converted to a number + struct = '"{}"'.format(struct) + def _recursive_parse(val): - # an extra round of recursive parsing, to catch any escaped $$profuncs + # an extra round of recursive parsing after literal_eval, to catch any + # escaped $$profuncs. This is commonly useful for object references. if is_iter(val): stype = type(val) if stype == dict: return {_recursive_parse(key): _recursive_parse(v) for key, v in val.items()} return stype((_recursive_parse(v) for v in val)) - return protfunc_parser(val) + return _PROTLIB.protfunc_parser(val) return _recursive_parse(struct) + + +def _obj_search(return_list=False, *args, **kwargs): + "Helper function to search for an object" + + query = "".join(args) + session = kwargs.get("session", None) + + if not session: + raise ValueError("$obj called by Evennia without Session. This is not supported.") + account = session.account + if not account: + raise ValueError("$obj requires a logged-in account session.") + targets = search.search_object(query) + + if return_list: + retlist = [] + for target in targets: + if target.access(account, target, 'control'): + retlist.append(target) + return retlist + else: + # single-match + if not targets: + raise ValueError("$obj: Query '{}' gave no matches.".format(query)) + if targets.count() > 1: + raise ValueError("$obj: Query '{query}' gave {nmatches} matches. Limit your " + "query or use $objlist instead.".format( + query=query, nmatches=targets.count())) + target = target[0] + if not target.access(account, target, 'control'): + raise ValueError("$obj: Obj {target}(#{dbref} cannot be added - " + "Account {account} does not have 'control' access.".format( + target=target.key, dbref=target.id, account=account)) + return target + + +def obj(*args, **kwargs): + """ + Usage $obj() + Returns one Object searched globally by key, alias or #dbref. Error if more than one. + + """ + return _obj_search(*args, **kwargs) + + +def objlist(*args, **kwargs): + """ + Usage $objlist() + Returns list with one or more Objects searched globally by key, alias or #dbref. + + """ + return _obj_search(return_list=True, *args, **kwargs) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 2e96af99c9..86230354b9 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -5,17 +5,17 @@ Handling storage of prototypes, both database-based ones (DBPrototypes) and thos """ +from ast import literal_eval from django.conf import settings - from evennia.scripts.scripts import DefaultScript from evennia.objects.models import ObjectDB from evennia.utils.create import create_script from evennia.utils.utils import ( - all_from_module, make_iter, is_iter, dbid_to_obj) + all_from_module, make_iter, is_iter, dbid_to_obj, callables_from_module) from evennia.locks.lockhandler import validate_lockstring, check_lockstring from evennia.utils import logger +from evennia.utils import inlinefuncs from evennia.utils.evtable import EvTable -from evennia.prototypes.protfuncs import protfunc_parser _MODULE_PROTOTYPE_MODULES = {} @@ -23,6 +23,7 @@ _MODULE_PROTOTYPES = {} _PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") _PROTOTYPE_TAG_CATEGORY = "from_prototype" _PROTOTYPE_TAG_META_CATEGORY = "db_prototype" +_PROT_FUNCS = {} class PermissionError(RuntimeError): @@ -36,6 +37,68 @@ class ValidationError(RuntimeError): pass +# Protfunc parsing + +for mod in settings.PROT_FUNC_MODULES: + try: + callables = callables_from_module(mod) + _PROT_FUNCS.update(callables) + except ImportError: + logger.log_trace() + raise + + +def protfunc_parser(value, available_functions=None, testing=False, **kwargs): + """ + Parse a prototype value string for a protfunc and process it. + + Available protfuncs are specified as callables in one of the modules of + `settings.PROTFUNC_MODULES`, or specified on the command line. + + Args: + value (any): The value to test for a parseable protfunc. Only strings will be parsed for + protfuncs, all other types are returned as-is. + available_functions (dict, optional): Mapping of name:protfunction to use for this parsing. + testing (bool, optional): Passed to protfunc. If in a testing mode, some protfuncs may + behave differently. + + Kwargs: + session (Session): Passed to protfunc. Session of the entity spawning the prototype. + protototype (dict): Passed to protfunc. The dict this protfunc is a part of. + current_key(str): Passed to protfunc. The key in the prototype that will hold this value. + any (any): Passed on to the protfunc. + + Returns: + testresult (tuple): If `testing` is set, returns a tuple (error, result) where error is + either None or a string detailing the error from protfunc_parser or seen when trying to + run `literal_eval` on the parsed string. + any (any): A structure to replace the string on the prototype level. If this is a + callable or a (callable, (args,)) structure, it will be executed as if one had supplied + it to the prototype directly. This structure is also passed through literal_eval so one + can get actual Python primitives out of it (not just strings). It will also identify + eventual object #dbrefs in the output from the protfunc. + + """ + if not isinstance(value, basestring): + return value + available_functions = _PROT_FUNCS if available_functions is None else available_functions + result = inlinefuncs.parse_inlinefunc( + value, available_funcs=available_functions, testing=testing, **kwargs) + # at this point we have a string where all procfuncs were parsed + # print("parse_inlinefuncs(\"{}\", available_funcs={}) => {}".format(value, available_functions, result)) + result = value_to_obj_or_any(result) + err = None + try: + result = literal_eval(result) + except ValueError: + pass + except Exception as err: + err = str(err) + if testing: + return err, result + return result + + # helper functions def value_to_obj(value, force=True): diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index fa7eeca246..36be5f4c6b 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -167,7 +167,7 @@ class TestProtLib(EvenniaTest): pass -@override_settings(PROT_FUNC_MODULES=['evennia.prototypes.protfuncs']) +@override_settings(PROT_FUNC_MODULES=['evennia.prototypes.protfuncs'], CLIENT_DEFAULT_WIDTH=20) class TestProtFuncs(EvenniaTest): def setUp(self): @@ -176,11 +176,55 @@ class TestProtFuncs(EvenniaTest): "prototype_desc": "testing prot", "key": "ExampleObj"} - @mock.patch("random.random", new=mock.MagicMock(return_value=0.5)) - @mock.patch("random.randint", new=mock.MagicMock(return_value=5)) + @mock.patch("evennia.prototypes.protfuncs.base_random", new=mock.MagicMock(return_value=0.5)) + @mock.patch("evennia.prototypes.protfuncs.base_randint", new=mock.MagicMock(return_value=5)) def test_protfuncs(self): - self.assertEqual(protfuncs.protfunc_parser("$random()", 0.5)) - self.assertEqual(protfuncs.protfunc_parser("$randint(1, 10)", 5)) + self.assertEqual(protlib.protfunc_parser("$random()"), 0.5) + self.assertEqual(protlib.protfunc_parser("$randint(1, 10)"), 5) + self.assertEqual(protlib.protfunc_parser("$left_justify( foo )"), "foo ") + self.assertEqual(protlib.protfunc_parser("$right_justify( foo )"), " foo") + self.assertEqual(protlib.protfunc_parser("$center_justify(foo )"), " foo ") + self.assertEqual(protlib.protfunc_parser( + "$full_justify(foo bar moo too)"), 'foo bar moo too') + self.assertEqual( + protlib.protfunc_parser("$right_justify( foo )", testing=True), + ('unexpected indent (, line 1)', ' foo')) + + test_prot = {"key1": "value1", + "key2": 2} + + self.assertEqual(protlib.protfunc_parser( + "$protkey(key1)", testing=True, prototype=test_prot), (None, "value1")) + self.assertEqual(protlib.protfunc_parser( + "$protkey(key2)", testing=True, prototype=test_prot), (None, 2)) + + self.assertEqual(protlib.protfunc_parser("$add(1, 2)"), 3) + self.assertEqual(protlib.protfunc_parser("$add(10, 25)"), 35) + self.assertEqual(protlib.protfunc_parser( + "$add('''[1,2,3]''', '''[4,5,6]''')"), [1, 2, 3, 4, 5, 6]) + self.assertEqual(protlib.protfunc_parser("$add(foo, bar)"), "foo bar") + + self.assertEqual(protlib.protfunc_parser("$sub(5, 2)"), 3) + self.assertRaises(TypeError, protlib.protfunc_parser, "$sub(5, test)") + + self.assertEqual(protlib.protfunc_parser("$mult(5, 2)"), 10) + self.assertEqual(protlib.protfunc_parser("$mult( 5 , 10)"), 50) + self.assertEqual(protlib.protfunc_parser("$mult('foo',3)"), "foofoofoo") + self.assertEqual(protlib.protfunc_parser("$mult(foo,3)"), "foofoofoo") + self.assertRaises(TypeError, protlib.protfunc_parser, "$mult(foo, foo)") + + self.assertEqual(protlib.protfunc_parser("$toint(5.3)"), 5) + + self.assertEqual(protlib.protfunc_parser("$div(5, 2)"), 2.5) + self.assertEqual(protlib.protfunc_parser("$toint($div(5, 2))"), 2) + self.assertEqual(protlib.protfunc_parser("$sub($add(5, 3), $add(10, 2))"), -4) + + self.assertEqual(protlib.protfunc_parser("$eval('2')"), '2') + + self.assertEqual(protlib.protfunc_parser( + "$eval(['test', 1, '2', 3.5, \"foo\"])"), ['test', 1, '2', 3.5, 'foo']) + self.assertEqual(protlib.protfunc_parser( + "$eval({'test': '1', 2:3, 3: $toint(3.5)})"), {'test': '1', 2: 3, 3: 3}) class TestPrototypeStorage(EvenniaTest): diff --git a/evennia/utils/inlinefuncs.py b/evennia/utils/inlinefuncs.py index 4becfb7b01..f60f9f0d8a 100644 --- a/evennia/utils/inlinefuncs.py +++ b/evennia/utils/inlinefuncs.py @@ -161,13 +161,15 @@ def clr(*args, **kwargs): def null(*args, **kwargs): return args[0] if args else '' +_INLINE_FUNCS = {} + # we specify a default nomatch function to use if no matching func was # found. This will be overloaded by any nomatch function defined in # the imported modules. -_INLINE_FUNCS = {"nomatch": lambda *args, **kwargs: "", - "stackfull": lambda *args, **kwargs: "\n (not parsed: " - "inlinefunc stack size exceeded.)"} +_DEFAULT_FUNCS = {"nomatch": lambda *args, **kwargs: "", + "stackfull": lambda *args, **kwargs: "\n (not parsed: "} +_INLINE_FUNCS.update(_DEFAULT_FUNCS) # load custom inline func modules. for module in utils.make_iter(settings.INLINEFUNC_MODULES): @@ -285,6 +287,11 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs): if not available_funcs: available_funcs = _INLINE_FUNCS usecache = True + else: + # make sure the default keys are available, but also allow overriding + tmp = _DEFAULT_FUNCS.copy() + tmp.update(available_funcs) + available_funcs = tmp if usecache and string in _PARSING_CACHE: # stack is already cached @@ -299,9 +306,14 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs): # process string on stack ncallable = 0 nlparens = 0 + + # print("STRING: {} =>".format(string)) + for match in _RE_TOKEN.finditer(string): gdict = match.groupdict() - # print("match: {}".format({key: val for key, val in gdict.items() if val})) + + # print(" MATCH: {}".format({key: val for key, val in gdict.items() if val})) + if gdict["singlequote"]: stack.append(gdict["singlequote"]) elif gdict["doublequote"]: @@ -386,10 +398,10 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs): kwargs["inlinefunc_stack_depth"] = depth retval = "" if strip else func(*args, **kwargs) return utils.to_str(retval, force_string=True) - - # print("STACK:\n{}".format(stack)) + retval = "".join(_run_stack(item) for item in stack) + # print("STACK: \n{} => {}\n".format(stack, retval)) # execute the stack - return "".join(_run_stack(item) for item in stack) + return retval # # Nick templating diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 22d59a165f..3d07a82e9a 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -43,8 +43,6 @@ _GA = object.__getattribute__ _SA = object.__setattr__ _DA = object.__delattr__ -_DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH - def is_iter(iterable): """ @@ -80,7 +78,7 @@ def make_iter(obj): return not hasattr(obj, '__iter__') and [obj] or obj -def wrap(text, width=_DEFAULT_WIDTH, indent=0): +def wrap(text, width=None, indent=0): """ Safely wrap text to a certain number of characters. @@ -93,6 +91,7 @@ def wrap(text, width=_DEFAULT_WIDTH, indent=0): text (str): Properly wrapped text. """ + width = width if width else settings.CLIENT_DEFAULT_WIDTH if not text: return "" text = to_unicode(text) @@ -104,7 +103,7 @@ def wrap(text, width=_DEFAULT_WIDTH, indent=0): fill = wrap -def pad(text, width=_DEFAULT_WIDTH, align="c", fillchar=" "): +def pad(text, width=None, align="c", fillchar=" "): """ Pads to a given width. @@ -119,6 +118,7 @@ def pad(text, width=_DEFAULT_WIDTH, align="c", fillchar=" "): text (str): The padded text. """ + width = width if width else settings.CLIENT_DEFAULT_WIDTH align = align if align in ('c', 'l', 'r') else 'c' fillchar = fillchar[0] if fillchar else " " if align == 'l': @@ -129,7 +129,7 @@ def pad(text, width=_DEFAULT_WIDTH, align="c", fillchar=" "): return text.center(width, fillchar) -def crop(text, width=_DEFAULT_WIDTH, suffix="[...]"): +def crop(text, width=None, suffix="[...]"): """ Crop text to a certain width, throwing away text from too-long lines. @@ -147,7 +147,7 @@ def crop(text, width=_DEFAULT_WIDTH, suffix="[...]"): text (str): The cropped text. """ - + width = width if width else settings.CLIENT_DEFAULT_WIDTH utext = to_unicode(text) ltext = len(utext) if ltext <= width: @@ -179,7 +179,7 @@ def dedent(text): return textwrap.dedent(text) -def justify(text, width=_DEFAULT_WIDTH, align="f", indent=0): +def justify(text, width=None, align="f", indent=0): """ Fully justify a text so that it fits inside `width`. When using full justification (default) this will be done by padding between @@ -198,6 +198,7 @@ def justify(text, width=_DEFAULT_WIDTH, align="f", indent=0): justified (str): The justified and indented block of text. """ + width = width if width else settings.CLIENT_DEFAULT_WIDTH def _process_line(line): """ From ec52ca1d5502b856985b18147a70b5a9bdfe0273 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 19 Jun 2018 21:44:20 +0200 Subject: [PATCH 344/466] Testing obj conversion in profuncs --- evennia/prototypes/protfuncs.py | 33 ++++++++++++++++---------------- evennia/prototypes/prototypes.py | 23 ++++++++++++++++++++++ evennia/prototypes/tests.py | 2 ++ 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/evennia/prototypes/protfuncs.py b/evennia/prototypes/protfuncs.py index 5ecb4b5e7d..4c9d9a4a5f 100644 --- a/evennia/prototypes/protfuncs.py +++ b/evennia/prototypes/protfuncs.py @@ -248,27 +248,21 @@ def eval(*args, **kwargs): if isinstance(struct, basestring): # we must shield the string, otherwise it will be merged as a string and future - # literal_evals will pick up e.g. '2' as something that should be converted to a number + # literal_evas will pick up e.g. '2' as something that should be converted to a number struct = '"{}"'.format(struct) - def _recursive_parse(val): - # an extra round of recursive parsing after literal_eval, to catch any - # escaped $$profuncs. This is commonly useful for object references. - if is_iter(val): - stype = type(val) - if stype == dict: - return {_recursive_parse(key): _recursive_parse(v) for key, v in val.items()} - return stype((_recursive_parse(v) for v in val)) - return _PROTLIB.protfunc_parser(val) + # convert any #dbrefs to objects (also in nested structures) + struct = _PROTLIB.value_to_obj_or_any(struct) - return _recursive_parse(struct) + return struct -def _obj_search(return_list=False, *args, **kwargs): +def _obj_search(*args, **kwargs): "Helper function to search for an object" query = "".join(args) session = kwargs.get("session", None) + return_list = kwargs.pop("return_list", False) if not session: raise ValueError("$obj called by Evennia without Session. This is not supported.") @@ -277,6 +271,8 @@ def _obj_search(return_list=False, *args, **kwargs): raise ValueError("$obj requires a logged-in account session.") targets = search.search_object(query) + print("targets: {}".format(targets)) + if return_list: retlist = [] for target in targets: @@ -287,11 +283,11 @@ def _obj_search(return_list=False, *args, **kwargs): # single-match if not targets: raise ValueError("$obj: Query '{}' gave no matches.".format(query)) - if targets.count() > 1: + if len(targets) > 1: raise ValueError("$obj: Query '{query}' gave {nmatches} matches. Limit your " "query or use $objlist instead.".format( - query=query, nmatches=targets.count())) - target = target[0] + query=query, nmatches=len(targets))) + target = targets[0] if not target.access(account, target, 'control'): raise ValueError("$obj: Obj {target}(#{dbref} cannot be added - " "Account {account} does not have 'control' access.".format( @@ -305,7 +301,10 @@ def obj(*args, **kwargs): Returns one Object searched globally by key, alias or #dbref. Error if more than one. """ - return _obj_search(*args, **kwargs) + obj = _obj_search(return_list=False, *args, **kwargs) + if obj: + return "#{}".format(obj.id) + return "".join(args) def objlist(*args, **kwargs): @@ -314,4 +313,4 @@ def objlist(*args, **kwargs): Returns list with one or more Objects searched globally by key, alias or #dbref. """ - return _obj_search(return_list=True, *args, **kwargs) + return ["#{}".format(obj.id) for obj in _obj_search(return_list=True, *args, **kwargs)] diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 86230354b9..81bb4188a2 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -5,6 +5,7 @@ Handling storage of prototypes, both database-based ones (DBPrototypes) and thos """ +import re from ast import literal_eval from django.conf import settings from evennia.scripts.scripts import DefaultScript @@ -26,6 +27,9 @@ _PROTOTYPE_TAG_META_CATEGORY = "db_prototype" _PROT_FUNCS = {} +_RE_DBREF = re.compile(r"(? {}".format(value, available_functions, result)) result = value_to_obj_or_any(result) @@ -102,10 +111,24 @@ def protfunc_parser(value, available_functions=None, testing=False, **kwargs): # helper functions def value_to_obj(value, force=True): + "Always convert value(s) to Object, or None" + stype = type(value) + if is_iter(value): + if stype == dict: + return {value_to_obj_or_any(key): value_to_obj_or_any(val) for key, val in value.iter()} + else: + return stype([value_to_obj_or_any(val) for val in value]) return dbid_to_obj(value, ObjectDB) def value_to_obj_or_any(value): + "Convert value(s) to Object if possible, otherwise keep original value" + stype = type(value) + if is_iter(value): + if stype == dict: + return {value_to_obj_or_any(key): value_to_obj_or_any(val) for key, val in value.items()} + else: + return stype([value_to_obj_or_any(val) for val in value]) obj = dbid_to_obj(value, ObjectDB) return obj if obj is not None else value diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 36be5f4c6b..c49292bbf5 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -226,6 +226,8 @@ class TestProtFuncs(EvenniaTest): self.assertEqual(protlib.protfunc_parser( "$eval({'test': '1', 2:3, 3: $toint(3.5)})"), {'test': '1', 2: 3, 3: 3}) + self.assertEqual(protlib.protfunc_parser("$obj(#1)", session=self.session), '#1') + class TestPrototypeStorage(EvenniaTest): From e601e03884e6f9d248698a88f67187a0b64d412d Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 21 Jun 2018 22:42:50 +0200 Subject: [PATCH 345/466] Unittests pass for all protfuncs --- evennia/prototypes/protfuncs.py | 31 ++++++++++++++++--------------- evennia/prototypes/prototypes.py | 11 +++++------ evennia/prototypes/tests.py | 16 ++++++++++++++-- evennia/utils/inlinefuncs.py | 22 +++++++++++++++++----- 4 files changed, 52 insertions(+), 28 deletions(-) diff --git a/evennia/prototypes/protfuncs.py b/evennia/prototypes/protfuncs.py index 4c9d9a4a5f..6dff62ef96 100644 --- a/evennia/prototypes/protfuncs.py +++ b/evennia/prototypes/protfuncs.py @@ -263,21 +263,21 @@ def _obj_search(*args, **kwargs): query = "".join(args) session = kwargs.get("session", None) return_list = kwargs.pop("return_list", False) + account = None + + if session: + account = session.account - if not session: - raise ValueError("$obj called by Evennia without Session. This is not supported.") - account = session.account - if not account: - raise ValueError("$obj requires a logged-in account session.") targets = search.search_object(query) - print("targets: {}".format(targets)) - if return_list: retlist = [] - for target in targets: - if target.access(account, target, 'control'): - retlist.append(target) + if account: + for target in targets: + if target.access(account, target, 'control'): + retlist.append(target) + else: + retlist = targets return retlist else: # single-match @@ -288,11 +288,12 @@ def _obj_search(*args, **kwargs): "query or use $objlist instead.".format( query=query, nmatches=len(targets))) target = targets[0] - if not target.access(account, target, 'control'): - raise ValueError("$obj: Obj {target}(#{dbref} cannot be added - " - "Account {account} does not have 'control' access.".format( - target=target.key, dbref=target.id, account=account)) - return target + if account: + if not target.access(account, target, 'control'): + raise ValueError("$obj: Obj {target}(#{dbref} cannot be added - " + "Account {account} does not have 'control' access.".format( + target=target.key, dbref=target.id, account=account)) + return target def obj(*args, **kwargs): diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 81bb4188a2..ac343b3ec6 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -27,7 +27,7 @@ _PROTOTYPE_TAG_META_CATEGORY = "db_prototype" _PROT_FUNCS = {} -_RE_DBREF = re.compile(r"(? {}".format(value, available_functions, result)) - result = value_to_obj_or_any(result) err = None try: result = literal_eval(result) diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index c49292bbf5..0eeb236fb2 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -5,10 +5,10 @@ Unit tests for the prototypes and spawner from random import randint import mock -from anything import Anything, Something +from anything import Something from django.test.utils import override_settings from evennia.utils.test_resources import EvenniaTest -from evennia.prototypes import spawner, prototypes as protlib, protfuncs +from evennia.prototypes import spawner, prototypes as protlib from evennia.prototypes.prototypes import _PROTOTYPE_TAG_META_CATEGORY @@ -227,6 +227,18 @@ class TestProtFuncs(EvenniaTest): "$eval({'test': '1', 2:3, 3: $toint(3.5)})"), {'test': '1', 2: 3, 3: 3}) self.assertEqual(protlib.protfunc_parser("$obj(#1)", session=self.session), '#1') + self.assertEqual(protlib.protfunc_parser("#1", session=self.session), '#1') + self.assertEqual(protlib.protfunc_parser("$obj(Char)", session=self.session), '#6') + self.assertEqual(protlib.protfunc_parser("$obj(Char)", session=self.session), '#6') + self.assertEqual(protlib.protfunc_parser("$objlist(#1)", session=self.session), ['#1']) + + self.assertEqual(protlib.value_to_obj( + protlib.protfunc_parser("#6", session=self.session)), self.char1) + self.assertEqual(protlib.value_to_obj_or_any( + protlib.protfunc_parser("#6", session=self.session)), self.char1) + self.assertEqual(protlib.value_to_obj_or_any( + protlib.protfunc_parser("[1,2,3,'#6',5]", session=self.session)), + [1, 2, 3, self.char1, 5]) class TestPrototypeStorage(EvenniaTest): diff --git a/evennia/utils/inlinefuncs.py b/evennia/utils/inlinefuncs.py index f60f9f0d8a..d62493c786 100644 --- a/evennia/utils/inlinefuncs.py +++ b/evennia/utils/inlinefuncs.py @@ -63,7 +63,8 @@ Error handling: import re import fnmatch from django.conf import settings -from evennia.utils import utils + +from evennia.utils import utils, logger # example/testing inline functions @@ -264,7 +265,7 @@ class InlinefuncError(RuntimeError): pass -def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs): +def parse_inlinefunc(string, strip=False, available_funcs=None, stacktrace=False, **kwargs): """ Parse the incoming string. @@ -274,6 +275,7 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs): execute them. available_funcs (dict, optional): Define an alternative source of functions to parse for. If unset, use the functions found through `settings.INLINEFUNC_MODULES`. + stacktrace (bool, optional): If set, print the stacktrace to log. Kwargs: session (Session): This is sent to this function by Evennia when triggering it. It is passed to the inlinefunc. @@ -307,12 +309,18 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs): ncallable = 0 nlparens = 0 - # print("STRING: {} =>".format(string)) + if stacktrace: + out = "STRING: {} =>".format(string) + print(out) + logger.log_info(out) for match in _RE_TOKEN.finditer(string): gdict = match.groupdict() - # print(" MATCH: {}".format({key: val for key, val in gdict.items() if val})) + if stacktrace: + out = " MATCH: {}".format({key: val for key, val in gdict.items() if val}) + print(out) + logger.log_info(out) if gdict["singlequote"]: stack.append(gdict["singlequote"]) @@ -399,7 +407,11 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs): retval = "" if strip else func(*args, **kwargs) return utils.to_str(retval, force_string=True) retval = "".join(_run_stack(item) for item in stack) - # print("STACK: \n{} => {}\n".format(stack, retval)) + if stacktrace: + out = "STACK: \n{} => {}\n".format(stack, retval) + print(out) + logger.log_info(out) + # execute the stack return retval From 9360dc71f183a3a823ab6d90744062d4b7d6152d Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 24 Jun 2018 09:50:03 +0200 Subject: [PATCH 346/466] Rename prototype to prototype_parent, fixing olc menu --- evennia/commands/default/building.py | 4 +- evennia/prototypes/menus.py | 52 ++++++++------- evennia/prototypes/prototypes.py | 94 +++++++++++++++++++++------- evennia/prototypes/spawner.py | 25 ++++---- evennia/prototypes/tests.py | 36 +++++++++++ evennia/utils/tests/test_evmenu.py | 3 +- 6 files changed, 155 insertions(+), 59 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 301bd03761..5c96ad1cf6 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -13,7 +13,7 @@ from evennia.utils import create, utils, search from evennia.utils.utils import inherits_from, class_from_module, get_all_typeclasses from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore -from evennia.prototypes import spawner, prototypes as protlib +from evennia.prototypes import spawner, prototypes as protlib, menus as olc_menus from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -2917,7 +2917,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): elif prototype: # one match prototype = prototype[0] - spawner.start_olc(caller, session=self.session, prototype=prototype) + olc_menus.start_olc(caller, session=self.session, prototype=prototype) return if 'search' in self.switches: diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index bebc6d00bd..ead299abc7 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -9,8 +9,8 @@ from django.conf import settings from evennia.utils.evmenu import EvMenu, list_node from evennia.utils.ansi import strip_ansi from evennia.utils import utils -from evennia.utils.prototypes import prototypes as protlib -from evennia.utils.prototypes import spawner +from evennia.prototypes import prototypes as protlib +from evennia.prototypes import spawner # ------------------------------------------------------------ # @@ -43,12 +43,6 @@ def _is_new_prototype(caller): return hasattr(caller.ndb._menutree, "olc_new") -def _set_menu_prototype(caller, field, value): - prototype = _get_menu_prototype(caller) - prototype[field] = value - caller.ndb._menutree.olc_prototype = prototype - - def _format_property(prop, required=False, prototype=None, cropper=None): if prototype is not None: @@ -67,6 +61,13 @@ def _format_property(prop, required=False, prototype=None, cropper=None): return " ({}|n)".format(cropper(out) if cropper else utils.crop(out, _MENU_CROP_WIDTH)) +def _set_prototype_value(caller, field, value): + prototype = _get_menu_prototype(caller) + prototype[field] = value + caller.ndb._menutree.olc_prototype = prototype + return prototype + + def _set_property(caller, raw_string, **kwargs): """ Update a property. To be called by the 'goto' option variable. @@ -102,22 +103,26 @@ def _set_property(caller, raw_string, **kwargs): if not value: return next_node - prototype = _get_menu_prototype(caller) + prototype = _set_prototype_value(caller, "prototype_key", value) - # typeclass and prototype can't co-exist + # typeclass and prototype_parent can't co-exist if propname_low == "typeclass": - prototype.pop("prototype", None) - if propname_low == "prototype": + prototype.pop("prototype_parent", None) + if propname_low == "prototype_parent": prototype.pop("typeclass", None) caller.ndb._menutree.olc_prototype = prototype - caller.msg("Set {prop} to '{value}'.".format(prop, value=str(value))) + caller.msg("Set {prop} to '{value}'.".format(prop=prop, value=str(value))) return next_node def _wizard_options(curr_node, prev_node, next_node, color="|W"): + """ + Creates default navigation options available in the wizard. + + """ options = [] if prev_node: options.append({"key": ("|wb|Wack", "b"), @@ -154,8 +159,8 @@ def node_index(caller): text = ("|c --- Prototype wizard --- |n\n\n" "Define the |yproperties|n of the prototype. All prototype values can be " "over-ridden at the time of spawning an instance of the prototype, but some are " - "required.\n\n'|wMeta'-properties|n are not used in the prototype itself but are used " - "to organize and list prototypes. The 'Meta-Key' uniquely identifies the prototype " + "required.\n\n'|wprototype-'-properties|n are not used in the prototype itself but are used " + "to organize and list prototypes. The 'prototype-key' uniquely identifies the prototype " "and allows you to edit an existing prototype or save a new one for use by you or " "others later.\n\n(make choice; q to abort. If unsure, start from 1.)") @@ -192,9 +197,12 @@ def node_validate_prototype(caller, raw_string, **kwargs): errors = "\n\n|g No validation errors found.|n (but errors could still happen at spawn-time)" try: # validate, don't spawn - spawner.spawn(prototype, return_prototypes=True) + spawner.spawn(prototype, only_validate=True) except RuntimeError as err: - errors = "\n\n|rError: {}|n".format(err) + errors = "\n\n|r{}|n".format(err) + except RuntimeWarning as err: + errors = "\n\n|y{}|n".format(err) + text = (txt + errors) options = _wizard_options(None, kwargs.get("back"), None) @@ -287,7 +295,9 @@ def node_prototype(caller): def _all_typeclasses(caller): - return list(sorted(utils.get_all_typeclasses().keys())) + return list(name for name in + sorted(utils.get_all_typeclasses("evennia.objects.models.ObjectDB").keys()) + if name != "evennia.objects.models.ObjectDB") def _typeclass_examine(caller, typeclass_path): @@ -403,7 +413,7 @@ def _add_attr(caller, attr_string, **kwargs): if attrname: prot = _get_menu_prototype(caller) prot['attrs'][attrname] = value - _set_menu_prototype(caller, "prototype", prot) + _set_prototype_value(caller, "prototype", prot) text = "Added" else: text = "Attribute must be given as 'attrname = ' where uses valid Python." @@ -468,7 +478,7 @@ def _add_tag(caller, tag, **kwargs): else: tags = [tag] prototype['tags'] = tags - _set_menu_prototype(caller, "prototype", prototype) + _set_prototype_value(caller, "prototype", prototype) text = kwargs.get("text") if not text: text = "Added tag {}. (return to continue)".format(tag) @@ -485,7 +495,7 @@ def _edit_tag(caller, old_tag, new_tag, **kwargs): new_tag = new_tag.strip().lower() tags[tags.index(old_tag)] = new_tag prototype['tags'] = tags - _set_menu_prototype(caller, 'prototype', prototype) + _set_prototype_value(caller, 'prototype', prototype) text = kwargs.get('text') if not text: diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index ac343b3ec6..18516681b2 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -12,7 +12,8 @@ from evennia.scripts.scripts import DefaultScript from evennia.objects.models import ObjectDB from evennia.utils.create import create_script from evennia.utils.utils import ( - all_from_module, make_iter, is_iter, dbid_to_obj, callables_from_module) + all_from_module, make_iter, is_iter, dbid_to_obj, callables_from_module, + get_all_typeclasses) from evennia.locks.lockhandler import validate_lockstring, check_lockstring from evennia.utils import logger from evennia.utils import inlinefuncs @@ -143,10 +144,10 @@ def prototype_to_str(prototype): header = ( "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" "|cdesc:|n {} \n|cprototype:|n ".format( - prototype['prototype_key'], - ", ".join(prototype['prototype_tags']), - prototype['prototype_locks'], - prototype['prototype_desc'])) + prototype.get('prototype_key', None), + ", ".join(prototype.get('prototype_tags', ['None'])), + prototype.get('prototype_locks', None), + prototype.get('prototype_desc', None))) proto = ("{{\n {} \n}}".format( "\n ".join( "{!r}: {!r},".format(key, value) for key, value in @@ -513,7 +514,8 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed return table -def validate_prototype(prototype, protkey=None, protparents=None, _visited=None): +def validate_prototype(prototype, protkey=None, protparents=None, + is_prototype_base=True, _flags=None): """ Run validation on a prototype, checking for inifinite regress. @@ -523,33 +525,77 @@ def validate_prototype(prototype, protkey=None, protparents=None, _visited=None) dict needs to have the `prototype_key` field set. protpartents (dict, optional): The available prototype parent library. If note given this will be determined from settings/database. - _visited (list, optional): This is an internal work array and should not be set manually. + is_prototype_base (bool, optional): We are trying to create a new object *based on this + object*. This means we can't allow 'mixin'-style prototypes without typeclass/parent + etc. + _flags (dict, optional): Internal work dict that should not be set externally. Raises: RuntimeError: If prototype has invalid structure. + RuntimeWarning: If prototype has issues that would make it unsuitable to build an object + with (it may still be useful as a mix-in prototype). """ + assert isinstance(prototype, dict) + + if _flags is None: + _flags = {"visited": [], "depth": 0, "typeclass": False, "errors": [], "warnings": []} + if not protparents: protparents = {prototype['prototype_key']: prototype for prototype in search_prototype()} - if _visited is None: - _visited = [] protkey = protkey and protkey.lower() or prototype.get('prototype_key', None) - assert isinstance(prototype, dict) + if not bool(protkey): + _flags['errors'].append("Prototype lacks a `prototype_key`.") + protkey = "[UNSET]" - if id(prototype) in _visited: - raise RuntimeError("%s has infinite nesting of prototypes." % protkey or prototype) + typeclass = prototype.get('typeclass') + prototype_parent = prototype.get('prototype_parent', []) - _visited.append(id(prototype)) - protstrings = prototype.get("prototype") + if not (typeclass or prototype_parent): + if is_prototype_base: + _flags['errors'].append("Prototype {} requires `typeclass` " + "or 'prototype_parent'.".format(protkey)) + else: + _flags['warnings'].append("Prototype {} can only be used as a mixin since it lacks " + "a typeclass or a prototype_parent.".format(protkey)) - if protstrings: - for protstring in make_iter(protstrings): - protstring = protstring.lower() - if protkey is not None and protstring == protkey: - raise RuntimeError("%s tries to prototype itself." % protkey or prototype) - protparent = protparents.get(protstring) - if not protparent: - raise RuntimeError( - "%s's prototype '%s' was not found." % (protkey or prototype, protstring)) - validate_prototype(protparent, protstring, protparents, _visited) + if typeclass and typeclass not in get_all_typeclasses("evennia.objects.models.ObjectDB"): + _flags['errors'].append( + "Prototype {} is based on typeclass {} which could not be imported!".format( + protkey, typeclass)) + + # recursively traverese prototype_parent chain + + if id(prototype) in _flags['visited']: + _flags['errors'].append( + "{} has infinite nesting of prototypes.".format(protkey or prototype)) + + _flags['visited'].append(id(prototype)) + + for protstring in make_iter(prototype_parent): + protstring = protstring.lower() + if protkey is not None and protstring == protkey: + _flags['errors'].append("Protototype {} tries to parent itself.".format(protkey)) + protparent = protparents.get(protstring) + if not protparent: + _flags['errors'].append("Prototype {}'s prototype_parent '{}' was not found.".format( + (protkey, protstring))) + _flags['depth'] += 1 + validate_prototype(protparent, protstring, protparents, _flags) + _flags['depth'] -= 1 + + if typeclass and not _flags['typeclass']: + _flags['typeclass'] = typeclass + + # if we get back to the current level without a typeclass it's an error. + if is_prototype_base and _flags['depth'] <= 0 and not _flags['typeclass']: + _flags['errors'].append("Prototype {} has no `typeclass` defined anywhere in its parent " + "chain. Add `typeclass`, or a `prototype_parent` pointing to a " + "prototype with a typeclass.".format(protkey)) + + if _flags['depth'] <= 0: + if _flags['errors']: + raise RuntimeError("Error: " + "\nError: ".join(_flags['errors'])) + if _flags['warnings']: + raise RuntimeWarning("Warning: " + "\nWarning: ".join(_flags['warnings'])) diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index da4d69eeb4..df07e3b155 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -32,7 +32,7 @@ Possible keywords are: prototype_tags(list, optional): List of tags or tuples (tag, category) used to group prototype in listings - parent (str, tuple or callable, optional): name (prototype_key) of eventual parent prototype, or + prototype_parent (str, tuple or callable, optional): name (prototype_key) of eventual parent prototype, or a list of parents, for multiple left-to-right inheritance. prototype: Deprecated. Same meaning as 'parent'. typeclass (str or callable, optional): if not set, will use typeclass of parent prototype or use @@ -75,13 +75,13 @@ import random GOBLIN_WIZARD = { - "parent": GOBLIN, + "prototype_parent": GOBLIN, "key": "goblin wizard", "spells": ["fire ball", "lighting bolt"] } GOBLIN_ARCHER = { - "parent": GOBLIN, + "prototype_parent": GOBLIN, "key": "goblin archer", "attack_skill": (random, (5, 10))" "attacks": ["short bow"] @@ -97,7 +97,7 @@ ARCHWIZARD = { GOBLIN_ARCHWIZARD = { "key" : "goblin archwizard" - "parent": (GOBLIN_WIZARD, ARCHWIZARD), + "prototype_parent": (GOBLIN_WIZARD, ARCHWIZARD), } ``` @@ -460,11 +460,15 @@ def spawn(*prototypes, **kwargs): prototype_parents (dict): A dictionary holding a custom prototype-parent dictionary. Will overload same-named prototypes from prototype_modules. - return_prototypes (bool): Only return a list of the + return_parents (bool): Only return a dict of the prototype-parents (no object creation happens) + only_validate (bool): Only run validation of prototype/parents + (no object creation) and return the create-kwargs. Returns: - object (Object): Spawned object. + object (Object, dict or list): Spawned object. If `only_validate` is given, return + a list of the creation kwargs to build the object(s) without actually creating it. If + `return_parents` is set, return dict of prototype parents. """ # get available protparents @@ -474,17 +478,14 @@ def spawn(*prototypes, **kwargs): protparents.update( {key.lower(): value for key, value in kwargs.get("prototype_parents", {}).items()}) - for key, prototype in protparents.items(): - protlib.validate_prototype(prototype, key.lower(), protparents) - - if "return_prototypes" in kwargs: + if "return_parents" in kwargs: # only return the parents return copy.deepcopy(protparents) objsparams = [] for prototype in prototypes: - protlib.validate_prototype(prototype, None, protparents) + protlib.validate_prototype(prototype, None, protparents, is_prototype_base=True) prot = _get_prototype(prototype, {}, protparents) if not prot: continue @@ -556,4 +557,6 @@ def spawn(*prototypes, **kwargs): objsparams.append((create_kwargs, permission_string, lock_string, alias_string, nattributes, attributes, tags, execs)) + if kwargs.get("only_validate"): + return objsparams return batch_create_object(*objsparams) diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 0eeb236fb2..0f48c3780a 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -8,7 +8,9 @@ import mock from anything import Something from django.test.utils import override_settings from evennia.utils.test_resources import EvenniaTest +from evennia.utils.tests.test_evmenu import TestEvMenu from evennia.prototypes import spawner, prototypes as protlib +from evennia.prototypes import menus as olc_menus from evennia.prototypes.prototypes import _PROTOTYPE_TAG_META_CATEGORY @@ -304,3 +306,37 @@ class TestPrototypeStorage(EvenniaTest): self.assertEqual(list(protlib.search_prototype(tags="foo1")), [prot1b, prot2, prot3]) self.assertTrue(str(unicode(protlib.list_prototypes(self.char1)))) + + +@mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock( + return_value=[{"prototype_key": "TestPrototype", + "typeclass": "TypeClassTest", "key": "TestObj"}])) +@mock.patch("evennia.utils.utils.get_all_typeclasses", new=mock.MagicMock( + return_value={"TypeclassTest": None})) +class TestOLCMenu(TestEvMenu): + + maxDiff = None + menutree = "evennia.prototypes.menus" + startnode = "node_index" + + debug_output = True + + expected_node_texts = { + "node_index": "|c --- Prototype wizard --- |n" + } + + expected_tree = \ + ['node_index', + ['node_prototype_key', + 'node_typeclass', + 'node_aliases', + 'node_attrs', + 'node_tags', + 'node_locks', + 'node_permissions', + 'node_location', + 'node_home', + 'node_destination', + 'node_prototype_desc', + 'node_prototype_tags', + 'node_prototype_locks']] diff --git a/evennia/utils/tests/test_evmenu.py b/evennia/utils/tests/test_evmenu.py index 04310c90ed..d3ee14a74f 100644 --- a/evennia/utils/tests/test_evmenu.py +++ b/evennia/utils/tests/test_evmenu.py @@ -58,7 +58,7 @@ class TestEvMenu(TestCase): def _debug_output(self, indent, msg): if self.debug_output: - print(" " * indent + msg) + print(" " * indent + ansi.strip_ansi(msg)) def _test_menutree(self, menu): """ @@ -168,6 +168,7 @@ class TestEvMenu(TestCase): self.caller2.msg = MagicMock() self.session = MagicMock() self.session2 = MagicMock() + self.menu = evmenu.EvMenu(self.caller, self.menutree, startnode=self.startnode, cmdset_mergetype=self.cmdset_mergetype, cmdset_priority=self.cmdset_priority, From 194eb8e42f87edc79f4624392769a0560f3f652f Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 24 Jun 2018 16:03:48 +0200 Subject: [PATCH 347/466] Unit testing/debugging olc menu --- evennia/prototypes/menus.py | 94 ++++++++++++++++++++++---------- evennia/prototypes/prototypes.py | 6 +- evennia/prototypes/tests.py | 86 +++++++++++++++++++++++++++++ evennia/utils/inlinefuncs.py | 16 +++++- 4 files changed, 171 insertions(+), 31 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index ead299abc7..ff38c3448e 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -29,7 +29,7 @@ _MENU_ATTR_LITERAL_EVAL_ERROR = ( def _get_menu_prototype(caller): - + """Return currently active menu prototype.""" prototype = None if hasattr(caller.ndb._menutree, "olc_prototype"): prototype = caller.ndb._menutree.olc_prototype @@ -40,11 +40,23 @@ def _get_menu_prototype(caller): def _is_new_prototype(caller): + """Check if prototype is marked as new or was loaded from a saved one.""" return hasattr(caller.ndb._menutree, "olc_new") -def _format_property(prop, required=False, prototype=None, cropper=None): +def _format_option_value(prop, required=False, prototype=None, cropper=None): + """ + Format wizard option values. + Args: + prop (str): Name or value to format. + required (bool, optional): The option is required. + prototype (dict, optional): If given, `prop` will be considered a key in this prototype. + cropper (callable, optional): A function to crop the value to a certain width. + + Returns: + value (str): The formatted value. + """ if prototype is not None: prop = prototype.get(prop, '') @@ -61,7 +73,8 @@ def _format_property(prop, required=False, prototype=None, cropper=None): return " ({}|n)".format(cropper(out) if cropper else utils.crop(out, _MENU_CROP_WIDTH)) -def _set_prototype_value(caller, field, value): +def _set_prototype_value(caller, field, value, parse=True): + """Set prototype's field in a safe way.""" prototype = _get_menu_prototype(caller) prototype[field] = value caller.ndb._menutree.olc_prototype = prototype @@ -70,15 +83,21 @@ def _set_prototype_value(caller, field, value): def _set_property(caller, raw_string, **kwargs): """ - Update a property. To be called by the 'goto' option variable. + Add or update a property. To be called by the 'goto' option variable. Args: caller (Object, Account): The user of the wizard. raw_string (str): Input from user on given node - the new value to set. + Kwargs: + test_parse (bool): If set (default True), parse raw_string for protfuncs and obj-refs and + try to run result through literal_eval. The parser will be run in 'testing' mode and any + parsing errors will shown to the user. Note that this is just for testing, the original + given string will be what is inserted. prop (str): Property name to edit with `raw_string`. processor (callable): Converts `raw_string` to a form suitable for saving. next_node (str): Where to redirect to after this has run. + Returns: next_node (str): Next node to go to. @@ -103,7 +122,7 @@ def _set_property(caller, raw_string, **kwargs): if not value: return next_node - prototype = _set_prototype_value(caller, "prototype_key", value) + prototype = _set_prototype_value(caller, prop, value) # typeclass and prototype_parent can't co-exist if propname_low == "typeclass": @@ -113,16 +132,26 @@ def _set_property(caller, raw_string, **kwargs): caller.ndb._menutree.olc_prototype = prototype - caller.msg("Set {prop} to '{value}'.".format(prop=prop, value=str(value))) + out = [" Set {prop} to {value} ({typ}).".format(prop=prop, value=value, typ=type(value))] + + if kwargs.get("test_parse", True): + out.append(" Simulating parsing ...") + err, parsed_value = protlib.protfunc_parser(value, testing=True) + if err: + out.append(" |yPython `literal_eval` warning: {}|n".format(err)) + if parsed_value != value: + out.append(" |g(Example-)value when parsed ({}):|n {}".format( + type(parsed_value), parsed_value)) + else: + out.append(" |gNo change.") + + caller.msg("\n".join(out)) return next_node def _wizard_options(curr_node, prev_node, next_node, color="|W"): - """ - Creates default navigation options available in the wizard. - - """ + """Creates default navigation options available in the wizard.""" options = [] if prev_node: options.append({"key": ("|wb|Wack", "b"), @@ -166,7 +195,7 @@ def node_index(caller): options = [] options.append( - {"desc": "|WPrototype-Key|n|n{}".format(_format_property("Key", True, prototype, None)), + {"desc": "|WPrototype-Key|n|n{}".format(_format_option_value("Key", True, prototype, None)), "goto": "node_prototype_key"}) for key in ('Prototype', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', 'Permissions', 'Location', 'Home', 'Destination'): @@ -178,13 +207,13 @@ def node_index(caller): cropper = _path_cropper options.append( {"desc": "|w{}|n{}".format( - key, _format_property(key, required, prototype, cropper=cropper)), + key, _format_option_value(key, required, prototype, cropper=cropper)), "goto": "node_{}".format(key.lower())}) required = False for key in ('Desc', 'Tags', 'Locks'): options.append( {"desc": "|WPrototype-{}|n|n{}".format( - key, _format_property(key, required, prototype, None)), + key, _format_option_value(key, required, prototype, None)), "goto": "node_prototype_{}".format(key.lower())}) return text, options @@ -215,6 +244,7 @@ def _check_prototype_key(caller, key): olc_new = _is_new_prototype(caller) key = key.strip().lower() if old_prototype: + old_prototype = old_prototype[0] # we are starting a new prototype that matches an existing if not caller.locks.check_lockstring( caller, old_prototype['prototype_locks'], access_type='edit'): @@ -229,7 +259,7 @@ def _check_prototype_key(caller, key): caller.msg("Prototype already exists. Reloading.") return "node_index" - return _set_property(caller, key, prop='prototype_key', next_node="node_prototype") + return _set_property(caller, key, prop='prototype_key', next_node="node_prototype_parent") def node_prototype_key(caller): @@ -250,27 +280,32 @@ def node_prototype_key(caller): return text, options -def _all_prototypes(caller): +def _all_prototype_parents(caller): + """Return prototype_key of all available prototypes for listing in menu""" return [prototype["prototype_key"] for prototype in protlib.search_prototype() if "prototype_key" in prototype] -def _prototype_examine(caller, prototype_name): +def _prototype_parent_examine(caller, prototype_name): + """Convert prototype to a string representation for closer inspection""" prototypes = protlib.search_prototype(key=prototype_name) if prototypes: - caller.msg(protlib.prototype_to_str(prototypes[0])) - caller.msg("Prototype not registered.") - return None + ret = protlib.prototype_to_str(prototypes[0]) + caller.msg(ret) + return ret + else: + caller.msg("Prototype not registered.") -def _prototype_select(caller, prototype): - ret = _set_property(caller, prototype, prop="prototype", processor=str, next_node="node_key") +def _prototype_parent_select(caller, prototype): + ret = _set_property(caller, prototype['prototype_key'], + prop="prototype_parent", processor=str, next_node="node_key") caller.msg("Selected prototype |y{}|n. Removed any set typeclass parent.".format(prototype)) return ret -@list_node(_all_prototypes, _prototype_select) -def node_prototype(caller): +@list_node(_all_prototype_parents, _prototype_parent_select) +def node_prototype_parent(caller): prototype = _get_menu_prototype(caller) prot_parent_key = prototype.get('prototype') @@ -289,18 +324,20 @@ def node_prototype(caller): text = "\n\n".join(text) options = _wizard_options("prototype", "prototype_key", "typeclass", color="|W") options.append({"key": "_default", - "goto": _prototype_examine}) + "goto": _prototype_parent_examine}) return text, options def _all_typeclasses(caller): + """Get name of available typeclasses.""" return list(name for name in sorted(utils.get_all_typeclasses("evennia.objects.models.ObjectDB").keys()) if name != "evennia.objects.models.ObjectDB") def _typeclass_examine(caller, typeclass_path): + """Show info (docstring) about given typeclass.""" if typeclass_path is None: # this means we are exiting the listing return "node_key" @@ -319,10 +356,11 @@ def _typeclass_examine(caller, typeclass_path): else: txt = "This is typeclass |y{}|n.".format(typeclass) caller.msg(txt) - return None + return txt def _typeclass_select(caller, typeclass): + """Select typeclass from list and add it to prototype. Return next node to go to.""" ret = _set_property(caller, typeclass, prop='typeclass', processor=str, next_node="node_key") caller.msg("Selected typeclass |y{}|n. Removed any set prototype parent.".format(typeclass)) return ret @@ -350,7 +388,7 @@ def node_key(caller): prototype = _get_menu_prototype(caller) key = prototype.get("key") - text = ["Set the prototype's |yKey|n. This will retain case sensitivity."] + text = ["Set the prototype's name (|yKey|n.) This will retain case sensitivity."] if key: text.append("Current key value is '|y{key}|n'.".format(key=key)) else: @@ -370,7 +408,7 @@ def node_aliases(caller): aliases = prototype.get("aliases") text = ["Set the prototype's |yAliases|n. Separate multiple aliases with commas. " - "ill retain case sensitivity."] + "they'll retain case sensitivity."] if aliases: text.append("Current aliases are '|y{aliases}|n'.".format(aliases=aliases)) else: @@ -714,7 +752,7 @@ def start_olc(caller, session=None, prototype=None): menudata = {"node_index": node_index, "node_validate_prototype": node_validate_prototype, "node_prototype_key": node_prototype_key, - "node_prototype": node_prototype, + "node_prototype_parent": node_prototype_parent, "node_typeclass": node_typeclass, "node_key": node_key, "node_aliases": node_aliases, diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 18516681b2..2ab3416afe 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -13,7 +13,7 @@ from evennia.objects.models import ObjectDB from evennia.utils.create import create_script from evennia.utils.utils import ( all_from_module, make_iter, is_iter, dbid_to_obj, callables_from_module, - get_all_typeclasses) + get_all_typeclasses, to_str) from evennia.locks.lockhandler import validate_lockstring, check_lockstring from evennia.utils import logger from evennia.utils import inlinefuncs @@ -64,6 +64,7 @@ def protfunc_parser(value, available_functions=None, testing=False, stacktrace=F value (any): The value to test for a parseable protfunc. Only strings will be parsed for protfuncs, all other types are returned as-is. available_functions (dict, optional): Mapping of name:protfunction to use for this parsing. + If not set, use default sources. testing (bool, optional): Passed to protfunc. If in a testing mode, some protfuncs may behave differently. stacktrace (bool, optional): If set, print the stack parsing process of the protfunc-parser. @@ -86,7 +87,8 @@ def protfunc_parser(value, available_functions=None, testing=False, stacktrace=F """ if not isinstance(value, basestring): - return value + value = to_str(value, force_string=True) + available_functions = _PROT_FUNCS if available_functions is None else available_functions # insert $obj(#dbref) for #dbref diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 0f48c3780a..49624905c7 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -308,6 +308,91 @@ class TestPrototypeStorage(EvenniaTest): self.assertTrue(str(unicode(protlib.list_prototypes(self.char1)))) +class _MockMenu(object): + pass + + +class TestMenuModule(EvenniaTest): + + def setUp(self): + super(TestMenuModule, self).setUp() + + # set up fake store + self.caller = self.char1 + menutree = _MockMenu() + self.caller.ndb._menutree = menutree + + self.test_prot = {"prototype_key": "test_prot", + "prototype_locks": "edit:all();spawn:all()"} + + def test_helpers(self): + + caller = self.caller + + # general helpers + + self.assertEqual(olc_menus._get_menu_prototype(caller), {}) + self.assertEqual(olc_menus._is_new_prototype(caller), True) + + self.assertEqual( + olc_menus._set_prototype_value(caller, "key", "TestKey"), {"key": "TestKey"}) + self.assertEqual(olc_menus._get_menu_prototype(caller), {"key": "TestKey"}) + + self.assertEqual(olc_menus._format_option_value( + "key", required=True, prototype=olc_menus._get_menu_prototype(caller)), " (TestKey|n)") + self.assertEqual(olc_menus._format_option_value( + [1, 2, 3, "foo"], required=True), ' (1, 2, 3, foo|n)') + + self.assertEqual(olc_menus._set_property( + caller, "ChangedKey", prop="key", processor=str, next_node="foo"), "foo") + self.assertEqual(olc_menus._get_menu_prototype(caller), {"key": "ChangedKey"}) + + self.assertEqual(olc_menus._wizard_options( + "ThisNode", "PrevNode", "NextNode"), + [{'goto': 'node_PrevNode', 'key': ('|wb|Wack', 'b'), 'desc': '|W(PrevNode)|n'}, + {'goto': 'node_NextNode', 'key': ('|wf|Worward', 'f'), 'desc': '|W(NextNode)|n'}, + {'goto': 'node_index', 'key': ('|wi|Wndex', 'i')}, + {'goto': ('node_validate_prototype', {'back': 'ThisNode'}), + 'key': ('|wv|Walidate prototype', 'v')}]) + + def test_node_helpers(self): + + caller = self.caller + + with mock.patch("evennia.prototypes.menus.protlib.search_prototype", + new=mock.MagicMock(return_value=[self.test_prot])): + self.assertEqual(olc_menus._check_prototype_key(caller, "test_prot"), + "node_prototype_parent") + caller.ndb._menutree.olc_new = True + self.assertEqual(olc_menus._check_prototype_key(caller, "test_prot"), + "node_index") + + self.assertEqual(olc_menus._all_prototype_parents(caller), ['test_prot']) + self.assertEqual(olc_menus._prototype_parent_examine( + caller, 'test_prot'), + '|cprototype key:|n test_prot, |ctags:|n None, |clocks:|n edit:all();spawn:all() ' + '\n|cdesc:|n None \n|cprototype:|n {\n \n}') + self.assertEqual(olc_menus._prototype_parent_select(caller, self.test_prot), "node_key") + self.assertEqual(olc_menus._get_menu_prototype(caller), + {'prototype_key': 'test_prot', + 'prototype_locks': 'edit:all();spawn:all()', + 'prototype_parent': "test_prot"}) + + with mock.patch("evennia.utils.utils.get_all_typeclasses", + new=mock.MagicMock(return_value={"foo": None, "bar": None})): + self.assertEqual(olc_menus._all_typeclasses(caller), ["bar", "foo"]) + self.assertTrue(olc_menus._typeclass_examine( + caller, "evennia.objects.objects.DefaultObject").startswith("Typeclass |y")) + + self.assertEqual(olc_menus._typeclass_select( + caller, "evennia.objects.objects.DefaultObject"), "node_key") + # prototype_parent should be popped off here + self.assertEqual(olc_menus._get_menu_prototype(caller), + {'prototype_key': 'test_prot', + 'prototype_locks': 'edit:all();spawn:all()', + 'typeclass': 'evennia.objects.objects.DefaultObject'}) + + @mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock( return_value=[{"prototype_key": "TestPrototype", "typeclass": "TypeClassTest", "key": "TestObj"}])) @@ -320,6 +405,7 @@ class TestOLCMenu(TestEvMenu): startnode = "node_index" debug_output = True + expect_all_nodes = True expected_node_texts = { "node_index": "|c --- Prototype wizard --- |n" diff --git a/evennia/utils/inlinefuncs.py b/evennia/utils/inlinefuncs.py index d62493c786..85ceeadc8a 100644 --- a/evennia/utils/inlinefuncs.py +++ b/evennia/utils/inlinefuncs.py @@ -162,6 +162,20 @@ def clr(*args, **kwargs): def null(*args, **kwargs): return args[0] if args else '' + +def nomatch(name, *args, **kwargs): + """ + Default implementation of nomatch returns the function as-is as a string. + + """ + kwargs.pop("inlinefunc_stack_depth", None) + kwargs.pop("session") + + return "${name}({args}{kwargs})".format( + name=name, + args=",".join(args), + kwargs=",".join("{}={}".format(key, val) for key, val in kwargs.items())) + _INLINE_FUNCS = {} # we specify a default nomatch function to use if no matching func was @@ -284,7 +298,6 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, stacktrace=False """ global _PARSING_CACHE - usecache = False if not available_funcs: available_funcs = _INLINE_FUNCS @@ -357,6 +370,7 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, stacktrace=False except KeyError: stack.append(available_funcs["nomatch"]) stack.append(funcname) + stack.append(None) ncallable += 1 elif gdict["escaped"]: # escaped tokens From 01af170eae6f9369a76ae5ae62863667e0a4a94f Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 27 Jun 2018 00:13:19 +0200 Subject: [PATCH 348/466] Start with final load/save/spawn nodes of menu --- evennia/prototypes/menus.py | 250 +++++++++++++++++++++---------- evennia/prototypes/prototypes.py | 6 +- evennia/prototypes/spawner.py | 2 +- 3 files changed, 179 insertions(+), 79 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index ff38c3448e..80e34e4c21 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -4,6 +4,7 @@ OLC Prototype menu nodes """ +import json from ast import literal_eval from django.conf import settings from evennia.utils.evmenu import EvMenu, list_node @@ -132,10 +133,16 @@ def _set_property(caller, raw_string, **kwargs): caller.ndb._menutree.olc_prototype = prototype - out = [" Set {prop} to {value} ({typ}).".format(prop=prop, value=value, typ=type(value))] + try: + # TODO simple way to get rid of the u'' markers in list reprs, remove this when on py3. + repr_value = json.dumps(value) + except Exception: + repr_value = value + + out = [" Set {prop} to {value} ({typ}).".format(prop=prop, value=repr_value, typ=type(value))] if kwargs.get("test_parse", True): - out.append(" Simulating parsing ...") + out.append(" Simulating prototype-func parsing ...") err, parsed_value = protlib.protfunc_parser(value, testing=True) if err: out.append(" |yPython `literal_eval` warning: {}|n".format(err)) @@ -143,7 +150,7 @@ def _set_property(caller, raw_string, **kwargs): out.append(" |g(Example-)value when parsed ({}):|n {}".format( type(parsed_value), parsed_value)) else: - out.append(" |gNo change.") + out.append(" |gNo change when parsed.") caller.msg("\n".join(out)) @@ -185,23 +192,24 @@ def _path_cropper(pythonpath): def node_index(caller): prototype = _get_menu_prototype(caller) - text = ("|c --- Prototype wizard --- |n\n\n" - "Define the |yproperties|n of the prototype. All prototype values can be " - "over-ridden at the time of spawning an instance of the prototype, but some are " - "required.\n\n'|wprototype-'-properties|n are not used in the prototype itself but are used " - "to organize and list prototypes. The 'prototype-key' uniquely identifies the prototype " - "and allows you to edit an existing prototype or save a new one for use by you or " - "others later.\n\n(make choice; q to abort. If unsure, start from 1.)") + text = ( + "|c --- Prototype wizard --- |n\n\n" + "Define the |yproperties|n of the prototype. All prototype values can be " + "over-ridden at the time of spawning an instance of the prototype, but some are " + "required.\n\n'|wprototype-'-properties|n are not used in the prototype itself but are used " + "to organize and list prototypes. The 'prototype-key' uniquely identifies the prototype " + "and allows you to edit an existing prototype or save a new one for use by you or " + "others later.\n\n(make choice; q to abort. If unsure, start from 1.)") options = [] options.append( {"desc": "|WPrototype-Key|n|n{}".format(_format_option_value("Key", True, prototype, None)), "goto": "node_prototype_key"}) - for key in ('Prototype', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', + for key in ('Typeclass', 'Prototype-parent', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', 'Permissions', 'Location', 'Home', 'Destination'): required = False cropper = None - if key in ("Prototype", "Typeclass"): + if key in ("Prototype-parent", "Typeclass"): required = "prototype" not in prototype and "typeclass" not in prototype if key == 'Typeclass': cropper = _path_cropper @@ -215,6 +223,12 @@ def node_index(caller): {"desc": "|WPrototype-{}|n|n{}".format( key, _format_option_value(key, required, prototype, None)), "goto": "node_prototype_{}".format(key.lower())}) + for key in ("Load", "Save", "Spawn"): + options.append( + {"key": ("|w{}|W{}".format(key[0], key[1:]), key[0]), + "desc": "|W{}|n".format( + key, _format_option_value(key, required, prototype, None)), + "goto": "node_prototype_{}".format(key.lower())}) return text, options @@ -429,54 +443,82 @@ def _caller_attrs(caller): return attrs -def _attrparse(caller, attr_string): - "attr is entering on the form 'attr = value'" +def _display_attribute(attr_tuple): + """Pretty-print attribute tuple""" + attrkey, value, category, locks, default_access = attr_tuple + value = protlib.protfunc_parser(value) + typ = type(value) + out = ("Attribute key: '{attrkey}' (category: {category}, " + "locks: {locks})\n" + "Value (parsed to {typ}): {value}").format( + attrkey=attrkey, + category=category, locks=locks, + typ=typ, value=value) + return out + + +def _add_attr(caller, attr_string, **kwargs): + """ + Add new attrubute, parsing input. + attr is entered on these forms + attr = value + attr;category = value + attr;category;lockstring = value + + """ + attrname = '' + category = None + locks = '' if '=' in attr_string: attrname, value = (part.strip() for part in attr_string.split('=', 1)) attrname = attrname.lower() - if attrname: - try: - value = literal_eval(value) - except SyntaxError: - caller.msg(_MENU_ATTR_LITERAL_EVAL_ERROR) - else: - return attrname, value - else: - return None, None + nameparts = attrname.split(";", 2) + nparts = len(nameparts) + if nparts == 2: + attrname, category = nameparts + elif nparts > 2: + attrname, category, locks = nameparts + attr_tuple = (attrname, category, locks) - -def _add_attr(caller, attr_string, **kwargs): - attrname, value = _attrparse(caller, attr_string) if attrname: prot = _get_menu_prototype(caller) - prot['attrs'][attrname] = value - _set_prototype_value(caller, "prototype", prot) - text = "Added" + attrs = prot.get('attrs', []) + + try: + # replace existing attribute with the same name in the prototype + ind = [tup[0] for tup in attrs].index(attrname) + attrs[ind] = attr_tuple + except IndexError: + attrs.append(attr_tuple) + + _set_prototype_value(caller, "attrs", attrs) + + text = kwargs.get('text') + if not text: + if 'edit' in kwargs: + text = "Edited " + _display_attribute(attr_tuple) + else: + text = "Added " + _display_attribute(attr_tuple) else: - text = "Attribute must be given as 'attrname = ' where uses valid Python." + text = "Attribute must be given as 'attrname[;category;locks] = '." + options = {"key": "_default", "goto": lambda caller: None} return text, options def _edit_attr(caller, attrname, new_value, **kwargs): - attrname, value = _attrparse("{}={}".format(caller, attrname, new_value)) - if attrname: - prot = _get_menu_prototype(caller) - prot['attrs'][attrname] = value - text = "Edited Attribute {} = {}".format(attrname, value) - else: - text = "Attribute value must be valid Python." - options = {"key": "_default", - "goto": lambda caller: None} - return text, options + + attr_string = "{}={}".format(attrname, new_value) + + return _add_attr(caller, attr_string, edit=True) def _examine_attr(caller, selection): prot = _get_menu_prototype(caller) - value = prot['attrs'][selection] - return "Attribute {} = {}".format(selection, value) + attr_tuple = prot['attrs'][selection] + return _display_attribute(attr_tuple) @list_node(_caller_attrs) @@ -484,8 +526,12 @@ def node_attrs(caller): prot = _get_menu_prototype(caller) attrs = prot.get("attrs") - text = ["Set the prototype's |yAttributes|n. Separate multiple attrs with commas. " - "Will retain case sensitivity."] + text = ["Set the prototype's |yAttributes|n. Enter attributes on one of these forms:\n" + " attrname=value\n attrname;category=value\n attrname;category;lockstring=value\n" + "To give an attribute without a category but with a lockstring, leave that spot empty " + "(attrname;;lockstring=value)." + "Separate multiple attrs with commas. Use quotes to escape inputs with commas and " + "semi-colon."] if attrs: text.append("Current attrs are '|y{attrs}|n'.".format(attrs=attrs)) else: @@ -506,46 +552,78 @@ def _caller_tags(caller): return tags +def _display_tag(tag_tuple): + """Pretty-print attribute tuple""" + tagkey, category, data = tag_tuple + out = ("Tag: '{tagkey}' (category: {category}{})".format( + tagkey=tagkey, category=category, data=", data: {}".format(data) if data else "")) + return out + + def _add_tag(caller, tag, **kwargs): + """ + Add tags to the system, parsing this syntax: + tagname + tagname;category + tagname;category;data + + """ + tag = tag.strip().lower() - prototype = _get_menu_prototype(caller) - tags = prototype.get('tags', []) - if tags: - if tag not in tags: - tags.append(tag) + category = None + data = "" + + tagtuple = tag.split(";", 2) + ntuple = len(tagtuple) + + if ntuple == 2: + tag, category = tagtuple + elif ntuple > 2: + tag, category, data = tagtuple + + tag_tuple = (tag, category, data) + + if tag: + prot = _get_menu_prototype(caller) + tags = prot.get('tags', []) + + old_tag = kwargs.get("edit", None) + + if old_tag: + # editing a tag means removing the old and replacing with new + try: + ind = [tup[0] for tup in tags].index(old_tag) + del tags[ind] + except IndexError: + pass + + tags.append(tag_tuple) + + _set_prototype_value(caller, "tags", tags) + + text = kwargs.get('text') + if not text: + if 'edit' in kwargs: + text = "Edited " + _display_tag(tag_tuple) + else: + text = "Added " + _display_tag(tag_tuple) else: - tags = [tag] - prototype['tags'] = tags - _set_prototype_value(caller, "prototype", prototype) - text = kwargs.get("text") - if not text: - text = "Added tag {}. (return to continue)".format(tag) + text = "Tag must be given as 'tag[;category;data]." + options = {"key": "_default", "goto": lambda caller: None} return text, options def _edit_tag(caller, old_tag, new_tag, **kwargs): - prototype = _get_menu_prototype(caller) - tags = prototype.get('tags', []) - - old_tag = old_tag.strip().lower() - new_tag = new_tag.strip().lower() - tags[tags.index(old_tag)] = new_tag - prototype['tags'] = tags - _set_prototype_value(caller, 'prototype', prototype) - - text = kwargs.get('text') - if not text: - text = "Changed tag {} to {}.".format(old_tag, new_tag) - options = {"key": "_default", - "goto": lambda caller: None} - return text, options + return _add_tag(caller, new_tag, edit=old_tag) @list_node(_caller_tags) def node_tags(caller): - text = "Set the prototype's |yTags|n." + text = ("Set the prototype's |yTags|n. Enter tags on one of the following forms:\n" + " tag\n tag;category\n tag;category;data\n" + "Note that 'data' is not commonly used.") options = _wizard_options("tags", "attrs", "locks") return text, options @@ -650,7 +728,7 @@ def node_destination(caller): def node_prototype_desc(caller): prototype = _get_menu_prototype(caller) - text = ["The |wMeta-Description|n briefly describes the prototype for viewing in listings."] + text = ["The |wPrototype-Description|n briefly describes the prototype for viewing in listings."] desc = prototype.get("prototype_desc", None) if desc: @@ -670,7 +748,7 @@ def node_prototype_desc(caller): def node_prototype_tags(caller): prototype = _get_menu_prototype(caller) - text = ["|wMeta-Tags|n can be used to classify and find prototypes. Tags are case-insensitive. " + text = ["|wPrototype-Tags|n can be used to classify and find prototypes. Tags are case-insensitive. " "Separate multiple by tags by commas."] tags = prototype.get('prototype_tags', []) @@ -691,15 +769,15 @@ def node_prototype_tags(caller): def node_prototype_locks(caller): prototype = _get_menu_prototype(caller) - text = ["Set |wMeta-Locks|n on the prototype. There are two valid lock types: " - "'edit' (who can edit the prototype) and 'use' (who can apply the prototype)\n" - "(If you are unsure, leave as default.)"] + text = ["Set |wPrototype-Locks|n on the prototype. There are two valid lock types: " + "'edit' (who can edit the prototype) and 'spawn' (who can spawn new objects with this " + "prototype)\n(If you are unsure, leave as default.)"] locks = prototype.get('prototype_locks', '') if locks: text.append("Current lock is |w'{lockstring}'|n".format(lockstring=locks)) else: text.append("Lock unset - if not changed the default lockstring will be set as\n" - " |w'use:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) + " |w'spawn:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) text = "\n\n".join(text) options = _wizard_options("prototype_locks", "prototype_tags", "index") options.append({"key": "_default", @@ -710,6 +788,21 @@ def node_prototype_locks(caller): return text, options +def node_prototype_load(caller): + # load prototype from storage + pass + + +def node_prototype_save(caller): + # save current prototype to disk + pass + + +def node_prototype_spawn(caller): + # spawn an instance of this prototype + pass + + class OLCMenu(EvMenu): """ A custom EvMenu with a different formatting for the options. @@ -766,5 +859,8 @@ def start_olc(caller, session=None, prototype=None): "node_prototype_desc": node_prototype_desc, "node_prototype_tags": node_prototype_tags, "node_prototype_locks": node_prototype_locks, + "node_prototype_load": node_prototype_load, + "node_prototype_save": node_prototype_save, + "node_prototype_spawn": node_prototype_spawn } OLCMenu(caller, menudata, startnode='node_index', session=session, olc_prototype=prototype) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 2ab3416afe..6f155fdac9 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -22,7 +22,11 @@ from evennia.utils.evtable import EvTable _MODULE_PROTOTYPE_MODULES = {} _MODULE_PROTOTYPES = {} -_PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") +_PROTOTYPE_META_NAMES = ( + "prototype_key", "prototype_desc", "prototype_tags", "prototype_locks", "prototype_parent") +_PROTOTYPE_RESERVED_KEYS = _PROTOTYPE_META_NAMES + ( + "key", "aliases", "typeclass", "location", "home", "destination", + "permissions", "locks", "exec", "tags", "attrs") _PROTOTYPE_TAG_CATEGORY = "from_prototype" _PROTOTYPE_TAG_META_CATEGORY = "db_prototype" _PROT_FUNCS = {} diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index df07e3b155..71aecfd61e 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -31,10 +31,10 @@ Possible keywords are: supported are 'edit' and 'use'. prototype_tags(list, optional): List of tags or tuples (tag, category) used to group prototype in listings - prototype_parent (str, tuple or callable, optional): name (prototype_key) of eventual parent prototype, or a list of parents, for multiple left-to-right inheritance. prototype: Deprecated. Same meaning as 'parent'. + typeclass (str or callable, optional): if not set, will use typeclass of parent prototype or use `settings.BASE_OBJECT_TYPECLASS` key (str or callable, optional): the name of the spawned object. If not given this will set to a From b297b882241ce2f750eda8702f6eee2db319a15d Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 30 Jun 2018 16:37:30 +0200 Subject: [PATCH 349/466] Complete design of olc menu, not tested yet --- evennia/prototypes/menus.py | 207 ++++++++++++++++++++++++++----- evennia/prototypes/prototypes.py | 4 +- 2 files changed, 180 insertions(+), 31 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 80e34e4c21..faeae88fca 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -5,7 +5,6 @@ OLC Prototype menu nodes """ import json -from ast import literal_eval from django.conf import settings from evennia.utils.evmenu import EvMenu, list_node from evennia.utils.ansi import strip_ansi @@ -40,6 +39,13 @@ def _get_menu_prototype(caller): return prototype +def _set_menu_prototype(caller, prototype): + """Set the prototype with existing one""" + caller.ndb._menutree.olc_prototype = prototype + caller.ndb._menutree.olc_new = False + return prototype + + def _is_new_prototype(caller): """Check if prototype is marked as new or was loaded from a saved one.""" return hasattr(caller.ndb._menutree, "olc_new") @@ -177,7 +183,7 @@ def _wizard_options(curr_node, prev_node, next_node, color="|W"): if curr_node: options.append({"key": ("|wv|Walidate prototype", "v"), - "goto": ("node_validate_prototype", {"back": curr_node})}) + "goto": ("node_view_prototype", {"back": curr_node})}) return options @@ -187,6 +193,26 @@ def _path_cropper(pythonpath): return pythonpath.split('.')[-1] +def _validate_prototype(prototype): + """Run validation on prototype""" + + txt = protlib.prototype_to_str(prototype) + errors = "\n\n|g No validation errors found.|n (but errors could still happen at spawn-time)" + err = False + try: + # validate, don't spawn + spawner.spawn(prototype, only_validate=True) + except RuntimeError as err: + errors = "\n\n|r{}|n".format(err) + err = True + except RuntimeWarning as err: + errors = "\n\n|y{}|n".format(err) + err = True + + text = (txt + errors) + return err, text + + # Menu nodes def node_index(caller): @@ -223,7 +249,7 @@ def node_index(caller): {"desc": "|WPrototype-{}|n|n{}".format( key, _format_option_value(key, required, prototype, None)), "goto": "node_prototype_{}".format(key.lower())}) - for key in ("Load", "Save", "Spawn"): + for key in ("Save", "Spawn", "Load"): options.append( {"key": ("|w{}|W{}".format(key[0], key[1:]), key[0]), "desc": "|W{}|n".format( @@ -233,22 +259,18 @@ def node_index(caller): return text, options -def node_validate_prototype(caller, raw_string, **kwargs): - prototype = _get_menu_prototype(caller) +def node_view_prototype(caller, raw_string, **kwargs): + """General node to view and validate a protototype""" + prototype = kwargs.get('prototype', _get_menu_prototype(caller)) + validate = kwargs.get("validate", True) + prev_node = kwargs.get("back", "node_index") - txt = protlib.prototype_to_str(prototype) - errors = "\n\n|g No validation errors found.|n (but errors could still happen at spawn-time)" - try: - # validate, don't spawn - spawner.spawn(prototype, only_validate=True) - except RuntimeError as err: - errors = "\n\n|r{}|n".format(err) - except RuntimeWarning as err: - errors = "\n\n|y{}|n".format(err) + if validate: + _, text = _validate_prototype(prototype) + else: + text = protlib.prototype_to_str(prototype) - text = (txt + errors) - - options = _wizard_options(None, kwargs.get("back"), None) + options = _wizard_options(None, prev_node, None) return text, options @@ -728,7 +750,8 @@ def node_destination(caller): def node_prototype_desc(caller): prototype = _get_menu_prototype(caller) - text = ["The |wPrototype-Description|n briefly describes the prototype for viewing in listings."] + text = ["The |wPrototype-Description|n briefly describes the prototype for " + "viewing in listings."] desc = prototype.get("prototype_desc", None) if desc: @@ -748,7 +771,8 @@ def node_prototype_desc(caller): def node_prototype_tags(caller): prototype = _get_menu_prototype(caller) - text = ["|wPrototype-Tags|n can be used to classify and find prototypes. Tags are case-insensitive. " + text = ["|wPrototype-Tags|n can be used to classify and find prototypes. " + "Tags are case-insensitive. " "Separate multiple by tags by commas."] tags = prototype.get('prototype_tags', []) @@ -788,19 +812,144 @@ def node_prototype_locks(caller): return text, options -def node_prototype_load(caller): - # load prototype from storage - pass +def node_prototype_save(caller, **kwargs): + """Save prototype to disk """ + # these are only set if we selected 'yes' to save on a previous pass + accept_save = kwargs.get("accept", False) + prototype = kwargs.get("prototype", None) + + if accept_save and prototype: + # we already validated and accepted the save, so this node acts as a goto callback and + # should now only return the next node + protlib.save_prototype(**prototype) + caller.msg("|gPrototype saved.|n") + return "node_spawn" + + # not validated yet + prototype = _get_menu_prototype(caller) + error, text = _validate_prototype(prototype) + + text = [text] + + if error: + # abort save + text.append( + "Validation errors were found. They need to be corrected before this prototype " + "can be saved (or used to spawn).") + options = _wizard_options("prototype_save", "prototype_locks", "index") + return "\n".join(text), options + + prototype_key = prototype['prototype_key'] + if protlib.search_prototype(prototype_key): + text.append("Do you want to save/overwrite the existing prototype '{name}'?".format( + name=prototype_key)) + else: + text.append("Do you want to save the prototype as '{name}'?".format(prototype_key)) + + options = ( + {"key": ("[|wY|Wes|n]", "yes", "y"), + "goto": lambda caller: + node_prototype_save(caller, + {"accept": True, "prototype": prototype})}, + {"key": ("|wN|Wo|n", "n"), + "goto": "node_spawn"}, + {"key": "_default", + "goto": lambda caller: + node_prototype_save(caller, + {"accept": True, "prototype": prototype})}) + + return "\n".join(text), options -def node_prototype_save(caller): - # save current prototype to disk - pass +def _spawn(caller, **kwargs): + """Spawn prototype""" + prototype = kwargs["prototype"].copy() + new_location = kwargs.get('location', None) + if new_location: + prototype['location'] = new_location + obj = spawner.spawn(prototype) + if obj: + caller.msg("|gNew instance|n {key} ({dbref}) |gspawned.|n".format( + key=obj.key, dbref=obj.dbref)) + else: + caller.msg("|rError: Spawner did not return a new instance.|n") -def node_prototype_spawn(caller): - # spawn an instance of this prototype - pass +def _update_spawned(caller, **kwargs): + """update existing objects""" + prototype = kwargs['prototype'] + objects = kwargs['objects'] + num_changed = spawner.batch_update_objects_with_prototype(prototype, objects=objects) + caller.msg("|g{num} objects were updated successfully.|n".format(num=num_changed)) + + +def node_prototype_spawn(caller, **kwargs): + """Submenu for spawning the prototype""" + + prototype = _get_menu_prototype(caller) + error, text = _validate_prototype(prototype) + + text = [text] + + if error: + text.append("|rPrototype validation failed. Correct the errors before spawning.|n") + options = _wizard_options("prototype_spawn", "prototype_locks", "index") + return "\n".join(text), options + + # show spawn submenu options + options = [] + prototype_key = prototype['prototype_key'] + location = prototype.get('location', None) + + if location: + options.append( + {"desc": "Spawn in prototype's defined location ({loc})".format(loc=location), + "goto": (_spawn, + dict(prototype=prototype))}) + caller_loc = caller.location + if location != caller_loc: + options.append( + {"desc": "Spawn in {caller}'s location ({loc})".format( + caller=caller, loc=caller_loc), + "goto": (_spawn, + dict(prototype=prototype, location=caller_loc))}) + if location != caller_loc != caller: + options.append( + {"desc": "Spawn in {caller}'s inventory".format(caller=caller), + "goto": (_spawn, + dict(prototype=prototype, location=caller))}) + + spawned_objects = protlib.search_objects_with_prototype(prototype_key) + nspawned = spawned_objects.count() + if spawned_objects: + options.append( + {"desc": "Update {num} existing objects with this prototype".format(num=nspawned), + "goto": (_update_spawned, + dict(prototype=prototype, + opjects=spawned_objects))}) + options.extend(_wizard_options("prototype_spawn", "prototype_save", "index")) + return text, options + + +def _prototype_load_select(caller, prototype_key): + matches = protlib.search_prototype(key=prototype_key) + if matches: + prototype = matches[0] + _set_menu_prototype(caller, prototype) + caller.msg("|gLoaded prototype '{}'.".format(prototype_key)) + return "node_index" + else: + caller.msg("|rFailed to load prototype '{}'.".format(prototype_key)) + return None + + +@list_node(_all_prototype_parents, _prototype_load_select) +def node_prototype_load(caller, **kwargs): + text = ["Select a prototype to load. This will replace any currently edited prototype."] + options = _wizard_options("load", "save", "index") + options.append({"key": "_default", + "goto": _prototype_parent_examine}) + return "\n".join(text), options class OLCMenu(EvMenu): @@ -843,7 +992,7 @@ def start_olc(caller, session=None, prototype=None): """ menudata = {"node_index": node_index, - "node_validate_prototype": node_validate_prototype, + "node_view_prototype": node_view_prototype, "node_prototype_key": node_prototype_key, "node_prototype_parent": node_prototype_parent, "node_typeclass": node_typeclass, diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 6f155fdac9..c02041d8bc 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -258,7 +258,7 @@ class DbPrototype(DefaultScript): # Prototype manager functions -def create_prototype(**kwargs): +def save_prototype(**kwargs): """ Create/Store a prototype persistently. @@ -335,7 +335,7 @@ def create_prototype(**kwargs): return stored_prototype.db.prototype # alias -save_prototype = create_prototype +create_prototype = save_prototype def delete_prototype(key, caller=None): From c004c6678baeacab3fe16dd49c1ea2341da256fc Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 1 Jul 2018 00:17:43 +0200 Subject: [PATCH 350/466] Start debugging olc menu structure --- evennia/prototypes/menus.py | 67 +++++++++++++++++++------------------ evennia/prototypes/tests.py | 2 ++ 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index faeae88fca..f978aae566 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -167,23 +167,23 @@ def _wizard_options(curr_node, prev_node, next_node, color="|W"): """Creates default navigation options available in the wizard.""" options = [] if prev_node: - options.append({"key": ("|wb|Wack", "b"), + options.append({"key": ("|wB|Wack", "b"), "desc": "{color}({node})|n".format( color=color, node=prev_node.replace("_", "-")), "goto": "node_{}".format(prev_node)}) if next_node: - options.append({"key": ("|wf|Worward", "f"), + options.append({"key": ("|wF|Worward", "f"), "desc": "{color}({node})|n".format( color=color, node=next_node.replace("_", "-")), "goto": "node_{}".format(next_node)}) if "index" not in (prev_node, next_node): - options.append({"key": ("|wi|Wndex", "i"), + options.append({"key": ("|wI|Wndex", "i"), "goto": "node_index"}) if curr_node: - options.append({"key": ("|wv|Walidate prototype", "v"), - "goto": ("node_view_prototype", {"back": curr_node})}) + options.append({"key": ("|wV|Walidate prototype", "validate", "v"), + "goto": ("node_validate_prototype", {"back": curr_node})}) return options @@ -229,19 +229,21 @@ def node_index(caller): options = [] options.append( - {"desc": "|WPrototype-Key|n|n{}".format(_format_option_value("Key", True, prototype, None)), + {"desc": "|WPrototype-Key|n|n{}".format( + _format_option_value("Key", "prototype_key" not in prototype, prototype, None)), "goto": "node_prototype_key"}) - for key in ('Typeclass', 'Prototype-parent', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', + for key in ('Typeclass', 'Prototype_parent', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', 'Permissions', 'Location', 'Home', 'Destination'): required = False cropper = None if key in ("Prototype-parent", "Typeclass"): - required = "prototype" not in prototype and "typeclass" not in prototype + required = "prototype_parent" not in prototype and "typeclass" not in prototype if key == 'Typeclass': cropper = _path_cropper options.append( {"desc": "|w{}|n{}".format( - key, _format_option_value(key, required, prototype, cropper=cropper)), + key.replace("_", "-"), + _format_option_value(key, required, prototype, cropper=cropper)), "goto": "node_{}".format(key.lower())}) required = False for key in ('Desc', 'Tags', 'Locks'): @@ -249,26 +251,26 @@ def node_index(caller): {"desc": "|WPrototype-{}|n|n{}".format( key, _format_option_value(key, required, prototype, None)), "goto": "node_prototype_{}".format(key.lower())}) - for key in ("Save", "Spawn", "Load"): - options.append( - {"key": ("|w{}|W{}".format(key[0], key[1:]), key[0]), - "desc": "|W{}|n".format( - key, _format_option_value(key, required, prototype, None)), - "goto": "node_prototype_{}".format(key.lower())}) + + options.extend(( + {"key": ("|wV|Walidate prototype", "validate", "v"), + "goto": "node_validate_prototype"}, + {"key": ("|wS|Wave prototype", "save", "s"), + "goto": "node_prototype_save"}, + {"key": ("|wSP|Wawn prototype", "spawn", "sp"), + "goto": "node_prototype_spawn"}, + {"key": ("|wL|Woad prototype", "load", "l"), + "goto": "node_prototype_load"})) return text, options -def node_view_prototype(caller, raw_string, **kwargs): +def node_validate_prototype(caller, raw_string, **kwargs): """General node to view and validate a protototype""" - prototype = kwargs.get('prototype', _get_menu_prototype(caller)) - validate = kwargs.get("validate", True) - prev_node = kwargs.get("back", "node_index") + prototype = _get_menu_prototype(caller) + prev_node = kwargs.get("back", "index") - if validate: - _, text = _validate_prototype(prototype) - else: - text = protlib.prototype_to_str(prototype) + _, text = _validate_prototype(prototype) options = _wizard_options(None, prev_node, None) @@ -310,7 +312,7 @@ def node_prototype_key(caller): text.append("The key is currently unset.") text.append("Enter text or make a choice (q for quit)") text = "\n\n".join(text) - options = _wizard_options("prototype_key", "index", "prototype") + options = _wizard_options("prototype_key", "index", "prototype_parent") options.append({"key": "_default", "goto": _check_prototype_key}) return text, options @@ -334,7 +336,7 @@ def _prototype_parent_examine(caller, prototype_name): def _prototype_parent_select(caller, prototype): - ret = _set_property(caller, prototype['prototype_key'], + ret = _set_property(caller, "", prop="prototype_parent", processor=str, next_node="node_key") caller.msg("Selected prototype |y{}|n. Removed any set typeclass parent.".format(prototype)) return ret @@ -358,7 +360,7 @@ def node_prototype_parent(caller): else: text.append("Parent prototype is not set") text = "\n\n".join(text) - options = _wizard_options("prototype", "prototype_key", "typeclass", color="|W") + options = _wizard_options("prototype_parent", "prototype_key", "typeclass", color="|W") options.append({"key": "_default", "goto": _prototype_parent_examine}) @@ -414,7 +416,7 @@ def node_typeclass(caller): text.append("Using default typeclass {typeclass}.".format( typeclass=settings.BASE_OBJECT_TYPECLASS)) text = "\n\n".join(text) - options = _wizard_options("typeclass", "prototype", "key", color="|W") + options = _wizard_options("typeclass", "prototype_parent", "key", color="|W") options.append({"key": "_default", "goto": _typeclass_examine}) return text, options @@ -923,7 +925,7 @@ def node_prototype_spawn(caller, **kwargs): nspawned = spawned_objects.count() if spawned_objects: options.append( - {"desc": "Update {num} existing objects with this prototype".format(num=nspawned), + {"desc": "Update {num} existing objects with this prototype".format(num=nspawned), "goto": (_update_spawned, dict(prototype=prototype, opjects=spawned_objects))}) @@ -962,18 +964,19 @@ class OLCMenu(EvMenu): Split the options into two blocks - olc options and normal options """ - olc_keys = ("index", "forward", "back", "previous", "next", "validate prototype") + olc_keys = ("index", "forward", "back", "previous", "next", "validate prototype", + "save prototype", "load prototype", "spawn prototype") olc_options = [] other_options = [] for key, desc in optionlist: - raw_key = strip_ansi(key) + raw_key = strip_ansi(key).lower() if raw_key in olc_keys: desc = " {}".format(desc) if desc else "" olc_options.append("|lc{}|lt{}|le{}".format(raw_key, key, desc)) else: other_options.append((key, desc)) - olc_options = " | ".join(olc_options) + " | " + "|wq|Wuit" if olc_options else "" + olc_options = " | ".join(olc_options) + " | " + "|wQ|Wuit" if olc_options else "" other_options = super(OLCMenu, self).options_formatter(other_options) sep = "\n\n" if olc_options and other_options else "" @@ -992,7 +995,7 @@ def start_olc(caller, session=None, prototype=None): """ menudata = {"node_index": node_index, - "node_view_prototype": node_view_prototype, + "node_validate_prototype": node_validate_prototype, "node_prototype_key": node_prototype_key, "node_prototype_parent": node_prototype_parent, "node_typeclass": node_typeclass, diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 49624905c7..0d5e247378 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -355,6 +355,8 @@ class TestMenuModule(EvenniaTest): {'goto': ('node_validate_prototype', {'back': 'ThisNode'}), 'key': ('|wv|Walidate prototype', 'v')}]) + self.assertEqual(olc_menus._validate_prototype(self.test_prot, (False, Something))) + def test_node_helpers(self): caller = self.caller From ec9813c25639c89f1998ac58477ec2b59c9de963 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 3 Jul 2018 23:56:55 +0200 Subject: [PATCH 351/466] Add functionality for object-update menu node, untested --- CHANGELOG.md | 40 ++++++-- evennia/prototypes/menus.py | 166 +++++++++++++++++++++++++------ evennia/prototypes/prototypes.py | 20 ++-- evennia/prototypes/spawner.py | 18 +++- evennia/prototypes/tests.py | 85 +++++++++++++--- 5 files changed, 265 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a05d65fdc0..3c1c4cf787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,29 @@ -# Evennia Changelog +# Changelog -# Sept 2017: -Release of Evennia 0.7; upgrade to Django 1.11, change 'Player' to +## Evennia 0.8 (2018) + +### Prototype changes + +- A new form of prototype - database-stored prototypes, editable from in-game. The old, + module-created prototypes remain as read-only prototypes. +- All prototypes must have a key `prototype_key` identifying the prototype in listings. This is + checked to be server-unique. Prototypes created in a module will use the global variable name they + are assigned to if no `prototype_key` is given. +- Prototype field `prototype` was renamed to `prototype_parent` to avoid mixing terms. +- All prototypes must either have `typeclass` or `prototype_parent` defined. If using + `prototype_parent`, `typeclass` must be defined somewhere in the inheritance chain. This is a + change from Evennia 0.7 which allowed 'mixin' prototypes without `typeclass`/`prototype_key`. To + make a mixin now, give it a default typeclass, like `evennia.objects.objects.DefaultObject` and just + override in the child as needed. +- The spawn command was extended to accept a full prototype on one line. +- The spawn command got the /save switch to save the defined prototype and its key. +- The command spawn/menu will now start an OLC (OnLine Creation) menu to load/save/edit/spawn prototypes. + + +# Overviews + +## Sept 2017: +Release of Evennia 0.7; upgrade to Django 1.11, change 'Player' to 'Account', rework the website template and a slew of other updates. Info on what changed and how to migrate is found here: https://groups.google.com/forum/#!msg/evennia/0JYYNGY-NfE/cDFaIwmPBAAJ @@ -14,9 +36,9 @@ Lots of bugfixes and considerable uptick in contributors. Unittest coverage and PEP8 adoption and refactoring. ## May 2016: -Evennia 0.6 with completely reworked Out-of-band system, making +Evennia 0.6 with completely reworked Out-of-band system, making the message path completely flexible and built around input/outputfuncs. -A completely new webclient, split into the evennia.js library and a +A completely new webclient, split into the evennia.js library and a gui library, making it easier to customize. ## Feb 2016: @@ -33,15 +55,15 @@ library format with a stand-alone launcher, in preparation for making an 'evennia' pypy package and using versioning. The version we will merge with will likely be 0.5. There is also work with an expanded testing structure and the use of threading for saves. We also now -use Travis for automatic build checking. +use Travis for automatic build checking. ## Sept 2014: Updated to Django 1.7+ which means South dependency was dropped and minimum Python version upped to 2.7. MULTISESSION_MODE=3 was added -and the web customization system was overhauled using the latest -functionality of django. Otherwise, mostly bug-fixes and +and the web customization system was overhauled using the latest +functionality of django. Otherwise, mostly bug-fixes and implementation of various smaller feature requests as we got used -to github. Many new users have appeared. +to github. Many new users have appeared. ## Jan 2014: Moved Evennia project from Google Code to github.com/evennia/evennia. diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index f978aae566..af670b743a 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -5,6 +5,7 @@ OLC Prototype menu nodes """ import json +from random import choice from django.conf import settings from evennia.utils.evmenu import EvMenu, list_node from evennia.utils.ansi import strip_ansi @@ -469,7 +470,7 @@ def _caller_attrs(caller): def _display_attribute(attr_tuple): """Pretty-print attribute tuple""" - attrkey, value, category, locks, default_access = attr_tuple + attrkey, value, category, locks = attr_tuple value = protlib.protfunc_parser(value) typ = type(value) out = ("Attribute key: '{attrkey}' (category: {category}, " @@ -503,7 +504,7 @@ def _add_attr(caller, attr_string, **kwargs): attrname, category = nameparts elif nparts > 2: attrname, category, locks = nameparts - attr_tuple = (attrname, category, locks) + attr_tuple = (attrname, value, category, locks) if attrname: prot = _get_menu_prototype(caller) @@ -513,7 +514,7 @@ def _add_attr(caller, attr_string, **kwargs): # replace existing attribute with the same name in the prototype ind = [tup[0] for tup in attrs].index(attrname) attrs[ind] = attr_tuple - except IndexError: + except ValueError: attrs.append(attr_tuple) _set_prototype_value(caller, "attrs", attrs) @@ -541,7 +542,8 @@ def _edit_attr(caller, attrname, new_value, **kwargs): def _examine_attr(caller, selection): prot = _get_menu_prototype(caller) - attr_tuple = prot['attrs'][selection] + ind = [part[0] for part in prot['attrs']].index(selection) + attr_tuple = prot['attrs'][ind] return _display_attribute(attr_tuple) @@ -572,15 +574,15 @@ def node_attrs(caller): def _caller_tags(caller): prototype = _get_menu_prototype(caller) - tags = prototype.get("tags") + tags = prototype.get("tags", []) return tags def _display_tag(tag_tuple): """Pretty-print attribute tuple""" tagkey, category, data = tag_tuple - out = ("Tag: '{tagkey}' (category: {category}{})".format( - tagkey=tagkey, category=category, data=", data: {}".format(data) if data else "")) + out = ("Tag: '{tagkey}' (category: {category}{dat})".format( + tagkey=tagkey, category=category, dat=", data: {}".format(data) if data else "")) return out @@ -613,16 +615,21 @@ def _add_tag(caller, tag, **kwargs): old_tag = kwargs.get("edit", None) - if old_tag: - # editing a tag means removing the old and replacing with new + if not old_tag: + # a fresh, new tag + tags.append(tag_tuple) + else: + # old tag exists; editing a tag means removing the old and replacing with new try: ind = [tup[0] for tup in tags].index(old_tag) del tags[ind] + if tags: + tags.insert(ind, tag_tuple) + else: + tags = [tag_tuple] except IndexError: pass - tags.append(tag_tuple) - _set_prototype_value(caller, "tags", tags) text = kwargs.get('text') @@ -814,18 +821,121 @@ def node_prototype_locks(caller): return text, options +def _update_spawned(caller, **kwargs): + """update existing objects""" + prototype = kwargs['prototype'] + objects = kwargs['objects'] + back_node = kwargs['back_key'] + num_changed = spawner.batch_update_objects_with_prototype(prototype, objects=objects) + caller.msg("|g{num} objects were updated successfully.|n".format(num=num_changed)) + return back_key + + +def _keep_diff(caller, **kwargs): + key = kwargs['key'] + diff = kwargs['diff'] + diff[key] = "KEEP" + + +def node_update_objects(caller, **kwargs): + """Offer options for updating objects""" + + def _keep_option(keyname, prototype, obj, obj_prototype, diff, objects, back_node): + """helper returning an option dict""" + options = {"desc": "Keep {} as-is".format(keyname), + "goto": (_keep_diff, + {"key": keyname, "prototype": prototype, + "obj": obj, "obj_prototype": obj_prototype, + "diff": diff, "objects": objects, "back_node": back_node})} + return options + + prototype = kwargs.get("prototype", None) + update_objects = kwargs.get("objects", None) + back_node = kwargs.get("back_node", "node_index") + obj_prototype = kwargs.get("obj_prototype", None) + diff = kwargs.get("diff", None) + + if not update_objects: + text = "There are no existing objects to update." + options = {"key": "_default", + "goto": back_node} + return text, options + + if not diff: + # use one random object as a reference to calculate a diff + obj = choice(update_objects) + diff, obj_prototype = spawner.prototype_diff_from_object(prototype, obj) + + text = ["Suggested changes to {} objects".format(len(update_objects)), + "Showing random example obj to change: {name} (#{dbref}))\n".format(obj.key, obj.dbref)] + options = [] + io = 0 + for (key, inst) in sorted(((key, val) for key, val in diff.items()), key=lambda tup: tup[0]): + line = "{iopt} |w{key}|n: {old}{sep}{new} {change}" + old_val = utils.crop(str(obj_prototype[key]), width=20) + + if inst == "KEEP": + text.append(line.format(iopt='', key=key, old=old_val, sep=" ", new='', change=inst)) + continue + + new_val = utils.crop(str(spawner.init_spawn_value(prototype[key])), width=20) + io += 1 + if inst in ("UPDATE", "REPLACE"): + text.append(line.format(iopt=io, key=key, old=old_val, + sep=" |y->|n ", new=new_val, change=inst)) + options.append(_keep_option(key, prototype, + obj, obj_prototype, diff, objects, back_node)) + elif inst == "REMOVE": + text.append(line.format(iopt=io, key=key, old=old_val, + sep=" |r->|n ", new='', change=inst)) + options.append(_keep_option(key, prototype, + obj, obj_prototype, diff, objects, back_node)) + options.extend( + [{"key": ("|wu|r update {} objects".format(len(update_objects)), "update", "u"), + "goto": (_update_spawned, {"prototype": prototype, "objects": objects, + "back_node": back_node, "diff": diff})}, + {"key": ("|wr|neset changes", "reset", "r"), + "goto": ("node_update_objects", {"prototype": prototype, "back_node": back_node, + "objects": update_objects})}, + {"key": "|wb|rack ({})".format(back_node[5:], 'b'), + "goto": back_node}]) + + return text, options + + def node_prototype_save(caller, **kwargs): """Save prototype to disk """ # these are only set if we selected 'yes' to save on a previous pass - accept_save = kwargs.get("accept", False) prototype = kwargs.get("prototype", None) + accept_save = kwargs.get("accept_save", False) if accept_save and prototype: # we already validated and accepted the save, so this node acts as a goto callback and # should now only return the next node + prototype_key = prototype.get("prototype_key") protlib.save_prototype(**prototype) - caller.msg("|gPrototype saved.|n") - return "node_spawn" + + spawned_objects = protlib.search_objects_with_prototype(prototype_key) + nspawned = spawned_objects.count() + + if nspawned: + text = ("Do you want to update {} object(s) " + "already using this prototype?".format(nspawned)) + options = ( + {"key": ("|wY|Wes|n", "yes", "y"), + "goto": ("node_update_objects", + {"accept_update": True, "objects": spawned_objects, + "prototype": prototype, "back_node": "node_prototype_save"})}, + {"key": ("[|wN|Wo|n]", "n"), + "goto": "node_spawn"}, + {"key": "_default", + "goto": "node_spawn"}) + else: + text = "|gPrototype saved.|n" + options = {"key": "_default", + "goto": "node_spawn"} + + return text, options # not validated yet prototype = _get_menu_prototype(caller) @@ -850,15 +960,13 @@ def node_prototype_save(caller, **kwargs): options = ( {"key": ("[|wY|Wes|n]", "yes", "y"), - "goto": lambda caller: - node_prototype_save(caller, - {"accept": True, "prototype": prototype})}, + "goto": ("node_prototype_save", + {"accept": True, "prototype": prototype})}, {"key": ("|wN|Wo|n", "n"), "goto": "node_spawn"}, {"key": "_default", - "goto": lambda caller: - node_prototype_save(caller, - {"accept": True, "prototype": prototype})}) + "goto": ("node_prototype_save", + {"accept": True, "prototype": prototype})}) return "\n".join(text), options @@ -869,20 +977,15 @@ def _spawn(caller, **kwargs): new_location = kwargs.get('location', None) if new_location: prototype['location'] = new_location + obj = spawner.spawn(prototype) if obj: + obj = obj[0] caller.msg("|gNew instance|n {key} ({dbref}) |gspawned.|n".format( key=obj.key, dbref=obj.dbref)) else: caller.msg("|rError: Spawner did not return a new instance.|n") - - -def _update_spawned(caller, **kwargs): - """update existing objects""" - prototype = kwargs['prototype'] - objects = kwargs['objects'] - num_changed = spawner.batch_update_objects_with_prototype(prototype, objects=objects) - caller.msg("|g{num} objects were updated successfully.|n".format(num=num_changed)) + return obj def node_prototype_spawn(caller, **kwargs): @@ -926,9 +1029,9 @@ def node_prototype_spawn(caller, **kwargs): if spawned_objects: options.append( {"desc": "Update {num} existing objects with this prototype".format(num=nspawned), - "goto": (_update_spawned, - dict(prototype=prototype, - opjects=spawned_objects))}) + "goto": ("node_update_objects", + dict(prototype=prototype, opjects=spawned_objects, + back_node="node_prototype_spawn"))}) options.extend(_wizard_options("prototype_spawn", "prototype_save", "index")) return text, options @@ -1008,6 +1111,7 @@ def start_olc(caller, session=None, prototype=None): "node_location": node_location, "node_home": node_home, "node_destination": node_destination, + "node_update_objects": node_o "node_prototype_desc": node_prototype_desc, "node_prototype_tags": node_prototype_tags, "node_prototype_locks": node_prototype_locks, diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index c02041d8bc..2457f86994 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -547,7 +547,8 @@ def validate_prototype(prototype, protkey=None, protparents=None, _flags = {"visited": [], "depth": 0, "typeclass": False, "errors": [], "warnings": []} if not protparents: - protparents = {prototype['prototype_key']: prototype for prototype in search_prototype()} + protparents = {prototype.get('prototype_key', "").lower(): prototype + for prototype in search_prototype()} protkey = protkey and protkey.lower() or prototype.get('prototype_key', None) @@ -568,17 +569,11 @@ def validate_prototype(prototype, protkey=None, protparents=None, if typeclass and typeclass not in get_all_typeclasses("evennia.objects.models.ObjectDB"): _flags['errors'].append( - "Prototype {} is based on typeclass {} which could not be imported!".format( + "Prototype {} is based on typeclass {}, which could not be imported!".format( protkey, typeclass)) # recursively traverese prototype_parent chain - if id(prototype) in _flags['visited']: - _flags['errors'].append( - "{} has infinite nesting of prototypes.".format(protkey or prototype)) - - _flags['visited'].append(id(prototype)) - for protstring in make_iter(prototype_parent): protstring = protstring.lower() if protkey is not None and protstring == protkey: @@ -587,8 +582,15 @@ def validate_prototype(prototype, protkey=None, protparents=None, if not protparent: _flags['errors'].append("Prototype {}'s prototype_parent '{}' was not found.".format( (protkey, protstring))) + if id(prototype) in _flags['visited']: + _flags['errors'].append( + "{} has infinite nesting of prototypes.".format(protkey or prototype)) + + _flags['visited'].append(id(prototype)) _flags['depth'] += 1 - validate_prototype(protparent, protstring, protparents, _flags) + validate_prototype(protparent, protstring, protparents, + is_prototype_base=is_prototype_base, _flags=_flags) + _flags['visited'].pop() _flags['depth'] -= 1 if typeclass and not _flags['typeclass']: diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 71aecfd61e..d826317fec 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -179,6 +179,7 @@ def prototype_from_object(obj): prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) if prot: prot = protlib.search_prototype(prot[0]) + if not prot or len(prot) > 1: # no unambiguous prototype found - build new prototype prot = {} @@ -187,6 +188,8 @@ def prototype_from_object(obj): prot['prototype_desc'] = "Built from {}".format(str(obj)) prot['prototype_locks'] = "spawn:all();edit:all()" prot['prototype_tags'] = [] + else: + prot = prot[0] prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6] prot['typeclass'] = obj.db_typeclass_path @@ -233,6 +236,8 @@ def prototype_diff_from_object(prototype, obj): Returns: diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...} + other_prototype (dict): The prototype for the given object. The diff is a how to convert + this prototype into the new prototype. """ prot1 = prototype @@ -253,7 +258,7 @@ def prototype_diff_from_object(prototype, obj): if key not in diff and key not in prot1: diff[key] = "REMOVE" - return diff + return diff, prot2 def batch_update_objects_with_prototype(prototype, diff=None, objects=None): @@ -475,8 +480,12 @@ def spawn(*prototypes, **kwargs): protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()} # overload module's protparents with specifically given protparents - protparents.update( - {key.lower(): value for key, value in kwargs.get("prototype_parents", {}).items()}) + # we allow prototype_key to be the key of the protparent dict, to allow for module-level + # prototype imports. We need to insert prototype_key in this case + for key, protparent in kwargs.get("prototype_parents", {}).items(): + key = str(key).lower() + protparent['prototype_key'] = str(protparent.get("prototype_key", key)).lower() + protparents[key] = protparent if "return_parents" in kwargs: # only return the parents @@ -541,6 +550,9 @@ def spawn(*prototypes, **kwargs): simple_attributes = [] for key, value in ((key, value) for key, value in prot.items() if not (key.startswith("ndb_"))): + if key in _PROTOTYPE_META_NAMES: + continue + if is_iter(value) and len(value) > 1: # (value, category) simple_attributes.append((key, diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 0d5e247378..69eb495dd5 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -17,6 +17,8 @@ from evennia.prototypes.prototypes import _PROTOTYPE_TAG_META_CATEGORY _PROTPARENTS = { "NOBODY": {}, "GOBLIN": { + "prototype_key": "GOBLIN", + "typeclass": "evennia.objects.objects.DefaultObject", "key": "goblin grunt", "health": lambda: randint(1, 1), "resists": ["cold", "poison"], @@ -24,21 +26,22 @@ _PROTPARENTS = { "weaknesses": ["fire", "light"] }, "GOBLIN_WIZARD": { - "prototype": "GOBLIN", + "prototype_parent": "GOBLIN", "key": "goblin wizard", "spells": ["fire ball", "lighting bolt"] }, "GOBLIN_ARCHER": { - "prototype": "GOBLIN", + "prototype_parent": "GOBLIN", "key": "goblin archer", "attacks": ["short bow"] }, "ARCHWIZARD": { + "prototype_parent": "GOBLIN", "attacks": ["archwizard staff"], }, "GOBLIN_ARCHWIZARD": { "key": "goblin archwizard", - "prototype": ("GOBLIN_WIZARD", "ARCHWIZARD") + "prototype_parent": ("GOBLIN_WIZARD", "ARCHWIZARD") } } @@ -47,7 +50,8 @@ class TestSpawner(EvenniaTest): def setUp(self): super(TestSpawner, self).setUp() - self.prot1 = {"prototype_key": "testprototype"} + self.prot1 = {"prototype_key": "testprototype", + "typeclass": "evennia.objects.objects.DefaultObject"} def test_spawn(self): obj1 = spawner.spawn(self.prot1) @@ -323,6 +327,7 @@ class TestMenuModule(EvenniaTest): self.caller.ndb._menutree = menutree self.test_prot = {"prototype_key": "test_prot", + "typeclass": "evennia.objects.objects.DefaultObject", "prototype_locks": "edit:all();spawn:all()"} def test_helpers(self): @@ -334,6 +339,8 @@ class TestMenuModule(EvenniaTest): self.assertEqual(olc_menus._get_menu_prototype(caller), {}) self.assertEqual(olc_menus._is_new_prototype(caller), True) + self.assertEqual(olc_menus._set_menu_prototype(caller, {}), {}) + self.assertEqual( olc_menus._set_prototype_value(caller, "key", "TestKey"), {"key": "TestKey"}) self.assertEqual(olc_menus._get_menu_prototype(caller), {"key": "TestKey"}) @@ -349,13 +356,16 @@ class TestMenuModule(EvenniaTest): self.assertEqual(olc_menus._wizard_options( "ThisNode", "PrevNode", "NextNode"), - [{'goto': 'node_PrevNode', 'key': ('|wb|Wack', 'b'), 'desc': '|W(PrevNode)|n'}, - {'goto': 'node_NextNode', 'key': ('|wf|Worward', 'f'), 'desc': '|W(NextNode)|n'}, - {'goto': 'node_index', 'key': ('|wi|Wndex', 'i')}, + [{'goto': 'node_PrevNode', 'key': ('|wB|Wack', 'b'), 'desc': '|W(PrevNode)|n'}, + {'goto': 'node_NextNode', 'key': ('|wF|Worward', 'f'), 'desc': '|W(NextNode)|n'}, + {'goto': 'node_index', 'key': ('|wI|Wndex', 'i')}, {'goto': ('node_validate_prototype', {'back': 'ThisNode'}), - 'key': ('|wv|Walidate prototype', 'v')}]) + 'key': ('|wV|Walidate prototype', 'validate', 'v')}]) - self.assertEqual(olc_menus._validate_prototype(self.test_prot, (False, Something))) + self.assertEqual(olc_menus._validate_prototype(self.test_prot), (False, Something)) + self.assertEqual(olc_menus._validate_prototype( + {"prototype_key": "testthing", "key": "mytest"}), + (True, Something)) def test_node_helpers(self): @@ -363,23 +373,27 @@ class TestMenuModule(EvenniaTest): with mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock(return_value=[self.test_prot])): + # prototype_key helpers self.assertEqual(olc_menus._check_prototype_key(caller, "test_prot"), "node_prototype_parent") caller.ndb._menutree.olc_new = True self.assertEqual(olc_menus._check_prototype_key(caller, "test_prot"), "node_index") + # prototype_parent helpers self.assertEqual(olc_menus._all_prototype_parents(caller), ['test_prot']) self.assertEqual(olc_menus._prototype_parent_examine( caller, 'test_prot'), - '|cprototype key:|n test_prot, |ctags:|n None, |clocks:|n edit:all();spawn:all() ' - '\n|cdesc:|n None \n|cprototype:|n {\n \n}') + "|cprototype key:|n test_prot, |ctags:|n None, |clocks:|n edit:all();spawn:all() " + "\n|cdesc:|n None \n|cprototype:|n " + "{\n 'typeclass': 'evennia.objects.objects.DefaultObject', \n}") self.assertEqual(olc_menus._prototype_parent_select(caller, self.test_prot), "node_key") self.assertEqual(olc_menus._get_menu_prototype(caller), {'prototype_key': 'test_prot', 'prototype_locks': 'edit:all();spawn:all()', - 'prototype_parent': "test_prot"}) + 'typeclass': 'evennia.objects.objects.DefaultObject'}) + # typeclass helpers with mock.patch("evennia.utils.utils.get_all_typeclasses", new=mock.MagicMock(return_value={"foo": None, "bar": None})): self.assertEqual(olc_menus._all_typeclasses(caller), ["bar", "foo"]) @@ -394,6 +408,53 @@ class TestMenuModule(EvenniaTest): 'prototype_locks': 'edit:all();spawn:all()', 'typeclass': 'evennia.objects.objects.DefaultObject'}) + # attr helpers + self.assertEqual(olc_menus._caller_attrs(caller), []) + self.assertEqual(olc_menus._add_attr(caller, "test1=foo1"), (Something, {"key": "_default", "goto": Something})) + self.assertEqual(olc_menus._add_attr(caller, "test2;cat1=foo2"), (Something, {"key": "_default", "goto": Something})) + self.assertEqual(olc_menus._add_attr(caller, "test3;cat2;edit:false()=foo3"), (Something, {"key": "_default", "goto": Something})) + self.assertEqual(olc_menus._add_attr(caller, "test4;cat3;set:true();edit:false()=foo4"), (Something, {"key": "_default", "goto": Something})) + self.assertEqual(olc_menus._add_attr(caller, "test5;cat4;set:true();edit:false()=123"), (Something, {"key": "_default", "goto": Something})) + self.assertEqual(olc_menus._caller_attrs( + caller), + [("test1", "foo1", None, ''), + ("test2", "foo2", "cat1", ''), + ("test3", "foo3", "cat2", "edit:false()"), + ("test4", "foo4", "cat3", "set:true();edit:false()"), + ("test5", '123', "cat4", "set:true();edit:false()")]) + self.assertEqual(olc_menus._edit_attr(caller, "test1", "1;cat5;edit:all()"), (Something, {"key": "_default", "goto": Something})) + self.assertEqual(olc_menus._examine_attr(caller, "test1"), Something) + + # tag helpers + self.assertEqual(olc_menus._caller_tags(caller), []) + self.assertEqual(olc_menus._add_tag(caller, "foo1"), (Something, {"key": "_default", "goto": Something})) + self.assertEqual(olc_menus._add_tag(caller, "foo2;cat1"), (Something, {"key": "_default", "goto": Something})) + self.assertEqual(olc_menus._add_tag(caller, "foo3;cat2;dat1"), (Something, {"key": "_default", "goto": Something})) + self.assertEqual(olc_menus._caller_tags( + caller), + [('foo1', None, ""), + ('foo2', 'cat1', ""), + ('foo3', 'cat2', "dat1")]) + self.assertEqual(olc_menus._edit_tag(caller, "foo1", "bar1;cat1"), (Something, {"key": "_default", "goto": Something})) + self.assertEqual(olc_menus._display_tag(olc_menus._caller_tags(caller)[0]), Something) + self.assertEqual(olc_menus._caller_tags(caller)[0], ("bar1", "cat1", "")) + + protlib.save_prototype(**self.test_prot) + + # spawn helpers + obj = olc_menus._spawn(caller, prototype=self.test_prot) + + self.assertEqual(obj.typeclass_path, "evennia.objects.objects.DefaultObject") + self.assertEqual(obj.tags.get(category=spawner._PROTOTYPE_TAG_CATEGORY), self.test_prot['prototype_key']) + self.assertEqual(olc_menus._update_spawned(caller, prototype=self.test_prot, objects=[obj]), 0) # no changes to apply + self.test_prot['key'] = "updated key" # change prototype + self.assertEqual(self._update_spawned(caller, prototype=self.test_prot, objects=[obj]), 1) # apply change to the one obj + + + # load helpers + + + @mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock( return_value=[{"prototype_key": "TestPrototype", From 706ed47cccc54db82d3a4e8539d408562c0bbe8d Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 4 Jul 2018 19:25:44 +0200 Subject: [PATCH 352/466] Add unit tests to all menu helpers --- evennia/prototypes/menus.py | 15 ++++++++------- evennia/prototypes/spawner.py | 2 +- evennia/prototypes/tests.py | 11 +++++++---- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index af670b743a..1f2eb26a4f 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -825,10 +825,11 @@ def _update_spawned(caller, **kwargs): """update existing objects""" prototype = kwargs['prototype'] objects = kwargs['objects'] - back_node = kwargs['back_key'] - num_changed = spawner.batch_update_objects_with_prototype(prototype, objects=objects) + back_node = kwargs['back_node'] + diff = kwargs.get('diff', None) + num_changed = spawner.batch_update_objects_with_prototype(prototype, diff=diff, objects=objects) caller.msg("|g{num} objects were updated successfully.|n".format(num=num_changed)) - return back_key + return back_node def _keep_diff(caller, **kwargs): @@ -884,15 +885,15 @@ def node_update_objects(caller, **kwargs): text.append(line.format(iopt=io, key=key, old=old_val, sep=" |y->|n ", new=new_val, change=inst)) options.append(_keep_option(key, prototype, - obj, obj_prototype, diff, objects, back_node)) + obj, obj_prototype, diff, update_objects, back_node)) elif inst == "REMOVE": text.append(line.format(iopt=io, key=key, old=old_val, sep=" |r->|n ", new='', change=inst)) options.append(_keep_option(key, prototype, - obj, obj_prototype, diff, objects, back_node)) + obj, obj_prototype, diff, update_objects, back_node)) options.extend( [{"key": ("|wu|r update {} objects".format(len(update_objects)), "update", "u"), - "goto": (_update_spawned, {"prototype": prototype, "objects": objects, + "goto": (_update_spawned, {"prototype": prototype, "objects": update_objects, "back_node": back_node, "diff": diff})}, {"key": ("|wr|neset changes", "reset", "r"), "goto": ("node_update_objects", {"prototype": prototype, "back_node": back_node, @@ -1111,7 +1112,7 @@ def start_olc(caller, session=None, prototype=None): "node_location": node_location, "node_home": node_home, "node_destination": node_destination, - "node_update_objects": node_o + "node_update_objects": node_update_objects, "node_prototype_desc": node_prototype_desc, "node_prototype_tags": node_prototype_tags, "node_prototype_locks": node_prototype_locks, diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index d826317fec..c09a192819 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -290,7 +290,7 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None): return 0 if not diff: - diff = prototype_diff_from_object(new_prototype, objects[0]) + diff, _ = prototype_diff_from_object(new_prototype, objects[0]) changed = 0 for obj in objects: diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 69eb495dd5..8932b368c1 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -446,13 +446,16 @@ class TestMenuModule(EvenniaTest): self.assertEqual(obj.typeclass_path, "evennia.objects.objects.DefaultObject") self.assertEqual(obj.tags.get(category=spawner._PROTOTYPE_TAG_CATEGORY), self.test_prot['prototype_key']) - self.assertEqual(olc_menus._update_spawned(caller, prototype=self.test_prot, objects=[obj]), 0) # no changes to apply - self.test_prot['key'] = "updated key" # change prototype - self.assertEqual(self._update_spawned(caller, prototype=self.test_prot, objects=[obj]), 1) # apply change to the one obj + # update helpers + self.assertEqual(olc_menus._update_spawned( + caller, prototype=self.test_prot, back_node="foo", objects=[obj]), 'foo') # no changes to apply + self.test_prot['key'] = "updated key" # change prototype + self.assertEqual(olc_menus._update_spawned( + caller, prototype=self.test_prot, objects=[obj], back_node='foo'), 'foo') # apply change to the one obj # load helpers - + self.assertEqual(olc_menus._prototype_load_select(caller, self.test_prot['prototype_key']), "node_index") From a721889fc1fd7676eabe794d400a017cf3ae0220 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 12 Jul 2018 10:18:04 +0200 Subject: [PATCH 353/466] More work on unittests, still issues --- evennia/commands/default/building.py | 86 +++++++++------------------- evennia/commands/default/tests.py | 26 +++++---- evennia/prototypes/prototypes.py | 13 ++++- 3 files changed, 56 insertions(+), 69 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 5c96ad1cf6..a589b5131e 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2795,17 +2795,17 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): spawn objects from prototype Usage: - @spawn[/noloc] + @spawn[/noloc] @spawn[/noloc] - @spawn/search [key][;tag[,tag]] - @spawn/list [tag, tag] - @spawn/show [] - @spawn/update + @spawn/search [prototype_keykey][;tag[,tag]] + @spawn/list [tag, tag, ...] + @spawn/show [] + @spawn/update - @spawn/save [;desc[;tag,tag[,...][;lockstring]]] = - @spawn/menu [] - @olc - equivalent to @spawn/menu + @spawn/save + @spawn/edit [] + @olc - equivalent to @spawn/edit Switches: noloc - allow location to be None if not specified explicitly. Otherwise, @@ -2819,7 +2819,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): them with latest version of given prototype. If given with /save, will auto-update all objects with the old version of the prototype without asking first. - menu, olc - create/manipulate prototype in a menu interface. + edit, olc - create/manipulate prototype in a menu interface. Example: @spawn GOBLIN @@ -2827,10 +2827,11 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): @spawn/save {"key": "grunt", prototype: "goblin"};;mobs;edit:all() Dictionary keys: - |wprototype |n - name of parent prototype to use. Can be a list for - multiple inheritance (inherits left to right) + |wprototype_parent |n - name of parent prototype to use. Required if typeclass is + not set. Can be a path or a list for multiple inheritance (inherits + left to right). If set one of the parents must have a typeclass. + |wtypeclass |n - string. Required if prototype_parent is not set. |wkey |n - string, the main object identifier - |wtypeclass |n - string, if not set, will use settings.BASE_OBJECT_TYPECLASS |wlocation |n - this should be a valid object or #dbref |whome |n - valid object or #dbref |wdestination|n - only valid for exits (object or dbref) @@ -2875,7 +2876,8 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): string = ("{}\n|RCritical Python syntax error in argument. Only primitive " "Python structures are allowed. \nYou also need to use correct " "Python syntax. Remember especially to put quotes around all " - "strings inside lists and dicts.|n".format(err)) + "strings inside lists and dicts.|n For more advanced uses, embed " + "inline functions in the strings.".format(err)) else: string = "Expected {}, got {}.".format(expect, type(prototype)) self.caller.msg(string) @@ -2896,9 +2898,9 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): def _search_show_prototype(query, prototypes=None): # prototype detail if not prototypes: - prototypes = spawner.search_prototype(key=query) + prototypes = protlib.search_prototype(key=query) if prototypes: - return "\n".join(spawner.prototype_to_str(prot) for prot in prototypes) + return "\n".join(protlib.prototype_to_str(prot) for prot in prototypes) else: return False @@ -2947,64 +2949,36 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if 'list' in self.switches: # for list, all optional arguments are tags + # import pudb; pudb.set_trace() + EvMore(caller, unicode(protlib.list_prototypes(caller, tags=self.lhslist)), exit_on_lastpage=True) return if 'save' in self.switches: # store a prototype to the database store - if not self.args or not self.rhs: + if not self.args: caller.msg( "Usage: @spawn/save [;desc[;tag,tag[,...][;lockstring]]] = ") return - # handle lhs - parts = self.lhs.split(";", 3) - nparts = len(parts) - if nparts == 1: - key = parts[0].strip() - elif nparts == 2: - key, desc = (part.strip() for part in parts) - elif nparts == 3: - key, desc, tags = (part.strip() for part in parts) - tags = [tag.strip().lower() for tag in tags.split(",") if tag] - else: - # lockstrings can itself contain ; - key, desc, tags, lockstring = (part.strip() for part in parts) - tags = [tag.strip().lower() for tag in tags.split(",") if tag] - if not key: - caller.msg("The prototype must have a key.") - return - if not desc: - desc = "User-created prototype" - if not tags: - tags = ["user"] - if not lockstring: - lockstring = "edit:id({}) or perm(Admin); use:all()".format(caller.id) - - is_valid, err = caller.locks.validate(lockstring) - if not is_valid: - caller.msg("|rLock error|n: {}".format(err)) - return - # handle rhs: - prototype = _parse_prototype(self.rhs) + prototype = _parse_prototype(self.lhs.strip()) if not prototype: return - # inject the prototype_* keys into the prototype to save - prototype['prototype_key'] = prototype.get('prototype_key', key) - prototype['prototype_desc'] = prototype.get('prototype_desc', desc) - prototype['prototype_tags'] = prototype.get('prototype_tags', tags) - prototype['prototype_locks'] = prototype.get('prototype_locks', lockstring) - # present prototype to save new_matchstring = _search_show_prototype("", prototypes=[prototype]) string = "|yCreating new prototype:|n\n{}".format(new_matchstring) question = "\nDo you want to continue saving? [Y]/N" + prototype_key = prototype.get("prototype_key") + if not prototype_key: + caller.msg("\n|yTo save a prototype it must have the 'prototype_key' set.") + return + # check for existing prototype, - old_matchstring = _search_show_prototype(key) + old_matchstring = _search_show_prototype(prototype_key) if old_matchstring: string += "\n|yExisting saved prototype found:|n\n{}".format(old_matchstring) @@ -3017,14 +2991,10 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # all seems ok. Try to save. try: - prot = spawner.save_db_prototype( - caller, key, prototype, desc=desc, tags=tags, locks=lockstring) + prot = protlib.save_prototype(**prototype) if not prot: caller.msg("|rError saving:|R {}.|n".format(key)) return - prot.locks.append("edit", "perm(Admin)") - if not prot.locks.get("use"): - prot.locks.add("use:all()") except PermissionError as err: caller.msg("|rError saving:|R {}|n".format(err)) return diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index e1688cdb48..f047b3a458 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -360,6 +360,7 @@ class TestBuilding(CommandTest): # check that it exists in the process. query = search_object(objKeyStr) commandTest.assertIsNotNone(query) + commandTest.assertTrue(bool(query)) obj = query[0] commandTest.assertIsNotNone(obj) return obj @@ -368,18 +369,20 @@ class TestBuilding(CommandTest): self.call(building.CmdSpawn(), " ", "Usage: @spawn") # Tests "@spawn " without specifying location. - self.call(building.CmdSpawn(), - "{'prototype_key': 'testprot', 'key':'goblin', " - "'typeclass':'evennia.DefaultCharacter'}", "Spawned goblin") - goblin = getObject(self, "goblin") + with mock.patch('evennia.commands.default.func', return_value=iter(['y'])) as mock_iter: + self.call(building.CmdSpawn(), + "/save {'prototype_key': 'testprot', 'key':'Test Char', " + "'typeclass':'evennia.objects.objects.DefaultCharacter'}", "") + mock_iter.assert_called() - # Tests that the spawned object's type is a DefaultCharacter. - self.assertIsInstance(goblin, DefaultCharacter) + self.call(building.CmdSpawn(), "/list", "foo") + self.call(building.CmdSpawn(), 'testprot', "Spawned Test Char") # Tests that the spawned object's location is the same as the caharacter's location, since # we did not specify it. - self.assertEqual(goblin.location, self.char1.location) - goblin.delete() + testchar = getObject(self, "Test Char") + self.assertEqual(testchar.location, self.char1.location) + testchar.delete() # Test "@spawn " with a location other than the character's. spawnLoc = self.room2 @@ -389,10 +392,13 @@ class TestBuilding(CommandTest): spawnLoc = self.room1 self.call(building.CmdSpawn(), - "{'prototype_key':'GOBLIN', 'key':'goblin', 'location':'%s'}" - % spawnLoc.dbref, "Spawned goblin") + "{'prototype_key':'GOBLIN', 'typeclass':'evennia.objects.objects.DefaultCharacter', " + "'key':'goblin', 'location':'%s'}" % spawnLoc.dbref, "Spawned goblin") goblin = getObject(self, "goblin") + # Tests that the spawned object's type is a DefaultCharacter. + self.assertIsInstance(goblin, DefaultCharacter) self.assertEqual(goblin.location, spawnLoc) + goblin.delete() protlib.create_prototype(**{'key': 'Ball', 'prototype': 'GOBLIN', 'prototype_key': 'testball'}) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 2457f86994..57087b133f 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -506,7 +506,7 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed ",".join(ptags))) if not display_tuples: - return None + return "" table = [] width = 78 @@ -607,3 +607,14 @@ def validate_prototype(prototype, protkey=None, protparents=None, raise RuntimeError("Error: " + "\nError: ".join(_flags['errors'])) if _flags['warnings']: raise RuntimeWarning("Warning: " + "\nWarning: ".join(_flags['warnings'])) + + # make sure prototype_locks are set to defaults + prototype_locks = [lstring.split(":", 1) + for lstring in prototype.get("prototype_locks", "").split(';')] + locktypes = [tup[0].strip() for tup in prototype_locks] + if "spawn" not in locktypes: + prototype_locks.append(("spawn", "all()")) + if "edit" not in locktypes: + prototype_locks.append(("edit", "all()")) + prototype_locks = ";".join(":".join(tup) for tup in prototype_locks) + prototype['prototype_locks'] = prototype_locks From a4b8b12e63ae865f53340444788cecea7e981a03 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 21 Jul 2018 13:40:46 +0200 Subject: [PATCH 354/466] Fix unittests --- evennia/commands/default/building.py | 12 +-- evennia/commands/default/tests.py | 49 +++++++++--- evennia/contrib/tutorial_world/objects.py | 24 +++--- evennia/prototypes/prototypes.py | 10 ++- evennia/prototypes/tests.py | 93 ++++++++++++++++------- 5 files changed, 128 insertions(+), 60 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index a589b5131e..cdcbadc103 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2993,22 +2993,22 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): try: prot = protlib.save_prototype(**prototype) if not prot: - caller.msg("|rError saving:|R {}.|n".format(key)) + caller.msg("|rError saving:|R {}.|n".format(prototype_key)) return - except PermissionError as err: + except protlib.PermissionError as err: caller.msg("|rError saving:|R {}|n".format(err)) return - caller.msg("|gSaved prototype:|n {}".format(key)) + caller.msg("|gSaved prototype:|n {}".format(prototype_key)) # check if we want to update existing objects - existing_objects = spawner.search_objects_with_prototype(key) + existing_objects = protlib.search_objects_with_prototype(prototype_key) if existing_objects: if 'update' not in self.switches: n_existing = len(existing_objects) slow = " (note that this may be slow)" if n_existing > 10 else "" string = ("There are {} objects already created with an older version " "of prototype {}. Should it be re-applied to them{}? [Y]/N".format( - n_existing, key, slow)) + n_existing, prototype_key, slow)) answer = yield(string) if answer.lower() in ["n", "no"]: caller.msg("|rNo update was done of existing objects. " @@ -3036,7 +3036,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): return try: success = protlib.delete_db_prototype(caller, self.args) - except PermissionError as err: + except protlib.PermissionError as err: caller.msg("|rError deleting:|R {}|n".format(err)) caller.msg("Deletion {}.".format( 'successful' if success else 'failed (does the prototype exist?)')) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index f047b3a458..709e7154ba 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -28,7 +28,7 @@ from evennia.utils import ansi, utils, gametime from evennia.server.sessionhandler import SESSIONS from evennia import search_object from evennia import DefaultObject, DefaultCharacter -from evennia.prototypes import spawner, prototypes as protlib +from evennia.prototypes import prototypes as protlib # set up signal here since we are not starting the server @@ -46,7 +46,7 @@ class CommandTest(EvenniaTest): Tests a command """ def call(self, cmdobj, args, msg=None, cmdset=None, noansi=True, caller=None, - receiver=None, cmdstring=None, obj=None): + receiver=None, cmdstring=None, obj=None, inputs=None): """ Test a command by assigning all the needed properties to cmdobj and running @@ -75,14 +75,31 @@ class CommandTest(EvenniaTest): cmdobj.obj = obj or (caller if caller else self.char1) # test old_msg = receiver.msg + inputs = inputs or [] + try: receiver.msg = Mock() if cmdobj.at_pre_cmd(): return cmdobj.parse() ret = cmdobj.func() + + # handle func's with yield in them (generators) if isinstance(ret, types.GeneratorType): - ret.next() + while True: + try: + inp = inputs.pop() if inputs else None + if inp: + try: + ret.send(inp) + except TypeError: + ret.next() + ret = ret.send(inp) + else: + ret.next() + except StopIteration: + break + cmdobj.at_post_cmd() except StopIteration: pass @@ -95,7 +112,7 @@ class CommandTest(EvenniaTest): # Get the first element of a tuple if msg received a tuple instead of a string stored_msg = [smsg[0] if isinstance(smsg, tuple) else smsg for smsg in stored_msg] if msg is not None: - returned_msg = "||".join(_RE.sub("", mess) for mess in stored_msg) + returned_msg = "||".join(_RE.sub("", str(mess)) for mess in stored_msg) returned_msg = ansi.parse_ansi(returned_msg, strip_ansi=noansi).strip() if msg == "" and returned_msg or not returned_msg.startswith(msg.strip()): sep1 = "\n" + "=" * 30 + "Wanted message" + "=" * 34 + "\n" @@ -369,13 +386,13 @@ class TestBuilding(CommandTest): self.call(building.CmdSpawn(), " ", "Usage: @spawn") # Tests "@spawn " without specifying location. - with mock.patch('evennia.commands.default.func', return_value=iter(['y'])) as mock_iter: - self.call(building.CmdSpawn(), - "/save {'prototype_key': 'testprot', 'key':'Test Char', " - "'typeclass':'evennia.objects.objects.DefaultCharacter'}", "") - mock_iter.assert_called() - self.call(building.CmdSpawn(), "/list", "foo") + self.call(building.CmdSpawn(), + "/save {'prototype_key': 'testprot', 'key':'Test Char', " + "'typeclass':'evennia.objects.objects.DefaultCharacter'}", + "Saved prototype: testprot", inputs=['y']) + + self.call(building.CmdSpawn(), "/list", "| Key ") self.call(building.CmdSpawn(), 'testprot', "Spawned Test Char") # Tests that the spawned object's location is the same as the caharacter's location, since @@ -401,10 +418,14 @@ class TestBuilding(CommandTest): goblin.delete() - protlib.create_prototype(**{'key': 'Ball', 'prototype': 'GOBLIN', 'prototype_key': 'testball'}) + # create prototype + protlib.create_prototype(**{'key': 'Ball', + 'typeclass': 'evennia.objects.objects.DefaultCharacter', + 'prototype_key': 'testball'}) # Tests "@spawn " self.call(building.CmdSpawn(), "testball", "Spawned Ball") + ball = getObject(self, "Ball") self.assertEqual(ball.location, self.char1.location) self.assertIsInstance(ball, DefaultObject) @@ -417,10 +438,14 @@ class TestBuilding(CommandTest): self.assertIsNone(ball.location) ball.delete() + self.call(building.CmdSpawn(), + "/noloc {'prototype_parent':'TESTBALL', 'prototype_key': 'testball', 'location':'%s'}" + % spawnLoc.dbref, "Error: Prototype testball tries to parent itself.") + # Tests "@spawn/noloc ...", but DO specify a location. # Location should be the specified location. self.call(building.CmdSpawn(), - "/noloc {'prototype':'TESTBALL', 'location':'%s'}" + "/noloc {'prototype_parent':'TESTBALL', 'key': 'Ball', 'prototype_key': 'foo', 'location':'%s'}" % spawnLoc.dbref, "Spawned Ball") ball = getObject(self, "Ball") self.assertEqual(ball.location, spawnLoc) diff --git a/evennia/contrib/tutorial_world/objects.py b/evennia/contrib/tutorial_world/objects.py index 807b4d5e09..1e088aa8d0 100644 --- a/evennia/contrib/tutorial_world/objects.py +++ b/evennia/contrib/tutorial_world/objects.py @@ -905,19 +905,19 @@ WEAPON_PROTOTYPES = { "magic": False, "desc": "A generic blade."}, "knife": { - "prototype": "weapon", + "prototype_parent": "weapon", "aliases": "sword", "key": "Kitchen knife", "desc": "A rusty kitchen knife. Better than nothing.", "damage": 3}, "dagger": { - "prototype": "knife", + "prototype_parent": "knife", "key": "Rusty dagger", "aliases": ["knife", "dagger"], "desc": "A double-edged dagger with a nicked edge and a wooden handle.", "hit": 0.25}, "sword": { - "prototype": "weapon", + "prototype_parent": "weapon", "key": "Rusty sword", "aliases": ["sword"], "desc": "A rusty shortsword. It has a leather-wrapped handle covered i food grease.", @@ -925,28 +925,28 @@ WEAPON_PROTOTYPES = { "damage": 5, "parry": 0.5}, "club": { - "prototype": "weapon", + "prototype_parent": "weapon", "key": "Club", "desc": "A heavy wooden club, little more than a heavy branch.", "hit": 0.4, "damage": 6, "parry": 0.2}, "axe": { - "prototype": "weapon", + "prototype_parent": "weapon", "key": "Axe", "desc": "A woodcutter's axe with a keen edge.", "hit": 0.4, "damage": 6, "parry": 0.2}, "ornate longsword": { - "prototype": "sword", + "prototype_parent": "sword", "key": "Ornate longsword", "desc": "A fine longsword with some swirling patterns on the handle.", "hit": 0.5, "magic": True, "damage": 5}, "warhammer": { - "prototype": "club", + "prototype_parent": "club", "key": "Silver Warhammer", "aliases": ["hammer", "warhammer", "war"], "desc": "A heavy war hammer with silver ornaments. This huge weapon causes massive damage - if you can hit.", @@ -954,21 +954,21 @@ WEAPON_PROTOTYPES = { "magic": True, "damage": 8}, "rune axe": { - "prototype": "axe", + "prototype_parent": "axe", "key": "Runeaxe", "aliases": ["axe"], "hit": 0.4, "magic": True, "damage": 6}, "thruning": { - "prototype": "ornate longsword", + "prototype_parent": "ornate longsword", "key": "Broadsword named Thruning", "desc": "This heavy bladed weapon is marked with the name 'Thruning'. It is very powerful in skilled hands.", "hit": 0.6, "parry": 0.6, "damage": 7}, "slayer waraxe": { - "prototype": "rune axe", + "prototype_parent": "rune axe", "key": "Slayer waraxe", "aliases": ["waraxe", "war", "slayer"], "desc": "A huge double-bladed axe marked with the runes for 'Slayer'." @@ -976,7 +976,7 @@ WEAPON_PROTOTYPES = { "hit": 0.7, "damage": 8}, "ghostblade": { - "prototype": "ornate longsword", + "prototype_parent": "ornate longsword", "key": "The Ghostblade", "aliases": ["blade", "ghost"], "desc": "This massive sword is large as you are tall, yet seems to weigh almost nothing." @@ -985,7 +985,7 @@ WEAPON_PROTOTYPES = { "parry": 0.8, "damage": 10}, "hawkblade": { - "prototype": "ghostblade", + "prototype_parent": "ghostblade", "key": "The Hawkblade", "aliases": ["hawk", "blade"], "desc": "The weapon of a long-dead heroine and a more civilized age," diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 57087b133f..df9674b4e7 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -13,7 +13,7 @@ from evennia.objects.models import ObjectDB from evennia.utils.create import create_script from evennia.utils.utils import ( all_from_module, make_iter, is_iter, dbid_to_obj, callables_from_module, - get_all_typeclasses, to_str) + get_all_typeclasses, to_str, dbref) from evennia.locks.lockhandler import validate_lockstring, check_lockstring from evennia.utils import logger from evennia.utils import inlinefuncs @@ -91,6 +91,10 @@ def protfunc_parser(value, available_functions=None, testing=False, stacktrace=F """ if not isinstance(value, basestring): + try: + value = value.dbref + except AttributeError: + pass value = to_str(value, force_string=True) available_functions = _PROT_FUNCS if available_functions is None else available_functions @@ -577,7 +581,7 @@ def validate_prototype(prototype, protkey=None, protparents=None, for protstring in make_iter(prototype_parent): protstring = protstring.lower() if protkey is not None and protstring == protkey: - _flags['errors'].append("Protototype {} tries to parent itself.".format(protkey)) + _flags['errors'].append("Prototype {} tries to parent itself.".format(protkey)) protparent = protparents.get(protstring) if not protparent: _flags['errors'].append("Prototype {}'s prototype_parent '{}' was not found.".format( @@ -610,7 +614,7 @@ def validate_prototype(prototype, protkey=None, protparents=None, # make sure prototype_locks are set to defaults prototype_locks = [lstring.split(":", 1) - for lstring in prototype.get("prototype_locks", "").split(';')] + for lstring in prototype.get("prototype_locks", "").split(';') if ":" in lstring] locktypes = [tup[0].strip() for tup in prototype_locks] if "spawn" not in locktypes: prototype_locks.append(("spawn", "all()")) diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 8932b368c1..221200672d 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -114,24 +114,41 @@ class TestUtils(EvenniaTest): self.assertEqual( pdiff, - {'aliases': 'REMOVE', - 'attrs': 'REPLACE', - 'home': 'KEEP', - 'key': 'UPDATE', - 'location': 'KEEP', - 'locks': 'KEEP', - 'new': 'UPDATE', - 'permissions': 'UPDATE', - 'prototype_desc': 'UPDATE', - 'prototype_key': 'UPDATE', - 'prototype_locks': 'KEEP', - 'prototype_tags': 'KEEP', - 'test': 'UPDATE', - 'typeclass': 'KEEP'}) + ({'aliases': 'REMOVE', + 'attrs': 'REPLACE', + 'home': 'KEEP', + 'key': 'UPDATE', + 'location': 'KEEP', + 'locks': 'KEEP', + 'new': 'UPDATE', + 'permissions': 'UPDATE', + 'prototype_desc': 'UPDATE', + 'prototype_key': 'UPDATE', + 'prototype_locks': 'KEEP', + 'prototype_tags': 'KEEP', + 'test': 'UPDATE', + 'typeclass': 'KEEP'}, + {'attrs': [('oldtest', 'to_remove', None, ['']), + ('test', 'testval', None, [''])], + 'prototype_locks': 'spawn:all();edit:all()', + 'prototype_key': Something, + 'locks': ['call:true()', 'control:perm(Developer)', + 'delete:perm(Admin)', 'edit:perm(Admin)', + 'examine:perm(Builder)', 'get:all()', + 'puppet:pperm(Developer)', 'tell:perm(Admin)', + 'view:all()'], + 'prototype_tags': [], + 'location': self.room1, + 'key': 'NewObj', + 'home': self.room1, + 'typeclass': 'evennia.objects.objects.DefaultObject', + 'prototype_desc': 'Built from NewObj', + 'aliases': 'foo'}) + ) # apply diff count = spawner.batch_update_objects_with_prototype( - old_prot, diff=pdiff, objects=[self.obj1]) + old_prot, diff=pdiff[0], objects=[self.obj1]) self.assertEqual(count, 1) new_prot = spawner.prototype_from_object(self.obj1) @@ -470,7 +487,7 @@ class TestOLCMenu(TestEvMenu): menutree = "evennia.prototypes.menus" startnode = "node_index" - debug_output = True + # debug_output = True expect_all_nodes = True expected_node_texts = { @@ -480,15 +497,37 @@ class TestOLCMenu(TestEvMenu): expected_tree = \ ['node_index', ['node_prototype_key', + ['node_index', + 'node_index', + 'node_validate_prototype', + ['node_index'], + 'node_index'], 'node_typeclass', - 'node_aliases', - 'node_attrs', - 'node_tags', - 'node_locks', - 'node_permissions', - 'node_location', - 'node_home', - 'node_destination', - 'node_prototype_desc', - 'node_prototype_tags', - 'node_prototype_locks']] + ['node_key', + ['node_typeclass', + 'node_key', + 'node_index', + 'node_validate_prototype', + 'node_validate_prototype'], + 'node_index', + 'node_index', + 'node_index', + 'node_validate_prototype', + 'node_validate_prototype'], + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype']] From 9c68b84ad3f94fe0540c04584e2d070f7428e306 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 21 Jul 2018 13:51:13 +0200 Subject: [PATCH 355/466] Remove old olc/ folder --- evennia/utils/olc/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 evennia/utils/olc/__init__.py diff --git a/evennia/utils/olc/__init__.py b/evennia/utils/olc/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 From e4016e435ec07ad3ab78bcda5c8dbceed5c8ae8c Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 21 Jul 2018 19:06:15 +0200 Subject: [PATCH 356/466] Start improve OLC menu docs and help texts --- CHANGELOG.md | 9 ++ evennia/prototypes/menus.py | 180 +++++++++++++++++++++++++++---- evennia/prototypes/prototypes.py | 22 +++- evennia/utils/evmenu.py | 15 ++- evennia/utils/inlinefuncs.py | 6 +- 5 files changed, 203 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c1c4cf787..5ae990b85b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,15 @@ - The spawn command got the /save switch to save the defined prototype and its key. - The command spawn/menu will now start an OLC (OnLine Creation) menu to load/save/edit/spawn prototypes. +### EvMenu + +- Added `EvMenu.helptext_formatter(helptext)` to allow custom formatting of per-node help. +- Added `evennia.utils.evmenu.list_node` decorator for turning an EvMenu node into a multi-page listing. +- A `goto` option callable returning None (rather than the name of the next node) will now rerun the + current node instead of failing. +- Better error handling of in-node syntax errors. + + # Overviews diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 1f2eb26a4f..1e775fbd77 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -8,6 +8,7 @@ import json from random import choice from django.conf import settings from evennia.utils.evmenu import EvMenu, list_node +from evennia.utils import evmore from evennia.utils.ansi import strip_ansi from evennia.utils import utils from evennia.prototypes import prototypes as protlib @@ -78,7 +79,9 @@ def _format_option_value(prop, required=False, prototype=None, cropper=None): out = ", ".join(str(pr) for pr in prop) if not out and required: out = "|rrequired" - return " ({}|n)".format(cropper(out) if cropper else utils.crop(out, _MENU_CROP_WIDTH)) + if out: + return " ({}|n)".format(cropper(out) if cropper else utils.crop(out, _MENU_CROP_WIDTH)) + return "" def _set_prototype_value(caller, field, value, parse=True): @@ -214,31 +217,75 @@ def _validate_prototype(prototype): return err, text -# Menu nodes +def _format_protfuncs(): + out = [] + sorted_funcs = [(key, func) for key, func in + sorted(protlib.PROT_FUNCS.items(), key=lambda tup: tup[0])] + for protfunc_name, protfunc in sorted_funcs: + out.append("- |c${name}|n - |W{docs}".format( + name=protfunc_name, + docs=utils.justify(protfunc.__doc__.strip(), align='l', indent=10).strip())) + return "\n ".join(out) + + +# Menu nodes ------------------------------ + + +# main index (start page) node + def node_index(caller): prototype = _get_menu_prototype(caller) - text = ( - "|c --- Prototype wizard --- |n\n\n" - "Define the |yproperties|n of the prototype. All prototype values can be " - "over-ridden at the time of spawning an instance of the prototype, but some are " - "required.\n\n'|wprototype-'-properties|n are not used in the prototype itself but are used " - "to organize and list prototypes. The 'prototype-key' uniquely identifies the prototype " - "and allows you to edit an existing prototype or save a new one for use by you or " - "others later.\n\n(make choice; q to abort. If unsure, start from 1.)") + text = """ + |c --- Prototype wizard --- |n + + A |cprototype|n is a 'template' for |wspawning|n an in-game entity. A field of the prototype + can be hard-coded or scripted using |w$protfuncs|n - for example to randomize the value + every time the prototype is used to spawn a new entity. + + The prototype fields named 'prototype_*' are not used to create the entity itself but for + organizing the template when saving it for you (and maybe others) to use later. + + Select prototype field to edit. If you are unsure, start from [|w1|n]. At any time you can + [|wV|n]alidate that the prototype works correctly and use it to [|wSP|n]awn a new entity. You + can also [|wSA|n]ve|n your work or [|wLO|n]oad an existing prototype to use as a base. Use + [|wL|n]ook to re-show a menu node. [|wQ|n]uit will always exit the menu and [|wH|n]elp will + show context-sensitive help. + """ + + helptxt = """ + |c- prototypes |n + + A prototype is really just a Python dictionary. When spawning, this dictionary is essentially + passed into `|wevennia.utils.create.create_object(**prototype)|n` to create a new object. By + using different prototypes you can customize instances of objects without having to do code + changes to their typeclass (something which requires code access). The classical example is + to spawn goblins with different names, looks, equipment and skill, each based on the same + `Goblin` typeclass. + + |c- $protfuncs |n + + Prototype-functions (protfuncs) allow for limited scripting within a prototype. These are + entered as a string $funcname(arg, arg, ...) and are evaluated |wat the time of spawning|n only. + They can also be nested for combined effects. + + {pfuncs} + """.format(pfuncs=_format_protfuncs()) + + text = (text, helptxt) options = [] options.append( {"desc": "|WPrototype-Key|n|n{}".format( _format_option_value("Key", "prototype_key" not in prototype, prototype, None)), "goto": "node_prototype_key"}) - for key in ('Typeclass', 'Prototype_parent', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', + for key in ('Typeclass', 'Prototype-parent', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', 'Permissions', 'Location', 'Home', 'Destination'): required = False cropper = None if key in ("Prototype-parent", "Typeclass"): - required = "prototype_parent" not in prototype and "typeclass" not in prototype + required = ("prototype_parent" not in prototype) and ("typeclass" not in prototype) if key == 'Typeclass': cropper = _path_cropper options.append( @@ -256,16 +303,18 @@ def node_index(caller): options.extend(( {"key": ("|wV|Walidate prototype", "validate", "v"), "goto": "node_validate_prototype"}, - {"key": ("|wS|Wave prototype", "save", "s"), + {"key": ("|wSA|Wve prototype", "save", "sa"), "goto": "node_prototype_save"}, {"key": ("|wSP|Wawn prototype", "spawn", "sp"), "goto": "node_prototype_spawn"}, - {"key": ("|wL|Woad prototype", "load", "l"), + {"key": ("|wLO|Wad prototype", "load", "lo"), "goto": "node_prototype_load"})) return text, options +# validate prototype (available as option from all nodes) + def node_validate_prototype(caller, raw_string, **kwargs): """General node to view and validate a protototype""" prototype = _get_menu_prototype(caller) @@ -273,11 +322,22 @@ def node_validate_prototype(caller, raw_string, **kwargs): _, text = _validate_prototype(prototype) + helptext = """ + The validator checks if the prototype's various values are on the expected form. It also test + any $protfuncs. + + """ + + text = (text, helptext) + options = _wizard_options(None, prev_node, None) return text, options +# prototype_key node + + def _check_prototype_key(caller, key): old_prototype = protlib.search_prototype(key) olc_new = _is_new_prototype(caller) @@ -303,22 +363,36 @@ def _check_prototype_key(caller, key): def node_prototype_key(caller): prototype = _get_menu_prototype(caller) - text = ["The prototype name, or |wMeta-Key|n, uniquely identifies the prototype. " - "It is used to find and use the prototype to spawn new entities. " - "It is not case sensitive."] + text = """ + The |cPrototype-Key|n uniquely identifies the prototype. It must be specified. It is used to + find and use the prototype to spawn new entities. It is not case sensitive. + + {current}""" + + helptext = """ + The prototype-key is not itself used to spawn the new object, but is only used for managing, + storing and loading the prototype. It must be globally unique, so existing keys will be + checked before a new key is accepted. If an existing key is picked, the existing prototype + will be loaded. + """ + old_key = prototype.get('prototype_key', None) if old_key: - text.append("Current key is '|w{key}|n'".format(key=old_key)) + text = text.format(current="Currently set to '|w{key}|n'".format(key=old_key)) else: - text.append("The key is currently unset.") - text.append("Enter text or make a choice (q for quit)") - text = "\n\n".join(text) + text = text.format(current="Currently |runset|n (required).") + options = _wizard_options("prototype_key", "index", "prototype_parent") options.append({"key": "_default", "goto": _check_prototype_key}) + + text = (text, helptext) return text, options +# prototype_parents node + + def _all_prototype_parents(caller): """Return prototype_key of all available prototypes for listing in menu""" return [prototype["prototype_key"] @@ -368,6 +442,8 @@ def node_prototype_parent(caller): return text, options +# typeclasses node + def _all_typeclasses(caller): """Get name of available typeclasses.""" return list(name for name in @@ -423,6 +499,9 @@ def node_typeclass(caller): return text, options +# key node + + def node_key(caller): prototype = _get_menu_prototype(caller) key = prototype.get("key") @@ -442,6 +521,9 @@ def node_key(caller): return text, options +# aliases node + + def node_aliases(caller): prototype = _get_menu_prototype(caller) aliases = prototype.get("aliases") @@ -462,6 +544,9 @@ def node_aliases(caller): return text, options +# attributes node + + def _caller_attrs(caller): prototype = _get_menu_prototype(caller) attrs = prototype.get("attrs", []) @@ -572,6 +657,9 @@ def node_attrs(caller): return text, options +# tags node + + def _caller_tags(caller): prototype = _get_menu_prototype(caller) tags = prototype.get("tags", []) @@ -659,6 +747,9 @@ def node_tags(caller): return text, options +# locks node + + def node_locks(caller): prototype = _get_menu_prototype(caller) locks = prototype.get("locks") @@ -679,6 +770,9 @@ def node_locks(caller): return text, options +# permissions node + + def node_permissions(caller): prototype = _get_menu_prototype(caller) permissions = prototype.get("permissions") @@ -699,6 +793,9 @@ def node_permissions(caller): return text, options +# location node + + def node_location(caller): prototype = _get_menu_prototype(caller) location = prototype.get("location") @@ -718,6 +815,9 @@ def node_location(caller): return text, options +# home node + + def node_home(caller): prototype = _get_menu_prototype(caller) home = prototype.get("home") @@ -737,6 +837,9 @@ def node_home(caller): return text, options +# destination node + + def node_destination(caller): prototype = _get_menu_prototype(caller) dest = prototype.get("dest") @@ -756,6 +859,9 @@ def node_destination(caller): return text, options +# prototype_desc node + + def node_prototype_desc(caller): prototype = _get_menu_prototype(caller) @@ -778,6 +884,9 @@ def node_prototype_desc(caller): return text, options +# prototype_tags node + + def node_prototype_tags(caller): prototype = _get_menu_prototype(caller) text = ["|wPrototype-Tags|n can be used to classify and find prototypes. " @@ -800,6 +909,9 @@ def node_prototype_tags(caller): return text, options +# prototype_locks node + + def node_prototype_locks(caller): prototype = _get_menu_prototype(caller) text = ["Set |wPrototype-Locks|n on the prototype. There are two valid lock types: " @@ -821,6 +933,9 @@ def node_prototype_locks(caller): return text, options +# update existing objects node + + def _update_spawned(caller, **kwargs): """update existing objects""" prototype = kwargs['prototype'] @@ -904,6 +1019,9 @@ def node_update_objects(caller, **kwargs): return text, options +# prototype save node + + def node_prototype_save(caller, **kwargs): """Save prototype to disk """ # these are only set if we selected 'yes' to save on a previous pass @@ -972,6 +1090,9 @@ def node_prototype_save(caller, **kwargs): return "\n".join(text), options +# spawning node + + def _spawn(caller, **kwargs): """Spawn prototype""" prototype = kwargs["prototype"].copy() @@ -1037,6 +1158,9 @@ def node_prototype_spawn(caller, **kwargs): return text, options +# prototype load node + + def _prototype_load_select(caller, prototype_key): matches = protlib.search_prototype(key=prototype_key) if matches: @@ -1052,12 +1176,15 @@ def _prototype_load_select(caller, prototype_key): @list_node(_all_prototype_parents, _prototype_load_select) def node_prototype_load(caller, **kwargs): text = ["Select a prototype to load. This will replace any currently edited prototype."] - options = _wizard_options("load", "save", "index") + options = _wizard_options("prototype_load", "prototype_save", "index") options.append({"key": "_default", "goto": _prototype_parent_examine}) return "\n".join(text), options +# EvMenu definition, formatting and access functions + + class OLCMenu(EvMenu): """ A custom EvMenu with a different formatting for the options. @@ -1086,6 +1213,15 @@ class OLCMenu(EvMenu): return "{}{}{}".format(olc_options, sep, other_options) + def helptext_formatter(self, helptext): + """ + Show help text + """ + return "|c --- Help ---|n\n" + helptext + + def display_helptext(self): + evmore.msg(self.caller, self.helptext, session=self._session) + def start_olc(caller, session=None, prototype=None): """ diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index df9674b4e7..011445b039 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -13,7 +13,7 @@ from evennia.objects.models import ObjectDB from evennia.utils.create import create_script from evennia.utils.utils import ( all_from_module, make_iter, is_iter, dbid_to_obj, callables_from_module, - get_all_typeclasses, to_str, dbref) + get_all_typeclasses, to_str, dbref, justify) from evennia.locks.lockhandler import validate_lockstring, check_lockstring from evennia.utils import logger from evennia.utils import inlinefuncs @@ -29,7 +29,7 @@ _PROTOTYPE_RESERVED_KEYS = _PROTOTYPE_META_NAMES + ( "permissions", "locks", "exec", "tags", "attrs") _PROTOTYPE_TAG_CATEGORY = "from_prototype" _PROTOTYPE_TAG_META_CATEGORY = "db_prototype" -_PROT_FUNCS = {} +PROT_FUNCS = {} _RE_DBREF = re.compile(r"(?".format(string) @@ -367,6 +368,7 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, stacktrace=False try: # try to fetch the matching inlinefunc from storage stack.append(available_funcs[funcname]) + nvalid += 1 except KeyError: stack.append(available_funcs["nomatch"]) stack.append(funcname) @@ -393,9 +395,9 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, stacktrace=False # this means not all inlinefuncs were complete return string - if _STACK_MAXSIZE > 0 and _STACK_MAXSIZE < len(stack): + if _STACK_MAXSIZE > 0 and _STACK_MAXSIZE < nvalid: # if stack is larger than limit, throw away parsing - return string + gdict["stackfull"](*args, **kwargs) + return string + available_funcs["stackfull"](*args, **kwargs) elif usecache: # cache the stack - we do this also if we don't check the cache above _PARSING_CACHE[string] = stack From 20d1ab0f3dc2bcf698aa9ea9f4db321a5037fddd Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 21 Jul 2018 22:43:36 +0200 Subject: [PATCH 357/466] Fix display error when telnet disabled --- evennia/server/portal/portal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index a720c73c71..6b15cde73a 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -343,7 +343,7 @@ if WEBSERVER_ENABLED: proxy_service = internet.TCPServer(proxyport, web_root, interface=interface) - proxy_service.setName('EvenniaWebProxy%s' % pstring) + proxy_service.setName('EvenniaWebProxy%s:%s' % (ifacestr, proxyport)) PORTAL.services.addService(proxy_service) INFO_DICT["webserver_proxy"].append("webserver-proxy%s: %s" % (ifacestr, proxyport)) INFO_DICT["webserver_internal"].append("webserver: %s" % serverport) From c8dae28cdfba7fbaca4ba78ffe8a2fdbd0360391 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 23 Jul 2018 21:09:06 +0200 Subject: [PATCH 358/466] Rework AMP data packet format and batch-handling. Resolves #1635 --- evennia/server/portal/amp.py | 64 +++++++++++++++++++---------- evennia/server/portal/amp_server.py | 11 +++-- 2 files changed, 49 insertions(+), 26 deletions(-) diff --git a/evennia/server/portal/amp.py b/evennia/server/portal/amp.py index 4ff4732708..71a1bcba91 100644 --- a/evennia/server/portal/amp.py +++ b/evennia/server/portal/amp.py @@ -44,9 +44,10 @@ SSHUTD = chr(17) # server shutdown PSTATUS = chr(18) # ping server or portal status SRESET = chr(19) # server shutdown in reset mode +NUL = b'\0' +NULNUL = '\0\0' + AMP_MAXLEN = amp.MAX_VALUE_LENGTH # max allowed data length in AMP protocol (cannot be changed) -BATCH_RATE = 250 # max commands/sec before switching to batch-sending -BATCH_TIMEOUT = 0.5 # how often to poll to empty batch queue, in seconds # buffers _SENDBATCH = defaultdict(list) @@ -61,11 +62,15 @@ _HTTP_WARNING = """ HTTP/1.1 200 OK Content-Type: text/html - -This is Evennia's interal AMP port. It handles communication -between Evennia's different processes.

This port should NOT be -publicly visible.

-""".strip() + + + This is Evennia's internal AMP port. It handles communication + between Evennia's different processes. +

+

This port should NOT be publicly visible.

+

+ +""".strip() # Helper functions for pickling. @@ -107,43 +112,45 @@ class Compressed(amp.String): def fromBox(self, name, strings, objects, proto): """ - Converts from box representation to python. We - group very long data into batches. + Converts from box string representation to python. We read back too-long batched data and + put it back together here. + """ value = StringIO() - value.write(strings.get(name)) + value.write(self.fromStringProto(strings.get(name), proto)) for counter in count(2): # count from 2 upwards chunk = strings.get("%s.%d" % (name, counter)) if chunk is None: break - value.write(chunk) + value.write(self.fromStringProto(chunk, proto)) objects[name] = value.getvalue() def toBox(self, name, strings, objects, proto): """ - Convert from data to box. We handled too-long - batched data and put it together here. + Convert from python object to string box representation. + we break up too-long data snippets into multiple batches here. + """ value = StringIO(objects[name]) - strings[name] = value.read(AMP_MAXLEN) + strings[name] = self.toStringProto(value.read(AMP_MAXLEN), proto) for counter in count(2): chunk = value.read(AMP_MAXLEN) if not chunk: break - strings["%s.%d" % (name, counter)] = chunk + strings["%s.%d" % (name, counter)] = self.toStringProto(chunk, proto) def toString(self, inObject): """ - Convert to send on the wire, with compression. + Convert to send as a string on the wire, with compression. """ - return zlib.compress(inObject, 9) + return zlib.compress(super(Compressed, self).toString(inObject), 9) def fromString(self, inString): """ - Convert (decompress) from the wire to Python. + Convert (decompress) from the string-representation on the wire to Python. """ - return zlib.decompress(inString) + return super(Compressed, self).fromString(zlib.decompress(inString)) class MsgLauncher2Portal(amp.Command): @@ -261,16 +268,29 @@ class AMPMultiConnectionProtocol(amp.AMP): self.send_reset_time = time.time() self.send_mode = True self.send_task = None + self.multibatches = 0 def dataReceived(self, data): """ Handle non-AMP messages, such as HTTP communication. """ - if data[0] != b'\0': + if data[0] == NUL: + # an AMP communication + if data[-2:] != NULNUL: + # an incomplete AMP box means more batches are forthcoming. + self.multibatches += 1 + super(AMPMultiConnectionProtocol, self).dataReceived(data) + elif self.multibatches: + # invalid AMP, but we have a pending multi-batch that is not yet complete + if data[-2:] == NULNUL: + # end of existing multibatch + self.multibatches = max(0, self.multibatches - 1) + super(AMPMultiConnectionProtocol, self).dataReceived(data) + else: + # not an AMP communication, return warning self.transport.write(_HTTP_WARNING) self.transport.loseConnection() - else: - super(AMPMultiConnectionProtocol, self).dataReceived(data) + print("HTML received: %s" % data) def makeConnection(self, transport): """ diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index c550a648c3..38e39fb464 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -356,10 +356,13 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): packed_data (str): Pickled data (sessid, kwargs) coming over the wire. """ - sessid, kwargs = self.data_in(packed_data) - session = self.factory.portal.sessions.get(sessid, None) - if session: - self.factory.portal.sessions.data_out(session, **kwargs) + try: + sessid, kwargs = self.data_in(packed_data) + session = self.factory.portal.sessions.get(sessid, None) + if session: + self.factory.portal.sessions.data_out(session, **kwargs) + except Exception: + logger.log_trace("packed_data len {}".format(len(packed_data))) return {} @amp.AdminServer2Portal.responder From 38ebfb47b197ecc8cd855eed248e5ec5bac7d94d Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 23 Jul 2018 23:12:11 +0200 Subject: [PATCH 359/466] Add more in-menu docs --- evennia/prototypes/menus.py | 133 +++++++++++++++++++++++++----------- 1 file changed, 95 insertions(+), 38 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 1e775fbd77..e70d5d87ae 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -117,8 +117,6 @@ def _set_property(caller, raw_string, **kwargs): processor = kwargs.get("processor", None) next_node = kwargs.get("next_node", "node_index") - propname_low = prop.strip().lower() - if callable(processor): try: value = processor(raw_string) @@ -134,13 +132,6 @@ def _set_property(caller, raw_string, **kwargs): return next_node prototype = _set_prototype_value(caller, prop, value) - - # typeclass and prototype_parent can't co-exist - if propname_low == "typeclass": - prototype.pop("prototype_parent", None) - if propname_low == "prototype_parent": - prototype.pop("typeclass", None) - caller.ndb._menutree.olc_prototype = prototype try: @@ -253,7 +244,6 @@ def node_index(caller): [|wL|n]ook to re-show a menu node. [|wQ|n]uit will always exit the menu and [|wH|n]elp will show context-sensitive help. """ - helptxt = """ |c- prototypes |n @@ -323,7 +313,7 @@ def node_validate_prototype(caller, raw_string, **kwargs): _, text = _validate_prototype(prototype) helptext = """ - The validator checks if the prototype's various values are on the expected form. It also test + The validator checks if the prototype's various values are on the expected form. It also tests any $protfuncs. """ @@ -364,16 +354,15 @@ def _check_prototype_key(caller, key): def node_prototype_key(caller): prototype = _get_menu_prototype(caller) text = """ - The |cPrototype-Key|n uniquely identifies the prototype. It must be specified. It is used to + The |cPrototype-Key|n uniquely identifies the prototype and is |wmandatory|n. It is used to find and use the prototype to spawn new entities. It is not case sensitive. {current}""" - helptext = """ - The prototype-key is not itself used to spawn the new object, but is only used for managing, - storing and loading the prototype. It must be globally unique, so existing keys will be - checked before a new key is accepted. If an existing key is picked, the existing prototype - will be loaded. + The prototype-key is not itself used when spawnng the new object, but is only used for + managing, storing and loading the prototype. It must be globally unique, so existing keys + will be checked before a new key is accepted. If an existing key is picked, the existing + prototype will be loaded. """ old_key = prototype.get('prototype_key', None) @@ -423,18 +412,36 @@ def node_prototype_parent(caller): prot_parent_key = prototype.get('prototype') - text = ["Set the prototype's |yParent Prototype|n. If this is unset, Typeclass will be used."] + text = """ + The |cPrototype Parent|n allows you to |winherit|n prototype values from another named + prototype (given as that prototype's |wprototype_key|). If not changing these values in the + current prototype, the parent's value will be used. Pick the available prototypes below. + + Note that somewhere in the prototype's parentage, a |ctypeclass|n must be specified. If no + parent is given, this prototype must define the typeclass (next menu node). + + {current} + """ + helptext = """ + Prototypes can inherit from one another. Changes in the child replace any values set in a + parent. The |wtypeclass|n key must exist |wsomewhere|n in the parent chain for the + prototype to be valid. + """ + if prot_parent_key: prot_parent = protlib.search_prototype(prot_parent_key) if prot_parent: - text.append( - "Current parent prototype is {}:\n{}".format(protlib.prototype_to_str(prot_parent))) + text.format( + current="Current parent prototype is {}:\n{}".format( + protlib.prototype_to_str(prot_parent))) else: - text.append("Current parent prototype |r{prototype}|n " + text.format( + current="Current parent prototype |r{prototype}|n " "does not appear to exist.".format(prot_parent_key)) else: - text.append("Parent prototype is not set") - text = "\n\n".join(text) + text.format(current="Parent prototype is not set") + text = (text, helptext) + options = _wizard_options("prototype_parent", "prototype_key", "typeclass", color="|W") options.append({"key": "_default", "goto": _prototype_parent_examine}) @@ -477,7 +484,7 @@ def _typeclass_examine(caller, typeclass_path): def _typeclass_select(caller, typeclass): """Select typeclass from list and add it to prototype. Return next node to go to.""" ret = _set_property(caller, typeclass, prop='typeclass', processor=str, next_node="node_key") - caller.msg("Selected typeclass |y{}|n. Removed any set prototype parent.".format(typeclass)) + caller.msg("Selected typeclass |y{}|n.".format(typeclass)) return ret @@ -486,13 +493,32 @@ def node_typeclass(caller): prototype = _get_menu_prototype(caller) typeclass = prototype.get("typeclass") - text = ["Set the typeclass's parent |yTypeclass|n."] + text = """ + The |cTypeclass|n defines what 'type' of object this is - the actual working code to use. + + All spawned objects must have a typeclass. If not given here, the typeclass must be set in + one of the prototype's |cparents|n. + + {current} + """ + helptext = """ + A |nTypeclass|n is specified by the actual python-path to the class definition in the + Evennia code structure. + + Which |cAttributes|n, |cLocks|n and other properties have special + effects or expects certain values depend greatly on the code in play. + """ + if typeclass: - text.append("Current typeclass is |y{typeclass}|n.".format(typeclass=typeclass)) + text.format( + current="Current typeclass is |y{typeclass}|n.".format(typeclass=typeclass)) else: - text.append("Using default typeclass {typeclass}.".format( - typeclass=settings.BASE_OBJECT_TYPECLASS)) - text = "\n\n".join(text) + text.format( + current="Using default typeclass {typeclass}.".format( + typeclass=settings.BASE_OBJECT_TYPECLASS)) + + text = (text, helptext) + options = _wizard_options("typeclass", "prototype_parent", "key", color="|W") options.append({"key": "_default", "goto": _typeclass_examine}) @@ -506,12 +532,27 @@ def node_key(caller): prototype = _get_menu_prototype(caller) key = prototype.get("key") - text = ["Set the prototype's name (|yKey|n.) This will retain case sensitivity."] + text = """ + The |cKey|n is the given name of the object to spawn. This will retain the given case. + + {current} + """ + helptext = """ + The key should often not be identical for every spawned object. Using a randomising + $protfunc can be used, for example |c$choice(Alan, Tom, John)|n will give one of the three + names every time an object of this prototype is spawned. + + |c$protfuncs|n + {pfuncs} + """.format(pfuncs=_format_protfuncs()) + if key: - text.append("Current key value is '|y{key}|n'.".format(key=key)) + text.format(current="Current key is '{key}'.".format(key=key)) else: - text.append("Key is currently unset.") - text = "\n\n".join(text) + text.format(current="The key is currently unset.") + + text = (text, helptext) + options = _wizard_options("key", "typeclass", "aliases") options.append({"key": "_default", "goto": (_set_property, @@ -528,13 +569,29 @@ def node_aliases(caller): prototype = _get_menu_prototype(caller) aliases = prototype.get("aliases") - text = ["Set the prototype's |yAliases|n. Separate multiple aliases with commas. " - "they'll retain case sensitivity."] + text = """ + |cAliases|n are alternative ways to address an object, next to its |cKey|n. Aliases are not + case sensitive. + + Add multiple aliases separating with commas. + + {current} + """ + helptext = """ + Aliases are fixed alternative identifiers and are stored with the new object. + + |c$protfuncs|n + + {pfuncs} + """.format(pfuncs=_format_protfuncs()) + if aliases: - text.append("Current aliases are '|y{aliases}|n'.".format(aliases=aliases)) + text.format(current="Current aliases are '|c{aliases}|n'.".format(aliases=aliases)) else: - text.append("No aliases are set.") - text = "\n\n".join(text) + text.format(current="No aliases are set.") + + text = (text, helptext) + options = _wizard_options("aliases", "key", "attrs") options.append({"key": "_default", "goto": (_set_property, From 4f72f0ccbbf1629b07bf9769c392abf1300bde31 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 24 Jul 2018 20:48:54 +0200 Subject: [PATCH 360/466] List lockfuncs in menu, more elaborate doc strings --- evennia/locks/lockhandler.py | 13 ++ evennia/prototypes/menus.py | 327 +++++++++++++++++++++++++++++------ 2 files changed, 284 insertions(+), 56 deletions(-) diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py index 9e27ca2fad..19bfbec707 100644 --- a/evennia/locks/lockhandler.py +++ b/evennia/locks/lockhandler.py @@ -663,6 +663,19 @@ def validate_lockstring(lockstring): return _LOCK_HANDLER.validate(lockstring) +def get_all_lockfuncs(): + """ + Get a dict of available lock funcs. + + Returns: + lockfuncs (dict): Mapping {lockfuncname:func}. + + """ + if not _LOCKFUNCS: + _cache_lockfuncs() + return _LOCKFUNCS + + def _test(): # testing diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index e70d5d87ae..182a8b63f4 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -11,6 +11,7 @@ from evennia.utils.evmenu import EvMenu, list_node from evennia.utils import evmore from evennia.utils.ansi import strip_ansi from evennia.utils import utils +from evennia.locks.lockhandler import get_all_lockfuncs from evennia.prototypes import prototypes as protlib from evennia.prototypes import spawner @@ -219,6 +220,16 @@ def _format_protfuncs(): return "\n ".join(out) +def _format_lockfuncs(): + out = [] + sorted_funcs = [(key, func) for key, func in + sorted(get_all_lockfuncs(), key=lambda tup: tup[0])] + for lockfunc_name, lockfunc in sorted_funcs: + out.append("- |c${name}|n - |W{docs}".format( + name=lockfunc_name, + docs=utils.justify(lockfunc.__doc__.strip(), align='l', indent=10).strip())) + + # Menu nodes ------------------------------ @@ -694,17 +705,37 @@ def node_attrs(caller): prot = _get_menu_prototype(caller) attrs = prot.get("attrs") - text = ["Set the prototype's |yAttributes|n. Enter attributes on one of these forms:\n" - " attrname=value\n attrname;category=value\n attrname;category;lockstring=value\n" - "To give an attribute without a category but with a lockstring, leave that spot empty " - "(attrname;;lockstring=value)." - "Separate multiple attrs with commas. Use quotes to escape inputs with commas and " - "semi-colon."] + text = """ + |cAttributes|n are custom properties of the object. Enter attributes on one of these forms: + + attrname=value + attrname;category=value + attrname;category;lockstring=value + + To give an attribute without a category but with a lockstring, leave that spot empty + (attrname;;lockstring=value). Attribute values can have embedded $protfuncs. + + {current} + """ + helptext = """ + Most commonly, Attributes don't need any categories or locks. If using locks, the lock-types + 'attredit', 'attrread' are used to limiting editing and viewing of the Attribute. Putting + the lock-type `attrcreate` in the |clocks|n prototype key can be used to restrict builders + to add new Attributes. + + |c$protfuncs + + {pfuncs} + """.format(pfuncs=_format_protfuncs()) + if attrs: - text.append("Current attrs are '|y{attrs}|n'.".format(attrs=attrs)) + text.format(current="Current attrs {attrs}.".format( + attrs=attrs)) else: - text.append("No attrs are set.") - text = "\n\n".join(text) + text.format(current="No attrs are set.") + + text = (text, helptext) + options = _wizard_options("attrs", "aliases", "tags") options.append({"key": "_default", "goto": (_set_property, @@ -797,9 +828,24 @@ def _edit_tag(caller, old_tag, new_tag, **kwargs): @list_node(_caller_tags) def node_tags(caller): - text = ("Set the prototype's |yTags|n. Enter tags on one of the following forms:\n" - " tag\n tag;category\n tag;category;data\n" - "Note that 'data' is not commonly used.") + text = """ + |cTags|n are used to group objects so they can quickly be found later. Enter tags on one of + the following forms: + tagname + tagname;category + tagname;category;data + """ + helptext = """ + Tags are shared between all objects with that tag. So the 'data' field (which is not + commonly used) can only hold eventual info about the Tag itself, not about the individual + object on which it sits. + + All objects created with this prototype will automatically get assigned a tag named the same + as the |cprototype_key|n and with a category "{tag_category}". This allows the spawner to + optionally update previously spawned objects when their prototype changes. + """.format(protlib._PROTOTYPE_TAG_CATEGORY) + + text = (text, helptext) options = _wizard_options("tags", "attrs", "locks") return text, options @@ -811,13 +857,39 @@ def node_locks(caller): prototype = _get_menu_prototype(caller) locks = prototype.get("locks") - text = ["Set the prototype's |yLock string|n. Separate multiple locks with semi-colons. " - "Will retain case sensitivity."] + text = """ + The |cLock string|n defines limitations for accessing various properties of the object once + it's spawned. The string should be on one of the following forms: + + locktype:[NOT] lockfunc(args) + locktype: [NOT] lockfunc(args) [AND|OR|NOT] lockfunc(args) [AND|OR|NOT] ... + + Separate multiple lockstrings by semicolons (;). + + {current} + """ + helptext = """ + Here is an example of a lock string constisting of two locks: + + edit:false();call:tag(Foo) OR perm(Builder) + + Above locks limit two things, 'edit' and 'call'. Which lock types are actually checked + depend on the typeclass of the object being spawned. Here 'edit' is never allowed by anyone + while 'call' is allowed to all accessors with a |ctag|n 'Foo' OR which has the + |cPermission|n 'Builder'. + + |c$lockfuncs|n + + {lfuncs} + """.format(lfuncs=_format_lockfuncs()) + if locks: - text.append("Current locks are '|y{locks}|n'.".format(locks=locks)) + text.format(current="Current locks are '|y{locks}|n'.".format(locks=locks)) else: - text.append("No locks are set.") - text = "\n\n".join(text) + text.format(current="No locks are set.") + + text = (text, helptext) + options = _wizard_options("locks", "tags", "permissions") options.append({"key": "_default", "goto": (_set_property, @@ -834,13 +906,32 @@ def node_permissions(caller): prototype = _get_menu_prototype(caller) permissions = prototype.get("permissions") - text = ["Set the prototype's |yPermissions|n. Separate multiple permissions with commas. " - "Will retain case sensitivity."] + text = """ + |cPermissions|n are simple strings used to grant access to this object. A permission is used + when a |clock|n is checked that contains the |wperm|n or |wpperm|n lock functions. + + {current} + """ + helptext = """ + Any string can act as a permission as long as a lock is set to look for it. Depending on the + lock, having a permission could even be negative (i.e. the lock is only passed if you + |wdon't|n have the 'permission'). The most common permissions are the hierarchical + permissions: + + {permissions}. + + For example, a |clock|n string like "edit:perm(Builder)" will grant access to accessors + having the |cpermission|n "Builder" or higher. + """.format(settings.PERMISSION_HIERARCHY) + if permissions: - text.append("Current permissions are '|y{permissions}|n'.".format(permissions=permissions)) + text.format(current="Current permissions are {permissions}.".format( + permissions=permissions)) else: - text.append("No permissions are set.") - text = "\n\n".join(text) + text.format(current="No permissions are set.") + + text = (text, helptext) + options = _wizard_options("permissions", "destination", "location") options.append({"key": "_default", "goto": (_set_property, @@ -857,12 +948,28 @@ def node_location(caller): prototype = _get_menu_prototype(caller) location = prototype.get("location") - text = ["Set the prototype's |yLocation|n"] + text = """ + The |cLocation|n of this object in the world. If not given, the object will spawn + in the inventory of |c{caller}|n instead. + + {current} + """.format(caller=caller.key) + helptext = """ + You get the most control by not specifying the location - you can then teleport the spawned + objects as needed later. Setting the location may be useful for quickly populating a given + location. One could also consider randomizing the location using a $protfunc. + + |c$protfuncs|n + {pfuncs} + """.format(pfuncs=_format_protfuncs) + if location: - text.append("Current location is |y{location}|n.".format(location=location)) + text.format(current="Current location is {location}.".format(location=location)) else: - text.append("Default location is {}'s inventory.".format(caller)) - text = "\n\n".join(text) + text.format(current="Default location is {}'s inventory.".format(caller)) + + text = (text, helptext) + options = _wizard_options("location", "permissions", "home") options.append({"key": "_default", "goto": (_set_property, @@ -879,12 +986,28 @@ def node_home(caller): prototype = _get_menu_prototype(caller) home = prototype.get("home") - text = ["Set the prototype's |yHome location|n"] + text = """ + The |cHome|n location of an object is often only used as a backup - this is where the object + will be moved to if its location is deleted. The home location can also be used as an actual + home for characters to quickly move back to. If unset, the global home default will be used. + + {current} + """ + helptext = """ + The location can be specified as as #dbref but can also be explicitly searched for using + $obj(name). + + The home location is often not used except as a backup. It should never be unset. + """ + if home: - text.append("Current home location is |y{home}|n.".format(home=home)) + text.format(current="Current home location is {home}.".format(home=home)) else: - text.append("Default home location (|y{home}|n) used.".format(home=settings.DEFAULT_HOME)) - text = "\n\n".join(text) + text.format( + current="Default home location ({home}) used.".format(home=settings.DEFAULT_HOME)) + + text = (text, helptext) + options = _wizard_options("home", "aliases", "destination") options.append({"key": "_default", "goto": (_set_property, @@ -901,12 +1024,24 @@ def node_destination(caller): prototype = _get_menu_prototype(caller) dest = prototype.get("dest") - text = ["Set the prototype's |yDestination|n. This is usually only used for Exits."] + text = """ + The object's |cDestination|n is usually only set for Exit-like objects and designates where + the exit 'leads to'. It's usually unset for all other types of objects. + + {current} + """ + helptext = """ + The destination can be given as a #dbref but can also be explicitly searched for using + $obj(name). + """ + if dest: - text.append("Current destination is |y{dest}|n.".format(dest=dest)) + text.format(current="Current destination is {dest}.".format(dest=dest)) else: - text.append("No destination is set (default).") - text = "\n\n".join(text) + text.format("No destination is set (default).") + + text = (text, helptext) + options = _wizard_options("destination", "home", "prototype_desc") options.append({"key": "_default", "goto": (_set_property, @@ -922,15 +1057,25 @@ def node_destination(caller): def node_prototype_desc(caller): prototype = _get_menu_prototype(caller) - text = ["The |wPrototype-Description|n briefly describes the prototype for " - "viewing in listings."] desc = prototype.get("prototype_desc", None) + text = """ + The |cPrototype-Description|n optionally briefly describes the prototype when it's viewed in + listings. + + {current} + """ + helptext = """ + Giving a brief description helps you and others to locate the prototype for use later. + """ + if desc: - text.append("The current meta desc is:\n\"|w{desc}|n\"".format(desc=desc)) + text.format(current="The current meta desc is:\n\"|w{desc}|n\"".format(desc=desc)) else: - text.append("Description is currently unset.") - text = "\n\n".join(text) + text.format(current="Prototype-Description is currently unset.") + + text = (text, helptext) + options = _wizard_options("prototype_desc", "prototype_key", "prototype_tags") options.append({"key": "_default", "goto": (_set_property, @@ -946,16 +1091,25 @@ def node_prototype_desc(caller): def node_prototype_tags(caller): prototype = _get_menu_prototype(caller) - text = ["|wPrototype-Tags|n can be used to classify and find prototypes. " - "Tags are case-insensitive. " - "Separate multiple by tags by commas."] + text = """ + |cPrototype-Tags|n can be used to classify and find prototypes in listings Tag names are not + case-sensitive and can have not have a custom category. Separate multiple tags by commas. + """ + helptext = """ + Using prototype-tags is a good way to organize and group large numbers of prototypes by + genre, type etc. Under the hood, prototypes' tags will all be stored with the category + '{tagmetacategory}'. + """.format(tagmetacategory=protlib._PROTOTYPE_TAG_META_CATEGORY) + tags = prototype.get('prototype_tags', []) if tags: - text.append("The current tags are:\n|w{tags}|n".format(tags=tags)) + text.format(current="The current tags are:\n|w{tags}|n".format(tags=tags)) else: - text.append("No tags are currently set.") - text = "\n\n".join(text) + text.format(current="No tags are currently set.") + + text = (text, helptext) + options = _wizard_options("prototype_tags", "prototype_desc", "prototype_locks") options.append({"key": "_default", "goto": (_set_property, @@ -971,16 +1125,35 @@ def node_prototype_tags(caller): def node_prototype_locks(caller): prototype = _get_menu_prototype(caller) - text = ["Set |wPrototype-Locks|n on the prototype. There are two valid lock types: " - "'edit' (who can edit the prototype) and 'spawn' (who can spawn new objects with this " - "prototype)\n(If you are unsure, leave as default.)"] locks = prototype.get('prototype_locks', '') + + text = """ + |cPrototype-Locks|n are used to limit access to this prototype when someone else is trying + to access it. By default any prototype can be edited only by the creator and by Admins while + they can be used by anyone with access to the spawn command. There are two valid lock types + the prototype access tools look for: + + - 'edit': Who can edit the prototype. + - 'spawn': Who can spawn new objects with this prototype. + + If unsure, leave as default. + + {current} + """ + helptext = """ + Prototype locks can be used when there are different tiers of builders or for developers to + produce 'base prototypes' only meant for builders to inherit and expand on rather than + change. + """ + if locks: - text.append("Current lock is |w'{lockstring}'|n".format(lockstring=locks)) + text.format(current="Current lock is |w'{lockstring}'|n".format(lockstring=locks)) else: - text.append("Lock unset - if not changed the default lockstring will be set as\n" - " |w'spawn:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) - text = "\n\n".join(text) + text.format( + current="Default lock set: |w'spawn:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) + + text = (text, helptext) + options = _wizard_options("prototype_locks", "prototype_tags", "index") options.append({"key": "_default", "goto": (_set_property, @@ -1039,7 +1212,7 @@ def node_update_objects(caller, **kwargs): obj = choice(update_objects) diff, obj_prototype = spawner.prototype_diff_from_object(prototype, obj) - text = ["Suggested changes to {} objects".format(len(update_objects)), + text = ["Suggested changes to {} objects. ".format(len(update_objects)), "Showing random example obj to change: {name} (#{dbref}))\n".format(obj.key, obj.dbref)] options = [] io = 0 @@ -1073,6 +1246,17 @@ def node_update_objects(caller, **kwargs): {"key": "|wb|rack ({})".format(back_node[5:], 'b'), "goto": back_node}]) + helptext = """ + Be careful with this operation! The upgrade mechanism will try to automatically estimate + what changes need to be applied. But the estimate is |wonly based on the analysis of one + randomly selected object|n among all objects spawned by this prototype. If that object + happens to be unusual in some way the estimate will be off and may lead to unexpected + results for other objects. Always test your objects carefully after an upgrade and + consider being conservative (switch to KEEP) or even do the update manually if you are + unsure that the results will be acceptable. """ + + text = (text, helptext) + return text, options @@ -1144,7 +1328,17 @@ def node_prototype_save(caller, **kwargs): "goto": ("node_prototype_save", {"accept": True, "prototype": prototype})}) - return "\n".join(text), options + helptext = """ + Saving the prototype makes it available for use later. It can also be used to inherit from, + by name. Depending on |cprototype-locks|n it also makes the prototype usable and/or + editable by others. Consider setting good |cPrototype-tags|n and to give a useful, brief + |cPrototype-desc|n to make the prototype easy to find later. + + """ + + text = (text, helptext) + + return text, options # spawning node @@ -1212,6 +1406,16 @@ def node_prototype_spawn(caller, **kwargs): dict(prototype=prototype, opjects=spawned_objects, back_node="node_prototype_spawn"))}) options.extend(_wizard_options("prototype_spawn", "prototype_save", "index")) + + helptext = """ + Spawning is the act of instantiating a prototype into an actual object. As a new object is + spawned, every $protfunc in the prototype is called anew. Since this is a common thing to + do, you may also temporarily change the |clocation|n of this prototype to bypass whatever + value is set in the prototype. + + """ + text = (text, helptext) + return text, options @@ -1232,11 +1436,22 @@ def _prototype_load_select(caller, prototype_key): @list_node(_all_prototype_parents, _prototype_load_select) def node_prototype_load(caller, **kwargs): - text = ["Select a prototype to load. This will replace any currently edited prototype."] + """Load prototype""" + + text = """ + Select a prototype to load. This will replace any prototype currently being edited! + """ + helptext = """ + Loading a prototype will load it and return you to the main index. It can be a good idea to + examine the prototype before loading it. + """ + + text = (text, helptext) + options = _wizard_options("prototype_load", "prototype_save", "index") options.append({"key": "_default", "goto": _prototype_parent_examine}) - return "\n".join(text), options + return text, options # EvMenu definition, formatting and access functions From 6aef654cf17c9342038a20352c790b4d6b16bc52 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 24 Jul 2018 20:54:21 +0200 Subject: [PATCH 361/466] Fix unit tests --- evennia/utils/tests/test_evmenu.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/evennia/utils/tests/test_evmenu.py b/evennia/utils/tests/test_evmenu.py index d3ee14a74f..a6959c0509 100644 --- a/evennia/utils/tests/test_evmenu.py +++ b/evennia/utils/tests/test_evmenu.py @@ -82,6 +82,8 @@ class TestEvMenu(TestCase): self.assertIsNotNone( bool(node_text), "node: {}: node-text is None, which was not expected.".format(nodename)) + if isinstance(node_text, tuple): + node_text, helptext = node_text node_text = ansi.strip_ansi(node_text.strip()) self.assertTrue( node_text.startswith(compare_text), From 405293729f3c680a2633f054b828f4a55119b4c7 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 24 Jul 2018 21:47:54 +0200 Subject: [PATCH 362/466] Prepare for flattening prototype display --- evennia/commands/default/building.py | 2 +- evennia/prototypes/menus.py | 63 +++++++++++++++------------- evennia/prototypes/spawner.py | 23 ++++++++-- 3 files changed, 54 insertions(+), 34 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index cdcbadc103..bd4fb5e188 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2855,7 +2855,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): """ key = "@spawn" - aliases = ["@olc"] + aliases = ["olc"] switch_options = ("noloc", "search", "list", "show", "save", "delete", "menu", "olc", "update") locks = "cmd:perm(spawn) or perm(Builder)" help_category = "Building" diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 182a8b63f4..0589a8e65c 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -281,7 +281,7 @@ def node_index(caller): {"desc": "|WPrototype-Key|n|n{}".format( _format_option_value("Key", "prototype_key" not in prototype, prototype, None)), "goto": "node_prototype_key"}) - for key in ('Typeclass', 'Prototype-parent', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', + for key in ('Prototype-parent', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', 'Permissions', 'Location', 'Home', 'Destination'): required = False cropper = None @@ -412,8 +412,9 @@ def _prototype_parent_examine(caller, prototype_name): def _prototype_parent_select(caller, prototype): ret = _set_property(caller, "", - prop="prototype_parent", processor=str, next_node="node_key") - caller.msg("Selected prototype |y{}|n. Removed any set typeclass parent.".format(prototype)) + prop="prototype_parent", processor=str, next_node="node_typeclass") + caller.msg("Selected prototype |y{}|n.".format(prototype)) + return ret @@ -442,15 +443,15 @@ def node_prototype_parent(caller): if prot_parent_key: prot_parent = protlib.search_prototype(prot_parent_key) if prot_parent: - text.format( + text = text.format( current="Current parent prototype is {}:\n{}".format( protlib.prototype_to_str(prot_parent))) else: - text.format( + text = text.format( current="Current parent prototype |r{prototype}|n " "does not appear to exist.".format(prot_parent_key)) else: - text.format(current="Parent prototype is not set") + text = text.format(current="Parent prototype is not set") text = (text, helptext) options = _wizard_options("prototype_parent", "prototype_key", "typeclass", color="|W") @@ -521,10 +522,10 @@ def node_typeclass(caller): """ if typeclass: - text.format( + text = text.format( current="Current typeclass is |y{typeclass}|n.".format(typeclass=typeclass)) else: - text.format( + text = text.format( current="Using default typeclass {typeclass}.".format( typeclass=settings.BASE_OBJECT_TYPECLASS)) @@ -558,9 +559,9 @@ def node_key(caller): """.format(pfuncs=_format_protfuncs()) if key: - text.format(current="Current key is '{key}'.".format(key=key)) + text = text.format(current="Current key is '{key}'.".format(key=key)) else: - text.format(current="The key is currently unset.") + text = text.format(current="The key is currently unset.") text = (text, helptext) @@ -597,9 +598,9 @@ def node_aliases(caller): """.format(pfuncs=_format_protfuncs()) if aliases: - text.format(current="Current aliases are '|c{aliases}|n'.".format(aliases=aliases)) + text = text.format(current="Current aliases are '|c{aliases}|n'.".format(aliases=aliases)) else: - text.format(current="No aliases are set.") + text = text.format(current="No aliases are set.") text = (text, helptext) @@ -729,10 +730,10 @@ def node_attrs(caller): """.format(pfuncs=_format_protfuncs()) if attrs: - text.format(current="Current attrs {attrs}.".format( + text = text.format(current="Current attrs {attrs}.".format( attrs=attrs)) else: - text.format(current="No attrs are set.") + text = text.format(current="No attrs are set.") text = (text, helptext) @@ -884,9 +885,9 @@ def node_locks(caller): """.format(lfuncs=_format_lockfuncs()) if locks: - text.format(current="Current locks are '|y{locks}|n'.".format(locks=locks)) + text = text.format(current="Current locks are '|y{locks}|n'.".format(locks=locks)) else: - text.format(current="No locks are set.") + text = text.format(current="No locks are set.") text = (text, helptext) @@ -925,10 +926,10 @@ def node_permissions(caller): """.format(settings.PERMISSION_HIERARCHY) if permissions: - text.format(current="Current permissions are {permissions}.".format( + text = text.format(current="Current permissions are {permissions}.".format( permissions=permissions)) else: - text.format(current="No permissions are set.") + text = text.format(current="No permissions are set.") text = (text, helptext) @@ -964,9 +965,9 @@ def node_location(caller): """.format(pfuncs=_format_protfuncs) if location: - text.format(current="Current location is {location}.".format(location=location)) + text = text.format(current="Current location is {location}.".format(location=location)) else: - text.format(current="Default location is {}'s inventory.".format(caller)) + text = text.format(current="Default location is {}'s inventory.".format(caller)) text = (text, helptext) @@ -1001,9 +1002,9 @@ def node_home(caller): """ if home: - text.format(current="Current home location is {home}.".format(home=home)) + text = text.format(current="Current home location is {home}.".format(home=home)) else: - text.format( + text = text.format( current="Default home location ({home}) used.".format(home=settings.DEFAULT_HOME)) text = (text, helptext) @@ -1036,9 +1037,9 @@ def node_destination(caller): """ if dest: - text.format(current="Current destination is {dest}.".format(dest=dest)) + text = text.format(current="Current destination is {dest}.".format(dest=dest)) else: - text.format("No destination is set (default).") + text = text.format("No destination is set (default).") text = (text, helptext) @@ -1070,9 +1071,9 @@ def node_prototype_desc(caller): """ if desc: - text.format(current="The current meta desc is:\n\"|w{desc}|n\"".format(desc=desc)) + text = text.format(current="The current meta desc is:\n\"|w{desc}|n\"".format(desc=desc)) else: - text.format(current="Prototype-Description is currently unset.") + text = text.format(current="Prototype-Description is currently unset.") text = (text, helptext) @@ -1094,6 +1095,8 @@ def node_prototype_tags(caller): text = """ |cPrototype-Tags|n can be used to classify and find prototypes in listings Tag names are not case-sensitive and can have not have a custom category. Separate multiple tags by commas. + + {current} """ helptext = """ Using prototype-tags is a good way to organize and group large numbers of prototypes by @@ -1104,9 +1107,9 @@ def node_prototype_tags(caller): tags = prototype.get('prototype_tags', []) if tags: - text.format(current="The current tags are:\n|w{tags}|n".format(tags=tags)) + text = text.format(current="The current tags are:\n|w{tags}|n".format(tags=tags)) else: - text.format(current="No tags are currently set.") + text = text.format(current="No tags are currently set.") text = (text, helptext) @@ -1147,9 +1150,9 @@ def node_prototype_locks(caller): """ if locks: - text.format(current="Current lock is |w'{lockstring}'|n".format(lockstring=locks)) + text = text.format(current="Current lock is |w'{lockstring}'|n".format(lockstring=locks)) else: - text.format( + text = text.format( current="Default lock set: |w'spawn:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) text = (text, helptext) diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index c09a192819..494837b5fb 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -150,17 +150,34 @@ def _get_prototype(dic, prot, protparents): for infinite recursion here. """ - if "prototype" in dic: + if "prototype_parent" in dic: # move backwards through the inheritance - for prototype in make_iter(dic["prototype"]): + for prototype in make_iter(dic["prototype_parent"]): # Build the prot dictionary in reverse order, overloading new_prot = _get_prototype(protparents.get(prototype.lower(), {}), prot, protparents) prot.update(new_prot) prot.update(dic) - prot.pop("prototype", None) # we don't need this anymore + prot.pop("prototype_parent", None) # we don't need this anymore return prot +def flatten_prototype(prototype): + """ + Produce a 'flattened' prototype, where all prototype parents in the inheritance tree have been + merged into a final prototype. + + Args: + prototype (dict): Prototype to flatten. Its `prototype_parent` field will be parsed. + + Returns: + flattened (dict): The final, flattened prototype. + + """ + protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()} + protlib.validate_prototype(prototype, None, protparents, is_prototype_base=True) + return _get_prototype(prototype, {}, protparents) + + # obj-related prototype functions def prototype_from_object(obj): From e09576812f1e7984db032bb81d5ff552fc3222f0 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 25 Jul 2018 14:11:44 +0200 Subject: [PATCH 363/466] Show flattened current values in menu --- evennia/prototypes/menus.py | 170 +++++++++++----------------------- evennia/prototypes/spawner.py | 8 +- 2 files changed, 59 insertions(+), 119 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 0589a8e65c..e63acb98c2 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -42,6 +42,17 @@ def _get_menu_prototype(caller): return prototype +def _get_flat_menu_prototype(caller, refresh=False): + """Return prototype where parent values are included""" + flat_prototype = None + if not refresh and hasattr(caller.ndb._menutree, "olc_flat_prototype"): + flat_prototype = caller.ndb._menutree.olc_flat_prototype + if not flat_prototype: + prot = _get_menu_prototype(caller) + caller.ndb._menutree.olc_flat_prototype = flat_prototype = spawner.flatten_prototype(prot) + return flat_prototype + + def _set_menu_prototype(caller, prototype): """Set the prototype with existing one""" caller.ndb._menutree.olc_prototype = prototype @@ -230,6 +241,19 @@ def _format_lockfuncs(): docs=utils.justify(lockfunc.__doc__.strip(), align='l', indent=10).strip())) +def _get_current_value(caller, keyname, formatter=str): + "Return current value, marking if value comes from parent or set in this prototype" + prot = _get_menu_prototype(caller) + if keyname in prot: + # value in current prot + return "Current {}: {}".format(keyname, formatter(prot[keyname])) + flat_prot = _get_flat_menu_prototype(caller) + if keyname in flat_prot: + # value in flattened prot + return "Current {} (|binherited|n): {}".format(keyname, formatter(flat_prot[keyname])) + return "[No {} set]".format(keyname) + + # Menu nodes ------------------------------ @@ -363,12 +387,13 @@ def _check_prototype_key(caller, key): def node_prototype_key(caller): - prototype = _get_menu_prototype(caller) + text = """ The |cPrototype-Key|n uniquely identifies the prototype and is |wmandatory|n. It is used to find and use the prototype to spawn new entities. It is not case sensitive. - {current}""" + {current}""".format(current=_get_current_value(caller, "prototype_key")) + helptext = """ The prototype-key is not itself used when spawnng the new object, but is only used for managing, storing and loading the prototype. It must be globally unique, so existing keys @@ -376,12 +401,6 @@ def node_prototype_key(caller): prototype will be loaded. """ - old_key = prototype.get('prototype_key', None) - if old_key: - text = text.format(current="Currently set to '|w{key}|n'".format(key=old_key)) - else: - text = text.format(current="Currently |runset|n (required).") - options = _wizard_options("prototype_key", "index", "prototype_parent") options.append({"key": "_default", "goto": _check_prototype_key}) @@ -502,9 +521,6 @@ def _typeclass_select(caller, typeclass): @list_node(_all_typeclasses, _typeclass_select) def node_typeclass(caller): - prototype = _get_menu_prototype(caller) - typeclass = prototype.get("typeclass") - text = """ The |cTypeclass|n defines what 'type' of object this is - the actual working code to use. @@ -512,7 +528,8 @@ def node_typeclass(caller): one of the prototype's |cparents|n. {current} - """ + """.format(current=_get_current_value(caller, "typeclass")) + helptext = """ A |nTypeclass|n is specified by the actual python-path to the class definition in the Evennia code structure. @@ -521,14 +538,6 @@ def node_typeclass(caller): effects or expects certain values depend greatly on the code in play. """ - if typeclass: - text = text.format( - current="Current typeclass is |y{typeclass}|n.".format(typeclass=typeclass)) - else: - text = text.format( - current="Using default typeclass {typeclass}.".format( - typeclass=settings.BASE_OBJECT_TYPECLASS)) - text = (text, helptext) options = _wizard_options("typeclass", "prototype_parent", "key", color="|W") @@ -541,14 +550,12 @@ def node_typeclass(caller): def node_key(caller): - prototype = _get_menu_prototype(caller) - key = prototype.get("key") - text = """ The |cKey|n is the given name of the object to spawn. This will retain the given case. {current} - """ + """.format(current=_get_current_value(caller, "key")) + helptext = """ The key should often not be identical for every spawned object. Using a randomising $protfunc can be used, for example |c$choice(Alan, Tom, John)|n will give one of the three @@ -558,11 +565,6 @@ def node_key(caller): {pfuncs} """.format(pfuncs=_format_protfuncs()) - if key: - text = text.format(current="Current key is '{key}'.".format(key=key)) - else: - text = text.format(current="The key is currently unset.") - text = (text, helptext) options = _wizard_options("key", "typeclass", "aliases") @@ -578,8 +580,6 @@ def node_key(caller): def node_aliases(caller): - prototype = _get_menu_prototype(caller) - aliases = prototype.get("aliases") text = """ |cAliases|n are alternative ways to address an object, next to its |cKey|n. Aliases are not @@ -588,7 +588,8 @@ def node_aliases(caller): Add multiple aliases separating with commas. {current} - """ + """.format(current=_get_current_value(caller, "aliases")) + helptext = """ Aliases are fixed alternative identifiers and are stored with the new object. @@ -597,11 +598,6 @@ def node_aliases(caller): {pfuncs} """.format(pfuncs=_format_protfuncs()) - if aliases: - text = text.format(current="Current aliases are '|c{aliases}|n'.".format(aliases=aliases)) - else: - text = text.format(current="No aliases are set.") - text = (text, helptext) options = _wizard_options("aliases", "key", "attrs") @@ -703,8 +699,6 @@ def _examine_attr(caller, selection): @list_node(_caller_attrs) def node_attrs(caller): - prot = _get_menu_prototype(caller) - attrs = prot.get("attrs") text = """ |cAttributes|n are custom properties of the object. Enter attributes on one of these forms: @@ -717,7 +711,8 @@ def node_attrs(caller): (attrname;;lockstring=value). Attribute values can have embedded $protfuncs. {current} - """ + """.format(current=_get_current_value(caller, "attrs")) + helptext = """ Most commonly, Attributes don't need any categories or locks. If using locks, the lock-types 'attredit', 'attrread' are used to limiting editing and viewing of the Attribute. Putting @@ -729,12 +724,6 @@ def node_attrs(caller): {pfuncs} """.format(pfuncs=_format_protfuncs()) - if attrs: - text = text.format(current="Current attrs {attrs}.".format( - attrs=attrs)) - else: - text = text.format(current="No attrs are set.") - text = (text, helptext) options = _wizard_options("attrs", "aliases", "tags") @@ -835,7 +824,10 @@ def node_tags(caller): tagname tagname;category tagname;category;data - """ + + {current} + """.format(current=_get_current_value(caller, 'tags')) + helptext = """ Tags are shared between all objects with that tag. So the 'data' field (which is not commonly used) can only hold eventual info about the Tag itself, not about the individual @@ -855,8 +847,6 @@ def node_tags(caller): def node_locks(caller): - prototype = _get_menu_prototype(caller) - locks = prototype.get("locks") text = """ The |cLock string|n defines limitations for accessing various properties of the object once @@ -868,7 +858,8 @@ def node_locks(caller): Separate multiple lockstrings by semicolons (;). {current} - """ + """.format(current=_get_current_value(caller, 'locks')) + helptext = """ Here is an example of a lock string constisting of two locks: @@ -884,11 +875,6 @@ def node_locks(caller): {lfuncs} """.format(lfuncs=_format_lockfuncs()) - if locks: - text = text.format(current="Current locks are '|y{locks}|n'.".format(locks=locks)) - else: - text = text.format(current="No locks are set.") - text = (text, helptext) options = _wizard_options("locks", "tags", "permissions") @@ -904,15 +890,14 @@ def node_locks(caller): def node_permissions(caller): - prototype = _get_menu_prototype(caller) - permissions = prototype.get("permissions") text = """ |cPermissions|n are simple strings used to grant access to this object. A permission is used when a |clock|n is checked that contains the |wperm|n or |wpperm|n lock functions. {current} - """ + """.format(current=_get_current_value(caller, "permissions")) + helptext = """ Any string can act as a permission as long as a lock is set to look for it. Depending on the lock, having a permission could even be negative (i.e. the lock is only passed if you @@ -925,12 +910,6 @@ def node_permissions(caller): having the |cpermission|n "Builder" or higher. """.format(settings.PERMISSION_HIERARCHY) - if permissions: - text = text.format(current="Current permissions are {permissions}.".format( - permissions=permissions)) - else: - text = text.format(current="No permissions are set.") - text = (text, helptext) options = _wizard_options("permissions", "destination", "location") @@ -946,15 +925,14 @@ def node_permissions(caller): def node_location(caller): - prototype = _get_menu_prototype(caller) - location = prototype.get("location") text = """ The |cLocation|n of this object in the world. If not given, the object will spawn in the inventory of |c{caller}|n instead. {current} - """.format(caller=caller.key) + """.format(caller=caller.key, current=_get_current_value(caller, "location")) + helptext = """ You get the most control by not specifying the location - you can then teleport the spawned objects as needed later. Setting the location may be useful for quickly populating a given @@ -964,11 +942,6 @@ def node_location(caller): {pfuncs} """.format(pfuncs=_format_protfuncs) - if location: - text = text.format(current="Current location is {location}.".format(location=location)) - else: - text = text.format(current="Default location is {}'s inventory.".format(caller)) - text = (text, helptext) options = _wizard_options("location", "permissions", "home") @@ -984,8 +957,6 @@ def node_location(caller): def node_home(caller): - prototype = _get_menu_prototype(caller) - home = prototype.get("home") text = """ The |cHome|n location of an object is often only used as a backup - this is where the object @@ -993,7 +964,7 @@ def node_home(caller): home for characters to quickly move back to. If unset, the global home default will be used. {current} - """ + """.format(current=_get_current_value(caller, "home")) helptext = """ The location can be specified as as #dbref but can also be explicitly searched for using $obj(name). @@ -1001,12 +972,6 @@ def node_home(caller): The home location is often not used except as a backup. It should never be unset. """ - if home: - text = text.format(current="Current home location is {home}.".format(home=home)) - else: - text = text.format( - current="Default home location ({home}) used.".format(home=settings.DEFAULT_HOME)) - text = (text, helptext) options = _wizard_options("home", "aliases", "destination") @@ -1022,25 +987,19 @@ def node_home(caller): def node_destination(caller): - prototype = _get_menu_prototype(caller) - dest = prototype.get("dest") text = """ The object's |cDestination|n is usually only set for Exit-like objects and designates where the exit 'leads to'. It's usually unset for all other types of objects. {current} - """ + """.format(current=_get_current_node(caller, "destination")) + helptext = """ The destination can be given as a #dbref but can also be explicitly searched for using $obj(name). """ - if dest: - text = text.format(current="Current destination is {dest}.".format(dest=dest)) - else: - text = text.format("No destination is set (default).") - text = (text, helptext) options = _wizard_options("destination", "home", "prototype_desc") @@ -1057,24 +1016,17 @@ def node_destination(caller): def node_prototype_desc(caller): - prototype = _get_menu_prototype(caller) - desc = prototype.get("prototype_desc", None) - text = """ The |cPrototype-Description|n optionally briefly describes the prototype when it's viewed in listings. {current} - """ + """.format(current=_get_current_value(caller, "prototype_desc")) + helptext = """ Giving a brief description helps you and others to locate the prototype for use later. """ - if desc: - text = text.format(current="The current meta desc is:\n\"|w{desc}|n\"".format(desc=desc)) - else: - text = text.format(current="Prototype-Description is currently unset.") - text = (text, helptext) options = _wizard_options("prototype_desc", "prototype_key", "prototype_tags") @@ -1091,26 +1043,19 @@ def node_prototype_desc(caller): def node_prototype_tags(caller): - prototype = _get_menu_prototype(caller) + text = """ |cPrototype-Tags|n can be used to classify and find prototypes in listings Tag names are not case-sensitive and can have not have a custom category. Separate multiple tags by commas. {current} - """ + """.format(current=_get_current_value(caller, "prototype_tags")) helptext = """ Using prototype-tags is a good way to organize and group large numbers of prototypes by genre, type etc. Under the hood, prototypes' tags will all be stored with the category '{tagmetacategory}'. """.format(tagmetacategory=protlib._PROTOTYPE_TAG_META_CATEGORY) - tags = prototype.get('prototype_tags', []) - - if tags: - text = text.format(current="The current tags are:\n|w{tags}|n".format(tags=tags)) - else: - text = text.format(current="No tags are currently set.") - text = (text, helptext) options = _wizard_options("prototype_tags", "prototype_desc", "prototype_locks") @@ -1127,8 +1072,6 @@ def node_prototype_tags(caller): def node_prototype_locks(caller): - prototype = _get_menu_prototype(caller) - locks = prototype.get('prototype_locks', '') text = """ |cPrototype-Locks|n are used to limit access to this prototype when someone else is trying @@ -1142,19 +1085,14 @@ def node_prototype_locks(caller): If unsure, leave as default. {current} - """ + """.format(current=_get_current_value(caller, "prototype_locks")) + helptext = """ Prototype locks can be used when there are different tiers of builders or for developers to produce 'base prototypes' only meant for builders to inherit and expand on rather than change. """ - if locks: - text = text.format(current="Current lock is |w'{lockstring}'|n".format(lockstring=locks)) - else: - text = text.format( - current="Default lock set: |w'spawn:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) - text = (text, helptext) options = _wizard_options("prototype_locks", "prototype_tags", "index") diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 494837b5fb..31a77ce303 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -173,9 +173,11 @@ def flatten_prototype(prototype): flattened (dict): The final, flattened prototype. """ - protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()} - protlib.validate_prototype(prototype, None, protparents, is_prototype_base=True) - return _get_prototype(prototype, {}, protparents) + if prototype: + protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()} + protlib.validate_prototype(prototype, None, protparents, is_prototype_base=True) + return _get_prototype(prototype, {}, protparents) + return {} # obj-related prototype functions From f27673b741ec1cd8c75346f2186bf943b54e5a30 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 25 Jul 2018 19:51:48 +0200 Subject: [PATCH 364/466] Validate prototype parent before chosing it --- evennia/prototypes/menus.py | 67 ++++++++++++++++++++------------ evennia/prototypes/prototypes.py | 12 +++--- evennia/prototypes/spawner.py | 6 ++- 3 files changed, 54 insertions(+), 31 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index e63acb98c2..34f8eaf648 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -42,14 +42,15 @@ def _get_menu_prototype(caller): return prototype -def _get_flat_menu_prototype(caller, refresh=False): +def _get_flat_menu_prototype(caller, refresh=False, validate=False): """Return prototype where parent values are included""" flat_prototype = None if not refresh and hasattr(caller.ndb._menutree, "olc_flat_prototype"): flat_prototype = caller.ndb._menutree.olc_flat_prototype if not flat_prototype: prot = _get_menu_prototype(caller) - caller.ndb._menutree.olc_flat_prototype = flat_prototype = spawner.flatten_prototype(prot) + caller.ndb._menutree.olc_flat_prototype = \ + flat_prototype = spawner.flatten_prototype(prot, validate=validate) return flat_prototype @@ -305,11 +306,11 @@ def node_index(caller): {"desc": "|WPrototype-Key|n|n{}".format( _format_option_value("Key", "prototype_key" not in prototype, prototype, None)), "goto": "node_prototype_key"}) - for key in ('Prototype-parent', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', + for key in ('Prototype_parent', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', 'Permissions', 'Location', 'Home', 'Destination'): required = False cropper = None - if key in ("Prototype-parent", "Typeclass"): + if key in ("Prototype_parent", "Typeclass"): required = ("prototype_parent" not in prototype) and ("typeclass" not in prototype) if key == 'Typeclass': cropper = _path_cropper @@ -429,11 +430,24 @@ def _prototype_parent_examine(caller, prototype_name): caller.msg("Prototype not registered.") -def _prototype_parent_select(caller, prototype): - ret = _set_property(caller, "", - prop="prototype_parent", processor=str, next_node="node_typeclass") - caller.msg("Selected prototype |y{}|n.".format(prototype)) +def _prototype_parent_select(caller, new_parent): + ret = None + prototype_parent = protlib.search_prototype(new_parent) + try: + if prototype_parent: + spawner.flatten_prototype(prototype_parent[0], validate=True) + else: + raise RuntimeError("Not found.") + except RuntimeError as err: + caller.msg("Selected prototype parent {} " + "caused Error(s):\n|r{}|n".format(new_parent, err)) + else: + ret = _set_property(caller, new_parent, + prop="prototype_parent", + processor=str, next_node="node_prototype_parent") + _get_flat_menu_prototype(caller, refresh=True) + caller.msg("Selected prototype parent |c{}|n.".format(new_parent)) return ret @@ -441,12 +455,12 @@ def _prototype_parent_select(caller, prototype): def node_prototype_parent(caller): prototype = _get_menu_prototype(caller) - prot_parent_key = prototype.get('prototype') + prot_parent_keys = prototype.get('prototype_parent') text = """ The |cPrototype Parent|n allows you to |winherit|n prototype values from another named - prototype (given as that prototype's |wprototype_key|). If not changing these values in the - current prototype, the parent's value will be used. Pick the available prototypes below. + prototype (given as that prototype's |wprototype_key|n). If not changing these values in + the current prototype, the parent's value will be used. Pick the available prototypes below. Note that somewhere in the prototype's parentage, a |ctypeclass|n must be specified. If no parent is given, this prototype must define the typeclass (next menu node). @@ -459,18 +473,23 @@ def node_prototype_parent(caller): prototype to be valid. """ - if prot_parent_key: - prot_parent = protlib.search_prototype(prot_parent_key) - if prot_parent: - text = text.format( - current="Current parent prototype is {}:\n{}".format( - protlib.prototype_to_str(prot_parent))) - else: - text = text.format( - current="Current parent prototype |r{prototype}|n " - "does not appear to exist.".format(prot_parent_key)) - else: - text = text.format(current="Parent prototype is not set") + ptexts = [] + if prot_parent_keys: + for pkey in utils.make_iter(prot_parent_keys): + prot_parent = protlib.search_prototype(pkey) + if prot_parent: + prot_parent = prot_parent[0] + ptexts.append("|c -- {pkey} -- |n\n{prot}".format( + pkey=pkey, + prot=protlib.prototype_to_str(prot_parent))) + else: + ptexts.append("Prototype parent |r{pkey} was not found.".format(pkey=pkey)) + + if not ptexts: + ptexts.append("[No prototype_parent set]") + + text = text.format(current="\n\n".join(ptexts)) + text = (text, helptext) options = _wizard_options("prototype_parent", "prototype_key", "typeclass", color="|W") @@ -993,7 +1012,7 @@ def node_destination(caller): the exit 'leads to'. It's usually unset for all other types of objects. {current} - """.format(current=_get_current_node(caller, "destination")) + """.format(current=_get_current_value(caller, "destination")) helptext = """ The destination can be given as a #dbref but can also be explicitly searched for using diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 011445b039..767919a7a9 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -539,7 +539,7 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed def validate_prototype(prototype, protkey=None, protparents=None, - is_prototype_base=True, _flags=None): + is_prototype_base=True, strict=True, _flags=None): """ Run validation on a prototype, checking for inifinite regress. @@ -552,6 +552,8 @@ def validate_prototype(prototype, protkey=None, protparents=None, is_prototype_base (bool, optional): We are trying to create a new object *based on this object*. This means we can't allow 'mixin'-style prototypes without typeclass/parent etc. + strict (bool, optional): If unset, don't require needed keys, only check against infinite + recursion etc. _flags (dict, optional): Internal work dict that should not be set externally. Raises: RuntimeError: If prototype has invalid structure. @@ -570,14 +572,14 @@ def validate_prototype(prototype, protkey=None, protparents=None, protkey = protkey and protkey.lower() or prototype.get('prototype_key', None) - if not bool(protkey): + if strict and not bool(protkey): _flags['errors'].append("Prototype lacks a `prototype_key`.") protkey = "[UNSET]" typeclass = prototype.get('typeclass') prototype_parent = prototype.get('prototype_parent', []) - if not (typeclass or prototype_parent): + if strict and not (typeclass or prototype_parent): if is_prototype_base: _flags['errors'].append("Prototype {} requires `typeclass` " "or 'prototype_parent'.".format(protkey)) @@ -585,7 +587,7 @@ def validate_prototype(prototype, protkey=None, protparents=None, _flags['warnings'].append("Prototype {} can only be used as a mixin since it lacks " "a typeclass or a prototype_parent.".format(protkey)) - if typeclass and typeclass not in get_all_typeclasses("evennia.objects.models.ObjectDB"): + if strict and typeclass and typeclass not in get_all_typeclasses("evennia.objects.models.ObjectDB"): _flags['errors'].append( "Prototype {} is based on typeclass {}, which could not be imported!".format( protkey, typeclass)) @@ -615,7 +617,7 @@ def validate_prototype(prototype, protkey=None, protparents=None, _flags['typeclass'] = typeclass # if we get back to the current level without a typeclass it's an error. - if is_prototype_base and _flags['depth'] <= 0 and not _flags['typeclass']: + if strict and is_prototype_base and _flags['depth'] <= 0 and not _flags['typeclass']: _flags['errors'].append("Prototype {} has no `typeclass` defined anywhere in its parent " "chain. Add `typeclass`, or a `prototype_parent` pointing to a " "prototype with a typeclass.".format(protkey)) diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 31a77ce303..3dd8e11d67 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -161,13 +161,14 @@ def _get_prototype(dic, prot, protparents): return prot -def flatten_prototype(prototype): +def flatten_prototype(prototype, validate=False): """ Produce a 'flattened' prototype, where all prototype parents in the inheritance tree have been merged into a final prototype. Args: prototype (dict): Prototype to flatten. Its `prototype_parent` field will be parsed. + validate (bool, optional): Validate for valid keys etc. Returns: flattened (dict): The final, flattened prototype. @@ -175,7 +176,8 @@ def flatten_prototype(prototype): """ if prototype: protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()} - protlib.validate_prototype(prototype, None, protparents, is_prototype_base=True) + protlib.validate_prototype(prototype, None, protparents, + is_prototype_base=validate, strict=validate) return _get_prototype(prototype, {}, protparents) return {} From 50c54501f1941dcd5ce876975a97ab3f4b91a2e8 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 26 Jul 2018 23:41:00 +0200 Subject: [PATCH 365/466] Refactor menu up until attrs --- evennia/prototypes/menus.py | 376 +++++++++++++++++++++++++------ evennia/prototypes/prototypes.py | 4 +- evennia/prototypes/tests.py | 31 ++- evennia/utils/evmenu.py | 2 +- 4 files changed, 329 insertions(+), 84 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 34f8eaf648..54ec054340 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -5,6 +5,7 @@ OLC Prototype menu nodes """ import json +import re from random import choice from django.conf import settings from evennia.utils.evmenu import EvMenu, list_node @@ -242,6 +243,25 @@ def _format_lockfuncs(): docs=utils.justify(lockfunc.__doc__.strip(), align='l', indent=10).strip())) +def _format_list_actions(*args, **kwargs): + """Create footer text for nodes with extra list actions + + Args: + actions (str): Available actions. The first letter of the action name will be assumed + to be a shortcut. + Kwargs: + prefix (str): Default prefix to use. + Returns: + string (str): Formatted footer for adding to the node text. + + """ + actions = [] + prefix = kwargs.get('prefix', "|WSelect with |w|W. Other actions:|n ") + for action in args: + actions.append("|w{}|n|W{} |w|n".format(action[0], action[1:])) + return prefix + "|W,|n ".join(actions) + + def _get_current_value(caller, keyname, formatter=str): "Return current value, marking if value comes from parent or set in this prototype" prot = _get_menu_prototype(caller) @@ -255,6 +275,32 @@ def _get_current_value(caller, keyname, formatter=str): return "[No {} set]".format(keyname) +def _default_parse(raw_inp, choices, *args): + """ + Helper to parse default input to a node decorated with the node_list decorator on + the form l1, l 2, look 1, etc. Spaces are ignored, as is case. + + Args: + raw_inp (str): Input from the user. + choices (list): List of available options on the node listing (list of strings). + args (tuples): The available actions, each specifed as a tuple (name, alias, ...) + Returns: + choice (str): A choice among the choices, or None if no match was found. + action (str): The action operating on the choice, or None. + + """ + raw_inp = raw_inp.lower().strip() + mapping = {t.lower(): tup[0] for tup in args for t in tup} + match = re.match(r"(%s)\s*?(\d+)$" % "|".join(mapping.keys()), raw_inp) + if match: + action = mapping.get(match.group(1), None) + num = int(match.group(2)) - 1 + num = num if 0 <= num < len(choices) else None + if action is not None and num is not None: + return choices[num], action + return None, None + + # Menu nodes ------------------------------ @@ -357,6 +403,26 @@ def node_validate_prototype(caller, raw_string, **kwargs): text = (text, helptext) options = _wizard_options(None, prev_node, None) + options.append({"key": "_default", + "goto": "node_" + prev_node}) + + return text, options + + +def node_examine_entity(caller, raw_string, **kwargs): + """ + General node to view a text and then return to previous node. Kwargs should contain "text" for + the text to show and 'back" pointing to the node to return to. + """ + text = kwargs.get("text", "Nothing was found here.") + helptext = "Use |wback|n to return to the previous node." + prev_node = kwargs.get('back', 'index') + + text = (text, helptext) + + options = _wizard_options(None, prev_node, None) + options.append({"key": "_default", + "goto": "node_" + prev_node}) return text, options @@ -419,15 +485,64 @@ def _all_prototype_parents(caller): for prototype in protlib.search_prototype() if "prototype_key" in prototype] -def _prototype_parent_examine(caller, prototype_name): - """Convert prototype to a string representation for closer inspection""" - prototypes = protlib.search_prototype(key=prototype_name) - if prototypes: - ret = protlib.prototype_to_str(prototypes[0]) - caller.msg(ret) - return ret - else: - caller.msg("Prototype not registered.") +def _prototype_parent_actions(caller, raw_inp, **kwargs): + """Parse the default Convert prototype to a string representation for closer inspection""" + choices = kwargs.get("available_choices", []) + prototype_parent, action = _default_parse( + raw_inp, choices, ("examine", "e", "l"), ("add", "a"), ("remove", "r", 'delete', 'd')) + + if prototype_parent: + # a selection of parent was made + prototype_parent = protlib.search_prototype(key=prototype_parent)[0] + prototype_parent_key = prototype_parent['prototype_key'] + + # which action to apply on the selection + if action == 'examine': + # examine the prototype + txt = protlib.prototype_to_str(prototype_parent) + kwargs['text'] = txt + kwargs['back'] = 'prototype_parent' + return "node_examine_entity", kwargs + elif action == 'add': + # add/append parent + prot = _get_menu_prototype(caller) + current_prot_parent = prot.get('prototype_parent', None) + if current_prot_parent: + current_prot_parent = utils.make_iter(current_prot_parent) + if prototype_parent_key in current_prot_parent: + caller.msg("Prototype_parent {} is already used.".format(prototype_parent_key)) + return "node_prototype_parent" + else: + current_prot_parent.append(prototype_parent_key) + caller.msg("Add prototype parent for multi-inheritance.") + else: + current_prot_parent = prototype_parent_key + try: + if prototype_parent: + spawner.flatten_prototype(prototype_parent, validate=True) + else: + raise RuntimeError("Not found.") + except RuntimeError as err: + caller.msg("Selected prototype-parent {} " + "caused Error(s):\n|r{}|n".format(prototype_parent, err)) + return "node_prototype_parent" + _set_prototype_value(caller, "prototype_parent", current_prot_parent) + _get_flat_menu_prototype(caller, refresh=True) + elif action == "remove": + # remove prototype parent + prot = _get_menu_prototype(caller) + current_prot_parent = prot.get('prototype_parent', None) + if current_prot_parent: + current_prot_parent = utils.make_iter(current_prot_parent) + try: + current_prot_parent.remove(prototype_parent_key) + _set_prototype_value(caller, 'prototype_parent', current_prot_parent) + _get_flat_menu_prototype(caller, refresh=True) + caller.msg("Removed prototype parent {}.".format(prototype_parent_key)) + except ValueError: + caller.msg("|rPrototype-parent {} could not be removed.".format( + prototype_parent_key)) + return 'node_prototype_parent' def _prototype_parent_select(caller, new_parent): @@ -440,7 +555,7 @@ def _prototype_parent_select(caller, new_parent): else: raise RuntimeError("Not found.") except RuntimeError as err: - caller.msg("Selected prototype parent {} " + caller.msg("Selected prototype-parent {} " "caused Error(s):\n|r{}|n".format(new_parent, err)) else: ret = _set_property(caller, new_parent, @@ -466,6 +581,8 @@ def node_prototype_parent(caller): parent is given, this prototype must define the typeclass (next menu node). {current} + + {actions} """ helptext = """ Prototypes can inherit from one another. Changes in the child replace any values set in a @@ -488,13 +605,14 @@ def node_prototype_parent(caller): if not ptexts: ptexts.append("[No prototype_parent set]") - text = text.format(current="\n\n".join(ptexts)) + text = text.format(current="\n\n".join(ptexts), + actions=_format_list_actions("examine", "add", "remove")) text = (text, helptext) options = _wizard_options("prototype_parent", "prototype_key", "typeclass", color="|W") options.append({"key": "_default", - "goto": _prototype_parent_examine}) + "goto": _prototype_parent_actions}) return text, options @@ -508,33 +626,45 @@ def _all_typeclasses(caller): if name != "evennia.objects.models.ObjectDB") -def _typeclass_examine(caller, typeclass_path): - """Show info (docstring) about given typeclass.""" - if typeclass_path is None: - # this means we are exiting the listing - return "node_key" +def _typeclass_actions(caller, raw_inp, **kwargs): + """Parse actions for typeclass listing""" - typeclass = utils.get_all_typeclasses().get(typeclass_path) - if typeclass: - docstr = [] - for line in typeclass.__doc__.split("\n"): - if line.strip(): - docstr.append(line) - elif docstr: - break - docstr = '\n'.join(docstr) if docstr else "" - txt = "Typeclass |y{typeclass_path}|n; First paragraph of docstring:\n\n{docstring}".format( - typeclass_path=typeclass_path, docstring=docstr) - else: - txt = "This is typeclass |y{}|n.".format(typeclass) - caller.msg(txt) - return txt + choices = kwargs.get("available_choices", []) + typeclass_path, action = _default_parse( + raw_inp, choices, ("examine", "e", "l"), ("remove", "r", "delete", "d")) + + if typeclass_path: + if action == 'examine': + typeclass = utils.get_all_typeclasses().get(typeclass_path) + if typeclass: + docstr = [] + for line in typeclass.__doc__.split("\n"): + if line.strip(): + docstr.append(line) + elif docstr: + break + docstr = '\n'.join(docstr) if docstr else "" + txt = "Typeclass |c{typeclass_path}|n; " \ + "First paragraph of docstring:\n\n{docstring}".format( + typeclass_path=typeclass_path, docstring=docstr) + else: + txt = "This is typeclass |y{}|n.".format(typeclass) + return "node_examine_entity", {"text": txt, "back": "typeclass"} + elif action == 'remove': + prototype = _get_menu_prototype(caller) + old_typeclass = prototype.pop('typeclass', None) + if old_typeclass: + _set_menu_prototype(caller, prototype) + caller.msg("Cleared typeclass {}.".format(old_typeclass)) + else: + caller.msg("No typeclass to remove.") + return "node_typeclass" def _typeclass_select(caller, typeclass): """Select typeclass from list and add it to prototype. Return next node to go to.""" ret = _set_property(caller, typeclass, prop='typeclass', processor=str, next_node="node_key") - caller.msg("Selected typeclass |y{}|n.".format(typeclass)) + caller.msg("Selected typeclass |c{}|n.".format(typeclass)) return ret @@ -547,7 +677,10 @@ def node_typeclass(caller): one of the prototype's |cparents|n. {current} - """.format(current=_get_current_value(caller, "typeclass")) + + {actions} + """.format(current=_get_current_value(caller, "typeclass"), + actions=_format_list_actions("examine", "remove")) helptext = """ A |nTypeclass|n is specified by the actual python-path to the class definition in the @@ -561,7 +694,7 @@ def node_typeclass(caller): options = _wizard_options("typeclass", "prototype_parent", "key", color="|W") options.append({"key": "_default", - "goto": _typeclass_examine}) + "goto": _typeclass_actions}) return text, options @@ -598,16 +731,62 @@ def node_key(caller): # aliases node +def _all_aliases(caller): + "Get aliases in prototype" + prototype = _get_menu_prototype(caller) + return prototype.get("aliases", []) + + +def _aliases_select(caller, alias): + "Add numbers as aliases" + aliases = _all_aliases(caller) + try: + ind = str(aliases.index(alias) + 1) + if ind not in aliases: + aliases.append(ind) + _set_prototype_value(caller, "aliases", aliases) + caller.msg("Added alias '{}'.".format(ind)) + except (IndexError, ValueError) as err: + caller.msg("Error: {}".format(err)) + + return "node_aliases" + + +def _aliases_actions(caller, raw_inp, **kwargs): + """Parse actions for aliases listing""" + choices = kwargs.get("available_choices", []) + alias, action = _default_parse( + raw_inp, choices, ("remove", "r", "delete", "d")) + + aliases = _all_aliases(caller) + if alias and action == 'remove': + try: + aliases.remove(alias) + _set_prototype_value(caller, "aliases", aliases) + caller.msg("Removed alias '{}'.".format(alias)) + except ValueError: + caller.msg("No matching alias found to remove.") + else: + # if not a valid remove, add as a new alias + alias = raw_inp.lower().strip() + if alias not in aliases: + aliases.append(alias) + _set_prototype_value(caller, "aliases", aliases) + caller.msg("Added alias '{}'.".format(alias)) + else: + caller.msg("Alias '{}' was already set.".format(alias)) + return "node_aliases" + + +@list_node(_all_aliases, _aliases_select) def node_aliases(caller): text = """ |cAliases|n are alternative ways to address an object, next to its |cKey|n. Aliases are not case sensitive. - Add multiple aliases separating with commas. - - {current} - """.format(current=_get_current_value(caller, "aliases")) + {actions} + """.format(_format_list_actions("remove", prefix="|w|W to add new alias. Other action: ")) helptext = """ Aliases are fixed alternative identifiers and are stored with the new object. @@ -621,10 +800,7 @@ def node_aliases(caller): options = _wizard_options("aliases", "key", "attrs") options.append({"key": "_default", - "goto": (_set_property, - dict(prop="aliases", - processor=lambda s: [part.strip() for part in s.split(",")], - next_node="node_attrs"))}) + "goto": _aliases_actions}) return text, options @@ -633,38 +809,62 @@ def node_aliases(caller): def _caller_attrs(caller): prototype = _get_menu_prototype(caller) - attrs = prototype.get("attrs", []) + attrs = ["{}={}".format(tup[0], utils.crop(utils.to_str(tup[1]), width=10)) + for tup in prototype.get("attrs", [])] return attrs +def _get_tup_by_attrname(caller, attrname): + prototype = _get_menu_prototype(caller) + attrs = prototype.get("attrs", []) + try: + inp = [tup[0] for tup in attrs].index(attrname) + return attrs[inp] + except ValueError: + return None + + def _display_attribute(attr_tuple): """Pretty-print attribute tuple""" attrkey, value, category, locks = attr_tuple value = protlib.protfunc_parser(value) typ = type(value) - out = ("Attribute key: '{attrkey}' (category: {category}, " - "locks: {locks})\n" - "Value (parsed to {typ}): {value}").format( + out = ("|cAttribute key:|n '{attrkey}' " + "(|ccategory:|n {category}, " + "|clocks:|n {locks})\n" + "|cValue|n |W(parsed to {typ})|n:\n{value}").format( attrkey=attrkey, - category=category, locks=locks, + category=category if category else "|wNone|n", + locks=locks if locks else "|wNone|n", typ=typ, value=value) return out def _add_attr(caller, attr_string, **kwargs): """ - Add new attrubute, parsing input. - attr is entered on these forms - attr = value - attr;category = value - attr;category;lockstring = value + Add new attribute, parsing input. + Args: + caller (Object): Caller of menu. + attr_string (str): Input from user + attr is entered on these forms + attr = value + attr;category = value + attr;category;lockstring = value + Kwargs: + delete (str): If this is set, attr_string is + considered the name of the attribute to delete and + no further parsing happens. + Returns: + result (str): Result string of action. """ attrname = '' category = None locks = '' - if '=' in attr_string: + if 'delete' in kwargs: + attrname = attr_string + elif '=' in attr_string: attrname, value = (part.strip() for part in attr_string.split('=', 1)) attrname = attrname.lower() nameparts = attrname.split(";", 2) @@ -679,6 +879,15 @@ def _add_attr(caller, attr_string, **kwargs): prot = _get_menu_prototype(caller) attrs = prot.get('attrs', []) + if 'delete' in kwargs: + try: + ind = [tup[0] for tup in attrs].index(attrname) + del attrs[ind] + _set_prototype_value(caller, "attrs", attrs) + return "Removed Attribute '{}'".format(attrname) + except IndexError: + return "Attribute to delete not found." + try: # replace existing attribute with the same name in the prototype ind = [tup[0] for tup in attrs].index(attrname) @@ -697,26 +906,47 @@ def _add_attr(caller, attr_string, **kwargs): else: text = "Attribute must be given as 'attrname[;category;locks] = '." - options = {"key": "_default", - "goto": lambda caller: None} - return text, options + return text -def _edit_attr(caller, attrname, new_value, **kwargs): +def _attr_select(caller, attrstr): + attrname, _ = attrstr.split("=", 1) + attrname = attrname.strip() - attr_string = "{}={}".format(attrname, new_value) - - return _add_attr(caller, attr_string, edit=True) + attr_tup = _get_tup_by_attrname(caller, attrname) + if attr_tup: + return "node_examine_entity", \ + {"text": _display_attribute(attr_tup), "back": "attrs"} + else: + caller.msg("Attribute not found.") + return "node_attrs" -def _examine_attr(caller, selection): - prot = _get_menu_prototype(caller) - ind = [part[0] for part in prot['attrs']].index(selection) - attr_tuple = prot['attrs'][ind] - return _display_attribute(attr_tuple) +def _attrs_actions(caller, raw_inp, **kwargs): + """Parse actions for attribute listing""" + choices = kwargs.get("available_choices", []) + attrstr, action = _default_parse( + raw_inp, choices, ('examine', 'e'), ('remove', 'r', 'delete', 'd')) + if attrstr is None: + attrstr = raw_inp + attrname, _ = attrstr.split("=", 1) + attrname = attrname.strip() + attr_tup = _get_tup_by_attrname(caller, attrname) + + if attr_tup: + if action == 'examine': + return "node_examine_entity", \ + {"text": _display_attribute(attr_tup), "back": "attrs"} + elif action == 'remove': + res = _add_attr(caller, attr_tup, delete=True) + caller.msg(res) + else: + res = _add_attr(caller, raw_inp) + caller.msg(res) + return "node_attrs" -@list_node(_caller_attrs) +@list_node(_caller_attrs, _attr_select) def node_attrs(caller): text = """ @@ -729,8 +959,8 @@ def node_attrs(caller): To give an attribute without a category but with a lockstring, leave that spot empty (attrname;;lockstring=value). Attribute values can have embedded $protfuncs. - {current} - """.format(current=_get_current_value(caller, "attrs")) + {actions} + """.format(actions=_format_list_actions("examine", "remove", prefix="Actions: ")) helptext = """ Most commonly, Attributes don't need any categories or locks. If using locks, the lock-types @@ -747,10 +977,7 @@ def node_attrs(caller): options = _wizard_options("attrs", "aliases", "tags") options.append({"key": "_default", - "goto": (_set_property, - dict(prop="attrs", - processor=lambda s: [part.strip() for part in s.split(",")], - next_node="node_tags"))}) + "goto": _attrs_actions}) return text, options @@ -1410,7 +1637,7 @@ def node_prototype_load(caller, **kwargs): options = _wizard_options("prototype_load", "prototype_save", "index") options.append({"key": "_default", - "goto": _prototype_parent_examine}) + "goto": _prototype_parent_actions}) return text, options @@ -1468,6 +1695,7 @@ def start_olc(caller, session=None, prototype=None): """ menudata = {"node_index": node_index, "node_validate_prototype": node_validate_prototype, + "node_examine_entity": node_examine_entity, "node_prototype_key": node_prototype_key, "node_prototype_parent": node_prototype_parent, "node_typeclass": node_typeclass, diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 767919a7a9..4c0a2d3186 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -606,6 +606,8 @@ def validate_prototype(prototype, protkey=None, protparents=None, _flags['errors'].append( "{} has infinite nesting of prototypes.".format(protkey or prototype)) + if _flags['errors']: + raise RuntimeError("Error: " + "\nError: ".join(_flags['errors'])) _flags['visited'].append(id(prototype)) _flags['depth'] += 1 validate_prototype(protparent, protstring, protparents, @@ -618,7 +620,7 @@ def validate_prototype(prototype, protkey=None, protparents=None, # if we get back to the current level without a typeclass it's an error. if strict and is_prototype_base and _flags['depth'] <= 0 and not _flags['typeclass']: - _flags['errors'].append("Prototype {} has no `typeclass` defined anywhere in its parent " + _flags['errors'].append("Prototype {} has no `typeclass` defined anywhere in its parent\n " "chain. Add `typeclass`, or a `prototype_parent` pointing to a " "prototype with a typeclass.".format(protkey)) diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 221200672d..4b16ad9ab2 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -384,6 +384,14 @@ class TestMenuModule(EvenniaTest): {"prototype_key": "testthing", "key": "mytest"}), (True, Something)) + choices = ["test1", "test2", "test3", "test4"] + actions = (("examine", "e", "l"), ("add", "a"), ("foo", "f")) + self.assertEqual(olc_menus._default_parse("l4", choices, *actions), ('test4', 'examine')) + self.assertEqual(olc_menus._default_parse("add 2", choices, *actions), ('test2', 'add')) + self.assertEqual(olc_menus._default_parse("foo3", choices, *actions), ('test3', 'foo')) + self.assertEqual(olc_menus._default_parse("f3", choices, *actions), ('test3', 'foo')) + self.assertEqual(olc_menus._default_parse("f5", choices, *actions), (None, None)) + def test_node_helpers(self): caller = self.caller @@ -399,15 +407,20 @@ class TestMenuModule(EvenniaTest): # prototype_parent helpers self.assertEqual(olc_menus._all_prototype_parents(caller), ['test_prot']) - self.assertEqual(olc_menus._prototype_parent_examine( - caller, 'test_prot'), - "|cprototype key:|n test_prot, |ctags:|n None, |clocks:|n edit:all();spawn:all() " - "\n|cdesc:|n None \n|cprototype:|n " - "{\n 'typeclass': 'evennia.objects.objects.DefaultObject', \n}") - self.assertEqual(olc_menus._prototype_parent_select(caller, self.test_prot), "node_key") + # self.assertEqual(olc_menus._prototype_parent_parse( + # caller, 'test_prot'), + # "|cprototype key:|n test_prot, |ctags:|n None, |clocks:|n edit:all();spawn:all() " + # "\n|cdesc:|n None \n|cprototype:|n " + # "{\n 'typeclass': 'evennia.objects.objects.DefaultObject', \n}") + + with mock.patch("evennia.prototypes.menus.protlib.search_prototype", + new=mock.MagicMock(return_value=[_PROTPARENTS['GOBLIN']])): + self.assertEqual(olc_menus._prototype_parent_select(caller, "goblin"), "node_prototype_parent") + self.assertEqual(olc_menus._get_menu_prototype(caller), {'prototype_key': 'test_prot', 'prototype_locks': 'edit:all();spawn:all()', + 'prototype_parent': 'goblin', 'typeclass': 'evennia.objects.objects.DefaultObject'}) # typeclass helpers @@ -423,6 +436,7 @@ class TestMenuModule(EvenniaTest): self.assertEqual(olc_menus._get_menu_prototype(caller), {'prototype_key': 'test_prot', 'prototype_locks': 'edit:all();spawn:all()', + 'prototype_parent': 'goblin', 'typeclass': 'evennia.objects.objects.DefaultObject'}) # attr helpers @@ -459,7 +473,9 @@ class TestMenuModule(EvenniaTest): protlib.save_prototype(**self.test_prot) # spawn helpers - obj = olc_menus._spawn(caller, prototype=self.test_prot) + with mock.patch("evennia.prototypes.menus.protlib.search_prototype", + new=mock.MagicMock(return_value=[_PROTPARENTS['GOBLIN']])): + obj = olc_menus._spawn(caller, prototype=self.test_prot) self.assertEqual(obj.typeclass_path, "evennia.objects.objects.DefaultObject") self.assertEqual(obj.tags.get(category=spawner._PROTOTYPE_TAG_CATEGORY), self.test_prot['prototype_key']) @@ -475,7 +491,6 @@ class TestMenuModule(EvenniaTest): self.assertEqual(olc_menus._prototype_load_select(caller, self.test_prot['prototype_key']), "node_index") - @mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock( return_value=[{"prototype_key": "TestPrototype", "typeclass": "TypeClassTest", "key": "TestObj"}])) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 2f1b7d64fa..d21aec2c56 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -938,7 +938,7 @@ class EvMenu(object): for key, desc in optionlist: if not (key or desc): continue - desc_string = ": %s" % desc if desc else "" + 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_string.split("\n")) + colsep) From e210e87490d0b32e6e5507c00f1e0db9428b8fe5 Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 27 Jul 2018 13:34:20 +0200 Subject: [PATCH 366/466] Add tag handling in old menu --- evennia/prototypes/menus.py | 163 +++++++++++++++++++++++------------- evennia/prototypes/tests.py | 37 ++++---- 2 files changed, 123 insertions(+), 77 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 54ec054340..e5ff1179a2 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -863,7 +863,7 @@ def _add_attr(caller, attr_string, **kwargs): locks = '' if 'delete' in kwargs: - attrname = attr_string + attrname = attr_string.lower().strip() elif '=' in attr_string: attrname, value = (part.strip() for part in attr_string.split('=', 1)) attrname = attrname.lower() @@ -892,17 +892,12 @@ def _add_attr(caller, attr_string, **kwargs): # replace existing attribute with the same name in the prototype ind = [tup[0] for tup in attrs].index(attrname) attrs[ind] = attr_tuple + text = "Edited Attribute '{}'.".format(attrname) except ValueError: attrs.append(attr_tuple) + text = "Added Attribute " + _display_attribute(attr_tuple) _set_prototype_value(caller, "attrs", attrs) - - text = kwargs.get('text') - if not text: - if 'edit' in kwargs: - text = "Edited " + _display_attribute(attr_tuple) - else: - text = "Added " + _display_attribute(attr_tuple) else: text = "Attribute must be given as 'attrname[;category;locks] = '." @@ -929,7 +924,12 @@ def _attrs_actions(caller, raw_inp, **kwargs): raw_inp, choices, ('examine', 'e'), ('remove', 'r', 'delete', 'd')) if attrstr is None: attrstr = raw_inp - attrname, _ = attrstr.split("=", 1) + try: + attrname, _ = attrstr.split("=", 1) + except ValueError: + caller.msg("|rNeed to enter the attribute on the form attrname=value.|n") + return "node_attrs" + attrname = attrname.strip() attr_tup = _get_tup_by_attrname(caller, attrname) @@ -938,7 +938,7 @@ def _attrs_actions(caller, raw_inp, **kwargs): return "node_examine_entity", \ {"text": _display_attribute(attr_tup), "back": "attrs"} elif action == 'remove': - res = _add_attr(caller, attr_tup, delete=True) + res = _add_attr(caller, attrname, delete=True) caller.msg(res) else: res = _add_attr(caller, raw_inp) @@ -964,9 +964,9 @@ def node_attrs(caller): helptext = """ Most commonly, Attributes don't need any categories or locks. If using locks, the lock-types - 'attredit', 'attrread' are used to limiting editing and viewing of the Attribute. Putting + 'attredit' and 'attrread' are used to limit editing and viewing of the Attribute. Putting the lock-type `attrcreate` in the |clocks|n prototype key can be used to restrict builders - to add new Attributes. + from adding new Attributes. |c$protfuncs @@ -986,83 +986,128 @@ def node_attrs(caller): def _caller_tags(caller): prototype = _get_menu_prototype(caller) - tags = prototype.get("tags", []) + tags = [tup[0] for tup in prototype.get("tags", [])] return tags +def _get_tup_by_tagname(caller, tagname): + prototype = _get_menu_prototype(caller) + tags = prototype.get("tags", []) + try: + inp = [tup[0] for tup in tags].index(tagname) + return tags[inp] + except ValueError: + return None + + def _display_tag(tag_tuple): - """Pretty-print attribute tuple""" + """Pretty-print tag tuple""" tagkey, category, data = tag_tuple out = ("Tag: '{tagkey}' (category: {category}{dat})".format( tagkey=tagkey, category=category, dat=", data: {}".format(data) if data else "")) return out -def _add_tag(caller, tag, **kwargs): +def _add_tag(caller, tag_string, **kwargs): """ - Add tags to the system, parsing this syntax: - tagname - tagname;category - tagname;category;data + Add tags to the system, parsing input + + Args: + caller (Object): Caller of menu. + tag_string (str): Input from user on one of these forms + tagname + tagname;category + tagname;category;data + + Kwargs: + delete (str): If this is set, tag_string is considered + the name of the tag to delete. + + Returns: + result (str): Result string of action. """ - - tag = tag.strip().lower() + tag = tag_string.strip().lower() category = None data = "" - tagtuple = tag.split(";", 2) - ntuple = len(tagtuple) + if 'delete' in kwargs: + tag = tag_string.lower().strip() + else: + nameparts = tag.split(";", 2) + ntuple = len(nameparts) + if ntuple == 2: + tag, category = nameparts + elif ntuple > 2: + tag, category, data = nameparts[:3] - if ntuple == 2: - tag, category = tagtuple - elif ntuple > 2: - tag, category, data = tagtuple - - tag_tuple = (tag, category, data) + tag_tuple = (tag.lower(), category.lower() if category else None, data) if tag: prot = _get_menu_prototype(caller) tags = prot.get('tags', []) - old_tag = kwargs.get("edit", None) + old_tag = _get_tup_by_tagname(caller, tag) - if not old_tag: + if 'delete' in kwargs: + + if old_tag: + tags.pop(tags.index(old_tag)) + text = "Removed tag '{}'".format(tag) + else: + text = "Found no tag to remove." + elif not old_tag: # a fresh, new tag tags.append(tag_tuple) + text = "Added Tag '{}'".format(tag) else: - # old tag exists; editing a tag means removing the old and replacing with new - try: - ind = [tup[0] for tup in tags].index(old_tag) - del tags[ind] - if tags: - tags.insert(ind, tag_tuple) - else: - tags = [tag_tuple] - except IndexError: - pass + # old tag exists; editing a tag means replacing old with new + ind = tags.index(old_tag) + tags[ind] = tag_tuple + text = "Edited Tag '{}'".format(tag) _set_prototype_value(caller, "tags", tags) - - text = kwargs.get('text') - if not text: - if 'edit' in kwargs: - text = "Edited " + _display_tag(tag_tuple) - else: - text = "Added " + _display_tag(tag_tuple) else: - text = "Tag must be given as 'tag[;category;data]." + text = "Tag must be given as 'tag[;category;data]'." - options = {"key": "_default", - "goto": lambda caller: None} - return text, options + return text -def _edit_tag(caller, old_tag, new_tag, **kwargs): - return _add_tag(caller, new_tag, edit=old_tag) +def _tag_select(caller, tagname): + tag_tup = _get_tup_by_tagname(caller, tagname) + if tag_tup: + return "node_examine_entity", \ + {"text": _display_tag(tag_tup), "back": "attrs"} + else: + caller.msg("Tag not found.") + return "node_attrs" -@list_node(_caller_tags) +def _tags_actions(caller, raw_inp, **kwargs): + """Parse actions for tags listing""" + choices = kwargs.get("available_choices", []) + tagname, action = _default_parse( + raw_inp, choices, ('examine', 'e'), ('remove', 'r', 'delete', 'd')) + + if tagname is None: + tagname = raw_inp.lower().strip() + + tag_tup = _get_tup_by_tagname(caller, tagname) + + if tag_tup: + if action == 'examine': + return "node_examine_entity", \ + {"text": _display_tag(tag_tup), 'back': 'tags'} + elif action == 'remove': + res = _add_tag(caller, tagname, delete=True) + caller.msg(res) + else: + res = _add_tag(caller, raw_inp) + caller.msg(res) + return "node_tags" + + +@list_node(_caller_tags, _tag_select) def node_tags(caller): text = """ |cTags|n are used to group objects so they can quickly be found later. Enter tags on one of @@ -1071,8 +1116,8 @@ def node_tags(caller): tagname;category tagname;category;data - {current} - """.format(current=_get_current_value(caller, 'tags')) + {actions} + """.format(actions=_format_list_actions("examine", "remove", prefix="Actions: ")) helptext = """ Tags are shared between all objects with that tag. So the 'data' field (which is not @@ -1082,10 +1127,12 @@ def node_tags(caller): All objects created with this prototype will automatically get assigned a tag named the same as the |cprototype_key|n and with a category "{tag_category}". This allows the spawner to optionally update previously spawned objects when their prototype changes. - """.format(protlib._PROTOTYPE_TAG_CATEGORY) + """.format(tag_category=protlib._PROTOTYPE_TAG_CATEGORY) text = (text, helptext) options = _wizard_options("tags", "attrs", "locks") + options.append({"key": "_default", + "goto": _tags_actions}) return text, options diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 4b16ad9ab2..299495628e 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -427,8 +427,6 @@ class TestMenuModule(EvenniaTest): with mock.patch("evennia.utils.utils.get_all_typeclasses", new=mock.MagicMock(return_value={"foo": None, "bar": None})): self.assertEqual(olc_menus._all_typeclasses(caller), ["bar", "foo"]) - self.assertTrue(olc_menus._typeclass_examine( - caller, "evennia.objects.objects.DefaultObject").startswith("Typeclass |y")) self.assertEqual(olc_menus._typeclass_select( caller, "evennia.objects.objects.DefaultObject"), "node_key") @@ -441,34 +439,35 @@ class TestMenuModule(EvenniaTest): # attr helpers self.assertEqual(olc_menus._caller_attrs(caller), []) - self.assertEqual(olc_menus._add_attr(caller, "test1=foo1"), (Something, {"key": "_default", "goto": Something})) - self.assertEqual(olc_menus._add_attr(caller, "test2;cat1=foo2"), (Something, {"key": "_default", "goto": Something})) - self.assertEqual(olc_menus._add_attr(caller, "test3;cat2;edit:false()=foo3"), (Something, {"key": "_default", "goto": Something})) - self.assertEqual(olc_menus._add_attr(caller, "test4;cat3;set:true();edit:false()=foo4"), (Something, {"key": "_default", "goto": Something})) - self.assertEqual(olc_menus._add_attr(caller, "test5;cat4;set:true();edit:false()=123"), (Something, {"key": "_default", "goto": Something})) - self.assertEqual(olc_menus._caller_attrs( - caller), + self.assertEqual(olc_menus._add_attr(caller, "test1=foo1"), Something) + self.assertEqual(olc_menus._add_attr(caller, "test2;cat1=foo2"), Something) + self.assertEqual(olc_menus._add_attr(caller, "test3;cat2;edit:false()=foo3"), Something) + self.assertEqual(olc_menus._add_attr(caller, "test4;cat3;set:true();edit:false()=foo4"), Something) + self.assertEqual(olc_menus._add_attr(caller, "test5;cat4;set:true();edit:false()=123"), Something) + self.assertEqual(olc_menus._get_menu_prototype(caller)['attrs'], [("test1", "foo1", None, ''), ("test2", "foo2", "cat1", ''), ("test3", "foo3", "cat2", "edit:false()"), ("test4", "foo4", "cat3", "set:true();edit:false()"), ("test5", '123', "cat4", "set:true();edit:false()")]) - self.assertEqual(olc_menus._edit_attr(caller, "test1", "1;cat5;edit:all()"), (Something, {"key": "_default", "goto": Something})) - self.assertEqual(olc_menus._examine_attr(caller, "test1"), Something) # tag helpers self.assertEqual(olc_menus._caller_tags(caller), []) - self.assertEqual(olc_menus._add_tag(caller, "foo1"), (Something, {"key": "_default", "goto": Something})) - self.assertEqual(olc_menus._add_tag(caller, "foo2;cat1"), (Something, {"key": "_default", "goto": Something})) - self.assertEqual(olc_menus._add_tag(caller, "foo3;cat2;dat1"), (Something, {"key": "_default", "goto": Something})) - self.assertEqual(olc_menus._caller_tags( - caller), + self.assertEqual(olc_menus._add_tag(caller, "foo1"), Something) + self.assertEqual(olc_menus._add_tag(caller, "foo2;cat1"), Something) + self.assertEqual(olc_menus._add_tag(caller, "foo3;cat2;dat1"), Something) + self.assertEqual(olc_menus._caller_tags(caller), ['foo1', 'foo2', 'foo3']) + self.assertEqual(olc_menus._get_menu_prototype(caller)['tags'], [('foo1', None, ""), ('foo2', 'cat1', ""), ('foo3', 'cat2', "dat1")]) - self.assertEqual(olc_menus._edit_tag(caller, "foo1", "bar1;cat1"), (Something, {"key": "_default", "goto": Something})) - self.assertEqual(olc_menus._display_tag(olc_menus._caller_tags(caller)[0]), Something) - self.assertEqual(olc_menus._caller_tags(caller)[0], ("bar1", "cat1", "")) + self.assertEqual(olc_menus._add_tag(caller, "foo1", delete=True), "Removed tag 'foo1'") + self.assertEqual(olc_menus._get_menu_prototype(caller)['tags'], + [('foo2', 'cat1', ""), + ('foo3', 'cat2', "dat1")]) + + self.assertEqual(olc_menus._display_tag(olc_menus._get_menu_prototype(caller)['tags'][0]), Something) + self.assertEqual(olc_menus._caller_tags(caller), ["foo2", "foo3"]) protlib.save_prototype(**self.test_prot) From 69ae055e20f247bcf2325d4052131b637f266a39 Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 27 Jul 2018 18:46:30 +0200 Subject: [PATCH 367/466] Refactor locks and permissions in olc menu --- evennia/prototypes/menus.py | 185 +++++++++++++++++++++++++++++++----- evennia/prototypes/tests.py | 14 ++- 2 files changed, 173 insertions(+), 26 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index e5ff1179a2..138f97cf13 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -236,11 +236,13 @@ def _format_protfuncs(): def _format_lockfuncs(): out = [] sorted_funcs = [(key, func) for key, func in - sorted(get_all_lockfuncs(), key=lambda tup: tup[0])] + sorted(get_all_lockfuncs().items(), key=lambda tup: tup[0])] for lockfunc_name, lockfunc in sorted_funcs: + doc = (lockfunc.__doc__ or "").strip() out.append("- |c${name}|n - |W{docs}".format( name=lockfunc_name, - docs=utils.justify(lockfunc.__doc__.strip(), align='l', indent=10).strip())) + docs=utils.justify(doc, align='l', indent=10).strip())) + return "\n".join(out) def _format_list_actions(*args, **kwargs): @@ -769,7 +771,7 @@ def _aliases_actions(caller, raw_inp, **kwargs): else: # if not a valid remove, add as a new alias alias = raw_inp.lower().strip() - if alias not in aliases: + if alias and alias not in aliases: aliases.append(alias) _set_prototype_value(caller, "aliases", aliases) caller.msg("Added alias '{}'.".format(alias)) @@ -786,7 +788,7 @@ def node_aliases(caller): case sensitive. {actions} - """.format(_format_list_actions("remove", prefix="|w|W to add new alias. Other action: ")) + """.format(actions=_format_list_actions("remove", prefix="|w|W to add new alias. Other action: ")) helptext = """ Aliases are fixed alternative identifiers and are stored with the new object. @@ -1053,9 +1055,9 @@ def _add_tag(caller, tag_string, **kwargs): if old_tag: tags.pop(tags.index(old_tag)) - text = "Removed tag '{}'".format(tag) + text = "Removed Tag '{}'.".format(tag) else: - text = "Found no tag to remove." + text = "Found no Tag to remove." elif not old_tag: # a fresh, new tag tags.append(tag_tuple) @@ -1138,7 +1140,80 @@ def node_tags(caller): # locks node +def _caller_locks(caller): + locks = _get_menu_prototype(caller).get("locks", "") + return [lck for lck in locks.split(";") if lck] + +def _locks_display(caller, lock): + try: + locktype, lockdef = lock.split(":", 1) + except ValueError: + txt = "Malformed lock string - Missing ':'" + else: + txt = ("{lockstr}\n\n" + "|WLocktype: |w{locktype}|n\n" + "|WLock def: |w{lockdef}|n\n").format( + lockstr=lock, + locktype=locktype, + lockdef=lockdef) + return txt + + +def _lock_select(caller, lockstr): + return "node_examine_entity", {"text": _locks_display(caller, lockstr), "back": "locks"} + + +def _lock_add(caller, lock, **kwargs): + locks = _caller_locks(caller) + + try: + locktype, lockdef = lock.split(":", 1) + except ValueError: + return "Lockstring lacks ':'." + + locktype = locktype.strip().lower() + + if 'delete' in kwargs: + try: + ind = locks.index(lock) + locks.pop(ind) + _set_prototype_value(caller, "locks", ";".join(locks), parse=False) + ret = "Lock {} deleted.".format(lock) + except ValueError: + ret = "No lock found to delete." + return ret + try: + locktypes = [lck.split(":", 1)[0].strip().lower() for lck in locks] + ind = locktypes.index(locktype) + locks[ind] = lock + ret = "Lock with locktype '{}' updated.".format(locktype) + except ValueError: + locks.append(lock) + ret = "Added lock '{}'.".format(lock) + _set_prototype_value(caller, "locks", ";".join(locks)) + return ret + + +def _locks_actions(caller, raw_inp, **kwargs): + choices = kwargs.get("available_choices", []) + lock, action = _default_parse( + raw_inp, choices, ("examine", "e"), ("remove", "r", "delete", "d")) + + if lock: + if action == 'examine': + return "node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"} + elif action == 'remove': + ret = _lock_add(caller, lock, delete=True) + caller.msg(ret) + else: + ret = _lock_add(caller, raw_inp) + caller.msg(ret) + + return "node_locks" + + +@list_node(_caller_locks, _lock_select) def node_locks(caller): text = """ @@ -1148,22 +1223,21 @@ def node_locks(caller): locktype:[NOT] lockfunc(args) locktype: [NOT] lockfunc(args) [AND|OR|NOT] lockfunc(args) [AND|OR|NOT] ... - Separate multiple lockstrings by semicolons (;). - - {current} - """.format(current=_get_current_value(caller, 'locks')) + {action} + """.format(action=_format_list_actions("examine", "remove", prefix="Actions: ")) helptext = """ - Here is an example of a lock string constisting of two locks: + Here is an example of two lock strings: - edit:false();call:tag(Foo) OR perm(Builder) + edit:false() + call:tag(Foo) OR perm(Builder) Above locks limit two things, 'edit' and 'call'. Which lock types are actually checked depend on the typeclass of the object being spawned. Here 'edit' is never allowed by anyone while 'call' is allowed to all accessors with a |ctag|n 'Foo' OR which has the |cPermission|n 'Builder'. - |c$lockfuncs|n + |cAvailable lockfuncs:|n {lfuncs} """.format(lfuncs=_format_lockfuncs()) @@ -1172,24 +1246,87 @@ def node_locks(caller): options = _wizard_options("locks", "tags", "permissions") options.append({"key": "_default", - "goto": (_set_property, - dict(prop="locks", - processor=lambda s: s.strip(), - next_node="node_permissions"))}) + "goto": _locks_actions}) + return text, options # permissions node +def _caller_permissions(caller): + prototype = _get_menu_prototype(caller) + perms = prototype.get("permissions", []) + return perms + +def _display_perm(caller, permission): + hierarchy = settings.PERMISSION_HIERARCHY + perm_low = permission.lower() + if perm_low in [prm.lower() for prm in hierarchy]: + txt = "Permission (in hieararchy): {}".format( + ", ".join( + ["|w[{}]|n".format(prm) + if prm.lower() == perm_low else "|W{}|n".format(prm) + for prm in hierarchy])) + else: + txt = "Permission: '{}'".format(permission) + return txt + + +def _permission_select(caller, permission, **kwargs): + return "node_examine_entity", {"text": _display_perm(caller, permission), "back": "permissions"} + + +def _add_perm(caller, perm, **kwargs): + if perm: + perm_low = perm.lower() + perms = _caller_permissions(caller) + perms_low = [prm.lower() for prm in perms] + if 'delete' in kwargs: + try: + ind = perms_low.index(perm_low) + del perms[ind] + text = "Removed Permission '{}'.".format(perm) + except ValueError: + text = "Found no Permission to remove." + else: + if perm_low in perms_low: + text = "Permission already set." + else: + perms.append(perm) + _set_prototype_value(caller, "permissions", perms) + text = "Added Permission '{}'".format(perm) + return text + + +def _permissions_actions(caller, raw_inp, **kwargs): + """Parse actions for permission listing""" + choices = kwargs.get("available_choices", []) + perm, action = _default_parse( + raw_inp, choices, ('examine', 'e'), ('remove', 'r', 'delete', 'd')) + + if perm: + if action == 'examine': + return "node_examine_entity", \ + {"text": _display_perm(caller, perm), "back": "permissions"} + elif action == 'remove': + res = _add_perm(caller, perm, delete=True) + caller.msg(res) + else: + res = _add_perm(caller, raw_inp.strip()) + caller.msg(res) + return "node_permissions" + + +@list_node(_caller_permissions, _permission_select) def node_permissions(caller): text = """ |cPermissions|n are simple strings used to grant access to this object. A permission is used when a |clock|n is checked that contains the |wperm|n or |wpperm|n lock functions. - {current} - """.format(current=_get_current_value(caller, "permissions")) + {actions} + """.format(actions=_format_list_actions("examine", "remove"), prefix="Actions: ") helptext = """ Any string can act as a permission as long as a lock is set to look for it. Depending on the @@ -1201,16 +1338,14 @@ def node_permissions(caller): For example, a |clock|n string like "edit:perm(Builder)" will grant access to accessors having the |cpermission|n "Builder" or higher. - """.format(settings.PERMISSION_HIERARCHY) + """.format(permissions=", ".join(settings.PERMISSION_HIERARCHY)) text = (text, helptext) - options = _wizard_options("permissions", "destination", "location") + options = _wizard_options("permissions", "locks", "location") options.append({"key": "_default", - "goto": (_set_property, - dict(prop="permissions", - processor=lambda s: [part.strip() for part in s.split(",")], - next_node="node_location"))}) + "goto": _permissions_actions}) + return text, options diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 299495628e..92b8e85a65 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -461,7 +461,7 @@ class TestMenuModule(EvenniaTest): [('foo1', None, ""), ('foo2', 'cat1', ""), ('foo3', 'cat2', "dat1")]) - self.assertEqual(olc_menus._add_tag(caller, "foo1", delete=True), "Removed tag 'foo1'") + self.assertEqual(olc_menus._add_tag(caller, "foo1", delete=True), "Removed Tag 'foo1'.") self.assertEqual(olc_menus._get_menu_prototype(caller)['tags'], [('foo2', 'cat1', ""), ('foo3', 'cat2', "dat1")]) @@ -471,6 +471,18 @@ class TestMenuModule(EvenniaTest): protlib.save_prototype(**self.test_prot) + # locks helpers + self.assertEqual(olc_menus._lock_add(caller, "foo:false()"), "Added lock 'foo:false()'.") + self.assertEqual(olc_menus._lock_add(caller, "foo2:false()"), "Added lock 'foo2:false()'.") + self.assertEqual(olc_menus._lock_add(caller, "foo2:true()"), "Lock with locktype 'foo2' updated.") + self.assertEqual(olc_menus._get_menu_prototype(caller)["locks"], "foo:false();foo2:true()") + + # perm helpers + self.assertEqual(olc_menus._add_perm(caller, "foo"), "Added Permission 'foo'") + self.assertEqual(olc_menus._add_perm(caller, "foo2"), "Added Permission 'foo2'") + self.assertEqual(olc_menus._get_menu_prototype(caller)["permissions"], ["foo", "foo2"]) + + # spawn helpers with mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock(return_value=[_PROTPARENTS['GOBLIN']])): From e5f63f84313737d5304d415382101aed3acf24ce Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 28 Jul 2018 18:26:00 +0200 Subject: [PATCH 368/466] Add search-object functionality to olc menu --- evennia/prototypes/menus.py | 233 +++++++++++++++++++++++++------ evennia/prototypes/prototypes.py | 79 +++++++++-- evennia/prototypes/spawner.py | 13 +- evennia/utils/evmenu.py | 7 +- 4 files changed, 266 insertions(+), 66 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 138f97cf13..ab66363c71 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -7,7 +7,9 @@ OLC Prototype menu nodes import json import re from random import choice +from django.db.models import Q from django.conf import settings +from evennia.objects.models import ObjectDB from evennia.utils.evmenu import EvMenu, list_node from evennia.utils import evmore from evennia.utils.ansi import strip_ansi @@ -273,7 +275,11 @@ def _get_current_value(caller, keyname, formatter=str): flat_prot = _get_flat_menu_prototype(caller) if keyname in flat_prot: # value in flattened prot - return "Current {} (|binherited|n): {}".format(keyname, formatter(flat_prot[keyname])) + if keyname == 'prototype_key': + # we don't inherit prototype_keys + return "[No prototype_key set] (|rnot inherited|n)" + else: + return "Current {} (|binherited|n): {}".format(keyname, formatter(flat_prot[keyname])) return "[No {} set]".format(keyname) @@ -305,6 +311,180 @@ def _default_parse(raw_inp, choices, *args): # Menu nodes ------------------------------ +# helper nodes + +# validate prototype (available as option from all nodes) + +def node_validate_prototype(caller, raw_string, **kwargs): + """General node to view and validate a protototype""" + prototype = _get_flat_menu_prototype(caller, validate=False) + prev_node = kwargs.get("back", "index") + + _, text = _validate_prototype(prototype) + + helptext = """ + The validator checks if the prototype's various values are on the expected form. It also tests + any $protfuncs. + + """ + + text = (text, helptext) + + options = _wizard_options(None, prev_node, None) + options.append({"key": "_default", + "goto": "node_" + prev_node}) + + return text, options + + +def node_examine_entity(caller, raw_string, **kwargs): + """ + General node to view a text and then return to previous node. Kwargs should contain "text" for + the text to show and 'back" pointing to the node to return to. + """ + text = kwargs.get("text", "Nothing was found here.") + helptext = "Use |wback|n to return to the previous node." + prev_node = kwargs.get('back', 'index') + + text = (text, helptext) + + options = _wizard_options(None, prev_node, None) + options.append({"key": "_default", + "goto": "node_" + prev_node}) + + return text, options + + +def _search_object(caller): + "update search term based on query stored on menu; store match too" + try: + searchstring = caller.ndb._menutree.olc_search_object_term.strip() + caller.ndb._menutree.olc_search_object_matches = [] + except AttributeError: + return [] + + if not searchstring: + caller.msg("Must specify a search criterion.") + return [] + + is_dbref = utils.dbref(searchstring) + is_account = searchstring.startswith("*") + + if is_dbref or is_account: + + if is_dbref: + # a dbref search + results = caller.search(searchstring, global_search=True, quiet=True) + else: + # an account search + searchstring = searchstring.lstrip("*") + results = caller.search_account(searchstring, quiet=True) + else: + keyquery = Q(db_key__istartswith=searchstring) + aliasquery = Q(db_tags__db_key__istartswith=searchstring, + db_tags__db_tagtype__iexact="alias") + results = ObjectDB.objects.filter(keyquery | aliasquery).distinct() + + caller.msg("Searching for '{}' ...".format(searchstring)) + caller.ndb._menutree.olc_search_object_matches = results + return ["{}(#{})".format(obj.key, obj.id) for obj in results] + + +def _object_select(caller, obj_entry, **kwargs): + choices = kwargs['available_choices'] + num = choices.index(obj_entry) + matches = caller.ndb._menutree.olc_search_object_matches + obj = matches[num] + + if not obj.access(caller, 'examine'): + caller.msg("|rYou don't have 'examine' access on this object.|n") + del caller.ndb._menutree.olc_search_object_term + return "node_search_object" + + prot = spawner.prototype_from_object(obj) + txt = protlib.prototype_to_str(prot) + return "node_examine_entity", {"text": txt, "back": "search_object"} + + +def _object_actions(caller, raw_inp, **kwargs): + "All this does is to queue a search query" + choices = kwargs['available_choices'] + obj_entry, action = _default_parse( + raw_inp, choices, ("examine", "e"), ("create prototype from object", "create", "c")) + + if obj_entry: + + num = choices.index(obj_entry) + matches = caller.ndb._menutree.olc_search_object_matches + obj = matches[num] + prot = spawner.prototype_from_object(obj) + + if action == "examine": + + if not obj.access(caller, 'examine'): + caller.msg("\n|rYou don't have 'examine' access on this object.|n") + del caller.ndb._menutree.olc_search_object_term + return "node_search_object" + + txt = protlib.prototype_to_str(prot) + return "node_examine_entity", {"text": txt, "back": "search_object"} + else: + # load prototype + + if not obj.access(caller, 'control'): + caller.msg("|rYou don't have access to do this with this object.|n") + del caller.ndb._menutree.olc_search_object_term + return "node_search_object" + + _set_menu_prototype(caller, prot) + caller.msg("Created prototype from object.") + return "node_index" + else: + caller.ndb._menutree.olc_search_object_term = raw_inp + return "node_search_object", kwargs + + +@list_node(_search_object, _object_select) +def node_search_object(caller, raw_inp, **kwargs): + """ + Node for searching for an existing object. + """ + try: + matches = caller.ndb._menutree.olc_search_object_matches + except AttributeError: + matches = [] + nmatches = len(matches) + prev_node = kwargs.get("back", "index") + + if matches: + text = """ + Found {num} match{post}. + + {actions} + (|RWarning: creating a prototype will |roverwrite|r |Rthe current prototype!)|n""".format( + num=nmatches, post="es" if nmatches > 1 else "", + actions=_format_list_actions( + "examine", "create prototype from object", prefix="Actions: ")) + else: + text = "Enter search criterion." + + helptext = """ + You can search objects by specifying partial key, alias or its exact #dbref. Use *query to + search for an Account instead. + + Once having found any matches you can choose to examine it or use |ccreate prototype from + object|n. If doing the latter, a prototype will be calculated from the selected object and + loaded as the new 'current' prototype. This is useful for having a base to build from but be + careful you are not throwing away any existing, unsaved, prototype work! + """ + + text = (text, helptext) + + options = _wizard_options(None, prev_node, None) + options.append({"key": "_default", + "goto": (_object_actions, {"back": prev_node})}) + + return text, options # main index (start page) node @@ -382,49 +562,9 @@ def node_index(caller): {"key": ("|wSP|Wawn prototype", "spawn", "sp"), "goto": "node_prototype_spawn"}, {"key": ("|wLO|Wad prototype", "load", "lo"), - "goto": "node_prototype_load"})) - - return text, options - - -# validate prototype (available as option from all nodes) - -def node_validate_prototype(caller, raw_string, **kwargs): - """General node to view and validate a protototype""" - prototype = _get_menu_prototype(caller) - prev_node = kwargs.get("back", "index") - - _, text = _validate_prototype(prototype) - - helptext = """ - The validator checks if the prototype's various values are on the expected form. It also tests - any $protfuncs. - - """ - - text = (text, helptext) - - options = _wizard_options(None, prev_node, None) - options.append({"key": "_default", - "goto": "node_" + prev_node}) - - return text, options - - -def node_examine_entity(caller, raw_string, **kwargs): - """ - General node to view a text and then return to previous node. Kwargs should contain "text" for - the text to show and 'back" pointing to the node to return to. - """ - text = kwargs.get("text", "Nothing was found here.") - helptext = "Use |wback|n to return to the previous node." - prev_node = kwargs.get('back', 'index') - - text = (text, helptext) - - options = _wizard_options(None, prev_node, None) - options.append({"key": "_default", - "goto": "node_" + prev_node}) + "goto": "node_prototype_load"}, + {"key": ("|wSE|Warch objects|n", "search", "se"), + "goto": "node_search_object"})) return text, options @@ -811,7 +951,7 @@ def node_aliases(caller): def _caller_attrs(caller): prototype = _get_menu_prototype(caller) - attrs = ["{}={}".format(tup[0], utils.crop(utils.to_str(tup[1]), width=10)) + attrs = ["{}={}".format(tup[0], utils.crop(utils.to_str(tup[1], force_string=True), width=10)) for tup in prototype.get("attrs", [])] return attrs @@ -1837,7 +1977,7 @@ class OLCMenu(EvMenu): """ olc_keys = ("index", "forward", "back", "previous", "next", "validate prototype", - "save prototype", "load prototype", "spawn prototype") + "save prototype", "load prototype", "spawn prototype", "search objects") olc_options = [] other_options = [] for key, desc in optionlist: @@ -1878,6 +2018,7 @@ def start_olc(caller, session=None, prototype=None): menudata = {"node_index": node_index, "node_validate_prototype": node_validate_prototype, "node_examine_entity": node_examine_entity, + "node_search_object": node_search_object, "node_prototype_key": node_prototype_key, "node_prototype_parent": node_prototype_parent, "node_typeclass": node_typeclass, diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 4c0a2d3186..8ce20d5311 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -150,7 +150,8 @@ def value_to_obj_or_any(value): stype = type(value) if is_iter(value): if stype == dict: - return {value_to_obj_or_any(key): value_to_obj_or_any(val) for key, val in value.items()} + return {value_to_obj_or_any(key): + value_to_obj_or_any(val) for key, val in value.items()} else: return stype([value_to_obj_or_any(val) for val in value]) obj = dbid_to_obj(value, ObjectDB) @@ -165,18 +166,70 @@ def prototype_to_str(prototype): prototype (dict): The prototype. """ - header = ( - "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" - "|cdesc:|n {} \n|cprototype:|n ".format( - prototype.get('prototype_key', None), - ", ".join(prototype.get('prototype_tags', ['None'])), - prototype.get('prototype_locks', None), - prototype.get('prototype_desc', None))) - proto = ("{{\n {} \n}}".format( - "\n ".join( - "{!r}: {!r},".format(key, value) for key, value in - sorted(prototype.items()) if key not in _PROTOTYPE_META_NAMES)).rstrip(",")) - return header + proto + header = """ +|cprototype-key:|n {prototype_key}, |c-tags:|n {prototype_tags}, |c-locks:|n {prototype_locks}|n +|c-desc|n: {prototype_desc} +|cprototype-parent:|n {prototype_parent} + \n""".format( + prototype_key=prototype.get('prototype_key', '|r[UNSET](required)|n'), + prototype_tags=prototype.get('prototype_tags', '|wNone|n'), + prototype_locks=prototype.get('prototype_locks', '|wNone|n'), + prototype_desc=prototype.get('prototype_desc', '|wNone|n'), + prototype_parent=prototype.get('prototype_parent', '|wNone|n')) + + key = prototype.get('key', '') + if key: + key = "|ckey:|n {key}".format(key=key) + aliases = prototype.get("aliases", '') + if aliases: + aliases = "|caliases:|n {aliases}".format( + aliases=", ".join(aliases)) + attrs = prototype.get("attrs", '') + if attrs: + out = [] + for (attrkey, value, category, locks) in attrs: + locks = ", ".join(lock for lock in locks if lock) + category = "|ccategory:|n {}".format(category) if category else '' + cat_locks = "" + if category or locks: + cat_locks = "(|ccategory:|n {category}, ".format( + category=category if category else "|wNone|n") + out.append( + "{attrkey} " + "{cat_locks}\n" + " |c=|n {value}".format( + attrkey=attrkey, + cat_locks=cat_locks, + locks=locks if locks else "|wNone|n", + value=value)) + attrs = "|cattrs:|n\n {attrs}".format(attrs="\n ".join(out)) + tags = prototype.get('tags', '') + if tags: + out = [] + for (tagkey, category, data) in tags: + out.append("{tagkey} (category: {category}{dat})".format( + tagkey=tagkey, category=category, dat=", data: {}".format(data) if data else "")) + tags = "|ctags:|n\n {tags}".format(tags="\n ".join(out)) + locks = prototype.get('locks', '') + if locks: + locks = "|clocks:|n\n {locks}".format(locks="\n ".join(locks.split(";"))) + permissions = prototype.get("permissions", '') + if permissions: + permissions = "|cpermissions:|n {perms}".format(perms=", ".join(permissions)) + location = prototype.get("location", '') + if location: + location = "|clocation:|n {location}".format(location=location) + home = prototype.get("home", '') + if home: + home = "|chome:|n {home}".format(home=home) + destination = prototype.get("destination", '') + if destination: + destination = "|cdestination:|n {destination}".format(destination=destination) + + body = "\n".join(part for part in (key, aliases, attrs, tags, locks, permissions, + location, home, destination) if part) + + return header.lstrip() + body.strip() def check_permission(prototype_key, action, default=True): diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 3dd8e11d67..1bae219368 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -150,6 +150,8 @@ def _get_prototype(dic, prot, protparents): for infinite recursion here. """ + # we don't overload the prototype_key + prototype_key = prot.get('prototype_key', None) if "prototype_parent" in dic: # move backwards through the inheritance for prototype in make_iter(dic["prototype_parent"]): @@ -157,6 +159,7 @@ def _get_prototype(dic, prot, protparents): new_prot = _get_prototype(protparents.get(prototype.lower(), {}), prot, protparents) prot.update(new_prot) prot.update(dic) + prot['prototype_key'] = prototype_key prot.pop("prototype_parent", None) # we don't need this anymore return prot @@ -217,19 +220,19 @@ def prototype_from_object(obj): location = obj.db_location if location: - prot['location'] = location + prot['location'] = location.dbref home = obj.db_home if home: - prot['home'] = home + prot['home'] = home.dbref destination = obj.db_destination if destination: - prot['destination'] = destination + prot['destination'] = destination.dbref locks = obj.locks.all() if locks: - prot['locks'] = locks + prot['locks'] = ";".join(locks) perms = obj.permissions.get() if perms: - prot['permissions'] = perms + prot['permissions'] = make_iter(perms) aliases = obj.aliases.get() if aliases: prot['aliases'] = aliases diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index d21aec2c56..a2c7429d0d 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1055,7 +1055,10 @@ def list_node(option_generator, select=None, pagesize=10): else: if callable(select): try: - return select(caller, selection) + if bool(getargspec(select).keywords): + return select(caller, selection, available_choices=available_choices) + else: + return select(caller, selection) except Exception: logger.log_trace() elif select: @@ -1124,7 +1127,7 @@ def list_node(option_generator, select=None, pagesize=10): except Exception: logger.log_trace() else: - if isinstance(decorated_options, {}): + if isinstance(decorated_options, dict): decorated_options = [decorated_options] else: decorated_options = make_iter(decorated_options) From e1cc36e4d4e00383e176ba88496d9c35726169df Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 28 Jul 2018 19:58:20 +0200 Subject: [PATCH 369/466] Complete refactoring of main nodes. Remain spawn/load/save --- evennia/prototypes/menus.py | 216 +++++++++++++++++++++++++++++------- evennia/prototypes/tests.py | 4 + evennia/utils/evmenu.py | 11 +- 3 files changed, 191 insertions(+), 40 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index ab66363c71..9024223c31 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -174,7 +174,7 @@ def _set_property(caller, raw_string, **kwargs): return next_node -def _wizard_options(curr_node, prev_node, next_node, color="|W"): +def _wizard_options(curr_node, prev_node, next_node, color="|W", search=False): """Creates default navigation options available in the wizard.""" options = [] if prev_node: @@ -195,6 +195,9 @@ def _wizard_options(curr_node, prev_node, next_node, color="|W"): if curr_node: options.append({"key": ("|wV|Walidate prototype", "validate", "v"), "goto": ("node_validate_prototype", {"back": curr_node})}) + if search: + options.append({"key": ("|wSE|Warch objects", "search object", "search", "se"), + "goto": ("node_search_object", {"back": curr_node})}) return options @@ -1495,10 +1498,11 @@ def node_permissions(caller): def node_location(caller): text = """ - The |cLocation|n of this object in the world. If not given, the object will spawn - in the inventory of |c{caller}|n instead. + The |cLocation|n of this object in the world. If not given, the object will spawn in the + inventory of |c{caller}|n by default. {current} + """.format(caller=caller.key, current=_get_current_value(caller, "location")) helptext = """ @@ -1508,11 +1512,11 @@ def node_location(caller): |c$protfuncs|n {pfuncs} - """.format(pfuncs=_format_protfuncs) + """.format(pfuncs=_format_protfuncs()) text = (text, helptext) - options = _wizard_options("location", "permissions", "home") + options = _wizard_options("location", "permissions", "home", search=True) options.append({"key": "_default", "goto": (_set_property, dict(prop="location", @@ -1529,20 +1533,27 @@ def node_home(caller): text = """ The |cHome|n location of an object is often only used as a backup - this is where the object will be moved to if its location is deleted. The home location can also be used as an actual - home for characters to quickly move back to. If unset, the global home default will be used. + home for characters to quickly move back to. + + If unset, the global home default (|w{default}|n) will be used. {current} - """.format(current=_get_current_value(caller, "home")) + """.format(default=settings.DEFAULT_HOME, + current=_get_current_value(caller, "home")) helptext = """ - The location can be specified as as #dbref but can also be explicitly searched for using - $obj(name). + The home can be given as a #dbref but can also be specified using the protfunc + '$obj(name)'. Use |wSE|nearch to find objects in the database. - The home location is often not used except as a backup. It should never be unset. - """ + The home location is commonly not used except as a backup; using the global default is often + enough. + + |c$protfuncs|n + {pfuncs} + """.format(pfuncs=_format_protfuncs()) text = (text, helptext) - options = _wizard_options("home", "aliases", "destination") + options = _wizard_options("home", "location", "destination", search=True) options.append({"key": "_default", "goto": (_set_property, dict(prop="home", @@ -1557,20 +1568,23 @@ def node_home(caller): def node_destination(caller): text = """ - The object's |cDestination|n is usually only set for Exit-like objects and designates where + The object's |cDestination|n is generally only used by Exit-like objects to designate where the exit 'leads to'. It's usually unset for all other types of objects. {current} """.format(current=_get_current_value(caller, "destination")) helptext = """ - The destination can be given as a #dbref but can also be explicitly searched for using - $obj(name). - """ + The destination can be given as a #dbref but can also be specified using the protfunc + '$obj(name)'. Use |wSEearch to find objects in the database. + + |c$protfuncs|n + {pfuncs} + """.format(pfuncs=_format_protfuncs()) text = (text, helptext) - options = _wizard_options("destination", "home", "prototype_desc") + options = _wizard_options("destination", "home", "prototype_desc", search=True) options.append({"key": "_default", "goto": (_set_property, dict(prop="dest", @@ -1585,8 +1599,7 @@ def node_destination(caller): def node_prototype_desc(caller): text = """ - The |cPrototype-Description|n optionally briefly describes the prototype when it's viewed in - listings. + The |cPrototype-Description|n briefly describes the prototype when it's viewed in listings. {current} """.format(current=_get_current_value(caller, "prototype_desc")) @@ -1602,7 +1615,7 @@ def node_prototype_desc(caller): "goto": (_set_property, dict(prop='prototype_desc', processor=lambda s: s.strip(), - next_node="node_prototype_tags"))}) + next_node="node_prototype_desc"))}) return text, options @@ -1610,14 +1623,87 @@ def node_prototype_desc(caller): # prototype_tags node +def _caller_prototype_tags(caller): + prototype = _get_menu_prototype(caller) + tags = prototype.get("prototype_tags", []) + return tags + + +def _add_prototype_tag(caller, tag_string, **kwargs): + """ + Add prototype_tags to the system. We only support straight tags, no + categories (category is assigned automatically). + + Args: + caller (Object): Caller of menu. + tag_string (str): Input from user - only tagname + + Kwargs: + delete (str): If this is set, tag_string is considered + the name of the tag to delete. + + Returns: + result (str): Result string of action. + + """ + tag = tag_string.strip().lower() + + if tag: + prot = _get_menu_prototype(caller) + tags = prot.get('prototype_tags', []) + exists = tag in tags + + if 'delete' in kwargs: + if exists: + tags.pop(tags.index(tag)) + text = "Removed Prototype-Tag '{}'.".format(tag) + else: + text = "Found no Prototype-Tag to remove." + elif not exists: + # a fresh, new tag + tags.append(tag) + text = "Added Prototype-Tag '{}'.".format(tag) + else: + text = "Prototype-Tag already added." + + _set_prototype_value(caller, "prototype_tags", tags) + else: + text = "No Prototype-Tag specified." + + return text + + +def _prototype_tag_select(caller, tagname): + caller.msg("Prototype-Tag: {}".format(tagname)) + return "node_prototype_tags" + + +def _prototype_tags_actions(caller, raw_inp, **kwargs): + """Parse actions for tags listing""" + choices = kwargs.get("available_choices", []) + tagname, action = _default_parse( + raw_inp, choices, ('remove', 'r', 'delete', 'd')) + + if tagname: + if action == 'remove': + res = _add_prototype_tag(caller, tagname, delete=True) + caller.msg(res) + else: + res = _add_prototype_tag(caller, raw_inp.lower().strip()) + caller.msg(res) + return "node_prototype_tags" + + +@list_node(_caller_prototype_tags, _prototype_tag_select) def node_prototype_tags(caller): text = """ |cPrototype-Tags|n can be used to classify and find prototypes in listings Tag names are not - case-sensitive and can have not have a custom category. Separate multiple tags by commas. + case-sensitive and can have not have a custom category. - {current} - """.format(current=_get_current_value(caller, "prototype_tags")) + {actions} + """.format(actions=_format_list_actions( + "remove", prefix="|w|n|W to add Tag. Other Action:|n ")) helptext = """ Using prototype-tags is a good way to organize and group large numbers of prototypes by genre, type etc. Under the hood, prototypes' tags will all be stored with the category @@ -1628,17 +1714,73 @@ def node_prototype_tags(caller): options = _wizard_options("prototype_tags", "prototype_desc", "prototype_locks") options.append({"key": "_default", - "goto": (_set_property, - dict(prop="prototype_tags", - processor=lambda s: [ - str(part.strip().lower()) for part in s.split(",")], - next_node="node_prototype_locks"))}) + "goto": _prototype_tags_actions}) + return text, options # prototype_locks node +def _caller_prototype_locks(caller): + locks = _get_menu_prototype(caller).get("prototype_locks", "") + return [lck for lck in locks.split(";") if lck] + + +def _prototype_lock_select(caller, lockstr): + return "node_examine_entity", {"text": _locks_display(caller, lockstr), "back": "prototype_locks"} + + +def _prototype_lock_add(caller, lock, **kwargs): + locks = _caller_prototype_locks(caller) + + try: + locktype, lockdef = lock.split(":", 1) + except ValueError: + return "Lockstring lacks ':'." + + locktype = locktype.strip().lower() + + if 'delete' in kwargs: + try: + ind = locks.index(lock) + locks.pop(ind) + _set_prototype_value(caller, "prototype_locks", ";".join(locks), parse=False) + ret = "Prototype-lock {} deleted.".format(lock) + except ValueError: + ret = "No Prototype-lock found to delete." + return ret + try: + locktypes = [lck.split(":", 1)[0].strip().lower() for lck in locks] + ind = locktypes.index(locktype) + locks[ind] = lock + ret = "Prototype-lock with locktype '{}' updated.".format(locktype) + except ValueError: + locks.append(lock) + ret = "Added Prototype-lock '{}'.".format(lock) + _set_prototype_value(caller, "prototype_locks", ";".join(locks)) + return ret + + +def _prototype_locks_actions(caller, raw_inp, **kwargs): + choices = kwargs.get("available_choices", []) + lock, action = _default_parse( + raw_inp, choices, ("examine", "e"), ("remove", "r", "delete", "d")) + + if lock: + if action == 'examine': + return "node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"} + elif action == 'remove': + ret = _prototype_lock_add(caller, lock.strip(), delete=True) + caller.msg(ret) + else: + ret = _prototype_lock_add(caller, raw_inp.strip()) + caller.msg(ret) + + return "node_prototype_locks" + + +@list_node(_caller_prototype_locks, _prototype_lock_select) def node_prototype_locks(caller): text = """ @@ -1650,25 +1792,23 @@ def node_prototype_locks(caller): - 'edit': Who can edit the prototype. - 'spawn': Who can spawn new objects with this prototype. - If unsure, leave as default. + If unsure, keep the open defaults. - {current} - """.format(current=_get_current_value(caller, "prototype_locks")) + {actions} + """.format(actions=_format_list_actions('examine', "remove", prefix="Actions: ")) helptext = """ - Prototype locks can be used when there are different tiers of builders or for developers to - produce 'base prototypes' only meant for builders to inherit and expand on rather than - change. + Prototype locks can be used to vary access for different tiers of builders. It also allows + developers to produce 'base prototypes' only meant for builders to inherit and expand on + rather than tweak in-place. """ text = (text, helptext) options = _wizard_options("prototype_locks", "prototype_tags", "index") options.append({"key": "_default", - "goto": (_set_property, - dict(prop="prototype_locks", - processor=lambda s: s.strip().lower(), - next_node="node_index"))}) + "goto": _prototype_locks_actions}) + return text, options diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 92b8e85a65..9da6ef44bc 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -482,6 +482,10 @@ class TestMenuModule(EvenniaTest): self.assertEqual(olc_menus._add_perm(caller, "foo2"), "Added Permission 'foo2'") self.assertEqual(olc_menus._get_menu_prototype(caller)["permissions"], ["foo", "foo2"]) + # prototype_tags helpers + self.assertEqual(olc_menus._add_prototype_tag(caller, "foo"), "Added Tag 'foo'.") + self.assertEqual(olc_menus._add_prototype_tag(caller, "foo2"), "Added Tag 'foo2'.") + self.assertEqual(olc_menus._get_menu_prototype(caller)["prototype_tags"], ["foo", "foo2"]) # spawn helpers with mock.patch("evennia.prototypes.menus.protlib.search_prototype", diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index a2c7429d0d..078ddf89c6 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1117,11 +1117,18 @@ def list_node(option_generator, select=None, pagesize=10): # add data from the decorated node decorated_options = [] + supports_kwargs = bool(getargspec(func).keywords) try: - text, decorated_options = func(caller, raw_string) + if supports_kwargs: + text, decorated_options = func(caller, raw_string, **kwargs) + else: + text, decorated_options = func(caller, raw_string) except TypeError: try: - text, decorated_options = func(caller) + if supports_kwargs: + text, decorated_options = func(caller, **kwargs) + else: + text, decorated_options = func(caller) except Exception: raise except Exception: From 43868864c7c52b091f5893ce71aa4d74c2df9833 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 29 Jul 2018 10:51:52 +0200 Subject: [PATCH 370/466] Limit current view for certain fields in olc --- evennia/prototypes/menus.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 9024223c31..5c4bb200a2 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -269,11 +269,13 @@ def _format_list_actions(*args, **kwargs): return prefix + "|W,|n ".join(actions) -def _get_current_value(caller, keyname, formatter=str): +def _get_current_value(caller, keyname, formatter=str, only_inherit=False): "Return current value, marking if value comes from parent or set in this prototype" prot = _get_menu_prototype(caller) if keyname in prot: # value in current prot + if only_inherit: + return '' return "Current {}: {}".format(keyname, formatter(prot[keyname])) flat_prot = _get_flat_menu_prototype(caller) if keyname in flat_prot: @@ -282,7 +284,11 @@ def _get_current_value(caller, keyname, formatter=str): # we don't inherit prototype_keys return "[No prototype_key set] (|rnot inherited|n)" else: - return "Current {} (|binherited|n): {}".format(keyname, formatter(flat_prot[keyname])) + ret = "Current {} (|binherited|n): {}".format(keyname, formatter(flat_prot[keyname])) + if only_inherit: + return "{}\n\n".format(ret) + return ret + return "[No {} set]".format(keyname) @@ -930,7 +936,7 @@ def node_aliases(caller): |cAliases|n are alternative ways to address an object, next to its |cKey|n. Aliases are not case sensitive. - {actions} + {current}{actions} """.format(actions=_format_list_actions("remove", prefix="|w|W to add new alias. Other action: ")) helptext = """ From 07eff10564b754415c5ebd9b6c1e189d7cb8e4f4 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 29 Jul 2018 11:57:16 +0200 Subject: [PATCH 371/466] Refactor locale stepping in olc --- evennia/prototypes/menus.py | 40 +++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 5c4bb200a2..3a3a4c4ff3 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -131,7 +131,7 @@ def _set_property(caller, raw_string, **kwargs): """ prop = kwargs.get("prop", "prototype_key") processor = kwargs.get("processor", None) - next_node = kwargs.get("next_node", "node_index") + next_node = kwargs.get("next_node", None) if callable(processor): try: @@ -346,6 +346,8 @@ def node_validate_prototype(caller, raw_string, **kwargs): return text, options +# node examine_entity + def node_examine_entity(caller, raw_string, **kwargs): """ General node to view a text and then return to previous node. Kwargs should contain "text" for @@ -364,6 +366,8 @@ def node_examine_entity(caller, raw_string, **kwargs): return text, options +# node object_search + def _search_object(caller): "update search term based on query stored on menu; store match too" try: @@ -399,7 +403,7 @@ def _search_object(caller): return ["{}(#{})".format(obj.key, obj.id) for obj in results] -def _object_select(caller, obj_entry, **kwargs): +def _object_search_select(caller, obj_entry, **kwargs): choices = kwargs['available_choices'] num = choices.index(obj_entry) matches = caller.ndb._menutree.olc_search_object_matches @@ -415,12 +419,14 @@ def _object_select(caller, obj_entry, **kwargs): return "node_examine_entity", {"text": txt, "back": "search_object"} -def _object_actions(caller, raw_inp, **kwargs): +def _object_search_actions(caller, raw_inp, **kwargs): "All this does is to queue a search query" choices = kwargs['available_choices'] obj_entry, action = _default_parse( raw_inp, choices, ("examine", "e"), ("create prototype from object", "create", "c")) + raw_inp = raw_inp.strip() + if obj_entry: num = choices.index(obj_entry) @@ -448,12 +454,16 @@ def _object_actions(caller, raw_inp, **kwargs): _set_menu_prototype(caller, prot) caller.msg("Created prototype from object.") return "node_index" - else: + elif raw_inp: caller.ndb._menutree.olc_search_object_term = raw_inp return "node_search_object", kwargs + else: + # empty input - exit back to previous node + prev_node = "node_" + kwargs.get("back", "index") + return prev_node -@list_node(_search_object, _object_select) +@list_node(_search_object, _object_search_select) def node_search_object(caller, raw_inp, **kwargs): """ Node for searching for an existing object. @@ -491,7 +501,7 @@ def node_search_object(caller, raw_inp, **kwargs): options = _wizard_options(None, prev_node, None) options.append({"key": "_default", - "goto": (_object_actions, {"back": prev_node})}) + "goto": (_object_search_actions, {"back": prev_node})}) return text, options @@ -601,7 +611,7 @@ def _check_prototype_key(caller, key): caller.msg("Prototype already exists. Reloading.") return "node_index" - return _set_property(caller, key, prop='prototype_key', next_node="node_prototype_parent") + return _set_property(caller, key, prop='prototype_key') def node_prototype_key(caller): @@ -814,7 +824,7 @@ def _typeclass_actions(caller, raw_inp, **kwargs): def _typeclass_select(caller, typeclass): """Select typeclass from list and add it to prototype. Return next node to go to.""" - ret = _set_property(caller, typeclass, prop='typeclass', processor=str, next_node="node_key") + ret = _set_property(caller, typeclass, prop='typeclass', processor=str) caller.msg("Selected typeclass |c{}|n.".format(typeclass)) return ret @@ -874,8 +884,7 @@ def node_key(caller): options.append({"key": "_default", "goto": (_set_property, dict(prop="key", - processor=lambda s: s.strip(), - next_node="node_aliases"))}) + processor=lambda s: s.strip()))}) return text, options @@ -936,7 +945,7 @@ def node_aliases(caller): |cAliases|n are alternative ways to address an object, next to its |cKey|n. Aliases are not case sensitive. - {current}{actions} + {actions} """.format(actions=_format_list_actions("remove", prefix="|w|W to add new alias. Other action: ")) helptext = """ @@ -1526,8 +1535,7 @@ def node_location(caller): options.append({"key": "_default", "goto": (_set_property, dict(prop="location", - processor=lambda s: s.strip(), - next_node="node_home"))}) + processor=lambda s: s.strip()))}) return text, options @@ -1563,8 +1571,7 @@ def node_home(caller): options.append({"key": "_default", "goto": (_set_property, dict(prop="home", - processor=lambda s: s.strip(), - next_node="node_destination"))}) + processor=lambda s: s.strip()))}) return text, options @@ -1594,8 +1601,7 @@ def node_destination(caller): options.append({"key": "_default", "goto": (_set_property, dict(prop="dest", - processor=lambda s: s.strip(), - next_node="node_prototype_desc"))}) + processor=lambda s: s.strip()))}) return text, options From 16640fa9234717664c5afc03d8598225687f9241 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 29 Jul 2018 12:06:11 +0200 Subject: [PATCH 372/466] Fix destination setting in olc --- evennia/prototypes/menus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 3a3a4c4ff3..fb6543c70b 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -1600,7 +1600,7 @@ def node_destination(caller): options = _wizard_options("destination", "home", "prototype_desc", search=True) options.append({"key": "_default", "goto": (_set_property, - dict(prop="dest", + dict(prop="destination", processor=lambda s: s.strip()))}) return text, options From 612639140601621c99989ba3e82aed232bd0ddee Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 29 Jul 2018 16:53:54 +0200 Subject: [PATCH 373/466] Refactor spawn, update remaining in olc --- evennia/prototypes/menus.py | 183 +++++++++++++++++++++++----------- evennia/prototypes/spawner.py | 12 ++- evennia/utils/evmenu.py | 5 +- 3 files changed, 135 insertions(+), 65 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index fb6543c70b..8574e944cf 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -1874,10 +1874,25 @@ def node_update_objects(caller, **kwargs): diff, obj_prototype = spawner.prototype_diff_from_object(prototype, obj) text = ["Suggested changes to {} objects. ".format(len(update_objects)), - "Showing random example obj to change: {name} (#{dbref}))\n".format(obj.key, obj.dbref)] - options = [] + "Showing random example obj to change: {name} ({dbref}))\n".format( + name=obj.key, dbref=obj.dbref)] + + helptext = """ + Be careful with this operation! The upgrade mechanism will try to automatically estimate + what changes need to be applied. But the estimate is |wonly based on the analysis of one + randomly selected object|n among all objects spawned by this prototype. If that object + happens to be unusual in some way the estimate will be off and may lead to unexpected + results for other objects. Always test your objects carefully after an upgrade and + consider being conservative (switch to KEEP) or even do the update manually if you are + unsure that the results will be acceptable. """ + + options = _wizard_options("update_objects", back_node[5:], None) io = 0 for (key, inst) in sorted(((key, val) for key, val in diff.items()), key=lambda tup: tup[0]): + + if key in protlib._PROTOTYPE_META_NAMES: + continue + line = "{iopt} |w{key}|n: {old}{sep}{new} {change}" old_val = utils.crop(str(obj_prototype[key]), width=20) @@ -1907,18 +1922,11 @@ def node_update_objects(caller, **kwargs): {"key": "|wb|rack ({})".format(back_node[5:], 'b'), "goto": back_node}]) - helptext = """ - Be careful with this operation! The upgrade mechanism will try to automatically estimate - what changes need to be applied. But the estimate is |wonly based on the analysis of one - randomly selected object|n among all objects spawned by this prototype. If that object - happens to be unusual in some way the estimate will be off and may lead to unexpected - results for other objects. Always test your objects carefully after an upgrade and - consider being conservative (switch to KEEP) or even do the update manually if you are - unsure that the results will be acceptable. """ + text = "\n".join(text) - text = (text, helptext) + text = (text, helptext) - return text, options + return text, options # prototype save node @@ -1928,7 +1936,8 @@ def node_prototype_save(caller, **kwargs): """Save prototype to disk """ # these are only set if we selected 'yes' to save on a previous pass prototype = kwargs.get("prototype", None) - accept_save = kwargs.get("accept_save", False) + # set to True/False if answered, None if first pass + accept_save = kwargs.get("accept_save", None) if accept_save and prototype: # we already validated and accepted the save, so this node acts as a goto callback and @@ -1939,22 +1948,38 @@ def node_prototype_save(caller, **kwargs): spawned_objects = protlib.search_objects_with_prototype(prototype_key) nspawned = spawned_objects.count() + text = ["|gPrototype saved.|n"] + if nspawned: - text = ("Do you want to update {} object(s) " - "already using this prototype?".format(nspawned)) + text.append("\nDo you want to update {} object(s) " + "already using this prototype?".format(nspawned)) options = ( {"key": ("|wY|Wes|n", "yes", "y"), + "desc": "Go to updating screen", "goto": ("node_update_objects", {"accept_update": True, "objects": spawned_objects, "prototype": prototype, "back_node": "node_prototype_save"})}, {"key": ("[|wN|Wo|n]", "n"), - "goto": "node_spawn"}, + "desc": "Return to index", + "goto": "node_index"}, {"key": "_default", - "goto": "node_spawn"}) + "goto": "node_index"}) else: - text = "|gPrototype saved.|n" + text.append("(press Return to continue)") options = {"key": "_default", - "goto": "node_spawn"} + "goto": "node_index"} + + text = "\n".join(text) + + helptext = """ + Updating objects means that the spawner will find all objects previously created by this + prototype. You will be presented with a list of the changes the system will try to apply to + each of these objects and you can choose to customize that change if needed. If you have + done a lot of manual changes to your objects after spawning, you might want to update those + objects manually instead. + """ + + text = (text, helptext) return text, options @@ -1967,27 +1992,19 @@ def node_prototype_save(caller, **kwargs): if error: # abort save text.append( - "Validation errors were found. They need to be corrected before this prototype " - "can be saved (or used to spawn).") - options = _wizard_options("prototype_save", "prototype_locks", "index") + "\n|yValidation errors were found. They need to be corrected before this prototype " + "can be saved (or used to spawn).|n") + options = _wizard_options("prototype_save", "index", None) return "\n".join(text), options prototype_key = prototype['prototype_key'] if protlib.search_prototype(prototype_key): - text.append("Do you want to save/overwrite the existing prototype '{name}'?".format( + text.append("\nDo you want to save/overwrite the existing prototype '{name}'?".format( name=prototype_key)) else: - text.append("Do you want to save the prototype as '{name}'?".format(prototype_key)) + text.append("\nDo you want to save the prototype as '{name}'?".format(name=prototype_key)) - options = ( - {"key": ("[|wY|Wes|n]", "yes", "y"), - "goto": ("node_prototype_save", - {"accept": True, "prototype": prototype})}, - {"key": ("|wN|Wo|n", "n"), - "goto": "node_spawn"}, - {"key": "_default", - "goto": ("node_prototype_save", - {"accept": True, "prototype": prototype})}) + text = "\n".join(text) helptext = """ Saving the prototype makes it available for use later. It can also be used to inherit from, @@ -1999,6 +2016,18 @@ def node_prototype_save(caller, **kwargs): text = (text, helptext) + options = ( + {"key": ("[|wY|Wes|n]", "yes", "y"), + "desc": "Save prototype", + "goto": ("node_prototype_save", + {"accept_save": True, "prototype": prototype})}, + {"key": ("|wN|Wo|n", "n"), + "desc": "Abort and return to Index", + "goto": "node_index"}, + {"key": "_default", + "goto": ("node_prototype_save", + {"accept_save": True, "prototype": prototype})}) + return text, options @@ -2015,26 +2044,42 @@ def _spawn(caller, **kwargs): obj = spawner.spawn(prototype) if obj: obj = obj[0] - caller.msg("|gNew instance|n {key} ({dbref}) |gspawned.|n".format( - key=obj.key, dbref=obj.dbref)) + text = "|gNew instance|n {key} ({dbref}) |gspawned.|n".format( + key=obj.key, dbref=obj.dbref) else: - caller.msg("|rError: Spawner did not return a new instance.|n") - return obj + text = "|rError: Spawner did not return a new instance.|n" + return "node_examine_entity", {"text": text, "back": "prototype_spawn"} def node_prototype_spawn(caller, **kwargs): """Submenu for spawning the prototype""" prototype = _get_menu_prototype(caller) - error, text = _validate_prototype(prototype) - text = [text] + already_validated = kwargs.get("already_validated", False) + + if already_validated: + error, text = None, [] + else: + error, text = _validate_prototype(prototype) + text = [text] if error: - text.append("|rPrototype validation failed. Correct the errors before spawning.|n") - options = _wizard_options("prototype_spawn", "prototype_locks", "index") + text.append("\n|rPrototype validation failed. Correct the errors before spawning.|n") + options = _wizard_options("prototype_spawn", "index", None) return "\n".join(text), options + text = "\n".join(text) + + helptext = """ + Spawning is the act of instantiating a prototype into an actual object. As a new object is + spawned, every $protfunc in the prototype is called anew. Since this is a common thing to + do, you may also temporarily change the |clocation|n of this prototype to bypass whatever + value is set in the prototype. + + """ + text = (text, helptext) + # show spawn submenu options options = [] prototype_key = prototype['prototype_key'] @@ -2064,18 +2109,10 @@ def node_prototype_spawn(caller, **kwargs): options.append( {"desc": "Update {num} existing objects with this prototype".format(num=nspawned), "goto": ("node_update_objects", - dict(prototype=prototype, opjects=spawned_objects, - back_node="node_prototype_spawn"))}) - options.extend(_wizard_options("prototype_spawn", "prototype_save", "index")) - - helptext = """ - Spawning is the act of instantiating a prototype into an actual object. As a new object is - spawned, every $protfunc in the prototype is called anew. Since this is a common thing to - do, you may also temporarily change the |clocation|n of this prototype to bypass whatever - value is set in the prototype. - - """ - text = (text, helptext) + {"objects": list(spawned_objects), + "prototype": prototype, + "back_node": "node_prototype_spawn"})}) + options.extend(_wizard_options("prototype_spawn", "index", None)) return text, options @@ -2088,30 +2125,56 @@ def _prototype_load_select(caller, prototype_key): if matches: prototype = matches[0] _set_menu_prototype(caller, prototype) - caller.msg("|gLoaded prototype '{}'.".format(prototype_key)) - return "node_index" + return "node_examine_entity", \ + {"text": "|gLoaded prototype {}.|n".format(prototype['prototype_key']), + "back": "index"} else: caller.msg("|rFailed to load prototype '{}'.".format(prototype_key)) return None +def _prototype_load_actions(caller, raw_inp, **kwargs): + """Parse the default Convert prototype to a string representation for closer inspection""" + choices = kwargs.get("available_choices", []) + prototype, action = _default_parse( + raw_inp, choices, ("examine", "e", "l")) + + if prototype: + # a selection of parent was made + prototype = protlib.search_prototype(key=prototype)[0] + + # which action to apply on the selection + if action == 'examine': + # examine the prototype + txt = protlib.prototype_to_str(prototype) + kwargs['text'] = txt + kwargs['back'] = 'prototype_load' + return "node_examine_entity", kwargs + + return 'node_prototype_load' + + @list_node(_all_prototype_parents, _prototype_load_select) def node_prototype_load(caller, **kwargs): """Load prototype""" text = """ Select a prototype to load. This will replace any prototype currently being edited! - """ + + {actions} + """.format(actions=_format_list_actions("examine")) + helptext = """ - Loading a prototype will load it and return you to the main index. It can be a good idea to - examine the prototype before loading it. + Loading a prototype will load it and return you to the main index. It can be a good idea + to examine the prototype before loading it. """ text = (text, helptext) - options = _wizard_options("prototype_load", "prototype_save", "index") + options = _wizard_options("prototype_load", "index", None) options.append({"key": "_default", - "goto": _prototype_parent_actions}) + "goto": _prototype_load_actions}) + return text, options diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 1bae219368..f250287c8f 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -553,7 +553,9 @@ def spawn(*prototypes, **kwargs): alias_string = init_spawn_value(val, make_iter) val = prot.pop("tags", []) - tags = init_spawn_value(val, make_iter) + tags = [] + for (tag, category, data) in tags: + tags.append((init_spawn_value(val, str), category, data)) prototype_key = prototype.get('prototype_key', None) if prototype_key: @@ -567,9 +569,11 @@ def spawn(*prototypes, **kwargs): nattributes = dict((key.split("_", 1)[1], init_spawn_value(val, value_to_obj)) for key, val in prot.items() if key.startswith("ndb_")) - # the rest are attributes - val = prot.pop("attrs", []) - attributes = init_spawn_value(val, list) + # the rest are attribute tuples (attrname, value, category, locks) + val = make_iter(prot.pop("attrs", [])) + attributes = [] + for (attrname, value, category, locks) in val: + attributes.append((attrname, init_spawn_value(val), category, locks)) simple_attributes = [] for key, value in ((key, value) for key, value in prot.items() diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 078ddf89c6..9941c81b11 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -938,7 +938,7 @@ class EvMenu(object): for key, desc in optionlist: if not (key or desc): continue - desc_string = ": %s" % (desc if desc else "") + 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_string.split("\n")) + colsep) @@ -1140,9 +1140,12 @@ def list_node(option_generator, select=None, pagesize=10): decorated_options = make_iter(decorated_options) extra_options = [] + if isinstance(decorated_options, dict): + decorated_options = [decorated_options] for eopt in decorated_options: cback = ("goto" in eopt and "goto") or ("exec" in eopt and "exec") or None if cback: + print("eopt, cback: {} {}".format(eopt, cback)) signature = eopt[cback] if callable(signature): # callable with no kwargs defined From 696cb3942b04e42a15aa855e8126d4b57808bf67 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 29 Jul 2018 20:58:56 +0200 Subject: [PATCH 374/466] Further cleanup and debugging of olc menu --- evennia/prototypes/menus.py | 99 ++++++++++++++++++++------------ evennia/prototypes/prototypes.py | 12 ++-- evennia/utils/evmenu.py | 1 - 3 files changed, 69 insertions(+), 43 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 8574e944cf..22a07903c3 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -518,14 +518,11 @@ def node_index(caller): can be hard-coded or scripted using |w$protfuncs|n - for example to randomize the value every time the prototype is used to spawn a new entity. - The prototype fields named 'prototype_*' are not used to create the entity itself but for - organizing the template when saving it for you (and maybe others) to use later. + The prototype fields whose names start with 'Prototype-' are not fields on the object itself + but are used in the template and when saving it for you (and maybe others) to use later. + Select prototype field to edit. If you are unsure, start from [|w1|n]. Enter [|wh|n]elp at + any menu node for more info. - Select prototype field to edit. If you are unsure, start from [|w1|n]. At any time you can - [|wV|n]alidate that the prototype works correctly and use it to [|wSP|n]awn a new entity. You - can also [|wSA|n]ve|n your work or [|wLO|n]oad an existing prototype to use as a base. Use - [|wL|n]ook to re-show a menu node. [|wQ|n]uit will always exit the menu and [|wH|n]elp will - show context-sensitive help. """ helptxt = """ |c- prototypes |n @@ -537,6 +534,13 @@ def node_index(caller): to spawn goblins with different names, looks, equipment and skill, each based on the same `Goblin` typeclass. + At any time you can [|wV|n]alidate that the prototype works correctly and use it to + [|wSP|n]awn a new entity. You can also [|wSA|n]ve|n your work, [|wLO|n]oad an existing + prototype to [|wSE|n]arch for existing objects to use as a base. Use [|wL|n]ook to re-show a + menu node. [|wQ|n]uit will always exit the menu and [|wH|n]elp will show context-sensitive + help. + + |c- $protfuncs |n Prototype-functions (protfuncs) allow for limited scripting within a prototype. These are @@ -553,11 +557,11 @@ def node_index(caller): {"desc": "|WPrototype-Key|n|n{}".format( _format_option_value("Key", "prototype_key" not in prototype, prototype, None)), "goto": "node_prototype_key"}) - for key in ('Prototype_parent', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', + for key in ('Prototype_Parent', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', 'Permissions', 'Location', 'Home', 'Destination'): required = False cropper = None - if key in ("Prototype_parent", "Typeclass"): + if key in ("Prototype_Parent", "Typeclass"): required = ("prototype_parent" not in prototype) and ("typeclass" not in prototype) if key == 'Typeclass': cropper = _path_cropper @@ -1827,7 +1831,7 @@ def node_prototype_locks(caller): # update existing objects node -def _update_spawned(caller, **kwargs): +def _apply_diff(caller, **kwargs): """update existing objects""" prototype = kwargs['prototype'] objects = kwargs['objects'] @@ -1844,7 +1848,7 @@ def _keep_diff(caller, **kwargs): diff[key] = "KEEP" -def node_update_objects(caller, **kwargs): +def node_apply_diff(caller, **kwargs): """Offer options for updating objects""" def _keep_option(keyname, prototype, obj, obj_prototype, diff, objects, back_node): @@ -1886,8 +1890,9 @@ def node_update_objects(caller, **kwargs): consider being conservative (switch to KEEP) or even do the update manually if you are unsure that the results will be acceptable. """ - options = _wizard_options("update_objects", back_node[5:], None) - io = 0 + options = [] + + ichanges = 0 for (key, inst) in sorted(((key, val) for key, val in diff.items()), key=lambda tup: tup[0]): if key in protlib._PROTOTYPE_META_NAMES: @@ -1897,30 +1902,40 @@ def node_update_objects(caller, **kwargs): old_val = utils.crop(str(obj_prototype[key]), width=20) if inst == "KEEP": - text.append(line.format(iopt='', key=key, old=old_val, sep=" ", new='', change=inst)) + inst = "|b{}|n".format(inst) + text.append(line.format(iopt='', key=key, old=old_val, + sep=" ", new='', change=inst)) continue new_val = utils.crop(str(spawner.init_spawn_value(prototype[key])), width=20) - io += 1 + ichanges += 1 if inst in ("UPDATE", "REPLACE"): - text.append(line.format(iopt=io, key=key, old=old_val, + inst = "|y{}|n".format(inst) + text.append(line.format(iopt=ichanges, key=key, old=old_val, sep=" |y->|n ", new=new_val, change=inst)) options.append(_keep_option(key, prototype, obj, obj_prototype, diff, update_objects, back_node)) elif inst == "REMOVE": - text.append(line.format(iopt=io, key=key, old=old_val, + inst = "|r{}|n".format(inst) + text.append(line.format(iopt=ichanges, key=key, old=old_val, sep=" |r->|n ", new='', change=inst)) options.append(_keep_option(key, prototype, obj, obj_prototype, diff, update_objects, back_node)) options.extend( - [{"key": ("|wu|r update {} objects".format(len(update_objects)), "update", "u"), - "goto": (_update_spawned, {"prototype": prototype, "objects": update_objects, - "back_node": back_node, "diff": diff})}, - {"key": ("|wr|neset changes", "reset", "r"), - "goto": ("node_update_objects", {"prototype": prototype, "back_node": back_node, - "objects": update_objects})}, - {"key": "|wb|rack ({})".format(back_node[5:], 'b'), - "goto": back_node}]) + [{"key": ("|wu|Wupdate {} objects".format(len(update_objects)), "update", "u"), + "goto": (_apply_diff, {"prototye": prototype, "objects": update_objects, + "back_node": back_node, "diff": diff})}, + {"key": ("|wr|Wneset changes", "reset", "r"), + "goto": ("node_apply_diff", {"prototype": prototype, "back_node": back_node, + "objects": update_objects})}]) + + if ichanges < 1: + text = ["Analyzed a random sample object (out of {}) - " + "found no changes to apply.".format(len(update_objects))] + + options.extend(_wizard_options("update_objects", back_node[5:], None)) + options.append({"key": "_default", + "goto": back_node}) text = "\n".join(text) @@ -1956,7 +1971,7 @@ def node_prototype_save(caller, **kwargs): options = ( {"key": ("|wY|Wes|n", "yes", "y"), "desc": "Go to updating screen", - "goto": ("node_update_objects", + "goto": ("node_apply_diff", {"accept_update": True, "objects": spawned_objects, "prototype": prototype, "back_node": "node_prototype_save"})}, {"key": ("[|wN|Wo|n]", "n"), @@ -1995,6 +2010,8 @@ def node_prototype_save(caller, **kwargs): "\n|yValidation errors were found. They need to be corrected before this prototype " "can be saved (or used to spawn).|n") options = _wizard_options("prototype_save", "index", None) + options.append({"key": "_default", + "goto": "node_index"}) return "\n".join(text), options prototype_key = prototype['prototype_key'] @@ -2044,8 +2061,8 @@ def _spawn(caller, **kwargs): obj = spawner.spawn(prototype) if obj: obj = obj[0] - text = "|gNew instance|n {key} ({dbref}) |gspawned.|n".format( - key=obj.key, dbref=obj.dbref) + text = "|gNew instance|n {key} ({dbref}) |gspawned at location |n{loc}|n|g.|n".format( + key=obj.key, dbref=obj.dbref, loc=prototype['location']) else: text = "|rError: Spawner did not return a new instance.|n" return "node_examine_entity", {"text": text, "back": "prototype_spawn"} @@ -2108,11 +2125,13 @@ def node_prototype_spawn(caller, **kwargs): if spawned_objects: options.append( {"desc": "Update {num} existing objects with this prototype".format(num=nspawned), - "goto": ("node_update_objects", + "goto": ("node_apply_diff", {"objects": list(spawned_objects), "prototype": prototype, "back_node": "node_prototype_spawn"})}) options.extend(_wizard_options("prototype_spawn", "index", None)) + options.append({"key": "_default", + "goto": "node_index"}) return text, options @@ -2137,19 +2156,25 @@ def _prototype_load_actions(caller, raw_inp, **kwargs): """Parse the default Convert prototype to a string representation for closer inspection""" choices = kwargs.get("available_choices", []) prototype, action = _default_parse( - raw_inp, choices, ("examine", "e", "l")) + raw_inp, choices, ("examine", "e", "l"), ("delete", "del", "d")) if prototype: - # a selection of parent was made - prototype = protlib.search_prototype(key=prototype)[0] # which action to apply on the selection if action == 'examine': # examine the prototype + prototype = protlib.search_prototype(key=prototype)[0] txt = protlib.prototype_to_str(prototype) - kwargs['text'] = txt - kwargs['back'] = 'prototype_load' - return "node_examine_entity", kwargs + return "node_examine_entity", {"text": txt, "back": 'prototype_load'} + elif action == 'delete': + # delete prototype from disk + try: + protlib.delete_prototype(prototype, caller=caller) + except protlib.PermissionError as err: + txt = "|rDeletion error:|n {}".format(err) + else: + txt = "|gPrototype {} was deleted.|n".format(prototype) + return "node_examine_entity", {"text": txt, "back": "prototype_load"} return 'node_prototype_load' @@ -2162,7 +2187,7 @@ def node_prototype_load(caller, **kwargs): Select a prototype to load. This will replace any prototype currently being edited! {actions} - """.format(actions=_format_list_actions("examine")) + """.format(actions=_format_list_actions("examine", "delete")) helptext = """ Loading a prototype will load it and return you to the main index. It can be a good idea @@ -2246,7 +2271,7 @@ def start_olc(caller, session=None, prototype=None): "node_location": node_location, "node_home": node_home, "node_destination": node_destination, - "node_update_objects": node_update_objects, + "node_apply_diff": node_apply_diff, "node_prototype_desc": node_prototype_desc, "node_prototype_tags": node_prototype_tags, "node_prototype_locks": node_prototype_locks, diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 8ce20d5311..4c53ed7d1c 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -297,10 +297,10 @@ def init_spawn_value(value, validator=None): for mod in settings.PROTOTYPE_MODULES: # to remove a default prototype, override it with an empty dict. # internally we store as (key, desc, locks, tags, prototype_dict) - prots = [(prototype_key, prot) for prototype_key, prot in all_from_module(mod).items() + prots = [(prototype_key.lower(), prot) for prototype_key, prot in all_from_module(mod).items() if prot and isinstance(prot, dict)] # assign module path to each prototype_key for easy reference - _MODULE_PROTOTYPE_MODULES.update({prototype_key: mod for prototype_key, _ in prots}) + _MODULE_PROTOTYPE_MODULES.update({prototype_key.lower(): mod for prototype_key, _ in prots}) # make sure the prototype contains all meta info for prototype_key, prot in prots: actual_prot_key = prot.get('prototype_key', prototype_key).lower() @@ -409,7 +409,7 @@ def save_prototype(**kwargs): create_prototype = save_prototype -def delete_prototype(key, caller=None): +def delete_prototype(prototype_key, caller=None): """ Delete a stored prototype @@ -424,14 +424,16 @@ def delete_prototype(key, caller=None): """ if prototype_key in _MODULE_PROTOTYPES: - mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key, "N/A") + mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key.lower(), "N/A") raise PermissionError("{} is a read-only prototype " "(defined as code in {}).".format(prototype_key, mod)) - stored_prototype = DbPrototype.objects.filter(db_key=prototype_key) + stored_prototype = DbPrototype.objects.filter(db_key__iexact=prototype_key) if not stored_prototype: raise PermissionError("Prototype {} was not found.".format(prototype_key)) + + stored_prototype = stored_prototype[0] if caller: if not stored_prototype.access(caller, 'edit'): raise PermissionError("{} does not have permission to " diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 9941c81b11..638f4eef6e 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1145,7 +1145,6 @@ def list_node(option_generator, select=None, pagesize=10): for eopt in decorated_options: cback = ("goto" in eopt and "goto") or ("exec" in eopt and "exec") or None if cback: - print("eopt, cback: {} {}".format(eopt, cback)) signature = eopt[cback] if callable(signature): # callable with no kwargs defined From 02b4eb8b25b1681e7f7e31d57911fdf0d86edc35 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 30 Jul 2018 17:38:59 +0200 Subject: [PATCH 375/466] Fix attr assignmen issue in olc menu --- evennia/objects/objects.py | 1 + evennia/prototypes/menus.py | 5 ++++- evennia/prototypes/tests.py | 25 +++++++++++++------------ 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 8ec1433dcd..d42f20c9ae 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -1753,6 +1753,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): else: msg_self = '{self} say, "{speech}"' if msg_self is True else msg_self msg_location = msg_location or '{object} says, "{speech}"' + msg_receivers = msg_receivers or message custom_mapping = kwargs.get('mapping', {}) receivers = make_iter(receivers) if receivers else None diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 22a07903c3..1141f27536 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -1023,6 +1023,7 @@ def _add_attr(caller, attr_string, **kwargs): result (str): Result string of action. """ attrname = '' + value = '' category = None locks = '' @@ -1097,7 +1098,7 @@ def _attrs_actions(caller, raw_inp, **kwargs): attrname = attrname.strip() attr_tup = _get_tup_by_attrname(caller, attrname) - if attr_tup: + if action and attr_tup: if action == 'examine': return "node_examine_entity", \ {"text": _display_attribute(attr_tup), "back": "attrs"} @@ -2057,6 +2058,8 @@ def _spawn(caller, **kwargs): new_location = kwargs.get('location', None) if new_location: prototype['location'] = new_location + if not prototype.get('location'): + prototype['location'] = caller obj = spawner.spawn(prototype) if obj: diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 9da6ef44bc..9fb47585c9 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -399,11 +399,9 @@ class TestMenuModule(EvenniaTest): with mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock(return_value=[self.test_prot])): # prototype_key helpers - self.assertEqual(olc_menus._check_prototype_key(caller, "test_prot"), - "node_prototype_parent") + self.assertEqual(olc_menus._check_prototype_key(caller, "test_prot"), None) caller.ndb._menutree.olc_new = True - self.assertEqual(olc_menus._check_prototype_key(caller, "test_prot"), - "node_index") + self.assertEqual(olc_menus._check_prototype_key(caller, "test_prot"), "node_index") # prototype_parent helpers self.assertEqual(olc_menus._all_prototype_parents(caller), ['test_prot']) @@ -429,7 +427,7 @@ class TestMenuModule(EvenniaTest): self.assertEqual(olc_menus._all_typeclasses(caller), ["bar", "foo"]) self.assertEqual(olc_menus._typeclass_select( - caller, "evennia.objects.objects.DefaultObject"), "node_key") + caller, "evennia.objects.objects.DefaultObject"), None) # prototype_parent should be popped off here self.assertEqual(olc_menus._get_menu_prototype(caller), {'prototype_key': 'test_prot', @@ -444,8 +442,9 @@ class TestMenuModule(EvenniaTest): self.assertEqual(olc_menus._add_attr(caller, "test3;cat2;edit:false()=foo3"), Something) self.assertEqual(olc_menus._add_attr(caller, "test4;cat3;set:true();edit:false()=foo4"), Something) self.assertEqual(olc_menus._add_attr(caller, "test5;cat4;set:true();edit:false()=123"), Something) + self.assertEqual(olc_menus._add_attr(caller, "test1=foo1_changed"), Something) self.assertEqual(olc_menus._get_menu_prototype(caller)['attrs'], - [("test1", "foo1", None, ''), + [("test1", "foo1_changed", None, ''), ("test2", "foo2", "cat1", ''), ("test3", "foo3", "cat2", "edit:false()"), ("test4", "foo4", "cat3", "set:true();edit:false()"), @@ -483,27 +482,29 @@ class TestMenuModule(EvenniaTest): self.assertEqual(olc_menus._get_menu_prototype(caller)["permissions"], ["foo", "foo2"]) # prototype_tags helpers - self.assertEqual(olc_menus._add_prototype_tag(caller, "foo"), "Added Tag 'foo'.") - self.assertEqual(olc_menus._add_prototype_tag(caller, "foo2"), "Added Tag 'foo2'.") + self.assertEqual(olc_menus._add_prototype_tag(caller, "foo"), "Added Prototype-Tag 'foo'.") + self.assertEqual(olc_menus._add_prototype_tag(caller, "foo2"), "Added Prototype-Tag 'foo2'.") self.assertEqual(olc_menus._get_menu_prototype(caller)["prototype_tags"], ["foo", "foo2"]) # spawn helpers with mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock(return_value=[_PROTPARENTS['GOBLIN']])): - obj = olc_menus._spawn(caller, prototype=self.test_prot) + self.assertEqual(olc_menus._spawn(caller, prototype=self.test_prot), Something) + obj = caller.contents[0] self.assertEqual(obj.typeclass_path, "evennia.objects.objects.DefaultObject") self.assertEqual(obj.tags.get(category=spawner._PROTOTYPE_TAG_CATEGORY), self.test_prot['prototype_key']) # update helpers - self.assertEqual(olc_menus._update_spawned( + self.assertEqual(olc_menus._apply_diff( caller, prototype=self.test_prot, back_node="foo", objects=[obj]), 'foo') # no changes to apply self.test_prot['key'] = "updated key" # change prototype - self.assertEqual(olc_menus._update_spawned( + self.assertEqual(olc_menus._apply_diff( caller, prototype=self.test_prot, objects=[obj], back_node='foo'), 'foo') # apply change to the one obj # load helpers - self.assertEqual(olc_menus._prototype_load_select(caller, self.test_prot['prototype_key']), "node_index") + self.assertEqual(olc_menus._prototype_load_select(caller, self.test_prot['prototype_key']), + ('node_examine_entity', {'text': '|gLoaded prototype test_prot.|n', 'back': 'index'}) ) @mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock( From d4ba24934c7b7d0b5ba1bbb5cec3bd1cdc4f6a01 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 30 Jul 2018 20:19:46 +0200 Subject: [PATCH 376/466] Correct unittests --- evennia/prototypes/tests.py | 62 +++++++++---------------------------- 1 file changed, 15 insertions(+), 47 deletions(-) diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 9fb47585c9..71956efb91 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -74,7 +74,8 @@ class TestUtils(EvenniaTest): 'home': Something, 'key': 'Obj', 'location': Something, - 'locks': ['call:true()', + 'locks': ";".join([ + 'call:true()', 'control:perm(Developer)', 'delete:perm(Admin)', 'edit:perm(Admin)', @@ -82,7 +83,7 @@ class TestUtils(EvenniaTest): 'get:all()', 'puppet:pperm(Developer)', 'tell:perm(Admin)', - 'view:all()'], + 'view:all()']), 'prototype_desc': 'Built from Obj', 'prototype_key': Something, 'prototype_locks': 'spawn:all();edit:all()', @@ -132,15 +133,16 @@ class TestUtils(EvenniaTest): ('test', 'testval', None, [''])], 'prototype_locks': 'spawn:all();edit:all()', 'prototype_key': Something, - 'locks': ['call:true()', 'control:perm(Developer)', + 'locks': ";".join([ + 'call:true()', 'control:perm(Developer)', 'delete:perm(Admin)', 'edit:perm(Admin)', 'examine:perm(Builder)', 'get:all()', 'puppet:pperm(Developer)', 'tell:perm(Admin)', - 'view:all()'], + 'view:all()']), 'prototype_tags': [], - 'location': self.room1, + 'location': "#1", 'key': 'NewObj', - 'home': self.room1, + 'home': '#1', 'typeclass': 'evennia.objects.objects.DefaultObject', 'prototype_desc': 'Built from NewObj', 'aliases': 'foo'}) @@ -157,7 +159,8 @@ class TestUtils(EvenniaTest): 'home': Something, 'key': 'Obj', 'location': Something, - 'locks': ['call:true()', + 'locks': ";".join([ + 'call:true()', 'control:perm(Developer)', 'delete:perm(Admin)', 'edit:perm(Admin)', @@ -165,8 +168,8 @@ class TestUtils(EvenniaTest): 'get:all()', 'puppet:pperm(Developer)', 'tell:perm(Admin)', - 'view:all()'], - 'permissions': 'builder', + 'view:all()']), + 'permissions': ['builder'], 'prototype_desc': 'Built from Obj', 'prototype_key': Something, 'prototype_locks': 'spawn:all();edit:all()', @@ -184,7 +187,8 @@ class TestProtLib(EvenniaTest): def test_prototype_to_str(self): prstr = protlib.prototype_to_str(self.prot) - self.assertTrue(prstr.startswith("|cprototype key:|n")) + print("prst: {}".format(prstr)) + self.assertTrue(prstr.startswith("|cprototype-key:|n")) def test_check_permission(self): pass @@ -525,40 +529,4 @@ class TestOLCMenu(TestEvMenu): "node_index": "|c --- Prototype wizard --- |n" } - expected_tree = \ - ['node_index', - ['node_prototype_key', - ['node_index', - 'node_index', - 'node_validate_prototype', - ['node_index'], - 'node_index'], - 'node_typeclass', - ['node_key', - ['node_typeclass', - 'node_key', - 'node_index', - 'node_validate_prototype', - 'node_validate_prototype'], - 'node_index', - 'node_index', - 'node_index', - 'node_validate_prototype', - 'node_validate_prototype'], - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype']] + expected_tree = ['node_index', ['node_prototype_key', ['node_index', 'node_index', 'node_validate_prototype', ['node_index', 'node_index'], 'node_index'], 'node_prototype_parent', ['node_prototype_parent', 'node_prototype_key', 'node_prototype_parent', 'node_index', 'node_validate_prototype', 'node_index'], 'node_typeclass', ['node_typeclass', 'node_prototype_parent', 'node_typeclass', 'node_index', 'node_validate_prototype', 'node_index'], 'node_key', ['node_typeclass', 'node_key', 'node_index', 'node_validate_prototype', 'node_index'], 'node_aliases', ['node_key', 'node_aliases', 'node_index', 'node_validate_prototype', 'node_index'], 'node_attrs', ['node_aliases', 'node_attrs', 'node_index', 'node_validate_prototype', 'node_index'], 'node_tags', ['node_attrs', 'node_tags', 'node_index', 'node_validate_prototype', 'node_index'], 'node_locks', ['node_tags', 'node_locks', 'node_index', 'node_validate_prototype', 'node_index'], 'node_permissions', ['node_locks', 'node_permissions', 'node_index', 'node_validate_prototype', 'node_index'], 'node_location', ['node_permissions', 'node_location', 'node_index', 'node_validate_prototype', 'node_index', 'node_index'], 'node_home', ['node_location', 'node_home', 'node_index', 'node_validate_prototype', 'node_index', 'node_index'], 'node_destination', ['node_home', 'node_destination', 'node_index', 'node_validate_prototype', 'node_index', 'node_index'], 'node_prototype_desc', ['node_prototype_key', 'node_prototype_parent', 'node_index', 'node_validate_prototype', 'node_index'], 'node_prototype_tags', ['node_prototype_desc', 'node_prototype_tags', 'node_index', 'node_validate_prototype', 'node_index'], 'node_prototype_locks', ['node_examine_entity', ['node_prototype_locks', 'node_prototype_locks', 'node_prototype_locks'], 'node_examine_entity', 'node_prototype_locks', 'node_index', 'node_validate_prototype', 'node_index'], 'node_validate_prototype', 'node_index', 'node_prototype_spawn', ['node_index', 'node_validate_prototype'], 'node_index', 'node_search_object', ['node_index', 'node_index']]] From 23db1ad010d038395513ba0bb39a550243c79ee2 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 31 Jul 2018 11:48:18 +0200 Subject: [PATCH 377/466] Cleanup menu style --- evennia/prototypes/menus.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 1141f27536..37911d7010 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -845,7 +845,8 @@ def node_typeclass(caller): {actions} """.format(current=_get_current_value(caller, "typeclass"), - actions=_format_list_actions("examine", "remove")) + actions="|WSelect with |w|W. Other actions: " + "|we|Wxamine |w|W, |wr|Wemove selection") helptext = """ A |nTypeclass|n is specified by the actual python-path to the class definition in the From e49993fbb57eb6baf712d28519f0561d13a1f041 Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 10 Aug 2018 10:13:05 +0200 Subject: [PATCH 378/466] Create columnize (no ansi support at this point) --- evennia/accounts/manager.py | 1 - evennia/game_template/typeclasses/accounts.py | 1 - evennia/prototypes/menus.py | 29 ++++++-- evennia/utils/utils.py | 72 ++++++++++++++++++- 4 files changed, 92 insertions(+), 11 deletions(-) diff --git a/evennia/accounts/manager.py b/evennia/accounts/manager.py index c612cf930d..5d9bda2ab9 100644 --- a/evennia/accounts/manager.py +++ b/evennia/accounts/manager.py @@ -35,7 +35,6 @@ class AccountDBManager(TypedObjectManager, UserManager): get_account_from_uid get_account_from_name account_search (equivalent to evennia.search_account) - #swap_character """ diff --git a/evennia/game_template/typeclasses/accounts.py b/evennia/game_template/typeclasses/accounts.py index bbab3d4f22..99d861bf0b 100644 --- a/evennia/game_template/typeclasses/accounts.py +++ b/evennia/game_template/typeclasses/accounts.py @@ -65,7 +65,6 @@ class Account(DefaultAccount): * Helper methods msg(text=None, **kwargs) - swap_character(new_character, delete_old_character=False) execute_cmd(raw_string, session=None) search(ostring, global_search=False, attribute_name=None, use_nicks=False, location=None, ignore_errors=False, account=False) is_typeclass(typeclass, exact=False) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 37911d7010..8e88cad13c 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -57,6 +57,18 @@ def _get_flat_menu_prototype(caller, refresh=False, validate=False): return flat_prototype +def _get_unchanged_inherited(caller, protname): + """Return prototype values inherited from parent(s), which are not replaced in child""" + protototype = _get_menu_prototype(caller) + if protname in prototype: + return protname[protname], False + else: + flattened = _get_flat_menu_prototype(caller) + if protname in flattened: + return protname[protname], True + return None, False + + def _set_menu_prototype(caller, prototype): """Set the prototype with existing one""" caller.ndb._menutree.olc_prototype = prototype @@ -515,11 +527,11 @@ def node_index(caller): |c --- Prototype wizard --- |n A |cprototype|n is a 'template' for |wspawning|n an in-game entity. A field of the prototype - can be hard-coded or scripted using |w$protfuncs|n - for example to randomize the value - every time the prototype is used to spawn a new entity. + can either be hard-coded, left empty or scripted using |w$protfuncs|n - for example to + randomize the value every time a new entity is spawned. The fields whose names start with + 'Prototype-' are not fields on the object itself but are used for prototype-inheritance, or + when saving and loading. - The prototype fields whose names start with 'Prototype-' are not fields on the object itself - but are used in the template and when saving it for you (and maybe others) to use later. Select prototype field to edit. If you are unsure, start from [|w1|n]. Enter [|wh|n]elp at any menu node for more info. @@ -544,8 +556,8 @@ def node_index(caller): |c- $protfuncs |n Prototype-functions (protfuncs) allow for limited scripting within a prototype. These are - entered as a string $funcname(arg, arg, ...) and are evaluated |wat the time of spawning|n only. - They can also be nested for combined effects. + entered as a string $funcname(arg, arg, ...) and are evaluated |wat the time of spawning|n + only. They can also be nested for combined effects. {pfuncs} """.format(pfuncs=_format_protfuncs()) @@ -951,7 +963,10 @@ def node_aliases(caller): case sensitive. {actions} - """.format(actions=_format_list_actions("remove", prefix="|w|W to add new alias. Other action: ")) + {current} + """.format(actions=_format_list_actions("remove", + prefix="|w|W to add new alias. Other action: "), + current) helptext = """ Aliases are fixed alternative identifiers and are stored with the new object. diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 3d07a82e9a..60d5c160d6 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -7,6 +7,7 @@ be of use when designing your own game. """ from __future__ import division, print_function +import itertools from builtins import object, range from future.utils import viewkeys, raise_ @@ -33,6 +34,7 @@ _MULTIMATCH_TEMPLATE = settings.SEARCH_MULTIMATCH_TEMPLATE _EVENNIA_DIR = settings.EVENNIA_DIR _GAME_DIR = settings.GAME_DIR + try: import cPickle as pickle except ImportError: @@ -210,18 +212,27 @@ def justify(text, width=None, align="f", indent=0): gap = " " # minimum gap between words if line_rest > 0: if align == 'l': - line[-1] += " " * line_rest + if line[-1] == "\n\n": + line[-1] = " " * (line_rest-1) + "\n" + " " * width + "\n" + " " * width + else: + line[-1] += " " * line_rest elif align == 'r': line[0] = " " * line_rest + line[0] elif align == 'c': pad = " " * (line_rest // 2) line[0] = pad + line[0] - line[-1] = line[-1] + pad + " " * (line_rest % 2) + if line[-1] == "\n\n": + line[-1] = line[-1] + pad + " " * (line_rest % 2) + else: + line[-1] = pad + " " * (line_rest % 2 - 1) + \ + "\n" + " " * width + "\n" + " " * width else: # align 'f' gap += " " * (line_rest // max(1, ngaps)) rest_gap = line_rest % max(1, ngaps) for i in range(rest_gap): line[i] += " " + elif not any(line): + return [" " * width] return gap.join(line) # split into paragraphs and words @@ -262,6 +273,62 @@ def justify(text, width=None, align="f", indent=0): return "\n".join([indentstring + line for line in lines]) +def columnize(string, columns=2, spacing=4, align='l', width=None): + """ + Break a string into a number of columns, using as little + vertical space as possible. + + Args: + string (str): The string to columnize. + columns (int, optional): The number of columns to use. + spacing (int, optional): How much space to have between columns. + width (int, optional): The max width of the columns. + Defaults to client's default width. + + Returns: + columns (str): Text divided into columns. + + Raises: + RuntimeError: If given invalid values. + + """ + columns = max(1, columns) + spacing = max(1, spacing) + width = width if width else settings.CLIENT_DEFAULT_WIDTH + + w_spaces = (columns - 1) * spacing + w_txt = max(1, width - w_spaces) + + if w_spaces + columns > width: # require at least 1 char per column + raise RuntimeError("Width too small to fit columns") + + colwidth = int(w_txt / (1.0 * columns)) + + # first make a single column which we then split + onecol = justify(string, width=colwidth, align=align) + onecol = onecol.split("\n") + + nrows, dangling = divmod(len(onecol), columns) + nrows = [nrows + 1 if i < dangling else nrows for i in range(columns)] + + height = max(nrows) + cols = [] + istart = 0 + for irows in nrows: + cols.append(onecol[istart:istart+irows]) + istart = istart + irows + for col in cols: + if len(col) < height: + col.append(" " * colwidth) + + sep = " " * spacing + rows = [] + for irow in range(height): + rows.append(sep.join(col[irow] for col in cols)) + + return "\n".join(rows) + + def list_to_string(inlist, endsep="and", addquote=False): """ This pretty-formats a list as string output, adding an optional @@ -1548,6 +1615,7 @@ def format_table(table, extra_space=1): Examples: ```python + ftable = format_table([[...], [...], ...]) for ir, row in enumarate(ftable): if ir == 0: # make first row white From 298b2c23c680601d8c352b65e894136aeb2bad09 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 11 Aug 2018 11:49:10 +0200 Subject: [PATCH 379/466] Cleanup/refactoring of olc menus --- CHANGELOG.md | 15 ++ evennia/prototypes/menus.py | 243 +++++++++++++++++++++---------- evennia/prototypes/prototypes.py | 18 +-- evennia/utils/evmenu.py | 7 +- evennia/utils/evmore.py | 10 +- evennia/utils/utils.py | 15 +- 6 files changed, 216 insertions(+), 92 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ae990b85b..37ff5bfef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,8 +26,23 @@ - A `goto` option callable returning None (rather than the name of the next node) will now rerun the current node instead of failing. - Better error handling of in-node syntax errors. +- Improve dedent of default text/helptext formatter. Right-strip whitespace. +### Utils + +- Added new `columnize` function for easily splitting text into multiple columns. At this point it + is not working too well with ansi-colored text however. +- Extend the `dedent` function with a new `baseline_index` kwarg. This allows to force all lines to + the indentation given by the given line regardless of if other lines were already a 0 indentation. + This removes a problem with the original `textwrap.dedent` which will only dedent to the least + indented part of a text. +- Added `exit_cmd` to EvMore pager, to allow for calling a command (e.g. 'look') when leaving the pager. + +### Genaral + +- Start structuring the `CHANGELOG` to list features in more detail. + # Overviews diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 8e88cad13c..c44cf0d5e2 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -214,6 +214,10 @@ def _wizard_options(curr_node, prev_node, next_node, color="|W", search=False): return options +def _set_actioninfo(caller, string): + caller.ndb._menutree.actioninfo = string + + def _path_cropper(pythonpath): "Crop path to only the last component" return pythonpath.split('.')[-1] @@ -278,30 +282,65 @@ def _format_list_actions(*args, **kwargs): prefix = kwargs.get('prefix', "|WSelect with |w|W. Other actions:|n ") for action in args: actions.append("|w{}|n|W{} |w|n".format(action[0], action[1:])) - return prefix + "|W,|n ".join(actions) + return prefix + " |W|||n ".join(actions) -def _get_current_value(caller, keyname, formatter=str, only_inherit=False): - "Return current value, marking if value comes from parent or set in this prototype" - prot = _get_menu_prototype(caller) - if keyname in prot: - # value in current prot +def _get_current_value(caller, keyname, comparer=None, formatter=str, only_inherit=False): + """ + Return current value, marking if value comes from parent or set in this prototype. + + Args: + keyname (str): Name of prototoype key to get current value of. + comparer (callable, optional): This will be called as comparer(prototype_value, + flattened_value) and is expected to return the value to show as the current + or inherited one. If not given, a straight comparison is used and what is returned + depends on the only_inherit setting. + formatter (callable, optional)): This will be called with the result of comparer. + only_inherit (bool, optional): If a current value should only be shown if all + the values are inherited from the prototype parent (otherwise, show an empty string). + Returns: + current (str): The current value. + + """ + def _default_comparer(protval, flatval): if only_inherit: - return '' - return "Current {}: {}".format(keyname, formatter(prot[keyname])) - flat_prot = _get_flat_menu_prototype(caller) - if keyname in flat_prot: - # value in flattened prot - if keyname == 'prototype_key': - # we don't inherit prototype_keys - return "[No prototype_key set] (|rnot inherited|n)" + return "" if protval else flatval else: - ret = "Current {} (|binherited|n): {}".format(keyname, formatter(flat_prot[keyname])) - if only_inherit: - return "{}\n\n".format(ret) - return ret + return protval if protval else flatval - return "[No {} set]".format(keyname) + if not callable(comparer): + comparer = _default_comparer + + prot = _get_menu_prototype(caller) + flat_prot = _get_flat_menu_prototype(caller) + + out = "" + if keyname in prot: + if keyname in flat_prot: + out = formatter(comparer(prot[keyname], flat_prot[keyname])) + if only_inherit: + if out: + return "|WCurrent|n {} |W(|binherited|W):|n {}".format(keyname, out) + return "" + else: + if out: + return "|WCurrent|n {}|W:|n {}".format(keyname, out) + return "|W[No {} set]|n".format(keyname) + elif only_inherit: + return "" + else: + out = formatter(prot[keyname]) + return "|WCurrent|n {}|W:|n {}".format(keyname, out) + elif keyname in flat_prot: + out = formatter(flat_prot[keyname]) + if out: + return "|WCurrent|n {} |W(|n|binherited|W):|n {}".format(keyname, out) + else: + return "" + elif only_inherit: + return "" + else: + return "|W[No {} set]|n".format(keyname) def _default_parse(raw_inp, choices, *args): @@ -491,10 +530,9 @@ def node_search_object(caller, raw_inp, **kwargs): text = """ Found {num} match{post}. - {actions} (|RWarning: creating a prototype will |roverwrite|r |Rthe current prototype!)|n""".format( - num=nmatches, post="es" if nmatches > 1 else "", - actions=_format_list_actions( + num=nmatches, post="es" if nmatches > 1 else "") + _set_actioninfo(caller, _format_list_actions( "examine", "create prototype from object", prefix="Actions: ")) else: text = "Enter search criterion." @@ -758,8 +796,6 @@ def node_prototype_parent(caller): parent is given, this prototype must define the typeclass (next menu node). {current} - - {actions} """ helptext = """ Prototypes can inherit from one another. Changes in the child replace any values set in a @@ -767,6 +803,8 @@ def node_prototype_parent(caller): prototype to be valid. """ + _set_actioninfo(caller, _format_list_actions("examine", "add", "remove")) + ptexts = [] if prot_parent_keys: for pkey in utils.make_iter(prot_parent_keys): @@ -782,8 +820,7 @@ def node_prototype_parent(caller): if not ptexts: ptexts.append("[No prototype_parent set]") - text = text.format(current="\n\n".join(ptexts), - actions=_format_list_actions("examine", "add", "remove")) + text = text.format(current="\n\n".join(ptexts)) text = (text, helptext) @@ -854,8 +891,6 @@ def node_typeclass(caller): one of the prototype's |cparents|n. {current} - - {actions} """.format(current=_get_current_value(caller, "typeclass"), actions="|WSelect with |w|W. Other actions: " "|we|Wxamine |w|W, |wr|Wemove selection") @@ -962,11 +997,15 @@ def node_aliases(caller): |cAliases|n are alternative ways to address an object, next to its |cKey|n. Aliases are not case sensitive. - {actions} {current} - """.format(actions=_format_list_actions("remove", - prefix="|w|W to add new alias. Other action: "), - current) + """.format(current=_get_current_value( + caller, 'aliases', + comparer=lambda propval, flatval: [al for al in flatval if al not in propval], + formatter=lambda lst: "\n" + ", ".join(lst), only_inherit=True)) + _set_actioninfo(caller, + _format_list_actions( + "remove", + prefix="|w|W to add new alias. Other action: ")) helptext = """ Aliases are fixed alternative identifiers and are stored with the new object. @@ -1009,14 +1048,13 @@ def _display_attribute(attr_tuple): attrkey, value, category, locks = attr_tuple value = protlib.protfunc_parser(value) typ = type(value) - out = ("|cAttribute key:|n '{attrkey}' " - "(|ccategory:|n {category}, " - "|clocks:|n {locks})\n" - "|cValue|n |W(parsed to {typ})|n:\n{value}").format( - attrkey=attrkey, - category=category if category else "|wNone|n", - locks=locks if locks else "|wNone|n", - typ=typ, value=value) + out = ("{attrkey} |c=|n {value} |W({typ}{category}{locks})|n".format( + attrkey=attrkey, + value=value, + typ=typ, + category=", category={}".format(category) if category else '', + locks=", locks={}".format(";".join(locks)) if any(locks) else '')) + return out @@ -1130,6 +1168,12 @@ def _attrs_actions(caller, raw_inp, **kwargs): @list_node(_caller_attrs, _attr_select) def node_attrs(caller): + def _currentcmp(propval, flatval): + "match by key + category" + cmp1 = [(tup[0].lower(), tup[2].lower() if tup[2] else None) for tup in propval] + return [tup for tup in flatval if (tup[0].lower(), tup[2].lower() + if tup[2] else None) not in cmp1] + text = """ |cAttributes|n are custom properties of the object. Enter attributes on one of these forms: @@ -1140,8 +1184,14 @@ def node_attrs(caller): To give an attribute without a category but with a lockstring, leave that spot empty (attrname;;lockstring=value). Attribute values can have embedded $protfuncs. - {actions} - """.format(actions=_format_list_actions("examine", "remove", prefix="Actions: ")) + {current} + """.format( + current=_get_current_value( + caller, "attrs", + comparer=_currentcmp, + formatter=lambda lst: "\n" + "\n".join(_display_attribute(tup) for tup in lst), + only_inherit=True)) + _set_actioninfo(caller, _format_list_actions("examine", "remove", prefix="Actions: ")) helptext = """ Most commonly, Attributes don't need any categories or locks. If using locks, the lock-types @@ -1290,6 +1340,13 @@ def _tags_actions(caller, raw_inp, **kwargs): @list_node(_caller_tags, _tag_select) def node_tags(caller): + + def _currentcmp(propval, flatval): + "match by key + category" + cmp1 = [(tup[0].lower(), tup[1].lower() if tup[2] else None) for tup in propval] + return [tup for tup in flatval if (tup[0].lower(), tup[1].lower() + if tup[1] else None) not in cmp1] + text = """ |cTags|n are used to group objects so they can quickly be found later. Enter tags on one of the following forms: @@ -1297,8 +1354,14 @@ def node_tags(caller): tagname;category tagname;category;data - {actions} - """.format(actions=_format_list_actions("examine", "remove", prefix="Actions: ")) + {current} + """.format( + current=_get_current_value( + caller, 'tags', + comparer=_currentcmp, + formatter=lambda lst: "\n" + "\n".join(_display_tag(tup) for tup in lst), + only_inherit=True)) + _set_actioninfo(caller, _format_list_actions("examine", "remove", prefix="Actions: ")) helptext = """ Tags are shared between all objects with that tag. So the 'data' field (which is not @@ -1325,18 +1388,7 @@ def _caller_locks(caller): def _locks_display(caller, lock): - try: - locktype, lockdef = lock.split(":", 1) - except ValueError: - txt = "Malformed lock string - Missing ':'" - else: - txt = ("{lockstr}\n\n" - "|WLocktype: |w{locktype}|n\n" - "|WLock def: |w{lockdef}|n\n").format( - lockstr=lock, - locktype=locktype, - lockdef=lockdef) - return txt + return lock def _lock_select(caller, lockstr): @@ -1395,6 +1447,11 @@ def _locks_actions(caller, raw_inp, **kwargs): @list_node(_caller_locks, _lock_select) def node_locks(caller): + def _currentcmp(propval, flatval): + "match by locktype" + cmp1 = [lck.split(":", 1)[0] for lck in propval.split(';')] + return ";".join(lstr for lstr in flatval.split(';') if lstr.split(':', 1)[0] not in cmp1) + text = """ The |cLock string|n defines limitations for accessing various properties of the object once it's spawned. The string should be on one of the following forms: @@ -1402,8 +1459,15 @@ def node_locks(caller): locktype:[NOT] lockfunc(args) locktype: [NOT] lockfunc(args) [AND|OR|NOT] lockfunc(args) [AND|OR|NOT] ... - {action} - """.format(action=_format_list_actions("examine", "remove", prefix="Actions: ")) + {current}{action} + """.format( + current=_get_current_value( + caller, 'locks', + comparer=_currentcmp, + formatter=lambda lockstr: "\n".join(_locks_display(caller, lstr) + for lstr in lockstr.split(';')), + only_inherit=True), + action=_format_list_actions("examine", "remove", prefix="Actions: ")) helptext = """ Here is an example of two lock strings: @@ -1438,16 +1502,17 @@ def _caller_permissions(caller): return perms -def _display_perm(caller, permission): +def _display_perm(caller, permission, only_hierarchy=False): hierarchy = settings.PERMISSION_HIERARCHY perm_low = permission.lower() + txt = '' if perm_low in [prm.lower() for prm in hierarchy]: txt = "Permission (in hieararchy): {}".format( ", ".join( ["|w[{}]|n".format(prm) if prm.lower() == perm_low else "|W{}|n".format(prm) for prm in hierarchy])) - else: + elif not only_hierarchy: txt = "Permission: '{}'".format(permission) return txt @@ -1500,12 +1565,23 @@ def _permissions_actions(caller, raw_inp, **kwargs): @list_node(_caller_permissions, _permission_select) def node_permissions(caller): + def _currentcmp(pval, fval): + cmp1 = [perm.lower() for perm in pval] + return [perm for perm in fval if perm.lower() not in cmp1] + text = """ |cPermissions|n are simple strings used to grant access to this object. A permission is used - when a |clock|n is checked that contains the |wperm|n or |wpperm|n lock functions. + when a |clock|n is checked that contains the |wperm|n or |wpperm|n lock functions. Certain + permissions belong in the |cpermission hierarchy|n together with the |Wperm()|n lock + function. - {actions} - """.format(actions=_format_list_actions("examine", "remove"), prefix="Actions: ") + {current} + """.format( + current=_get_current_value( + caller, 'permissions', + comparer=_currentcmp, + formatter=lambda lst: "\n" + "\n".join(prm for prm in lst), only_inherit=True)) + _set_actioninfo(caller, _format_list_actions("examine", "remove", prefix="Actions: ")) helptext = """ Any string can act as a permission as long as a lock is set to look for it. Depending on the @@ -1538,7 +1614,6 @@ def node_location(caller): inventory of |c{caller}|n by default. {current} - """.format(caller=caller.key, current=_get_current_value(caller, "location")) helptext = """ @@ -1734,9 +1809,13 @@ def node_prototype_tags(caller): |cPrototype-Tags|n can be used to classify and find prototypes in listings Tag names are not case-sensitive and can have not have a custom category. - {actions} - """.format(actions=_format_list_actions( - "remove", prefix="|w|n|W to add Tag. Other Action:|n ")) + {current} + """.format( + current=_get_current_value( + caller, 'prototype_tags', + formatter=lambda lst: ", ".join(tg for tg in lst), only_inherit=True)) + _set_actioninfo(caller, _format_list_actions( + "remove", prefix="|w|n|W to add Tag. Other Action:|n ")) helptext = """ Using prototype-tags is a good way to organize and group large numbers of prototypes by genre, type etc. Under the hood, prototypes' tags will all be stored with the category @@ -1827,8 +1906,14 @@ def node_prototype_locks(caller): If unsure, keep the open defaults. - {actions} - """.format(actions=_format_list_actions('examine', "remove", prefix="Actions: ")) + {current} + """.format( + current=_get_current_value( + caller, 'prototype_locks', + formatter=lambda lstring: "\n".join(_locks_display(caller, lstr) + for lstr in lstring.split(';')), + only_inherit=True)) + _set_actioninfo(caller, _format_list_actions('examine', "remove", prefix="Actions: ")) helptext = """ Prototype locks can be used to vary access for different tiers of builders. It also allows @@ -2204,9 +2289,8 @@ def node_prototype_load(caller, **kwargs): text = """ Select a prototype to load. This will replace any prototype currently being edited! - - {actions} - """.format(actions=_format_list_actions("examine", "delete")) + """ + _set_actioninfo(caller, _format_list_actions("examine", "delete")) helptext = """ Loading a prototype will load it and return you to the main index. It can be a good idea @@ -2230,6 +2314,13 @@ class OLCMenu(EvMenu): A custom EvMenu with a different formatting for the options. """ + def nodetext_formatter(self, nodetext): + """ + Format the node text itself. + + """ + return super(OLCMenu, self).nodetext_formatter(nodetext) + def options_formatter(self, optionlist): """ Split the options into two blocks - olc options and normal options @@ -2237,6 +2328,7 @@ class OLCMenu(EvMenu): """ olc_keys = ("index", "forward", "back", "previous", "next", "validate prototype", "save prototype", "load prototype", "spawn prototype", "search objects") + actioninfo = self.actioninfo + "\n" if hasattr(self, 'actioninfo') else '' olc_options = [] other_options = [] for key, desc in optionlist: @@ -2247,7 +2339,8 @@ class OLCMenu(EvMenu): else: other_options.append((key, desc)) - olc_options = " | ".join(olc_options) + " | " + "|wQ|Wuit" if olc_options else "" + olc_options = actioninfo + \ + " |W|||n ".join(olc_options) + " |W|||n " + "|wQ|Wuit" if olc_options else "" other_options = super(OLCMenu, self).options_formatter(other_options) sep = "\n\n" if olc_options and other_options else "" @@ -2257,10 +2350,10 @@ class OLCMenu(EvMenu): """ Show help text """ - return "|c --- Help ---|n\n" + helptext + return "|c --- Help ---|n\n" + utils.dedent(helptext) def display_helptext(self): - evmore.msg(self.caller, self.helptext, session=self._session) + evmore.msg(self.caller, self.helptext, session=self._session, exit_cmd='look') def start_olc(caller, session=None, prototype=None): diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 4c53ed7d1c..0cc016300f 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -192,16 +192,14 @@ def prototype_to_str(prototype): category = "|ccategory:|n {}".format(category) if category else '' cat_locks = "" if category or locks: - cat_locks = "(|ccategory:|n {category}, ".format( + cat_locks = " (|ccategory:|n {category}, ".format( category=category if category else "|wNone|n") out.append( - "{attrkey} " - "{cat_locks}\n" - " |c=|n {value}".format( - attrkey=attrkey, - cat_locks=cat_locks, - locks=locks if locks else "|wNone|n", - value=value)) + "{attrkey}{cat_locks} |c=|n {value}".format( + attrkey=attrkey, + cat_locks=cat_locks, + locks=locks if locks else "|wNone|n", + value=value)) attrs = "|cattrs:|n\n {attrs}".format(attrs="\n ".join(out)) tags = prototype.get('tags', '') if tags: @@ -209,10 +207,10 @@ def prototype_to_str(prototype): for (tagkey, category, data) in tags: out.append("{tagkey} (category: {category}{dat})".format( tagkey=tagkey, category=category, dat=", data: {}".format(data) if data else "")) - tags = "|ctags:|n\n {tags}".format(tags="\n ".join(out)) + tags = "|ctags:|n\n {tags}".format(tags=", ".join(out)) locks = prototype.get('locks', '') if locks: - locks = "|clocks:|n\n {locks}".format(locks="\n ".join(locks.split(";"))) + locks = "|clocks:|n\n {locks}".format(locks=locks) permissions = prototype.get("permissions", '') if permissions: permissions = "|cpermissions:|n {perms}".format(perms=", ".join(permissions)) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 638f4eef6e..0297170da2 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -167,14 +167,13 @@ from __future__ import print_function import random from builtins import object, range -from textwrap import dedent from inspect import isfunction, getargspec from django.conf import settings from evennia import Command, CmdSet from evennia.utils import logger from evennia.utils.evtable import EvTable from evennia.utils.ansi import strip_ansi -from evennia.utils.utils import mod_import, make_iter, pad, m_len, is_iter +from evennia.utils.utils import mod_import, make_iter, pad, m_len, is_iter, dedent from evennia.commands import cmdhandler # read from protocol NAWS later? @@ -896,7 +895,7 @@ class EvMenu(object): nodetext (str): The formatted node text. """ - return dedent(nodetext).strip() + return dedent(nodetext.strip('\n'), baseline_index=0).rstrip() def helptext_formatter(self, helptext): """ @@ -909,7 +908,7 @@ class EvMenu(object): helptext (str): The formatted help text. """ - return dedent(helptext).strip() + return dedent(helptext.strip('\n'), baseline_index=0).rstrip() def options_formatter(self, optionlist): """ diff --git a/evennia/utils/evmore.py b/evennia/utils/evmore.py index e0ec091005..94173b9eca 100644 --- a/evennia/utils/evmore.py +++ b/evennia/utils/evmore.py @@ -122,7 +122,8 @@ class EvMore(object): """ def __init__(self, caller, text, always_page=False, session=None, - justify_kwargs=None, exit_on_lastpage=False, **kwargs): + justify_kwargs=None, exit_on_lastpage=False, + exit_cmd=None, **kwargs): """ Initialization of the text handler. @@ -141,6 +142,10 @@ class EvMore(object): page being completely filled, exit pager immediately. If unset, another move forward is required to exit. If set, the pager exit message will not be shown. + exit_cmd (str, optional): If given, this command-string will be executed on + the caller when the more page exits. Note that this will be using whatever + cmdset the user had *before* the evmore pager was activated (so none of + the evmore commands will be available when this is run). kwargs (any, optional): These will be passed on to the `caller.msg` method. @@ -151,6 +156,7 @@ class EvMore(object): self._npages = [] self._npos = [] self.exit_on_lastpage = exit_on_lastpage + self.exit_cmd = exit_cmd self._exit_msg = "Exited |wmore|n pager." if not session: # if not supplied, use the first session to @@ -269,6 +275,8 @@ class EvMore(object): if not quiet: self._caller.msg(text=self._exit_msg, **self._kwargs) self._caller.cmdset.remove(CmdSetMore) + if self.exit_cmd: + self._caller.execute_cmd(self.exit_cmd, session=self._session) def msg(caller, text="", always_page=False, session=None, diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 60d5c160d6..abe7d3c1e3 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -160,12 +160,16 @@ def crop(text, width=None, suffix="[...]"): return to_str(utext) -def dedent(text): +def dedent(text, baseline_index=None): """ Safely clean all whitespace at the left of a paragraph. Args: text (str): The text to dedent. + baseline_index (int or None, optional): Which row to use as a 'base' + for the indentation. Lines will be dedented to this level but + no further. If None, indent so as to completely deindent the + least indented text. Returns: text (str): Dedented string. @@ -178,7 +182,14 @@ def dedent(text): """ if not text: return "" - return textwrap.dedent(text) + if baseline_index is None: + return textwrap.dedent(text) + else: + lines = text.split('\n') + baseline = lines[baseline_index] + spaceremove = len(baseline) - len(baseline.lstrip(' ')) + return "\n".join(line[min(spaceremove, len(line) - len(line.lstrip(' '))):] + for line in lines) def justify(text, width=None, align="f", indent=0): From fba58c4649cba7f858162b4193ffbbe45e6fc036 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 12 Aug 2018 13:13:13 +0200 Subject: [PATCH 380/466] Fix further bugs in menu spawn --- evennia/prototypes/menus.py | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index c44cf0d5e2..0e4f59ffbc 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -1953,12 +1953,12 @@ def _keep_diff(caller, **kwargs): def node_apply_diff(caller, **kwargs): """Offer options for updating objects""" - def _keep_option(keyname, prototype, obj, obj_prototype, diff, objects, back_node): + def _keep_option(keyname, prototype, base_obj, obj_prototype, diff, objects, back_node): """helper returning an option dict""" options = {"desc": "Keep {} as-is".format(keyname), "goto": (_keep_diff, {"key": keyname, "prototype": prototype, - "obj": obj, "obj_prototype": obj_prototype, + "base_obj": base_obj, "obj_prototype": obj_prototype, "diff": diff, "objects": objects, "back_node": back_node})} return options @@ -1966,6 +1966,7 @@ def node_apply_diff(caller, **kwargs): update_objects = kwargs.get("objects", None) back_node = kwargs.get("back_node", "node_index") obj_prototype = kwargs.get("obj_prototype", None) + base_obj = kwargs.get("base_obj", None) diff = kwargs.get("diff", None) if not update_objects: @@ -1976,12 +1977,12 @@ def node_apply_diff(caller, **kwargs): if not diff: # use one random object as a reference to calculate a diff - obj = choice(update_objects) - diff, obj_prototype = spawner.prototype_diff_from_object(prototype, obj) + base_obj = choice(update_objects) + diff, obj_prototype = spawner.prototype_diff_from_object(prototype, base_obj) text = ["Suggested changes to {} objects. ".format(len(update_objects)), "Showing random example obj to change: {name} ({dbref}))\n".format( - name=obj.key, dbref=obj.dbref)] + name=base_obj.key, dbref=base_obj.dbref)] helptext = """ Be careful with this operation! The upgrade mechanism will try to automatically estimate @@ -2001,7 +2002,7 @@ def node_apply_diff(caller, **kwargs): continue line = "{iopt} |w{key}|n: {old}{sep}{new} {change}" - old_val = utils.crop(str(obj_prototype[key]), width=20) + old_val = str(obj_prototype.get(key, "")) if inst == "KEEP": inst = "|b{}|n".format(inst) @@ -2009,25 +2010,29 @@ def node_apply_diff(caller, **kwargs): sep=" ", new='', change=inst)) continue - new_val = utils.crop(str(spawner.init_spawn_value(prototype[key])), width=20) + if key in prototype: + new_val = str(spawner.init_spawn_value(prototype[key])) + else: + new_val = "" ichanges += 1 if inst in ("UPDATE", "REPLACE"): inst = "|y{}|n".format(inst) text.append(line.format(iopt=ichanges, key=key, old=old_val, sep=" |y->|n ", new=new_val, change=inst)) options.append(_keep_option(key, prototype, - obj, obj_prototype, diff, update_objects, back_node)) + base_obj, obj_prototype, diff, update_objects, back_node)) elif inst == "REMOVE": inst = "|r{}|n".format(inst) text.append(line.format(iopt=ichanges, key=key, old=old_val, sep=" |r->|n ", new='', change=inst)) options.append(_keep_option(key, prototype, - obj, obj_prototype, diff, update_objects, back_node)) + base_obj, obj_prototype, diff, update_objects, back_node)) options.extend( - [{"key": ("|wu|Wupdate {} objects".format(len(update_objects)), "update", "u"), - "goto": (_apply_diff, {"prototye": prototype, "objects": update_objects, - "back_node": back_node, "diff": diff})}, - {"key": ("|wr|Wneset changes", "reset", "r"), + [{"key": ("|wu|Wpdate {} objects".format(len(update_objects)), "update", "u"), + "desc": "Update {} objects".format(len(update_objects)), + "goto": (_apply_diff, {"prototype": prototype, "objects": update_objects, + "back_node": back_node, "diff": diff, "base_obj": base_obj})}, + {"key": ("|wr|Weset changes", "reset", "r"), "goto": ("node_apply_diff", {"prototype": prototype, "back_node": back_node, "objects": update_objects})}]) From b6675b854e875c1d7ff23fecc57a94679b3889ac Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 12 Aug 2018 13:37:19 +0200 Subject: [PATCH 381/466] Resolve unittests --- evennia/prototypes/tests.py | 1 - evennia/utils/utils.py | 7 +++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 71956efb91..1c77fd85c3 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -187,7 +187,6 @@ class TestProtLib(EvenniaTest): def test_prototype_to_str(self): prstr = protlib.prototype_to_str(self.prot) - print("prst: {}".format(prstr)) self.assertTrue(prstr.startswith("|cprototype-key:|n")) def test_check_permission(self): diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index abe7d3c1e3..cd6c57a21f 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -7,7 +7,6 @@ be of use when designing your own game. """ from __future__ import division, print_function -import itertools from builtins import object, range from future.utils import viewkeys, raise_ @@ -233,10 +232,10 @@ def justify(text, width=None, align="f", indent=0): pad = " " * (line_rest // 2) line[0] = pad + line[0] if line[-1] == "\n\n": - line[-1] = line[-1] + pad + " " * (line_rest % 2) - else: - line[-1] = pad + " " * (line_rest % 2 - 1) + \ + line[-1] += pad + " " * (line_rest % 2 - 1) + \ "\n" + " " * width + "\n" + " " * width + else: + line[-1] = line[-1] + pad + " " * (line_rest % 2) else: # align 'f' gap += " " * (line_rest // max(1, ngaps)) rest_gap = line_rest % max(1, ngaps) From 9717b1b3b6c448a2ab699ae803875298e42f3bcb Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 12 Aug 2018 14:58:12 +0200 Subject: [PATCH 382/466] Update changelog and readme with current changes --- CHANGELOG.md | 28 ++++++++++++++++++++++++++-- evennia/contrib/README.md | 4 ++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37ff5bfef3..d876b419d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,20 @@ ## Evennia 0.8 (2018) +### Server/Portal + +- Removed `evennia_runner`, completely refactor `evennia_launcher.py` (the 'evennia' program) + with different functionality). +- Both Portal/Server are now stand-alone processes (easy to run as daemon) +- Made Portal the AMP Server for starting/restarting the Server (the AMP client) +- Dynamic logging now happens using `evennia -l` rather than by interactive. +- Made AMP secure against erroneous HTTP requests on the wrong port (return error messages). + ### Prototype changes -- A new form of prototype - database-stored prototypes, editable from in-game. The old, +- Moved evennia/utils/spawner.py into the new evennia/prototypes/ along with all new + functionality around prototypes. +- A new form of prototype - database-stored prototypes, editable from in-game, was added. The old, module-created prototypes remain as read-only prototypes. - All prototypes must have a key `prototype_key` identifying the prototype in listings. This is checked to be server-unique. Prototypes created in a module will use the global variable name they @@ -39,10 +50,23 @@ indented part of a text. - Added `exit_cmd` to EvMore pager, to allow for calling a command (e.g. 'look') when leaving the pager. -### Genaral +### General - Start structuring the `CHANGELOG` to list features in more detail. +### Contribs + +- `Health Bar` (Tim Ashley Jenkins): Easily create colorful bars/meters. +- `Tree select` (Fluttersprite): Wrapper around EvMenu to easier create + a common form of menu from a string. +- `Turnbattle suite` (Tim Ashley Jenkins)- the old `turnbattle.py` was moved into its own + `turnbattle/` package and reworked with many different flavors of combat systems: + - `tb_basic` - The basic turnbattle system, with initiative/turn order attack/defense/damage. + - `tb_equip` - Adds weapon and armor, wielding, accuracy modifiers. + - `tb_items` - Extends `tb_equip` with item use with conditions/status effects. + - `tb_magic` - Extends `tb_equip` with spellcasting. + - `tb_range` - Adds system for abstract positioning and movement. +- Updates and some cleanup of existing contribs. # Overviews diff --git a/evennia/contrib/README.md b/evennia/contrib/README.md index 5ca11b1799..4785be6197 100644 --- a/evennia/contrib/README.md +++ b/evennia/contrib/README.md @@ -31,6 +31,7 @@ things you want from here into your game folder and change them there. multiple descriptions for time and season as well as details. * GenderSub (Griatch 2015) - Simple example (only) of storing gender on a character and access it in an emote with a custom marker. +* Health Bar (Tim Ashley Jenkins 2017) - Tool to create colorful bars/meters. * Mail (grungies1138 2016) - An in-game mail system for communication. * Menu login (Griatch 2011) - A login system using menus asking for name/password rather than giving them as one command. @@ -53,6 +54,9 @@ things you want from here into your game folder and change them there. * Tree Select (FlutterSprite 2017) - A simple system for creating a branching EvMenu with selection options sourced from a single multi-line string. +* Turnbattle (Tim Ashley Jenkins 2017) - This is a framework for a turn-based + combat system with different levels of complexity, including versions with + equipment and magic as well as ranged combat. * Wilderness (titeuf87 2017) - Make infinitely large wilderness areas with dynamically created locations. * UnixCommand (Vincent Le Geoff 2017) - Add commands with UNIX-style syntax. From 5ec9fd499144f09816121dbbde22cf3a1d6ee34a Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 12 Aug 2018 15:06:16 +0200 Subject: [PATCH 383/466] Correct spawner import in contrib --- evennia/contrib/turnbattle/tb_items.py | 182 ++++++++++++------------- 1 file changed, 91 insertions(+), 91 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index d0e9fe8e34..cfb511b4ad 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -28,7 +28,7 @@ This module includes a number of example conditions: Haste: +1 action per turn Paralyzed: No actions per turn Frightened: Character can't use the 'attack' command - + Since conditions can have a wide variety of effects, their code is scattered throughout the other functions wherever they may apply. @@ -70,7 +70,7 @@ from random import randint from evennia import DefaultCharacter, Command, default_cmds, DefaultScript from evennia.commands.default.muxcommand import MuxCommand from evennia.commands.default.help import CmdHelp -from evennia.utils.spawner import spawn +from evennia.prototypes.spawner import spawn from evennia import TICKER_HANDLER as tickerhandler """ @@ -230,10 +230,10 @@ def apply_damage(defender, damage): 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 @@ -250,7 +250,7 @@ def resolve_attack(attacker, defender, attack_value=None, defense_value=None, Args: attacker (obj): Character doing the attacking defender (obj): Character being attacked - + Options: attack_value (int): Override for attack roll defense_value (int): Override for defense value @@ -353,11 +353,11 @@ def spend_action(character, actions, action_name=None): def spend_item_use(item, user): """ Spends one use on an item with limited uses. - + Args: item (obj): Item being used user (obj): Character using the item - + Notes: If item.db.item_consumable is 'True', the item is destroyed if it runs out of uses - if it's a string instead of 'True', it will also @@ -365,32 +365,32 @@ def spend_item_use(item, user): as the name of the prototype to spawn. """ item.db.item_uses -= 1 # Spend one use - + if item.db.item_uses > 0: # Has uses remaining # Inform the player user.msg("%s has %i uses remaining." % (item.key.capitalize(), item.db.item_uses)) - + else: # All uses spent - + if not item.db.item_consumable: # Item isn't consumable # Just inform the player that the uses are gone user.msg("%s has no uses remaining." % item.key.capitalize()) - + else: # If item is consumable if item.db.item_consumable == True: # If the value is 'True', just destroy the item user.msg("%s has been consumed." % item.key.capitalize()) item.delete() # Delete the spent item - + else: # If a string, use value of item_consumable to spawn an object in its place residue = spawn({"prototype":item.db.item_consumable})[0] # Spawn the residue residue.location = item.location # Move the residue to the same place as the item user.msg("After using %s, you are left with %s." % (item, residue)) item.delete() # Delete the spent item - + def use_item(user, item, target): """ Performs the action of using an item. - + Args: user (obj): Character using the item item (obj): Item being used @@ -399,53 +399,53 @@ def use_item(user, item, target): # If item is self only and no target given, set target to self. if item.db.item_selfonly and target == None: target = user - + # If item is self only, abort use if used on others. if item.db.item_selfonly and user != target: user.msg("%s can only be used on yourself." % item) return - + # Set kwargs to pass to item_func kwargs = {} - if item.db.item_kwargs: - kwargs = item.db.item_kwargs - + if item.db.item_kwargs: + kwargs = item.db.item_kwargs + # Match item_func string to function try: item_func = ITEMFUNCS[item.db.item_func] except KeyError: # If item_func string doesn't match to a function in ITEMFUNCS user.msg("ERROR: %s not defined in ITEMFUNCS" % item.db.item_func) return - + # Call the item function - abort if it returns False, indicating an error. # This performs the actual action of using the item. # Regardless of what the function returns (if anything), it's still executed. if item_func(item, user, target, **kwargs) == False: return - + # If we haven't returned yet, we assume the item was used successfully. # Spend one use if item has limited uses if item.db.item_uses: spend_item_use(item, user) - + # Spend an action if in combat if is_in_combat(user): spend_action(user, 1, action_name="item") - + def condition_tickdown(character, turnchar): """ Ticks down the duration of conditions on a character at the start of a given character's turn. - + Args: character (obj): Character to tick down the conditions of turnchar (obj): Character whose turn it currently is - + Notes: In combat, this is called on every fighter at the start of every character's turn. Out of combat, it's instead called when a character's at_update() hook is called, which is every 30 seconds by default. """ - + for key in character.db.conditions: # The first value is the remaining turns - the second value is whose turn to count down on. condition_duration = character.db.conditions[key][0] @@ -459,11 +459,11 @@ def condition_tickdown(character, turnchar): # If the duration is brought down to 0, remove the condition and inform everyone. character.location.msg_contents("%s no longer has the '%s' condition." % (str(character), str(key))) del character.db.conditions[key] - + def add_condition(character, turnchar, condition, duration): """ Adds a condition to a fighter. - + Args: character (obj): Character to give the condition to turnchar (obj): Character whose turn to tick down the condition on in combat @@ -501,7 +501,7 @@ class TBItemsCharacter(DefaultCharacter): """ Adds attributes for a character's current and maximum HP. We're just going to set this value at '100' by default. - + An empty dictionary is created to store conditions later, and the character is subscribed to the Ticker Handler, which will call at_update() on the character, with the interval @@ -536,17 +536,17 @@ class TBItemsCharacter(DefaultCharacter): self.msg("You can't move, you've been defeated!") return False return True - + def at_turn_start(self): """ Hook called at the beginning of this character's turn in combat. """ # Prompt the character for their turn and give some information. self.msg("|wIt's your turn! You have %i HP remaining.|n" % self.db.hp) - + # Apply conditions that fire at the start of each turn. self.apply_turn_conditions() - + def apply_turn_conditions(self): """ Applies the effect of conditions that occur at the start of each @@ -559,7 +559,7 @@ class TBItemsCharacter(DefaultCharacter): to_heal = self.db.max_hp - self.db.hp # Cap healing to max HP self.db.hp += to_heal self.location.msg_contents("%s regains %i HP from Regeneration." % (self, to_heal)) - + # Poisoned: does 4 to 8 damage at the start of character's turn if "Poisoned" in self.db.conditions: to_hurt = randint(POISON_RATE[0], POISON_RATE[1]) # Deal damage @@ -568,18 +568,18 @@ class TBItemsCharacter(DefaultCharacter): if self.db.hp <= 0: # Call at_defeat if poison defeats the character at_defeat(self) - + # Haste: Gain an extra action in combat. if is_in_combat(self) and "Haste" in self.db.conditions: self.db.combat_actionsleft += 1 self.msg("You gain an extra action this turn from Haste!") - + # Paralyzed: Have no actions in combat. if is_in_combat(self) and "Paralyzed" in self.db.conditions: self.db.combat_actionsleft = 0 self.location.msg_contents("%s is Paralyzed, and can't act this turn!" % self) self.db.combat_turnhandler.turn_end_check(self) - + def at_update(self): """ Fires every 30 seconds. @@ -602,7 +602,7 @@ class TBItemsCharacterTest(TBItemsCharacter): self.db.max_hp = 100 # Set maximum HP to 100 self.db.hp = self.db.max_hp # Set current HP to maximum self.db.conditions = {} # Set empty dict for conditions - + """ ---------------------------------------------------------------------------- @@ -651,7 +651,7 @@ class TBItemsTurnHandler(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]) @@ -748,14 +748,14 @@ class TBItemsTurnHandler(DefaultScript): self.db.turn += 1 # Go to the next in the turn order. if self.db.turn > len(self.db.fighters) - 1: self.db.turn = 0 # Go back to the first in the turn order once you reach the end. - + newchar = self.db.fighters[self.db.turn] # Note the new character - + self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer. self.db.timeout_warning_given = False # Reset the timeout warning. self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) self.start_turn(newchar) # Start the new character's turn. - + # Count down condition timers. for fighter in self.db.fighters: condition_tickdown(fighter, newchar) @@ -785,7 +785,7 @@ class TBItemsTurnHandler(DefaultScript): # Initialize the character like you do at the start. self.initialize_for_combat(character) - + """ ---------------------------------------------------------------------------- COMMANDS START HERE @@ -865,7 +865,7 @@ class CmdAttack(Command): if not self.caller.db.hp: # Can't attack if you have no HP. self.caller.msg("You can't attack, you've been defeated.") return - + if "Frightened" in self.caller.db.conditions: # Can't attack if frightened self.caller.msg("You're too frightened to attack!") return @@ -1033,29 +1033,29 @@ class CmdUse(MuxCommand): item = self.caller.search(self.lhs, candidates=self.caller.contents) if not item: return - + # Search for target, if any is given target = None if self.rhs: target = self.caller.search(self.rhs) if not target: return - + # If in combat, can only use items on your turn if is_in_combat(self.caller): if not is_turn(self.caller): self.caller.msg("You can only use items on your turn.") return - + if not item.db.item_func: # Object has no item_func, not usable self.caller.msg("'%s' is not a usable item." % item.key.capitalize()) return - + if item.attributes.has("item_uses"): # Item has limited uses if item.db.item_uses <= 0: # Limited uses are spent self.caller.msg("'%s' has no uses remaining." % item.key.capitalize()) return - + # If everything checks out, call the use_item function use_item(self.caller, item, target) @@ -1077,7 +1077,7 @@ class BattleCmdSet(default_cmds.CharacterCmdSet): self.add(CmdDisengage()) self.add(CmdCombatHelp()) self.add(CmdUse()) - + """ ---------------------------------------------------------------------------- ITEM FUNCTIONS START HERE @@ -1091,7 +1091,7 @@ Every item function must take the following arguments: item (obj): The item being used user (obj): The character using the item target (obj): The target of the item use - + Item functions must also accept **kwargs - these keyword arguments can be used to define how different items that use the same function can have different effects (for example, different attack items doing different @@ -1104,25 +1104,25 @@ take and the effect they have on the result. def itemfunc_heal(item, user, target, **kwargs): """ Item function that heals HP. - + kwargs: min_healing(int): Minimum amount of HP recovered max_healing(int): Maximum amount of HP recovered """ - if not target: + if not target: target = user # Target user if none specified - + if not target.attributes.has("max_hp"): # Has no HP to speak of user.msg("You can't use %s on that." % item) return False # Returning false aborts the item use - + if target.db.hp >= target.db.max_hp: user.msg("%s is already at full health." % target) return False - + min_healing = 20 max_healing = 40 - + # Retrieve healing range from kwargs, if present if "healing_range" in kwargs: min_healing = kwargs["healing_range"][0] @@ -1132,62 +1132,62 @@ def itemfunc_heal(item, user, target, **kwargs): if target.db.hp + to_heal > target.db.max_hp: to_heal = target.db.max_hp - target.db.hp # Cap healing to max HP target.db.hp += to_heal - + user.location.msg_contents("%s uses %s! %s regains %i HP!" % (user, item, target, to_heal)) - + def itemfunc_add_condition(item, user, target, **kwargs): """ Item function that gives the target one or more conditions. - + kwargs: conditions (list): Conditions added by the item formatted as a list of tuples: (condition (str), duration (int or True)) - + Notes: Should mostly be used for beneficial conditions - use itemfunc_attack for an item that can give an enemy a harmful condition. """ conditions = [("Regeneration", 5)] - - if not target: + + if not target: target = user # Target user if none specified - + if not target.attributes.has("max_hp"): # Is not a fighter user.msg("You can't use %s on that." % item) return False # Returning false aborts the item use - + # Retrieve condition / duration from kwargs, if present if "conditions" in kwargs: conditions = kwargs["conditions"] - + user.location.msg_contents("%s uses %s!" % (user, item)) - + # Add conditions to the target - for condition in conditions: + for condition in conditions: add_condition(target, user, condition[0], condition[1]) - + def itemfunc_cure_condition(item, user, target, **kwargs): """ Item function that'll remove given conditions from a target. - + kwargs: to_cure(list): List of conditions (str) that the item cures when used """ to_cure = ["Poisoned"] - - if not target: + + if not target: target = user # Target user if none specified - + if not target.attributes.has("max_hp"): # Is not a fighter user.msg("You can't use %s on that." % item) return False # Returning false aborts the item use - + # Retrieve condition(s) to cure from kwargs, if present if "to_cure" in kwargs: to_cure = kwargs["to_cure"] - + item_msg = "%s uses %s! " % (user, item) - + for key in target.db.conditions: if key in to_cure: # If condition specified in to_cure, remove it. @@ -1195,11 +1195,11 @@ def itemfunc_cure_condition(item, user, target, **kwargs): del target.db.conditions[key] user.location.msg_contents(item_msg) - + def itemfunc_attack(item, user, target, **kwargs): """ Item function that attacks a target. - + kwargs: min_damage(int): Minimum damage dealt by the attack max_damage(int): Maximum damage dealth by the attack @@ -1207,31 +1207,31 @@ def itemfunc_attack(item, user, target, **kwargs): inflict_condition(list): List of conditions inflicted on hit, formatted as a (str, int) tuple containing condition name and duration. - + Notes: Calls resolve_attack at the end. """ if not is_in_combat(user): user.msg("You can only use that in combat.") return False # Returning false aborts the item use - - if not target: + + if not target: user.msg("You have to specify a target to use %s! (use = )" % item) return False - + if target == user: user.msg("You can't attack yourself!") - return False - + return False + if not target.db.hp: # Has no HP user.msg("You can't use %s on that." % item) return False - + min_damage = 20 max_damage = 40 accuracy = 0 inflict_condition = [] - + # Retrieve values from kwargs, if present if "damage_range" in kwargs: min_damage = kwargs["damage_range"][0] @@ -1240,17 +1240,17 @@ def itemfunc_attack(item, user, target, **kwargs): accuracy = kwargs["accuracy"] if "inflict_condition" in kwargs: inflict_condition = kwargs["inflict_condition"] - + # Roll attack and damage attack_value = randint(1, 100) + accuracy damage_value = randint(min_damage, max_damage) - + # Account for "Accuracy Up" and "Accuracy Down" conditions if "Accuracy Up" in user.db.conditions: attack_value += 25 if "Accuracy Down" in user.db.conditions: attack_value -= 25 - + user.location.msg_contents("%s attacks %s with %s!" % (user, target, item)) resolve_attack(user, target, attack_value=attack_value, damage_value=damage_value, inflict_condition=inflict_condition) @@ -1283,14 +1283,14 @@ Only "item_func" is required, but item behavior can be further modified by specifying any of the following: item_uses (int): If defined, item has a limited number of uses - + item_selfonly (bool): If True, user can only use the item on themself - + item_consumable(True or str): If True, item is destroyed when it runs out of uses. If a string is given, the item will spawn a new object as it's destroyed, with the string specifying what prototype to spawn. - + item_kwargs (dict): Keyword arguments to pass to the function defined in item_func. Unique to each function, and can be used to make multiple items using the same function work differently. From 2a6e914161fa30362ddbb6f67b48d9f8bd2cb768 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 18 Aug 2018 10:38:06 +0200 Subject: [PATCH 384/466] Restart server, run collectstatic at init. Fix tintin++ default. Resolves #1593. --- evennia/server/amp_client.py | 4 +++- evennia/server/initial_setup.py | 21 ++++++++++++++++----- evennia/server/portal/ttype.py | 2 +- evennia/server/server.py | 5 ++--- evennia/server/sessionhandler.py | 3 +-- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/evennia/server/amp_client.py b/evennia/server/amp_client.py index 8b9f9d4e8e..a4300adf4d 100644 --- a/evennia/server/amp_client.py +++ b/evennia/server/amp_client.py @@ -51,7 +51,7 @@ class AMPClientFactory(protocol.ReconnectingClientFactory): def buildProtocol(self, addr): """ - Creates an AMPProtocol instance when connecting to the server. + Creates an AMPProtocol instance when connecting to the AMP server. Args: addr (str): Connection address. Not used. @@ -108,6 +108,8 @@ class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol): # back with the Server side. We also need the startup mode (reload, reset, shutdown) self.send_AdminServer2Portal( amp.DUMMYSESSION, operation=amp.PSYNC, spid=os.getpid(), info_dict=info_dict) + # run the intial setup if needed + self.factory.server.run_initial_setup() def data_to_portal(self, command, sessid, **kwargs): """ diff --git a/evennia/server/initial_setup.py b/evennia/server/initial_setup.py index 985a54dc95..9852229e45 100644 --- a/evennia/server/initial_setup.py +++ b/evennia/server/initial_setup.py @@ -59,7 +59,7 @@ def create_objects(): """ - logger.log_info("Creating objects (Account #1 and Limbo room) ...") + logger.log_info("Initial setup: Creating objects (Account #1 and Limbo room) ...") # Set the initial User's account object's username on the #1 object. # This object is pure django and only holds name, email and password. @@ -121,7 +121,7 @@ def create_channels(): Creates some sensible default channels. """ - logger.log_info("Creating default channels ...") + logger.log_info("Initial setup: Creating default channels ...") goduser = get_god_account() for channeldict in settings.DEFAULT_CHANNELS: @@ -144,11 +144,21 @@ def at_initial_setup(): mod = __import__(modname, fromlist=[None]) except (ImportError, ValueError): return - logger.log_info(" Running at_initial_setup() hook.") + logger.log_info("Initial setup: Running at_initial_setup() hook.") if mod.__dict__.get("at_initial_setup", None): mod.at_initial_setup() +def collectstatic(): + """ + Run collectstatic to make sure all web assets are loaded. + + """ + from django.core.management import call_command + logger.log_info("Initial setup: Gathering static resources using 'collectstatic'") + call_command('collectstatic', '--noinput') + + def reset_server(): """ We end the initialization by resetting the server. This makes sure @@ -159,8 +169,8 @@ def reset_server(): """ ServerConfig.objects.conf("server_epoch", time.time()) from evennia.server.sessionhandler import SESSIONS - logger.log_info(" Initial setup complete. Restarting Server once.") - SESSIONS.server.shutdown(mode='reset') + logger.log_info("Initial setup complete. Restarting Server once.") + SESSIONS.portal_reset_server() def handle_setup(last_step): @@ -186,6 +196,7 @@ def handle_setup(last_step): setup_queue = [create_objects, create_channels, at_initial_setup, + collectstatic, reset_server] # step through queue, from last completed function diff --git a/evennia/server/portal/ttype.py b/evennia/server/portal/ttype.py index d143b69747..faf4737842 100644 --- a/evennia/server/portal/ttype.py +++ b/evennia/server/portal/ttype.py @@ -116,7 +116,7 @@ class Ttype(object): self.protocol.protocol_flags["FORCEDENDLINE"] = False if cupper.startswith("TINTIN++"): - self.protocol.protocol_flags["FORCEDENDLINE"] = False + self.protocol.protocol_flags["FORCEDENDLINE"] = True if (cupper.startswith("XTERM") or cupper.endswith("-256COLOR") or diff --git a/evennia/server/server.py b/evennia/server/server.py index 1be9b5be0b..e42c85bae7 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -181,9 +181,6 @@ class Evennia(object): self.start_time = time.time() - # Run the initial setup if needed - self.run_initial_setup() - # initialize channelhandler channelhandler.CHANNELHANDLER.update() @@ -274,6 +271,8 @@ class Evennia(object): def run_initial_setup(self): """ + This is triggered by the amp protocol when the connection + to the portal has been established. This attempts to run the initial_setup script of the server. It returns if this is not the first time the server starts. Once finished the last_initial_setup_step is set to -1. diff --git a/evennia/server/sessionhandler.py b/evennia/server/sessionhandler.py index dcb9adb689..8e439b42dd 100644 --- a/evennia/server/sessionhandler.py +++ b/evennia/server/sessionhandler.py @@ -278,7 +278,7 @@ class ServerSessionHandler(SessionHandler): """ super(ServerSessionHandler, self).__init__(*args, **kwargs) - self.server = None + self.server = None # set at server initialization self.server_data = {"servername": _SERVERNAME} def _run_cmd_login(self, session): @@ -290,7 +290,6 @@ class ServerSessionHandler(SessionHandler): if not session.logged_in: self.data_in(session, text=[[CMD_LOGINSTART], {}]) - def portal_connect(self, portalsessiondata): """ Called by Portal when a new session has connected. From 42038737ff7c76e6920f4b43650b9492438998a3 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 19 Aug 2018 21:40:19 +0200 Subject: [PATCH 385/466] Add interactive server-start mode. --- evennia/server/evennia_launcher.py | 47 +++++++++++++++++++++++++----- evennia/server/server.py | 9 +++--- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 2f9206b4c1..c83d869336 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -467,9 +467,10 @@ ARG_OPTIONS = \ stop - shutdown server+portal reboot - shutdown server+portal, then start again reset - restart server in 'shutdown' mode - sstart - start only server (requires portal) + istart - start server in the foreground (until reload) + sstop - stop only server kill - send kill signal to portal+server (force) - skill = send kill signal only to server + skill - send kill signal only to server status - show server and portal run state info - show server and portal port info menu - show a menu of options @@ -955,14 +956,39 @@ def reboot_evennia(pprofiler=False, sprofiler=False): send_instruction(PSTATUS, None, _portal_running, _portal_not_running) -def stop_server_only(): +def start_server_interactive(): + """ + Start the Server under control of the launcher process (foreground) + + """ + def _iserver(): + _, server_twistd_cmd = _get_twistd_cmdline(False, False) + server_twistd_cmd.append("--nodaemon") + print("Starting Server in interactive mode (stop with Ctrl-C)...") + try: + Popen(server_twistd_cmd, env=getenv(), stderr=STDOUT).wait() + except KeyboardInterrupt: + print("... Stopped Server with Ctrl-C.") + else: + print("... Server stopped (leaving interactive mode).") + stop_server_only(when_stopped=_iserver) + + +def stop_server_only(when_stopped=None): """ Only stop the Server-component of Evennia (this is not useful except for debug) + Args: + when_stopped (callable): This will be called with no arguments when Server has stopped (or + if it had already stopped when this is called). + """ def _server_stopped(*args): - print("... Server stopped.") - _reactor_stop() + if when_stopped: + when_stopped() + else: + print("... Server stopped.") + _reactor_stop() def _portal_running(response): _, srun, _, _, _, _ = _parse_status(response) @@ -971,8 +997,11 @@ def stop_server_only(): wait_for_status_reply(_server_stopped) send_instruction(SSHUTD, {}) else: - print("Server is not running.") - _reactor_stop() + if when_stopped: + when_stopped() + else: + print("Server is not running.") + _reactor_stop() def _portal_not_running(fail): print("Evennia is not running.") @@ -1937,7 +1966,7 @@ def main(): # launch menu for operation init_game_directory(CURRENT_DIR, check_db=True) run_menu() - elif option in ('status', 'info', 'start', 'reload', 'reboot', + elif option in ('status', 'info', 'start', 'istart', 'reload', 'reboot', 'reset', 'stop', 'sstop', 'kill', 'skill'): # operate the server directly if not SERVER_LOGFILE: @@ -1948,6 +1977,8 @@ def main(): query_info() elif option == "start": start_evennia(args.profiler, args.profiler) + elif option == "istart": + start_server_interactive() elif option == 'reload': reload_evennia(args.profiler) elif option == 'reboot': diff --git a/evennia/server/server.py b/evennia/server/server.py index e42c85bae7..5a225704c2 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -507,10 +507,11 @@ ServerConfig.objects.conf("server_starting_mode", True) # what to execute from. application = service.Application('Evennia') -# custom logging -logfile = logger.WeeklyLogFile(os.path.basename(settings.SERVER_LOG_FILE), - os.path.dirname(settings.SERVER_LOG_FILE)) -application.setComponent(ILogObserver, logger.ServerLogObserver(logfile).emit) +if "--nodaemon" not in sys.argv: + # custom logging, but only if we are not running in interactive mode + logfile = logger.WeeklyLogFile(os.path.basename(settings.SERVER_LOG_FILE), + os.path.dirname(settings.SERVER_LOG_FILE)) + application.setComponent(ILogObserver, logger.ServerLogObserver(logfile).emit) # The main evennia server program. This sets up the database # and is where we store all the other services. From 1923689040a477188a2e2331f551901ef977a6c0 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 20 Aug 2018 20:21:22 +0200 Subject: [PATCH 386/466] Fix except-finally section that swallowed command unittest error message. Resolves #1629. --- CHANGELOG.md | 7 +++++ evennia/commands/default/general.py | 2 +- evennia/commands/default/tests.py | 44 ++++++++++++++--------------- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d876b419d7..b0cc6ac95e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ - Made Portal the AMP Server for starting/restarting the Server (the AMP client) - Dynamic logging now happens using `evennia -l` rather than by interactive. - Made AMP secure against erroneous HTTP requests on the wrong port (return error messages). +- The `evennia istart` option will start/switch the Server in foreground (interactive) mode, where it logs + to terminal and can be stopped with Ctrl-C. Using `evennia reload`, or reloading in-game, will + return Server to normal daemon operation. ### Prototype changes @@ -26,9 +29,13 @@ change from Evennia 0.7 which allowed 'mixin' prototypes without `typeclass`/`prototype_key`. To make a mixin now, give it a default typeclass, like `evennia.objects.objects.DefaultObject` and just override in the child as needed. +- Spawning an object using a prototype will automatically assign a new tag to it, named the same as + the `prototype_key` and with the category `from_prototype`. - The spawn command was extended to accept a full prototype on one line. - The spawn command got the /save switch to save the defined prototype and its key. - The command spawn/menu will now start an OLC (OnLine Creation) menu to load/save/edit/spawn prototypes. +- The OLC allows for updating all objects previously created using a given prototype with any + changes done. ### EvMenu diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index d72a2006b9..85fb1b4dd4 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -71,7 +71,7 @@ class CmdLook(COMMAND_DEFAULT_CLASS): target = caller.search(self.args) if not target: return - self.msg((caller.at_look(target), {'type':'look'}), options=None) + self.msg((caller.at_look(target), {'type': 'look'}), options=None) class CmdNick(COMMAND_DEFAULT_CLASS): diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 3fb762910d..19277c168a 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -105,28 +105,28 @@ class CommandTest(EvenniaTest): pass except InterruptCommand: pass - finally: - # clean out evtable sugar. We only operate on text-type - stored_msg = [args[0] if args and args[0] else kwargs.get("text", utils.to_str(kwargs, force_string=True)) - for name, args, kwargs in receiver.msg.mock_calls] - # Get the first element of a tuple if msg received a tuple instead of a string - stored_msg = [smsg[0] if isinstance(smsg, tuple) else smsg for smsg in stored_msg] - if msg is not None: - # set our separator for returned messages based on parsing ansi or not - msg_sep = "|" if noansi else "||" - # Have to strip ansi for each returned message for the regex to handle it correctly - returned_msg = msg_sep.join(_RE.sub("", ansi.parse_ansi(mess, strip_ansi=noansi)) - for mess in stored_msg).strip() - if msg == "" and returned_msg or not returned_msg.startswith(msg.strip()): - sep1 = "\n" + "=" * 30 + "Wanted message" + "=" * 34 + "\n" - sep2 = "\n" + "=" * 30 + "Returned message" + "=" * 32 + "\n" - sep3 = "\n" + "=" * 78 - retval = sep1 + msg.strip() + sep2 + returned_msg + sep3 - raise AssertionError(retval) - else: - returned_msg = "\n".join(str(msg) for msg in stored_msg) - returned_msg = ansi.parse_ansi(returned_msg, strip_ansi=noansi).strip() - receiver.msg = old_msg + + # clean out evtable sugar. We only operate on text-type + stored_msg = [args[0] if args and args[0] else kwargs.get("text", utils.to_str(kwargs, force_string=True)) + for name, args, kwargs in receiver.msg.mock_calls] + # Get the first element of a tuple if msg received a tuple instead of a string + stored_msg = [smsg[0] if isinstance(smsg, tuple) else smsg for smsg in stored_msg] + if msg is not None: + # set our separator for returned messages based on parsing ansi or not + msg_sep = "|" if noansi else "||" + # Have to strip ansi for each returned message for the regex to handle it correctly + returned_msg = msg_sep.join(_RE.sub("", ansi.parse_ansi(mess, strip_ansi=noansi)) + for mess in stored_msg).strip() + if msg == "" and returned_msg or not returned_msg.startswith(msg.strip()): + sep1 = "\n" + "=" * 30 + "Wanted message" + "=" * 34 + "\n" + sep2 = "\n" + "=" * 30 + "Returned message" + "=" * 32 + "\n" + sep3 = "\n" + "=" * 78 + retval = sep1 + msg.strip() + sep2 + returned_msg + sep3 + raise AssertionError(retval) + else: + returned_msg = "\n".join(str(msg) for msg in stored_msg) + returned_msg = ansi.parse_ansi(returned_msg, strip_ansi=noansi).strip() + receiver.msg = old_msg return returned_msg From 6ec7c7a63f94dd06921468a9880123dbb77bb21f Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 20 Aug 2018 20:39:04 +0200 Subject: [PATCH 387/466] Improve docstring --- evennia/utils/utils.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index cd6c57a21f..c7c6a03d06 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -1010,17 +1010,17 @@ def delay(timedelay, callback, *args, **kwargs): Delay the return of a value. Args: - timedelay (int or float): The delay in seconds - callback (callable): Will be called with optional - arguments after `timedelay` seconds. - args (any, optional): Will be used as arguments to callback + timedelay (int or float): The delay in seconds + callback (callable): Will be called as `callback(*args, **kwargs)` + after `timedelay` seconds. + args (any, optional): Will be used as arguments to callback Kwargs: - persistent (bool, optional): should make the delay persistent - over a reboot or reload - any (any): Will be used to call the callback. + persistent (bool, optional): should make the delay persistent + over a reboot or reload + any (any): Will be used as keyword arguments to callback. Returns: - deferred (deferred): Will fire fire with callback after + deferred (deferred): Will fire with callback after `timedelay` seconds. Note that if `timedelay()` is used in the commandhandler callback chain, the callback chain can be defined directly in the command body and don't need to be From f855c9078fe8d355442880e9d40ecc4efd529d94 Mon Sep 17 00:00:00 2001 From: Henddher Pedroza Date: Tue, 21 Aug 2018 19:54:26 -0500 Subject: [PATCH 388/466] #1459: force evform.raw_form to have all lines of the same length - effectively a rectangle --- evennia/utils/evform.py | 34 ++++++++++++++++++++-- evennia/utils/tests/data/evform_example.py | 3 ++ evennia/utils/tests/test_evform.py | 9 ++++-- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/evennia/utils/evform.py b/evennia/utils/evform.py index 55fa0ec9e2..8a9828bc17 100644 --- a/evennia/utils/evform.py +++ b/evennia/utils/evform.py @@ -153,6 +153,31 @@ INVALID_FORMCHARS = r"\s\/\|\\\*\_\-\#\<\>\~\^\:\;\.\," _ANSI_ESCAPE = re.compile(r"\|\|") +def _to_rect(lines): + """ + Forces all lines to be as long as the longest + + Args: + lines (list): list of `ANSIString`s + + Returns: + nlines (list): list of `ANSIString`s of + same length as the longest input line + + """ + maxl = 0 + for line in lines: + if isinstance(line, (ANSIString, basestring)): + maxl = max(len(line), maxl) + else: + raise ValueError() + nlines = [] + for line in lines: + line += ' ' * (maxl - len(line)) + nlines.append(line) + return nlines + + def _to_ansi(obj, regexable=False): "convert to ANSIString" if isinstance(obj, basestring): @@ -184,7 +209,7 @@ class EvForm(object): filename (str): Path to template file. cells (dict): A dictionary mapping of {id:text} tables (dict): A dictionary mapping of {id:EvTable}. - form (dict): A dictionary of {"CELLCHAR":char, + form (dict): A dictionary of {"FORMCHAR":char, "TABLECHAR":char, "FORM":templatestring} if this is given, filename is not read. @@ -408,7 +433,9 @@ class EvForm(object): self.tablechar = tablechar[0] if len(tablechar) > 1 else tablechar # split into a list of list of lines. Form can be indexed with form[iy][ix] - self.raw_form = _to_ansi(to_unicode(datadict.get("FORM", "")).split("\n")) + raw_form = _to_ansi(to_unicode(datadict.get("FORM", "")).split("\n")) + self.raw_form = _to_rect(raw_form) + # strip first line self.raw_form = self.raw_form[1:] if self.raw_form else self.raw_form @@ -440,7 +467,8 @@ def _test(): 6: 5, 7: 18, 8: 10, - 9: 3}) + 9: 3, + "F": "rev 1"}) # create the EvTables tableA = EvTable("HP", "MV", "MP", table=[["**"], ["*****"], ["***"]], diff --git a/evennia/utils/tests/data/evform_example.py b/evennia/utils/tests/data/evform_example.py index 572a2fff5e..bd6b42fc0b 100644 --- a/evennia/utils/tests/data/evform_example.py +++ b/evennia/utils/tests/data/evform_example.py @@ -6,6 +6,7 @@ Test form FORMCHAR = "x" TABLECHAR = "c" + FORM = """ .------------------------------------------------. | | @@ -27,4 +28,6 @@ FORM = """ | ccccccccc | ccccccccccccccccBccccccccccccccccc | | | | -----------`------------------------------------- + Footer: xxxFxxx + info """ diff --git a/evennia/utils/tests/test_evform.py b/evennia/utils/tests/test_evform.py index e6a0d26049..fce6d6582e 100644 --- a/evennia/utils/tests/test_evform.py +++ b/evennia/utils/tests/test_evform.py @@ -19,7 +19,7 @@ class TestEvForm(TestCase): u'|\n' u'| \x1b[0m\x1b[1m\x1b[32mBouncer\x1b[0m \x1b[0m |\n' u'| |\n' - u' >----------------------------------------------<\n' + u' >----------------------------------------------< \n' u'| |\n' u'| Desc: \x1b[0mA sturdy \x1b[0m \x1b[0m' u' STR: \x1b[0m12 \x1b[0m\x1b[0m\x1b[0m\x1b[0m' @@ -31,7 +31,7 @@ class TestEvForm(TestCase): u' LUC: \x1b[0m10 \x1b[0m\x1b[0m\x1b[0m' u' MAG: \x1b[0m3 \x1b[0m\x1b[0m\x1b[0m |\n' u'| |\n' - u' >----------.-----------------------------------<\n' + u' >----------.-----------------------------------< \n' u'| | |\n' u'| \x1b[0mHP\x1b[0m|\x1b[0mMV \x1b[0m|\x1b[0mMP\x1b[0m ' u'| \x1b[0mSkill \x1b[0m|\x1b[0mValue \x1b[0m' @@ -47,7 +47,10 @@ class TestEvForm(TestCase): u'| \x1b[0mSmithing \x1b[0m|\x1b[0m9 \x1b[0m' u'|\x1b[0m205/900 \x1b[0m\x1b[0m\x1b[0m\x1b[0m\x1b[0m\x1b[0m\x1b[0m |\n' u'| | |\n' - u' -----------`-------------------------------------\n') + u' -----------`-------------------------------------\n' + u' Footer: \x1b[0mrev 1 \x1b[0m \n' + u' info \n' + u' ') def test_ansi_escape(self): # note that in a msg() call, the result would be the correct |-----, From 247ad0d056ed4146c6ce3e7cd1c7fa1a0a7baa5f Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 23 Aug 2018 18:28:28 +0200 Subject: [PATCH 389/466] Support inflection of colored object-names. Resolves #1572. --- evennia/objects/objects.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index d42f20c9ae..27d8147999 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -23,6 +23,7 @@ from evennia.commands.cmdsethandler import CmdSetHandler from evennia.commands import cmdhandler from evennia.utils import search from evennia.utils import logger +from evennia.utils import ansi from evennia.utils.utils import (variable_from_module, lazy_property, make_iter, to_unicode, is_iter, list_to_string, to_str) @@ -305,12 +306,13 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): count (int): Number of objects of this type looker (Object): Onlooker. Not used by default. Kwargs: - key (str): Optional key to pluralize, use this instead of the object's key. + key (str): Optional key to pluralize, if given, use this instead of the object's key. Returns: singular (str): The singular form to display. plural (str): The determined plural form of the key, including the count. """ key = kwargs.get("key", self.key) + key = ansi.ANSIString(key) # this is needed to allow inflection of colored names plural = _INFLECT.plural(key, 2) plural = "%s %s" % (_INFLECT.number_to_words(count, threshold=12), plural) singular = _INFLECT.an(key) From b96f1a5c8660fb4bcbdd1bac744c5b814f60a520 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 23 Aug 2018 20:46:13 +0200 Subject: [PATCH 390/466] Add menudebug command for debugging EvMenu --- CHANGELOG.md | 7 ++++++ evennia/prototypes/menus.py | 3 ++- evennia/utils/evmenu.py | 47 +++++++++++++++++++++++++++++++++---- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0cc6ac95e..22e14ae163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,8 +45,14 @@ current node instead of failing. - Better error handling of in-node syntax errors. - Improve dedent of default text/helptext formatter. Right-strip whitespace. +- Add `debug` option when creating menu - this turns of persistence and makes the `menudebug` + command available for examining the current menu state. +### Webclient + +- Refactoring of webclient structure. + ### Utils - Added new `columnize` function for easily splitting text into multiple columns. At this point it @@ -60,6 +66,7 @@ ### General - Start structuring the `CHANGELOG` to list features in more detail. +- Inflection and grouping of multiple objects in default room (an box, three boxes) ### Contribs diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 0e4f59ffbc..cc5a9f5914 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -2396,4 +2396,5 @@ def start_olc(caller, session=None, prototype=None): "node_prototype_save": node_prototype_save, "node_prototype_spawn": node_prototype_spawn } - OLCMenu(caller, menudata, startnode='node_index', session=session, olc_prototype=prototype) + OLCMenu(caller, menudata, startnode='node_index', session=session, + olc_prototype=prototype, debug=True) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 0297170da2..127e2d3f13 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -165,6 +165,7 @@ evennia.utils.evmenu`. """ from __future__ import print_function import random +import inspect from builtins import object, range from inspect import isfunction, getargspec @@ -173,7 +174,7 @@ from evennia import Command, CmdSet from evennia.utils import logger from evennia.utils.evtable import EvTable from evennia.utils.ansi import strip_ansi -from evennia.utils.utils import mod_import, make_iter, pad, m_len, is_iter, dedent +from evennia.utils.utils import mod_import, make_iter, pad, to_str, m_len, is_iter, dedent, crop from evennia.commands import cmdhandler # read from protocol NAWS later? @@ -322,7 +323,7 @@ class EvMenu(object): auto_quit=True, auto_look=True, auto_help=True, cmd_on_exit="look", persistent=False, startnode_input="", session=None, - **kwargs): + debug=False, **kwargs): """ Initialize the menu tree and start the caller onto the first node. @@ -375,7 +376,8 @@ class EvMenu(object): *pickle*. When the server is reloaded, the latest node shown will be completely re-run with the same input arguments - so be careful if you are counting up some persistent counter or similar - the counter may be run twice if - reload happens on the node that does that. + reload happens on the node that does that. Note that if `debug` is True, + this setting is ignored and assumed to be False. startnode_input (str or (str, dict), optional): Send an input text to `startnode` as if a user input text from a fictional previous node. If including the dict, this will be passed as **kwargs to that node. When the server reloads, @@ -385,6 +387,10 @@ class EvMenu(object): for the very first display of the first node - after that, EvMenu itself will keep the session updated from the command input. So a persistent menu will *not* be using this same session anymore after a reload. + debug (bool, optional): If set, the 'menudebug' command will be made available + by default in all nodes of the menu. This will print out the current state of + the menu. Deactivate for production use! When the debug flag is active, the + `persistent` flag is deactivated. Kwargs: any (any): All kwargs will become initialization variables on `caller.ndb._menutree`, @@ -408,7 +414,7 @@ class EvMenu(object): """ self._startnode = startnode self._menutree = self._parse_menudata(menudata) - self._persistent = persistent + self._persistent = persistent if not debug else False self._quitting = False if startnode not in self._menutree: @@ -422,6 +428,7 @@ class EvMenu(object): self.auto_quit = auto_quit self.auto_look = auto_look self.auto_help = auto_help + self.debug_mode = debug self._session = session if isinstance(cmd_on_exit, str): # At this point menu._session will have been replaced by the @@ -844,6 +851,36 @@ class EvMenu(object): if self.cmd_on_exit is not None: self.cmd_on_exit(self.caller, self) + def print_debug_info(self, arg): + """ + Messages the caller with the current menu state, for debug purposes. + + Args: + arg (str): Arg to debug instruction, either nothing, 'full' or the name + of a property to inspect. + + """ + all_props = inspect.getmembers(self) + all_methods = [name for name, _ in inspect.getmembers(self, predicate=inspect.ismethod)] + all_builtins = [name for name, _ in inspect.getmembers(self, predicate=inspect.isbuiltin)] + props = {prop: value for prop, value in all_props if prop not in all_methods and + prop not in all_builtins and not prop.endswith("__")} + + if arg: + if arg in props: + debugtxt = " |y* {}:|n\n{}".format(arg, props[arg]) + elif arg == 'full': + debugtxt = ("|yMENU DEBUG full ... |n\n" + "\n".join(props) + + "\n |y... END MENU DEBUG|n") + else: + debugtxt = "|yUsage: menudebug full||n" + else: + debugtxt = "|yMENU DEBUG properties:|n\n" + "\n".join("|y *|n {}: {}".format( + prop, crop(to_str(value, force_string=True), width=50)) + for prop, value in sorted(props.items())) + debugtxt += "\n|y... END MENU DEBUG (use menudebug for full value)|n" + self.caller.msg(debugtxt) + def parse_input(self, raw_string): """ Parses the incoming string from the menu user. @@ -870,6 +907,8 @@ class EvMenu(object): self.display_helptext() elif self.auto_quit and cmd in ("quit", "q", "exit"): self.close_menu() + elif self.debug_mode and cmd.startswith("menudebug"): + self.print_debug_info(cmd[9:].strip()) elif self.default: goto, goto_kwargs, execfunc, exec_kwargs = self.default self.run_exec_then_goto(execfunc, goto, raw_string, exec_kwargs, goto_kwargs) From f4422544cd091825d7cb411797e2d36220d29fee Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 23 Aug 2018 22:09:36 +0200 Subject: [PATCH 391/466] Resolve error in prototype validate menu node --- evennia/prototypes/menus.py | 2 +- evennia/prototypes/spawner.py | 37 +++++++++++++++++++++++------------ 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index cc5a9f5914..1d291e8732 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -377,7 +377,7 @@ def _default_parse(raw_inp, choices, *args): def node_validate_prototype(caller, raw_string, **kwargs): """General node to view and validate a protototype""" - prototype = _get_flat_menu_prototype(caller, validate=False) + prototype = _get_flat_menu_prototype(caller, refresh=True, validate=False) prev_node = kwargs.get("back", "index") _, text = _validate_prototype(prototype) diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index f250287c8f..5ead6239e7 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -143,25 +143,34 @@ _NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES # Helper -def _get_prototype(dic, prot, protparents): +def _get_prototype(inprot, protparents, uninherited=None, _workprot=None): """ Recursively traverse a prototype dictionary, including multiple inheritance. Use validate_prototype before this, we don't check for infinite recursion here. + Args: + inprot (dict): Prototype dict (the individual prototype, with no inheritance included). + protparents (dict): Available protparents, keyed by prototype_key. + uninherited (dict): Parts of prototype to not inherit. + _workprot (dict, optional): Work dict for the recursive algorithm. + """ - # we don't overload the prototype_key - prototype_key = prot.get('prototype_key', None) - if "prototype_parent" in dic: + _workprot = {} if _workprot is None else _workprot + if "prototype_parent" in inprot: # move backwards through the inheritance - for prototype in make_iter(dic["prototype_parent"]): + for prototype in make_iter(inprot["prototype_parent"]): # Build the prot dictionary in reverse order, overloading - new_prot = _get_prototype(protparents.get(prototype.lower(), {}), prot, protparents) - prot.update(new_prot) - prot.update(dic) - prot['prototype_key'] = prototype_key - prot.pop("prototype_parent", None) # we don't need this anymore - return prot + new_prot = _get_prototype(protparents.get(prototype.lower(), {}), + protparents, _workprot=_workprot) + _workprot.update(new_prot) + # the inprot represents a higher level (a child prot), which should override parents + _workprot.update(inprot) + if uninherited: + # put back the parts that should not be inherited + _workprot.update(uninherited) + _workprot.pop("prototype_parent", None) # we don't need this for spawning + return _workprot def flatten_prototype(prototype, validate=False): @@ -181,7 +190,8 @@ def flatten_prototype(prototype, validate=False): protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()} protlib.validate_prototype(prototype, None, protparents, is_prototype_base=validate, strict=validate) - return _get_prototype(prototype, {}, protparents) + return _get_prototype(prototype, protparents, + uninherited={"prototype_key": prototype.get("prototype_key")}) return {} @@ -519,7 +529,8 @@ def spawn(*prototypes, **kwargs): for prototype in prototypes: protlib.validate_prototype(prototype, None, protparents, is_prototype_base=True) - prot = _get_prototype(prototype, {}, protparents) + prot = _get_prototype(prototype, protparents, + uninherited={"prototype_key": prototype.get("prototype_key")}) if not prot: continue From e72f0c832d47c0680b5c1001840bc8873e6b48b1 Mon Sep 17 00:00:00 2001 From: Henddher Pedroza Date: Thu, 23 Aug 2018 20:44:31 -0500 Subject: [PATCH 392/466] Code review: succint and pythonic statements --- evennia/utils/evform.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/evennia/utils/evform.py b/evennia/utils/evform.py index 8a9828bc17..3742f50da2 100644 --- a/evennia/utils/evform.py +++ b/evennia/utils/evform.py @@ -161,21 +161,12 @@ def _to_rect(lines): lines (list): list of `ANSIString`s Returns: - nlines (list): list of `ANSIString`s of + (list): list of `ANSIString`s of same length as the longest input line """ - maxl = 0 - for line in lines: - if isinstance(line, (ANSIString, basestring)): - maxl = max(len(line), maxl) - else: - raise ValueError() - nlines = [] - for line in lines: - line += ' ' * (maxl - len(line)) - nlines.append(line) - return nlines + maxl = max(len(line) for line in lines) + return [line + ' ' * (maxl - len(line)) for line in lines] def _to_ansi(obj, regexable=False): From 8a52a76d8cad48820c6809c2ba954b9f65896739 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 25 Aug 2018 23:46:05 +0200 Subject: [PATCH 393/466] Correct olc update options, add local display to menudebug command --- evennia/prototypes/menus.py | 16 ++++++++-------- evennia/utils/evmenu.py | 25 ++++++++++++++++++++----- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 1d291e8732..edef289962 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -2027,14 +2027,14 @@ def node_apply_diff(caller, **kwargs): sep=" |r->|n ", new='', change=inst)) options.append(_keep_option(key, prototype, base_obj, obj_prototype, diff, update_objects, back_node)) - options.extend( - [{"key": ("|wu|Wpdate {} objects".format(len(update_objects)), "update", "u"), - "desc": "Update {} objects".format(len(update_objects)), - "goto": (_apply_diff, {"prototype": prototype, "objects": update_objects, - "back_node": back_node, "diff": diff, "base_obj": base_obj})}, - {"key": ("|wr|Weset changes", "reset", "r"), - "goto": ("node_apply_diff", {"prototype": prototype, "back_node": back_node, - "objects": update_objects})}]) + options.extend( + [{"key": ("|wu|Wpdate {} objects".format(len(update_objects)), "update", "u"), + "desc": "Update {} objects".format(len(update_objects)), + "goto": (_apply_diff, {"prototype": prototype, "objects": update_objects, + "back_node": back_node, "diff": diff, "base_obj": base_obj})}, + {"key": ("|wr|Weset changes", "reset", "r"), + "goto": ("node_apply_diff", {"prototype": prototype, "back_node": back_node, + "objects": update_objects})}]) if ichanges < 1: text = ["Analyzed a random sample object (out of {}) - " diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 127e2d3f13..f82dc5cb1f 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -866,19 +866,34 @@ class EvMenu(object): props = {prop: value for prop, value in all_props if prop not in all_methods and prop not in all_builtins and not prop.endswith("__")} + local = {key: var for key, var in locals().items() + if key not in all_props and not key.endswith("__")} + if arg: if arg in props: debugtxt = " |y* {}:|n\n{}".format(arg, props[arg]) + elif arg in local: + debugtxt = " |y* {}:|n\n{}".format(arg, local[arg]) elif arg == 'full': - debugtxt = ("|yMENU DEBUG full ... |n\n" + "\n".join(props) + + debugtxt = ("|yMENU DEBUG full ... |n\n" + "\n".join( + "|y *|n {}: {}".format(key, val) + for key, val in sorted(props.items())) + + "\n |yLOCAL VARS:|n\n" + "\n".join( + "|y *|n {}: {}".format(key, val) + for key, val in sorted(local.items())) + "\n |y... END MENU DEBUG|n") else: debugtxt = "|yUsage: menudebug full||n" else: - debugtxt = "|yMENU DEBUG properties:|n\n" + "\n".join("|y *|n {}: {}".format( - prop, crop(to_str(value, force_string=True), width=50)) - for prop, value in sorted(props.items())) - debugtxt += "\n|y... END MENU DEBUG (use menudebug for full value)|n" + debugtxt = ("|yMENU DEBUG properties ... |n\n" + "\n".join( + "|y *|n {}: {}".format( + key, crop(to_str(val, force_string=True), width=50)) + for key, val in sorted(props.items())) + + "\n |yLOCAL VARS:|n\n" + "\n".join( + "|y *|n {}: {}".format( + key, crop(to_str(val, force_string=True), width=50)) + for key, val in sorted(local.items())) + + "\n |y... END MENU DEBUG|n") self.caller.msg(debugtxt) def parse_input(self, raw_string): From 566481310c67e4a7a4c9c9bf88af9d2d81565528 Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 31 Aug 2018 09:57:24 +0200 Subject: [PATCH 394/466] Update CHANGELOG, delay import of dummy handler --- CHANGELOG.md | 14 +++++++++++++- evennia/locks/lockhandler.py | 11 +++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22e14ae163..d169657c12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,7 @@ - Spawning an object using a prototype will automatically assign a new tag to it, named the same as the `prototype_key` and with the category `from_prototype`. - The spawn command was extended to accept a full prototype on one line. -- The spawn command got the /save switch to save the defined prototype and its key. +- The spawn command got the /save switch to save the defined prototype and its key - The command spawn/menu will now start an OLC (OnLine Creation) menu to load/save/edit/spawn prototypes. - The OLC allows for updating all objects previously created using a given prototype with any changes done. @@ -53,6 +53,16 @@ - Refactoring of webclient structure. +### Locks + +- New function `evennia.locks.lockhandler.check_lockstring`. This allows for checking an object + against an arbitrary lockstring without needing the lock to be stored on an object first. +- New function `evennia.locks.lockhandler.validate_lockstring` allows for stand-alone validation + of a lockstring. +- New function `evennia.locks.lockhandler.get_all_lockfuncs` gives a dict {"name": lockfunc} for + all available lock funcs. This is useful for dynamic listings. + + ### Utils - Added new `columnize` function for easily splitting text into multiple columns. At this point it @@ -62,6 +72,8 @@ This removes a problem with the original `textwrap.dedent` which will only dedent to the least indented part of a text. - Added `exit_cmd` to EvMore pager, to allow for calling a command (e.g. 'look') when leaving the pager. +- `get_all_typeclasses` will return dict `{"path": typeclass, ...}` for all typeclasses available + in the system. This is used by the new `@typeclass/list` subcommand (useful for builders etc). ### General diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py index 19bfbec707..2338b18667 100644 --- a/evennia/locks/lockhandler.py +++ b/evennia/locks/lockhandler.py @@ -114,6 +114,9 @@ from django.utils.translation import ugettext as _ __all__ = ("LockHandler", "LockException") WARNING_LOG = settings.LOCKWARNING_LOG_FILE +_LOCK_HANDLER = None + + # # Exception class. This will be raised @@ -614,8 +617,6 @@ class LockHandler(object): class _ObjDummy: lock_storage = '' -_LOCK_HANDLER = LockHandler(_ObjDummy()) - def check_lockstring(self, accessing_obj, lockstring, no_superuser_bypass=False, default=False, access_type=None): @@ -642,6 +643,9 @@ def check_lockstring(self, accessing_obj, lockstring, no_superuser_bypass=False, access (bool): If check is passed or not. """ + global _LOCKHANDLER + if not _LOCKHANDLER: + _LOCKHANDLER = LockHandler(_ObjDummy()) return _LOCK_HANDLER.check_lockstring( accessing_obj, lockstring, no_superuser_bypass=no_superuser_bypass, default=default, access_type=access_type) @@ -660,6 +664,9 @@ def validate_lockstring(lockstring): if no error was found. """ + global _LOCKHANDLER + if not _LOCKHANDLER: + _LOCKHANDLER = LockHandler(_ObjDummy()) return _LOCK_HANDLER.validate(lockstring) From e97d0d794a899a5412900c3f14a8d8d2b0912b8a Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 4 Sep 2018 20:33:48 +0200 Subject: [PATCH 395/466] Sync win/unix requirement files --- requirements.txt | 2 ++ win_requirements.txt | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 72df29b9d0..347b838fe6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ # Evennia dependencies, for Linux/Mac platforms +# general django > 1.10, < 2.0 twisted == 16.0.0 pillow == 2.9.0 @@ -8,5 +9,6 @@ future >= 0.15.2 django-sekizai inflect +# testing mock >= 1.0.1 anything diff --git a/win_requirements.txt b/win_requirements.txt index 8a130b3268..8a2f519a81 100644 --- a/win_requirements.txt +++ b/win_requirements.txt @@ -2,12 +2,16 @@ # windows specific pypiwin32 + # general django > 1.10, < 2.0 twisted >= 16.0.0 -mock >= 1.0.1 pillow == 2.9.0 pytz future >= 0.15.2 django-sekizai inflect + +# testing +mock >= 1.0.1 +anything From fa31367a761ff416d5fdb98dae17b22f8fe8661b Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Tue, 4 Sep 2018 20:33:54 +0200 Subject: [PATCH 396/466] Update the building menu, following Griatch's feedback --- evennia/contrib/building_menu.py | 181 +++++++++++++++++++++++++++---- evennia/contrib/tests.py | 7 +- 2 files changed, 164 insertions(+), 24 deletions(-) diff --git a/evennia/contrib/building_menu.py b/evennia/contrib/building_menu.py index 4a2f204c6c..75b7fe63c2 100644 --- a/evennia/contrib/building_menu.py +++ b/evennia/contrib/building_menu.py @@ -3,11 +3,39 @@ Module containing the building menu system. Evennia contributor: vincent-lg 2018 -Building menus are similar to `EvMenu`, except that they have been -specifically designed to edit information as a builder. Creating a -building menu in a command allows builders quick-editing of a -given object, like a room. Here is an example of output you could -obtain when editing the room: +Building menus are in-game menus, not unlike `EvMenu` though using a +different approach. Building menus have been specifically designed to edit +information as a builder. Creating a building menu in a command allows +builders quick-editing of a given object, like a room. If you follow the +steps below to add the contrib, you will have access to an `@edit` command +that will edit any default object offering to change its key and description. + +1. Import the `GenericBuildingCmd` class from this contrib in your `mygame/commands/default_cmdset.py` file: + + ```python + from evennia.contrib.building_menu import GenericBuildingCmd + ``` + +2. Below, add the command in the `CharacterCmdSet`: + + ```python + # ... These lines should exist in the file + class CharacterCmdSet(default_cmds.CharacterCmdSet): + key = "DefaultCharacter" + + def at_cmdset_creation(self): + super(CharacterCmdSet, self).at_cmdset_creation() + # ... add the line below + self.add(GenericBuildingCmd()) + ``` + +The `@edit` command will allow you to edit any object. You will need to +specify the object name or ID as an argument. For instance: `@edit here` +will edit the current room. However, building menus can perform much more +than this very simple example, read on for more details. + +Building menus can be set to edit about anything. Here is an example of +output you could obtain when editing the room: ``` Editing the room: Limbo(#2) @@ -51,12 +79,24 @@ and enter t, she will be in the title choice. She can change the title (it will write in the room's `key` attribute) and then go back to the main menu using `@`. -`add_choice` has a lot of arguments and offer a great deal of +`add_choice` has a lot of arguments and offers a great deal of flexibility. The most useful ones is probably the usage of callbacks, as you can set almost any argument in `add_choice` to be a callback, a function that you have defined above in your module. This function will be called when the menu element is triggered. +Notice that in order to edit a description, the best method to call isn't +`add_choice`, but `add_choice_edit`. This is a convenient shortcut +which is available to quickly open an `EvEditor` when entering this choice +and going back to the menu when the editor closes. + +``` +class RoomBuildingMenu(BuildingMenu): + def init(self, room): + self.add_choice("title", "t", attr="key") + self.add_choice_edit("description", key="d", attr="db.desc") +``` + When you wish to create a building menu, you just need to import your class, create it specifying your intended caller and object to edit, then call `open`: @@ -66,6 +106,8 @@ from import RoomBuildingMenu class CmdEdit(Command): + key = "redit" + def func(self): menu = RoomBuildingMenu(self.caller, self.caller.location) menu.open() @@ -114,7 +156,7 @@ def _menu_savefunc(caller, buf): return True def _menu_quitfunc(caller): - caller.cmdset.add(BuildingMenuCmdSet, permanent=calelr.ndb._building_menu and caller.ndb._building_menu.persistent or False) + caller.cmdset.add(BuildingMenuCmdSet, permanent=caller.ndb._building_menu and caller.ndb._building_menu.persistent or False) if caller.ndb._building_menu: caller.ndb._building_menu.move(back=True) @@ -129,7 +171,7 @@ def _call_or_get(value, menu=None, choice=None, string=None, obj=None, caller=No menu (BuildingMenu, optional): the building menu to pass to value if it is a callable. choice (Choice, optional): the choice to pass to value if a callable. - string (str, optional): the raw string to pass to value if a callback. if a callable. + string (str, optional): the raw string to pass to value if a callback. obj (Object): the object to pass to value if a callable. caller (Account or Object, optional): the caller to pass to value if a callable. @@ -202,7 +244,10 @@ def menu_setattr(menu, choice, obj, string): """ attr = getattr(choice, "attr", None) if choice else None if choice is None or string is None or attr is None or menu is None: - log_err("The `menu_setattr` function was called to set the attribute {} of object {} to {}, but the choice {} of menu {} or another information is missing.".format(attr, obj, repr(string), choice, menu)) + log_err(dedent(""" + The `menu_setattr` function was called to set the attribute {} of object {} to {}, + but the choice {} of menu {} or another information is missing. + """.format(attr, obj, repr(string), choice, menu)).strip("\n")).strip() return for part in attr.split(".")[:-1]: @@ -219,6 +264,11 @@ def menu_quit(caller, menu): caller (Account or Object): the caller. menu (BuildingMenu): the building menu to close. + Note: + This callback is used by default when using the + `BuildingMenu.add_choice_quit` method. This method is called + automatically if the menu has no parent. + """ if caller is None or menu is None: log_err("The function `menu_quit` was called with missing arguments: caller={}, menu={}".format(caller, menu)) @@ -231,7 +281,7 @@ def menu_quit(caller, menu): def menu_edit(caller, choice, obj): """ - Open the EvEditor to edit a specified field. + Open the EvEditor to edit a specified attribute. Args: caller (Account or Object): the caller. @@ -437,13 +487,13 @@ class BuildingMenu(object): """ Class allowing to create and set building menus to edit specific objects. - A building menu is a kind of `EvMenu` designed to edit objects by - builders, although it can be used for players in some contexts. You - could, for instance, create a building menu to edit a room with a + A building menu is somewhat similar to `EvMenu`, but designed to edit + objects by builders, although it can be used for players in some contexts. + You could, for instance, create a building menu to edit a room with a sub-menu for the room's key, another for the room's description, another for the room's exits, and so on. - To add choices (sub-menus), you should call `add_choice` (see the + To add choices (simple sub-menus), you should call `add_choice` (see the full documentation of this method). With most arguments, you can specify either a plain string or a callback. This callback will be called when the operation is to be performed. @@ -492,9 +542,13 @@ class BuildingMenu(object): self.persistent = persistent self.choices = [] self.cmds = {} + self.can_quit = False if obj: self.init(obj) + if not parents and not self.can_quit: + # Automatically add the menu to quit + self.add_choice_quit(key=None) self._add_keys_choice() @property @@ -686,16 +740,26 @@ class BuildingMenu(object): key = key.lower() aliases = aliases or [] aliases = [a.lower() for a in aliases] - if on_enter is None and on_nomatch is None: - if attr is None: - raise ValueError("The choice {} has neither attr nor callback, specify one of these as arguments".format(title)) - if attr and on_nomatch is None: on_nomatch = menu_setattr if key and key in self.cmds: raise ValueError("A conflict exists between {} and {}, both use key or alias {}".format(self.cmds[key], title, repr(key))) + if attr: + if glance is None: + glance = "{obj." + attr + "}" + if text is None: + text = """ + ------------------------------------------------------------------------------- + {attr} for {{obj}}(#{{obj.id}}) + + You can change this value simply by entering it. + Use |y{back}|n to go back to the main menu. + + Current value: |c{{{obj_attr}}}|n + """.format(attr=attr, obj_attr="obj." + attr, back="|n or |y".join(self.keys_go_back)) + choice = Choice(title, key=key, aliases=aliases, attr=attr, text=text, glance=glance, on_enter=on_enter, on_nomatch=on_nomatch, on_leave=on_leave, menu=self, caller=self.caller, obj=self.obj) self.choices.append(choice) @@ -731,7 +795,7 @@ class BuildingMenu(object): """ on_enter = on_enter or menu_edit - return self.add_choice(title, key=key, aliases=aliases, attr=attr, glance=glance, on_enter=on_enter) + return self.add_choice(title, key=key, aliases=aliases, attr=attr, glance=glance, on_enter=on_enter, text="") def add_choice_quit(self, title="quit the menu", key="q", aliases=None, on_enter=None): """ @@ -753,6 +817,7 @@ class BuildingMenu(object): """ on_enter = on_enter or menu_quit + self.can_quit = True return self.add_choice(title, key=key, aliases=aliases, on_enter=on_enter) def open(self): @@ -767,6 +832,11 @@ class BuildingMenu(object): """ caller = self.caller self._save() + + # Remove the same-key cmdset if exists + if caller.cmdset.has(BuildingMenuCmdSet): + caller.cmdset.remove(BuildingMenuCmdSet) + self.caller.cmdset.add(BuildingMenuCmdSet, permanent=self.persistent) self.display() @@ -923,7 +993,11 @@ class BuildingMenu(object): if choice.glance: glance = _call_or_get(choice.glance, menu=self, choice=choice, caller=self.caller, string="", obj=self.obj) - glance = glance.format(obj=self.obj, caller=self.caller) + try: + glance = glance.format(obj=self.obj, caller=self.caller) + except: + import pdb;pdb.set_trace() + ret += ": " + glance return ret @@ -978,3 +1052,70 @@ class BuildingMenu(object): return return building_menu + + +# Generic building menu and command +class GenericBuildingMenu(BuildingMenu): + + """A generic building menu, allowing to edit any object. + + This is more a demonstration menu. By default, it allows to edit the + object key and description. Nevertheless, it will be useful to demonstrate + how building menus are meant to be used. + + """ + + def init(self, obj): + """Build the meny, adding the 'key' and 'description' choices. + + Args: + obj (Object): any object to be edited, like a character or room. + + Note: + The 'quit' choice will be automatically added, though you can + call `add_choice_quit` to add this choice with different options. + + """ + self.add_choice("key", key="k", attr="key", glance="{obj.key}", text=""" + ------------------------------------------------------------------------------- + Editing the key of {{obj.key}}(#{{obj.id}}) + + You can change the simply by entering it. + Use |y{back}|n to go back to the main menu. + + Current key: |c{{obj.key}}|n + """.format(back="|n or |y".join(self.keys_go_back))) + self.add_choice_edit("description", key="d", attr="db.desc") + + +class GenericBuildingCmd(Command): + + """ + Generic building command. + + Syntax: + @edit [object] + + Open a building menu to edit the specified object. This menu allows to + change the object's key and description. + + Examples: + @edit here + @edit self + @edit #142 + + """ + + key = "@edit" + + def func(self): + if not self.args.strip(): + self.msg("You should provide an argument to this function: the object to edit.") + return + + obj = self.caller.search(self.args.strip(), global_search=True) + if not obj: + return + + menu = GenericBuildingMenu(self.caller, obj) + menu.open() diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index b651198ccf..6f73e23837 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1712,7 +1712,6 @@ class TestBuildingMenu(CommandTest): super(TestBuildingMenu, self).setUp() self.menu = BuildingMenu(caller=self.char1, obj=self.room1, title="test") self.menu.add_choice("title", key="t", attr="key") - self.menu.add_choice_quit() def test_quit(self): """Try to quit the building menu.""" @@ -1774,9 +1773,9 @@ class TestBuildingMenu(CommandTest): def on_nomatch_t2(caller, menu): menu.move("t3") # this time the key matters - t1 = self.menu.add_choice("what", key="t1", attr="t1", on_nomatch=on_nomatch_t1) - t2 = self.menu.add_choice("and", key="t1.*", attr="t2", on_nomatch=on_nomatch_t2) - t3 = self.menu.add_choice("why", key="t1.*.t3", attr="t3") + t1 = self.menu.add_choice("what", key="t1", on_nomatch=on_nomatch_t1) + t2 = self.menu.add_choice("and", key="t1.*", on_nomatch=on_nomatch_t2) + t3 = self.menu.add_choice("why", key="t1.*.t3") self.menu.open() # Move into t1 From ff5ffa8d0ec71ab98ad8af28e374073ca2f43c14 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 4 Sep 2018 21:59:31 +0200 Subject: [PATCH 397/466] Add evennia.set_trace() for easy launch of debugger --- CHANGELOG.md | 1 + evennia/__init__.py | 50 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d169657c12..b78961c6d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,7 @@ - Start structuring the `CHANGELOG` to list features in more detail. - Inflection and grouping of multiple objects in default room (an box, three boxes) +- `evennia.set_trace()` is now a shortcut for launching pdb/pudb on a line in the Evennia event loop. ### Contribs diff --git a/evennia/__init__.py b/evennia/__init__.py index fc916351ad..bb9c29a594 100644 --- a/evennia/__init__.py +++ b/evennia/__init__.py @@ -319,3 +319,53 @@ def _init(): del object del absolute_import del print_function + + +def set_trace(debugger="auto", term_size=(140, 40)): + """ + Helper function for running a debugger inside the Evennia event loop. + + Args: + debugger (str, optional): One of 'auto', 'pdb' or 'pudb'. Pdb is the standard debugger. Pudb + is an external package with a different, more 'graphical', ncurses-based UI. With + 'auto', will use pudb if possible, otherwise fall back to pdb. Pudb is available through + `pip install pudb`. + term_size (tuple, optional): Only used for Pudb and defines the size of the terminal + (width, height) in number of characters. + + Notes: + To use: + + 1) add this to a line to act as a breakpoint for entering the debugger: + + from evennia import set_trace; set_trace() + + 2) restart evennia in interactive mode + + evennia istart + + 3) debugger will appear in the interactive terminal when breakpoint is reached. Exit + with 'q', remove the break line and restart server when finished. + + """ + import sys + dbg = None + + if debugger in ('auto', 'pudb'): + try: + from pudb import debugger + dbg = debugger.Debugger(stdout=sys.__stdout__, + term_size=term_size) + except ImportError: + if debugger == 'pudb': + raise + pass + + if not dbg: + import pdb + dbg = pdb.Pdb(stdout=sys.__stdout__) + + # + # stopped at breakpoint. Use 'n' (next) to continue into the code. + # + dbg.set_trace() From 840692805840a9397f5d380bbf6274e33e780ef0 Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 30 Aug 2018 00:30:02 +0000 Subject: [PATCH 398/466] Implements user input and server output auditing. --- evennia/contrib/auditing/__init__.py | 0 evennia/contrib/auditing/example.py | 22 +++ evennia/contrib/auditing/server.py | 266 +++++++++++++++++++++++++++ evennia/contrib/auditing/tests.py | 66 +++++++ 4 files changed, 354 insertions(+) create mode 100644 evennia/contrib/auditing/__init__.py create mode 100644 evennia/contrib/auditing/example.py create mode 100644 evennia/contrib/auditing/server.py create mode 100644 evennia/contrib/auditing/tests.py diff --git a/evennia/contrib/auditing/__init__.py b/evennia/contrib/auditing/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evennia/contrib/auditing/example.py b/evennia/contrib/auditing/example.py new file mode 100644 index 0000000000..d75942489c --- /dev/null +++ b/evennia/contrib/auditing/example.py @@ -0,0 +1,22 @@ +from evennia.utils.logger import * +from twisted.internet.threads import deferToThread +import json + +def output(data, *args, **kwargs): + """ + Writes dictionaries of data generated by an AuditedServerSession to files + in JSON format, bucketed by date. + + Uses Evennia's native logger and writes to the default + log directory (~/yourgame/server/logs/ or settings.LOG_DIR) + + Args: + data (dict): Parsed session transmission data. + + """ + # Bucket logs by day + bucket = data.pop('objects')['time'].strftime('%Y-%m-%d') + + # Write it + log_file(json.dumps(data), filename="auditing_%s.log" % bucket) + \ No newline at end of file diff --git a/evennia/contrib/auditing/server.py b/evennia/contrib/auditing/server.py new file mode 100644 index 0000000000..6bdd06bbbf --- /dev/null +++ b/evennia/contrib/auditing/server.py @@ -0,0 +1,266 @@ +""" +Auditable Server Sessions: +Extension of the stock ServerSession that yields objects representing +all user input and all system output. + +Evennia contribution - Johnny 2017 +""" + +import os +import re +import socket + +from django.utils import timezone +from django.conf import settings as ev_settings +from evennia.utils import logger, mod_import, get_evennia_version +from evennia.server.serversession import ServerSession + +# Attributes governing auditing of commands and where to send log objects +AUDIT_CALLBACK = getattr(ev_settings, 'AUDIT_CALLBACK', None) +AUDIT_IN = getattr(ev_settings, 'AUDIT_IN', False) +AUDIT_OUT = getattr(ev_settings, 'AUDIT_OUT', False) +AUDIT_MASK_IGNORE = set(['@ccreate', '@create'] + getattr(ev_settings, 'AUDIT_IGNORE', [])) +AUDIT_MASK_KEEP_BIGRAM = set(['create', 'connect', '@userpassword'] + getattr(ev_settings, 'AUDIT_MASK_KEEP_BIGRAM', [])) + +if AUDIT_CALLBACK: + try: + AUDIT_CALLBACK = mod_import(AUDIT_CALLBACK).output + logger.log_info("Auditing module online.") + logger.log_info("Recording user input = %s." % AUDIT_IN) + logger.log_info("Recording server output = %s." % AUDIT_OUT) + except Exception as e: + logger.log_err("Failed to activate Auditing module. %s" % e) + +class AuditedServerSession(ServerSession): + """ + This class represents a player's session and is a template for + both portal- and server-side sessions. + + Each connection will see two session instances created: + + 1. A Portal session. This is customized for the respective connection + protocols that Evennia supports, like Telnet, SSH etc. The Portal + session must call init_session() as part of its initialization. The + respective hook methods should be connected to the methods unique + for the respective protocol so that there is a unified interface + to Evennia. + 2. A Server session. This is the same for all connected accounts, + regardless of how they connect. + + The Portal and Server have their own respective sessionhandlers. These + are synced whenever new connections happen or the Server restarts etc, + which means much of the same information must be stored in both places + e.g. the portal can re-sync with the server when the server reboots. + + This particular implementation parses all server inputs and/or outputs and + passes a dict containing the parsed metadata to a callback method of your + creation. This is useful for recording player activity where necessary for + security auditing, usage analysis or post-incident forensic discovery. + + *** WARNING *** + All strings are recorded and stored in plaintext. This includes those strings + which might contain sensitive data (create, connect, @password). These commands + have their arguments masked by default, but you must mask or mask any + custom commands of your own that handle sensitive information. + + Installation: + + Designate this class as the SERVER_SESSION_CLASS in `settings.py`, then set + some additional options concerning what to log and where to send it. + + settings.py: + SERVER_SESSION_CLASS = 'evennia.contrib.auditing.server.AuditedServerSession' + + # Where to send logs? Define the path to a module containing a function + # called 'output()' you've written that accepts a dict object as its sole + # argument. From that function you can store/forward the message received + # as you please. An example file-logger is below: + AUDIT_CALLBACK = 'evennia.contrib.auditing.examples' + + # Log all user input? Be ethical about this; it will log all private and + # public communications between players and/or admins. + AUDIT_IN = True/False + + # Log all server output? This will result in logging of ALL system + # messages and ALL broadcasts to connected players, so on a busy MUD this + # will be very voluminous! + AUDIT_OUT = True/False + + # What commands do you NOT want masked for sensitivity? + AUDIT_MASK_IGNORE = ['@ccreate', '@create'] + + # What commands do you want to keep the first two terms of, masking the rest? + # This only triggers if there are more than two terms in the message. + AUDIT_MASK_KEEP_BIGRAM = ['create', 'connect', '@userpassword'] + """ + def audit(self, **kwargs): + """ + Extracts messages and system data from a Session object upon message + send or receive. + + Kwargs: + src (str): Source of data; 'client' or 'server'. Indicates direction. + text (list): Message sent from client to server. + text (str): Message from server back to client. + + Returns: + log (dict): Dictionary object containing parsed system and user data + related to this message. + + """ + # Get time at start of processing + time_obj = timezone.now() + time_str = str(time_obj) + + # Sanitize user input + session = self + src = kwargs.pop('src', '?') + bytes = 0 + + if src == 'client': + try: + data = str(kwargs['text'][0][0]) + except IndexError: + logger.log_err('Failed to parse client-submitted string!') + return False + + elif src == 'server': + # Server outputs can be unpredictable-- sometimes tuples, sometimes + # plain strings. Try to parse both. + try: + if isinstance(kwargs.get('text', ''), (tuple,list)): + data = kwargs['text'][0] + elif not 'text' in kwargs and len(kwargs.keys()) == 1: + data = kwargs.keys()[0] + else: + data = str(kwargs['text']) + + except: data = str(kwargs) + + bytes = len(data.encode('utf-8')) + + data = data.strip() + + # Do not log empty lines + if not data: return {} + + # Get current session's IP address + client_ip = session.address + + # Capture Account name and dbref together + account = session.get_account() + account_token = '' + if account: + account_token = '%s%s' % (account.key, account.dbref) + + # Capture Character name and dbref together + char = session.get_puppet() + char_token = '' + if char: + char_token = '%s%s' % (char.key, char.dbref) + + # Capture Room name and dbref together + room = None + room_token = '' + if char: + room = char.location + room_token = '%s%s' % (room.key, room.dbref) + + # Mask any PII in message, where possible + data = self.mask(data, **kwargs) + + # Compile the IP, Account, Character, Room, and the message. + log = { + 'time': time_str, + 'hostname': socket.getfqdn(), + 'application': '%s' % ev_settings.SERVERNAME, + 'version': get_evennia_version(), + 'pid': os.getpid(), + 'direction': 'SND' if src == 'server' else 'RCV', + 'protocol': self.protocol_key, + 'ip': client_ip, + 'session': 'session#%s' % self.sessid, + 'account': account_token, + 'character': char_token, + 'room': room_token, + 'msg': '%s' % data, + 'bytes': bytes, + 'objects': { + 'time': time_obj, + 'session': self, + 'account': account, + 'character': char, + 'room': room, + } + } + + return log + + def mask(self, msg, **kwargs): + """ + Masks potentially sensitive user information within messages before + writing to log. Recording cleartext password attempts is bad policy. + + Args: + msg (str): Raw text string sent from client <-> server + + Returns: + msg (str): Text string with sensitive information masked out. + + """ + # Get command based on fuzzy match + command = next((x for x in re.findall('^(?:Command\s\')*[\s]*([create]{5,6}|[connect]{6,7}|[@userpassword]{6,13}).*', msg, flags=re.IGNORECASE)), None) + if not command or command in AUDIT_MASK_IGNORE: + return msg + + # Break msg into terms + terms = [x.strip() for x in re.split('[\s\=]+', msg) if x] + num_terms = len(terms) + + # If the first term was typed correctly, grab the appropriate number + # of subsequent terms and mask the remainder + if command in AUDIT_MASK_KEEP_BIGRAM and num_terms >= 3: + terms = terms[:2] + ['*' * sum([len(x.zfill(8)) for x in terms[2:]])] + else: + # If the first term was not typed correctly, doesn't have the right + # number of terms or is clearly password-related, + # only grab the first term (minimizes chances of capturing passwords + # conjoined with username i.e. 'conect johnnypassword1234!'). + terms = [terms[0]] + ['*' * sum([len(x.zfill(8)) for x in terms[1:]])] + + msg = ' '.join(terms) + return msg + + def data_out(self, **kwargs): + """ + Generic hook for sending data out through the protocol. + + Kwargs: + kwargs (any): Other data to the protocol. + + """ + if AUDIT_CALLBACK and AUDIT_OUT: + try: + log = self.audit(src='server', **kwargs) + if log: AUDIT_CALLBACK(log, **kwargs) + except Exception as e: + logger.log_err(e) + + super(AuditedServerSession, self).data_out(**kwargs) + + def data_in(self, **kwargs): + """ + Hook for protocols to send incoming data to the engine. + + Kwargs: + kwargs (any): Other data from the protocol. + + """ + if AUDIT_CALLBACK and AUDIT_IN: + try: + log = self.audit(src='client', **kwargs) + if log: AUDIT_CALLBACK(log, **kwargs) + except Exception as e: + logger.log_err(e) + + super(AuditedServerSession, self).data_in(**kwargs) diff --git a/evennia/contrib/auditing/tests.py b/evennia/contrib/auditing/tests.py new file mode 100644 index 0000000000..4115cc58a5 --- /dev/null +++ b/evennia/contrib/auditing/tests.py @@ -0,0 +1,66 @@ +""" +Module containing the test cases for the Audit system. +""" + +from django.conf import settings +from evennia.contrib.auditing.server import AuditedServerSession +from evennia.utils.test_resources import EvenniaTest + +class AuditingTest(EvenniaTest): + def setUp(self): + # Configure session auditing settings + settings.AUDIT_CALLBACK = "evennia.contrib.auditing.examples" + settings.AUDIT_IN = True + settings.AUDIT_OUT = True + + # Configure settings to use custom session + settings.SERVER_SESSION_CLASS = "evennia.contrib.auditing.server.AuditedServerSession" + + super(AuditingTest, self).setUp() + + def test_mask(self): + """ + Make sure the 'mask' function is properly masking potentially sensitive + information from strings. + """ + safe_cmds = ( + 'say hello to my little friend', + '@ccreate channel = for channeling', + '@create a pretty shirt : evennia.contrib.clothing.Clothing', + '@charcreate johnnyefhiwuhefwhef', + 'Command "@logout" is not available. Maybe you meant "@color" or "@cboot"?', + ) + + for cmd in safe_cmds: + self.assertEqual(self.session.mask(cmd), cmd) + + unsafe_cmds = ( + ('connect johnny password123', 'connect johnny ***********'), + ('concnct johnny password123', 'concnct *******************'), + ('create johnny password123', 'create johnny ***********'), + ('@userpassword johnny = password234', '@userpassword johnny ***********'), + ('craete johnnypassword123', 'craete *****************'), + ("Command 'conncect teddy teddy' is not available. Maybe you meant \"@encode\"?", 'Command *************************************************************************************') + ) + + for unsafe, safe in unsafe_cmds: + self.assertEqual(self.session.mask(unsafe), safe) + + def test_audit(self): + """ + Make sure the 'audit' function is returning a dictionary based on values + parsed from the Session object. + """ + log = self.session.audit(src='client', text=[['hello']]) + obj = {k:v for k,v in log.iteritems() if k in ('direction', 'protocol', 'application', 'msg')} + self.assertEqual(obj, { + 'direction': 'RCV', + 'protocol': 'telnet', + 'application': 'Evennia', + 'msg': 'hello' + }) + + # Make sure auditor is breaking down responses without actual text + log = self.session.audit(**{'logged_in': {}, 'src': 'server'}) + self.assertEqual(log['msg'], 'logged_in') + \ No newline at end of file From 6fb375ace3c5f053f611f9a9971a3ec8a49087b9 Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 30 Aug 2018 23:24:33 +0000 Subject: [PATCH 399/466] Fixes broken coverage. --- evennia/contrib/auditing/example.py | 3 +-- evennia/contrib/auditing/tests.py | 20 +++++++++----------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/evennia/contrib/auditing/example.py b/evennia/contrib/auditing/example.py index d75942489c..f83ad130f6 100644 --- a/evennia/contrib/auditing/example.py +++ b/evennia/contrib/auditing/example.py @@ -1,5 +1,4 @@ -from evennia.utils.logger import * -from twisted.internet.threads import deferToThread +from evennia.utils.logger import log_file import json def output(data, *args, **kwargs): diff --git a/evennia/contrib/auditing/tests.py b/evennia/contrib/auditing/tests.py index 4115cc58a5..2e7e06e70e 100644 --- a/evennia/contrib/auditing/tests.py +++ b/evennia/contrib/auditing/tests.py @@ -6,18 +6,16 @@ from django.conf import settings from evennia.contrib.auditing.server import AuditedServerSession from evennia.utils.test_resources import EvenniaTest +# Configure session auditing settings +settings.AUDIT_CALLBACK = "evennia.contrib.auditing.examples" +settings.AUDIT_IN = True +settings.AUDIT_OUT = True + +# Configure settings to use custom session +settings.SERVER_SESSION_CLASS = "evennia.contrib.auditing.server.AuditedServerSession" + class AuditingTest(EvenniaTest): - def setUp(self): - # Configure session auditing settings - settings.AUDIT_CALLBACK = "evennia.contrib.auditing.examples" - settings.AUDIT_IN = True - settings.AUDIT_OUT = True - - # Configure settings to use custom session - settings.SERVER_SESSION_CLASS = "evennia.contrib.auditing.server.AuditedServerSession" - - super(AuditingTest, self).setUp() - + def test_mask(self): """ Make sure the 'mask' function is properly masking potentially sensitive From ef6494c5ac601a2298ee5b4d4a82d7dd8838c0c9 Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 4 Sep 2018 21:48:03 +0000 Subject: [PATCH 400/466] Allows more configurable extensibility and addresses PR change requests. --- evennia/contrib/auditing/example.py | 21 ----- evennia/contrib/auditing/outputs.py | 58 ++++++++++++ evennia/contrib/auditing/server.py | 136 +++++++++++++--------------- evennia/contrib/auditing/tests.py | 42 +++++++-- 4 files changed, 157 insertions(+), 100 deletions(-) delete mode 100644 evennia/contrib/auditing/example.py create mode 100644 evennia/contrib/auditing/outputs.py diff --git a/evennia/contrib/auditing/example.py b/evennia/contrib/auditing/example.py deleted file mode 100644 index f83ad130f6..0000000000 --- a/evennia/contrib/auditing/example.py +++ /dev/null @@ -1,21 +0,0 @@ -from evennia.utils.logger import log_file -import json - -def output(data, *args, **kwargs): - """ - Writes dictionaries of data generated by an AuditedServerSession to files - in JSON format, bucketed by date. - - Uses Evennia's native logger and writes to the default - log directory (~/yourgame/server/logs/ or settings.LOG_DIR) - - Args: - data (dict): Parsed session transmission data. - - """ - # Bucket logs by day - bucket = data.pop('objects')['time'].strftime('%Y-%m-%d') - - # Write it - log_file(json.dumps(data), filename="auditing_%s.log" % bucket) - \ No newline at end of file diff --git a/evennia/contrib/auditing/outputs.py b/evennia/contrib/auditing/outputs.py new file mode 100644 index 0000000000..ec5e84200f --- /dev/null +++ b/evennia/contrib/auditing/outputs.py @@ -0,0 +1,58 @@ +""" +Auditable Server Sessions - Example Outputs +Example methods demonstrating output destinations for logs generated by +audited server sessions. + +This is designed to be a single source of events for developers to customize +and add any additional enhancements before events are written out-- i.e. if you +want to keep a running list of what IPs a user logs in from on account/character +objects, or if you want to perform geoip or ASN lookups on IPs before committing, +or tag certain events with the results of a reputational lookup, this should be +the easiest place to do it. Write a method and invoke it via +`settings.AUDIT_CALLBACK` to have log data objects passed to it. + +Evennia contribution - Johnny 2017 +""" +from evennia.utils.logger import log_file +import json +import syslog + +def to_file(data): + """ + Writes dictionaries of data generated by an AuditedServerSession to files + in JSON format, bucketed by date. + + Uses Evennia's native logger and writes to the default + log directory (~/yourgame/server/logs/ or settings.LOG_DIR) + + Args: + data (dict): Parsed session transmission data. + + """ + # Bucket logs by day and remove objects before serialization + bucket = data.pop('objects')['time'].strftime('%Y-%m-%d') + + # Write it + log_file(json.dumps(data), filename="audit_%s.log" % bucket) + +def to_syslog(data): + """ + Writes dictionaries of data generated by an AuditedServerSession to syslog. + + Takes advantage of your system's native logger and writes to wherever + you have it configured, which is independent of Evennia. + Linux systems tend to write to /var/log/syslog. + + If you're running rsyslog, you can configure it to dump and/or forward logs + to disk and/or an external data warehouse (recommended-- if your server is + compromised or taken down, losing your logs along with it is no help!). + + Args: + data (dict): Parsed session transmission data. + + """ + # Remove objects before serialization + data.pop('objects') + + # Write it out + syslog.syslog(json.dumps(data)) \ No newline at end of file diff --git a/evennia/contrib/auditing/server.py b/evennia/contrib/auditing/server.py index 6bdd06bbbf..d636395e7d 100644 --- a/evennia/contrib/auditing/server.py +++ b/evennia/contrib/auditing/server.py @@ -1,7 +1,7 @@ """ Auditable Server Sessions: Extension of the stock ServerSession that yields objects representing -all user input and all system output. +user inputs and system outputs. Evennia contribution - Johnny 2017 """ @@ -19,39 +19,27 @@ from evennia.server.serversession import ServerSession AUDIT_CALLBACK = getattr(ev_settings, 'AUDIT_CALLBACK', None) AUDIT_IN = getattr(ev_settings, 'AUDIT_IN', False) AUDIT_OUT = getattr(ev_settings, 'AUDIT_OUT', False) -AUDIT_MASK_IGNORE = set(['@ccreate', '@create'] + getattr(ev_settings, 'AUDIT_IGNORE', [])) -AUDIT_MASK_KEEP_BIGRAM = set(['create', 'connect', '@userpassword'] + getattr(ev_settings, 'AUDIT_MASK_KEEP_BIGRAM', [])) +AUDIT_MASKS = [ + {'connect': r"^[@\s]*[connect]{5,8}\s+(\".+?\"|[^\s]+)\s+(?P.+)"}, + {'connect': r"^[@\s]*[connect]{5,8}\s+(?P[\w\\]+)"}, + {'create': r"^[^@]?[create]{5,7}\s+(\w+|\".+?\")\s+(?P[\w\\]+)"}, + {'create': r"^[^@]?[create]{5,7}\s+(?P[\w\\]+)"}, + {'userpassword': r"^[@\s]*[userpassword]{11,14}\s+(\w+|\".+?\")\s+=*\s*(?P[\w\\]+)"}, + {'password': r"^[@\s]*[password]{6,9}\s+(?P.*)"}, +] + getattr(ev_settings, 'AUDIT_MASKS', []) if AUDIT_CALLBACK: try: - AUDIT_CALLBACK = mod_import(AUDIT_CALLBACK).output + AUDIT_CALLBACK = getattr(mod_import('.'.join(AUDIT_CALLBACK.split('.')[:-1])), AUDIT_CALLBACK.split('.')[-1]) logger.log_info("Auditing module online.") - logger.log_info("Recording user input = %s." % AUDIT_IN) - logger.log_info("Recording server output = %s." % AUDIT_OUT) + logger.log_info("Recording user input: %s" % AUDIT_IN) + logger.log_info("Recording server output: %s" % AUDIT_OUT) + logger.log_info("Log destination: %s" % AUDIT_CALLBACK) except Exception as e: logger.log_err("Failed to activate Auditing module. %s" % e) class AuditedServerSession(ServerSession): """ - This class represents a player's session and is a template for - both portal- and server-side sessions. - - Each connection will see two session instances created: - - 1. A Portal session. This is customized for the respective connection - protocols that Evennia supports, like Telnet, SSH etc. The Portal - session must call init_session() as part of its initialization. The - respective hook methods should be connected to the methods unique - for the respective protocol so that there is a unified interface - to Evennia. - 2. A Server session. This is the same for all connected accounts, - regardless of how they connect. - - The Portal and Server have their own respective sessionhandlers. These - are synced whenever new connections happen or the Server restarts etc, - which means much of the same information must be stored in both places - e.g. the portal can re-sync with the server when the server reboots. - This particular implementation parses all server inputs and/or outputs and passes a dict containing the parsed metadata to a callback method of your creation. This is useful for recording player activity where necessary for @@ -75,7 +63,7 @@ class AuditedServerSession(ServerSession): # called 'output()' you've written that accepts a dict object as its sole # argument. From that function you can store/forward the message received # as you please. An example file-logger is below: - AUDIT_CALLBACK = 'evennia.contrib.auditing.examples' + AUDIT_CALLBACK = 'evennia.contrib.auditing.outputs.to_file' # Log all user input? Be ethical about this; it will log all private and # public communications between players and/or admins. @@ -86,12 +74,17 @@ class AuditedServerSession(ServerSession): # will be very voluminous! AUDIT_OUT = True/False - # What commands do you NOT want masked for sensitivity? - AUDIT_MASK_IGNORE = ['@ccreate', '@create'] + # Any custom regexes to detect and mask sensitive information, to be used + # to detect and mask any sensitive custom commands you may develop. + # Takes the form of a list of dictionaries, one k:v pair per dictionary + # where the key name is the canonical name of a command and gets displayed + # at the tail end of the message so you can tell which regex masked it. + # The sensitive data itself must be captured in a named group with a + # label of 'secret'. + AUDIT_MASKS = [ + {'authentication': r"^@auth\s+(?P[\w]+)"}, + ] - # What commands do you want to keep the first two terms of, masking the rest? - # This only triggers if there are more than two terms in the message. - AUDIT_MASK_KEEP_BIGRAM = ['create', 'connect', '@userpassword'] """ def audit(self, **kwargs): """ @@ -100,8 +93,8 @@ class AuditedServerSession(ServerSession): Kwargs: src (str): Source of data; 'client' or 'server'. Indicates direction. - text (list): Message sent from client to server. - text (str): Message from server back to client. + text (str or list): Client sends messages to server in the form of + lists. Server sends messages to client as string. Returns: log (dict): Dictionary object containing parsed system and user data @@ -115,7 +108,7 @@ class AuditedServerSession(ServerSession): # Sanitize user input session = self src = kwargs.pop('src', '?') - bytes = 0 + bytecount = 0 if src == 'client': try: @@ -125,19 +118,9 @@ class AuditedServerSession(ServerSession): return False elif src == 'server': - # Server outputs can be unpredictable-- sometimes tuples, sometimes - # plain strings. Try to parse both. - try: - if isinstance(kwargs.get('text', ''), (tuple,list)): - data = kwargs['text'][0] - elif not 'text' in kwargs and len(kwargs.keys()) == 1: - data = kwargs.keys()[0] - else: - data = str(kwargs['text']) - - except: data = str(kwargs) + data = str(kwargs) - bytes = len(data.encode('utf-8')) + bytecount = len(data.encode('utf-8')) data = data.strip() @@ -167,7 +150,7 @@ class AuditedServerSession(ServerSession): room_token = '%s%s' % (room.key, room.dbref) # Mask any PII in message, where possible - data = self.mask(data, **kwargs) + data = self.mask(data) # Compile the IP, Account, Character, Room, and the message. log = { @@ -184,7 +167,7 @@ class AuditedServerSession(ServerSession): 'character': char_token, 'room': room_token, 'msg': '%s' % data, - 'bytes': bytes, + 'bytes': bytecount, 'objects': { 'time': time_obj, 'session': self, @@ -196,7 +179,7 @@ class AuditedServerSession(ServerSession): return log - def mask(self, msg, **kwargs): + def mask(self, msg): """ Masks potentially sensitive user information within messages before writing to log. Recording cleartext password attempts is bad policy. @@ -208,27 +191,38 @@ class AuditedServerSession(ServerSession): msg (str): Text string with sensitive information masked out. """ - # Get command based on fuzzy match - command = next((x for x in re.findall('^(?:Command\s\')*[\s]*([create]{5,6}|[connect]{6,7}|[@userpassword]{6,13}).*', msg, flags=re.IGNORECASE)), None) - if not command or command in AUDIT_MASK_IGNORE: - return msg - - # Break msg into terms - terms = [x.strip() for x in re.split('[\s\=]+', msg) if x] - num_terms = len(terms) + # Check to see if the command is embedded within server output + _msg = msg + is_embedded = False + match = re.match(".*Command.*'(.+)'.*is not available.*", msg, flags=re.IGNORECASE) + if match: + msg = match.group(1).replace('\\', '') + submsg = msg + is_embedded = True - # If the first term was typed correctly, grab the appropriate number - # of subsequent terms and mask the remainder - if command in AUDIT_MASK_KEEP_BIGRAM and num_terms >= 3: - terms = terms[:2] + ['*' * sum([len(x.zfill(8)) for x in terms[2:]])] - else: - # If the first term was not typed correctly, doesn't have the right - # number of terms or is clearly password-related, - # only grab the first term (minimizes chances of capturing passwords - # conjoined with username i.e. 'conect johnnypassword1234!'). - terms = [terms[0]] + ['*' * sum([len(x.zfill(8)) for x in terms[1:]])] - - msg = ' '.join(terms) + for mask in AUDIT_MASKS: + for command, regex in mask.iteritems(): + try: + match = re.match(regex, msg, flags=re.IGNORECASE) + except Exception as e: + logger.log_err(modified_regex) + logger.log_err(e) + continue + + if match: + term = match.group('secret') + try: + masked = re.sub(term, '*' * len(term.zfill(8)), msg) + except Exception as e: + print(msg, regex, term) + quit() + + if is_embedded: + msg = re.sub(submsg, masked, _msg, flags=re.IGNORECASE) + else: msg = masked + + return '%s ' % (msg, command) + return msg def data_out(self, **kwargs): @@ -242,7 +236,7 @@ class AuditedServerSession(ServerSession): if AUDIT_CALLBACK and AUDIT_OUT: try: log = self.audit(src='server', **kwargs) - if log: AUDIT_CALLBACK(log, **kwargs) + if log: AUDIT_CALLBACK(log) except Exception as e: logger.log_err(e) @@ -259,7 +253,7 @@ class AuditedServerSession(ServerSession): if AUDIT_CALLBACK and AUDIT_IN: try: log = self.audit(src='client', **kwargs) - if log: AUDIT_CALLBACK(log, **kwargs) + if log: AUDIT_CALLBACK(log) except Exception as e: logger.log_err(e) diff --git a/evennia/contrib/auditing/tests.py b/evennia/contrib/auditing/tests.py index 2e7e06e70e..0650469c72 100644 --- a/evennia/contrib/auditing/tests.py +++ b/evennia/contrib/auditing/tests.py @@ -5,9 +5,10 @@ Module containing the test cases for the Audit system. from django.conf import settings from evennia.contrib.auditing.server import AuditedServerSession from evennia.utils.test_resources import EvenniaTest +import re # Configure session auditing settings -settings.AUDIT_CALLBACK = "evennia.contrib.auditing.examples" +settings.AUDIT_CALLBACK = "evennia.contrib.auditing.outputs.to_syslog" settings.AUDIT_IN = True settings.AUDIT_OUT = True @@ -22,11 +23,19 @@ class AuditingTest(EvenniaTest): information from strings. """ safe_cmds = ( - 'say hello to my little friend', + '/say hello to my little friend', '@ccreate channel = for channeling', + '@create/drop some stuff', + '@create rock', '@create a pretty shirt : evennia.contrib.clothing.Clothing', '@charcreate johnnyefhiwuhefwhef', 'Command "@logout" is not available. Maybe you meant "@color" or "@cboot"?', + '/me says, "what is the password?"', + 'say the password is plugh', + # Unfortunately given the syntax, there is no way to discern the + # latter of these as sensitive + '@create pretty sunset' + '@create johnny password123', ) for cmd in safe_cmds: @@ -34,15 +43,32 @@ class AuditingTest(EvenniaTest): unsafe_cmds = ( ('connect johnny password123', 'connect johnny ***********'), - ('concnct johnny password123', 'concnct *******************'), + ('concnct johnny password123', 'concnct johnny ***********'), + ('concnct johnnypassword123', 'concnct *****************'), + ('connect "johnny five" "password 123"', 'connect "johnny five" **************'), + ('connect johnny "password 123"', 'connect johnny **************'), ('create johnny password123', 'create johnny ***********'), - ('@userpassword johnny = password234', '@userpassword johnny ***********'), + ('@password password1234 = password2345', '@password ***************************'), + ('@password password1234 password2345', '@password *************************'), + ('@passwd password1234 = password2345', '@passwd ***************************'), + ('@userpassword johnny = password234', '@userpassword johnny = ***********'), ('craete johnnypassword123', 'craete *****************'), - ("Command 'conncect teddy teddy' is not available. Maybe you meant \"@encode\"?", 'Command *************************************************************************************') + ("Command 'conncect teddy teddy' is not available. Maybe you meant \"@encode\"?", 'Command \'conncect ***** *****\' is not available. Maybe you meant "@encode"?'), + ("{'text': u'Command \\'conncect jsis dfiidf\\' is not available. Type \"help\" for help.'}", "{'text': u'Command \\'conncect jsis ******\\' is not available. Type \"help\" for help.'}") ) - for unsafe, safe in unsafe_cmds: - self.assertEqual(self.session.mask(unsafe), safe) + for index, (unsafe, safe) in enumerate(unsafe_cmds): + self.assertEqual(re.sub('', '', self.session.mask(unsafe)).strip(), safe) + + # Make sure scrubbing is not being abused to evade monitoring + secrets = [ + 'say password password password; ive got a secret that i cant explain', + 'whisper johnny = password let\'s lynch the landlord', + 'say connect johnny password1234 secret life of arabia', + "@password;eval(\"__import__('os').system('clear')\", {'__builtins__':{}})" + ] + for secret in secrets: + self.assertEqual(self.session.mask(secret), secret) def test_audit(self): """ @@ -60,5 +86,5 @@ class AuditingTest(EvenniaTest): # Make sure auditor is breaking down responses without actual text log = self.session.audit(**{'logged_in': {}, 'src': 'server'}) - self.assertEqual(log['msg'], 'logged_in') + self.assertEqual(log['msg'], "{'logged_in': {}}") \ No newline at end of file From 27796c786f8efeb7adee766b2dc14b49c2ee019a Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 4 Sep 2018 22:13:56 +0000 Subject: [PATCH 401/466] Fixes failing tests. --- evennia/contrib/auditing/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/auditing/tests.py b/evennia/contrib/auditing/tests.py index 0650469c72..1db245d7b4 100644 --- a/evennia/contrib/auditing/tests.py +++ b/evennia/contrib/auditing/tests.py @@ -53,8 +53,8 @@ class AuditingTest(EvenniaTest): ('@passwd password1234 = password2345', '@passwd ***************************'), ('@userpassword johnny = password234', '@userpassword johnny = ***********'), ('craete johnnypassword123', 'craete *****************'), - ("Command 'conncect teddy teddy' is not available. Maybe you meant \"@encode\"?", 'Command \'conncect ***** *****\' is not available. Maybe you meant "@encode"?'), - ("{'text': u'Command \\'conncect jsis dfiidf\\' is not available. Type \"help\" for help.'}", "{'text': u'Command \\'conncect jsis ******\\' is not available. Type \"help\" for help.'}") + ("Command 'conncect teddy teddy' is not available. Maybe you meant \"@encode\"?", 'Command \'conncect ******** ********\' is not available. Maybe you meant "@encode"?'), + ("{'text': u'Command \\'conncect jsis dfiidf\\' is not available. Type \"help\" for help.'}", "{'text': u'Command \\'conncect jsis ********\\' is not available. Type \"help\" for help.'}") ) for index, (unsafe, safe) in enumerate(unsafe_cmds): From 8f8ad26e66c9e5eab7dd4bd7074ec4d54412e2ef Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 5 Sep 2018 19:53:35 +0000 Subject: [PATCH 402/466] Adds filtering for sparse values, better/recursive parsing of text field, and regex-based command detection. --- evennia/contrib/auditing/server.py | 95 +++++++++++++++++------------- evennia/contrib/auditing/tests.py | 23 ++++---- 2 files changed, 67 insertions(+), 51 deletions(-) diff --git a/evennia/contrib/auditing/server.py b/evennia/contrib/auditing/server.py index d636395e7d..e5a9d67a67 100644 --- a/evennia/contrib/auditing/server.py +++ b/evennia/contrib/auditing/server.py @@ -5,26 +5,26 @@ user inputs and system outputs. Evennia contribution - Johnny 2017 """ - import os import re import socket from django.utils import timezone from django.conf import settings as ev_settings -from evennia.utils import logger, mod_import, get_evennia_version +from evennia.utils import utils, logger, mod_import, get_evennia_version from evennia.server.serversession import ServerSession # Attributes governing auditing of commands and where to send log objects AUDIT_CALLBACK = getattr(ev_settings, 'AUDIT_CALLBACK', None) AUDIT_IN = getattr(ev_settings, 'AUDIT_IN', False) AUDIT_OUT = getattr(ev_settings, 'AUDIT_OUT', False) +AUDIT_ALLOW_SPARSE = getattr(ev_settings, 'AUDIT_ALLOW_SPARSE', False) AUDIT_MASKS = [ {'connect': r"^[@\s]*[connect]{5,8}\s+(\".+?\"|[^\s]+)\s+(?P.+)"}, - {'connect': r"^[@\s]*[connect]{5,8}\s+(?P[\w\\]+)"}, - {'create': r"^[^@]?[create]{5,7}\s+(\w+|\".+?\")\s+(?P[\w\\]+)"}, - {'create': r"^[^@]?[create]{5,7}\s+(?P[\w\\]+)"}, - {'userpassword': r"^[@\s]*[userpassword]{11,14}\s+(\w+|\".+?\")\s+=*\s*(?P[\w\\]+)"}, + {'connect': r"^[@\s]*[connect]{5,8}\s+(?P[\w]+)"}, + {'create': r"^[^@]?[create]{5,6}\s+(\w+|\".+?\")\s+(?P[\w]+)"}, + {'create': r"^[^@]?[create]{5,6}\s+(?P[\w]+)"}, + {'userpassword': r"^[@\s]*[userpassword]{11,14}\s+(\w+|\".+?\")\s+=*\s*(?P[\w]+)"}, {'password': r"^[@\s]*[password]{6,9}\s+(?P.*)"}, ] + getattr(ev_settings, 'AUDIT_MASKS', []) @@ -34,7 +34,8 @@ if AUDIT_CALLBACK: logger.log_info("Auditing module online.") logger.log_info("Recording user input: %s" % AUDIT_IN) logger.log_info("Recording server output: %s" % AUDIT_OUT) - logger.log_info("Log destination: %s" % AUDIT_CALLBACK) + logger.log_info("Recording sparse values: %s" % AUDIT_ALLOW_SPARSE) + logger.log_info("Log callback destination: %s" % AUDIT_CALLBACK) except Exception as e: logger.log_err("Failed to activate Auditing module. %s" % e) @@ -69,16 +70,25 @@ class AuditedServerSession(ServerSession): # public communications between players and/or admins. AUDIT_IN = True/False - # Log all server output? This will result in logging of ALL system + # Log server output? This will result in logging of ALL system # messages and ALL broadcasts to connected players, so on a busy MUD this # will be very voluminous! AUDIT_OUT = True/False + # The default output is a dict. Do you want to allow key:value pairs with + # null/blank values? If you're just writing to disk, disabling this saves + # some disk space, but whether you *want* sparse values or not is more of a + # consideration if you're shipping logs to a NoSQL/schemaless database. + AUDIT_ALLOW_SPARSE = True/False + # Any custom regexes to detect and mask sensitive information, to be used - # to detect and mask any sensitive custom commands you may develop. + # to detect and mask any custom commands you may develop. # Takes the form of a list of dictionaries, one k:v pair per dictionary - # where the key name is the canonical name of a command and gets displayed - # at the tail end of the message so you can tell which regex masked it. + # where the key name is the canonical name of a command which gets displayed + # at the tail end of the message so you can tell which regex masked it-- + # i.e. for a log entry with a typoed `connect` command: + # `conncect johnny *********** ` + # # The sensitive data itself must be captured in a named group with a # label of 'secret'. AUDIT_MASKS = [ @@ -105,28 +115,13 @@ class AuditedServerSession(ServerSession): time_obj = timezone.now() time_str = str(time_obj) - # Sanitize user input session = self src = kwargs.pop('src', '?') bytecount = 0 - if src == 'client': - try: - data = str(kwargs['text'][0][0]) - except IndexError: - logger.log_err('Failed to parse client-submitted string!') - return False - - elif src == 'server': - data = str(kwargs) - - bytecount = len(data.encode('utf-8')) - - data = data.strip() - # Do not log empty lines - if not data: return {} - + if not kwargs: return {} + # Get current session's IP address client_ip = session.address @@ -148,10 +143,25 @@ class AuditedServerSession(ServerSession): if char: room = char.location room_token = '%s%s' % (room.key, room.dbref) - + + # Try to compile an input/output string + def drill(obj, bucket): + if isinstance(obj, dict): return bucket + elif utils.is_iter(obj): + for sub_obj in obj: + bucket.extend(drill(sub_obj, [])) + else: + bucket.append(obj) + return bucket + + text = kwargs.pop('text', '') + if utils.is_iter(text): + text = '|'.join(drill(text, [])) + # Mask any PII in message, where possible - data = self.mask(data) - + bytecount = len(text.encode('utf-8')) + text = self.mask(text) + # Compile the IP, Account, Character, Room, and the message. log = { 'time': time_str, @@ -166,8 +176,9 @@ class AuditedServerSession(ServerSession): 'account': account_token, 'character': char_token, 'room': room_token, - 'msg': '%s' % data, + 'text': text.strip(), 'bytes': bytecount, + 'data': kwargs, 'objects': { 'time': time_obj, 'session': self, @@ -176,6 +187,12 @@ class AuditedServerSession(ServerSession): 'room': room, } } + + # Remove any keys with blank values + if AUDIT_ALLOW_SPARSE == False: + log['data'] = {k:v for k,v in log['data'].iteritems() if v} + log['objects'] = {k:v for k,v in log['objects'].iteritems() if v} + log = {k:v for k,v in log.iteritems() if v} return log @@ -205,25 +222,21 @@ class AuditedServerSession(ServerSession): try: match = re.match(regex, msg, flags=re.IGNORECASE) except Exception as e: - logger.log_err(modified_regex) + logger.log_err(regex) logger.log_err(e) continue if match: term = match.group('secret') - try: - masked = re.sub(term, '*' * len(term.zfill(8)), msg) - except Exception as e: - print(msg, regex, term) - quit() + masked = re.sub(term, '*' * len(term.zfill(8)), msg) if is_embedded: - msg = re.sub(submsg, masked, _msg, flags=re.IGNORECASE) + msg = re.sub(submsg, '%s ' % (masked, command), _msg, flags=re.IGNORECASE) else: msg = masked - return '%s ' % (msg, command) + return msg - return msg + return _msg def data_out(self, **kwargs): """ diff --git a/evennia/contrib/auditing/tests.py b/evennia/contrib/auditing/tests.py index 1db245d7b4..434b4feb87 100644 --- a/evennia/contrib/auditing/tests.py +++ b/evennia/contrib/auditing/tests.py @@ -11,6 +11,7 @@ import re settings.AUDIT_CALLBACK = "evennia.contrib.auditing.outputs.to_syslog" settings.AUDIT_IN = True settings.AUDIT_OUT = True +settings.AUDIT_ALLOW_SPARSE = True # Configure settings to use custom session settings.SERVER_SESSION_CLASS = "evennia.contrib.auditing.server.AuditedServerSession" @@ -36,6 +37,7 @@ class AuditingTest(EvenniaTest): # latter of these as sensitive '@create pretty sunset' '@create johnny password123', + '{"text": "Command \'do stuff\' is not available. Type \"help\" for help."}' ) for cmd in safe_cmds: @@ -58,14 +60,14 @@ class AuditingTest(EvenniaTest): ) for index, (unsafe, safe) in enumerate(unsafe_cmds): - self.assertEqual(re.sub('', '', self.session.mask(unsafe)).strip(), safe) + self.assertEqual(re.sub(' ', '', self.session.mask(unsafe)).strip(), safe) # Make sure scrubbing is not being abused to evade monitoring secrets = [ 'say password password password; ive got a secret that i cant explain', - 'whisper johnny = password let\'s lynch the landlord', - 'say connect johnny password1234 secret life of arabia', - "@password;eval(\"__import__('os').system('clear')\", {'__builtins__':{}})" + 'whisper johnny = password\n let\'s lynch the landlord', + 'say connect johnny password1234|the secret life of arabia', + "@password eval(\"__import__('os').system('clear')\", {'__builtins__':{}})" ] for secret in secrets: self.assertEqual(self.session.mask(secret), secret) @@ -76,15 +78,16 @@ class AuditingTest(EvenniaTest): parsed from the Session object. """ log = self.session.audit(src='client', text=[['hello']]) - obj = {k:v for k,v in log.iteritems() if k in ('direction', 'protocol', 'application', 'msg')} + obj = {k:v for k,v in log.iteritems() if k in ('direction', 'protocol', 'application', 'text')} self.assertEqual(obj, { 'direction': 'RCV', 'protocol': 'telnet', 'application': 'Evennia', - 'msg': 'hello' + 'text': 'hello' }) - # Make sure auditor is breaking down responses without actual text - log = self.session.audit(**{'logged_in': {}, 'src': 'server'}) - self.assertEqual(log['msg'], "{'logged_in': {}}") - \ No newline at end of file + # Make sure OOB data is being recorded + log = self.session.audit(src='client', text="connect johnny password123", prompt="hp=20|st=10|ma=15", pane=2) + self.assertEqual(log['text'], 'connect johnny ***********') + self.assertEqual(log['data']['prompt'], 'hp=20|st=10|ma=15') + self.assertEqual(log['data']['pane'], 2) \ No newline at end of file From 7c50331ea7ca61a37454234276745e2c0b379f52 Mon Sep 17 00:00:00 2001 From: Johnny Date: Fri, 31 Aug 2018 23:25:19 +0000 Subject: [PATCH 403/466] Adds trailing slash to authenticate endpoint in urls.py to correct weird '/authenticatelogin' path. --- evennia/web/website/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/web/website/urls.py b/evennia/web/website/urls.py index 2f5dd88848..cb425656a4 100644 --- a/evennia/web/website/urls.py +++ b/evennia/web/website/urls.py @@ -18,7 +18,7 @@ urlpatterns = [ url(r'^tbi/', website_views.to_be_implemented, name='to_be_implemented'), # User Authentication (makes login/logout url names available) - url(r'^authenticate', include('django.contrib.auth.urls')), + url(r'^authenticate/', include('django.contrib.auth.urls')), # Django original admin page. Make this URL is always available, whether # we've chosen to use Evennia's custom admin or not. From c4f501123a63287824ce3482547b57aeee3f2539 Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 14 Sep 2018 08:47:46 +0200 Subject: [PATCH 404/466] Starting to fix prototype diffs which don't work right --- evennia/prototypes/menus.py | 23 +++- evennia/prototypes/protfuncs.py | 12 +- evennia/prototypes/spawner.py | 202 +++++++++++++++++++++++++++----- 3 files changed, 202 insertions(+), 35 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index edef289962..0178910755 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -59,7 +59,7 @@ def _get_flat_menu_prototype(caller, refresh=False, validate=False): def _get_unchanged_inherited(caller, protname): """Return prototype values inherited from parent(s), which are not replaced in child""" - protototype = _get_menu_prototype(caller) + prototype = _get_menu_prototype(caller) if protname in prototype: return protname[protname], False else: @@ -1968,6 +1968,7 @@ def node_apply_diff(caller, **kwargs): obj_prototype = kwargs.get("obj_prototype", None) base_obj = kwargs.get("base_obj", None) diff = kwargs.get("diff", None) + custom_location = kwargs.get("custom_location", None) if not update_objects: text = "There are no existing objects to update." @@ -1978,24 +1979,36 @@ def node_apply_diff(caller, **kwargs): if not diff: # use one random object as a reference to calculate a diff base_obj = choice(update_objects) - diff, obj_prototype = spawner.prototype_diff_from_object(prototype, base_obj) + + # from evennia import set_trace + diff, obj_prototype = spawner.prototype_diff_from_object( + prototype, base_obj, exceptions={"location": "KEEP"}) text = ["Suggested changes to {} objects. ".format(len(update_objects)), "Showing random example obj to change: {name} ({dbref}))\n".format( name=base_obj.key, dbref=base_obj.dbref)] helptext = """ + This will go through all existing objects and apply the changes you accept. + Be careful with this operation! The upgrade mechanism will try to automatically estimate what changes need to be applied. But the estimate is |wonly based on the analysis of one randomly selected object|n among all objects spawned by this prototype. If that object happens to be unusual in some way the estimate will be off and may lead to unexpected - results for other objects. Always test your objects carefully after an upgrade and - consider being conservative (switch to KEEP) or even do the update manually if you are - unsure that the results will be acceptable. """ + results for other objects. Always test your objects carefully after an upgrade and consider + being conservative (switch to KEEP) for things you are unsure of. For complex upgrades it + may be better to get help from an administrator with access to the `@py` command for doing + this manually. + + Note that the `location` will never be auto-adjusted because it's so rare to want to + homogenize the location of all object instances.""" options = [] ichanges = 0 + + # convert diff to a menu text + options to edit + for (key, inst) in sorted(((key, val) for key, val in diff.items()), key=lambda tup: tup[0]): if key in protlib._PROTOTYPE_META_NAMES: diff --git a/evennia/prototypes/protfuncs.py b/evennia/prototypes/protfuncs.py index 6dff62ef96..a13aa7e532 100644 --- a/evennia/prototypes/protfuncs.py +++ b/evennia/prototypes/protfuncs.py @@ -36,7 +36,7 @@ prototype key (this value must be possible to serialize in an Attribute). """ from ast import literal_eval -from random import randint as base_randint, random as base_random +from random import randint as base_randint, random as base_random, choice as base_choice from evennia.utils import search from evennia.utils.utils import justify as base_justify, is_iter, to_str @@ -101,6 +101,16 @@ def center_justify(*args, **kwargs): return "" +def choice(*args, **kwargs): + """ + Usage: $choice(val, val, val, ...) + Returns one of the values randomly + """ + if args: + return base_choice(args) + return "" + + def full_justify(*args, **kwargs): """ diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 5ead6239e7..08803797b3 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -128,6 +128,7 @@ import hashlib import time from django.conf import settings + import evennia from evennia.objects.models import ObjectDB from evennia.utils.utils import make_iter, is_iter @@ -138,6 +139,8 @@ from evennia.prototypes.prototypes import ( _CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") _PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") +_PROTOTYPE_ROOT_NAMES = ('typeclass', 'key', 'aliases', 'attrs', 'tags', 'locks', 'permissions', + 'location', 'home', 'destination') _NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES @@ -240,10 +243,10 @@ def prototype_from_object(obj): locks = obj.locks.all() if locks: prot['locks'] = ";".join(locks) - perms = obj.permissions.get() + perms = obj.permissions.get(return_list=True) if perms: prot['permissions'] = make_iter(perms) - aliases = obj.aliases.get() + aliases = obj.aliases.get(return_list=True) if aliases: prot['aliases'] = aliases tags = [(tag.db_key, tag.db_category, tag.db_data) @@ -258,7 +261,160 @@ def prototype_from_object(obj): return prot -def prototype_diff_from_object(prototype, obj): +def get_detailed_prototype_diff(prototype1, prototype2): + """ + A 'detailed' diff specifies differences down to individual sub-sectiions + of the prototype, like individual attributes, permissions etc. It is used + by the menu to allow a user to customize what should be kept. + + Args: + prototype1 (dict): Original prototype. + prototype2 (dict): Comparison prototype. + + Returns: + diff (dict): A structure detailing how to convert prototype1 to prototype2. + + Notes: + A detailed diff has instructions REMOVE, ADD, UPDATE and KEEP. + + """ + def _recursive_diff(old, new): + + old_type = type(old) + new_type = type(new) + + if old_type != new_type: + if old and not new: + return (old, new, "REMOVE") + elif not old and new: + return (old, new, "ADD") + else: + return (old, new, "UPDATE") + elif new_type == dict: + all_keys = set(old.keys() + new.keys()) + return {key: _recursive_diff(old.get(key), new.get(key)) for key in all_keys} + elif is_iter(new): + old_map = {part[0] if is_iter(part) else part: part for part in old} + new_map = {part[0] if is_iter(part) else part: part for part in new} + all_keys = set(old_map.keys() + new_map.keys()) + return new_type(_recursive_diff(old_map.get(key), new_map.get(key)) + for key in all_keys) + elif old != new: + return (old, new, "UPDATE") + else: + return (old, new, "KEEP") + + diff = _recursive_diff(prototype1, prototype2) + + return diff + + +def flatten_diff(detailed_diff): + """ + For spawning, a 'detailed' diff is not necessary, rather we just + want instructions on how to handle each root key. + + Args: + detailed_diff (dict): Diff produced by `get_detailed_prototype_diff` and + possibly modified by the user. + + Returns: + flattened_diff (dict): A flat structure detailing how to operate on each + root component of the prototype. + + Notes: + The flattened diff has the following possible instructions: + UPDATE, REPLACE, REMOVE + Many of the detailed diff's values can hold nested structures with their own + individual instructions. A detailed diff can have the following instructions: + REMOVE, ADD, UPDATE, KEEP + Here's how they are translated: + - All REMOVE -> REMOVE + - All ADD|UPDATE -> UPDATE + - All KEEP -> (remove from flattened diff) + - Mix KEEP, UPDATE, ADD -> UPDATE + - Mix REMOVE, KEEP, UPDATE, ADD -> REPLACE + """ + + typ = type(diffpart) + if typ == tuple and _is_diff_instruction(diffpart): + key = args[0] + _, val, inst = diffpart + elif typ == dict: + for key, subdiffpart in diffpart: + _apply_diff(subdiffpart, obj, *(args + (key, ))) + else: + # all other types in the diff are iterables (tups or lists) and + # are identified by their first element. + for tup in diffpart: + _apply_diff(tup, obj, *(args + (tup[0], ))) + + + + +def _is_diff_instruction(obj): + return (isinstance(obj, tuple) and + len(obj) == 3 and + obj[2] in ('KEEP', 'REMOVE', 'ADD', 'UPDATE')) + + +def apply_diff_to_prototype(prototype, diff): + """ + When spawning we don't need the full details of the diff; we have (in the menu) had our + chance to customize we just want to know if the + current root key should be + + """ + + +def menu_format_diff(diff): + """ + Reformat the diff in a way suitable for the olc menu. + + Args: + diff (dict): A diff as produced by `prototype_diff`. The root level of this diff + (which is always a dict) is used to group sub-changes. + + Returns: + + + """ + + def _apply_diff(diffpart, obj, *args): + """ + Recursively apply the diff for a given rootname. + + Args: + diffpart (tuple or dict): Part of diff to apply. + obj (Object): Object to apply diff to. + args (str): Listing of identifiers for the part to apply, + starting from the root. + + """ + typ = type(diffpart) + if typ == tuple and _is_diff_instruction(diffpart): + key = args[0] + _, val, inst = diffpart + elif typ == dict: + for key, subdiffpart in diffpart: + _apply_diff(subdiffpart, obj, *(args + (key, ))) + else: + # all other types in the diff are iterables (tups or lists) and + # are identified by their first element. + for tup in diffpart: + _apply_diff(tup, obj, *(args + (tup[0], ))) + + + def _iter_diff(obj): + if _is_diff_instruction(obj): + old, new, inst = obj + + out_dict = {} + for root_key, root_val in diff.items(): + pass + + +def prototype_diff_from_object(prototype, obj, exceptions=None): """ Get a simple diff for a prototype compared to an object which may or may not already have a prototype (or has one but changed locally). For more complex migratations a manual diff may be @@ -266,32 +422,25 @@ def prototype_diff_from_object(prototype, obj): Args: prototype (dict): Prototype. - obj (Object): Object to + obj (Object): Object to compare prototype against. + exceptions (dict, optional): A mapping {"key": "KEEP|REPLACE|UPDATE|REMOVE" for + enforcing a specific outcome for that key regardless of the diff. Returns: diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...} other_prototype (dict): The prototype for the given object. The diff is a how to convert this prototype into the new prototype. + diff = {"key": (old, new, "KEEP|REPLACE|UPDATE|REMOVE"), + "attrs": {"attrkey": (old, new, "KEEP|REPLACE|UPDATE|REMOVE"), + "attrkey": (old, new, "KEEP|REPLACE|UPDATE|REMOVE"), ...}, + "aliases": {"aliasname": (old, new, "KEEP...", ...}, + ... } + + """ - prot1 = prototype prot2 = prototype_from_object(obj) - - diff = {} - for key, value in prot1.items(): - diff[key] = "KEEP" - if key in prot2: - if callable(prot2[key]) or value != prot2[key]: - if key in ('attrs', 'tags', 'permissions', 'locks', 'aliases'): - diff[key] = 'REPLACE' - else: - diff[key] = "UPDATE" - elif key not in prot2: - diff[key] = "UPDATE" - for key in prot2: - if key not in diff and key not in prot1: - diff[key] = "REMOVE" - + diff = prototype_diff(prototype, prot2) return diff, prot2 @@ -589,17 +738,12 @@ def spawn(*prototypes, **kwargs): simple_attributes = [] for key, value in ((key, value) for key, value in prot.items() if not (key.startswith("ndb_"))): + # we don't support categories, nor locks for simple attributes if key in _PROTOTYPE_META_NAMES: continue - - if is_iter(value) and len(value) > 1: - # (value, category) - simple_attributes.append((key, - init_spawn_value(value[0], value_to_obj_or_any), - init_spawn_value(value[1], str))) else: - simple_attributes.append((key, - init_spawn_value(value, value_to_obj_or_any))) + simple_attributes.append( + (key, init_spawn_value(value, value_to_obj_or_any), None, None)) attributes = attributes + simple_attributes attributes = [tup for tup in attributes if not tup[0] in _NON_CREATE_KWARGS] From 1d1f8d5725ba698d47131ab5bb16b99435ced30e Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Fri, 14 Sep 2018 12:51:50 +0200 Subject: [PATCH 405/466] Force evennia.set_trace() to go to the upper frame --- evennia/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/evennia/__init__.py b/evennia/__init__.py index bb9c29a594..01bec176a1 100644 --- a/evennia/__init__.py +++ b/evennia/__init__.py @@ -365,7 +365,9 @@ def set_trace(debugger="auto", term_size=(140, 40)): import pdb dbg = pdb.Pdb(stdout=sys.__stdout__) + # Force the debugger to go up one frame + # (Otherwise, `set_trace` will be placed on this function, not the call) # # stopped at breakpoint. Use 'n' (next) to continue into the code. # - dbg.set_trace() + dbg.set_trace(sys._getframe().f_back) From 6a90ccd0afa941b53eec97258c83fb3b4085f3d3 Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 14 Sep 2018 22:12:12 +0200 Subject: [PATCH 406/466] Fix lockhandler import error --- evennia/locks/lockhandler.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py index 2338b18667..d7170c54d1 100644 --- a/evennia/locks/lockhandler.py +++ b/evennia/locks/lockhandler.py @@ -664,9 +664,9 @@ def validate_lockstring(lockstring): if no error was found. """ - global _LOCKHANDLER - if not _LOCKHANDLER: - _LOCKHANDLER = LockHandler(_ObjDummy()) + global _LOCK_HANDLER + if not _LOCK_HANDLER: + _LOCK_HANDLER = LockHandler(_ObjDummy()) return _LOCK_HANDLER.validate(lockstring) From c6aba5f802458528a7a8bcca72821ea1cc65a55d Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 14 Sep 2018 22:24:53 +0200 Subject: [PATCH 407/466] Minor refinement of code comment --- evennia/__init__.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/evennia/__init__.py b/evennia/__init__.py index 01bec176a1..6858744d42 100644 --- a/evennia/__init__.py +++ b/evennia/__init__.py @@ -365,9 +365,6 @@ def set_trace(debugger="auto", term_size=(140, 40)): import pdb dbg = pdb.Pdb(stdout=sys.__stdout__) - # Force the debugger to go up one frame - # (Otherwise, `set_trace` will be placed on this function, not the call) - # - # stopped at breakpoint. Use 'n' (next) to continue into the code. - # + # Start debugger, forcing it up one stack frame (otherwise `set_trace` will start # debugger at + # this point, not the actual code location) dbg.set_trace(sys._getframe().f_back) From d55634d542e88ad4bb7c75fd1df0aa00d75446be Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 15 Sep 2018 17:10:50 +0200 Subject: [PATCH 408/466] New version of prototype diff management --- evennia/prototypes/menus.py | 139 ++++++++++++++++++----------- evennia/prototypes/spawner.py | 161 +++++++++++++--------------------- 2 files changed, 149 insertions(+), 151 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 0178910755..1c7ae1ea92 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -1945,9 +1945,79 @@ def _apply_diff(caller, **kwargs): def _keep_diff(caller, **kwargs): - key = kwargs['key'] + path = kwargs['path'] diff = kwargs['diff'] - diff[key] = "KEEP" + tmp = diff + for key in path[:-1]: + tmp = diff[key] + tmp[path[-1]] = "KEEP" + + +def _format_diff_text_and_options(diff, exclude=None): + """ + Reformat the diff in a way suitable for the olc menu. + + Args: + diff (dict): A diff as produced by `prototype_diff`. + exclude (list, optional): List of root keys to skip, regardless + of diff instruction. + + Returns: + options (list): List of options dict. + + """ + valid_instructions = ('KEEP', 'REMOVE', 'ADD', 'UPDATE') + + def _visualize(obj, rootname, get_name=False): + if utils.is_iter(obj): + if get_name: + return obj[0] + if rootname == "attrs": + return "{} |W=|n {} |W(category:|n {}|W, locks:|n {}|W)|n".format(*obj) + elif rootname == "tags": + return "{} |W(category:|n {}|W)|n".format(obj[0], obj[1]) + return obj + + def _parse_diffpart(diffpart, optnum, indent, *args): + typ = type(diffpart) + texts = [] + options = [] + if typ == tuple and len(diffpart) == 3 and diffpart[2] in valid_instructions: + old, new, instruction = diffpart + if instruction == 'KEEP': + texts.append("{old} |gKEEP|n".format(old=old)) + else: + texts.append("{indent}|c({num}) {inst}|W:|n {old} |W->|n {new}".format( + indent=" " * indent, + inst="|rREMOVE|n" if instruction == 'REMOVE' else "|y{}|n".format(instruction), + num=optnum, + old=_visualize(old, args[-1]), + new=_visualize(new, args[-1]))) + options.append({"key": str(optnum), + "desc": "|gKEEP|n {}".format( + _visualize(old, args[-1], get_name=True)), + "goto": (_keep_diff, {"path": args, "diff": diff})}) + optnum += 1 + else: + for key, subdiffpart in diffpart.items(): + text, option, optnum = _parse_diffpart( + subdiffpart, optnum, indent + 1, *(args + (key, ))) + texts.extend(text) + options.extend(option) + return text, options, optnum + + texts = [] + options = [] + # we use this to allow for skipping full KEEP instructions + flattened_diff = spawner.flatten_diff(diff) + optnum = 1 + + for root_key, diffpart in flattened_diff.items(): + text, option, optnum = _parse_diffpart(diffpart, optnum, 1, root_key) + texts.extend(text) + options.extend(option) + + return texts, options def node_apply_diff(caller, **kwargs): @@ -1984,10 +2054,6 @@ def node_apply_diff(caller, **kwargs): diff, obj_prototype = spawner.prototype_diff_from_object( prototype, base_obj, exceptions={"location": "KEEP"}) - text = ["Suggested changes to {} objects. ".format(len(update_objects)), - "Showing random example obj to change: {name} ({dbref}))\n".format( - name=base_obj.key, dbref=base_obj.dbref)] - helptext = """ This will go through all existing objects and apply the changes you accept. @@ -2003,53 +2069,21 @@ def node_apply_diff(caller, **kwargs): Note that the `location` will never be auto-adjusted because it's so rare to want to homogenize the location of all object instances.""" - options = [] + txt, options = _format_diff_text_and_options(diff, exclude=['location'] if custom_location else None) - ichanges = 0 - - # convert diff to a menu text + options to edit - - for (key, inst) in sorted(((key, val) for key, val in diff.items()), key=lambda tup: tup[0]): - - if key in protlib._PROTOTYPE_META_NAMES: - continue - - line = "{iopt} |w{key}|n: {old}{sep}{new} {change}" - old_val = str(obj_prototype.get(key, "")) - - if inst == "KEEP": - inst = "|b{}|n".format(inst) - text.append(line.format(iopt='', key=key, old=old_val, - sep=" ", new='', change=inst)) - continue - - if key in prototype: - new_val = str(spawner.init_spawn_value(prototype[key])) - else: - new_val = "" - ichanges += 1 - if inst in ("UPDATE", "REPLACE"): - inst = "|y{}|n".format(inst) - text.append(line.format(iopt=ichanges, key=key, old=old_val, - sep=" |y->|n ", new=new_val, change=inst)) - options.append(_keep_option(key, prototype, - base_obj, obj_prototype, diff, update_objects, back_node)) - elif inst == "REMOVE": - inst = "|r{}|n".format(inst) - text.append(line.format(iopt=ichanges, key=key, old=old_val, - sep=" |r->|n ", new='', change=inst)) - options.append(_keep_option(key, prototype, - base_obj, obj_prototype, diff, update_objects, back_node)) - options.extend( - [{"key": ("|wu|Wpdate {} objects".format(len(update_objects)), "update", "u"), - "desc": "Update {} objects".format(len(update_objects)), - "goto": (_apply_diff, {"prototype": prototype, "objects": update_objects, - "back_node": back_node, "diff": diff, "base_obj": base_obj})}, - {"key": ("|wr|Weset changes", "reset", "r"), - "goto": ("node_apply_diff", {"prototype": prototype, "back_node": back_node, - "objects": update_objects})}]) - - if ichanges < 1: + if options: + text = ["Suggested changes to {} objects. ".format(len(update_objects)), + "Showing random example obj to change: {name} ({dbref}))\n".format( + name=base_obj.key, dbref=base_obj.dbref)] + txt + options.extend( + [{"key": ("|wu|Wpdate {} objects".format(len(update_objects)), "update", "u"), + "desc": "Update {} objects".format(len(update_objects)), + "goto": (_apply_diff, {"prototype": prototype, "objects": update_objects, + "back_node": back_node, "diff": diff, "base_obj": base_obj})}, + {"key": ("|wr|Weset changes", "reset", "r"), + "goto": ("node_apply_diff", {"prototype": prototype, "back_node": back_node, + "objects": update_objects})}]) + else: text = ["Analyzed a random sample object (out of {}) - " "found no changes to apply.".format(len(update_objects))] @@ -2058,7 +2092,6 @@ def node_apply_diff(caller, **kwargs): "goto": back_node}) text = "\n".join(text) - text = (text, helptext) return text, options diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 08803797b3..efce03eb7b 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -261,7 +261,7 @@ def prototype_from_object(obj): return prot -def get_detailed_prototype_diff(prototype1, prototype2): +def prototype_diff(prototype1, prototype2): """ A 'detailed' diff specifies differences down to individual sub-sectiions of the prototype, like individual attributes, permissions etc. It is used @@ -272,10 +272,12 @@ def get_detailed_prototype_diff(prototype1, prototype2): prototype2 (dict): Comparison prototype. Returns: - diff (dict): A structure detailing how to convert prototype1 to prototype2. - - Notes: - A detailed diff has instructions REMOVE, ADD, UPDATE and KEEP. + diff (dict): A structure detailing how to convert prototype1 to prototype2. All + nested structures are dicts with keys matching either the prototype's matching + key or the first element in the tuple describing the prototype value (so for + a tag tuple `(tagname, category)` the second-level key in the diff would be tagname). + The the bottom level of the diff consist of tuples `(old, new, instruction)`, where + instruction can be one of "REMOVE", "ADD", "UPDATE" or "KEEP". """ def _recursive_diff(old, new): @@ -297,8 +299,7 @@ def get_detailed_prototype_diff(prototype1, prototype2): old_map = {part[0] if is_iter(part) else part: part for part in old} new_map = {part[0] if is_iter(part) else part: part for part in new} all_keys = set(old_map.keys() + new_map.keys()) - return new_type(_recursive_diff(old_map.get(key), new_map.get(key)) - for key in all_keys) + return {key: _recursive_diff(old_map.get(key), new_map.get(key)) for key in all_keys} elif old != new: return (old, new, "UPDATE") else: @@ -309,14 +310,15 @@ def get_detailed_prototype_diff(prototype1, prototype2): return diff -def flatten_diff(detailed_diff): +def flatten_diff(diff): """ - For spawning, a 'detailed' diff is not necessary, rather we just - want instructions on how to handle each root key. + For spawning, a 'detailed' diff is not necessary, rather we just want instructions on how to + handle each root key. Args: - detailed_diff (dict): Diff produced by `get_detailed_prototype_diff` and - possibly modified by the user. + diff (dict): Diff produced by `prototype_diff` and + possibly modified by the user. Note that also a pre-flattened diff will come out + unchanged by this function. Returns: flattened_diff (dict): A flat structure detailing how to operate on each @@ -331,117 +333,77 @@ def flatten_diff(detailed_diff): Here's how they are translated: - All REMOVE -> REMOVE - All ADD|UPDATE -> UPDATE - - All KEEP -> (remove from flattened diff) + - All KEEP -> KEEP - Mix KEEP, UPDATE, ADD -> UPDATE - Mix REMOVE, KEEP, UPDATE, ADD -> REPLACE """ + valid_instructions = ('KEEP', 'REMOVE', 'ADD', 'UPDATE') + + def _get_all_nested_diff_instructions(diffpart): + "Started for each root key, returns all instructions nested under it" + out = [] typ = type(diffpart) - if typ == tuple and _is_diff_instruction(diffpart): - key = args[0] - _, val, inst = diffpart - elif typ == dict: - for key, subdiffpart in diffpart: - _apply_diff(subdiffpart, obj, *(args + (key, ))) + if typ == tuple and len(diffpart) == 3 and diffpart[2] in valid_instructions: + out = [diffpart[2]] + elif type == dict: + # all other are dicts + for val in diffpart.values(): + out.extend(_get_all_nested_diff_instructions(val)) else: - # all other types in the diff are iterables (tups or lists) and - # are identified by their first element. - for tup in diffpart: - _apply_diff(tup, obj, *(args + (tup[0], ))) + raise RuntimeError("Diff contains non-dicts that are not on the " + "form (old, new, inst): {}".format(diff)) + return out + flat_diff = {} - - -def _is_diff_instruction(obj): - return (isinstance(obj, tuple) and - len(obj) == 3 and - obj[2] in ('KEEP', 'REMOVE', 'ADD', 'UPDATE')) - - -def apply_diff_to_prototype(prototype, diff): - """ - When spawning we don't need the full details of the diff; we have (in the menu) had our - chance to customize we just want to know if the - current root key should be - - """ - - -def menu_format_diff(diff): - """ - Reformat the diff in a way suitable for the olc menu. - - Args: - diff (dict): A diff as produced by `prototype_diff`. The root level of this diff - (which is always a dict) is used to group sub-changes. - - Returns: - - - """ - - def _apply_diff(diffpart, obj, *args): - """ - Recursively apply the diff for a given rootname. - - Args: - diffpart (tuple or dict): Part of diff to apply. - obj (Object): Object to apply diff to. - args (str): Listing of identifiers for the part to apply, - starting from the root. - - """ - typ = type(diffpart) - if typ == tuple and _is_diff_instruction(diffpart): - key = args[0] - _, val, inst = diffpart - elif typ == dict: - for key, subdiffpart in diffpart: - _apply_diff(subdiffpart, obj, *(args + (key, ))) + # flatten diff based on rules + for rootkey, diffpart in diff.items(): + insts = _get_all_nested_diff_instructions(diffpart) + if all(inst == "KEEP" for inst in insts): + rootinst = "KEEP" + elif all(inst in ("ADD", "UPDATE") for inst in insts): + rootinst = "UPDATE" + elif all(inst == "REMOVE" for inst in insts): + rootinst = "REMOVE" + elif "REMOVE" in insts: + rootinst = "REPLACE" else: - # all other types in the diff are iterables (tups or lists) and - # are identified by their first element. - for tup in diffpart: - _apply_diff(tup, obj, *(args + (tup[0], ))) + rootinst = "UPDATE" + + flat_diff[rootkey] = rootinst + + return flat_diff - def _iter_diff(obj): - if _is_diff_instruction(obj): - old, new, inst = obj - - out_dict = {} - for root_key, root_val in diff.items(): - pass - - -def prototype_diff_from_object(prototype, obj, exceptions=None): +def prototype_diff_from_object(prototype, obj): """ Get a simple diff for a prototype compared to an object which may or may not already have a prototype (or has one but changed locally). For more complex migratations a manual diff may be needed. Args: - prototype (dict): Prototype. + prototype (dict): New prototype. obj (Object): Object to compare prototype against. - exceptions (dict, optional): A mapping {"key": "KEEP|REPLACE|UPDATE|REMOVE" for - enforcing a specific outcome for that key regardless of the diff. Returns: diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...} - other_prototype (dict): The prototype for the given object. The diff is a how to convert - this prototype into the new prototype. + obj_prototype (dict): The prototype calculated for the given object. The diff is how to + convert this prototype into the new prototype. - diff = {"key": (old, new, "KEEP|REPLACE|UPDATE|REMOVE"), - "attrs": {"attrkey": (old, new, "KEEP|REPLACE|UPDATE|REMOVE"), - "attrkey": (old, new, "KEEP|REPLACE|UPDATE|REMOVE"), ...}, - "aliases": {"aliasname": (old, new, "KEEP...", ...}, - ... } + Notes: + The `diff` is on the following form: + {"key": (old, new, "KEEP|REPLACE|UPDATE|REMOVE"), + "attrs": {"attrkey": (old, new, "KEEP|REPLACE|UPDATE|REMOVE"), + "attrkey": (old, new, "KEEP|REPLACE|UPDATE|REMOVE"), ...}, + "aliases": {"aliasname": (old, new, "KEEP...", ...}, + ... } """ - prot2 = prototype_from_object(obj) - diff = prototype_diff(prototype, prot2) - return diff, prot2 + obj_prototype = prototype_from_object(obj) + diff = prototype_diff(obj_prototype, prototype) + return diff, obj_prototype def batch_update_objects_with_prototype(prototype, diff=None, objects=None): @@ -475,6 +437,9 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None): if not diff: diff, _ = prototype_diff_from_object(new_prototype, objects[0]) + # make sure the diff is flattened + diff = flatten_diff(diff) + changed = 0 for obj in objects: do_save = False From a29b46d0915cc95d672ce482010840420bd759b4 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 19 Sep 2018 22:51:27 +0200 Subject: [PATCH 409/466] Cleanup, bug fixes, refactoring --- evennia/prototypes/menus.py | 2 +- evennia/prototypes/prototypes.py | 514 ++++++++++++++++-------------- evennia/prototypes/spawner.py | 53 +-- evennia/prototypes/tests.py | 135 +++++--- evennia/typeclasses/attributes.py | 2 +- 5 files changed, 404 insertions(+), 302 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 1c7ae1ea92..e18e467e35 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -1092,7 +1092,7 @@ def _add_attr(caller, attr_string, **kwargs): attrname, category = nameparts elif nparts > 2: attrname, category, locks = nameparts - attr_tuple = (attrname, value, category, locks) + attr_tuple = (attrname, value, category, str(locks)) if attrname: prot = _get_menu_prototype(caller) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 0cc016300f..0843a67105 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -1,7 +1,7 @@ """ Handling storage of prototypes, both database-based ones (DBPrototypes) and those defined in modules -(Read-only prototypes). +(Read-only prototypes). Also contains utility functions, formatters and manager functions. """ @@ -31,7 +31,6 @@ _PROTOTYPE_TAG_CATEGORY = "from_prototype" _PROTOTYPE_TAG_META_CATEGORY = "db_prototype" PROT_FUNCS = {} - _RE_DBREF = re.compile(r"(? (any): value of a nattribute (ndb_ is stripped) + ndb_ (any): value of a nattribute (ndb_ is stripped) - this is of limited use. other (any): any other name is interpreted as the key of an Attribute with its value. Such Attributes have no categories. @@ -66,15 +72,16 @@ return the value to enter into the field and will be called every time the prototype is used to spawn an object. Note, if you want to store a callable in an Attribute, embed it in a tuple to the `args` keyword. -By specifying the "prototype" key, the prototype becomes a child of -that prototype, inheritng all prototype slots it does not explicitly +By specifying the "prototype_parent" key, the prototype becomes a child of +the given prototype, inheritng all prototype slots it does not explicitly define itself, while overloading those that it does specify. ```python import random -GOBLIN_WIZARD = { +{ + "prototype_key": "goblin_wizard", "prototype_parent": GOBLIN, "key": "goblin wizard", "spells": ["fire ball", "lighting bolt"] @@ -189,7 +196,9 @@ def flatten_prototype(prototype, validate=False): flattened (dict): The final, flattened prototype. """ + if prototype: + prototype = protlib.homogenize_prototype(prototype) protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()} protlib.validate_prototype(prototype, None, protparents, is_prototype_base=validate, strict=validate) @@ -253,7 +262,7 @@ def prototype_from_object(obj): for tag in obj.tags.get(return_tagobj=True, return_list=True) if tag] if tags: prot['tags'] = tags - attrs = [(attr.key, attr.value, attr.category, attr.locks.all()) + attrs = [(attr.key, attr.value, attr.category, ';'.join(attr.locks.all())) for attr in obj.attributes.get(return_obj=True, return_list=True) if attr] if attrs: prot['attrs'] = attrs @@ -261,7 +270,7 @@ def prototype_from_object(obj): return prot -def prototype_diff(prototype1, prototype2): +def prototype_diff(prototype1, prototype2, maxdepth=2): """ A 'detailed' diff specifies differences down to individual sub-sectiions of the prototype, like individual attributes, permissions etc. It is used @@ -270,6 +279,9 @@ def prototype_diff(prototype1, prototype2): Args: prototype1 (dict): Original prototype. prototype2 (dict): Comparison prototype. + maxdepth (int, optional): The maximum depth into the diff we go before treating the elements + of iterables as individual entities to compare. This is important since a single + attr/tag (for example) are represented by a tuple. Returns: diff (dict): A structure detailing how to convert prototype1 to prototype2. All @@ -280,7 +292,7 @@ def prototype_diff(prototype1, prototype2): instruction can be one of "REMOVE", "ADD", "UPDATE" or "KEEP". """ - def _recursive_diff(old, new): + def _recursive_diff(old, new, depth=0): old_type = type(old) new_type = type(new) @@ -292,14 +304,14 @@ def prototype_diff(prototype1, prototype2): return (old, new, "ADD") else: return (old, new, "UPDATE") - elif new_type == dict: + elif depth < maxdepth and new_type == dict: all_keys = set(old.keys() + new.keys()) - return {key: _recursive_diff(old.get(key), new.get(key)) for key in all_keys} - elif is_iter(new): + return {key: _recursive_diff(old.get(key), new.get(key), depth=depth + 1) for key in all_keys} + elif depth < maxdepth and is_iter(new): old_map = {part[0] if is_iter(part) else part: part for part in old} new_map = {part[0] if is_iter(part) else part: part for part in new} all_keys = set(old_map.keys() + new_map.keys()) - return {key: _recursive_diff(old_map.get(key), new_map.get(key)) for key in all_keys} + return {key: _recursive_diff(old_map.get(key), new_map.get(key), depth=depth + 1) for key in all_keys} elif old != new: return (old, new, "UPDATE") else: @@ -346,13 +358,13 @@ def flatten_diff(diff): typ = type(diffpart) if typ == tuple and len(diffpart) == 3 and diffpart[2] in valid_instructions: out = [diffpart[2]] - elif type == dict: + elif typ == dict: # all other are dicts for val in diffpart.values(): out.extend(_get_all_nested_diff_instructions(val)) else: raise RuntimeError("Diff contains non-dicts that are not on the " - "form (old, new, inst): {}".format(diff)) + "form (old, new, inst): {}".format(diffpart)) return out flat_diff = {} @@ -402,7 +414,7 @@ def prototype_diff_from_object(prototype, obj): """ obj_prototype = prototype_from_object(obj) - diff = prototype_diff(obj_prototype, prototype) + diff = prototype_diff(obj_prototype, protlib.homogenize_prototype(prototype)) return diff, obj_prototype @@ -421,6 +433,8 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None): changed (int): The number of objects that had changes applied to them. """ + prototype = protlib.homogenize_prototype(prototype) + if isinstance(prototype, basestring): new_prototype = protlib.search_prototype(prototype) else: @@ -439,7 +453,6 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None): # make sure the diff is flattened diff = flatten_diff(diff) - changed = 0 for obj in objects: do_save = False @@ -619,9 +632,9 @@ def spawn(*prototypes, **kwargs): (no object creation) and return the create-kwargs. Returns: - object (Object, dict or list): Spawned object. If `only_validate` is given, return + object (Object, dict or list): Spawned object(s). If `only_validate` is given, return a list of the creation kwargs to build the object(s) without actually creating it. If - `return_parents` is set, return dict of prototype parents. + `return_parents` is set, instead return dict of prototype parents. """ # get available protparents diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 1c77fd85c3..9f782f991b 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -70,7 +70,7 @@ class TestUtils(EvenniaTest): self.obj1.tags.add('foo') new_prot = spawner.prototype_from_object(self.obj1) self.assertEqual( - {'attrs': [('test', 'testval', None, [''])], + {'attrs': [('test', 'testval', None, '')], 'home': Something, 'key': 'Obj', 'location': Something, @@ -94,14 +94,15 @@ class TestUtils(EvenniaTest): def test_update_objects_from_prototypes(self): self.maxDiff = None - self.obj1.attributes.add('oldtest', 'to_remove') + self.obj1.attributes.add('oldtest', 'to_keep') old_prot = spawner.prototype_from_object(self.obj1) # modify object away from prototype self.obj1.attributes.add('test', 'testval') + self.obj1.attributes.add('desc', 'changed desc') self.obj1.aliases.add('foo') - self.obj1.key = 'NewObj' + self.obj1.tags.add('footag', 'foocategory') # modify prototype old_prot['new'] = 'new_val' @@ -109,53 +110,111 @@ class TestUtils(EvenniaTest): old_prot['permissions'] = 'Builder' # this will not update, since we don't update the prototype on-disk old_prot['prototype_desc'] = 'New version of prototype' + old_prot['attrs'] += (("fooattr", "fooattrval", None, ''),) # diff obj/prototype - pdiff = spawner.prototype_diff_from_object(old_prot, self.obj1) + old_prot_copy = old_prot.copy() + pdiff, obj_prototype = spawner.prototype_diff_from_object(old_prot, self.obj1) + + self.assertEqual(old_prot_copy, old_prot) + + self.assertEqual(obj_prototype, + {'aliases': ['foo'], + 'attrs': [('oldtest', 'to_keep', None, ''), + ('test', 'testval', None, ''), + ('desc', 'changed desc', None, '')], + 'key': 'Obj', + 'home': '#1', + 'location': '#1', + 'locks': 'call:true();control:perm(Developer);delete:perm(Admin);' + 'edit:perm(Admin);examine:perm(Builder);get:all();' + 'puppet:pperm(Developer);tell:perm(Admin);view:all()', + 'prototype_desc': 'Built from Obj', + 'prototype_key': Something, + 'prototype_locks': 'spawn:all();edit:all()', + 'prototype_tags': [], + 'typeclass': 'evennia.objects.objects.DefaultObject'}) + + self.assertEqual(old_prot, + {'attrs': [('oldtest', 'to_keep', None, ''), + ('fooattr', 'fooattrval', None, '')], + 'home': '#1', + 'key': 'Obj', + 'location': '#1', + 'locks': 'call:true();control:perm(Developer);delete:perm(Admin);' + 'edit:perm(Admin);examine:perm(Builder);get:all();' + 'puppet:pperm(Developer);tell:perm(Admin);view:all()', + 'new': 'new_val', + 'permissions': 'Builder', + 'prototype_desc': 'New version of prototype', + 'prototype_key': Something, + 'prototype_locks': 'spawn:all();edit:all()', + 'prototype_tags': [], + 'test': 'testval_changed', + 'typeclass': 'evennia.objects.objects.DefaultObject'}) + + # from evennia import set_trace; set_trace(term_size=(182, 50)) self.assertEqual( pdiff, - ({'aliases': 'REMOVE', - 'attrs': 'REPLACE', - 'home': 'KEEP', - 'key': 'UPDATE', - 'location': 'KEEP', - 'locks': 'KEEP', - 'new': 'UPDATE', - 'permissions': 'UPDATE', - 'prototype_desc': 'UPDATE', - 'prototype_key': 'UPDATE', - 'prototype_locks': 'KEEP', - 'prototype_tags': 'KEEP', - 'test': 'UPDATE', - 'typeclass': 'KEEP'}, - {'attrs': [('oldtest', 'to_remove', None, ['']), - ('test', 'testval', None, [''])], - 'prototype_locks': 'spawn:all();edit:all()', - 'prototype_key': Something, - 'locks': ";".join([ - 'call:true()', 'control:perm(Developer)', - 'delete:perm(Admin)', 'edit:perm(Admin)', - 'examine:perm(Builder)', 'get:all()', - 'puppet:pperm(Developer)', 'tell:perm(Admin)', - 'view:all()']), - 'prototype_tags': [], - 'location': "#1", - 'key': 'NewObj', - 'home': '#1', - 'typeclass': 'evennia.objects.objects.DefaultObject', - 'prototype_desc': 'Built from NewObj', - 'aliases': 'foo'}) + {'home': ('#1', '#1', 'KEEP'), + 'prototype_locks': ('spawn:all();edit:all()', + 'spawn:all();edit:all()', 'KEEP'), + 'prototype_key': (Something, Something, 'UPDATE'), + 'location': ('#1', '#1', 'KEEP'), + 'locks': ('call:true();control:perm(Developer);delete:perm(Admin);' + 'edit:perm(Admin);examine:perm(Builder);get:all();' + 'puppet:pperm(Developer);tell:perm(Admin);view:all()', + 'call:true();control:perm(Developer);delete:perm(Admin);' + 'edit:perm(Admin);examine:perm(Builder);get:all();' + 'puppet:pperm(Developer);tell:perm(Admin);view:all()', 'KEEP'), + 'prototype_tags': {}, + 'attrs': {'oldtest': (('oldtest', 'to_keep', None, ''), + ('oldtest', 'to_keep', None, ''), 'KEEP'), + 'test': (('test', 'testval', None, ''), + None, 'REMOVE'), + 'desc': (('desc', 'changed desc', None, ''), + None, 'REMOVE'), + 'fooattr': (None, ('fooattr', 'fooattrval', None, ''), 'ADD'), + 'test': (('test', 'testval', None, ''), + ('test', 'testval_changed', None, ''), 'UPDATE'), + 'new': (None, ('new', 'new_val', None, ''), 'ADD')}, + 'key': ('Obj', 'Obj', 'KEEP'), + 'typeclass': ('evennia.objects.objects.DefaultObject', + 'evennia.objects.objects.DefaultObject', 'KEEP'), + 'aliases': (['foo'], None, 'REMOVE'), + 'prototype_desc': ('Built from Obj', + 'New version of prototype', 'UPDATE'), + 'permissions': (None, 'Builder', 'ADD')} ) + # from evennia import set_trace;set_trace() + self.assertEqual( + spawner.flatten_diff(pdiff), + {'aliases': 'REMOVE', + 'attrs': 'REPLACE', + 'home': 'KEEP', + 'key': 'KEEP', + 'location': 'KEEP', + 'locks': 'KEEP', + 'permissions': 'UPDATE', + 'prototype_desc': 'UPDATE', + 'prototype_key': 'UPDATE', + 'prototype_locks': 'KEEP', + 'prototype_tags': 'KEEP', + 'typeclass': 'KEEP'} + ) + # apply diff count = spawner.batch_update_objects_with_prototype( - old_prot, diff=pdiff[0], objects=[self.obj1]) + old_prot, diff=pdiff, objects=[self.obj1]) self.assertEqual(count, 1) new_prot = spawner.prototype_from_object(self.obj1) - self.assertEqual({'attrs': [('test', 'testval_changed', None, ['']), - ('new', 'new_val', None, [''])], + self.assertEqual({'attrs': [('oldtest', 'to_keep', None, ''), + ('fooattr', 'fooattrval', None, ''), + ('new', 'new_val', None, ''), + ('test', 'testval_changed', None, '')], 'home': Something, 'key': 'Obj', 'location': Something, diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index eb698e6f0e..7c7280c448 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -564,7 +564,7 @@ class AttributeHandler(object): ntup = len(tup) keystr = str(tup[0]).strip().lower() new_value = tup[1] - category = str(tup[2]).strip().lower() if ntup > 2 else None + category = str(tup[2]).strip().lower() if ntup > 2 and tup[2] is not None else None lockstring = tup[3] if ntup > 3 else "" attr_objs = self._getcache(keystr, category) From f31433c3ef60eb50b476131e2ee6259d8f135ae7 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 19 Sep 2018 23:49:46 +0200 Subject: [PATCH 410/466] Fix unit tests --- evennia/prototypes/prototypes.py | 3 ++- evennia/prototypes/tests.py | 38 ++++++++++++++++++++++++++++++- evennia/typeclasses/attributes.py | 4 ++-- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 0843a67105..7b60770ccb 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -66,7 +66,8 @@ def homogenize_prototype(prototype, custom_keys=None): homogenized[key] = val else: attrs.append((key, val, None, '')) - homogenized['attrs'] = attrs + if attrs: + homogenized['attrs'] = attrs return homogenized diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 9f782f991b..7e1b5a93f0 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -349,6 +349,7 @@ class TestPrototypeStorage(EvenniaTest): def test_prototype_storage(self): + # from evennia import set_trace;set_trace(term_size=(180, 50)) prot1 = protlib.create_prototype(**self.prot1) self.assertTrue(bool(prot1)) @@ -587,4 +588,39 @@ class TestOLCMenu(TestEvMenu): "node_index": "|c --- Prototype wizard --- |n" } - expected_tree = ['node_index', ['node_prototype_key', ['node_index', 'node_index', 'node_validate_prototype', ['node_index', 'node_index'], 'node_index'], 'node_prototype_parent', ['node_prototype_parent', 'node_prototype_key', 'node_prototype_parent', 'node_index', 'node_validate_prototype', 'node_index'], 'node_typeclass', ['node_typeclass', 'node_prototype_parent', 'node_typeclass', 'node_index', 'node_validate_prototype', 'node_index'], 'node_key', ['node_typeclass', 'node_key', 'node_index', 'node_validate_prototype', 'node_index'], 'node_aliases', ['node_key', 'node_aliases', 'node_index', 'node_validate_prototype', 'node_index'], 'node_attrs', ['node_aliases', 'node_attrs', 'node_index', 'node_validate_prototype', 'node_index'], 'node_tags', ['node_attrs', 'node_tags', 'node_index', 'node_validate_prototype', 'node_index'], 'node_locks', ['node_tags', 'node_locks', 'node_index', 'node_validate_prototype', 'node_index'], 'node_permissions', ['node_locks', 'node_permissions', 'node_index', 'node_validate_prototype', 'node_index'], 'node_location', ['node_permissions', 'node_location', 'node_index', 'node_validate_prototype', 'node_index', 'node_index'], 'node_home', ['node_location', 'node_home', 'node_index', 'node_validate_prototype', 'node_index', 'node_index'], 'node_destination', ['node_home', 'node_destination', 'node_index', 'node_validate_prototype', 'node_index', 'node_index'], 'node_prototype_desc', ['node_prototype_key', 'node_prototype_parent', 'node_index', 'node_validate_prototype', 'node_index'], 'node_prototype_tags', ['node_prototype_desc', 'node_prototype_tags', 'node_index', 'node_validate_prototype', 'node_index'], 'node_prototype_locks', ['node_examine_entity', ['node_prototype_locks', 'node_prototype_locks', 'node_prototype_locks'], 'node_examine_entity', 'node_prototype_locks', 'node_index', 'node_validate_prototype', 'node_index'], 'node_validate_prototype', 'node_index', 'node_prototype_spawn', ['node_index', 'node_validate_prototype'], 'node_index', 'node_search_object', ['node_index', 'node_index']]] + expected_tree = [ + 'node_index', + ['node_prototype_key', + ['node_index', 'node_index', 'node_validate_prototype', + ['node_index', 'node_index'], 'node_index'], + 'node_prototype_parent', + ['node_prototype_parent', 'node_prototype_key', 'node_prototype_parent', 'node_index', + 'node_validate_prototype', 'node_index'], + 'node_typeclass', ['node_typeclass', 'node_prototype_parent', 'node_typeclass', + 'node_index', 'node_validate_prototype', 'node_index'], + 'node_key', ['node_typeclass', 'node_key', 'node_index', 'node_validate_prototype', + 'node_index'], + 'node_aliases', ['node_key', 'node_aliases', 'node_index', 'node_validate_prototype', + 'node_index'], + 'node_attrs', ['node_aliases', 'node_attrs', 'node_index', 'node_validate_prototype', + 'node_index'], + 'node_tags', ['node_attrs', 'node_tags', 'node_index', 'node_validate_prototype', + 'node_index'], + 'node_locks', ['node_tags', 'node_locks', 'node_index', 'node_validate_prototype', + 'node_index'], + 'node_permissions', ['node_locks', 'node_permissions', 'node_index', + 'node_validate_prototype', 'node_index'], + 'node_location', ['node_permissions', 'node_location', 'node_index', + 'node_validate_prototype', 'node_index', 'node_index'], + 'node_home', ['node_location', 'node_home', 'node_index', 'node_validate_prototype', + 'node_index', 'node_index'], + 'node_destination', ['node_home', 'node_destination', 'node_index', + 'node_validate_prototype', 'node_index', 'node_index'], + 'node_prototype_desc', ['node_prototype_key', 'node_prototype_parent', 'node_index', + 'node_validate_prototype', 'node_index'], + 'node_prototype_tags', ['node_prototype_desc', 'node_prototype_tags', 'node_index', + 'node_validate_prototype', 'node_index'], + 'node_prototype_locks', ['node_prototype_tags', 'node_prototype_locks', + 'node_validate_prototype', 'node_prototype_locks'], + 'node_validate_prototype', 'node_prototype_locks', 'node_prototype_locks', + 'node_prototype_locks', 'node_prototype_locks']] diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index 7c7280c448..863628172a 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -573,7 +573,7 @@ class AttributeHandler(object): attr_obj = attr_objs[0] # update an existing attribute object attr_obj.db_category = category - attr_obj.db_lock_storage = lockstring + attr_obj.db_lock_storage = lockstring or '' attr_obj.save(update_fields=["db_category", "db_lock_storage"]) if strattr: # store as a simple string (will not notify OOB handlers) @@ -590,7 +590,7 @@ class AttributeHandler(object): "db_attrtype": self._attrtype, "db_value": None if strattr else to_pickle(new_value), "db_strvalue": new_value if strattr else None, - "db_lock_storage": lockstring} + "db_lock_storage": lockstring or ''} new_attr = Attribute(**kwargs) new_attr.save() new_attrobjs.append(new_attr) From 24fbfdf35cf6094c90534b437bf1f5efdb3247bc Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 20 Sep 2018 00:01:53 +0200 Subject: [PATCH 411/466] Clean actioninfo in menu between nodes --- evennia/prototypes/menus.py | 1 + 1 file changed, 1 insertion(+) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index e18e467e35..45f047b1e0 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -2380,6 +2380,7 @@ class OLCMenu(EvMenu): olc_keys = ("index", "forward", "back", "previous", "next", "validate prototype", "save prototype", "load prototype", "spawn prototype", "search objects") actioninfo = self.actioninfo + "\n" if hasattr(self, 'actioninfo') else '' + self.actioninfo = '' # important, or this could bleed over to other nodes olc_options = [] other_options = [] for key, desc in optionlist: From ddf01d1631f2d1235f4c3ba93bb5238a9447dbeb Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 20 Sep 2018 00:06:09 +0000 Subject: [PATCH 412/466] Implements password validation via the native Django framework. --- evennia/contrib/security/__init__.py | 0 evennia/contrib/security/validators.py | 51 ++++++++++++++++++++++++++ evennia/settings_default.py | 23 ++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 evennia/contrib/security/__init__.py create mode 100644 evennia/contrib/security/validators.py diff --git a/evennia/contrib/security/__init__.py b/evennia/contrib/security/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evennia/contrib/security/validators.py b/evennia/contrib/security/validators.py new file mode 100644 index 0000000000..b10f990a8a --- /dev/null +++ b/evennia/contrib/security/validators.py @@ -0,0 +1,51 @@ +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ +import re + +class EvenniaPasswordValidator: + + def __init__(self, regex=r"^[\w. @+\-',]+$", policy="Password should contain a mix of letters, spaces, digits and @/./+/-/_/'/, only."): + """ + Constructs a standard Django password validator. + + Args: + regex (str): Regex pattern of valid characters to allow. + policy (str): Brief explanation of what the defined regex permits. + + """ + self.regex = regex + self.policy = policy + + def validate(self, password, user=None): + """ + Validates a password string to make sure it meets predefined Evennia + acceptable character policy. + + Args: + password (str): Password to validate + user (None): Unused argument but required by Django + + Returns: + None (None): None if password successfully validated, + raises ValidationError otherwise. + + """ + # Check complexity + if not re.findall(self.regex, password): + raise ValidationError( + _(self.policy), + code='evennia_password_policy', + ) + + def get_help_text(self): + """ + Returns a user-facing explanation of the password policy defined + by this validator. + + Returns: + text (str): Explanation of password policy. + + """ + return _( + "%s From a terminal client, you can also use a phrase of multiple words if you enclose the password in double quotes." % self.policy + ) \ No newline at end of file diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 9c34a6165a..d705d77d18 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -802,6 +802,29 @@ INSTALLED_APPS = ( # This should usually not be changed. AUTH_USER_MODEL = "accounts.AccountDB" +# Password validation +# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + 'OPTIONS': { + 'min_length': 8, + } + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, + { + 'NAME': 'evennia.contrib.security.validators.EvenniaPasswordValidator', + }, +] + # Use a custom test runner that just tests Evennia-specific apps. TEST_RUNNER = 'evennia.server.tests.EvenniaTestSuiteRunner' From c8c9e831eec906eac09a2f7164bb75dcc9d2177a Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 20 Sep 2018 20:37:48 +0000 Subject: [PATCH 413/466] Forces validation on Account.set_password() and provides an Account.validate_password() method to validate passwords. --- evennia/accounts/accounts.py | 74 ++++++++++++++++++- evennia/accounts/tests.py | 25 ++++++- .../security => server}/validators.py | 0 evennia/settings_default.py | 2 +- 4 files changed, 98 insertions(+), 3 deletions(-) rename evennia/{contrib/security => server}/validators.py (100%) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index c4c8c37df7..ba2616f003 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -13,6 +13,8 @@ instead for most things). import time from django.conf import settings +from django.contrib.auth import password_validation +from django.core.exceptions import ValidationError from django.utils import timezone from evennia.typeclasses.models import TypeclassBase from evennia.accounts.manager import AccountManager @@ -357,7 +359,66 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): puppet = property(__get_single_puppet) # utility methods - + @classmethod + def validate_password(cls, password, account=None): + """ + Checks the given password against the list of Django validators enabled + in the server.conf file. + + Args: + password (str): Password to validate + + Kwargs: + account (DefaultAccount, optional): Account object to validate the + password for. Optional, but Django includes some validators to + do things like making sure users aren't setting passwords to the + same value as their username. If left blank, these user-specific + checks are skipped. + + Returns: + valid (bool): Whether or not the password passed validation + error (ValidationError, None): Any validation error(s) raised. Multiple + errors can be nested within a single object. + + """ + valid = False + error = None + + # Validation returns None on success; invert it and return a more sensible bool + try: + valid = not password_validation.validate_password(password, user=account) + except ValidationError as e: + error = e + + return valid, error + + def set_password(self, password, force=False): + """ + Applies the given password to the account if it passes validation checks. + Can be overridden by using the 'force' flag. + + Args: + password (str): Password to set + + Kwargs: + force (bool): Sets password without running validation checks. + + Raises: + ValidationError + + Returns: + None (None): Does not return a value. + + """ + if not force: + # Run validation checks + valid, error = self.validate_password(password, account=self) + if error: raise error + + super(DefaultAccount, self).set_password(password) + logger.log_info("Password succesfully changed for %s." % self) + self.at_password_change() + def delete(self, *args, **kwargs): """ Deletes the account permanently. @@ -714,6 +775,17 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): """ pass + + def at_password_change(self, **kwargs): + """ + Called after a successful password set/modify. + + Args: + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + pass def at_pre_login(self, **kwargs): """ diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index 039a25601f..1eabd1e542 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -57,6 +57,29 @@ class TestDefaultAccount(TestCase): def setUp(self): self.s1 = Session() self.s1.sessid = 0 + + def test_password_validation(self): + "Check password validators deny bad passwords" + + self.account = create.create_account("TestAccount%s" % randint(0, 9), email="test@test.com", password="testpassword", typeclass=DefaultAccount) + for bad in ('', '123', 'password', 'TestAccount', '#', 'xyzzy'): + self.assertFalse(self.account.validate_password(bad, account=self.account)[0]) + + "Check validators allow sufficiently complex passwords" + for better in ('Mxyzptlk', "j0hn, i'M 0n1y d4nc1nG"): + self.assertTrue(self.account.validate_password(better, account=self.account)[0]) + + def test_password_change(self): + "Check password setting and validation is working as expected" + self.account = create.create_account("TestAccount%s" % randint(0, 9), email="test@test.com", password="testpassword", typeclass=DefaultAccount) + + from django.core.exceptions import ValidationError + # Try setting some bad passwords + for bad in ('', '#', 'TestAccount', 'password'): + self.assertRaises(ValidationError, self.account.set_password, bad) + + # Try setting a better password (test for False; returns None on success) + self.assertFalse(self.account.set_password('Mxyzptlk')) def test_puppet_object_no_object(self): "Check puppet_object method called with no object param" @@ -157,4 +180,4 @@ class TestDefaultAccount(TestCase): account.puppet_object(self.s1, obj) self.assertTrue(self.s1.data_out.call_args[1]['text'].endswith("is already puppeted by another Account.")) - self.assertIsNone(obj.at_post_puppet.call_args) + self.assertIsNone(obj.at_post_puppet.call_args) \ No newline at end of file diff --git a/evennia/contrib/security/validators.py b/evennia/server/validators.py similarity index 100% rename from evennia/contrib/security/validators.py rename to evennia/server/validators.py diff --git a/evennia/settings_default.py b/evennia/settings_default.py index d705d77d18..8c07244636 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -821,7 +821,7 @@ AUTH_PASSWORD_VALIDATORS = [ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', }, { - 'NAME': 'evennia.contrib.security.validators.EvenniaPasswordValidator', + 'NAME': 'evennia.server.validators.EvenniaPasswordValidator', }, ] From e5828024e25f735d90a7e90e060cf60a0e1c143f Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 20 Sep 2018 21:29:56 +0000 Subject: [PATCH 414/466] Modifies CmdUnconnectedCreate, CmdPassword and CmdNewPassword to use Django password validation before modification. --- evennia/commands/default/account.py | 10 ++++++++-- evennia/commands/default/admin.py | 17 ++++++++++++++--- evennia/commands/default/unloggedin.py | 12 ++++++++---- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index 40805f7465..d81ef8a8f0 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -627,10 +627,16 @@ class CmdPassword(COMMAND_DEFAULT_CLASS): return oldpass = self.lhslist[0] # Both of these are newpass = self.rhslist[0] # already stripped by parse() + + # Validate password + validated, error = account.validate_password(newpass) + if not account.check_password(oldpass): self.msg("The specified old password isn't correct.") - elif len(newpass) < 3: - self.msg("Passwords must be at least three characters long.") + elif not validated: + errors = [e for suberror in error.messages for e in error.messages] + string = "\n".join(errors) + self.msg(string) else: account.set_password(newpass) account.save() diff --git a/evennia/commands/default/admin.py b/evennia/commands/default/admin.py index 4e517b77f9..fc90277127 100644 --- a/evennia/commands/default/admin.py +++ b/evennia/commands/default/admin.py @@ -428,12 +428,23 @@ class CmdNewPassword(COMMAND_DEFAULT_CLASS): account = caller.search_account(self.lhs) if not account: return - account.set_password(self.rhs) + + newpass = self.rhs + + # Validate password + validated, error = account.validate_password(newpass) + if not validated: + errors = [e for suberror in error.messages for e in error.messages] + string = "\n".join(errors) + caller.msg(string) + return + + account.set_password(newpass) account.save() - self.msg("%s - new password set to '%s'." % (account.name, self.rhs)) + self.msg("%s - new password set to '%s'." % (account.name, newpass)) if account.character != caller: account.msg("%s has changed your password to '%s'." % (caller.name, - self.rhs)) + newpass)) class CmdPerm(COMMAND_DEFAULT_CLASS): diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index bc7e69934f..0b181538a1 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -294,10 +294,14 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS): string = "\n\r That name is reserved. Please choose another Accountname." 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 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." + + # Validate password + Account = utils.class_from_module(settings.BASE_ACCOUNT_TYPECLASS) + # Have to create a dummy Account object to check username similarity + valid, error = Account.validate_password(password, account=Account(username=accountname)) + if error: + errors = [e for suberror in error.messages for e in error.messages] + string = "\n".join(errors) session.msg(string) return From bed96328a94e0a8242457faa1162237c48ddacd2 Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 21 Sep 2018 00:05:23 +0200 Subject: [PATCH 415/466] Lots of bug fixes, still issues in prototype-update from menu --- evennia/__init__.py | 13 ++++++++++--- evennia/prototypes/menus.py | 21 ++++++++++----------- evennia/prototypes/prototypes.py | 19 ++++++++++++++----- evennia/utils/dbserialize.py | 27 ++++++++++++++++++++++++++- 4 files changed, 60 insertions(+), 20 deletions(-) diff --git a/evennia/__init__.py b/evennia/__init__.py index 6858744d42..e04ab84dba 100644 --- a/evennia/__init__.py +++ b/evennia/__init__.py @@ -350,12 +350,14 @@ def set_trace(debugger="auto", term_size=(140, 40)): """ import sys dbg = None + pudb_mode = False if debugger in ('auto', 'pudb'): try: from pudb import debugger dbg = debugger.Debugger(stdout=sys.__stdout__, term_size=term_size) + pudb_mode = True except ImportError: if debugger == 'pudb': raise @@ -364,7 +366,12 @@ def set_trace(debugger="auto", term_size=(140, 40)): if not dbg: import pdb dbg = pdb.Pdb(stdout=sys.__stdout__) + pudb_mode = False - # Start debugger, forcing it up one stack frame (otherwise `set_trace` will start # debugger at - # this point, not the actual code location) - dbg.set_trace(sys._getframe().f_back) + if pudb_mode: + # Stopped at breakpoint. Press 'n' to continue into the code. + dbg.set_trace() + else: + # Start debugger, forcing it up one stack frame (otherwise `set_trace` will start debugger + # this point, not the actual code location) + dbg.set_trace(sys._getframe().f_back) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 45f047b1e0..6e14993a1c 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -200,9 +200,8 @@ def _wizard_options(curr_node, prev_node, next_node, color="|W", search=False): color=color, node=next_node.replace("_", "-")), "goto": "node_{}".format(next_node)}) - if "index" not in (prev_node, next_node): - options.append({"key": ("|wI|Wndex", "i"), - "goto": "node_index"}) + options.append({"key": ("|wI|Wndex", "i"), + "goto": "node_index"}) if curr_node: options.append({"key": ("|wV|Walidate prototype", "validate", "v"), @@ -319,7 +318,7 @@ def _get_current_value(caller, keyname, comparer=None, formatter=str, only_inher if keyname in flat_prot: out = formatter(comparer(prot[keyname], flat_prot[keyname])) if only_inherit: - if out: + if str(out).strip(): return "|WCurrent|n {} |W(|binherited|W):|n {}".format(keyname, out) return "" else: @@ -1953,14 +1952,12 @@ def _keep_diff(caller, **kwargs): tmp[path[-1]] = "KEEP" -def _format_diff_text_and_options(diff, exclude=None): +def _format_diff_text_and_options(diff): """ Reformat the diff in a way suitable for the olc menu. Args: diff (dict): A diff as produced by `prototype_diff`. - exclude (list, optional): List of root keys to skip, regardless - of diff instruction. Returns: options (list): List of options dict. @@ -2051,8 +2048,7 @@ def node_apply_diff(caller, **kwargs): base_obj = choice(update_objects) # from evennia import set_trace - diff, obj_prototype = spawner.prototype_diff_from_object( - prototype, base_obj, exceptions={"location": "KEEP"}) + diff, obj_prototype = spawner.prototype_diff_from_object(prototype, base_obj) helptext = """ This will go through all existing objects and apply the changes you accept. @@ -2069,7 +2065,10 @@ def node_apply_diff(caller, **kwargs): Note that the `location` will never be auto-adjusted because it's so rare to want to homogenize the location of all object instances.""" - txt, options = _format_diff_text_and_options(diff, exclude=['location'] if custom_location else None) + if not custom_location: + diff.pop("location", None) + + txt, options = _format_diff_text_and_options(diff) if options: text = ["Suggested changes to {} objects. ".format(len(update_objects)), @@ -2261,7 +2260,7 @@ def node_prototype_spawn(caller, **kwargs): options.append( {"desc": "Spawn in prototype's defined location ({loc})".format(loc=location), "goto": (_spawn, - dict(prototype=prototype))}) + dict(prototype=prototype, location=location, custom_location=True))}) caller_loc = caller.location if location != caller_loc: options.append( diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 7b60770ccb..67eccaafd0 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -16,7 +16,7 @@ from evennia.utils.utils import ( get_all_typeclasses, to_str, dbref, justify) from evennia.locks.lockhandler import validate_lockstring, check_lockstring from evennia.utils import logger -from evennia.utils import inlinefuncs +from evennia.utils import inlinefuncs, dbserialize from evennia.utils.evtable import EvTable @@ -102,9 +102,18 @@ class DbPrototype(DefaultScript): """ def at_script_creation(self): self.key = "empty prototype" # prototype_key - self.desc = "A prototype" # prototype_desc + self.desc = "A prototype" # prototype_desc (.tags are used for prototype_tags) self.db.prototype = {} # actual prototype + @property + def prototype(self): + "Make sure to decouple from db!" + return dbserialize.deserialize(self.attributes.get('prototype', {})) + + @prototype.setter + def prototype(self, prototype): + self.attributes.add('prototype', prototype) + # Prototype manager functions @@ -152,7 +161,7 @@ def save_prototype(**kwargs): # make sure meta properties are included with defaults stored_prototype = DbPrototype.objects.filter(db_key=prototype_key) - prototype = dict(stored_prototype[0].db.prototype) if stored_prototype else {} + prototype = stored_prototype[0].prototype if stored_prototype else {} kwargs['prototype_desc'] = kwargs.get("prototype_desc", prototype.get("prototype_desc", "")) prototype_locks = kwargs.get( @@ -185,7 +194,7 @@ def save_prototype(**kwargs): DbPrototype, key=prototype_key, desc=prototype['prototype_desc'], persistent=True, locks=prototype_locks, tags=prototype['prototype_tags'], attributes=[("prototype", prototype)]) - return stored_prototype.db.prototype + return stored_prototype.prototype create_prototype = save_prototype # alias @@ -279,7 +288,7 @@ def search_prototype(key=None, tags=None): # exact or partial match on key db_matches = db_matches.filter(db_key=key) or db_matches.filter(db_key__icontains=key) # return prototype - db_prototypes = [dict(dbprot.attributes.get("prototype", {})) for dbprot in db_matches] + db_prototypes = [dbprot.prototype for dbprot in db_matches] matches = db_prototypes + module_prototypes nmatches = len(matches) diff --git a/evennia/utils/dbserialize.py b/evennia/utils/dbserialize.py index c08704be0e..0ea024274e 100644 --- a/evennia/utils/dbserialize.py +++ b/evennia/utils/dbserialize.py @@ -29,7 +29,7 @@ except ImportError: from pickle import dumps, loads from django.core.exceptions import ObjectDoesNotExist from django.contrib.contenttypes.models import ContentType -from evennia.utils.utils import to_str, uses_database +from evennia.utils.utils import to_str, uses_database, is_iter from evennia.utils import logger __all__ = ("to_pickle", "from_pickle", "do_pickle", "do_unpickle", @@ -364,6 +364,31 @@ class _SaverDeque(_SaverMutable): def rotate(self, *args): self._data.rotate(*args) + +_DESERIALIZE_MAPPING = {_SaverList.__name__: list, _SaverDict.__name__: dict, + _SaverSet.__name__: set, _SaverOrderedDict.__name__: OrderedDict, + _SaverDeque.__name__: deque} + + +def deserialize(obj): + """ + Make sure to *fully* decouple a structure from the database, by turning all _Saver*-mutables + inside it back into their normal Python forms. + + """ + def _iter(obj): + typ = type(obj) + tname = typ.__name__ + if tname in ('_SaverDict', 'dict'): + return {_iter(key): _iter(val) for key, val in obj.items()} + elif tname in _DESERIALIZE_MAPPING: + return _DESERIALIZE_MAPPING[tname](_iter(val) for val in obj) + elif is_iter(obj): + return typ(_iter(val) for val in obj) + return obj + return _iter(obj) + + # # serialization helpers From cbfe6d9e38798a60320b0ff5a306e5a290717774 Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 21 Sep 2018 00:24:19 +0200 Subject: [PATCH 416/466] Resolve unit tests --- CHANGELOG.md | 3 ++ evennia/prototypes/tests.py | 62 ++++++++++++++++--------------------- 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b78961c6d8..13d13d63b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,9 @@ - Added `exit_cmd` to EvMore pager, to allow for calling a command (e.g. 'look') when leaving the pager. - `get_all_typeclasses` will return dict `{"path": typeclass, ...}` for all typeclasses available in the system. This is used by the new `@typeclass/list` subcommand (useful for builders etc). +- `evennia.utils.dbserialize.deserialize(obj)` is a new helper function to *completely* disconnect + a mutable recovered from an Attribute from the database. This will convert all nested `_Saver*` + classes to their plain-Python counterparts. ### General diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 7e1b5a93f0..91d81310d1 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -588,39 +588,31 @@ class TestOLCMenu(TestEvMenu): "node_index": "|c --- Prototype wizard --- |n" } - expected_tree = [ - 'node_index', - ['node_prototype_key', - ['node_index', 'node_index', 'node_validate_prototype', - ['node_index', 'node_index'], 'node_index'], - 'node_prototype_parent', - ['node_prototype_parent', 'node_prototype_key', 'node_prototype_parent', 'node_index', - 'node_validate_prototype', 'node_index'], - 'node_typeclass', ['node_typeclass', 'node_prototype_parent', 'node_typeclass', + expected_tree = ['node_index', ['node_prototype_key', ['node_index', 'node_index', 'node_index', + 'node_validate_prototype', ['node_index', 'node_index', 'node_index'], 'node_index'], + 'node_prototype_parent', ['node_prototype_parent', 'node_prototype_key', + 'node_prototype_parent', 'node_index', 'node_validate_prototype', 'node_index'], + 'node_typeclass', ['node_typeclass', 'node_prototype_parent', 'node_typeclass', + 'node_index', 'node_validate_prototype', 'node_index'], 'node_key', ['node_typeclass', + 'node_key', 'node_index', 'node_validate_prototype', 'node_index'], 'node_aliases', + ['node_key', 'node_aliases', 'node_index', 'node_validate_prototype', 'node_index'], + 'node_attrs', ['node_aliases', 'node_attrs', 'node_index', 'node_validate_prototype', + 'node_index'], 'node_tags', ['node_attrs', 'node_tags', 'node_index', + 'node_validate_prototype', 'node_index'], 'node_locks', ['node_tags', + 'node_locks', 'node_index', 'node_validate_prototype', 'node_index'], + 'node_permissions', ['node_locks', 'node_permissions', 'node_index', + 'node_validate_prototype', 'node_index'], 'node_location', + ['node_permissions', 'node_location', 'node_index', 'node_validate_prototype', + 'node_index', 'node_index'], 'node_home', ['node_location', 'node_home', + 'node_index', 'node_validate_prototype', 'node_index', 'node_index'], + 'node_destination', ['node_home', 'node_destination', 'node_index', + 'node_validate_prototype', 'node_index', 'node_index'], + 'node_prototype_desc', ['node_prototype_key', 'node_prototype_parent', 'node_index', 'node_validate_prototype', 'node_index'], - 'node_key', ['node_typeclass', 'node_key', 'node_index', 'node_validate_prototype', - 'node_index'], - 'node_aliases', ['node_key', 'node_aliases', 'node_index', 'node_validate_prototype', - 'node_index'], - 'node_attrs', ['node_aliases', 'node_attrs', 'node_index', 'node_validate_prototype', - 'node_index'], - 'node_tags', ['node_attrs', 'node_tags', 'node_index', 'node_validate_prototype', - 'node_index'], - 'node_locks', ['node_tags', 'node_locks', 'node_index', 'node_validate_prototype', - 'node_index'], - 'node_permissions', ['node_locks', 'node_permissions', 'node_index', - 'node_validate_prototype', 'node_index'], - 'node_location', ['node_permissions', 'node_location', 'node_index', - 'node_validate_prototype', 'node_index', 'node_index'], - 'node_home', ['node_location', 'node_home', 'node_index', 'node_validate_prototype', - 'node_index', 'node_index'], - 'node_destination', ['node_home', 'node_destination', 'node_index', - 'node_validate_prototype', 'node_index', 'node_index'], - 'node_prototype_desc', ['node_prototype_key', 'node_prototype_parent', 'node_index', - 'node_validate_prototype', 'node_index'], - 'node_prototype_tags', ['node_prototype_desc', 'node_prototype_tags', 'node_index', - 'node_validate_prototype', 'node_index'], - 'node_prototype_locks', ['node_prototype_tags', 'node_prototype_locks', - 'node_validate_prototype', 'node_prototype_locks'], - 'node_validate_prototype', 'node_prototype_locks', 'node_prototype_locks', - 'node_prototype_locks', 'node_prototype_locks']] + 'node_prototype_tags', ['node_prototype_desc', 'node_prototype_tags', + 'node_index', 'node_validate_prototype', 'node_index'], + 'node_prototype_locks', ['node_prototype_tags', 'node_prototype_locks', + 'node_index', 'node_validate_prototype', 'node_index'], + 'node_validate_prototype', 'node_index', 'node_prototype_spawn', + ['node_index', 'node_index', 'node_validate_prototype'], 'node_index', + 'node_search_object', ['node_index', 'node_index', 'node_index']]] From 35efb57b5647842324f2c886bbd24d25b8963a67 Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 20 Sep 2018 22:57:20 +0000 Subject: [PATCH 417/466] Adds test for Evennia validator. --- evennia/server/tests.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/evennia/server/tests.py b/evennia/server/tests.py index e821d58583..bb1fae4af5 100644 --- a/evennia/server/tests.py +++ b/evennia/server/tests.py @@ -23,6 +23,9 @@ try: from django.utils import unittest except ImportError: import unittest + +from evennia.server.validators import EvenniaPasswordValidator +from evennia.utils.test_resources import EvenniaTest from django.test.runner import DiscoverRunner @@ -77,3 +80,16 @@ class TestDeprecations(TestCase): self.assertRaises(DeprecationWarning, check_errors, MockSettings(setting)) # test check for WEBSERVER_PORTS having correct value self.assertRaises(DeprecationWarning, check_errors, MockSettings("WEBSERVER_PORTS", value=["not a tuple"])) + +class ValidatorTest(EvenniaTest): + + def test_validator(self): + # Validator returns None on success and ValidationError on failure. + validator = EvenniaPasswordValidator() + + # This password should meet Evennia standards. + self.assertFalse(validator.validate('testpassword', user=self.account)) + + # This password contains illegal characters and should raise an Exception. + from django.core.exceptions import ValidationError + self.assertRaises(ValidationError, validator.validate, '(#)[#]<>', user=self.account) \ No newline at end of file From b50364038cfb267cc33223d3f1e7edd6cbe56d74 Mon Sep 17 00:00:00 2001 From: Johnny Date: Fri, 21 Sep 2018 00:11:15 +0000 Subject: [PATCH 418/466] Reimplements Throttle as a standalone class with improved memory management. --- evennia/server/tests.py | 40 ++++++++++++++++ evennia/server/throttle.py | 98 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 evennia/server/throttle.py diff --git a/evennia/server/tests.py b/evennia/server/tests.py index e821d58583..352245e033 100644 --- a/evennia/server/tests.py +++ b/evennia/server/tests.py @@ -26,6 +26,9 @@ except ImportError: from django.test.runner import DiscoverRunner +from evennia.server.throttle import Throttle +from evennia.utils.test_resources import EvenniaTest + from .deprecations import check_errors @@ -77,3 +80,40 @@ class TestDeprecations(TestCase): self.assertRaises(DeprecationWarning, check_errors, MockSettings(setting)) # test check for WEBSERVER_PORTS having correct value self.assertRaises(DeprecationWarning, check_errors, MockSettings("WEBSERVER_PORTS", value=["not a tuple"])) + +class ThrottleTest(EvenniaTest): + """ + Class for testing the connection/IP throttle. + """ + def test_throttle(self): + ips = ('94.100.176.153', '45.56.148.77', '5.196.1.129') + kwargs = { + 'maxlim': 5, + 'timeout': 5 * 60 + } + + for ip in ips: + # Throttle should not be engaged by default + self.assertFalse(Throttle.check(ip, **kwargs)) + + # Pretend to fail a bunch of events + for x in xrange(5): + obj = Throttle.update(ip) + self.assertFalse(obj) + + # Next ones should be blocked + self.assertTrue(Throttle.check(ip, **kwargs)) + + for x in xrange(Throttle.cache_size * 2): + obj = Throttle.update(ip) + self.assertFalse(obj) + + # Should still be blocked + self.assertTrue(Throttle.check(ip, **kwargs)) + + # Number of values should be limited by cache size + self.assertEqual(Throttle.cache_size, len(Throttle.get(ip))) + + # There should only be (cache_size * num_ips) total in the Throttle cache + cache = Throttle.get() + self.assertEqual(sum([len(cache[x]) for x in cache.keys()]), Throttle.cache_size * len(ips)) \ No newline at end of file diff --git a/evennia/server/throttle.py b/evennia/server/throttle.py new file mode 100644 index 0000000000..670703f840 --- /dev/null +++ b/evennia/server/throttle.py @@ -0,0 +1,98 @@ +from collections import defaultdict, deque +import time + +_LATEST_FAILURES = defaultdict(deque) + +class Throttle(object): + """ + Keeps a running count of failed actions per IP address. + + Available methods indicate whether or not the number of failures exceeds a + particular threshold. + + This version of the throttle is usable by both the terminal server as well + as the web server, imposes limits on memory consumption by using deques + with length limits instead of open-ended lists, and removes sparse keys when + no recent failures have been recorded. + """ + + error_msg = 'Too many failed attempts; you must wait a few minutes before trying again.' + cache_size = 20 + + @classmethod + def get(cls, ip=None, storage=_LATEST_FAILURES): + """ + Convenience function that appends a new event to the table. + + Args: + ip (str, optional): IP address of requestor + + Returns: + storage (dict): When no IP is provided, returns a dict of all + current IPs being tracked and the timestamps of their recent + failures. + timestamps (deque): When an IP is provided, returns a deque of + timestamps of recent failures only for that IP. + + """ + if ip: return storage.get(ip, deque(maxlen=cls.cache_size)) + return storage + + @classmethod + def update(cls, ip): + """ + Convenience function that appends a new event to the table. + + Args: + ip (str): IP address of requestor + + Returns: + throttled (False): Always returns False + + """ + return cls.check(ip) + + @classmethod + def check(cls, ip, maxlim=None, timeout=None, storage=_LATEST_FAILURES): + """ + This will check the session's address against the + _LATEST_FAILURES dictionary to check they haven't + spammed too many fails recently. + + Args: + ip (str): IP address of requestor + maxlim (int): max number of attempts to allow + timeout (int): number of timeout seconds after + max number of tries has been reached. + + Returns: + throttled (bool): True if throttling is active, + False otherwise. + + Notes: + If maxlim and/or timeout are set, the function will + just do the comparison, not append a new datapoint. + """ + now = time.time() + ip = str(ip) + if maxlim and timeout: + # checking mode + latest_fails = storage[ip] + if latest_fails and len(latest_fails) >= maxlim: + # too many fails recently + if now - latest_fails[-1] < timeout: + # too soon - timeout in play + return True + else: + # timeout has passed. clear faillist + del(storage[ip]) + return False + else: + return False + else: + # store the time of the latest fail + if ip not in storage or not storage[ip].maxlen: + storage[ip] = deque(maxlen=cls.cache_size) + + storage[ip].append(time.time()) + return False \ No newline at end of file From 791ace73bc1cdfde81ce7a6bc9087f7b33e66042 Mon Sep 17 00:00:00 2001 From: Johnny Date: Fri, 21 Sep 2018 17:38:31 +0000 Subject: [PATCH 419/466] Further improvements; Throttle maintains its own storage and no longer requires it to be supplied as an arg. --- evennia/server/tests.py | 30 ++++++++------ evennia/server/throttle.py | 85 ++++++++++++++++++++------------------ 2 files changed, 62 insertions(+), 53 deletions(-) diff --git a/evennia/server/tests.py b/evennia/server/tests.py index 352245e033..94eac8206a 100644 --- a/evennia/server/tests.py +++ b/evennia/server/tests.py @@ -88,32 +88,38 @@ class ThrottleTest(EvenniaTest): def test_throttle(self): ips = ('94.100.176.153', '45.56.148.77', '5.196.1.129') kwargs = { - 'maxlim': 5, - 'timeout': 5 * 60 + 'limit': 5, + 'timeout': 15 * 60 } + throttle = Throttle(**kwargs) + for ip in ips: # Throttle should not be engaged by default - self.assertFalse(Throttle.check(ip, **kwargs)) + self.assertFalse(throttle.check(ip)) # Pretend to fail a bunch of events - for x in xrange(5): - obj = Throttle.update(ip) + for x in xrange(50): + obj = throttle.update(ip) self.assertFalse(obj) # Next ones should be blocked - self.assertTrue(Throttle.check(ip, **kwargs)) + self.assertTrue(throttle.check(ip)) - for x in xrange(Throttle.cache_size * 2): - obj = Throttle.update(ip) + for x in xrange(throttle.cache_size * 2): + obj = throttle.update(ip) self.assertFalse(obj) # Should still be blocked - self.assertTrue(Throttle.check(ip, **kwargs)) + self.assertTrue(throttle.check(ip)) # Number of values should be limited by cache size - self.assertEqual(Throttle.cache_size, len(Throttle.get(ip))) + self.assertEqual(throttle.cache_size, len(throttle.get(ip))) + + cache = throttle.get() + + # Make sure there are entries for each IP + self.assertEqual(len(ips), len(cache.keys())) # There should only be (cache_size * num_ips) total in the Throttle cache - cache = Throttle.get() - self.assertEqual(sum([len(cache[x]) for x in cache.keys()]), Throttle.cache_size * len(ips)) \ No newline at end of file + self.assertEqual(sum([len(cache[x]) for x in cache.keys()]), throttle.cache_size * len(ips)) \ No newline at end of file diff --git a/evennia/server/throttle.py b/evennia/server/throttle.py index 670703f840..56c88c63f2 100644 --- a/evennia/server/throttle.py +++ b/evennia/server/throttle.py @@ -1,8 +1,6 @@ from collections import defaultdict, deque import time -_LATEST_FAILURES = defaultdict(deque) - class Throttle(object): """ Keeps a running count of failed actions per IP address. @@ -17,12 +15,26 @@ class Throttle(object): """ error_msg = 'Too many failed attempts; you must wait a few minutes before trying again.' - cache_size = 20 - @classmethod - def get(cls, ip=None, storage=_LATEST_FAILURES): + def __init__(self, **kwargs): """ - Convenience function that appends a new event to the table. + Allows setting of throttle parameters. + + Kwargs: + limit (int): Max number of failures before imposing limiter + timeout (int): number of timeout seconds after + max number of tries has been reached. + cache_size (int): Max number of attempts to record per IP within a + rolling window; this is NOT the same as the limit after which + the throttle is imposed! + """ + self.storage = defaultdict(deque) + self.cache_size = self.limit = kwargs.get('limit', 5) + self.timeout = kwargs.get('timeout', 5 * 60) + + def get(self, ip=None): + """ + Convenience function that returns the storage table, or part of. Args: ip (str, optional): IP address of requestor @@ -35,64 +47,55 @@ class Throttle(object): timestamps of recent failures only for that IP. """ - if ip: return storage.get(ip, deque(maxlen=cls.cache_size)) - return storage + if ip: return self.storage.get(ip, deque(maxlen=self.cache_size)) + else: return self.storage - @classmethod - def update(cls, ip): + def update(self, ip): """ - Convenience function that appends a new event to the table. + Store the time of the latest failure/ Args: ip (str): IP address of requestor Returns: - throttled (False): Always returns False + None """ - return cls.check(ip) + # Enforce length limits + if not self.storage[ip].maxlen: + self.storage[ip] = deque(maxlen=self.cache_size) + + self.storage[ip].append(time.time()) - @classmethod - def check(cls, ip, maxlim=None, timeout=None, storage=_LATEST_FAILURES): + def check(self, ip): """ This will check the session's address against the - _LATEST_FAILURES dictionary to check they haven't - spammed too many fails recently. + storage dictionary to check they haven't spammed too many + fails recently. Args: ip (str): IP address of requestor - maxlim (int): max number of attempts to allow - timeout (int): number of timeout seconds after - max number of tries has been reached. Returns: throttled (bool): True if throttling is active, False otherwise. - Notes: - If maxlim and/or timeout are set, the function will - just do the comparison, not append a new datapoint. """ now = time.time() ip = str(ip) - if maxlim and timeout: - # checking mode - latest_fails = storage[ip] - if latest_fails and len(latest_fails) >= maxlim: - # too many fails recently - if now - latest_fails[-1] < timeout: - # too soon - timeout in play - return True - else: - # timeout has passed. clear faillist - del(storage[ip]) - return False + + # checking mode + latest_fails = self.storage[ip] + if latest_fails and len(latest_fails) >= self.limit: + # too many fails recently + if now - latest_fails[-1] < self.timeout: + # too soon - timeout in play + return True else: + # timeout has passed. clear faillist + del(self.storage[ip]) return False else: - # store the time of the latest fail - if ip not in storage or not storage[ip].maxlen: - storage[ip] = deque(maxlen=cls.cache_size) - - storage[ip].append(time.time()) - return False \ No newline at end of file + return False + + \ No newline at end of file From a2cccd73266fcb178a86fa369606f0a44b488106 Mon Sep 17 00:00:00 2001 From: Johnny Date: Fri, 21 Sep 2018 17:39:51 +0000 Subject: [PATCH 420/466] Integrates new Throttle with unconnected Commands; rate limits new account creation (partial fix for #1523). --- evennia/commands/default/unloggedin.py | 90 +++++++++----------------- 1 file changed, 31 insertions(+), 59 deletions(-) diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index bc7e69934f..f1b26137b7 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -4,13 +4,14 @@ Commands that are available from the connect screen. import re import time import datetime -from collections import defaultdict +from collections import defaultdict, deque from random import getrandbits from django.conf import settings from django.contrib.auth import authenticate from evennia.accounts.models import AccountDB from evennia.objects.models import ObjectDB from evennia.server.models import ServerConfig +from evennia.server.throttle import Throttle from evennia.comms.models import ChannelDB from evennia.server.sessionhandler import SESSIONS @@ -26,58 +27,11 @@ __all__ = ("CmdUnconnectedConnect", "CmdUnconnectedCreate", MULTISESSION_MODE = settings.MULTISESSION_MODE CONNECTION_SCREEN_MODULE = settings.CONNECTION_SCREEN_MODULE -# Helper function to throttle failed connection attempts. -# This can easily be used to limit account creation too, -# (just supply a different storage dictionary), but this -# would also block dummyrunner, so it's not added as default. - -_LATEST_FAILED_LOGINS = defaultdict(list) - - -def _throttle(session, maxlim=None, timeout=None, storage=_LATEST_FAILED_LOGINS): - """ - This will check the session's address against the - _LATEST_LOGINS dictionary to check they haven't - spammed too many fails recently. - - Args: - session (Session): Session failing - maxlim (int): max number of attempts to allow - timeout (int): number of timeout seconds after - max number of tries has been reached. - - Returns: - throttles (bool): True if throttling is active, - False otherwise. - - Notes: - If maxlim and/or timeout are set, the function will - just do the comparison, not append a new datapoint. - - """ - address = session.address - if isinstance(address, tuple): - address = address[0] - now = time.time() - if maxlim and timeout: - # checking mode - latest_fails = storage[address] - if latest_fails and len(latest_fails) >= maxlim: - # too many fails recently - if now - latest_fails[-1] < timeout: - # too soon - timeout in play - return True - else: - # timeout has passed. Reset faillist - storage[address] = [] - return False - else: - return False - else: - # store the time of the latest fail - storage[address].append(time.time()) - return False +# Create an object to store account creation attempts per IP +CREATION_THROTTLE = Throttle(limit=2, timeout=10 * 60) +# Create an object to store failed login attempts per IP +LOGIN_THROTTLE = Throttle(limit=5, timeout=5 * 60) def create_guest_account(session): """ @@ -134,7 +88,7 @@ def create_guest_account(session): session.msg("An error occurred. Please e-mail an admin if the problem persists.") logger.log_trace() raise - + def create_normal_account(session, name, password): """ @@ -149,8 +103,11 @@ def create_normal_account(session, name, password): account (Account): the account which was created from the name and password. """ # check for too many login errors too quick. - if _throttle(session, maxlim=5, timeout=5 * 60): - # timeout is 5 minutes. + address = session.address + if isinstance(address, tuple): + address = address[0] + + if LOGIN_THROTTLE.check(address): session.msg("|RYou made too many connection attempts. Try again in a few minutes.|n") return None @@ -161,7 +118,7 @@ def create_normal_account(session, name, password): # No accountname or password match session.msg("Incorrect login information given.") # this just updates the throttle - _throttle(session) + LOGIN_THROTTLE.update(address) # calls account hook for a failed login if possible. account = AccountDB.objects.get_account_from_name(name) if account: @@ -171,7 +128,6 @@ def create_normal_account(session, name, password): # Check IP and/or name bans bans = ServerConfig.objects.conf("server_bans") if bans and (any(tup[0] == account.name.lower() for tup in bans) or - any(tup[2].match(session.address) for tup in bans if tup[2])): # this is a banned IP or name! string = "|rYou have been banned and cannot continue from here." \ @@ -182,7 +138,6 @@ def create_normal_account(session, name, password): return account - class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS): """ connect to the game @@ -211,7 +166,10 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS): session = self.caller # check for too many login errors too quick. - if _throttle(session, maxlim=5, timeout=5 * 60, storage=_LATEST_FAILED_LOGINS): + address = session.address + if isinstance(address, tuple): + address = address[0] + if CONNECTION_THROTTLE.check(address): # timeout is 5 minutes. session.msg("|RYou made too many connection attempts. Try again in a few minutes.|n") return @@ -234,6 +192,7 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS): session.msg("\n\r Usage (without <>): connect ") return + CONNECTION_THROTTLE.update(address) name, password = parts account = create_normal_account(session, name, password) if account: @@ -262,6 +221,15 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS): session = self.caller args = self.args.strip() + + # Rate-limit account creation. + address = session.address + + if isinstance(address, tuple): + address = address[0] + if CREATION_THROTTLE.check(address): + session.msg("|RYou are creating too many accounts. Try again in a few minutes.|n") + return # extract double quoted parts parts = [part.strip() for part in re.split(r"\"", args) if part.strip()] @@ -322,6 +290,10 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS): if MULTISESSION_MODE < 2: default_home = ObjectDB.objects.get_id(settings.DEFAULT_HOME) _create_character(session, new_account, typeclass, default_home, permissions) + + # Update the throttle to indicate a new account was created from this IP + CREATION_THROTTLE.update(address) + # tell the caller everything went well. string = "A new account '%s' was created. Welcome!" if " " in accountname: From c0da1ff86a0fb4dd1a5e6423464bc6552ffcddf2 Mon Sep 17 00:00:00 2001 From: Johnny Date: Fri, 21 Sep 2018 17:45:59 +0000 Subject: [PATCH 421/466] Removes stray imports. --- evennia/commands/default/unloggedin.py | 1 - 1 file changed, 1 deletion(-) diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index f1b26137b7..580272c420 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -4,7 +4,6 @@ Commands that are available from the connect screen. import re import time import datetime -from collections import defaultdict, deque from random import getrandbits from django.conf import settings from django.contrib.auth import authenticate From 156dbd8d2dd16b855aafa56eed05ad4f14a99b2a Mon Sep 17 00:00:00 2001 From: Johnny Date: Fri, 21 Sep 2018 18:29:44 +0000 Subject: [PATCH 422/466] Adds a logger for security-related events. --- evennia/utils/logger.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/evennia/utils/logger.py b/evennia/utils/logger.py index d28c4e0e26..0a87d36010 100644 --- a/evennia/utils/logger.py +++ b/evennia/utils/logger.py @@ -254,6 +254,23 @@ def log_dep(depmsg): log_depmsg = log_dep +def log_sec(secmsg): + """ + Prints a security-related message. + + Args: + secmsg (str): The security message to log. + """ + try: + secmsg = str(secmsg) + except Exception as e: + secmsg = str(e) + for line in secmsg.splitlines(): + log_msg('[SS] %s' % line) + + +log_secmsg = log_sec + # Arbitrary file logger From 982f9774294039ec2db2477433ac0eb4b97d7eeb Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 22 Sep 2018 17:23:31 +0200 Subject: [PATCH 423/466] Continuing bug fixes --- CHANGELOG.md | 4 +-- evennia/prototypes/menus.py | 59 ++++++++++++++++++++++------------- evennia/prototypes/spawner.py | 16 ++++++++-- evennia/prototypes/tests.py | 31 ++++++++++++++++++ 4 files changed, 84 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13d13d63b3..c1ead4b367 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,7 +45,7 @@ current node instead of failing. - Better error handling of in-node syntax errors. - Improve dedent of default text/helptext formatter. Right-strip whitespace. -- Add `debug` option when creating menu - this turns of persistence and makes the `menudebug` +- Add `debug` option when creating menu - this turns off persistence and makes the `menudebug` command available for examining the current menu state. @@ -76,7 +76,7 @@ in the system. This is used by the new `@typeclass/list` subcommand (useful for builders etc). - `evennia.utils.dbserialize.deserialize(obj)` is a new helper function to *completely* disconnect a mutable recovered from an Attribute from the database. This will convert all nested `_Saver*` - classes to their plain-Python counterparts. + classes to their plain-Python counterparts. ### General diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 6e14993a1c..e124e4cb40 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -1944,21 +1944,26 @@ def _apply_diff(caller, **kwargs): def _keep_diff(caller, **kwargs): + """Change to KEEP setting for a given section of a diff""" + # from evennia import set_trace;set_trace(term_size=(182, 50)) path = kwargs['path'] diff = kwargs['diff'] tmp = diff for key in path[:-1]: - tmp = diff[key] - tmp[path[-1]] = "KEEP" + tmp = tmp[key] + tmp[path[-1]] = tuple(list(tmp[path[-1]][:-1]) + ["KEEP"]) -def _format_diff_text_and_options(diff): +def _format_diff_text_and_options(diff, **kwargs): """ Reformat the diff in a way suitable for the olc menu. Args: diff (dict): A diff as produced by `prototype_diff`. + Kwargs: + any (any): Forwarded into the generated options as arguments to the callable. + Returns: options (list): List of options dict. @@ -1968,49 +1973,60 @@ def _format_diff_text_and_options(diff): def _visualize(obj, rootname, get_name=False): if utils.is_iter(obj): if get_name: - return obj[0] + return obj[0] if obj[0] else "" if rootname == "attrs": return "{} |W=|n {} |W(category:|n {}|W, locks:|n {}|W)|n".format(*obj) elif rootname == "tags": return "{} |W(category:|n {}|W)|n".format(obj[0], obj[1]) return obj - def _parse_diffpart(diffpart, optnum, indent, *args): + def _parse_diffpart(diffpart, optnum, *args): typ = type(diffpart) texts = [] options = [] if typ == tuple and len(diffpart) == 3 and diffpart[2] in valid_instructions: + rootname = args[0] old, new, instruction = diffpart if instruction == 'KEEP': - texts.append("{old} |gKEEP|n".format(old=old)) + texts.append(" |gKEEP|W:|n {old}".format(old=old)) else: - texts.append("{indent}|c({num}) {inst}|W:|n {old} |W->|n {new}".format( - indent=" " * indent, - inst="|rREMOVE|n" if instruction == 'REMOVE' else "|y{}|n".format(instruction), - num=optnum, - old=_visualize(old, args[-1]), - new=_visualize(new, args[-1]))) + vold = _visualize(old, rootname) + vnew = _visualize(new, rootname) + vsep = "" if len(vold) < 78 else "\n" + vinst = "|rREMOVE|n" if instruction == 'REMOVE' else "|y{}|n".format(instruction) + texts.append(" |c[{num}] {inst}|W:|n {old} |W->|n{sep} {new}".format( + inst=vinst, num=optnum, old=vold, sep=vsep, new=vnew)) options.append({"key": str(optnum), - "desc": "|gKEEP|n {}".format( - _visualize(old, args[-1], get_name=True)), - "goto": (_keep_diff, {"path": args, "diff": diff})}) + "desc": "|gKEEP|n ({}) {}".format( + rootname, _visualize(old, args[-1], get_name=True)), + "goto": (_keep_diff, dict((("path", args), + ("diff", diff)), **kwargs))}) optnum += 1 else: for key, subdiffpart in diffpart.items(): text, option, optnum = _parse_diffpart( - subdiffpart, optnum, indent + 1, *(args + (key, ))) + subdiffpart, optnum, *(args + (key, ))) texts.extend(text) options.extend(option) - return text, options, optnum + return texts, options, optnum texts = [] options = [] # we use this to allow for skipping full KEEP instructions - flattened_diff = spawner.flatten_diff(diff) optnum = 1 - for root_key, diffpart in flattened_diff.items(): - text, option, optnum = _parse_diffpart(diffpart, optnum, 1, root_key) + for root_key in sorted(diff): + diffpart = diff[root_key] + text, option, optnum = _parse_diffpart(diffpart, optnum, root_key) + + heading = "- |w{}:|n ".format(root_key) + if root_key in ("attrs", "tags", "permissions"): + texts.append(heading) + elif text: + text = [heading + text[0]] + text[1:] + else: + text = [heading] + texts.extend(text) options.extend(option) @@ -2047,7 +2063,6 @@ def node_apply_diff(caller, **kwargs): # use one random object as a reference to calculate a diff base_obj = choice(update_objects) - # from evennia import set_trace diff, obj_prototype = spawner.prototype_diff_from_object(prototype, base_obj) helptext = """ @@ -2068,7 +2083,7 @@ def node_apply_diff(caller, **kwargs): if not custom_location: diff.pop("location", None) - txt, options = _format_diff_text_and_options(diff) + txt, options = _format_diff_text_and_options(diff, objects=update_objects, base_obj=base_obj) if options: text = ["Suggested changes to {} objects. ".format(len(update_objects)), diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index f46cbfcf3e..8981a015b4 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -299,19 +299,31 @@ def prototype_diff(prototype1, prototype2, maxdepth=2): if old_type != new_type: if old and not new: + if depth < maxdepth and old_type == dict: + return {key: (part, None, "REMOVE") for key, part in old.items()} + elif depth < maxdepth and is_iter(old): + return {part[0] if is_iter(part) else part: + (part, None, "REMOVE") for part in old} return (old, new, "REMOVE") elif not old and new: + if depth < maxdepth and new_type == dict: + return {key: (None, part, "ADD") for key, part in new.items()} + elif depth < maxdepth and is_iter(new): + return {part[0] if is_iter(part) else part: (None, part, "ADD") for part in new} return (old, new, "ADD") else: + # this condition should not occur in a standard diff return (old, new, "UPDATE") elif depth < maxdepth and new_type == dict: all_keys = set(old.keys() + new.keys()) - return {key: _recursive_diff(old.get(key), new.get(key), depth=depth + 1) for key in all_keys} + return {key: _recursive_diff(old.get(key), new.get(key), depth=depth + 1) + for key in all_keys} elif depth < maxdepth and is_iter(new): old_map = {part[0] if is_iter(part) else part: part for part in old} new_map = {part[0] if is_iter(part) else part: part for part in new} all_keys = set(old_map.keys() + new_map.keys()) - return {key: _recursive_diff(old_map.get(key), new_map.get(key), depth=depth + 1) for key in all_keys} + return {key: _recursive_diff(old_map.get(key), new_map.get(key), depth=depth + 1) + for key in all_keys} elif old != new: return (old, new, "UPDATE") else: diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 91d81310d1..8f58f9f2a1 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -569,6 +569,37 @@ class TestMenuModule(EvenniaTest): self.assertEqual(olc_menus._prototype_load_select(caller, self.test_prot['prototype_key']), ('node_examine_entity', {'text': '|gLoaded prototype test_prot.|n', 'back': 'index'}) ) + # diff helpers + obj_diff = { + 'attrs': { + u'desc': ((u'desc', u'This is User #1.', None, ''), + (u'desc', u'This is User #1.', None, ''), + 'KEEP'), + u'foo': (None, + (u'foo', u'bar', None, ''), + 'ADD'), + u'prelogout_location': ((u'prelogout_location', "#2", None, ''), + (u'prelogout_location', "#2", None, ''), + 'KEEP')}, + 'home': ('#2', '#2', 'KEEP'), + 'key': (u'TestChar', u'TestChar', 'KEEP'), + 'locks': ('boot:false();call:false();control:perm(Developer);delete:false();' + 'edit:false();examine:perm(Developer);get:false();msg:all();' + 'puppet:false();tell:perm(Admin);view:all()', + 'boot:false();call:false();control:perm(Developer);delete:false();' + 'edit:false();examine:perm(Developer);get:false();msg:all();' + 'puppet:false();tell:perm(Admin);view:all()', + 'KEEP'), + 'permissions': {'developer': ('developer', 'developer', 'KEEP')}, + 'prototype_desc': ('Testobject build', None, 'REMOVE'), + 'prototype_key': ('TestDiffKey', 'TestDiffKey', 'KEEP'), + 'prototype_locks': ('spawn:all();edit:all()', 'spawn:all();edit:all()', 'KEEP'), + 'prototype_tags': {}, + 'tags': {'foo': (None, ('foo', None, ''), 'ADD')}, + 'typeclass': (u'typeclasses.characters.Character', + u'typeclasses.characters.Character', 'KEEP')} + self.assertEqual(olc_menus._format_diff_text_and_options(obj_diff), "") + @mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock( return_value=[{"prototype_key": "TestPrototype", From 3537ae13a4e25d2622a485a568323edf423a65d7 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 22 Sep 2018 22:27:50 +0200 Subject: [PATCH 424/466] Correct issues with object-update in OLC menu. Resolves #1647. --- evennia/prototypes/menus.py | 5 ++-- evennia/prototypes/spawner.py | 15 ++++++++---- evennia/prototypes/tests.py | 44 ++++++++++++++++++++++++++++------- 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index e124e4cb40..9605ea1a8f 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -1965,6 +1965,7 @@ def _format_diff_text_and_options(diff, **kwargs): any (any): Forwarded into the generated options as arguments to the callable. Returns: + texts (list): List of texts. options (list): List of options dict. """ @@ -1978,7 +1979,7 @@ def _format_diff_text_and_options(diff, **kwargs): return "{} |W=|n {} |W(category:|n {}|W, locks:|n {}|W)|n".format(*obj) elif rootname == "tags": return "{} |W(category:|n {}|W)|n".format(obj[0], obj[1]) - return obj + return "{}".format(obj) def _parse_diffpart(diffpart, optnum, *args): typ = type(diffpart) @@ -1988,7 +1989,7 @@ def _format_diff_text_and_options(diff, **kwargs): rootname = args[0] old, new, instruction = diffpart if instruction == 'KEEP': - texts.append(" |gKEEP|W:|n {old}".format(old=old)) + texts.append(" |gKEEP|W:|n {old}".format(old=_visualize(old, rootname))) else: vold = _visualize(old, rootname) vnew = _visualize(new, rootname) diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 8981a015b4..7d876cf580 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -502,19 +502,26 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None): elif key == 'permissions': if directive == 'REPLACE': obj.permissions.clear() - obj.permissions.batch_add(*init_spawn_value(val, make_iter)) + obj.permissions.batch_add(*(init_spawn_value(perm, str) for perm in val)) elif key == 'aliases': if directive == 'REPLACE': obj.aliases.clear() - obj.aliases.batch_add(*init_spawn_value(val, make_iter)) + obj.aliases.batch_add(*(init_spawn_value(alias, str) for alias in val)) elif key == 'tags': if directive == 'REPLACE': obj.tags.clear() - obj.tags.batch_add(*init_spawn_value(val, make_iter)) + obj.tags.batch_add(*( + (init_spawn_value(ttag, str), tcategory, tdata) + for ttag, tcategory, tdata in val)) elif key == 'attrs': if directive == 'REPLACE': obj.attributes.clear() - obj.attributes.batch_add(*init_spawn_value(val, make_iter)) + obj.attributes.batch_add(*( + (init_spawn_value(akey, str), + init_spawn_value(aval, value_to_obj), + acategory, + alocks) + for akey, aval, acategory, alocks in val)) elif key == 'exec': # we don't auto-rerun exec statements, it would be huge security risk! pass diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 8f58f9f2a1..1ad1d9ac47 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -107,7 +107,7 @@ class TestUtils(EvenniaTest): # modify prototype old_prot['new'] = 'new_val' old_prot['test'] = 'testval_changed' - old_prot['permissions'] = 'Builder' + old_prot['permissions'] = ['Builder'] # this will not update, since we don't update the prototype on-disk old_prot['prototype_desc'] = 'New version of prototype' old_prot['attrs'] += (("fooattr", "fooattrval", None, ''),) @@ -146,7 +146,7 @@ class TestUtils(EvenniaTest): 'edit:perm(Admin);examine:perm(Builder);get:all();' 'puppet:pperm(Developer);tell:perm(Admin);view:all()', 'new': 'new_val', - 'permissions': 'Builder', + 'permissions': ['Builder'], 'prototype_desc': 'New version of prototype', 'prototype_key': Something, 'prototype_locks': 'spawn:all();edit:all()', @@ -154,7 +154,6 @@ class TestUtils(EvenniaTest): 'test': 'testval_changed', 'typeclass': 'evennia.objects.objects.DefaultObject'}) - # from evennia import set_trace; set_trace(term_size=(182, 50)) self.assertEqual( pdiff, {'home': ('#1', '#1', 'KEEP'), @@ -182,13 +181,12 @@ class TestUtils(EvenniaTest): 'key': ('Obj', 'Obj', 'KEEP'), 'typeclass': ('evennia.objects.objects.DefaultObject', 'evennia.objects.objects.DefaultObject', 'KEEP'), - 'aliases': (['foo'], None, 'REMOVE'), + 'aliases': {'foo': ('foo', None, 'REMOVE')}, 'prototype_desc': ('Built from Obj', 'New version of prototype', 'UPDATE'), - 'permissions': (None, 'Builder', 'ADD')} - ) + 'permissions': {"Builder": (None, 'Builder', 'ADD')} + }) - # from evennia import set_trace;set_trace() self.assertEqual( spawner.flatten_diff(pdiff), {'aliases': 'REMOVE', @@ -598,7 +596,37 @@ class TestMenuModule(EvenniaTest): 'tags': {'foo': (None, ('foo', None, ''), 'ADD')}, 'typeclass': (u'typeclasses.characters.Character', u'typeclasses.characters.Character', 'KEEP')} - self.assertEqual(olc_menus._format_diff_text_and_options(obj_diff), "") + + texts, options = olc_menus._format_diff_text_and_options(obj_diff) + self.assertEqual( + "\n".join(texts), + '- |wattrs:|n \n' + ' |c[1] |yADD|n|W:|n None |W->|n foo |W=|n bar |W(category:|n None|W, locks:|n |W)|n\n' + ' |gKEEP|W:|n prelogout_location |W=|n #2 |W(category:|n None|W, locks:|n |W)|n\n' + ' |gKEEP|W:|n desc |W=|n This is User #1. |W(category:|n None|W, locks:|n |W)|n\n' + '- |whome:|n |gKEEP|W:|n #2\n' + '- |wkey:|n |gKEEP|W:|n TestChar\n' + '- |wlocks:|n |gKEEP|W:|n boot:false();call:false();control:perm(Developer);delete:false();edit:false();examine:perm(Developer);get:false();msg:all();puppet:false();tell:perm(Admin);view:all()\n' + '- |wpermissions:|n \n' + ' |gKEEP|W:|n developer\n' + '- |wprototype_desc:|n |c[2] |rREMOVE|n|W:|n Testobject build |W->|n None\n' + '- |wprototype_key:|n |gKEEP|W:|n TestDiffKey\n' + '- |wprototype_locks:|n |gKEEP|W:|n spawn:all();edit:all()\n' + '- |wprototype_tags:|n \n' + '- |wtags:|n \n' + ' |c[3] |yADD|n|W:|n None |W->|n foo |W(category:|n None|W)|n\n' + '- |wtypeclass:|n |gKEEP|W:|n typeclasses.characters.Character') + self.assertEqual( + options, + [{'goto': (Something, Something), + 'key': '1', + 'desc': '|gKEEP|n (attrs) None'}, + {'goto': (Something, Something), + 'key': '2', + 'desc': '|gKEEP|n (prototype_desc) Testobject build'}, + {'goto': (Something, Something), + 'key': '3', + 'desc': '|gKEEP|n (tags) None'}]) @mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock( From 0d3c9ebea346d0c589b04713ca50fd710ba92fb3 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 23 Sep 2018 23:29:23 +0200 Subject: [PATCH 425/466] Fix no-location issues for istart (resolves #1662). --- evennia/server/evennia_launcher.py | 12 +++++++++--- evennia/server/server.py | 4 ++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index c83d869336..ee6c955366 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -971,16 +971,17 @@ def start_server_interactive(): print("... Stopped Server with Ctrl-C.") else: print("... Server stopped (leaving interactive mode).") - stop_server_only(when_stopped=_iserver) + stop_server_only(when_stopped=_iserver, interactive=True) -def stop_server_only(when_stopped=None): +def stop_server_only(when_stopped=None, interactive=False): """ Only stop the Server-component of Evennia (this is not useful except for debug) Args: when_stopped (callable): This will be called with no arguments when Server has stopped (or if it had already stopped when this is called). + interactive (bool, optional): Set if this is called as part of the interactive reload mechanism. """ def _server_stopped(*args): @@ -995,7 +996,10 @@ def stop_server_only(when_stopped=None): if srun: print("Server stopping ...") wait_for_status_reply(_server_stopped) - send_instruction(SSHUTD, {}) + if interactive: + send_instruction(SRELOAD, {}) + else: + send_instruction(SSHUTD, {}) else: if when_stopped: when_stopped() @@ -1005,6 +1009,8 @@ def stop_server_only(when_stopped=None): def _portal_not_running(fail): print("Evennia is not running.") + if interactive: + print("Start Evennia normally first, then use `istart` to switch to interactive mode.") _reactor_stop() send_instruction(PSTATUS, None, _portal_running, _portal_not_running) diff --git a/evennia/server/server.py b/evennia/server/server.py index 5a225704c2..0e9193f723 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -192,9 +192,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("shutdown", _reactor_stopping=True)) + d.addCallback(lambda _: self.shutdown("reload", _reactor_stopping=True)) else: - d = Deferred(lambda _: self.shutdown("shutdown", _reactor_stopping=True)) + d = Deferred(lambda _: self.shutdown("reload", _reactor_stopping=True)) d.addCallback(lambda _: reactor.stop()) reactor.callLater(1, d.callback, None) reactor.sigInt = _wrap_sigint_handler From 07d56f562ba933afcb988470ace3e77cc6d61429 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 25 Sep 2018 00:24:45 +0200 Subject: [PATCH 426/466] Make portal possible to start in the foreground too --- evennia/server/evennia_launcher.py | 74 +++++++++++++++++++++++------ evennia/server/portal/amp_server.py | 6 +-- evennia/server/portal/portal.py | 63 ++++++++++++++---------- 3 files changed, 99 insertions(+), 44 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index ee6c955366..4ae2699e60 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -42,7 +42,6 @@ EVENNIA_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(_ 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") EVENNIA_TEMPLATE = os.path.join(EVENNIA_LIB, "game_template") EVENNIA_PROFILING = os.path.join(EVENNIA_SERVER, "profiling") EVENNIA_DUMMYRUNNER = os.path.join(EVENNIA_PROFILING, "dummyrunner.py") @@ -462,18 +461,19 @@ SERVER_INFO = \ ARG_OPTIONS = \ """Actions on installed server. One of: - start - launch server+portal if not running - reload - restart server in 'reload' mode - stop - shutdown server+portal - reboot - shutdown server+portal, then start again - reset - restart server in 'shutdown' mode - istart - start server in the foreground (until reload) - sstop - stop only server - kill - send kill signal to portal+server (force) - skill - send kill signal only to server - status - show server and portal run state - info - show server and portal port info - menu - show a menu of options + start - launch server+portal if not running + reload - restart server in 'reload' mode + stop - shutdown server+portal + reboot - shutdown server+portal, then start again + reset - restart server in 'shutdown' mode + istart - start server in foreground (until reload) + ipstart - start portal in foreground + sstop - stop only server + 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 Others, like migrate, test and shell is passed on to Django.""" # ------------------------------------------------------------ @@ -974,6 +974,47 @@ def start_server_interactive(): stop_server_only(when_stopped=_iserver, interactive=True) +def start_portal_interactive(): + """ + Start the Portal under control of the launcher process (foreground) + + Notes: + In a normal start, the launcher waits for the Portal to start, then + tells it to start the Server. Since we can't do this here, we instead + start the Server first and then starts the Portal - the Server will + auto-reconnect to the Portal. To allow the Server to be reloaded, this + relies on a fixed server server-cmdline stored as a fallback on the + portal application in evennia/server/portal/portal.py. + + """ + def _iportal(fail): + portal_twistd_cmd, server_twistd_cmd = _get_twistd_cmdline(False, False) + portal_twistd_cmd.append("--nodaemon") + + # starting Server first - it will auto-connect once Portal comes up + if _is_windows(): + # Windows requires special care + create_no_window = 0x08000000 + Popen(server_twistd_cmd, env=getenv(), bufsize=-1, + creationflags=create_no_window) + else: + Popen(server_twistd_cmd, env=getenv(), bufsize=-1) + + print("Starting Portal in interactive mode (stop with Ctrl-C)...") + try: + Popen(portal_twistd_cmd, env=getenv(), stderr=STDOUT).wait() + except KeyboardInterrupt: + print("... Stopped Portal with Ctrl-C.") + else: + print("... Portal stopped (leaving interactive mode).") + + def _portal_running(response): + print("Evennia must be shut down completely before running Portal in interactive mode.") + _reactor_stop() + + send_instruction(PSTATUS, None, _portal_running, _iportal) + + def stop_server_only(when_stopped=None, interactive=False): """ Only stop the Server-component of Evennia (this is not useful except for debug) @@ -981,7 +1022,8 @@ def stop_server_only(when_stopped=None, interactive=False): Args: when_stopped (callable): This will be called with no arguments when Server has stopped (or if it had already stopped when this is called). - interactive (bool, optional): Set if this is called as part of the interactive reload mechanism. + interactive (bool, optional): Set if this is called as part of the interactive reload + mechanism. """ def _server_stopped(*args): @@ -1972,7 +2014,7 @@ def main(): # launch menu for operation init_game_directory(CURRENT_DIR, check_db=True) run_menu() - elif option in ('status', 'info', 'start', 'istart', 'reload', 'reboot', + elif option in ('status', 'info', 'start', 'istart', 'ipstart', 'reload', 'reboot', 'reset', 'stop', 'sstop', 'kill', 'skill'): # operate the server directly if not SERVER_LOGFILE: @@ -1985,6 +2027,8 @@ def main(): start_evennia(args.profiler, args.profiler) elif option == "istart": start_server_interactive() + elif option == "ipstart": + start_portal_interactive() elif option == 'reload': reload_evennia(args.profiler) elif option == 'reboot': diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index 38e39fb464..c07b5c121d 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -336,9 +336,9 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): elif operation == amp.PSHUTD: # portal + server shutdown #16 if server_connected: self.factory.server_connection.wait_for_disconnect( - self.factory.portal.shutdown, restart=False) + self.factory.portal.shutdown ) else: - self.factory.portal.shutdown(restart=False) + self.factory.portal.shutdown() else: raise Exception("operation %(op)s not recognized." % {'op': operation}) @@ -414,7 +414,7 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): elif operation == amp.PSHUTD: # full server+server shutdown self.factory.server_connection.wait_for_disconnect( - self.factory.portal.shutdown, restart=False) + self.factory.portal.shutdown) self.stop_server(mode='shutdown') elif operation == amp.PSYNC: # portal sync diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index 6b15cde73a..91b3efc7bc 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -12,6 +12,7 @@ from builtins import object import sys import os +from os.path import dirname, abspath from twisted.application import internet, service from twisted.internet import protocol, reactor from twisted.python.log import ILogObserver @@ -113,41 +114,46 @@ class Portal(object): self.server_restart_mode = "shutdown" self.server_info_dict = {} + # in non-interactive portal mode, this gets overwritten by + # cmdline sent by the evennia launcher + self.server_twistd_cmd = self._get_backup_server_twistd_cmd() + # set a callback if the server is killed abruptly, # by Ctrl-C, reboot etc. - reactor.addSystemEventTrigger('before', 'shutdown', self.shutdown, _reactor_stopping=True) + reactor.addSystemEventTrigger('before', 'shutdown', + self.shutdown, _reactor_stopping=True, _stop_server=True) + + def _get_backup_server_twistd_cmd(self): + """ + For interactive Portal mode there is no way to get the server cmdline from the launcher, so + we need to guess it here (it's very likely to not change) + + Returns: + server_twistd_cmd (list): An instruction for starting the server, to pass to Popen. + """ + server_twistd_cmd = [ + "twistd", + "--python={}".format(os.path.join(dirname(dirname(abspath(__file__))), "server.py"))] + if os.name != 'nt': + gamedir = os.getcwd() + server_twistd_cmd.append("--pidfile={}".format( + os.path.join(gamedir, "server", "server.pid"))) + return server_twistd_cmd 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 - should be restarted or is shutting down. - - Args: - mode (bool or None): Valid modes are True/False and None. - If mode is None, no change will be done to the flag file. - - """ - if mode is None: - return - with open(PORTAL_RESTART, 'w') as f: - f.write(str(mode)) - - def shutdown(self, restart=None, _reactor_stopping=False): + def shutdown(self, _reactor_stopping=False, _stop_server=False): """ Shuts down the server from inside it. Args: - restart (bool or None, optional): True/False sets the - flags so the server will be restarted or not. If None, the - current flag setting (set at initialization or previous - runs) is used. _reactor_stopping (bool, optional): This is set if server is already in the process of shutting down; in this case we don't need to stop it again. + _stop_server (bool, optional): Only used in portal-interactive mode; + makes sure to stop the Server cleanly. Note that restarting (regardless of the setting) will not work if the Portal is currently running in daemon mode. In that @@ -158,8 +164,10 @@ class Portal(object): # we get here due to us calling reactor.stop below. No need # to do the shutdown procedure again. return + self.sessions.disconnect_all() - self.set_restart_mode(restart) + if _stop_server: + self.amp_protocol.stop_server(mode='shutdown') if not _reactor_stopping: # shutting down the reactor will trigger another signal. We set @@ -179,9 +187,11 @@ 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)) -application.setComponent(ILogObserver, logger.PortalLogObserver(logfile).emit) + +if "--nodaemon" not in sys.argv: + 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. @@ -331,7 +341,8 @@ if WEBSERVER_ENABLED: factory.noisy = False factory.protocol = webclient.WebSocketClient factory.sessionhandler = PORTAL_SESSIONS - websocket_service = internet.TCPServer(port, WebSocketFactory(factory), interface=w_interface) + websocket_service = internet.TCPServer(port, WebSocketFactory(factory), + interface=w_interface) websocket_service.setName('EvenniaWebSocket%s:%s' % (w_ifacestr, port)) PORTAL.services.addService(websocket_service) websocket_started = True From d132912a7a09ef04cd8153752e28d77847c60d0e Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 25 Sep 2018 19:37:59 +0200 Subject: [PATCH 427/466] RUN MIGRATIONS. Resolves #1595. --- .../migrations/0016_auto_20180925_1735.py | 24 +++++++++++++++++++ evennia/comms/models.py | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 evennia/comms/migrations/0016_auto_20180925_1735.py diff --git a/evennia/comms/migrations/0016_auto_20180925_1735.py b/evennia/comms/migrations/0016_auto_20180925_1735.py new file mode 100644 index 0000000000..cd4588843f --- /dev/null +++ b/evennia/comms/migrations/0016_auto_20180925_1735.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2018-09-25 17:35 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('comms', '0015_auto_20170706_2041'), + ] + + operations = [ + migrations.RemoveField( + model_name='channeldb', + name='db_subscriptions', + ), + migrations.AlterField( + model_name='msg', + name='db_message', + field=models.TextField(verbose_name=b'message'), + ), + ] diff --git a/evennia/comms/models.py b/evennia/comms/models.py index c358cf352b..bff356ab9f 100644 --- a/evennia/comms/models.py +++ b/evennia/comms/models.py @@ -107,7 +107,7 @@ class Msg(SharedMemoryModel): # it, or as a separate store for the mail subject line maybe. db_header = models.TextField('header', null=True, blank=True) # the message body itself - db_message = models.TextField('messsage') + db_message = models.TextField('message') # send date db_date_created = models.DateTimeField('date sent', editable=False, auto_now_add=True, db_index=True) # lock storage From e51c48c8fce70faaa1ea1328686c5b556a8ddcec Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 25 Sep 2018 20:07:11 +0200 Subject: [PATCH 428/466] Make Docker image support develop branch launcher --- bin/unix/evennia-docker-start.sh | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bin/unix/evennia-docker-start.sh b/bin/unix/evennia-docker-start.sh index 270f6ec627..d1333aaef6 100644 --- a/bin/unix/evennia-docker-start.sh +++ b/bin/unix/evennia-docker-start.sh @@ -7,7 +7,4 @@ 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 +exec 3>&1; evennia start -l From 240cc9e02fd9b7783c4a26624b8527227b2da2bb Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 25 Sep 2018 20:16:53 +0200 Subject: [PATCH 429/466] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1ead4b367..d54222cd26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ ### General - Start structuring the `CHANGELOG` to list features in more detail. +- Docker image `evennia/evennia:develop` is now auto-built, tracking the develop branch. - Inflection and grouping of multiple objects in default room (an box, three boxes) - `evennia.set_trace()` is now a shortcut for launching pdb/pudb on a line in the Evennia event loop. From a489256cc342adab27f728f96d56f31890d0777c Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 25 Sep 2018 20:43:01 +0200 Subject: [PATCH 430/466] Some pep8 fixes --- evennia/contrib/README.md | 2 + evennia/contrib/building_menu.py | 100 +++++++++++++++++++------------ 2 files changed, 65 insertions(+), 37 deletions(-) diff --git a/evennia/contrib/README.md b/evennia/contrib/README.md index 4785be6197..a3dd837444 100644 --- a/evennia/contrib/README.md +++ b/evennia/contrib/README.md @@ -17,6 +17,8 @@ things you want from here into your game folder and change them there. * Barter system (Griatch 2012) - A safe and effective barter-system for any game. Allows safe trading of any goods (including coin). +* Building menu (vincent-lg 2018) - An @edit command for modifying + objects using a generated menu. Customizable for different games. * CharGen (Griatch 2011) - A simple Character creator for OOC mode. Meant as a starting point for a more fleshed-out system. * Clothing (FlutterSprite 2017) - A layered clothing system with diff --git a/evennia/contrib/building_menu.py b/evennia/contrib/building_menu.py index 75b7fe63c2..596af136b3 100644 --- a/evennia/contrib/building_menu.py +++ b/evennia/contrib/building_menu.py @@ -130,12 +130,14 @@ from evennia.utils.eveditor import EvEditor from evennia.utils.logger import log_err, log_trace from evennia.utils.utils import class_from_module -## Constants + +# Constants _MAX_TEXT_WIDTH = settings.CLIENT_DEFAULT_WIDTH _CMD_NOMATCH = cmdhandler.CMD_NOMATCH _CMD_NOINPUT = cmdhandler.CMD_NOINPUT -## Private functions + +# Private functions def _menu_loadfunc(caller): obj, attr = caller.attributes.get("_building_menu_to_edit", [None, None]) if obj and attr: @@ -144,6 +146,7 @@ def _menu_loadfunc(caller): return getattr(obj, attr.split(".")[-1]) if obj is not None else "" + def _menu_savefunc(caller, buf): obj, attr = caller.attributes.get("_building_menu_to_edit", [None, None]) if obj and attr: @@ -155,11 +158,15 @@ def _menu_savefunc(caller, buf): caller.attributes.remove("_building_menu_to_edit") return True + def _menu_quitfunc(caller): - caller.cmdset.add(BuildingMenuCmdSet, permanent=caller.ndb._building_menu and caller.ndb._building_menu.persistent or False) + caller.cmdset.add(BuildingMenuCmdSet, + permanent=caller.ndb._building_menu and + caller.ndb._building_menu.persistent or False) if caller.ndb._building_menu: caller.ndb._building_menu.move(back=True) + def _call_or_get(value, menu=None, choice=None, string=None, obj=None, caller=None): """ Call the value, if appropriate, or just return it. @@ -224,7 +231,8 @@ def _call_or_get(value, menu=None, choice=None, string=None, obj=None, caller=No return value -## Helper functions, to be used in menu choices + +# Helper functions, to be used in menu choices def menu_setattr(menu, choice, obj, string): """ @@ -256,6 +264,7 @@ def menu_setattr(menu, choice, obj, string): setattr(obj, attr.split(".")[-1], string) return True + def menu_quit(caller, menu): """ Quit the menu, closing the CmdSet. @@ -271,7 +280,8 @@ def menu_quit(caller, menu): """ if caller is None or menu is None: - log_err("The function `menu_quit` was called with missing arguments: caller={}, menu={}".format(caller, menu)) + log_err("The function `menu_quit` was called with missing " + "arguments: caller={}, menu={}".format(caller, menu)) if caller.cmdset.has(BuildingMenuCmdSet): menu.close() @@ -279,6 +289,7 @@ def menu_quit(caller, menu): else: caller.msg("It looks like the building menu has already been closed.") + def menu_edit(caller, choice, obj): """ Open the EvEditor to edit a specified attribute. @@ -292,10 +303,11 @@ def menu_edit(caller, choice, obj): attr = choice.attr caller.db._building_menu_to_edit = (obj, attr) caller.cmdset.remove(BuildingMenuCmdSet) - EvEditor(caller, loadfunc=_menu_loadfunc, savefunc=_menu_savefunc, quitfunc=_menu_quitfunc, key="editor", persistent=True) + EvEditor(caller, loadfunc=_menu_loadfunc, savefunc=_menu_savefunc, quitfunc=_menu_quitfunc, + key="editor", persistent=True) -## Building menu commands and CmdSet +# Building menu commands and CmdSet class CmdNoInput(Command): @@ -380,14 +392,16 @@ class BuildingMenuCmdSet(CmdSet): for cmd in cmds: self.add(cmd(building_menu=menu)) -## Menu classes + +# Menu classes class Choice(object): """A choice object, created by `add_choice`.""" - def __init__(self, title, key=None, aliases=None, attr=None, text=None, glance=None, on_enter=None, on_nomatch=None, on_leave=None, - menu=None, caller=None, obj=None): + def __init__(self, title, key=None, aliases=None, attr=None, text=None, + glance=None, on_enter=None, on_nomatch=None, on_leave=None, + menu=None, caller=None, obj=None): """Constructor. Args: @@ -452,7 +466,8 @@ class Choice(object): """ if self.on_enter: - _call_or_get(self.on_enter, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) + _call_or_get(self.on_enter, menu=self.menu, choice=self, string=string, + caller=self.caller, obj=self.obj) def nomatch(self, string): """Called when the user entered something in the choice. @@ -467,7 +482,8 @@ class Choice(object): """ if self.on_nomatch: - return _call_or_get(self.on_nomatch, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) + return _call_or_get(self.on_nomatch, menu=self.menu, choice=self, + string=string, caller=self.caller, obj=self.obj) return True @@ -479,7 +495,8 @@ class Choice(object): """ if self.on_leave: - _call_or_get(self.on_leave, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) + _call_or_get(self.on_leave, menu=self.menu, choice=self, + string=string, caller=self.caller, obj=self.obj) class BuildingMenu(object): @@ -505,12 +522,13 @@ class BuildingMenu(object): """ - keys_go_back = ["@"] # The keys allowing to go back in the menu tree - sep_keys = "." # The key separator for menus with more than 2 levels - joker_key = "*" # The special key meaning "anything" in a choice key - min_shortcut = 1 # The minimum length of shorcuts when `key` is not set + keys_go_back = ["@"] # The keys allowing to go back in the menu tree + sep_keys = "." # The key separator for menus with more than 2 levels + joker_key = "*" # The special key meaning "anything" in a choice key + min_shortcut = 1 # The minimum length of shorcuts when `key` is not set - def __init__(self, caller=None, obj=None, title="Building menu: {obj}", keys=None, parents=None, persistent=False): + def __init__(self, caller=None, obj=None, title="Building menu: {obj}", + keys=None, parents=None, persistent=False): """Constructor, you shouldn't override. See `init` instead. Args: @@ -681,7 +699,7 @@ class BuildingMenu(object): pass def add_choice(self, title, key=None, aliases=None, attr=None, text=None, glance=None, - on_enter=None, on_nomatch=None, on_leave=None): + on_enter=None, on_nomatch=None, on_leave=None): """ Add a choice, a valid sub-menu, in the current builder menu. @@ -744,7 +762,8 @@ class BuildingMenu(object): on_nomatch = menu_setattr if key and key in self.cmds: - raise ValueError("A conflict exists between {} and {}, both use key or alias {}".format(self.cmds[key], title, repr(key))) + raise ValueError("A conflict exists between {} and {}, both use " + "key or alias {}".format(self.cmds[key], title, repr(key))) if attr: if glance is None: @@ -758,10 +777,12 @@ class BuildingMenu(object): Use |y{back}|n to go back to the main menu. Current value: |c{{{obj_attr}}}|n - """.format(attr=attr, obj_attr="obj." + attr, back="|n or |y".join(self.keys_go_back)) + """.format(attr=attr, obj_attr="obj." + attr, + back="|n or |y".join(self.keys_go_back)) - choice = Choice(title, key=key, aliases=aliases, attr=attr, text=text, glance=glance, on_enter=on_enter, on_nomatch=on_nomatch, on_leave=on_leave, - menu=self, caller=self.caller, obj=self.obj) + choice = Choice(title, key=key, aliases=aliases, attr=attr, text=text, glance=glance, + on_enter=on_enter, on_nomatch=on_nomatch, on_leave=on_leave, + menu=self, caller=self.caller, obj=self.obj) self.choices.append(choice) if key: self.cmds[key] = choice @@ -771,7 +792,8 @@ class BuildingMenu(object): return choice - def add_choice_edit(self, title="description", key="d", aliases=None, attr="db.desc", glance="\n {obj.db.desc}", on_enter=None): + def add_choice_edit(self, title="description", key="d", aliases=None, attr="db.desc", + glance="\n {obj.db.desc}", on_enter=None): """ Add a simple choice to edit a given attribute in the EvEditor. @@ -795,7 +817,8 @@ class BuildingMenu(object): """ on_enter = on_enter or menu_edit - return self.add_choice(title, key=key, aliases=aliases, attr=attr, glance=glance, on_enter=on_enter, text="") + return self.add_choice(title, key=key, aliases=aliases, attr=attr, + glance=glance, on_enter=on_enter, text="") def add_choice_quit(self, title="quit the menu", key="q", aliases=None, on_enter=None): """ @@ -860,14 +883,17 @@ class BuildingMenu(object): try: menu_class = class_from_module(parent_class) except Exception: - log_trace("BuildingMenu: attempting to load class {} failed".format(repr(parent_class))) + log_trace("BuildingMenu: attempting to load class {} failed".format( + repr(parent_class))) return # Create the parent menu try: - building_menu = menu_class(self.caller, parent_obj, keys=parent_keys, parents=tuple(parents)) + building_menu = menu_class(self.caller, parent_obj, + keys=parent_keys, parents=tuple(parents)) except Exception: - log_trace("An error occurred while creating building menu {}".format(repr(parent_class))) + log_trace("An error occurred while creating building menu {}".format( + repr(parent_class))) return else: return building_menu.open() @@ -942,12 +968,12 @@ class BuildingMenu(object): if choice: choice.leave("") - if not back: # Move forward + if not back: # Move forward if not key: raise ValueError("you are asking to move forward, you should specify a key.") self.keys.append(key) - else: # Move backward + else: # Move backward if not self.keys: raise ValueError("you already are at the top of the tree, you cannot move backward.") @@ -992,11 +1018,9 @@ class BuildingMenu(object): ret += "[|y" + choice.key.title() + "|n] " + title if choice.glance: - glance = _call_or_get(choice.glance, menu=self, choice=choice, caller=self.caller, string="", obj=self.obj) - try: - glance = glance.format(obj=self.obj, caller=self.caller) - except: - import pdb;pdb.set_trace() + glance = _call_or_get(choice.glance, menu=self, choice=choice, + caller=self.caller, string="", obj=self.obj) + glance = glance.format(obj=self.obj, caller=self.caller) ret += ": " + glance @@ -1030,7 +1054,8 @@ class BuildingMenu(object): if menu: class_name = menu.get("class") if not class_name: - log_err("BuildingMenu: on caller {}, a persistent attribute holds building menu data, but no class could be found to restore the menu".format(caller)) + log_err("BuildingMenu: on caller {}, a persistent attribute holds building menu " + "data, but no class could be found to restore the menu".format(caller)) return try: @@ -1046,7 +1071,8 @@ class BuildingMenu(object): parents = menu.get("parents") persistent = menu.get("persistent", False) try: - building_menu = menu_class(caller, obj, title=title, keys=keys, parents=parents, persistent=persistent) + building_menu = menu_class(caller, obj, title=title, keys=keys, + parents=parents, persistent=persistent) except Exception: log_trace("An error occurred while creating building menu {}".format(repr(class_name))) return From 4f93bc7ee5a846d0cb3e12ed6d403289155e1a10 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 25 Sep 2018 21:12:35 +0200 Subject: [PATCH 431/466] Fix for PEP8 and resolve a traceback. --- CHANGELOG.md | 5 +- evennia/contrib/fieldfill.py | 294 ++++++++++++++++++----------------- 2 files changed, 153 insertions(+), 146 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d54222cd26..91b1d5b3da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,9 +87,10 @@ ### Contribs +- `Build Menu` (vincent-lg): New @edit command to edit object properties in a menu. +- `Field Fill` (Tim Ashley Jenkins): Wraps EvMenu for creating submittable forms. - `Health Bar` (Tim Ashley Jenkins): Easily create colorful bars/meters. -- `Tree select` (Fluttersprite): Wrapper around EvMenu to easier create - a common form of menu from a string. +- `Tree select` (Fluttersprite): Wrap EvMenu to create a common type of menu from a string. - `Turnbattle suite` (Tim Ashley Jenkins)- the old `turnbattle.py` was moved into its own `turnbattle/` package and reworked with many different flavors of combat systems: - `tb_basic` - The basic turnbattle system, with initiative/turn order attack/defense/damage. diff --git a/evennia/contrib/fieldfill.py b/evennia/contrib/fieldfill.py index 2595f4eaf9..b20f64cd66 100644 --- a/evennia/contrib/fieldfill.py +++ b/evennia/contrib/fieldfill.py @@ -12,8 +12,8 @@ is submitted, the form's data is submitted as a dictionary to any callable of your choice. The function that initializes the fillable form menu is fairly simple, and -includes the caller, the template for the form, and the callback which the form -data will be sent to upon submission: +includes the caller, the template for the form, and the callback(caller, result) to which the form +data will be sent to upon submission. init_fill_field(formtemplate, caller, formcallback) @@ -27,27 +27,27 @@ a brief character profile: {"fieldname":"Age", "fieldtype":"number"}, {"fieldname":"History", "fieldtype":"text"}, ] - + This will present the player with an EvMenu showing this basic form: - Name: - Age: - History: + Name: + Age: + History: While in this menu, the player can assign a new value to any field with the syntax = , like so: > name = Ashley Field 'Name' set to: Ashley - + Typing 'look' by itself will show the form and its current values. > look - Name: Ashley - Age: - History: - + Name: Ashley + Age: + History: + Number fields require an integer input, and will reject any text that can't be converted into an integer. @@ -55,18 +55,18 @@ be converted into an integer. Field 'Age' requires a number. > age = 31 Field 'Age' set to: 31 - + Form data is presented as an EvTable, so text of any length will wrap cleanly. > history = EVERY MORNING I WAKE UP AND OPEN PALM SLAM[...] Field 'History' set to: EVERY MORNING I WAKE UP AND[...] > look - Name: Ashley - Age: 31 - History: EVERY MORNING I WAKE UP AND OPEN PALM SLAM A VHS INTO THE SLOT. - IT'S CHRONICLES OF RIDDICK AND RIGHT THEN AND THERE I START DOING - THE MOVES ALONGSIDE WITH THE MAIN CHARACTER, RIDDICK. I DO EVERY + Name: Ashley + Age: 31 + History: EVERY MORNING I WAKE UP AND OPEN PALM SLAM A VHS INTO THE SLOT. + IT'S CHRONICLES OF RIDDICK AND RIGHT THEN AND THERE I START DOING + THE MOVES ALONGSIDE WITH THE MAIN CHARACTER, RIDDICK. I DO EVERY MOVE AND I DO EVERY MOVE HARD. When the player types 'submit' (or your specified submit command), the menu @@ -88,7 +88,7 @@ do this by specifying "min" and "max" values in your field's dictionary: {"fieldname":"Age", "fieldtype":"number", "min":18, "max":100}, {"fieldname":"History", "fieldtype":"text"} ] - + Now if the player tries to enter a value out of range, the form will not acept the given value. @@ -96,10 +96,10 @@ given value. Field 'Age' reqiures a minimum value of 18. > age = 900 Field 'Age' has a maximum value of 100. - + Setting 'min' and 'max' for a text field will instead act as a minimum or maximum character length for the player's input. - + There are lots of ways to present the form to the player - fields can have default values or show a custom message in place of a blank value, and player input can be verified by a custom function, allowing for a great deal of flexibility. There @@ -139,15 +139,16 @@ Optional: the field to False or True. """ -from evennia.utils import evmenu, evtable, delay, list_to_string +from evennia.utils import evmenu, evtable, delay, list_to_string, logger from evennia import Command from evennia.server.sessionhandler import SESSIONS + class FieldEvMenu(evmenu.EvMenu): """ Custom EvMenu type with its own node formatter - removes extraneous lines """ - + def node_formatter(self, nodetext, optionstext): """ Formats the entirety of the node. @@ -163,7 +164,7 @@ class FieldEvMenu(evmenu.EvMenu): """ # Only return node text, no options or separators return nodetext - + def init_fill_field(formtemplate, caller, formcallback, pretext="", posttext="", submitcmd="submit", borderstyle="cells", formhelptext=None, @@ -172,12 +173,12 @@ def init_fill_field(formtemplate, caller, formcallback, pretext="", posttext="", Initializes a menu presenting a player with a fillable form - once the form is submitted, the data will be passed as a dictionary to your chosen function. - + Args: formtemplate (list of dicts): The template for the form's fields. caller (obj): Player who will be filling out the form. formcallback (callable): Function to pass the completed form's data to. - + Options: pretext (str): Text to put before the form in the menu. posttext (str): Text to put after the form in the menu. @@ -191,38 +192,39 @@ def init_fill_field(formtemplate, caller, formcallback, pretext="", posttext="", similar application, you may want to generate the initial form data dynamically before calling init_fill_field. """ - + # Initialize form data from the template if none provided formdata = form_template_to_dict(formtemplate) if initial_formdata: formdata = initial_formdata - + # Provide default help text if none given - if formhelptext == None: - formhelptext = ("Available commands:|/" - "|w = :|n Set given field to new value, replacing the old value|/" - "|wclear :|n Clear the value in the given field, making it blank|/" - "|wlook|n: Show the form's current values|/" - "|whelp|n: Display this help screen|/" - "|wquit|n: Quit the form menu without submitting|/" - "|w%s|n: Submit this form and quit the menu" % submitcmd) - + if formhelptext is None: + formhelptext = ( + "Available commands:|/" + "|w = :|n Set given field to new value, replacing the old value|/" + "|wclear :|n Clear the value in the given field, making it blank|/" + "|wlook|n: Show the form's current values|/" + "|whelp|n: Display this help screen|/" + "|wquit|n: Quit the form menu without submitting|/" + "|w%s|n: Submit this form and quit the menu" % submitcmd) + # Pass kwargs to store data needed in the menu kwargs = { - "formdata":formdata, - "formtemplate": formtemplate, - "formcallback": formcallback, - "pretext": pretext, - "posttext": posttext, - "submitcmd": submitcmd, - "borderstyle": borderstyle, - "formhelptext": formhelptext + "formdata": formdata, + "formtemplate": formtemplate, + "formcallback": formcallback, + "pretext": pretext, + "posttext": posttext, + "submitcmd": submitcmd, + "borderstyle": borderstyle, + "formhelptext": formhelptext } - + # Initialize menu of selections FieldEvMenu(caller, "evennia.contrib.fieldfill", startnode="menunode_fieldfill", auto_look=False, persistent=persistent, **kwargs) - + def menunode_fieldfill(caller, raw_string, **kwargs): """ @@ -230,7 +232,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): allow a player to enter values into a fillable form. When the form is submitted, the form data is passed to a callback as a dictionary. """ - + # Retrieve menu info - taken from ndb if not persistent or db if persistent if not caller.db._menutree: formdata = caller.ndb._menutree.formdata @@ -250,16 +252,16 @@ def menunode_fieldfill(caller, raw_string, **kwargs): submitcmd = caller.db._menutree.submitcmd borderstyle = caller.db._menutree.borderstyle formhelptext = caller.db._menutree.formhelptext - + # Syntax error syntax_err = "Syntax: = |/Or: clear , help, look, quit|/'%s' to submit form" % submitcmd - + # Display current form data text = (display_formdata(formtemplate, formdata, pretext=pretext, posttext=posttext, borderstyle=borderstyle), formhelptext) options = ({"key": "_default", - "goto":"menunode_fieldfill"}) - + "goto": "menunode_fieldfill"}) + if raw_string: # Test for given 'submit' command if raw_string.lower().strip() == submitcmd: @@ -276,18 +278,18 @@ def menunode_fieldfill(caller, raw_string, **kwargs): caller.msg("The following blank fields require a value: %s" % list_to_string(blank_and_required)) text = (None, formhelptext) return text, options - + # If everything checks out, pass form data to the callback and end the menu! try: formcallback(caller, formdata) except Exception: - log_trace("Error in fillable form callback.") + logger.log_trace("Error in fillable form callback.") return None, None - + # Test for 'look' command if raw_string.lower().strip() == "look" or raw_string.lower().strip() == "l": return text, options - + # Test for 'clear' command cleartest = raw_string.lower().strip().split(" ", 1) if cleartest[0].lower() == "clear": @@ -296,16 +298,16 @@ def menunode_fieldfill(caller, raw_string, **kwargs): caller.msg(syntax_err) return text, options matched_field = None - + for key in formdata.keys(): if cleartest[1].lower() in key.lower(): matched_field = key - + if not matched_field: caller.msg("Field '%s' does not exist!" % cleartest[1]) text = (None, formhelptext) return text, options - + # Test to see if field can be cleared for field in formtemplate: if field["fieldname"] == matched_field and "cantclear" in field.keys(): @@ -313,13 +315,13 @@ def menunode_fieldfill(caller, raw_string, **kwargs): caller.msg("Field '%s' can't be cleared!" % matched_field) text = (None, formhelptext) return text, options - + # Clear the field - formdata.update({matched_field:None}) + formdata.update({matched_field: None}) caller.ndb._menutree.formdata = formdata caller.msg("Field '%s' cleared." % matched_field) return text, options - + if "=" not in raw_string: text = (None, formhelptext) caller.msg(syntax_err) @@ -329,25 +331,25 @@ def menunode_fieldfill(caller, raw_string, **kwargs): entry = raw_string.split("=", 1) fieldname = entry[0].strip() newvalue = entry[1].strip() - + # Syntax error if field name is too short or blank if len(fieldname) < 1: caller.msg(syntax_err) text = (None, formhelptext) return text, options - + # Attempt to match field name to field in form data matched_field = None for key in formdata.keys(): if fieldname.lower() in key.lower(): matched_field = key - + # No matched field - if matched_field == None: + if matched_field is None: caller.msg("Field '%s' does not exist!" % fieldname) text = (None, formhelptext) return text, options - + # Set new field value if match # Get data from template fieldtype = None @@ -369,21 +371,21 @@ def menunode_fieldfill(caller, raw_string, **kwargs): falsestr = field["falsestr"] if "verifyfunc" in field.keys(): verifyfunc = field["verifyfunc"] - + # Field type text verification if fieldtype == "text": # Test for max/min - if max_value != None: + if max_value is not None: if len(newvalue) > max_value: caller.msg("Field '%s' has a maximum length of %i characters." % (matched_field, max_value)) text = (None, formhelptext) return text, options - if min_value != None: + if min_value is not None: if len(newvalue) < min_value: caller.msg("Field '%s' reqiures a minimum length of %i characters." % (matched_field, min_value)) text = (None, formhelptext) return text, options - + # Field type number verification if fieldtype == "number": try: @@ -393,17 +395,17 @@ def menunode_fieldfill(caller, raw_string, **kwargs): text = (None, formhelptext) return text, options # Test for max/min - if max_value != None: + if max_value is not None: if newvalue > max_value: caller.msg("Field '%s' has a maximum value of %i." % (matched_field, max_value)) text = (None, formhelptext) return text, options - if min_value != None: + if min_value is not None: if newvalue < min_value: caller.msg("Field '%s' reqiures a minimum value of %i." % (matched_field, min_value)) text = (None, formhelptext) return text, options - + # Field type bool verification if fieldtype == "bool": if newvalue.lower() != truestr.lower() and newvalue.lower() != falsestr.lower(): @@ -414,7 +416,7 @@ def menunode_fieldfill(caller, raw_string, **kwargs): newvalue = True elif newvalue.lower() == falsestr.lower(): newvalue = False - + # Call verify function if present if verifyfunc: if verifyfunc(caller, newvalue) is False: @@ -429,65 +431,67 @@ def menunode_fieldfill(caller, raw_string, **kwargs): newvalue = False elif newvalue == 1: newvalue = True - + # If everything checks out, update form!! - formdata.update({matched_field:newvalue}) + formdata.update({matched_field: newvalue}) caller.ndb._menutree.formdata = formdata - + # Account for truestr and falsestr when updating a boolean form announced_newvalue = newvalue if newvalue is True: announced_newvalue = truestr elif newvalue is False: announced_newvalue = falsestr - + # Announce the new value to the player caller.msg("Field '%s' set to: %s" % (matched_field, str(announced_newvalue))) text = (None, formhelptext) - + return text, options + def form_template_to_dict(formtemplate): """ Initializes a dictionary of form data from the given list-of-dictionaries form template, as formatted above. - + Args: formtemplate (list of dicts): Tempate for the form to be initialized. - + Returns: formdata (dict): Dictionary of initalized form data. """ formdata = {} - + for field in formtemplate: # Value is blank by default fieldvalue = None if "default" in field: # Add in default value if present fieldvalue = field["default"] - formdata.update({field["fieldname"]:fieldvalue}) - + formdata.update({field["fieldname"]: fieldvalue}) + return formdata - + + def display_formdata(formtemplate, formdata, pretext="", posttext="", borderstyle="cells"): """ Displays a form's current data as a table. Used in the form menu. - + Args: formtemplate (list of dicts): Template for the form formdata (dict): Form's current data - + Options: pretext (str): Text to put before the form table. posttext (str): Text to put after the form table. borderstyle (str): EvTable's border style. """ - + formtable = evtable.EvTable(border=borderstyle, valign="t", maxwidth=80) field_name_width = 5 - + for field in formtemplate: new_fieldname = None new_fieldvalue = None @@ -496,12 +500,12 @@ def display_formdata(formtemplate, formdata, if len(field["fieldname"]) + 5 > field_name_width: field_name_width = len(field["fieldname"]) + 5 # Get field value - if formdata[field["fieldname"]] != None: + if formdata[field["fieldname"]] is not None: new_fieldvalue = str(formdata[field["fieldname"]]) # Use blank message if field is blank and once is present - if new_fieldvalue == None and "blankmsg" in field: + if new_fieldvalue is None and "blankmsg" in field: new_fieldvalue = "|x" + str(field["blankmsg"]) + "|n" - elif new_fieldvalue == None: + elif new_fieldvalue is None: new_fieldvalue = " " # Replace True and False values with truestr and falsestr from template if formdata[field["fieldname"]] is True and "truestr" in field: @@ -510,24 +514,24 @@ def display_formdata(formtemplate, formdata, new_fieldvalue = field["falsestr"] # Add name and value to table formtable.add_row(new_fieldname, new_fieldvalue) - + formtable.reformat_column(0, align="r", width=field_name_width) - + return pretext + "|/" + str(formtable) + "|/" + posttext -""" -EXAMPLE FUNCTIONS / COMMAND STARTS HERE -""" - + +# EXAMPLE FUNCTIONS / COMMAND STARTS HERE + + def verify_online_player(caller, value): """ Example 'verify function' that matches player input to an online character or else rejects their input as invalid. - + Args: caller (obj): Player entering the form data. value (str): String player entered into the form, to be verified. - + Returns: matched_character (obj or False): dbref to a currently logged in character object - reference to the object will be stored in @@ -538,7 +542,7 @@ def verify_online_player(caller, value): session_list = SESSIONS.get_sessions() char_list = [] matched_character = None - + # Get a list of online characters for session in session_list: if not session.logged_in: @@ -551,56 +555,57 @@ def verify_online_player(caller, value): for character in char_list: if value.lower() == character.key.lower(): matched_character = character - + # If input didn't match to a character if not matched_character: # Send the player an error message unique to this function caller.msg("No character matching '%s' is online." % value) # Returning False indicates the new value is not valid return False - - # Returning anything besides True or False will replace the player's input with the returned value - # In this case, the value becomes a reference to the character object - # You can store data besides strings and integers in the 'formdata' dictionary this way! + + # Returning anything besides True or False will replace the player's input with the returned + # value. In this case, the value becomes a reference to the character object. You can store data + # besides strings and integers in the 'formdata' dictionary this way! return matched_character # Form template for the example 'delayed message' form SAMPLE_FORM = [ -{"fieldname":"Character", - "fieldtype":"text", - "max":30, - "blankmsg":"(Name of an online player)", - "required":True, - "verifyfunc":verify_online_player - }, -{"fieldname":"Delay", - "fieldtype":"number", - "min":3, - "max":30, - "default":10, - "cantclear":True - }, -{"fieldname":"Message", - "fieldtype":"text", - "min":3, - "max":200, - "blankmsg":"(Message up to 200 characters)" - }, -{"fieldname":"Anonymous", - "fieldtype":"bool", - "truestr":"Yes", - "falsestr":"No", - "default":False - } -] - + {"fieldname": "Character", + "fieldtype": "text", + "max": 30, + "blankmsg": "(Name of an online player)", + "required": True, + "verifyfunc": verify_online_player + }, + {"fieldname": "Delay", + "fieldtype": "number", + "min": 3, + "max": 30, + "default": 10, + "cantclear": True + }, + {"fieldname": "Message", + "fieldtype": "text", + "min": 3, + "max": 200, + "blankmsg": "(Message up to 200 characters)" + }, + {"fieldname": "Anonymous", + "fieldtype": "bool", + "truestr": "Yes", + "falsestr": "No", + "default": False + } + ] + + class CmdTestMenu(Command): """ This test command will initialize a menu that presents you with a form. You can fill out the fields of this form in any order, and then type in 'send' to send a message to another online player, which will reach them after a delay you specify. - + Usage: = clear @@ -618,28 +623,30 @@ class CmdTestMenu(Command): """ pretext = "|cSend a delayed message to another player ---------------------------------------|n" posttext = ("|c--------------------------------------------------------------------------------|n|/" - "Syntax: type |c = |n to change the values of the form. Given|/" - "player must be currently logged in, delay is given in seconds. When you are|/" - "finished, type '|csend|n' to send the message.|/") + "Syntax: type |c = |n to change the values of the form. Given|/" + "player must be currently logged in, delay is given in seconds. When you are|/" + "finished, type '|csend|n' to send the message.|/") init_fill_field(SAMPLE_FORM, self.caller, init_delayed_message, pretext=pretext, posttext=posttext, submitcmd="send", borderstyle="none") + def sendmessage(obj, text): """ Callback to send a message to a player. - + Args: obj (obj): Player to message. text (str): Message. """ - obj.msg(text) + obj.msg(text) + def init_delayed_message(caller, formdata): """ Initializes a delayed message, using data from the example form. - + Args: caller (obj): Character submitting the message. formdata (dict): Data from submitted form. @@ -653,9 +660,8 @@ def init_delayed_message(caller, formdata): if formdata["Anonymous"] is True: sender = "anonymous" message = ("Message from %s: " % sender) + str(formdata["Message"]) - + caller.msg("Message sent to %s!" % player_to_message) # Make a deferred call to 'sendmessage' above. - deferred = delay(message_delay, sendmessage, player_to_message, message) + delay(message_delay, sendmessage, player_to_message, message) return - From 036f2a9835c72407f460d72b2c52f863deeab42c Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 25 Sep 2018 21:22:27 +0200 Subject: [PATCH 432/466] Making settings dict less spacy to match style elsewhere --- evennia/settings_default.py | 27 +++++++-------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 8c07244636..134f37578b 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -802,28 +802,15 @@ INSTALLED_APPS = ( # This should usually not be changed. AUTH_USER_MODEL = "accounts.AccountDB" -# Password validation +# Password validation plugins # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - 'OPTIONS': { - 'min_length': 8, - } - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, - { - 'NAME': 'evennia.server.validators.EvenniaPasswordValidator', - }, -] + {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, + {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + 'OPTIONS': {'min_length': 8}}, + {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, + {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, + {'NAME': 'evennia.server.validators.EvenniaPasswordValidator'}] # Use a custom test runner that just tests Evennia-specific apps. TEST_RUNNER = 'evennia.server.tests.EvenniaTestSuiteRunner' From f4cb75c4960ab6792868bc0a5b12e858426bef30 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 25 Sep 2018 22:11:34 +0200 Subject: [PATCH 433/466] Up requirements to Django 1.11, Twisted 18 and pillow 5.2.0 --- CHANGELOG.md | 1 + evennia/server/evennia_launcher.py | 2 +- requirements.txt | 6 +++--- win_requirements.txt | 5 ++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91b1d5b3da..6dda27e14c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,7 @@ ### General +- Up requirements to Django 1.11.x, Twited 18 and pillow 5.2.0 - Start structuring the `CHANGELOG` to list features in more detail. - Docker image `evennia/evennia:develop` is now auto-built, tracking the develop branch. - Inflection and grouping of multiple objects in default room (an box, three boxes) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 4ae2699e60..6b41f16d4a 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -94,7 +94,7 @@ SRESET = chr(19) # shutdown server in reset mode # requirements PYTHON_MIN = '2.7' -TWISTED_MIN = '16.0.0' +TWISTED_MIN = '18.0.0' DJANGO_MIN = '1.11' DJANGO_REC = '1.11' diff --git a/requirements.txt b/requirements.txt index 347b838fe6..cf51c7c98d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ # Evennia dependencies, for Linux/Mac platforms # general -django > 1.10, < 2.0 -twisted == 16.0.0 -pillow == 2.9.0 +django > 1.11, < 2.0 +twisted >= 18.0.0, < 19.0.0 +pillow == 5.2.0 pytz future >= 0.15.2 django-sekizai diff --git a/win_requirements.txt b/win_requirements.txt index 8a2f519a81..5e23f6fe67 100644 --- a/win_requirements.txt +++ b/win_requirements.txt @@ -3,9 +3,8 @@ # windows specific pypiwin32 -# general -django > 1.10, < 2.0 -twisted >= 16.0.0 +django > 1.11, < 2.0 +twisted >= 18.0.0, < 19.0.0 pillow == 2.9.0 pytz future >= 0.15.2 From 54213ab6146aa2793c7f019a13d8a09999d00f3c Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 25 Sep 2018 21:24:54 +0000 Subject: [PATCH 434/466] Moves installation/config instructions to README. --- evennia/contrib/auditing/README.md | 67 ++++++++++++++++++++++++++++++ evennia/contrib/auditing/server.py | 44 +------------------- 2 files changed, 68 insertions(+), 43 deletions(-) create mode 100644 evennia/contrib/auditing/README.md diff --git a/evennia/contrib/auditing/README.md b/evennia/contrib/auditing/README.md new file mode 100644 index 0000000000..ce1eff800b --- /dev/null +++ b/evennia/contrib/auditing/README.md @@ -0,0 +1,67 @@ +# Input/Output Auditing + +Contrib - Johnny 2017 + +This is a tap that optionally intercepts all data sent to/from clients and the +server and passes it to a callback of your choosing. + +It is intended for quality assurance, post-incident investigations and debugging +but obviously can be abused. All data is recorded in cleartext. Please +be ethical, and if you are unwilling to properly deal with the implications of +recording user passwords or private communications, please do not enable +this module. + +Some checks have been implemented to protect the privacy of users. + + +Files included in this module: + + outputs.py - Example callback methods. This module ships with examples of + callbacks that send data as JSON to a file in your game/server/logs + dir or to your native Linux syslog daemon. You can of course write + your own to do other things like post them to Kafka topics. + + server.py - Extends the Evennia ServerSession object to pipe data to the + callback upon receipt. + + tests.py - Unit tests that check to make sure commands with sensitive + arguments are having their PII scrubbed. + + +Installation/Configuration: + +Deployment is completed by configuring a few settings in server.conf. In short, +you must tell Evennia to use this ServerSession instead of its own, specify +which direction(s) you wish to record and where you want the data sent. + + SERVER_SESSION_CLASS = 'evennia.contrib.auditing.server.AuditedServerSession' + + # Where to send logs? Define the path to a module containing your callback + # function. It should take a single dict argument as input. + AUDIT_CALLBACK = 'evennia.contrib.auditing.outputs.to_file' + + # Log user input? Be ethical about this; it will log all private and + # public communications between players and/or admins. + AUDIT_IN = True/False + + # Log server output? This will result in logging of ALL system + # messages and ALL broadcasts to connected players, so on a busy game any + # broadcast to all users will yield a single event for every connected user! + AUDIT_OUT = True/False + + # The default output is a dict. Do you want to allow key:value pairs with + # null/blank values? If you're just writing to disk, disabling this saves + # some disk space, but whether you *want* sparse values or not is more of a + # consideration if you're shipping logs to a NoSQL/schemaless database. + AUDIT_ALLOW_SPARSE = True/False + + # If you write custom commands that handle sensitive data like passwords, + # you must write a regular expression to remove that before writing to log. + # AUDIT_MASKS is a list of dictionaries that define the names of commands + # and the regexes needed to scrub them. + # + # The sensitive data itself must be captured in a named group with a + # label of 'secret'. + AUDIT_MASKS = [ + {'authentication': r"^@auth\s+(?P[\w]+)"}, + ] \ No newline at end of file diff --git a/evennia/contrib/auditing/server.py b/evennia/contrib/auditing/server.py index e5a9d67a67..923d873f0c 100644 --- a/evennia/contrib/auditing/server.py +++ b/evennia/contrib/auditing/server.py @@ -52,49 +52,7 @@ class AuditedServerSession(ServerSession): have their arguments masked by default, but you must mask or mask any custom commands of your own that handle sensitive information. - Installation: - - Designate this class as the SERVER_SESSION_CLASS in `settings.py`, then set - some additional options concerning what to log and where to send it. - - settings.py: - SERVER_SESSION_CLASS = 'evennia.contrib.auditing.server.AuditedServerSession' - - # Where to send logs? Define the path to a module containing a function - # called 'output()' you've written that accepts a dict object as its sole - # argument. From that function you can store/forward the message received - # as you please. An example file-logger is below: - AUDIT_CALLBACK = 'evennia.contrib.auditing.outputs.to_file' - - # Log all user input? Be ethical about this; it will log all private and - # public communications between players and/or admins. - AUDIT_IN = True/False - - # Log server output? This will result in logging of ALL system - # messages and ALL broadcasts to connected players, so on a busy MUD this - # will be very voluminous! - AUDIT_OUT = True/False - - # The default output is a dict. Do you want to allow key:value pairs with - # null/blank values? If you're just writing to disk, disabling this saves - # some disk space, but whether you *want* sparse values or not is more of a - # consideration if you're shipping logs to a NoSQL/schemaless database. - AUDIT_ALLOW_SPARSE = True/False - - # Any custom regexes to detect and mask sensitive information, to be used - # to detect and mask any custom commands you may develop. - # Takes the form of a list of dictionaries, one k:v pair per dictionary - # where the key name is the canonical name of a command which gets displayed - # at the tail end of the message so you can tell which regex masked it-- - # i.e. for a log entry with a typoed `connect` command: - # `conncect johnny *********** ` - # - # The sensitive data itself must be captured in a named group with a - # label of 'secret'. - AUDIT_MASKS = [ - {'authentication': r"^@auth\s+(?P[\w]+)"}, - ] - + See README.md for installation/configuration instructions. """ def audit(self, **kwargs): """ From e99330a44505910cb3b7eeb87eb2612a7e3db266 Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 25 Sep 2018 21:37:34 +0000 Subject: [PATCH 435/466] Adds additional CmdNewPassword() checks and tests. --- evennia/contrib/auditing/server.py | 2 ++ evennia/contrib/auditing/tests.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/evennia/contrib/auditing/server.py b/evennia/contrib/auditing/server.py index 923d873f0c..38c97b598e 100644 --- a/evennia/contrib/auditing/server.py +++ b/evennia/contrib/auditing/server.py @@ -25,6 +25,8 @@ AUDIT_MASKS = [ {'create': r"^[^@]?[create]{5,6}\s+(\w+|\".+?\")\s+(?P[\w]+)"}, {'create': r"^[^@]?[create]{5,6}\s+(?P[\w]+)"}, {'userpassword': r"^[@\s]*[userpassword]{11,14}\s+(\w+|\".+?\")\s+=*\s*(?P[\w]+)"}, + {'userpassword': r"^.*new password set to '(?P[^']+)'\."}, + {'userpassword': r"^.* has changed your password to '(?P[^']+)'\."}, {'password': r"^[@\s]*[password]{6,9}\s+(?P.*)"}, ] + getattr(ev_settings, 'AUDIT_MASKS', []) diff --git a/evennia/contrib/auditing/tests.py b/evennia/contrib/auditing/tests.py index 434b4feb87..8d3611a202 100644 --- a/evennia/contrib/auditing/tests.py +++ b/evennia/contrib/auditing/tests.py @@ -44,6 +44,8 @@ class AuditingTest(EvenniaTest): self.assertEqual(self.session.mask(cmd), cmd) unsafe_cmds = ( + ("something - new password set to 'asdfghjk'.", "something - new password set to '********'."), + ("someone has changed your password to 'something'.", "someone has changed your password to '*********'."), ('connect johnny password123', 'connect johnny ***********'), ('concnct johnny password123', 'concnct johnny ***********'), ('concnct johnnypassword123', 'concnct *****************'), From be5f289a8ac2efbe0375135b1edfb55998f0ed87 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 27 Sep 2018 21:20:54 +0200 Subject: [PATCH 436/466] Resolve all django deprecation warnings --- evennia/accounts/tests.py | 27 +++++++++++++--------- evennia/scripts/migrations/0001_initial.py | 4 ++-- evennia/scripts/models.py | 6 +++-- evennia/utils/idmapper/manager.py | 4 ---- evennia/utils/idmapper/tests.py | 8 +++---- evennia/web/urls.py | 2 +- evennia/web/webclient/urls.py | 1 + evennia/web/website/urls.py | 7 +----- 8 files changed, 29 insertions(+), 30 deletions(-) diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index 1eabd1e542..2855dd0ca2 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -51,33 +51,38 @@ class TestAccountSessionHandler(TestCase): "Check count method" self.assertEqual(self.handler.count(), len(self.handler.get())) + class TestDefaultAccount(TestCase): "Check DefaultAccount class" def setUp(self): self.s1 = Session() + self.s1.puppet = None self.s1.sessid = 0 - + def test_password_validation(self): "Check password validators deny bad passwords" - - self.account = create.create_account("TestAccount%s" % randint(0, 9), email="test@test.com", password="testpassword", typeclass=DefaultAccount) + + self.account = create.create_account("TestAccount%s" % randint(0, 9), + email="test@test.com", password="testpassword", typeclass=DefaultAccount) for bad in ('', '123', 'password', 'TestAccount', '#', 'xyzzy'): self.assertFalse(self.account.validate_password(bad, account=self.account)[0]) - + "Check validators allow sufficiently complex passwords" for better in ('Mxyzptlk', "j0hn, i'M 0n1y d4nc1nG"): self.assertTrue(self.account.validate_password(better, account=self.account)[0]) - + self.account.delete() + def test_password_change(self): "Check password setting and validation is working as expected" - self.account = create.create_account("TestAccount%s" % randint(0, 9), email="test@test.com", password="testpassword", typeclass=DefaultAccount) - + self.account = create.create_account("TestAccount%s" % randint(0, 9), + email="test@test.com", password="testpassword", typeclass=DefaultAccount) + from django.core.exceptions import ValidationError # Try setting some bad passwords for bad in ('', '#', 'TestAccount', 'password'): self.assertRaises(ValidationError, self.account.set_password, bad) - + # Try setting a better password (test for False; returns None on success) self.assertFalse(self.account.set_password('Mxyzptlk')) @@ -88,7 +93,7 @@ class TestDefaultAccount(TestCase): DefaultAccount().puppet_object(self.s1, None) self.fail("Expected error: 'Object not found'") except RuntimeError as re: - self.assertEqual("Object not found", re.message) + self.assertEqual("Object not found", str(re)) def test_puppet_object_no_session(self): "Check puppet_object method called with no session param" @@ -97,7 +102,7 @@ class TestDefaultAccount(TestCase): DefaultAccount().puppet_object(None, Mock()) self.fail("Expected error: 'Session not found'") except RuntimeError as re: - self.assertEqual("Session not found", re.message) + self.assertEqual("Session not found", str(re)) def test_puppet_object_already_puppeting(self): "Check puppet_object method called, already puppeting this" @@ -180,4 +185,4 @@ class TestDefaultAccount(TestCase): account.puppet_object(self.s1, obj) self.assertTrue(self.s1.data_out.call_args[1]['text'].endswith("is already puppeted by another Account.")) - self.assertIsNone(obj.at_post_puppet.call_args) \ No newline at end of file + self.assertIsNone(obj.at_post_puppet.call_args) diff --git a/evennia/scripts/migrations/0001_initial.py b/evennia/scripts/migrations/0001_initial.py index 751bd2928e..363d0b28bc 100644 --- a/evennia/scripts/migrations/0001_initial.py +++ b/evennia/scripts/migrations/0001_initial.py @@ -29,8 +29,8 @@ class Migration(migrations.Migration): ('db_persistent', models.BooleanField(default=False, verbose_name=b'survive server reboot')), ('db_is_active', models.BooleanField(default=False, verbose_name=b'script active')), ('db_attributes', models.ManyToManyField(help_text=b'attributes on this object. An attribute can hold any pickle-able python object (see docs for special cases).', to='typeclasses.Attribute', null=True)), - ('db_obj', models.ForeignKey(blank=True, to='objects.ObjectDB', help_text=b'the object to store this script on, if not a global script.', null=True, verbose_name=b'scripted object')), - ('db_account', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, help_text=b'the account to store this script on (should not be set if obj is set)', null=True, verbose_name=b'scripted account')), + ('db_obj', models.ForeignKey(blank=True, to='objects.ObjectDB', on_delete=models.CASCADE, help_text=b'the object to store this script on, if not a global script.', null=True, verbose_name=b'scripted object')), + ('db_account', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE, help_text=b'the account to store this script on (should not be set if obj is set)', null=True, verbose_name=b'scripted account')), ('db_tags', models.ManyToManyField(help_text=b'tags on this object. Tags are simple string markers to identify, group and alias objects.', to='typeclasses.Tag', null=True)), ], options={ diff --git a/evennia/scripts/models.py b/evennia/scripts/models.py index d470349964..bb40b9382e 100644 --- a/evennia/scripts/models.py +++ b/evennia/scripts/models.py @@ -83,9 +83,11 @@ class ScriptDB(TypedObject): # optional description. db_desc = models.CharField('desc', max_length=255, blank=True) # A reference to the database object affected by this Script, if any. - db_obj = models.ForeignKey("objects.ObjectDB", null=True, blank=True, verbose_name='scripted object', + db_obj = models.ForeignKey("objects.ObjectDB", null=True, blank=True, on_delete=models.CASCADE, + verbose_name='scripted object', help_text='the object to store this script on, if not a global script.') - db_account = models.ForeignKey("accounts.AccountDB", null=True, blank=True, verbose_name="scripted account", + db_account = models.ForeignKey("accounts.AccountDB", null=True, blank=True, + on_delete=models.CASCADE, verbose_name="scripted account", help_text='the account to store this script on (should not be set if db_obj is set)') # how often to run Script (secs). -1 means there is no timer diff --git a/evennia/utils/idmapper/manager.py b/evennia/utils/idmapper/manager.py index 9053149e8a..9ff096f454 100644 --- a/evennia/utils/idmapper/manager.py +++ b/evennia/utils/idmapper/manager.py @@ -5,10 +5,6 @@ from django.db.models.manager import Manager class SharedMemoryManager(Manager): - # CL: this ensures our manager is used when accessing instances via - # ForeignKey etc. (see docs) - use_for_related_fields = True - # TODO: improve on this implementation # We need a way to handle reverse lookups so that this model can # still use the singleton cache, but the active model isn't required diff --git a/evennia/utils/idmapper/tests.py b/evennia/utils/idmapper/tests.py index 4647b4a824..3ccd6ea74d 100644 --- a/evennia/utils/idmapper/tests.py +++ b/evennia/utils/idmapper/tests.py @@ -17,14 +17,14 @@ class RegularCategory(models.Model): class Article(SharedMemoryModel): name = models.CharField(max_length=32) - category = models.ForeignKey(Category) - category2 = models.ForeignKey(RegularCategory) + category = models.ForeignKey(Category, on_delete=models.CASCADE) + category2 = models.ForeignKey(RegularCategory, on_delete=models.CASCADE) class RegularArticle(models.Model): name = models.CharField(max_length=32) - category = models.ForeignKey(Category) - category2 = models.ForeignKey(RegularCategory) + category = models.ForeignKey(Category, on_delete=models.CASCADE) + category2 = models.ForeignKey(RegularCategory, on_delete=models.CASCADE) class SharedMemorysTest(TestCase): diff --git a/evennia/web/urls.py b/evennia/web/urls.py index 332c969031..87d0e2cd15 100644 --- a/evennia/web/urls.py +++ b/evennia/web/urls.py @@ -17,7 +17,7 @@ urlpatterns = [ url(r'^', include('evennia.web.website.urls')), # , namespace='website', app_name='website')), # webclient - url(r'^webclient/', include('evennia.web.webclient.urls', namespace='webclient', app_name='webclient')), + url(r'^webclient/', include('evennia.web.webclient.urls', namespace='webclient')), # favicon url(r'^favicon\.ico$', RedirectView.as_view(url='/media/images/favicon.ico', permanent=False)) diff --git a/evennia/web/webclient/urls.py b/evennia/web/webclient/urls.py index cf2f822140..3c278ee181 100644 --- a/evennia/web/webclient/urls.py +++ b/evennia/web/webclient/urls.py @@ -5,5 +5,6 @@ webpage 'application'. from django.conf.urls import * from evennia.web.webclient import views as webclient_views +app_name = "webclient" urlpatterns = [ url(r'^$', webclient_views.webclient, name="index")] diff --git a/evennia/web/website/urls.py b/evennia/web/website/urls.py index cb425656a4..f906b6b142 100644 --- a/evennia/web/website/urls.py +++ b/evennia/web/website/urls.py @@ -8,11 +8,6 @@ from django.conf.urls import url, include from django import views as django_views from evennia.web.website import views as website_views -# loop over all settings.INSTALLED_APPS and execute code in -# files named admin.py in each such app (this will add those -# models to the admin site) -admin.autodiscover() - urlpatterns = [ url(r'^$', website_views.page_index, name="index"), url(r'^tbi/', website_views.to_be_implemented, name='to_be_implemented'), @@ -34,7 +29,7 @@ if settings.EVENNIA_ADMIN: url('^admin/$', website_views.evennia_admin, name="evennia_admin"), # Makes sure that other admin pages get loaded. - url(r'^admin/', include(admin.site.urls))] + url(r'^admin/', admin.site.urls)] else: # Just include the normal Django admin. urlpatterns += [url(r'^admin/', include(admin.site.urls))] From c0bab475d697c6d63e10b307a0ca080654a0f33a Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 29 Sep 2018 11:11:41 +0200 Subject: [PATCH 437/466] Remove MAX_NR_CHARACTERS=1 enforcement for MULTISESSION_MODEs 0 and 1. --- CHANGELOG.md | 5 +++- evennia/accounts/accounts.py | 38 ++++++++++++++--------------- evennia/commands/default/account.py | 6 ++--- evennia/settings_default.py | 4 +-- 4 files changed, 27 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dda27e14c..1463f67537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - The `evennia istart` option will start/switch the Server in foreground (interactive) mode, where it logs to terminal and can be stopped with Ctrl-C. Using `evennia reload`, or reloading in-game, will return Server to normal daemon operation. +- For validating passwords, use safe Django password-validation backend instead of custom Evennia one. ### Prototype changes @@ -80,11 +81,13 @@ ### General -- Up requirements to Django 1.11.x, Twited 18 and pillow 5.2.0 +- Up requirements to Django 1.11.x, Twisted 18 and pillow 5.2.0 - Start structuring the `CHANGELOG` to list features in more detail. - Docker image `evennia/evennia:develop` is now auto-built, tracking the develop branch. - Inflection and grouping of multiple objects in default room (an box, three boxes) - `evennia.set_trace()` is now a shortcut for launching pdb/pudb on a line in the Evennia event loop. +- Removed the enforcing of `MAX_NR_CHARACTERS=1` for `MULTISESSION_MODE` `0` and `1` by default. +- Add `evennia.utils.logger.log_sec` for logging security-related messages (marked SS in log). ### Contribs diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index ba2616f003..2c33e5c1f8 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -362,63 +362,63 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): @classmethod def validate_password(cls, password, account=None): """ - Checks the given password against the list of Django validators enabled + Checks the given password against the list of Django validators enabled in the server.conf file. - + Args: password (str): Password to validate - + Kwargs: account (DefaultAccount, optional): Account object to validate the password for. Optional, but Django includes some validators to - do things like making sure users aren't setting passwords to the + do things like making sure users aren't setting passwords to the same value as their username. If left blank, these user-specific checks are skipped. - + Returns: valid (bool): Whether or not the password passed validation error (ValidationError, None): Any validation error(s) raised. Multiple errors can be nested within a single object. - + """ valid = False error = None - + # Validation returns None on success; invert it and return a more sensible bool - try: + try: valid = not password_validation.validate_password(password, user=account) except ValidationError as e: error = e - + return valid, error - + def set_password(self, password, force=False): """ Applies the given password to the account if it passes validation checks. Can be overridden by using the 'force' flag. - + Args: password (str): Password to set - + Kwargs: force (bool): Sets password without running validation checks. - + Raises: ValidationError - + Returns: None (None): Does not return a value. - + """ if not force: # Run validation checks valid, error = self.validate_password(password, account=self) if error: raise error - + super(DefaultAccount, self).set_password(password) logger.log_info("Password succesfully changed for %s." % self) self.at_password_change() - + def delete(self, *args, **kwargs): """ Deletes the account permanently. @@ -775,7 +775,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): """ pass - + def at_password_change(self, **kwargs): """ Called after a successful password set/modify. @@ -1022,7 +1022,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): result.append("\n\n |whelp|n - more commands") result.append("\n |wooc |n - talk on public channel") - charmax = _MAX_NR_CHARACTERS if _MULTISESSION_MODE > 1 else 1 + charmax = _MAX_NR_CHARACTERS if is_su or len(characters) < charmax: if not characters: diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index d81ef8a8f0..b50f55a8e0 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -136,7 +136,7 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS): key = self.lhs desc = self.rhs - charmax = _MAX_NR_CHARACTERS if _MULTISESSION_MODE > 1 else 1 + charmax = _MAX_NR_CHARACTERS if not account.is_superuser and \ (account.db._playable_characters and @@ -627,10 +627,10 @@ class CmdPassword(COMMAND_DEFAULT_CLASS): return oldpass = self.lhslist[0] # Both of these are newpass = self.rhslist[0] # already stripped by parse() - + # Validate password validated, error = account.validate_password(newpass) - + if not account.check_password(oldpass): self.msg("The specified old password isn't correct.") elif not validated: diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 134f37578b..9efbb6314b 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -552,9 +552,7 @@ PROTOTYPEFUNC_MODULES = ["evennia.utils.prototypefuncs", # 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 -# MULTISESSION_MODE 0 and 1. +# The maximum number of characters allowed by the default ooc char-creation command MAX_NR_CHARACTERS = 1 # The access hierarchy, in climbing order. A higher permission in the # hierarchy includes access of all levels below it. Used by the perm()/pperm() From a8eecce9899a57144c4fa461e2fc3e172e36d456 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 29 Sep 2018 15:13:06 +0200 Subject: [PATCH 438/466] Some default cleanup of contrib, pep8 adjustments --- evennia/contrib/auditing/README.md | 69 +++++++++--------- evennia/contrib/auditing/outputs.py | 30 ++++---- evennia/contrib/auditing/server.py | 104 +++++++++++++++------------- evennia/contrib/auditing/tests.py | 18 ++--- 4 files changed, 118 insertions(+), 103 deletions(-) diff --git a/evennia/contrib/auditing/README.md b/evennia/contrib/auditing/README.md index ce1eff800b..687fc6edcc 100644 --- a/evennia/contrib/auditing/README.md +++ b/evennia/contrib/auditing/README.md @@ -2,12 +2,12 @@ Contrib - Johnny 2017 -This is a tap that optionally intercepts all data sent to/from clients and the +This is a tap that optionally intercepts all data sent to/from clients and the server and passes it to a callback of your choosing. -It is intended for quality assurance, post-incident investigations and debugging -but obviously can be abused. All data is recorded in cleartext. Please -be ethical, and if you are unwilling to properly deal with the implications of +It is intended for quality assurance, post-incident investigations and debugging +but obviously can be abused. All data is recorded in cleartext. Please +be ethical, and if you are unwilling to properly deal with the implications of recording user passwords or private communications, please do not enable this module. @@ -17,51 +17,56 @@ Some checks have been implemented to protect the privacy of users. Files included in this module: outputs.py - Example callback methods. This module ships with examples of - callbacks that send data as JSON to a file in your game/server/logs - dir or to your native Linux syslog daemon. You can of course write + callbacks that send data as JSON to a file in your game/server/logs + dir or to your native Linux syslog daemon. You can of course write your own to do other things like post them to Kafka topics. - + server.py - Extends the Evennia ServerSession object to pipe data to the callback upon receipt. - + tests.py - Unit tests that check to make sure commands with sensitive arguments are having their PII scrubbed. - + Installation/Configuration: -Deployment is completed by configuring a few settings in server.conf. In short, -you must tell Evennia to use this ServerSession instead of its own, specify -which direction(s) you wish to record and where you want the data sent. +Deployment is completed by configuring a few settings in server.conf. This line +is required: SERVER_SESSION_CLASS = 'evennia.contrib.auditing.server.AuditedServerSession' - + +This tells Evennia to use this ServerSession instead of its own. Below are the +other possible options along with the default value that will be used if unset. + # Where to send logs? Define the path to a module containing your callback - # function. It should take a single dict argument as input. + # function. It should take a single dict argument as input AUDIT_CALLBACK = 'evennia.contrib.auditing.outputs.to_file' - - # Log user input? Be ethical about this; it will log all private and - # public communications between players and/or admins. - AUDIT_IN = True/False - + + # Log user input? Be ethical about this; it will log all private and + # public communications between players and/or admins (default: False). + AUDIT_IN = False + # Log server output? This will result in logging of ALL system # messages and ALL broadcasts to connected players, so on a busy game any # broadcast to all users will yield a single event for every connected user! - AUDIT_OUT = True/False - + AUDIT_OUT = False + # The default output is a dict. Do you want to allow key:value pairs with - # null/blank values? If you're just writing to disk, disabling this saves - # some disk space, but whether you *want* sparse values or not is more of a + # null/blank values? If you're just writing to disk, disabling this saves + # some disk space, but whether you *want* sparse values or not is more of a # consideration if you're shipping logs to a NoSQL/schemaless database. - AUDIT_ALLOW_SPARSE = True/False - - # If you write custom commands that handle sensitive data like passwords, + # (default: False) + AUDIT_ALLOW_SPARSE = False + + # If you write custom commands that handle sensitive data like passwords, # you must write a regular expression to remove that before writing to log. - # AUDIT_MASKS is a list of dictionaries that define the names of commands + # AUDIT_MASKS is a list of dictionaries that define the names of commands # and the regexes needed to scrub them. + # The system already has defaults to filter out sensitive login/creation + # commands in the default command set. Your list of AUDIT_MASKS will be appended + # to those defaults. # - # The sensitive data itself must be captured in a named group with a - # label of 'secret'. - AUDIT_MASKS = [ - {'authentication': r"^@auth\s+(?P[\w]+)"}, - ] \ No newline at end of file + # In the regex, the sensitive data itself must be captured in a named group with a + # label of 'secret' (see the Python docs on the `re` module for more info). For + # example: `{'authentication': r"^@auth\s+(?P[\w]+)"}` + AUDIT_MASKS = [] diff --git a/evennia/contrib/auditing/outputs.py b/evennia/contrib/auditing/outputs.py index ec5e84200f..b3f9d72c23 100644 --- a/evennia/contrib/auditing/outputs.py +++ b/evennia/contrib/auditing/outputs.py @@ -4,10 +4,10 @@ Example methods demonstrating output destinations for logs generated by audited server sessions. This is designed to be a single source of events for developers to customize -and add any additional enhancements before events are written out-- i.e. if you +and add any additional enhancements before events are written out-- i.e. if you want to keep a running list of what IPs a user logs in from on account/character -objects, or if you want to perform geoip or ASN lookups on IPs before committing, -or tag certain events with the results of a reputational lookup, this should be +objects, or if you want to perform geoip or ASN lookups on IPs before committing, +or tag certain events with the results of a reputational lookup, this should be the easiest place to do it. Write a method and invoke it via `settings.AUDIT_CALLBACK` to have log data objects passed to it. @@ -17,12 +17,13 @@ from evennia.utils.logger import log_file import json import syslog + def to_file(data): """ - Writes dictionaries of data generated by an AuditedServerSession to files + Writes dictionaries of data generated by an AuditedServerSession to files in JSON format, bucketed by date. - - Uses Evennia's native logger and writes to the default + + Uses Evennia's native logger and writes to the default log directory (~/yourgame/server/logs/ or settings.LOG_DIR) Args: @@ -31,28 +32,29 @@ def to_file(data): """ # Bucket logs by day and remove objects before serialization bucket = data.pop('objects')['time'].strftime('%Y-%m-%d') - + # Write it log_file(json.dumps(data), filename="audit_%s.log" % bucket) - + + def to_syslog(data): """ Writes dictionaries of data generated by an AuditedServerSession to syslog. - + Takes advantage of your system's native logger and writes to wherever - you have it configured, which is independent of Evennia. + you have it configured, which is independent of Evennia. Linux systems tend to write to /var/log/syslog. - + If you're running rsyslog, you can configure it to dump and/or forward logs to disk and/or an external data warehouse (recommended-- if your server is compromised or taken down, losing your logs along with it is no help!). - + Args: data (dict): Parsed session transmission data. """ # Remove objects before serialization data.pop('objects') - + # Write it out - syslog.syslog(json.dumps(data)) \ No newline at end of file + syslog.syslog(json.dumps(data)) diff --git a/evennia/contrib/auditing/server.py b/evennia/contrib/auditing/server.py index 38c97b598e..5c3c2c9f6d 100644 --- a/evennia/contrib/auditing/server.py +++ b/evennia/contrib/auditing/server.py @@ -1,6 +1,6 @@ """ Auditable Server Sessions: -Extension of the stock ServerSession that yields objects representing +Extension of the stock ServerSession that yields objects representing user inputs and system outputs. Evennia contribution - Johnny 2017 @@ -15,7 +15,8 @@ from evennia.utils import utils, logger, mod_import, get_evennia_version from evennia.server.serversession import ServerSession # Attributes governing auditing of commands and where to send log objects -AUDIT_CALLBACK = getattr(ev_settings, 'AUDIT_CALLBACK', None) +AUDIT_CALLBACK = getattr(ev_settings, 'AUDIT_CALLBACK', + 'evennia.contrib.auditing.outputs.to_file') AUDIT_IN = getattr(ev_settings, 'AUDIT_IN', False) AUDIT_OUT = getattr(ev_settings, 'AUDIT_OUT', False) AUDIT_ALLOW_SPARSE = getattr(ev_settings, 'AUDIT_ALLOW_SPARSE', False) @@ -30,42 +31,44 @@ AUDIT_MASKS = [ {'password': r"^[@\s]*[password]{6,9}\s+(?P.*)"}, ] + getattr(ev_settings, 'AUDIT_MASKS', []) + if AUDIT_CALLBACK: try: - AUDIT_CALLBACK = getattr(mod_import('.'.join(AUDIT_CALLBACK.split('.')[:-1])), AUDIT_CALLBACK.split('.')[-1]) - logger.log_info("Auditing module online.") - logger.log_info("Recording user input: %s" % AUDIT_IN) - logger.log_info("Recording server output: %s" % AUDIT_OUT) - logger.log_info("Recording sparse values: %s" % AUDIT_ALLOW_SPARSE) - logger.log_info("Log callback destination: %s" % AUDIT_CALLBACK) + AUDIT_CALLBACK = getattr( + mod_import('.'.join(AUDIT_CALLBACK.split('.')[:-1])), AUDIT_CALLBACK.split('.')[-1]) + logger.log_sec("Auditing module online.") + logger.log_sec("Audit record User input: {}, output: {}.\n" + "Audit sparse recording: {}, Log callback: {}".format( + AUDIT_IN, AUDIT_OUT, AUDIT_ALLOW_SPARSE, AUDIT_CALLBACK)) except Exception as e: logger.log_err("Failed to activate Auditing module. %s" % e) + class AuditedServerSession(ServerSession): """ - This particular implementation parses all server inputs and/or outputs and - passes a dict containing the parsed metadata to a callback method of your - creation. This is useful for recording player activity where necessary for + This particular implementation parses all server inputs and/or outputs and + passes a dict containing the parsed metadata to a callback method of your + creation. This is useful for recording player activity where necessary for security auditing, usage analysis or post-incident forensic discovery. - + *** WARNING *** All strings are recorded and stored in plaintext. This includes those strings which might contain sensitive data (create, connect, @password). These commands have their arguments masked by default, but you must mask or mask any custom commands of your own that handle sensitive information. - + See README.md for installation/configuration instructions. """ def audit(self, **kwargs): """ - Extracts messages and system data from a Session object upon message + Extracts messages and system data from a Session object upon message send or receive. - + Kwargs: src (str): Source of data; 'client' or 'server'. Indicates direction. text (str or list): Client sends messages to server in the form of lists. Server sends messages to client as string. - + Returns: log (dict): Dictionary object containing parsed system and user data related to this message. @@ -74,54 +77,56 @@ class AuditedServerSession(ServerSession): # Get time at start of processing time_obj = timezone.now() time_str = str(time_obj) - + session = self src = kwargs.pop('src', '?') bytecount = 0 - + # Do not log empty lines - if not kwargs: return {} + if not kwargs: + return {} # Get current session's IP address client_ip = session.address - + # Capture Account name and dbref together account = session.get_account() account_token = '' - if account: + if account: account_token = '%s%s' % (account.key, account.dbref) - + # Capture Character name and dbref together char = session.get_puppet() char_token = '' if char: char_token = '%s%s' % (char.key, char.dbref) - + # Capture Room name and dbref together room = None room_token = '' if char: room = char.location room_token = '%s%s' % (room.key, room.dbref) - + # Try to compile an input/output string def drill(obj, bucket): - if isinstance(obj, dict): return bucket + if isinstance(obj, dict): + return bucket elif utils.is_iter(obj): for sub_obj in obj: bucket.extend(drill(sub_obj, [])) else: bucket.append(obj) return bucket - + text = kwargs.pop('text', '') if utils.is_iter(text): text = '|'.join(drill(text, [])) - + # Mask any PII in message, where possible bytecount = len(text.encode('utf-8')) text = self.mask(text) - + # Compile the IP, Account, Character, Room, and the message. log = { 'time': time_str, @@ -147,23 +152,23 @@ class AuditedServerSession(ServerSession): 'room': room, } } - + # Remove any keys with blank values - if AUDIT_ALLOW_SPARSE == False: - log['data'] = {k:v for k,v in log['data'].iteritems() if v} - log['objects'] = {k:v for k,v in log['objects'].iteritems() if v} - log = {k:v for k,v in log.iteritems() if v} + if AUDIT_ALLOW_SPARSE is False: + log['data'] = {k: v for k, v in log['data'].iteritems() if v} + log['objects'] = {k: v for k, v in log['objects'].iteritems() if v} + log = {k: v for k, v in log.iteritems() if v} return log - + def mask(self, msg): """ Masks potentially sensitive user information within messages before writing to log. Recording cleartext password attempts is bad policy. - + Args: msg (str): Raw text string sent from client <-> server - + Returns: msg (str): Text string with sensitive information masked out. @@ -176,7 +181,7 @@ class AuditedServerSession(ServerSession): msg = match.group(1).replace('\\', '') submsg = msg is_embedded = True - + for mask in AUDIT_MASKS: for command, regex in mask.iteritems(): try: @@ -185,19 +190,20 @@ class AuditedServerSession(ServerSession): logger.log_err(regex) logger.log_err(e) continue - + if match: term = match.group('secret') masked = re.sub(term, '*' * len(term.zfill(8)), msg) - + if is_embedded: msg = re.sub(submsg, '%s ' % (masked, command), _msg, flags=re.IGNORECASE) - else: msg = masked - + else: + msg = masked + return msg - + return _msg - + def data_out(self, **kwargs): """ Generic hook for sending data out through the protocol. @@ -209,12 +215,13 @@ class AuditedServerSession(ServerSession): if AUDIT_CALLBACK and AUDIT_OUT: try: log = self.audit(src='server', **kwargs) - if log: AUDIT_CALLBACK(log) + if log: + AUDIT_CALLBACK(log) except Exception as e: logger.log_err(e) - + super(AuditedServerSession, self).data_out(**kwargs) - + def data_in(self, **kwargs): """ Hook for protocols to send incoming data to the engine. @@ -226,8 +233,9 @@ class AuditedServerSession(ServerSession): if AUDIT_CALLBACK and AUDIT_IN: try: log = self.audit(src='client', **kwargs) - if log: AUDIT_CALLBACK(log) + if log: + AUDIT_CALLBACK(log) except Exception as e: logger.log_err(e) - + super(AuditedServerSession, self).data_in(**kwargs) diff --git a/evennia/contrib/auditing/tests.py b/evennia/contrib/auditing/tests.py index 8d3611a202..7b4dd4208d 100644 --- a/evennia/contrib/auditing/tests.py +++ b/evennia/contrib/auditing/tests.py @@ -3,7 +3,6 @@ Module containing the test cases for the Audit system. """ from django.conf import settings -from evennia.contrib.auditing.server import AuditedServerSession from evennia.utils.test_resources import EvenniaTest import re @@ -16,11 +15,12 @@ settings.AUDIT_ALLOW_SPARSE = True # Configure settings to use custom session settings.SERVER_SESSION_CLASS = "evennia.contrib.auditing.server.AuditedServerSession" + class AuditingTest(EvenniaTest): def test_mask(self): """ - Make sure the 'mask' function is properly masking potentially sensitive + Make sure the 'mask' function is properly masking potentially sensitive information from strings. """ safe_cmds = ( @@ -39,10 +39,10 @@ class AuditingTest(EvenniaTest): '@create johnny password123', '{"text": "Command \'do stuff\' is not available. Type \"help\" for help."}' ) - + for cmd in safe_cmds: self.assertEqual(self.session.mask(cmd), cmd) - + unsafe_cmds = ( ("something - new password set to 'asdfghjk'.", "something - new password set to '********'."), ("someone has changed your password to 'something'.", "someone has changed your password to '*********'."), @@ -60,10 +60,10 @@ class AuditingTest(EvenniaTest): ("Command 'conncect teddy teddy' is not available. Maybe you meant \"@encode\"?", 'Command \'conncect ******** ********\' is not available. Maybe you meant "@encode"?'), ("{'text': u'Command \\'conncect jsis dfiidf\\' is not available. Type \"help\" for help.'}", "{'text': u'Command \\'conncect jsis ********\\' is not available. Type \"help\" for help.'}") ) - + for index, (unsafe, safe) in enumerate(unsafe_cmds): self.assertEqual(re.sub(' ', '', self.session.mask(unsafe)).strip(), safe) - + # Make sure scrubbing is not being abused to evade monitoring secrets = [ 'say password password password; ive got a secret that i cant explain', @@ -73,7 +73,7 @@ class AuditingTest(EvenniaTest): ] for secret in secrets: self.assertEqual(self.session.mask(secret), secret) - + def test_audit(self): """ Make sure the 'audit' function is returning a dictionary based on values @@ -87,9 +87,9 @@ class AuditingTest(EvenniaTest): 'application': 'Evennia', 'text': 'hello' }) - + # Make sure OOB data is being recorded log = self.session.audit(src='client', text="connect johnny password123", prompt="hp=20|st=10|ma=15", pane=2) self.assertEqual(log['text'], 'connect johnny ***********') self.assertEqual(log['data']['prompt'], 'hp=20|st=10|ma=15') - self.assertEqual(log['data']['pane'], 2) \ No newline at end of file + self.assertEqual(log['data']['pane'], 2) From 27910b990475a6ab48fa027721df613f9affe8cc Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 29 Sep 2018 17:47:56 +0200 Subject: [PATCH 439/466] Move audit contrib into `security` subfolder --- evennia/contrib/README.md | 1 + evennia/contrib/security/README.md | 5 +++++ evennia/contrib/{ => security}/auditing/README.md | 4 ++-- evennia/contrib/{ => security}/auditing/__init__.py | 0 evennia/contrib/{ => security}/auditing/outputs.py | 0 evennia/contrib/{ => security}/auditing/server.py | 2 +- evennia/contrib/{ => security}/auditing/tests.py | 4 ++-- 7 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 evennia/contrib/security/README.md rename evennia/contrib/{ => security}/auditing/README.md (94%) rename evennia/contrib/{ => security}/auditing/__init__.py (100%) rename evennia/contrib/{ => security}/auditing/outputs.py (100%) rename evennia/contrib/{ => security}/auditing/server.py (99%) rename evennia/contrib/{ => security}/auditing/tests.py (96%) diff --git a/evennia/contrib/README.md b/evennia/contrib/README.md index c9eaad71f1..fad1802237 100644 --- a/evennia/contrib/README.md +++ b/evennia/contrib/README.md @@ -51,6 +51,7 @@ things you want from here into your game folder and change them there. speaking unfamiliar languages. Also obfuscates whispers. * RPSystem (Griatch 2015) - Full director-style emoting system replacing names with sdescs/recogs. Supports wearing masks. +* Security/Auditing (Johhny 2018) - Log server input/output for debug/security. * Simple Door - Example of an exit that can be opened and closed. * Slow exit (Griatch 2014) - Custom Exit class that takes different time to pass depending on if you are walking/running etc. diff --git a/evennia/contrib/security/README.md b/evennia/contrib/security/README.md new file mode 100644 index 0000000000..87908dd3c8 --- /dev/null +++ b/evennia/contrib/security/README.md @@ -0,0 +1,5 @@ +# Security + +This directory contains security-related contribs + +- Auditing (Johnny 2018) - Allow for optional security logging of user input/output. diff --git a/evennia/contrib/auditing/README.md b/evennia/contrib/security/auditing/README.md similarity index 94% rename from evennia/contrib/auditing/README.md rename to evennia/contrib/security/auditing/README.md index 687fc6edcc..ab669f30c9 100644 --- a/evennia/contrib/auditing/README.md +++ b/evennia/contrib/security/auditing/README.md @@ -33,14 +33,14 @@ Installation/Configuration: Deployment is completed by configuring a few settings in server.conf. This line is required: - SERVER_SESSION_CLASS = 'evennia.contrib.auditing.server.AuditedServerSession' + SERVER_SESSION_CLASS = 'evennia.contrib.security.auditing.server.AuditedServerSession' This tells Evennia to use this ServerSession instead of its own. Below are the other possible options along with the default value that will be used if unset. # Where to send logs? Define the path to a module containing your callback # function. It should take a single dict argument as input - AUDIT_CALLBACK = 'evennia.contrib.auditing.outputs.to_file' + AUDIT_CALLBACK = 'evennia.contrib.security.auditing.outputs.to_file' # Log user input? Be ethical about this; it will log all private and # public communications between players and/or admins (default: False). diff --git a/evennia/contrib/auditing/__init__.py b/evennia/contrib/security/auditing/__init__.py similarity index 100% rename from evennia/contrib/auditing/__init__.py rename to evennia/contrib/security/auditing/__init__.py diff --git a/evennia/contrib/auditing/outputs.py b/evennia/contrib/security/auditing/outputs.py similarity index 100% rename from evennia/contrib/auditing/outputs.py rename to evennia/contrib/security/auditing/outputs.py diff --git a/evennia/contrib/auditing/server.py b/evennia/contrib/security/auditing/server.py similarity index 99% rename from evennia/contrib/auditing/server.py rename to evennia/contrib/security/auditing/server.py index 5c3c2c9f6d..07bf24ccc5 100644 --- a/evennia/contrib/auditing/server.py +++ b/evennia/contrib/security/auditing/server.py @@ -16,7 +16,7 @@ from evennia.server.serversession import ServerSession # Attributes governing auditing of commands and where to send log objects AUDIT_CALLBACK = getattr(ev_settings, 'AUDIT_CALLBACK', - 'evennia.contrib.auditing.outputs.to_file') + 'evennia.contrib.security.auditing.outputs.to_file') AUDIT_IN = getattr(ev_settings, 'AUDIT_IN', False) AUDIT_OUT = getattr(ev_settings, 'AUDIT_OUT', False) AUDIT_ALLOW_SPARSE = getattr(ev_settings, 'AUDIT_ALLOW_SPARSE', False) diff --git a/evennia/contrib/auditing/tests.py b/evennia/contrib/security/auditing/tests.py similarity index 96% rename from evennia/contrib/auditing/tests.py rename to evennia/contrib/security/auditing/tests.py index 7b4dd4208d..4a522925eb 100644 --- a/evennia/contrib/auditing/tests.py +++ b/evennia/contrib/security/auditing/tests.py @@ -7,13 +7,13 @@ from evennia.utils.test_resources import EvenniaTest import re # Configure session auditing settings -settings.AUDIT_CALLBACK = "evennia.contrib.auditing.outputs.to_syslog" +settings.AUDIT_CALLBACK = "evennia.security.contrib.auditing.outputs.to_syslog" settings.AUDIT_IN = True settings.AUDIT_OUT = True settings.AUDIT_ALLOW_SPARSE = True # Configure settings to use custom session -settings.SERVER_SESSION_CLASS = "evennia.contrib.auditing.server.AuditedServerSession" +settings.SERVER_SESSION_CLASS = "evennia.contrib.security.auditing.server.AuditedServerSession" class AuditingTest(EvenniaTest): From aa19b9b73f1197c30999172beaf99aadce9d03c1 Mon Sep 17 00:00:00 2001 From: Brenden Tuck Date: Mon, 27 Aug 2018 00:24:27 -0400 Subject: [PATCH 440/466] Plugin-ify the webclient --- .../static/webclient/css/webclient.css | 7 + .../static/webclient/js/plugins/default_in.js | 44 + .../webclient/js/plugins/default_out.js | 60 ++ .../webclient/js/plugins/default_unload.js | 17 + .../static/webclient/js/plugins/history.js | 116 +++ .../webclient/js/plugins/notifications.js | 88 ++ .../static/webclient/js/plugins/oob.js | 35 + .../static/webclient/js/plugins/options.js | 162 ++++ .../static/webclient/js/plugins/popups.js | 101 +++ .../webclient/js/plugins/splithandler.js | 368 +++++++++ .../static/webclient/js/splithandler.js | 145 ---- .../static/webclient/js/webclient_gui.js | 774 +++++------------- .../webclient/templates/webclient/base.html | 10 +- .../templates/webclient/webclient.html | 62 +- 14 files changed, 1230 insertions(+), 759 deletions(-) create mode 100644 evennia/web/webclient/static/webclient/js/plugins/default_in.js create mode 100644 evennia/web/webclient/static/webclient/js/plugins/default_out.js create mode 100644 evennia/web/webclient/static/webclient/js/plugins/default_unload.js create mode 100644 evennia/web/webclient/static/webclient/js/plugins/history.js create mode 100644 evennia/web/webclient/static/webclient/js/plugins/notifications.js create mode 100644 evennia/web/webclient/static/webclient/js/plugins/oob.js create mode 100644 evennia/web/webclient/static/webclient/js/plugins/options.js create mode 100644 evennia/web/webclient/static/webclient/js/plugins/popups.js create mode 100644 evennia/web/webclient/static/webclient/js/plugins/splithandler.js delete mode 100644 evennia/web/webclient/static/webclient/js/splithandler.js diff --git a/evennia/web/webclient/static/webclient/css/webclient.css b/evennia/web/webclient/static/webclient/css/webclient.css index 7a33cfa207..94ac7a0d8d 100644 --- a/evennia/web/webclient/static/webclient/css/webclient.css +++ b/evennia/web/webclient/static/webclient/css/webclient.css @@ -105,6 +105,13 @@ div {margin:0px;} } /* Input field */ +#input { + position: fixed; + bottom: 0; + left: 0; + width: 100%; +} + #inputfield, #inputsizer { height: 100%; background: #000; diff --git a/evennia/web/webclient/static/webclient/js/plugins/default_in.js b/evennia/web/webclient/static/webclient/js/plugins/default_in.js new file mode 100644 index 0000000000..28bfc9f315 --- /dev/null +++ b/evennia/web/webclient/static/webclient/js/plugins/default_in.js @@ -0,0 +1,44 @@ +/* + * + * Evennia Webclient default 'send-text-on-enter-key' IO plugin + * + */ +let defaultin_plugin = (function () { + + // + // handle the default key triggering onSend() + var onKeydown = function (event) { + if ( (event.which === 13) && (!event.shiftKey) ) { // Enter Key without shift + var inputfield = $("#inputfield"); + var outtext = inputfield.val(); + var lines = outtext.trim().replace(/[\r]+/,"\n").replace(/[\n]+/, "\n").split("\n"); + for (var i = 0; i < lines.length; i++) { + plugin_handler.onSend( lines[i].trim() ); + } + inputfield.val(''); + event.preventDefault(); + } + + return true; + } + + // + // Mandatory plugin init function + var init = function () { + // Handle pressing the send button + $("#inputsend") + .bind("click", function (event) { + var e = $.Event( "keydown" ); + e.which = 13; + $('#inputfield').trigger(e); + }); + + console.log('DefaultIn initialized'); + } + + return { + init: init, + onKeydown: onKeydown, + } +})(); +plugin_handler.add('defaultin', defaultin_plugin); diff --git a/evennia/web/webclient/static/webclient/js/plugins/default_out.js b/evennia/web/webclient/static/webclient/js/plugins/default_out.js new file mode 100644 index 0000000000..01be2f9c62 --- /dev/null +++ b/evennia/web/webclient/static/webclient/js/plugins/default_out.js @@ -0,0 +1,60 @@ +/* + * + * Evennia Webclient default outputs plugin + * + */ +let defaultout_plugin = (function () { + + // + // By default add all unclaimed onText messages to the #messagewindow
and scroll + var onText = function (args, kwargs) { + // append message to default pane, then scroll so latest is at the bottom. + var mwin = $("#messagewindow"); + var cls = kwargs == null ? 'out' : kwargs['cls']; + mwin.append("
" + args[0] + "
"); + var scrollHeight = mwin.parent().parent().prop("scrollHeight"); + mwin.parent().parent().animate({ scrollTop: scrollHeight }, 0); + + return true; + } + + // + // By default just show the prompt. + var onPrompt = function (args, kwargs) { + // show prompt + $('#prompt') + .addClass("out") + .html(args[0]); + + return true; + } + + // + // By default just show an error for the Unhandled Event. + var onUnknownCmd = function (args, kwargs) { + var mwin = $("#messagewindow"); + mwin.append( + "
" + + "Error or Unhandled event:
" + + cmdname + ", " + + JSON.stringify(args) + ", " + + JSON.stringify(kwargs) + "

"); + mwin.scrollTop(mwin[0].scrollHeight); + + return true; + } + + // + // Mandatory plugin init function + var init = function () { + console.log('DefaultOut initialized'); + } + + return { + init: init, + onText: onText, + onPrompt: onPrompt, + onUnknownCmd: onUnknownCmd, + } +})(); +plugin_handler.add('defaultout', defaultout_plugin); diff --git a/evennia/web/webclient/static/webclient/js/plugins/default_unload.js b/evennia/web/webclient/static/webclient/js/plugins/default_unload.js new file mode 100644 index 0000000000..42fa41c930 --- /dev/null +++ b/evennia/web/webclient/static/webclient/js/plugins/default_unload.js @@ -0,0 +1,17 @@ +/* + * + * Evennia Webclient default unload plugin + * + */ +let unload_plugin = (function () { + + let onBeforeUnload = function () { + return "You are about to leave the game. Please confirm."; + } + + return { + init: function () {}, + onBeforeUnload: onBeforeUnload, + } +})(); +plugin_handler.add('unload', unload_plugin); diff --git a/evennia/web/webclient/static/webclient/js/plugins/history.js b/evennia/web/webclient/static/webclient/js/plugins/history.js new file mode 100644 index 0000000000..1bef6031cd --- /dev/null +++ b/evennia/web/webclient/static/webclient/js/plugins/history.js @@ -0,0 +1,116 @@ +/* + * + * Evennia Webclient Command History plugin + * + */ +let history_plugin = (function () { + + // Manage history for input line + var history_max = 21; + var history = new Array(); + var history_pos = 0; + + history[0] = ''; // the very latest input is empty for new entry. + + // + // move back in the history + var back = function () { + // step backwards in history stack + history_pos = Math.min(++history_pos, history.length - 1); + return history[history.length - 1 - history_pos]; + } + + // + // move forward in the history + var fwd = function () { + // step forwards in history stack + history_pos = Math.max(--history_pos, 0); + return history[history.length - 1 - history_pos]; + } + + // + // add a new history line + var add = function (input) { + // add a new entry to history, don't repeat latest + if (input && input != history[history.length-2]) { + if (history.length >= history_max) { + history.shift(); // kill oldest entry + } + history[history.length-1] = input; + history[history.length] = ''; + } + // reset the position to the last history entry + history_pos = 0; + } + + // + // Go to the last history line + var end = function () { + // move to the end of the history stack + history_pos = 0; + return history[history.length -1]; + } + + // + // Add input to the scratch line + var scratch = function (input) { + // Put the input into the last history entry (which is normally empty) + // without making the array larger as with add. + // Allows for in-progress editing to be saved. + history[history.length-1] = input; + } + + // Public + + // + // Handle up arrow and down arrow events. + var onKeydown = function(event) { + var code = event.which; + var history_entry = null; + var inputfield = $("#inputfield"); + + if (inputfield[0].selectionStart == inputfield.val().length) { + // Only process up/down arrow if cursor is at the end of the line. + if (code === 38) { // Arrow up + history_entry = back(); + } + else if (code === 40) { // Arrow down + history_entry = fwd(); + } + } + + if (history_entry !== null) { + // Doing a history navigation; replace the text in the input. + inputfield.val(history_entry); + } + else { + // Save the current contents of the input to the history scratch area. + setTimeout(function () { + // Need to wait until after the key-up to capture the value. + scratch(inputfield.val()); + end(); + }, 0); + } + + return false; + } + + // + // Listen for onSend lines to add to history + var onSend = function (line) { + add(line); + } + + // + // Init function + var init = function () { + console.log('History Plugin Initialized.'); + } + + return { + init: init, + onKeydown: onKeydown, + onSend: onSend, + } +})() +plugin_handler.add('history', history_plugin); diff --git a/evennia/web/webclient/static/webclient/js/plugins/notifications.js b/evennia/web/webclient/static/webclient/js/plugins/notifications.js new file mode 100644 index 0000000000..ba68cf9081 --- /dev/null +++ b/evennia/web/webclient/static/webclient/js/plugins/notifications.js @@ -0,0 +1,88 @@ +/* + * + * Desktop Notifications Plugin + * + */ +let notifications_plugin = (function () { + // Notifications + var unread = 0; + var originalTitle = document.title; + var focused = true; + var favico; + + var onBlur = function (e) { + focused = false; + } + + // + // Notifications for unfocused window + var onFocus = function (e) { + focused = true; + document.title = originalTitle; + unread = 0; + favico.badge(0); + } + + // + // on receiving new text from the server, if we are not focused, send a notification to the desktop + var onText = function (args, kwargs) { + if(!focused) { + // Changes unfocused browser tab title to number of unread messages + unread++; + favico.badge(unread); + document.title = "(" + unread + ") " + originalTitle; + if ("Notification" in window) { + if (("notification_popup" in options) && (options["notification_popup"])) { + // 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 = { + body: text.replace(/(<([^>]+)>)/ig,""), + icon: "/static/website/images/evennia_logo.png" + } + + var n = new Notification(title, options); + n.onclick = function(e) { + e.preventDefault(); + window.focus(); + this.close(); + } + } + }); + } + if (("notification_sound" in options) && (options["notification_sound"])) { + var audio = new Audio("/static/webclient/media/notification.wav"); + audio.play(); + } + } + } + + return false; + } + + // + // required init function + var init = function () { + if ("Notification" in window) { + Notification.requestPermission(); + } + + favico = new Favico({ + animation: 'none' + }); + + $(window).blur(onBlur); + $(window).focus(onFocus); + + console.log('Notifications Plugin Initialized.'); + } + + return { + init: init, + onText: onText, + } +})() +plugin_handler.add('notifications', notifications_plugin); diff --git a/evennia/web/webclient/static/webclient/js/plugins/oob.js b/evennia/web/webclient/static/webclient/js/plugins/oob.js new file mode 100644 index 0000000000..55cedc9a3d --- /dev/null +++ b/evennia/web/webclient/static/webclient/js/plugins/oob.js @@ -0,0 +1,35 @@ +/* + * + * OOB Plugin + * enables '##send { "command", [ args ], { kwargs } }' as a way to inject OOB instructions + * + */ +let oob_plugin = (function () { + + // + // Check outgoing text for handtyped/injected JSON OOB instruction + var onSend = function (line) { + if (line.length > 7 && line.substr(0, 7) == "##send ") { + // send a specific oob instruction ["cmdname",[args],{kwargs}] + line = line.slice(7); + var cmdarr = JSON.parse(line); + var cmdname = cmdarr[0]; + var args = cmdarr[1]; + var kwargs = cmdarr[2]; + log(cmdname, args, kwargs); + return (cmdname, args, kwargs); + } + } + + // + // init function + var init = function () { + console.log('OOB Plugin Initialized.'); + } + + return { + init: init, + onSend: onSend, + } +})() +plugin_handler.add('oob', oob_plugin); diff --git a/evennia/web/webclient/static/webclient/js/plugins/options.js b/evennia/web/webclient/static/webclient/js/plugins/options.js new file mode 100644 index 0000000000..d45c0c76bc --- /dev/null +++ b/evennia/web/webclient/static/webclient/js/plugins/options.js @@ -0,0 +1,162 @@ +/* + * + * Evennia Options GUI plugin + * + * This code deals with all of the UI and events related to Options. + * + */ +let options_plugin = (function () { + // + // addOptionsUI + var addOptionsUI = function () { + var content = [ // TODO dynamically create this based on the options{} hash + '

Output display

', + '', + '
', + '', + '
', + '
', + '

Notifications

', + '', + '
', + '', + '
', + ].join("\n"); + + // Create a new options Dialog + plugins['popups'].createDialog( 'optionsdialog', 'Options', content ); + } + + // + // addHelpUI + var addHelpUI = function () { + // Create a new Help Dialog + plugins['popups'].createDialog( 'helpdialog', 'Help', "" ); + } + + // addToolbarButton + var addToolbarButton = function () { + var optionsbutton = $( [ + '', + ].join("") ); + $('#toolbar').append( optionsbutton ); + } + + // + // Opens the options dialog + var doOpenOptions = function () { + if (!Evennia.isConnected()) { + alert("You need to be connected."); + return; + } + + plugins['popups'].togglePopup("#optionsdialog"); + } + + // + // When the user changes a setting from the interface + var onOptionCheckboxChanged = function () { + var name = $(this).data("setting"); + var value = this.checked; + + var changedoptions = {}; + changedoptions[name] = value; + Evennia.msg("webclient_options", [], changedoptions); + + options[name] = value; + } + + // Public functions + + // + // onKeydown check for 'ESC' key. + var onKeydown = function (event) { + var code = event.which; + + if (code === 27) { // Escape key + if ($('#helpdialog').is(':visible')) { + plugins['popups'].closePopup("#helpdialog"); + } else { + plugins['popups'].closePopup("#optionsdialog"); + } + return true; + } + return false; + } + + // + // Called when options settings are sent from server + var onGotOptions = function (args, kwargs) { + options = kwargs; + + $.each(kwargs, function(key, value) { + var elem = $("[data-setting='" + key + "']"); + if (elem.length === 0) { + console.log("Could not find option: " + key); + console.log(args); + console.log(kwargs); + } else { + elem.prop('checked', value); + }; + }); + } + + // + // Called when the user logged in + var onLoggedIn = function (args, kwargs) { + $('#optionsbutton').removeClass('hidden'); + Evennia.msg("webclient_options", [], {}); + } + + // + // Display a "prompt" command from the server + var onPrompt = function (args, kwargs) { + // also display the prompt in the output window if gagging is disabled + if (("gagprompt" in options) && (!options["gagprompt"])) { + plugin_handler.onText(args, kwargs); + } + + // don't claim this Prompt as completed. + return false; + } + + // + // Make sure to close any dialogs on connection lost + var onConnectionClose = function () { + $('#optionsbutton').addClass('hidden'); + plugins['popups'].closePopup("#optionsdialog"); + plugins['popups'].closePopup("#helpdialog"); + } + + // + // Register and init plugin + var init = function () { + // Add GUI components + addOptionsUI(); + addHelpUI(); + + // Add Options toolbar button. + addToolbarButton(); + + // Pressing the settings button + $("#optionsbutton").bind("click", doOpenOptions); + + // Checking a checkbox in the settings dialog + $("[data-setting]").bind("change", onOptionCheckboxChanged); + + console.log('Options Plugin Initialized.'); + } + + return { + init: init, + onKeydown: onKeydown, + onLoggedIn: onLoggedIn, + onGotOptions: onGotOptions, + onPrompt: onPrompt, + onConnectionClose: onConnectionClose, + } +})() +plugin_handler.add('options', options_plugin); diff --git a/evennia/web/webclient/static/webclient/js/plugins/popups.js b/evennia/web/webclient/static/webclient/js/plugins/popups.js new file mode 100644 index 0000000000..7d9667a79f --- /dev/null +++ b/evennia/web/webclient/static/webclient/js/plugins/popups.js @@ -0,0 +1,101 @@ +/* + * Popups GUI functions plugin + */ +let popups_plugin = (function () { + + // + // openPopup + var openPopup = function (dialogname, content) { + var dialog = $(dialogname); + if (!dialog.length) { + console.log("Dialog " + renderto + " not found."); + return; + } + + if (content) { + var contentel = dialog.find(".dialogcontent"); + contentel.html(content); + } + dialog.show(); + } + + // + // closePopup + var closePopup = function (dialogname) { + var dialog = $(dialogname); + dialog.hide(); + } + + // + // togglePopup + var togglePopup = function (dialogname, content) { + var dialog = $(dialogname); + if (dialog.css('display') == 'none') { + openPopup(dialogname, content); + } else { + closePopup(dialogname); + } + } + + // + // createDialog + var createDialog = function (dialogid, dialogtitle, content) { + var dialog = $( [ + '
', + '
'+ dialogtitle +'×
', + '
', + '
'+ content +'
', + '
', + '
', + '
', + ].join("\n") ); + + $('body').append( dialog ); + + $('#'+ dialogid +' .dialogclose').bind('click', function (event) { $('#'+dialogid).hide(); }); + } + + // + // User clicked on a dialog to drag it + var doStartDragDialog = function (event) { + var dialog = $(event.target).closest(".dialog"); + dialog.css('cursor', 'move'); + + var position = dialog.offset(); + var diffx = event.pageX; + var diffy = event.pageY; + + var drag = function(event) { + var y = position.top + event.pageY - diffy; + var x = position.left + event.pageX - diffx; + dialog.offset({top: y, left: x}); + }; + + var undrag = function() { + $(document).unbind("mousemove", drag); + $(document).unbind("mouseup", undrag); + dialog.css('cursor', ''); + } + + $(document).bind("mousemove", drag); + $(document).bind("mouseup", undrag); + } + + // + // required plugin function + var init = function () { + // Makes dialogs draggable + $(".dialogtitle").bind("mousedown", doStartDragDialog); + + console.log('Popups Plugin Initialized.'); + } + + return { + init: init, + openPopup: openPopup, + closePopup: closePopup, + togglePopup: togglePopup, + createDialog: createDialog, + } +})() +plugin_handler.add('popups', popups_plugin); diff --git a/evennia/web/webclient/static/webclient/js/plugins/splithandler.js b/evennia/web/webclient/static/webclient/js/plugins/splithandler.js new file mode 100644 index 0000000000..b55dea7696 --- /dev/null +++ b/evennia/web/webclient/static/webclient/js/plugins/splithandler.js @@ -0,0 +1,368 @@ +/* + * + * Plugin to use split.js to create a basic windowed ui + * + */ +let splithandler_plugin = (function () { + + var num_splits = 0; + var split_panes = {}; + var backout_list = new Array; + + var known_types = new Array(); + + // Exported Functions + + // + // function to assign "Text types to catch" to a pane + var set_pane_types = function (splitpane, types) { + split_panes[splitpane]['types'] = types; + } + + // + // Add buttons to the Evennia webcilent toolbar + function addToolbarButtons () { + var toolbar = $('#toolbar'); + toolbar.append( $('') ); + toolbar.append( $('') ); + toolbar.append( $('') ); + $('#undobutton').hide(); + } + + function addSplitDialog () { + plugins['popups'].createDialog('splitdialog', 'Split Dialog', ''); + } + + function addPaneDialog () { + plugins['popups'].createDialog('panedialog', 'Pane Dialog', ''); + } + + // + // Handle resizing the InputField after a client resize event so that the splits dont get too big. + function resizeInputField () { + var wrapper = $("#inputform") + var input = $("#inputcontrol") + var prompt = $("#prompt") + + input.height( wrapper.height() - (input.offset().top - wrapper.offset().top) ); + } + + // + // Handle resizing of client + function doWindowResize() { + var resizable = $("[data-update-append]"); + var parents = resizable.closest(".split"); + + resizeInputField(); + + parents.animate({ + scrollTop: parents.prop("scrollHeight") + }, 0); + } + + // + // create a new UI split + var dynamic_split = function (splitpane, direction, pane_name1, pane_name2, update_method1, update_method2, sizes) { + // find the sub-div of the pane we are being asked to split + splitpanesub = splitpane + '-sub'; + + // create the new div stack to replace the sub-div with. + var first_div = $( '
' ) + var first_sub = $( '
' ) + var second_div = $( '
' ) + var second_sub = $( '
' ) + + // check to see if this sub-pane contains anything + contents = $('#'+splitpanesub).contents(); + if( contents ) { + // it does, so move it to the first new div-sub (TODO -- selectable between first/second?) + contents.appendTo(first_sub); + } + first_div.append( first_sub ); + second_div.append( second_sub ); + + // update the split_panes array to remove this pane name, but store it for the backout stack + var backout_settings = split_panes[splitpane]; + delete( split_panes[splitpane] ); + + // now vaporize the current split_N-sub placeholder and create two new panes. + $('#'+splitpane).append(first_div); + $('#'+splitpane).append(second_div); + $('#'+splitpane+'-sub').remove(); + + // And split + Split(['#'+pane_name1,'#'+pane_name2], { + direction: direction, + sizes: sizes, + gutterSize: 4, + minSize: [50,50], + }); + + // store our new split sub-divs for future splits/uses by the main UI. + split_panes[pane_name1] = { 'types': [], 'update_method': update_method1 }; + split_panes[pane_name2] = { 'types': [], 'update_method': update_method2 }; + + // add our new split to the backout stack + backout_list.push( {'pane1': pane_name1, 'pane2': pane_name2, 'undo': backout_settings} ); + + $('#undobutton').show(); + } + + // + // Reverse the last UI split + var undo_split = function () { + // pop off the last split pair + var back = backout_list.pop(); + if( !back ) { + return; + } + + if( backout_list.length === 0 ) { + $('#undobutton').hide(); + } + + // Collect all the divs/subs in play + var pane1 = back['pane1']; + var pane2 = back['pane2']; + var pane1_sub = $('#'+pane1+'-sub'); + var pane2_sub = $('#'+pane2+'-sub'); + var pane1_parent = $('#'+pane1).parent(); + var pane2_parent = $('#'+pane2).parent(); + + if( pane1_parent.attr('id') != pane2_parent.attr('id') ) { + // sanity check failed...somebody did something weird...bail out + console.log( pane1 ); + console.log( pane2 ); + console.log( pane1_parent ); + console.log( pane2_parent ); + return; + } + + // create a new sub-pane in the panes parent + var parent_sub = $( '
' ) + + // check to see if the special #messagewindow is in either of our sub-panes. + var msgwindow = pane1_sub.find('#messagewindow') + if( !msgwindow ) { + //didn't find it in pane 1, try pane 2 + msgwindow = pane2_sub.find('#messagewindow') + } + if( msgwindow ) { + // It is, so collect all contents into it instead of our parent_sub div + // then move it to parent sub div, this allows future #messagewindow divs to flow properly + msgwindow.append( pane1_sub.contents() ); + msgwindow.append( pane2_sub.contents() ); + parent_sub.append( msgwindow ); + } else { + //didn't find it, so move the contents of the two panes' sub-panes into the new sub-pane + parent_sub.append( pane1_sub.contents() ); + parent_sub.append( pane2_sub.contents() ); + } + + // clear the parent + pane1_parent.empty(); + + // add the new sub-pane back to the parent div + pane1_parent.append(parent_sub); + + // pull the sub-div's from split_panes + delete split_panes[pane1]; + delete split_panes[pane2]; + + // add our parent pane back into the split_panes list for future splitting + split_panes[pane1_parent.attr('id')] = back['undo']; + } + + // + // UI elements + // + + // + // Draw "Split Controls" Dialog + var onSplitDialog = function () { + var dialog = $("#splitdialogcontent"); + dialog.empty(); + + dialog.append("

Split?

"); + dialog.append(' top/bottom
'); + dialog.append(' side-by-side
'); + + dialog.append("

Split Which Pane?

"); + for ( var pane in split_panes ) { + dialog.append(''+ pane +'
'); + } + + dialog.append("

New Pane Names

"); + dialog.append(''); + dialog.append(''); + + dialog.append("

New First Pane Flow

"); + dialog.append('append
'); + dialog.append('replace
'); + + dialog.append("

New Second Pane Flow

"); + dialog.append('append
'); + dialog.append('replace
'); + + dialog.append('
Split It
'); + + $("#splitclose").bind("click", onSplitDialogClose); + + plugins['popups'].togglePopup("#splitdialog"); + } + + // + // Close "Split Controls" Dialog + var onSplitDialogClose = function () { + var pane = $("input[name=pane]:checked").attr("value"); + var direction = $("input[name=direction]:checked").attr("value"); + var new_pane1 = $("input[name=new_pane1]").val(); + var new_pane2 = $("input[name=new_pane2]").val(); + var flow1 = $("input[name=flow1]:checked").attr("value"); + var flow2 = $("input[name=flow2]:checked").attr("value"); + + if( new_pane1 == "" ) { + new_pane1 = 'pane_'+num_splits; + num_splits++; + } + + if( new_pane2 == "" ) { + new_pane2 = 'pane_'+num_splits; + num_splits++; + } + + if( document.getElementById(new_pane1) ) { + alert('An element: "' + new_pane1 + '" already exists'); + return; + } + + if( document.getElementById(new_pane2) ) { + alert('An element: "' + new_pane2 + '" already exists'); + return; + } + + dynamic_split( pane, direction, new_pane1, new_pane2, flow1, flow2, [50,50] ); + + plugins['popups'].closePopup("#splitdialog"); + } + + // + // Draw "Pane Controls" dialog + var onPaneControlDialog = function () { + var dialog = $("#splitdialogcontent"); + dialog.empty(); + + dialog.append("

Set Which Pane?

"); + for ( var pane in split_panes ) { + dialog.append(''+ pane +'
'); + } + + dialog.append("

Which content types?

"); + for ( var type in known_types ) { + dialog.append(''+ known_types[type] +'
'); + } + + dialog.append('
Make It So
'); + + $("#paneclose").bind("click", onPaneControlDialogClose); + + plugins['popups'].togglePopup("#splitdialog"); + } + + // + // Close "Pane Controls" dialog + var onPaneControlDialogClose = function () { + var pane = $("input[name=pane]:checked").attr("value"); + + var types = new Array; + $('#splitdialogcontent input[type=checkbox]:checked').each(function() { + types.push( $(this).attr('value') ); + }); + + set_pane_types( pane, types ); + + plugins['popups'].closePopup("#splitdialog"); + } + + // + // plugin functions + // + + // + // Accept plugin onText events + var onText = function (args, kwargs) { + if ( kwargs && 'type' in kwargs ) { + var msgtype = kwargs['type']; + if ( ! known_types.includes(msgtype) ) { + // this is a new output type that can be mapped to panes + console.log('detected new output type: ' + msgtype) + known_types.push(msgtype); + } + + for ( var key in split_panes) { + var pane = split_panes[key]; + + // is this message type mapped to this pane? + if ( (pane['types'].length > 0) && pane['types'].includes(msgtype) ) { + // yes, so append/replace this pane's inner div with this message + var text_div = $('#'+key+'-sub'); + if ( pane['update_method'] == 'replace' ) { + text_div.html(args[0]) + } else { + text_div.append(args[0]); + var scrollHeight = text_div.parent().prop("scrollHeight"); + text_div.parent().animate({ scrollTop: scrollHeight }, 0); + } + return true; + } + } + } + return false; + } + + // + // Required plugin "init" function + var init = function(settings) { + known_types.push('help'); + + Split(['#main','#input'], { + direction: 'vertical', + sizes: [90,10], + gutterSize: 4, + minSize: [50,50], + }); + + split_panes['main'] = { 'types': [], 'update_method': 'append' }; + + // Create our UI + addToolbarButtons(); + addSplitDialog(); + addPaneDialog(); + + // Register our utility button events + $("#splitbutton").bind("click", onSplitDialog); + $("#panebutton").bind("click", onPaneControlDialog); + $("#undobutton").bind("click", undo_split); + + // Event when client window changes + $(window).bind("resize", doWindowResize); + + $("[data-role-input]").bind("resize", doWindowResize) + .bind("paste", resizeInputField) + .bind("cut", resizeInputField); + + // Event when any key is pressed + $(document).keyup(resizeInputField); + + console.log("Splithandler Plugin Initialized."); + } + + return { + init: init, + onText: onText, + dynamic_split: dynamic_split, + undo_split: undo_split, + set_pane_types: set_pane_types, + } +})() +plugin_handler.add('splithandler', splithandler_plugin); diff --git a/evennia/web/webclient/static/webclient/js/splithandler.js b/evennia/web/webclient/static/webclient/js/splithandler.js deleted file mode 100644 index 81210df854..0000000000 --- a/evennia/web/webclient/static/webclient/js/splithandler.js +++ /dev/null @@ -1,145 +0,0 @@ -// Use split.js to create a basic ui -var SplitHandler = (function () { - var split_panes = {}; - var backout_list = new Array; - - var set_pane_types = function(splitpane, types) { - split_panes[splitpane]['types'] = types; - } - - - var dynamic_split = function(splitpane, direction, pane_name1, pane_name2, update_method1, update_method2, sizes) { - // find the sub-div of the pane we are being asked to split - splitpanesub = splitpane + '-sub'; - - // create the new div stack to replace the sub-div with. - var first_div = $( '
' ) - var first_sub = $( '
' ) - var second_div = $( '
' ) - var second_sub = $( '
' ) - - // check to see if this sub-pane contains anything - contents = $('#'+splitpanesub).contents(); - if( contents ) { - // it does, so move it to the first new div-sub (TODO -- selectable between first/second?) - contents.appendTo(first_sub); - } - first_div.append( first_sub ); - second_div.append( second_sub ); - - // update the split_panes array to remove this pane name, but store it for the backout stack - var backout_settings = split_panes[splitpane]; - delete( split_panes[splitpane] ); - - // now vaporize the current split_N-sub placeholder and create two new panes. - $('#'+splitpane).append(first_div); - $('#'+splitpane).append(second_div); - $('#'+splitpane+'-sub').remove(); - - // And split - Split(['#'+pane_name1,'#'+pane_name2], { - direction: direction, - sizes: sizes, - gutterSize: 4, - minSize: [50,50], - }); - - // store our new split sub-divs for future splits/uses by the main UI. - split_panes[pane_name1] = { 'types': [], 'update_method': update_method1 }; - split_panes[pane_name2] = { 'types': [], 'update_method': update_method2 }; - - // add our new split to the backout stack - backout_list.push( {'pane1': pane_name1, 'pane2': pane_name2, 'undo': backout_settings} ); - } - - - var undo_split = function() { - // pop off the last split pair - var back = backout_list.pop(); - if( !back ) { - return; - } - - // Collect all the divs/subs in play - var pane1 = back['pane1']; - var pane2 = back['pane2']; - var pane1_sub = $('#'+pane1+'-sub'); - var pane2_sub = $('#'+pane2+'-sub'); - var pane1_parent = $('#'+pane1).parent(); - var pane2_parent = $('#'+pane2).parent(); - - if( pane1_parent.attr('id') != pane2_parent.attr('id') ) { - // sanity check failed...somebody did something weird...bail out - console.log( pane1 ); - console.log( pane2 ); - console.log( pane1_parent ); - console.log( pane2_parent ); - return; - } - - // create a new sub-pane in the panes parent - var parent_sub = $( '
' ) - - // check to see if the special #messagewindow is in either of our sub-panes. - var msgwindow = pane1_sub.find('#messagewindow') - if( !msgwindow ) { - //didn't find it in pane 1, try pane 2 - msgwindow = pane2_sub.find('#messagewindow') - } - if( msgwindow ) { - // It is, so collect all contents into it instead of our parent_sub div - // then move it to parent sub div, this allows future #messagewindow divs to flow properly - msgwindow.append( pane1_sub.contents() ); - msgwindow.append( pane2_sub.contents() ); - parent_sub.append( msgwindow ); - } else { - //didn't find it, so move the contents of the two panes' sub-panes into the new sub-pane - parent_sub.append( pane1_sub.contents() ); - parent_sub.append( pane2_sub.contents() ); - } - - // clear the parent - pane1_parent.empty(); - - // add the new sub-pane back to the parent div - pane1_parent.append(parent_sub); - - // pull the sub-div's from split_panes - delete split_panes[pane1]; - delete split_panes[pane2]; - - // add our parent pane back into the split_panes list for future splitting - split_panes[pane1_parent.attr('id')] = back['undo']; - } - - - var init = function(settings) { - //change Mustache tags to ruby-style (Django gets mad otherwise) - var customTags = [ '<%', '%>' ]; - Mustache.tags = customTags; - - var input_template = $('#input-template').html(); - Mustache.parse(input_template); - - Split(['#main','#input'], { - direction: 'vertical', - sizes: [90,10], - gutterSize: 4, - minSize: [50,50], - }); - - split_panes['main'] = { 'types': [], 'update_method': 'append' }; - - var input_render = Mustache.render(input_template); - $('[data-role-input]').html(input_render); - console.log("SplitHandler initialized"); - } - - return { - init: init, - set_pane_types: set_pane_types, - dynamic_split: dynamic_split, - split_panes: split_panes, - undo_split: undo_split, - } -})(); diff --git a/evennia/web/webclient/static/webclient/js/webclient_gui.js b/evennia/web/webclient/static/webclient/js/webclient_gui.js index e1ed4d31fd..146d4a5268 100644 --- a/evennia/web/webclient/static/webclient/js/webclient_gui.js +++ b/evennia/web/webclient/static/webclient/js/webclient_gui.js @@ -5,625 +5,285 @@ * This is used in conjunction with the main evennia.js library, which * handles all the communication with the Server. * - * The job of this code is to create listeners to subscribe to evennia - * messages, via Evennia.emitter.on(cmdname, listener) and to handle - * input from the user and send it to - * Evennia.msg(cmdname, args, kwargs, [callback]). + * The job of this code is to coordinate between listeners subscribed to + * evennia messages and any registered plugins that want to process those + * messages and send data back to Evennia + * + * This is done via Evennia.emitter.on(cmdname, listener) and calling + * each plugin's init() function to give each plugin a chance to register + * input handlers or other events on startup. + * + * Once a plugin has determined it wants to send a message back to the + * server, it generates an onSend() function event which allows all + * other plugins a chance to modify the event and then uses + * Evennia.msg(cmdname, args, kwargs, [callback]) to finally send the data. * */ -(function () { -"use strict" - -var num_splits = 0; //unique id counter for default split-panel names - -var options = {}; - -var known_types = new Array(); - known_types.push('help'); - // -// GUI Elements +// Global Plugins system // +var options = {}; // Global "settings" object that all plugins can use to + // save/pass data to each other and the server. + // format should match: + // { 'plugin_name': { 'option_key': value, ... }, ... } -// Manage history for input line -var input_history = function() { - var history_max = 21; - var history = new Array(); - var history_pos = 0; - - history[0] = ''; // the very latest input is empty for new entry. - - var back = function () { - // step backwards in history stack - history_pos = Math.min(++history_pos, history.length - 1); - return history[history.length - 1 - history_pos]; - }; - var fwd = function () { - // step forwards in history stack - history_pos = Math.max(--history_pos, 0); - return history[history.length - 1 - history_pos]; - }; - var add = function (input) { - // add a new entry to history, don't repeat latest - if (input && input != history[history.length-2]) { - if (history.length >= history_max) { - history.shift(); // kill oldest entry - } - history[history.length-1] = input; - history[history.length] = ''; - }; - // reset the position to the last history entry - history_pos = 0; - }; - var end = function () { - // move to the end of the history stack - history_pos = 0; - return history[history.length -1]; - } - - var scratch = function (input) { - // Put the input into the last history entry (which is normally empty) - // without making the array larger as with add. - // Allows for in-progress editing to be saved. - history[history.length-1] = input; - } - - return {back: back, - fwd: fwd, - add: add, - end: end, - scratch: scratch} -}(); - -function openPopup(dialogname, content) { - var dialog = $(dialogname); - if (!dialog.length) { - console.log("Dialog " + renderto + " not found."); - return; - } - - if (content) { - var contentel = dialog.find(".dialogcontent"); - contentel.html(content); - } - dialog.show(); -} - -function closePopup(dialogname) { - var dialog = $(dialogname); - dialog.hide(); -} - -function togglePopup(dialogname, content) { - var dialog = $(dialogname); - if (dialog.css('display') == 'none') { - openPopup(dialogname, content); - } else { - closePopup(dialogname); - } -} +var plugins = {}; // Global plugin objects by name. + // Each must have an init() function. // -// GUI Event Handlers +// Global plugin_handler // +var plugin_handler = (function () { + "use strict" -// Grab text from inputline and send to Evennia -function doSendText() { - console.log("sending text"); - if (!Evennia.isConnected()) { - var reconnect = confirm("Not currently connected. Reconnect?"); - if (reconnect) { - onText(["Attempting to reconnnect..."], {cls: "sys"}); - Evennia.connect(); - } - // Don't try to send anything until the connection is back. - return; - } - var inputfield = $("#inputfield"); - var outtext = inputfield.val(); - var lines = outtext.trim().replace(/[\r]+/,"\n").replace(/[\n]+/, "\n").split("\n"); - for (var i = 0; i < lines.length; i++) { - var line = lines[i].trim(); - if (line.length > 7 && line.substr(0, 7) == "##send ") { - // send a specific oob instruction ["cmdname",[args],{kwargs}] - line = line.slice(7); - var cmdarr = JSON.parse(line); - var cmdname = cmdarr[0]; - var args = cmdarr[1]; - var kwargs = cmdarr[2]; - log(cmdname, args, kwargs); - Evennia.msg(cmdname, args, kwargs); - } else { - input_history.add(line); - inputfield.val(""); - Evennia.msg("text", [line], {}); - } - } -} + var ordered_plugins = new Array; // plugins in loaded order -// Opens the options dialog -function doOpenOptions() { - if (!Evennia.isConnected()) { - alert("You need to be connected."); - return; + // + // Plugin Support Functions + // + + // Add a new plugin + var add = function (name, plugin) { + plugins[name] = plugin; + ordered_plugins.push( plugin ); } - togglePopup("#optionsdialog"); -} -// Closes the currently open dialog -function doCloseDialog(event) { - var dialog = $(event.target).closest(".dialog"); - dialog.hide(); -} + // + // GUI Event Handlers + // -// catch all keyboard input, handle special chars -function onKeydown (event) { - var code = event.which; - var history_entry = null; - var inputfield = $("#inputfield"); - if (code === 9) { - return; - } - - //inputfield.focus(); - - if (code === 13) { // Enter key sends text - doSendText(); - event.preventDefault(); - } - else if (inputfield[0].selectionStart == inputfield.val().length) { - // Only process up/down arrow if cursor is at the end of the line. - if (code === 38) { // Arrow up - history_entry = input_history.back(); - } - else if (code === 40) { // Arrow down - history_entry = input_history.fwd(); - } - } - - if (code === 27) { // Escape key - if ($('#helpdialog').is(':visible')) { - closePopup("#helpdialog"); - } else { - closePopup("#optionsdialog"); - } - } - - if (history_entry !== null) { - // Doing a history navigation; replace the text in the input. - inputfield.val(history_entry); - event.preventDefault(); - } - else { - // Save the current contents of the input to the history scratch area. - setTimeout(function () { - // Need to wait until after the key-up to capture the value. - input_history.scratch(inputfield.val()); - input_history.end(); - }, 0); - } -}; - -function onKeyPress (event) { - // Prevent carriage returns inside the input area. - if (event.which === 13) { - event.preventDefault(); - } -} - -var resizeInputField = function () { - return function() { - var wrapper = $("#inputform") - var input = $("#inputcontrol") - var prompt = $("#prompt") - - input.height(wrapper.height() - (input.offset().top - wrapper.offset().top)); - } -}(); - -// Handle resizing of client -function doWindowResize() { - resizeInputField(); - var resizable = $("[data-update-append]"); - var parents = resizable.closest(".split") - parents.animate({ - scrollTop: parents.prop("scrollHeight") - }, 0); -} - -// Handle text coming from the server -function onText(args, kwargs) { - var use_default_pane = true; - - if ( kwargs && 'type' in kwargs ) { - var msgtype = kwargs['type']; - if ( ! known_types.includes(msgtype) ) { - // this is a new output type that can be mapped to panes - console.log('detected new output type: ' + msgtype) - known_types.push(msgtype); - } - - // pass this message to each pane that has this msgtype mapped - if( SplitHandler ) { - for ( var key in SplitHandler.split_panes) { - var pane = SplitHandler.split_panes[key]; - // is this message type mapped to this pane? - if ( (pane['types'].length > 0) && pane['types'].includes(msgtype) ) { - // yes, so append/replace this pane's inner div with this message - var text_div = $('#'+key+'-sub'); - if ( pane['update_method'] == 'replace' ) { - text_div.html(args[0]) - } else { - text_div.append(args[0]); - var scrollHeight = text_div.parent().prop("scrollHeight"); - text_div.parent().animate({ scrollTop: scrollHeight }, 0); - } - // record sending this message to a pane, no need to update the default div - use_default_pane = false; + // catch all keyboard input, handle special chars + var onKeydown = function (event) { + // cycle through each plugin's keydown + for( let n=0; n < ordered_plugins.length; n++ ) { + let plugin = ordered_plugins[n]; + // does this plugin handle keydown events? + if( 'onKeydown' in plugin ) { + // yes, does this plugin claim this event exclusively? + if( plugin.onKeydown(event) ) { + // 'true' claims this event has been handled + return; } } } + console.log('NO plugin handled this Keydown'); } - // append message to default pane, then scroll so latest is at the bottom. - if(use_default_pane) { - var mwin = $("#messagewindow"); - var cls = kwargs == null ? 'out' : kwargs['cls']; - mwin.append("
" + args[0] + "
"); - var scrollHeight = mwin.parent().parent().prop("scrollHeight"); - mwin.parent().parent().animate({ scrollTop: scrollHeight }, 0); - onNewLine(args[0], null); - } -} - -// Handle prompt output from the server -function onPrompt(args, kwargs) { - // show prompt - $('#prompt') - .addClass("out") - .html(args[0]); - doWindowResize(); - - // also display the prompt in the output window if gagging is disabled - if (("gagprompt" in options) && (!options["gagprompt"])) { - onText(args, kwargs); - } -} - -// Called when the user logged in -function onLoggedIn() { - $('#optionsbutton').removeClass('hidden'); - Evennia.msg("webclient_options", [], {}); -} - -// Called when a setting changed -function onGotOptions(args, kwargs) { - options = kwargs; - - $.each(kwargs, function(key, value) { - var elem = $("[data-setting='" + key + "']"); - if (elem.length === 0) { - console.log("Could not find option: " + key); - } else { - elem.prop('checked', value); - }; - }); -} - -// Called when the user changed a setting from the interface -function onOptionCheckboxChanged() { - var name = $(this).data("setting"); - var value = this.checked; - - var changedoptions = {}; - changedoptions[name] = value; - Evennia.msg("webclient_options", [], changedoptions); - - options[name] = value; -} - -// Silences events we don't do anything with. -function onSilence(cmdname, args, kwargs) {} - -// Handle the server connection closing -function onConnectionClose(conn_name, evt) { - $('#optionsbutton').addClass('hidden'); - closePopup("#optionsdialog"); - onText(["The connection was closed or lost."], {'cls': 'err'}); -} - -// Handle unrecognized commands from server -function onDefault(cmdname, args, kwargs) { - var mwin = $("#messagewindow"); - mwin.append( - "
" - + "Error or Unhandled event:
" - + cmdname + ", " - + JSON.stringify(args) + ", " - + JSON.stringify(kwargs) + "

"); - mwin.scrollTop(mwin[0].scrollHeight); -} - -// Ask if user really wants to exit session when closing -// the tab or reloading the page. Note: the message is not shown -// in Firefox, there it's a standard error. -function onBeforeUnload() { - return "You are about to leave the game. Please confirm."; -} - -// Notifications -var unread = 0; -var originalTitle = document.title; -var focused = true; -var favico; - -function onBlur(e) { - focused = false; -} - -// Notifications for unfocused window -function onFocus(e) { - focused = true; - document.title = originalTitle; - unread = 0; - favico.badge(0); -} - -function onNewLine(text, originator) { - if(!focused) { - // Changes unfocused browser tab title to number of unread messages - unread++; - favico.badge(unread); - document.title = "(" + unread + ") " + originalTitle; - if ("Notification" in window){ - if (("notification_popup" in options) && (options["notification_popup"])) { - // 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 = { - body: text.replace(/(<([^>]+)>)/ig,""), - icon: "/static/website/images/evennia_logo.png" - } - - var n = new Notification(title, options); - n.onclick = function(e) { - e.preventDefault(); - window.focus(); - this.close(); - } + // Ask if user really wants to exit session when closing + // the tab or reloading the page. Note: the message is not shown + // in Firefox, there it's a standard error. + var onBeforeUnload = function () { + // cycle through each plugin to look for unload handlers + for( let n=0; n < ordered_plugins.length; n++ ) { + let plugin = ordered_plugins[n]; + if( 'onBeforeUnload' in plugin ) { + plugin.onBeforeUnload(); } - }); - } - if (("notification_sound" in options) && (options["notification_sound"])) { - var audio = new Audio("/static/webclient/media/notification.wav"); - audio.play(); - } - } - } -} - -// User clicked on a dialog to drag it -function doStartDragDialog(event) { - var dialog = $(event.target).closest(".dialog"); - dialog.css('cursor', 'move'); - - var position = dialog.offset(); - var diffx = event.pageX; - var diffy = event.pageY; - - var drag = function(event) { - var y = position.top + event.pageY - diffy; - var x = position.left + event.pageX - diffx; - dialog.offset({top: y, left: x}); - }; - - var undrag = function() { - $(document).unbind("mousemove", drag); - $(document).unbind("mouseup", undrag); - dialog.css('cursor', ''); + } } - $(document).bind("mousemove", drag); - $(document).bind("mouseup", undrag); -} -function onSplitDialogClose() { - var pane = $("input[name=pane]:checked").attr("value"); - var direction = $("input[name=direction]:checked").attr("value"); - var new_pane1 = $("input[name=new_pane1]").val(); - var new_pane2 = $("input[name=new_pane2]").val(); - var flow1 = $("input[name=flow1]:checked").attr("value"); - var flow2 = $("input[name=flow2]:checked").attr("value"); + // + // Evennia Public Event Handlers + // - if( new_pane1 == "" ) { - new_pane1 = 'pane_'+num_splits; - num_splits++; + // Handle onLoggedIn from the server + var onLoggedIn = function (args, kwargs) { + for( let n=0; n < ordered_plugins.length; n++ ) { + let plugin = ordered_plugins[n]; + if( 'onLoggedIn' in plugin ) { + plugin.onLoggedIn(args, kwargs); + } + } } - if( new_pane2 == "" ) { - new_pane2 = 'pane_'+num_splits; - num_splits++; + + // Handle onGotOptions from the server + var onGotOptions = function (args, kwargs) { + // does any plugin handle Options? + for( let n=0; n < ordered_plugins.length; n++ ) { + let plugin = ordered_plugins[n]; + if( 'onGotOptions' in plugin ) { + plugin.onGotOptions(args, kwargs); + } + } } - if( document.getElementById(new_pane1) ) { - alert('An element: "' + new_pane1 + '" already exists'); - return; + + // Handle text coming from the server + var onText = function (args, kwargs) { + // does this plugin handle this onText event? + for( let n=0; n < ordered_plugins.length; n++ ) { + let plugin = ordered_plugins[n]; + if( 'onText' in plugin ) { + if( plugin.onText(args, kwargs) ) { + // True -- means this plugin claims this Text exclusively. + return; + } + } + } + console.log('NO plugin handled this Text'); } - if( document.getElementById(new_pane2) ) { - alert('An element: "' + new_pane2 + '" already exists'); - return; + + // Handle prompt output from the server + var onPrompt = function (args, kwargs) { + // does this plugin handle this onPrompt event? + for( let n=0; n < ordered_plugins.length; n++ ) { + let plugin = ordered_plugins[n]; + if( 'onPrompt' in plugin ) { + if( plugin.onPrompt(args, kwargs) ) { + // True -- means this plugin claims this Prompt exclusively. + return; + } + } + } + console.log('NO plugin handled this Prompt'); } - SplitHandler.dynamic_split( pane, direction, new_pane1, new_pane2, flow1, flow2, [50,50] ); - closePopup("#splitdialog"); -} - -function onSplitDialog() { - var dialog = $("#splitdialogcontent"); - dialog.empty(); - - dialog.append("

Split?

"); - dialog.append(' top/bottom
'); - dialog.append(' side-by-side
'); - - dialog.append("

Split Which Pane?

"); - for ( var pane in SplitHandler.split_panes ) { - dialog.append(''+ pane +'
'); + // Handle unrecognized commands from server + var onDefault = function (cmdname, args, kwargs) { + // does this plugin handle this UnknownCmd? + for( let n=0; n < ordered_plugins.length; n++ ) { + let plugin = ordered_plugins[n]; + if( 'onUnknownCmd' in plugin ) { + if( plugin.onUnknownCmd(args, kwargs) ) { + // True -- means this plugin claims this UnknownCmd exclusively. + return; + } + } + } + console.log('NO plugin handled this Unknown Evennia Command'); } - dialog.append("

New Pane Names

"); - dialog.append(''); - dialog.append(''); - dialog.append("

New First Pane

"); - dialog.append('append new incoming messages
'); - dialog.append('replace old messages with new ones
'); + // Handle the server connection closing + var onConnectionClose = function (args, kwargs) { + // give every plugin a chance to do stuff onConnectionClose + for( let n=0; n < ordered_plugins.length; n++ ) { + let plugin = ordered_plugins[n]; + if( 'onConnectionClose' in plugin ) { + plugin.onConnectionClose(args, kwargs); + } + } - dialog.append("

New Second Pane

"); - dialog.append('append new incoming messages
'); - dialog.append('replace old messages with new ones
'); - - dialog.append('
Split It
'); - - $("#splitclose").bind("click", onSplitDialogClose); - - togglePopup("#splitdialog"); -} - -function onPaneControlDialogClose() { - var pane = $("input[name=pane]:checked").attr("value"); - - var types = new Array; - $('#splitdialogcontent input[type=checkbox]:checked').each(function() { - types.push( $(this).attr('value') ); - }); - - SplitHandler.set_pane_types( pane, types ); - - closePopup("#splitdialog"); -} - -function onPaneControlDialog() { - var dialog = $("#splitdialogcontent"); - dialog.empty(); - - dialog.append("

Set Which Pane?

"); - for ( var pane in SplitHandler.split_panes ) { - dialog.append(''+ pane +'
'); + onText(["The connection was closed or lost."], {'cls': 'err'}); } - dialog.append("

Which content types?

"); - for ( var type in known_types ) { - dialog.append(''+ known_types[type] +'
'); + + // Silences events we don't do anything with. + var onSilence = function (cmdname, args, kwargs) {} + + + // + // Global onSend() function to iterate through all plugins before sending text to the server. + // This can be called by other plugins for "Triggers", , and other automated sends + // + var onSend = function (line) { + if (!Evennia.isConnected()) { + var reconnect = confirm("Not currently connected. Reconnect?"); + if (reconnect) { + onText(["Attempting to reconnnect..."], {cls: "sys"}); + Evennia.connect(); + } + // Don't try to send anything until the connection is back. + return; + } + + // default output command + var cmd = { + command: "text", + args: [ line ], + kwargs: {} + }; + + // Give each plugin a chance to use/modify the outgoing command for aliases/history/etc + for( let n=0; n < ordered_plugins.length; n++ ) { + let plugin = ordered_plugins[n]; + if( 'onSend' in plugin ) { + var outCmd = plugin.onSend(line); + if( outCmd ) { + cmd = outCmd; + } + } + } + + // console.log('sending: ' + cmd.command + ', [' + cmd.args[0].toString() + '], ' + cmd.kwargs.toString() ); + Evennia.msg(cmd.command, cmd.args, cmd.kwargs); } - dialog.append('
Make It So
'); - $("#paneclose").bind("click", onPaneControlDialogClose); + // + // call each plugins' init function (the only required function) + // + var init = function () { + for( let n=0; n < ordered_plugins.length; n++ ) { + ordered_plugins[n].init(); + } + } + + + return { + add: add, + onKeydown: onKeydown, + onBeforeUnload: onBeforeUnload, + onLoggedIn: onLoggedIn, + onText: onText, + onGotOptions: onGotOptions, + onPrompt: onPrompt, + onDefault: onDefault, + onSilence: onSilence, + onConnectionClose: onConnectionClose, + onSend: onSend, + init: init, + } +})(); - togglePopup("#splitdialog"); -} // -// Register Events +// Webclient Initialization // // Event when client finishes loading $(document).ready(function() { - - if( SplitHandler ) { - SplitHandler.init(); - $("#splitbutton").bind("click", onSplitDialog); - $("#panebutton").bind("click", onPaneControlDialog); - $("#undobutton").bind("click", SplitHandler.undo_split); - $("#optionsbutton").hide(); - } else { - $("#splitbutton").hide(); - $("#panebutton").hide(); - $("#undobutton").hide(); - } - - if ("Notification" in window) { - Notification.requestPermission(); - } - - favico = new Favico({ - animation: 'none' - }); - - // Event when client window changes - $(window).bind("resize", doWindowResize); - - $(window).blur(onBlur); - $(window).focus(onFocus); - - //$(document).on("visibilitychange", onVisibilityChange); - - $("[data-role-input]").bind("resize", doWindowResize) - .keypress(onKeyPress) - .bind("paste", resizeInputField) - .bind("cut", resizeInputField); - - // Event when any key is pressed - $(document).keydown(onKeydown) - .keyup(resizeInputField); - - // Pressing the send button - $("#inputsend").bind("click", doSendText); - - // Pressing the settings button - $("#optionsbutton").bind("click", doOpenOptions); - - // Checking a checkbox in the settings dialog - $("[data-setting]").bind("change", onOptionCheckboxChanged); - - // Pressing the close button on a dialog - $(".dialogclose").bind("click", doCloseDialog); - - // Makes dialogs draggable - $(".dialogtitle").bind("mousedown", doStartDragDialog); - // This is safe to call, it will always only // initialize once. Evennia.init(); - // register listeners - Evennia.emitter.on("text", onText); - Evennia.emitter.on("prompt", onPrompt); - Evennia.emitter.on("default", onDefault); - Evennia.emitter.on("connection_close", onConnectionClose); - Evennia.emitter.on("logged_in", onLoggedIn); - Evennia.emitter.on("webclient_options", onGotOptions); - // silence currently unused events - Evennia.emitter.on("connection_open", onSilence); - Evennia.emitter.on("connection_error", onSilence); - // Handle pressing the send button - $("#inputsend").bind("click", doSendText); + // register listeners + Evennia.emitter.on("logged_in", plugin_handler.onLoggedIn); + Evennia.emitter.on("text", plugin_handler.onText); + Evennia.emitter.on("webclient_options", plugin_handler.onGotOptions); + Evennia.emitter.on("prompt", plugin_handler.onPrompt); + Evennia.emitter.on("default", plugin_handler.onDefault); + Evennia.emitter.on("connection_close", plugin_handler.onConnectionClose); + + // silence currently unused events + Evennia.emitter.on("connection_open", plugin_handler.onSilence); + Evennia.emitter.on("connection_error", plugin_handler.onSilence); + // Event when closing window (have to have Evennia initialized) - $(window).bind("beforeunload", onBeforeUnload); + $(window).bind("beforeunload", plugin_handler.onBeforeUnload); $(window).bind("unload", Evennia.connection.close); - doWindowResize(); + // Event when any key is pressed + $(document).keydown(plugin_handler.onKeydown) + // set an idle timer to send idle every 3 minutes, // to avoid proxy servers timing out on us - setInterval(function() { - // Connect to server - Evennia.msg("text", ["idle"], {}); - }, - 60000*3 + setInterval( function() { // Connect to server + Evennia.msg("text", ["idle"], {}); + }, + 60000*3 ); - console.log("Completed GUI setup"); + // Initialize all plugins + plugin_handler.init(); + console.log("Completed Webclient setup"); }); - -})(); diff --git a/evennia/web/webclient/templates/webclient/base.html b/evennia/web/webclient/templates/webclient/base.html index a5c65fad2c..1b506c8cd5 100644 --- a/evennia/web/webclient/templates/webclient/base.html +++ b/evennia/web/webclient/templates/webclient/base.html @@ -63,15 +63,21 @@ JQuery available. - - {% block guilib_import %} + + + + + + + + {% endblock %} diff --git a/evennia/web/webclient/templates/webclient/webclient.html b/evennia/web/webclient/templates/webclient/webclient.html index 74bef631cf..e2aed49ecc 100644 --- a/evennia/web/webclient/templates/webclient/webclient.html +++ b/evennia/web/webclient/templates/webclient/webclient.html @@ -6,67 +6,19 @@ - guilib_import - for using your own gui lib --> - {% block client %} -
- - - - -
- + +
+ +
- -
- - -
-
Split Pane×
-
-
-
-
-
- -
-
Options×
-
-
-

Output display

-
-
-
-

Notifications

-
-
-
-
-
- -
-
Help×
-
-
-
-
-
- - - - - - +
{% endblock %} {% block scripts %} From efab90b6b06a165a6feb8cd8a1cbb11edf48f2e2 Mon Sep 17 00:00:00 2001 From: friarzen Date: Sat, 29 Sep 2018 17:29:56 +0000 Subject: [PATCH 441/466] Add hotbuttons as a plugin contrib --- .../static/webclient/js/plugins/hotbuttons.js | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 evennia/web/webclient/static/webclient/js/plugins/hotbuttons.js diff --git a/evennia/web/webclient/static/webclient/js/plugins/hotbuttons.js b/evennia/web/webclient/static/webclient/js/plugins/hotbuttons.js new file mode 100644 index 0000000000..e3d2ea8a7a --- /dev/null +++ b/evennia/web/webclient/static/webclient/js/plugins/hotbuttons.js @@ -0,0 +1,154 @@ +/* + * + * Assignable 'hot-buttons' Plugin + * + * This adds a bar of 9 buttons that can be shift-click assigned whatever is in the textinput buffer, so you can simply + * click the button again and have it execute those commands, instead of having to type it all out again and again. + * + * It stores these commands as server side options. + * + * NOTE: This is a CONTRIB. To use this in your game: + * + * Stop Evennia + * + * Copy this file to mygame/web/static_overrides/webclient/js/plugins/hotbuttons.js + * Copy evennia/web/webclient/templates/webclient/base.html to mygame/web/template_overrides/webclient/base.html + * + * Edit mygame/web/template_overrides/webclient/base.html to add: + * + * after the other plugin tags. + * + * Run: evennia collectstatic (say 'yes' to the overwrite prompt) + * Start Evennia + */ +plugin_handler.add('hotbuttons', (function () { + + var num_buttons = 9; + var command_cache = new Array(num_buttons); + + // + // Add Buttons + var addButtonsUI = function () { + var buttons = $( [ + '
', + '
', + '
', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '
', + '
', + '
', + ].join("\n") ); + + // Add buttons in front of the existing #inputform + buttons.insertBefore('#inputform'); + $('#inputform').addClass('split split-vertical'); + + Split(['#buttons','#inputform'], { + direction: 'vertical', + sizes: [50,50], + gutterSize: 4, + minSize: 150, + }); + } + + // + // collect command text + var assignButton = function(n, text) { // n is 1-based + // make sure text has something in it + if( text && text.length ) { + // cache the command text + command_cache[n] = text; + + // is there a space in the command, indicating "command argument" syntax? + if( text.indexOf(" ") > 0 ) { + // use the first word as the text on the button + $("#assign_button"+n).text( text.slice(0, text.indexOf(" ")) ); + } else { + // use the single-word-text on the button + $("#assign_button"+n).text( text ); + } + } + } + + // + // Shift click a button to clear it + var clearButton = function(n) { + // change button text to "unassigned" + $("#assign_button"+n).text( "unassigned" ); + // clear current command + command_cache[n] = "unassigned"; + } + + // + // actually send the command associated with the button that is clicked + var sendImmediate = function(n) { + var text = command_cache[n]; + if( text.length ) { + Evennia.msg("text", [text], {}); + } + } + + // + // send, assign, or clear the button + var hotButtonClicked = function(e) { + var button = $("#assign_button"+e.data); + console.log("button " + e.data + " clicked"); + if( button.text() == "unassigned" ) { + // Assign the button and send the full button state to the server using a Webclient_Options event + assignButton( e.data, $('#inputfield').val() ); + Evennia.msg("webclient_options", [], { "HotButtons": command_cache }); + } else { + if( e.shiftKey ) { + // Clear the button and send the full button state to the server using a Webclient_Options event + clearButton(e.data); + Evennia.msg("webclient_options", [], { "HotButtons": command_cache }); + } else { + sendImmediate(e.data); + } + } + } + + // Public + + // + // Handle the HotButtons part of a Webclient_Options event + var onGotOptions = function(args, kwargs) { + console.log( args ); + console.log( kwargs ); + if( kwargs['HotButtons'] ) { + var buttons = kwargs['HotButtons']; + $.each( buttons, function( key, value ) { + assignButton(key, value); + }); + } + } + + // + // Initialize me + var init = function() { + + // Add buttons to the UI + addButtonsUI(); + + // assign button cache + for( var n=0; n Date: Sat, 29 Sep 2018 20:19:54 +0000 Subject: [PATCH 442/466] Add a systemd unit file using the latest "foreground portal" options --- bin/unix/evennia.service | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 bin/unix/evennia.service diff --git a/bin/unix/evennia.service b/bin/unix/evennia.service new file mode 100644 index 0000000000..a312bb8b4e --- /dev/null +++ b/bin/unix/evennia.service @@ -0,0 +1,34 @@ +# Evennia systemd unit script +# +# Copy this to /usr/lib/systemd/system/ and Edit the paths to match your game. +# +# Then, register with systemd using: +# +# sudo systemctl daemon-reload +# sudo systemctl enable evennia.service +# + +[Unit] +Description=Evennia Server + +[Service] +Type=simple + +# +# Change this to the user the game should run as. +# Don't run this as root. Please, I beg you. +# +User=your-user + +# +# The command to start Evennia as a Systemd service. NOTE: These must be absolute paths. +# Replace /your/path/to with whatever is appropriate. +# +ExecStart=/your/path/to/pyenv/bin/python /your/path/to/evennia/bin/unix/evennia ipstart --gamedir /your/path/to/mygame + +# restart on all failures, wait 3 seconds before doing so. +Restart=on-failure +RestartSec=3 + + [Install] +WantedBy=default.target From fedc08c04388fa97661c462ae6b629e03c8607f6 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 30 Sep 2018 13:06:36 +0200 Subject: [PATCH 443/466] Cleanup of changelog --- CHANGELOG.md | 13 +++++++++---- evennia/server/evennia_launcher.py | 4 ++-- evennia/utils/evmenu.py | 7 +++++-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1463f67537..afe5ad1ba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,15 +8,17 @@ with different functionality). - Both Portal/Server are now stand-alone processes (easy to run as daemon) - Made Portal the AMP Server for starting/restarting the Server (the AMP client) -- Dynamic logging now happens using `evennia -l` rather than by interactive. +- Dynamic logging now happens using `evennia -l` rather than by interactive mode. - Made AMP secure against erroneous HTTP requests on the wrong port (return error messages). - The `evennia istart` option will start/switch the Server in foreground (interactive) mode, where it logs to terminal and can be stopped with Ctrl-C. Using `evennia reload`, or reloading in-game, will return Server to normal daemon operation. - For validating passwords, use safe Django password-validation backend instead of custom Evennia one. +- Alias `evennia restart` to mean the same as `evennia reload`. ### Prototype changes +- New OLC started from `olc` command for loading/saving/manipulating prototypes in a menu. - Moved evennia/utils/spawner.py into the new evennia/prototypes/ along with all new functionality around prototypes. - A new form of prototype - database-stored prototypes, editable from in-game, was added. The old, @@ -35,8 +37,6 @@ - The spawn command was extended to accept a full prototype on one line. - The spawn command got the /save switch to save the defined prototype and its key - The command spawn/menu will now start an OLC (OnLine Creation) menu to load/save/edit/spawn prototypes. -- The OLC allows for updating all objects previously created using a given prototype with any - changes done. ### EvMenu @@ -52,7 +52,11 @@ ### Webclient -- Refactoring of webclient structure. +- Webclient now uses a plugin system to inject new components from the html file. +- Split-windows - divide input field into any number of horizontal/vertical panes and + assign different types of server messages to them. +- Lots of cleanup and bug fixes. +- Hot buttons plugin (friarzen) (disabled by default). ### Locks @@ -91,6 +95,7 @@ ### Contribs +- `Auditing` (Johnny): Log and filter server input/output for security purposes - `Build Menu` (vincent-lg): New @edit command to edit object properties in a menu. - `Field Fill` (Tim Ashley Jenkins): Wraps EvMenu for creating submittable forms. - `Health Bar` (Tim Ashley Jenkins): Easily create colorful bars/meters. diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 6b41f16d4a..779a1e4aa2 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -2014,7 +2014,7 @@ def main(): # launch menu for operation init_game_directory(CURRENT_DIR, check_db=True) run_menu() - elif option in ('status', 'info', 'start', 'istart', 'ipstart', 'reload', 'reboot', + elif option in ('status', 'info', 'start', 'istart', 'ipstart', 'reload', 'restart', 'reboot', 'reset', 'stop', 'sstop', 'kill', 'skill'): # operate the server directly if not SERVER_LOGFILE: @@ -2029,7 +2029,7 @@ def main(): start_server_interactive() elif option == "ipstart": start_portal_interactive() - elif option == 'reload': + elif option in ('reload', 'restart'): reload_evennia(args.profiler) elif option == 'reboot': reboot_evennia(args.profiler, args.profiler) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index f82dc5cb1f..e4a5e4c5e0 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -731,11 +731,14 @@ class EvMenu(object): Args: nodename (str or callable): Name of node or a callable - to be called as `function(caller, raw_string)` or `function(caller)` - to return the actual goto string. + to be called as `function(caller, raw_string, **kwargs)` or + `function(caller, **kwargs)` to return the actual goto string or + a ("nodename", kwargs) tuple. raw_string (str): The raw default string entered on the previous node (only used if the node accepts it as an argument) + Kwargs: + any: Extra arguments to goto callables. """ From 0097f24576e95f0ce41f50fba314c93f96ae9b81 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 30 Sep 2018 14:24:44 +0200 Subject: [PATCH 444/466] Fix to tutorial_world's locks --- evennia/contrib/tutorial_world/build.ev | 6 +++--- evennia/contrib/tutorial_world/rooms.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/evennia/contrib/tutorial_world/build.ev b/evennia/contrib/tutorial_world/build.ev index 682171f66c..1184865004 100644 --- a/evennia/contrib/tutorial_world/build.ev +++ b/evennia/contrib/tutorial_world/build.ev @@ -749,9 +749,9 @@ hole to the ground together with the stone archway that once help it up. # # We lock the bridge exit for the mob, so it don't wander out on the bridge. Only -# traversing objects controlled by a player (i.e. Characters) may cross the bridge. +# traversing objects controlled by an account (i.e. Characters) may cross the bridge. # -@lock bridge = traverse:has_player() +@lock bridge = traverse:has_account() #------------------------------------------------------------ # @@ -997,7 +997,7 @@ mobon ghost The stairs are worn by the age-old passage of feet. # # Lock the antechamber so the ghost cannot get in there. -@lock stairs down = traverse:has_player() +@lock stairs down = traverse:has_account() # # Go down # diff --git a/evennia/contrib/tutorial_world/rooms.py b/evennia/contrib/tutorial_world/rooms.py index 15bddefb01..780f774af7 100644 --- a/evennia/contrib/tutorial_world/rooms.py +++ b/evennia/contrib/tutorial_world/rooms.py @@ -747,7 +747,7 @@ class CmdLookDark(Command): """ caller = self.caller - if random.random() < 0.8: + if random.random() < 0.75: # we don't find anything caller.msg(random.choice(DARK_MESSAGES)) else: From 984141daa4b5152bdd76be480296307cba67302a Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 30 Sep 2018 15:01:16 +0200 Subject: [PATCH 445/466] Up version to 0.8 --- evennia/VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/VERSION.txt b/evennia/VERSION.txt index f8d71478f5..a3df0a6959 100644 --- a/evennia/VERSION.txt +++ b/evennia/VERSION.txt @@ -1 +1 @@ -0.8.0-dev +0.8.0 From 1113fa0b3725ae0525fbc02f117151e7172b3231 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 30 Sep 2018 17:24:26 +0200 Subject: [PATCH 446/466] Add rest/all type for panes --- .../webclient/js/plugins/splithandler.js | 81 ++++++++++++++----- 1 file changed, 59 insertions(+), 22 deletions(-) diff --git a/evennia/web/webclient/static/webclient/js/plugins/splithandler.js b/evennia/web/webclient/static/webclient/js/plugins/splithandler.js index b55dea7696..0df944bd19 100644 --- a/evennia/web/webclient/static/webclient/js/plugins/splithandler.js +++ b/evennia/web/webclient/static/webclient/js/plugins/splithandler.js @@ -7,9 +7,9 @@ let splithandler_plugin = (function () { var num_splits = 0; var split_panes = {}; - var backout_list = new Array; + var backout_list = []; - var known_types = new Array(); + var known_types = ['all', 'rest']; // Exported Functions @@ -283,40 +283,77 @@ let splithandler_plugin = (function () { plugins['popups'].closePopup("#splitdialog"); } + // + // helper function sending text to a pane + var txtToPane = function (panekey, txt) { + var pane = split_panes[panekey]; + var text_div = $('#' + panekey + '-sub'); + + if ( pane['update_method'] == 'replace' ) { + text_div.html(txt) + } else { + text_div.append(txt); + var scrollHeight = text_div.parent().prop("scrollHeight"); + text_div.parent().animate({ scrollTop: scrollHeight }, 0); + } + } + // // plugin functions // + // // Accept plugin onText events var onText = function (args, kwargs) { + + // If the message is not itself tagged, we'll assume it + // should go into any panes with 'all' or 'rest' set + var msgtype = "rest"; + if ( kwargs && 'type' in kwargs ) { - var msgtype = kwargs['type']; + msgtype = kwargs['type']; if ( ! known_types.includes(msgtype) ) { // this is a new output type that can be mapped to panes console.log('detected new output type: ' + msgtype) known_types.push(msgtype); } - - for ( var key in split_panes) { - var pane = split_panes[key]; - - // is this message type mapped to this pane? - if ( (pane['types'].length > 0) && pane['types'].includes(msgtype) ) { - // yes, so append/replace this pane's inner div with this message - var text_div = $('#'+key+'-sub'); - if ( pane['update_method'] == 'replace' ) { - text_div.html(args[0]) - } else { - text_div.append(args[0]); - var scrollHeight = text_div.parent().prop("scrollHeight"); - text_div.parent().animate({ scrollTop: scrollHeight }, 0); - } - return true; - } - } - } + } + var target_panes = []; + var rest_panes = []; + + for (var key in split_panes) { + var pane = split_panes[key]; + // is this message type mapped to this pane (or does the pane has an 'all' type)? + if (pane['types'].length > 0) { + if (pane['types'].includes(msgtype) || pane['types'].includes('all')) { + target_panes.push(key); + } else if (pane['types'].includes('rest')) { + // store rest-panes in case we have no explicit to send to + rest_panes.push(key); + } + } else { + // unassigned panes are assumed to be rest-panes too + rest_panes.push(key); + } + } + var ntargets = target_panes.length; + var nrests = rest_panes.length; + if (ntargets > 0) { + // we have explicit target panes to send to + for (var i=0; i 0) { + // no targets, send remainder to rest-panes/unassigned + for (var i=0; i Date: Sun, 30 Sep 2018 20:41:14 +0200 Subject: [PATCH 447/466] Cleanup of CSS and some functionality for webclient --- .../static/webclient/css/webclient.css | 8 ++- .../static/webclient/js/plugins/options.js | 2 - .../webclient/js/plugins/splithandler.js | 72 ++++++++++--------- 3 files changed, 45 insertions(+), 37 deletions(-) diff --git a/evennia/web/webclient/static/webclient/css/webclient.css b/evennia/web/webclient/static/webclient/css/webclient.css index 94ac7a0d8d..00281d82d4 100644 --- a/evennia/web/webclient/static/webclient/css/webclient.css +++ b/evennia/web/webclient/static/webclient/css/webclient.css @@ -225,7 +225,8 @@ div {margin:0px;} z-index: 10; background-color: #fefefe; border: 1px solid #888; - color: black; + color: lightgray; + background-color: #2c2c2c; } @@ -263,11 +264,12 @@ div {margin:0px;} cursor: move; font-weight: bold; font-size: 16px; - background-color: #d9d9d9; + color: white; + background-color: #595959; } .dialogclose { - color: #aaa; + color: #d5d5d5; float: right; font-size: 28px; font-weight: bold; diff --git a/evennia/web/webclient/static/webclient/js/plugins/options.js b/evennia/web/webclient/static/webclient/js/plugins/options.js index d45c0c76bc..114cd6cf0e 100644 --- a/evennia/web/webclient/static/webclient/js/plugins/options.js +++ b/evennia/web/webclient/static/webclient/js/plugins/options.js @@ -10,13 +10,11 @@ let options_plugin = (function () { // addOptionsUI var addOptionsUI = function () { var content = [ // TODO dynamically create this based on the options{} hash - '

Output display

', '', '
', '', '
', '
', - '

Notifications

', '', '
', '', diff --git a/evennia/web/webclient/static/webclient/js/plugins/splithandler.js b/evennia/web/webclient/static/webclient/js/plugins/splithandler.js index 0df944bd19..21517a620e 100644 --- a/evennia/web/webclient/static/webclient/js/plugins/splithandler.js +++ b/evennia/web/webclient/static/webclient/js/plugins/splithandler.js @@ -30,11 +30,11 @@ let splithandler_plugin = (function () { } function addSplitDialog () { - plugins['popups'].createDialog('splitdialog', 'Split Dialog', ''); + plugins['popups'].createDialog('splitdialog', 'Split Pane', ''); } function addPaneDialog () { - plugins['popups'].createDialog('panedialog', 'Pane Dialog', ''); + plugins['popups'].createDialog('panedialog', 'Assign Pane Options', ''); } // @@ -183,28 +183,27 @@ let splithandler_plugin = (function () { var dialog = $("#splitdialogcontent"); dialog.empty(); - dialog.append("

Split?

"); - dialog.append(' top/bottom
'); - dialog.append(' side-by-side
'); - - dialog.append("

Split Which Pane?

"); + var selection = ''+ pane +'
'); + selection = selection + ''; } + selection = "Pane to split: " + selection + " "; + dialog.append(selection); - dialog.append("

New Pane Names

"); - dialog.append(''); - dialog.append(''); + dialog.append(' top/bottom '); + dialog.append(' side-by-side
'); - dialog.append("

New First Pane Flow

"); - dialog.append('append
'); - dialog.append('replace
'); + dialog.append('Pane 1: '); + dialog.append('line feed '); + dialog.append('append '); + dialog.append('replace content
'); - dialog.append("

New Second Pane Flow

"); - dialog.append('append
'); - dialog.append('replace
'); + dialog.append('Pane 2: '); + dialog.append('line feed '); + dialog.append('append '); + dialog.append('replace content
'); - dialog.append('
Split It
'); + dialog.append('
Split
'); $("#splitclose").bind("click", onSplitDialogClose); @@ -214,7 +213,7 @@ let splithandler_plugin = (function () { // // Close "Split Controls" Dialog var onSplitDialogClose = function () { - var pane = $("input[name=pane]:checked").attr("value"); + var pane = $("select[name=pane]").val(); var direction = $("input[name=direction]:checked").attr("value"); var new_pane1 = $("input[name=new_pane1]").val(); var new_pane2 = $("input[name=new_pane2]").val(); @@ -249,39 +248,44 @@ let splithandler_plugin = (function () { // // Draw "Pane Controls" dialog var onPaneControlDialog = function () { - var dialog = $("#splitdialogcontent"); + var dialog = $("#panedialogcontent"); dialog.empty(); - dialog.append("

Set Which Pane?

"); + var selection = ''+ pane +'
'); + selection = selection + ''; } + selection = "Assign to pane: " + selection + "
"; + dialog.append(selection); - dialog.append("

Which content types?

"); + var multiple = ''+ known_types[type] +'
'); + multiple = multiple + ''; } + multiple = "Content types: " + multiple + "
"; + dialog.append(multiple); - dialog.append('
Make It So
'); + dialog.append('
Assign
'); $("#paneclose").bind("click", onPaneControlDialogClose); - plugins['popups'].togglePopup("#splitdialog"); + plugins['popups'].togglePopup("#panedialog"); } // // Close "Pane Controls" dialog var onPaneControlDialogClose = function () { - var pane = $("input[name=pane]:checked").attr("value"); + var pane = $("select[name=assign-pane]").val(); + var types = $("select[name=assign-type]").val(); - var types = new Array; - $('#splitdialogcontent input[type=checkbox]:checked').each(function() { - types.push( $(this).attr('value') ); - }); + // var types = new Array; + // $('#splitdialogcontent input[type=checkbox]:checked').each(function() { + // types.push( $(this).attr('value') ); + // }); set_pane_types( pane, types ); - plugins['popups'].closePopup("#splitdialog"); + plugins['popups'].closePopup("#panedialog"); } // // helper function sending text to a pane @@ -291,6 +295,10 @@ let splithandler_plugin = (function () { if ( pane['update_method'] == 'replace' ) { text_div.html(txt) + } else if ( pane['update_method'] == 'linefeed' ) { + text_div.append("
" + txt + "
"); + var scrollHeight = text_div.parent().prop("scrollHeight"); + text_div.parent().animate({ scrollTop: scrollHeight }, 0); } else { text_div.append(txt); var scrollHeight = text_div.parent().prop("scrollHeight"); From 9553acecb204194f32b05e02570614bc111b5626 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 30 Sep 2018 21:08:24 +0200 Subject: [PATCH 448/466] Run collectstatic. Add webclient states --- .../webclient/js/plugins/splithandler.js | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/evennia/web/webclient/static/webclient/js/plugins/splithandler.js b/evennia/web/webclient/static/webclient/js/plugins/splithandler.js index 21517a620e..4667065856 100644 --- a/evennia/web/webclient/static/webclient/js/plugins/splithandler.js +++ b/evennia/web/webclient/static/webclient/js/plugins/splithandler.js @@ -190,18 +190,18 @@ let splithandler_plugin = (function () { selection = "Pane to split: " + selection + " "; dialog.append(selection); - dialog.append(' top/bottom '); - dialog.append(' side-by-side
'); + dialog.append('top/bottom '); + dialog.append('side-by-side
'); dialog.append('Pane 1: '); - dialog.append('line feed '); - dialog.append('append '); - dialog.append('replace content
'); + dialog.append('newlines '); + dialog.append('replace '); + dialog.append('append
'); dialog.append('Pane 2: '); - dialog.append('line feed '); - dialog.append('append '); - dialog.append('replace content
'); + dialog.append('newlines '); + dialog.append('replace '); + dialog.append('append
'); dialog.append('
Split
'); @@ -295,15 +295,16 @@ let splithandler_plugin = (function () { if ( pane['update_method'] == 'replace' ) { text_div.html(txt) - } else if ( pane['update_method'] == 'linefeed' ) { - text_div.append("
" + txt + "
"); - var scrollHeight = text_div.parent().prop("scrollHeight"); - text_div.parent().animate({ scrollTop: scrollHeight }, 0); - } else { + } else if ( pane['update_method'] == 'append' ) { text_div.append(txt); var scrollHeight = text_div.parent().prop("scrollHeight"); text_div.parent().animate({ scrollTop: scrollHeight }, 0); + } else { // line feed + text_div.append("
" + txt + "
"); + var scrollHeight = text_div.parent().prop("scrollHeight"); + text_div.parent().animate({ scrollTop: scrollHeight }, 0); } + } @@ -377,7 +378,7 @@ let splithandler_plugin = (function () { minSize: [50,50], }); - split_panes['main'] = { 'types': [], 'update_method': 'append' }; + split_panes['main'] = { 'types': [], 'update_method': 'linefeed' }; // Create our UI addToolbarButtons(); From 70c5e9608ebad38905fe3c4a2fef09fee673c207 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 1 Oct 2018 18:29:21 +0200 Subject: [PATCH 449/466] Run collectstatic. Fix input autofocus in webclient --- evennia/web/webclient/static/webclient/js/plugins/default_in.js | 1 + 1 file changed, 1 insertion(+) diff --git a/evennia/web/webclient/static/webclient/js/plugins/default_in.js b/evennia/web/webclient/static/webclient/js/plugins/default_in.js index 28bfc9f315..02fd401706 100644 --- a/evennia/web/webclient/static/webclient/js/plugins/default_in.js +++ b/evennia/web/webclient/static/webclient/js/plugins/default_in.js @@ -8,6 +8,7 @@ let defaultin_plugin = (function () { // // handle the default key triggering onSend() var onKeydown = function (event) { + $("#inputfield").focus(); if ( (event.which === 13) && (!event.shiftKey) ) { // Enter Key without shift var inputfield = $("#inputfield"); var outtext = inputfield.val(); From 52fb3674dc51be83000e3e6a7cf6972b0904917e Mon Sep 17 00:00:00 2001 From: Brenden Tuck Date: Thu, 4 Oct 2018 19:59:30 -0400 Subject: [PATCH 450/466] Fix #1668 - up arrow key regression --- .../static/webclient/js/plugins/history.js | 30 ++++--------------- 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/evennia/web/webclient/static/webclient/js/plugins/history.js b/evennia/web/webclient/static/webclient/js/plugins/history.js index 1bef6031cd..c33dbcabf9 100644 --- a/evennia/web/webclient/static/webclient/js/plugins/history.js +++ b/evennia/web/webclient/static/webclient/js/plugins/history.js @@ -43,14 +43,6 @@ let history_plugin = (function () { history_pos = 0; } - // - // Go to the last history line - var end = function () { - // move to the end of the history stack - history_pos = 0; - return history[history.length -1]; - } - // // Add input to the scratch line var scratch = function (input) { @@ -69,28 +61,17 @@ let history_plugin = (function () { var history_entry = null; var inputfield = $("#inputfield"); - if (inputfield[0].selectionStart == inputfield.val().length) { - // Only process up/down arrow if cursor is at the end of the line. - if (code === 38) { // Arrow up - history_entry = back(); - } - else if (code === 40) { // Arrow down - history_entry = fwd(); - } + if (code === 38) { // Arrow up + history_entry = back(); + } + else if (code === 40) { // Arrow down + history_entry = fwd(); } if (history_entry !== null) { // Doing a history navigation; replace the text in the input. inputfield.val(history_entry); } - else { - // Save the current contents of the input to the history scratch area. - setTimeout(function () { - // Need to wait until after the key-up to capture the value. - scratch(inputfield.val()); - end(); - }, 0); - } return false; } @@ -99,6 +80,7 @@ let history_plugin = (function () { // Listen for onSend lines to add to history var onSend = function (line) { add(line); + return null; // we are not returning an altered input line } // From 7133492630936604c53ed6089da4fdbdc9a4030b Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 6 Oct 2018 19:00:54 +0200 Subject: [PATCH 451/466] Be more lenient with spawning old, more free-form prototypes --- evennia/commands/default/building.py | 3 +- evennia/prototypes/prototypes.py | 51 ++++++++++++++++++++++------ evennia/prototypes/spawner.py | 3 ++ 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index bd4fb5e188..f0ae108f00 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2889,7 +2889,8 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): "use the 'exec' prototype key.") return None try: - protlib.validate_prototype(prototype) + # we homogenize first, to be more lenient + protlib.validate_prototype(protlib.homogenize_prototype(prototype)) except RuntimeError as err: self.caller.msg(str(err)) return diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 67eccaafd0..a03cbc519f 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -6,6 +6,8 @@ Handling storage of prototypes, both database-based ones (DBPrototypes) and thos """ import re +import hashlib +import time from ast import literal_eval from django.conf import settings from evennia.scripts.scripts import DefaultScript @@ -13,7 +15,7 @@ from evennia.objects.models import ObjectDB from evennia.utils.create import create_script from evennia.utils.utils import ( all_from_module, make_iter, is_iter, dbid_to_obj, callables_from_module, - get_all_typeclasses, to_str, dbref, justify) + get_all_typeclasses, to_str, dbref, justify, class_from_module) from evennia.locks.lockhandler import validate_lockstring, check_lockstring from evennia.utils import logger from evennia.utils import inlinefuncs, dbserialize @@ -47,8 +49,8 @@ class ValidationError(RuntimeError): def homogenize_prototype(prototype, custom_keys=None): """ - Homogenize the more free-form prototype (where undefined keys are non-category attributes) - into the stricter form using `attrs` required by the system. + Homogenize the more free-form prototype supported pre Evennia 0.7 into the stricter form. + Args: prototype (dict): Prototype. @@ -56,18 +58,45 @@ def homogenize_prototype(prototype, custom_keys=None): the default reserved keys. Returns: - homogenized (dict): Prototype where all non-identified keys grouped as attributes. + homogenized (dict): Prototype where all non-identified keys grouped as attributes and other + homogenizations like adding missing prototype_keys and setting a default typeclass. + """ reserved = _PROTOTYPE_RESERVED_KEYS + (custom_keys or ()) + attrs = list(prototype.get('attrs', [])) # break reference + tags = make_iter(prototype.get('tags', [])) + homogenized_tags = [] + homogenized = {} for key, val in prototype.items(): if key in reserved: - homogenized[key] = val + if key == 'tags': + for tag in tags: + if not is_iter(tag): + homogenized_tags.append((tag, None, None)) + else: + homogenized_tags.append(tag) + else: + homogenized[key] = val else: + # unassigned keys -> attrs attrs.append((key, val, None, '')) if attrs: homogenized['attrs'] = attrs + if homogenized_tags: + homogenized['tags'] = homogenized_tags + + # add required missing parts that had defaults before + + if "prototype_key" not in prototype: + # assign a random hash as key + homogenized["prototype_key"] = "prototype-{}".format( + hashlib.md5(str(time.time())).hexdigest()[:7]) + + if "typeclass" not in prototype: + homogenized["typeclass"] = settings.BASE_OBJECT_TYPECLASS + return homogenized @@ -432,11 +461,13 @@ def validate_prototype(prototype, protkey=None, protparents=None, _flags['warnings'].append("Prototype {} can only be used as a mixin since it lacks " "a typeclass or a prototype_parent.".format(protkey)) - if (strict and typeclass and typeclass not - in get_all_typeclasses("evennia.objects.models.ObjectDB")): - _flags['errors'].append( - "Prototype {} is based on typeclass {}, which could not be imported!".format( - protkey, typeclass)) + if strict and typeclass: + try: + class_from_module(typeclass) + except ImportError as err: + _flags['errors'].append( + "{}: Prototype {} is based on typeclass {}, which could not be imported!".format( + err, protkey, typeclass)) # recursively traverese prototype_parent chain diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 7d876cf580..1ca7229bea 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -659,6 +659,9 @@ def spawn(*prototypes, **kwargs): # get available protparents protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()} + if not kwargs.get("only_validate"): + prototypes = [protlib.homogenize_prototype(prot) for prot in prototypes] + # overload module's protparents with specifically given protparents # we allow prototype_key to be the key of the protparent dict, to allow for module-level # prototype imports. We need to insert prototype_key in this case From b5c6a483ac70ccb25d0d046fc1602f8f89eeb273 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 6 Oct 2018 19:05:57 +0200 Subject: [PATCH 452/466] Fix bug in spawning with attributes --- evennia/prototypes/spawner.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 1ca7229bea..b22770aa94 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -733,8 +733,9 @@ def spawn(*prototypes, **kwargs): val = make_iter(prot.pop("attrs", [])) attributes = [] for (attrname, value, category, locks) in val: - attributes.append((attrname, init_spawn_value(val), category, locks)) + attributes.append((attrname, init_spawn_value(value), category, locks)) + print("attributes to spawn: IN: {}, OUT: {}".format(val, attributes)) simple_attributes = [] for key, value in ((key, value) for key, value in prot.items() if not (key.startswith("ndb_"))): From 8b1ab0bc8594513732fa5be0bd0431f1ad43c1ad Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 6 Oct 2018 19:06:44 +0200 Subject: [PATCH 453/466] Remove debug info --- evennia/prototypes/spawner.py | 1 - 1 file changed, 1 deletion(-) diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index b22770aa94..ce08944139 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -735,7 +735,6 @@ def spawn(*prototypes, **kwargs): for (attrname, value, category, locks) in val: attributes.append((attrname, init_spawn_value(value), category, locks)) - print("attributes to spawn: IN: {}, OUT: {}".format(val, attributes)) simple_attributes = [] for key, value in ((key, value) for key, value in prot.items() if not (key.startswith("ndb_"))): From 550a25820d55095fa6e9179955fa53f2f28d2fb7 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 6 Oct 2018 21:00:31 +0200 Subject: [PATCH 454/466] Fix unittests. Implement #1675. --- evennia/prototypes/prototypes.py | 2 +- evennia/prototypes/tests.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index a03cbc519f..550f1b2e7b 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -94,7 +94,7 @@ def homogenize_prototype(prototype, custom_keys=None): homogenized["prototype_key"] = "prototype-{}".format( hashlib.md5(str(time.time())).hexdigest()[:7]) - if "typeclass" not in prototype: + if "typeclass" not in prototype and "prototype_parent" not in prototype: homogenized["typeclass"] = settings.BASE_OBJECT_TYPECLASS return homogenized diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 1ad1d9ac47..3cf2e38c7b 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -384,8 +384,9 @@ class TestPrototypeStorage(EvenniaTest): prot3 = protlib.create_prototype(**self.prot3) # partial match - self.assertEqual(list(protlib.search_prototype("prot")), [prot1b, prot2, prot3]) - self.assertEqual(list(protlib.search_prototype(tags="foo1")), [prot1b, prot2, prot3]) + with mock.patch("evennia.prototypes.prototypes._MODULE_PROTOTYPES", {}): + self.assertEqual(list(protlib.search_prototype("prot")), [prot1b, prot2, prot3]) + self.assertEqual(list(protlib.search_prototype(tags="foo1")), [prot1b, prot2, prot3]) self.assertTrue(str(unicode(protlib.list_prototypes(self.char1)))) From 68ff0ac9d63d23766abb19259454d721d95d5ed1 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 7 Oct 2018 10:50:35 +0200 Subject: [PATCH 455/466] Clarify prototype_key replacement in modules; address #1676. --- evennia/prototypes/prototypes.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 550f1b2e7b..eac53a6504 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -105,9 +105,11 @@ def homogenize_prototype(prototype, custom_keys=None): for mod in settings.PROTOTYPE_MODULES: # to remove a default prototype, override it with an empty dict. # internally we store as (key, desc, locks, tags, prototype_dict) - prots = [(prototype_key.lower(), homogenize_prototype(prot)) - for prototype_key, prot in all_from_module(mod).items() - if prot and isinstance(prot, dict)] + prots = [] + for variable_name, prot in all_from_module(mod).items(): + if "prototype_key" not in prot: + prot['prototype_key'] = variable_name.lower() + prots.append((prot['prototype_key'], homogenize_prototype(prot))) # assign module path to each prototype_key for easy reference _MODULE_PROTOTYPE_MODULES.update({prototype_key.lower(): mod for prototype_key, _ in prots}) # make sure the prototype contains all meta info From fe14dfddef2cba06fc0eefe9067beb4708c152c9 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 7 Oct 2018 12:31:43 +0200 Subject: [PATCH 456/466] Fix bug in unittest that would cause occational name collision --- evennia/accounts/tests.py | 18 +++++++++++++++--- evennia/prototypes/spawner.py | 7 ++++--- evennia/typeclasses/attributes.py | 2 +- evennia/typeclasses/tags.py | 5 ++++- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index 2855dd0ca2..1175051d72 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -14,9 +14,15 @@ class TestAccountSessionHandler(TestCase): "Check AccountSessionHandler class" def setUp(self): - self.account = create.create_account("TestAccount%s" % randint(0, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount) + self.account = create.create_account( + "TestAccount%s" % randint(0, 999999), email="test@test.com", + password="testpassword", typeclass=DefaultAccount) self.handler = AccountSessionHandler(self.account) + def tearDown(self): + if hasattr(self, 'account'): + self.account.delete() + def test_get(self): "Check get method" self.assertEqual(self.handler.get(), []) @@ -60,6 +66,10 @@ class TestDefaultAccount(TestCase): self.s1.puppet = None self.s1.sessid = 0 + def tearDown(self): + if hasattr(self, "account"): + self.account.delete() + def test_password_validation(self): "Check password validators deny bad passwords" @@ -71,7 +81,6 @@ class TestDefaultAccount(TestCase): "Check validators allow sufficiently complex passwords" for better in ('Mxyzptlk', "j0hn, i'M 0n1y d4nc1nG"): self.assertTrue(self.account.validate_password(better, account=self.account)[0]) - self.account.delete() def test_password_change(self): "Check password setting and validation is working as expected" @@ -109,7 +118,9 @@ class TestDefaultAccount(TestCase): import evennia.server.sessionhandler - account = create.create_account("TestAccount%s" % randint(0, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount) + account = create.create_account( + "TestAccount%s" % randint(0, 999999), email="test@test.com", + password="testpassword", typeclass=DefaultAccount) self.s1.uid = account.uid evennia.server.sessionhandler.SESSIONS[self.s1.uid] = self.s1 @@ -171,6 +182,7 @@ class TestDefaultAccount(TestCase): import evennia.server.sessionhandler account = create.create_account("TestAccount%s" % randint(0, 999999), email="test@test.com", password="testpassword", typeclass=DefaultAccount) + self.account = account self.s1.uid = account.uid evennia.server.sessionhandler.SESSIONS[self.s1.uid] = self.s1 diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index ce08944139..ac6ad854b1 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -259,11 +259,11 @@ def prototype_from_object(obj): if aliases: prot['aliases'] = aliases tags = [(tag.db_key, tag.db_category, tag.db_data) - for tag in obj.tags.get(return_tagobj=True, return_list=True) if tag] + for tag in obj.tags.all(return_objs=True)] if tags: prot['tags'] = tags attrs = [(attr.key, attr.value, attr.category, ';'.join(attr.locks.all())) - for attr in obj.attributes.get(return_obj=True, return_list=True) if attr] + for attr in obj.attributes.all()] if attrs: prot['attrs'] = attrs @@ -660,6 +660,7 @@ def spawn(*prototypes, **kwargs): protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()} if not kwargs.get("only_validate"): + # homogenization to be more lenient about prototype format when entering the prototype manually prototypes = [protlib.homogenize_prototype(prot) for prot in prototypes] # overload module's protparents with specifically given protparents @@ -714,7 +715,7 @@ def spawn(*prototypes, **kwargs): val = prot.pop("tags", []) tags = [] - for (tag, category, data) in tags: + for (tag, category, data) in val: tags.append((init_spawn_value(val, str), category, data)) prototype_key = prototype.get('prototype_key', None) diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index 863628172a..1dc1902494 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -668,7 +668,7 @@ class AttributeHandler(object): def all(self, accessing_obj=None, default_access=True): """ - Return all Attribute objects on this object. + Return all Attribute objects on this object, regardless of category. Args: accessing_obj (object, optional): Check the `attrread` diff --git a/evennia/typeclasses/tags.py b/evennia/typeclasses/tags.py index 488dce0f85..ea675366fd 100644 --- a/evennia/typeclasses/tags.py +++ b/evennia/typeclasses/tags.py @@ -345,13 +345,14 @@ class TagHandler(object): self._catcache = {} self._cache_complete = False - def all(self, return_key_and_category=False): + def all(self, return_key_and_category=False, return_objs=False): """ Get all tags in this handler, regardless of category. Args: return_key_and_category (bool, optional): Return a list of tuples `[(key, category), ...]`. + return_objs (bool, optional): Return tag objects. Returns: tags (list): A list of tag keys `[tagkey, tagkey, ...]` or @@ -365,6 +366,8 @@ class TagHandler(object): if return_key_and_category: # return tuple (key, category) return [(to_str(tag.db_key), to_str(tag.db_category)) for tag in tags] + elif return_objs: + return tags else: return [to_str(tag.db_key) for tag in tags] From fdc4550e19902a6b7d162de170344a2da6f146f5 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 7 Oct 2018 14:31:36 +0200 Subject: [PATCH 457/466] Cleanup of account tests with more mocking --- evennia/accounts/tests.py | 29 +++++++++++++++-------------- evennia/prototypes/spawner.py | 1 + 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index 1175051d72..78ee87f37d 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -1,7 +1,8 @@ -from mock import Mock +from mock import Mock, MagicMock from random import randint from unittest import TestCase +from django.test import override_settings from evennia.accounts.accounts import AccountSessionHandler from evennia.accounts.accounts import DefaultAccount from evennia.server.session import Session @@ -30,24 +31,24 @@ class TestAccountSessionHandler(TestCase): import evennia.server.sessionhandler - s1 = Session() + s1 = MagicMock() s1.logged_in = True s1.uid = self.account.uid evennia.server.sessionhandler.SESSIONS[s1.uid] = s1 - s2 = Session() + s2 = MagicMock() s2.logged_in = True s2.uid = self.account.uid + 1 evennia.server.sessionhandler.SESSIONS[s2.uid] = s2 - s3 = Session() + s3 = MagicMock() s3.logged_in = False s3.uid = self.account.uid + 2 evennia.server.sessionhandler.SESSIONS[s3.uid] = s3 - self.assertEqual(self.handler.get(), [s1]) - self.assertEqual(self.handler.get(self.account.uid), [s1]) - self.assertEqual(self.handler.get(self.account.uid + 1), []) + self.assertEqual([s.uid for s in self.handler.get()], [s1.uid]) + self.assertEqual([s.uid for s in [self.handler.get(self.account.uid)]], [s1.uid]) + self.assertEqual([s.uid for s in self.handler.get(self.account.uid + 1)], []) def test_all(self): "Check all method" @@ -62,9 +63,10 @@ class TestDefaultAccount(TestCase): "Check DefaultAccount class" def setUp(self): - self.s1 = Session() + self.s1 = MagicMock() self.s1.puppet = None self.s1.sessid = 0 + self.s1.data_outj def tearDown(self): if hasattr(self, "account"): @@ -142,10 +144,7 @@ class TestDefaultAccount(TestCase): self.s1.uid = account.uid evennia.server.sessionhandler.SESSIONS[self.s1.uid] = self.s1 - self.s1.puppet = None - self.s1.logged_in = True - self.s1.data_out = Mock(return_value=None) - + self.s1.data_out = MagicMock() obj = Mock() obj.access = Mock(return_value=False) @@ -154,6 +153,7 @@ class TestDefaultAccount(TestCase): self.assertTrue(self.s1.data_out.call_args[1]['text'].startswith("You don't have permission to puppet")) self.assertIsNone(obj.at_post_puppet.call_args) + @override_settings(MULTISESSION_MODE=0) def test_puppet_object_joining_other_session(self): "Check puppet_object method called, joining other session" @@ -165,15 +165,16 @@ class TestDefaultAccount(TestCase): self.s1.puppet = None self.s1.logged_in = True - self.s1.data_out = Mock(return_value=None) + self.s1.data_out = MagicMock() obj = Mock() obj.access = Mock(return_value=True) obj.account = account + obj.sessions.all = MagicMock(return_value=[self.s1]) account.puppet_object(self.s1, obj) # works because django.conf.settings.MULTISESSION_MODE is not in (1, 3) - self.assertTrue(self.s1.data_out.call_args[1]['text'].endswith("from another of your sessions.")) + self.assertTrue(self.s1.data_out.call_args[1]['text'].endswith("from another of your sessions.|n")) self.assertTrue(obj.at_post_puppet.call_args[1] == {}) def test_puppet_object_already_puppeted(self): diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index ac6ad854b1..03bc6d2024 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -258,6 +258,7 @@ def prototype_from_object(obj): aliases = obj.aliases.get(return_list=True) if aliases: prot['aliases'] = aliases + from evennia import set_trace;set_trace() tags = [(tag.db_key, tag.db_category, tag.db_data) for tag in obj.tags.all(return_objs=True)] if tags: From a2e8b27ee4905d993623e0ff8e79b06a334e258f Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 7 Oct 2018 18:29:25 +0200 Subject: [PATCH 458/466] Update docker file to better handle starting without an existing game folder --- Dockerfile | 12 ++++++++++-- bin/unix/evennia-docker-start.sh | 16 ++++++++++++---- evennia/prototypes/spawner.py | 1 - 3 files changed, 22 insertions(+), 7 deletions(-) mode change 100644 => 100755 bin/unix/evennia-docker-start.sh diff --git a/Dockerfile b/Dockerfile index 381c83f925..961d3ad8ed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ # Usage: # 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 +# docker run -it --rm -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 # folder). @@ -15,6 +15,14 @@ # You will end up in a shell where the `evennia` command is available. From here you # can install and run the game normally. Use Ctrl-D to exit the evennia docker container. # +# You can also start evennia directly by passing arguments to the folder: +# +# docker run -it --rm -p 4000:4000 -p 4001:4001 -p 4005:4005 -v $PWD:/usr/src/game evennia/evennia evennia start -l +# +# This will start Evennia running as the core process of the container. Note that you *must* use -l +# or one of the foreground modes (like evennia ipstart) since otherwise the container will immediately +# die since no foreground process keeps it up. +# # The evennia/evennia base image is found on DockerHub and can also be used # as a base for creating your own custom containerized Evennia game. For more # info, see https://github.com/evennia/evennia/wiki/Running%20Evennia%20in%20Docker . @@ -58,7 +66,7 @@ WORKDIR /usr/src/game ENV PS1 "evennia|docker \w $ " # startup a shell when we start the container -ENTRYPOINT bash -c "source /usr/src/evennia/bin/unix/evennia-docker-start.sh" +ENTRYPOINT ["/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 old mode 100644 new mode 100755 index d1333aaef6..5c87052da9 --- a/bin/unix/evennia-docker-start.sh +++ b/bin/unix/evennia-docker-start.sh @@ -1,10 +1,18 @@ -#! /bin/bash +#! /bin/sh # 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 -l +PS1="evennia|docker \w $ " + +cmd="$@" +output="Docker starting with argument '$cmd' ..." +if test -z $cmd; then + cmd="bash" + output="No argument given, starting shell ..." +fi + +echo $output +exec 3>&1; $cmd diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 03bc6d2024..ac6ad854b1 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -258,7 +258,6 @@ def prototype_from_object(obj): aliases = obj.aliases.get(return_list=True) if aliases: prot['aliases'] = aliases - from evennia import set_trace;set_trace() tags = [(tag.db_key, tag.db_category, tag.db_data) for tag in obj.tags.all(return_objs=True)] if tags: From 14eea024bbfdc3c2e95962aa058c22d6e58fc234 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 7 Oct 2018 18:45:28 +0200 Subject: [PATCH 459/466] Correct tag handling in prototype; fix unittests --- evennia/prototypes/tests.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 3cf2e38c7b..411bd45c27 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -134,6 +134,7 @@ class TestUtils(EvenniaTest): 'prototype_key': Something, 'prototype_locks': 'spawn:all();edit:all()', 'prototype_tags': [], + 'tags': [(u'footag', u'foocategory', None)], 'typeclass': 'evennia.objects.objects.DefaultObject'}) self.assertEqual(old_prot, @@ -182,6 +183,7 @@ class TestUtils(EvenniaTest): 'typeclass': ('evennia.objects.objects.DefaultObject', 'evennia.objects.objects.DefaultObject', 'KEEP'), 'aliases': {'foo': ('foo', None, 'REMOVE')}, + 'tags': {u'footag': ((u'footag', u'foocategory', None), None, 'REMOVE')}, 'prototype_desc': ('Built from Obj', 'New version of prototype', 'UPDATE'), 'permissions': {"Builder": (None, 'Builder', 'ADD')} @@ -200,6 +202,7 @@ class TestUtils(EvenniaTest): 'prototype_key': 'UPDATE', 'prototype_locks': 'KEEP', 'prototype_tags': 'KEEP', + 'tags': 'REMOVE', 'typeclass': 'KEEP'} ) From 40eb691cd4dcdfe86d3e00c6208002401556da71 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 7 Oct 2018 21:17:40 +0200 Subject: [PATCH 460/466] Create hash password when creating irc bot. --- evennia/commands/default/comms.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index d9fe0b0d20..04fcceba46 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -7,6 +7,8 @@ make sure to homogenize self.caller to always be the account object for easy handling. """ +import hashlib +import time from past.builtins import cmp from django.conf import settings from evennia.comms.models import ChannelDB, Msg @@ -918,8 +920,9 @@ class CmdIRC2Chan(COMMAND_DEFAULT_CLASS): self.msg("Account '%s' already exists and is not a bot." % botname) return else: + password = hashlib.md5(str(time.time())).hexdigest()[:11] try: - bot = create.create_account(botname, None, None, typeclass=botclass) + bot = create.create_account(botname, None, password, typeclass=botclass) except Exception as err: self.msg("|rError, could not create the bot:|n '%s'." % err) return From c153a1d7e4c93513d9c072a8f3b08bf1229d854d Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 8 Oct 2018 18:20:35 +0200 Subject: [PATCH 461/466] Resolve bug when trying to examine self when unprivileged --- evennia/accounts/accounts.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 2c33e5c1f8..3eab69a3ce 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -1001,7 +1001,10 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): if target and not is_iter(target): # single target - just show it - return target.return_appearance(self) + if hasattr(target, "return_appearance"): + return target.return_appearance(self) + else: + return "{} has no in-game appearance.".format(target) else: # list of targets - make list to disconnect from db characters = list(tar for tar in target if tar) if target else [] From 3b75780b40604564e846a9baedb4354800425b99 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 8 Oct 2018 18:50:33 +0200 Subject: [PATCH 462/466] Make tutorial_world roots give clearer errors. Allow home/quit from dark room. Resolves #1584. --- evennia/contrib/tutorial_world/objects.py | 10 +++++----- evennia/contrib/tutorial_world/rooms.py | 16 +++++++++++++--- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/evennia/contrib/tutorial_world/objects.py b/evennia/contrib/tutorial_world/objects.py index f83462ad6b..331b6b1a21 100644 --- a/evennia/contrib/tutorial_world/objects.py +++ b/evennia/contrib/tutorial_world/objects.py @@ -475,14 +475,14 @@ class CmdShiftRoot(Command): root_pos["blue"] -= 1 self.caller.msg("The root with blue flowers gets in the way and is pushed to the left.") else: - self.caller.msg("You cannot move the root in that direction.") + self.caller.msg("The root hangs straight down - you can only move it left or right.") elif color == "blue": if direction == "left": root_pos[color] = max(-1, root_pos[color] - 1) self.caller.msg("You shift the root with small blue flowers to the left.") if root_pos[color] != 0 and root_pos[color] == root_pos["red"]: root_pos["red"] += 1 - self.caller.msg("The reddish root is to big to fit as well, so that one falls away to the left.") + self.caller.msg("The reddish root is too big to fit as well, so that one falls away to the left.") elif direction == "right": root_pos[color] = min(1, root_pos[color] + 1) self.caller.msg("You shove the root adorned with small blue flowers to the right.") @@ -490,7 +490,7 @@ class CmdShiftRoot(Command): root_pos["red"] -= 1 self.caller.msg("The thick reddish root gets in the way and is pushed back to the left.") else: - self.caller.msg("You cannot move the root in that direction.") + self.caller.msg("The root hangs straight down - you can only move it left or right.") # now the horizontal roots (yellow/green). They can be moved up/down elif color == "yellow": @@ -507,7 +507,7 @@ class CmdShiftRoot(Command): root_pos["green"] -= 1 self.caller.msg("The weedy green root is shifted upwards to make room.") else: - self.caller.msg("You cannot move the root in that direction.") + self.caller.msg("The root hangs across the wall - you can only move it up or down.") elif color == "green": if direction == "up": root_pos[color] = max(-1, root_pos[color] - 1) @@ -522,7 +522,7 @@ class CmdShiftRoot(Command): root_pos["yellow"] -= 1 self.caller.msg("The root with yellow flowers gets in the way and is pushed upwards.") else: - self.caller.msg("You cannot move the root in that direction.") + self.caller.msg("The root hangs across the wall - you can only move it up or down.") # we have moved the root. Store new position self.obj.db.root_pos = root_pos diff --git a/evennia/contrib/tutorial_world/rooms.py b/evennia/contrib/tutorial_world/rooms.py index 780f774af7..58e13a1356 100644 --- a/evennia/contrib/tutorial_world/rooms.py +++ b/evennia/contrib/tutorial_world/rooms.py @@ -747,9 +747,16 @@ class CmdLookDark(Command): """ caller = self.caller - if random.random() < 0.75: + # count how many searches we've done + nr_searches = caller.ndb.dark_searches + if nr_searches is None: + nr_searches = 0 + caller.ndb.dark_searches = nr_searches + + if nr_searches < 4 and random.random() < 0.90: # we don't find anything caller.msg(random.choice(DARK_MESSAGES)) + caller.ndb.dark_searches += 1 else: # we could have found something! if any(obj for obj in caller.contents if utils.inherits_from(obj, LightSource)): @@ -791,7 +798,8 @@ class CmdDarkNoMatch(Command): def func(self): """Implements the command.""" - self.caller.msg("Until you find some light, there's not much you can do. Try feeling around.") + self.caller.msg("Until you find some light, there's not much you can do. " + "Try feeling around, maybe you'll find something helpful!") class DarkCmdSet(CmdSet): @@ -814,7 +822,9 @@ class DarkCmdSet(CmdSet): self.add(CmdLookDark()) self.add(CmdDarkHelp()) self.add(CmdDarkNoMatch()) - self.add(default_cmds.CmdSay) + self.add(default_cmds.CmdSay()) + self.add(default_cmds.CmdQuit()) + self.add(default_cmds.CmdHome()) class DarkRoom(TutorialRoom): From 52c84b44b58a0af578814e48313d3f3a687e5d2f Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 8 Oct 2018 19:03:15 +0200 Subject: [PATCH 463/466] Handle prototype modules with non-dicts as global variables --- evennia/prototypes/prototypes.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index eac53a6504..fc8edb55ab 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -107,9 +107,10 @@ for mod in settings.PROTOTYPE_MODULES: # internally we store as (key, desc, locks, tags, prototype_dict) prots = [] for variable_name, prot in all_from_module(mod).items(): - if "prototype_key" not in prot: - prot['prototype_key'] = variable_name.lower() - prots.append((prot['prototype_key'], homogenize_prototype(prot))) + if isinstance(prot, dict): + if "prototype_key" not in prot: + prot['prototype_key'] = variable_name.lower() + prots.append((prot['prototype_key'], homogenize_prototype(prot))) # assign module path to each prototype_key for easy reference _MODULE_PROTOTYPE_MODULES.update({prototype_key.lower(): mod for prototype_key, _ in prots}) # make sure the prototype contains all meta info From 5f9047b161e2d5ec25263ba6d42dc33ab060662e Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 10 Oct 2018 23:26:20 +0200 Subject: [PATCH 464/466] Make Session.execute_cmd consistent with Account/Object by accepting the `session` keyword --- evennia/server/serversession.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/evennia/server/serversession.py b/evennia/server/serversession.py index b7f74cef5d..c5de7cf5be 100644 --- a/evennia/server/serversession.py +++ b/evennia/server/serversession.py @@ -407,7 +407,7 @@ class ServerSession(Session): else: self.data_out(**kwargs) - def execute_cmd(self, raw_string, **kwargs): + def execute_cmd(self, raw_string, session=None, **kwargs): """ Do something as this object. This method is normally never called directly, instead incoming command instructions are @@ -417,6 +417,9 @@ class ServerSession(Session): Args: raw_string (string): Raw command input + session (Session): This is here to make API consistent with + Account/Object.execute_cmd. If given, data is passed to + that Session, otherwise use self. Kwargs: Other keyword arguments will be added to the found command object instace as variables before it executes. This is @@ -426,7 +429,7 @@ class ServerSession(Session): """ # inject instruction into input stream kwargs["text"] = ((raw_string,), {}) - self.sessionhandler.data_in(self, **kwargs) + self.sessionhandler.data_in(session or self, **kwargs) def __eq__(self, other): """Handle session comparisons""" From 3fbd74b33203ca9fa9b8e7816c64becaf3b83474 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 13 Oct 2018 16:59:07 +0200 Subject: [PATCH 465/466] Fix (again) of tag batch creation --- evennia/prototypes/spawner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index ac6ad854b1..d1c099fb57 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -716,7 +716,7 @@ def spawn(*prototypes, **kwargs): val = prot.pop("tags", []) tags = [] for (tag, category, data) in val: - tags.append((init_spawn_value(val, str), category, data)) + tags.append((init_spawn_value(tag, str), category, data)) prototype_key = prototype.get('prototype_key', None) if prototype_key: From 9f8c1a4f644e5a73f479386334f30696ee6c8496 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 16 Oct 2018 10:13:05 +0200 Subject: [PATCH 466/466] Add requirement changes to 0.8 changelog, for clarity --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afe5ad1ba7..13c450939f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Evennia 0.8 (2018) +### Requirements + +- Up requirements to Django 1.11.x, Twisted 18 and pillow 5.2.0 +- Add `autobahn` dependency for Websocket support, removing very old embedded txWS library (from a + time before websocket specification was still not fixed). +- Add `inflect` dependency for automatic pluralization of object names. + ### Server/Portal - Removed `evennia_runner`, completely refactor `evennia_launcher.py` (the 'evennia' program) @@ -85,7 +92,6 @@ ### General -- Up requirements to Django 1.11.x, Twisted 18 and pillow 5.2.0 - Start structuring the `CHANGELOG` to list features in more detail. - Docker image `evennia/evennia:develop` is now auto-built, tracking the develop branch. - Inflection and grouping of multiple objects in default room (an box, three boxes)