From ea802b4567b9a9fcb456d1a4d17e8cfb0cae870a Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 21 Mar 2020 19:01:02 +0100 Subject: [PATCH 01/13] Extend rplanguage sentence detection. Resolve #2075 --- evennia/contrib/rplanguage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/contrib/rplanguage.py b/evennia/contrib/rplanguage.py index 6fae496960..a4dc931674 100644 --- a/evennia/contrib/rplanguage.py +++ b/evennia/contrib/rplanguage.py @@ -331,7 +331,7 @@ class LanguageHandler(DefaultScript): # find out what preceeded this word wpos = match.start() preceeding = match.string[:wpos].strip() - start_sentence = preceeding.endswith(".") or not preceeding + start_sentence = preceeding.endswith((".", "!", "?")) or not preceeding # make up translation on the fly. Length can # vary from un-translated word. From fb4931e85b14fd909b55d2a4b57768ad5c07eae8 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 23 Mar 2020 23:19:46 +0100 Subject: [PATCH 02/13] Fixing protoype spawning --- evennia/commands/default/building.py | 478 ++++++++++++++++----------- evennia/prototypes/prototypes.py | 27 +- evennia/prototypes/spawner.py | 101 +++++- evennia/server/webserver.py | 17 +- 4 files changed, 411 insertions(+), 212 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 1ab8e8714a..0f8756440b 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -13,13 +13,12 @@ from evennia.utils.utils import ( class_from_module, get_all_typeclasses, variable_from_module, - dbref, + dbref, interactive ) from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore from evennia.prototypes import spawner, prototypes as protlib, menus as olc_menus from evennia.utils.ansi import raw -from evennia.prototypes.menus import _format_diff_text_and_options COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -2099,10 +2098,10 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): # to confirm changes. if "prototype" in self.switches: diff, _ = spawner.prototype_diff_from_object(prototype, obj) - txt, options = _format_diff_text_and_options(diff, objects=[obj]) + txt = spawner.format_diff(diff) prompt = ( "Applying prototype '%s' over '%s' will cause the follow changes:\n%s\n" - % (prototype["key"], obj.name, "\n".join(txt)) + % (prototype["key"], obj.name, txt) ) if not reset: prompt += "\n|yWARNING:|n Use the /reset switch to apply the prototype over a blank state." @@ -3227,6 +3226,10 @@ class CmdTag(COMMAND_DEFAULT_CLASS): self.caller.msg(string) +# helper functions for spawn + + + class CmdSpawn(COMMAND_DEFAULT_CLASS): """ spawn objects from prototype @@ -3256,7 +3259,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. - edit, olc - create/manipulate prototype in a menu interface. + edit, menu, olc - create/manipulate prototype in a menu interface. Example: spawn GOBLIN @@ -3309,56 +3312,203 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): locks = "cmd:perm(spawn) or perm(Builder)" help_category = "Building" + def _search_prototype(self, prototype_key, quiet=False): + """ + Search for prototype and handle no/multi-match and access. + + Returns a single found prototype or None - in the + case, the caller has already been informed of the + search error we need not do any further action. + + """ + prototypes = protlib.search_prototype(prototype_key) + nprots = len(prototypes) + + # handle the search result + err = None + if not prototypes: + err = f"No prototype named '{prototype_key}'." + elif nprots > 1: + err = "Found {} prototypes matching '{}':\n {}".format( + nprots, + prototype_key, + ", ".join(proto.get("prototype_key", "") for proto in prototypes), + ) + else: + # we have a single prototype, check access + prototype = prototypes[0] + if not self.caller.locks.check_lockstring( + self.caller, prototype.get("prototype_locks", ""), + access_type="spawn", default=True): + err = "You don't have access to use this prototype." + + if err: + # return None on any error + if not quiet: + self.caller.msg(err) + return + return prototype + + def _parse_prototype(self, inp, expect=dict): + """ + Parse a prototype dict or key from the input and convert it safely + into a dict if appropriate. + + Args: + inp (str): The input from user. + expect (type, optional): + Returns: + prototype (dict, str or None): The parsed prototype. If None, the error + was already reported. + + """ + eval_err = None + try: + prototype = _LITERAL_EVAL(inp) + except (SyntaxError, ValueError) as err: + # treat as string + eval_err = err + prototype = utils.to_str(inp) + finally: + # it's possible that the input was a prototype-key, in which case + # it's okay for the LITERAL_EVAL to fail. Only if the result does not + # match the expected type do we have a problem. + if not isinstance(prototype, expect): + if eval_err: + string = ( + f"{inp}\n{eval_err}\n|RCritical Python syntax error in argument. Only primitive " + "Python structures are allowed. \nMake sure to use correct " + "Python syntax. Remember especially to put quotes around all " + "strings inside lists and dicts.|n For more advanced uses, embed " + "inlinefuncs in the strings." + ) + else: + string = "Expected {}, got {}.".format(expect, type(prototype)) + self.caller.msg(string) + return + + if expect == dict: + # an actual prototype. We need to make sure it's safe, + # so don't allow exec. + # TODO: Exec support is deprecated. Remove completely for 1.0. + if "exec" in prototype and not self.caller.check_permstring("Developer"): + self.caller.msg( + "Spawn aborted: You are not allowed to " "use the 'exec' prototype key." + ) + return + try: + # we homogenize the protoype first, to be more lenient with free-form + protlib.validate_prototype(protlib.homogenize_prototype(prototype)) + except RuntimeError as err: + self.caller.msg(str(err)) + return + return prototype + + def _get_prototype_detail(self, query=None, prototypes=None): + """ + Display the detailed specs of one or more prototypes. + + Args: + query (str, optional): If this is given and `prototypes` is not, search for + the prototype(s) by this query. This may be a partial query which + may lead to multiple matches, all being displayed. + prototypes (list, optional): If given, ignore `query` and only show these + prototype-details. + Returns: + display (str, None): A formatted string of one or more prototype details. + If None, the caller was already informed of the error. + + + """ + if not prototypes: + # we need to query. Note that if query is None, all prototypes will + # be returned. + prototypes = protlib.search_prototype(key=query) + if prototypes: + return "\n".join(protlib.prototype_to_str(prot) for prot in prototypes) + elif query: + self.caller.msg(f"No prototype found to match the query '{query}'.") + else: + self.caller.msg(f"No prototypes found.") + + def _list_prototypes(self, key=None, tags=None): + """Display prototypes as a list, optionally limited by key/tags. """ + EvMore( + self.caller, + str(protlib.list_prototypes(self.caller, key=key, tags=tags)), + exit_on_lastpage=True, + justify_kwargs=False, + ) + + @interactive + def _update_existing_objects(self, caller, prototype_key, quiet=False): + """ + Update existing objects (if any) with this prototype-key to the latest + prototype version. + + Args: + caller (Object): This is necessary for @interactive to work. + prototype_key (str): The prototype to update. + quiet (bool, optional): If set, don't report to user if no + old objects were found to update. + Returns: + n_updated (int): Number of updated objects. + + """ + prototype = self._search_prototype(prototype_key) + if not prototype: + return + + existing_objects = protlib.search_objects_with_prototype(prototype_key) + if not existing_objects: + if not quiet: + caller.msg("No existing objects found with an older version of this prototype.") + return + + if existing_objects: + n_existing = len(existing_objects) + slow = " (note that this may be slow)" if n_existing > 10 else "" + string = ( + f"There are {n_existing} existing object(s) with an older version " + f"of prototype '{prototype_key}'. Should it be re-applied to them{slow}? [Y]/N" + ) + 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( + prototype, objects=existing_objects) + caller.msg(f"{n_updated} objects were updated.") + return + + def _parse_key_desc_tags(self, argstring, desc=True): + """ + Parse ;-separated input list. + """ + key, desc, tags = "", "", [] + if ";" in self.args: + parts = (part.strip().lower() for part in self.args.split(";")) + if len(parts) > 1 and desc: + key = parts[0] + desc = parts[1] + tags = parts[2:] + else: + key = parts[0] + tags = parts[1:] + else: + key = argstring.strip().lower() + return key, desc, tags + def func(self): """Implements the spawner""" - def _parse_prototype(inp, expect=dict): - err = None - try: - 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 For more advanced uses, embed " - "inline functions in the strings.".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 are not allowed to " "use the 'exec' prototype key." - ) - return None - try: - # we homogenize first, to be more lenient - protlib.validate_prototype(protlib.homogenize_prototype(prototype)) - except RuntimeError as err: - self.caller.msg(str(err)) - return - return prototype - - def _search_show_prototype(query, prototypes=None): - # prototype detail - if not prototypes: - prototypes = protlib.search_prototype(key=query) - if prototypes: - return "\n".join(protlib.prototype_to_str(prot) for prot in prototypes) - else: - return False - caller = self.caller + noloc = "noloc" in self.switches + # run the menu/olc if ( self.cmdstring == "olc" or "menu" in self.switches @@ -3368,63 +3518,42 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # OLC menu mode prototype = None if self.lhs: - key = self.lhs - prototype = protlib.search_prototype(key=key) - if len(prototype) > 1: - caller.msg( - "More than one match for {}:\n{}".format( - key, "\n".join(proto.get("prototype_key", "") for proto in prototype) - ) - ) - return - elif prototype: - # one match - prototype = prototype[0] - else: - # no match - caller.msg("No prototype '{}' was found.".format(key)) + prototype_key = self.lhs + prototype = self._search_prototype(prototype_key) + if not prototype: return olc_menus.start_olc(caller, session=self.session, prototype=prototype) return if "search" in self.switches: - # query for a key match + # query for a key match. The arg is a search query or nothing. + 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, - str(protlib.list_prototypes(caller, key=key, tags=tags)), - exit_on_lastpage=True, - ) + # an empty search returns the full list + self._list_prototypes() return + # search for key;tag combinations + key, _, tags = self._parse_key_desc_tags(self.args, desc=False) + self._list_prototypes(key, tags) + return + if "show" in self.switches or "examine" in self.switches: - # the argument is a key in this case (may be a partial key) + # show a specific prot detail. The argument is a search query or empty. if not self.args: - self.switches.append("list") - else: - matchstring = _search_show_prototype(self.args) - if matchstring: - caller.msg(matchstring) - else: - caller.msg("No prototype '{}' was found.".format(self.args)) + # we don't show the list of all details, that's too spammy. + caller.msg("You need to specify a prototype-key to show.") return - if "list" in self.switches: - # for list, all optional arguments are tags - # import pudb; pudb.set_trace() + detail_string = self._get_prototype_detail(self.args) + if not detail_string: + return + caller.msg(detail_string) + return - EvMore( - caller, - str(protlib.list_prototypes(caller, tags=self.lhslist)), - exit_on_lastpage=True, - justify_kwargs=False, - ) + if "list" in self.switches: + # for list, all optional arguments are tags. + self._list_prototypes(tags=self.lhslist) return if "save" in self.switches: @@ -3435,27 +3564,41 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): ) return + prototype_key, prototype_desc, prototype_tags = self._parse_key_desc_tags(self.lhs) + # handle rhs: - prototype = _parse_prototype(self.lhs.strip()) + prototype = self._parse_prototype(self.rhs.strip()) if not prototype: return - # 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" + if prototype.get("prototype_key") != prototype_key: + caller.msg("(Replacing `prototype_key` in prototype with given key.)") + prototype['prototype_key'] = prototype_key + if prototype_desc and prototype.get("prototype_desc") != prototype_desc: + caller.msg("(Replacing `prototype_desc` in prototype with given desc.)") + prototype['prototype_desc'] = prototype_desc + if prototype_tags and prototype.get("prototype_tags") != prototype_tags: + caller.msg("(Replacing `prototype_tags` in prototype with given tag(s))" ) + prototype['prototype_tags'] = prototype_tags - 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 + string = "" + # check for existing prototype (exact match) + old_prototype = self._search_prototype(prototype_key, quiet=True) - # check for existing prototype, - old_matchstring = _search_show_prototype(prototype_key) + print("old_prototype", old_prototype) + print("new_prototype", prototype) + diff = spawner.prototype_diff(old_prototype, prototype, homogenize=True) + print("diff", diff) + diffstr = spawner.format_diff(diff) + new_prototype_detail = self._get_prototype_detail(prototypes=[prototype]) - 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" + if old_prototype: + string = (f"|yExisting prototype \"{prototype_key}\" found. Change:|n\n{diffstr}\n" + f"|yNew changed prototype:|n\n{new_prototype_detail}") + question = "\n|yDo you want to apply the change to the existing prototype?|n [Y]/N" + else: + string = f"|yCreating new prototype:|n\n{new_prototype_detail}" + question = "\nDo you want to continue saving? [Y]/N" answer = yield (string + question) if answer.lower() in ["n", "no"]: @@ -3474,82 +3617,52 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): caller.msg("|gSaved prototype:|n {}".format(prototype_key)) # check if we want to update existing objects - 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, prototype_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)) + + self._update_existing_objects(self.caller, prototype_key, quiet=True) return if not self.args: + # all switches beyond this point gets a common non-arg return ncount = len(protlib.search_prototype()) caller.msg( "Usage: spawn or {{key: value, ...}}" - "\n ({} existing prototypes. Use /list to inspect)".format(ncount) + f"\n ({ncount} existing prototypes. Use /list to inspect)" ) 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 = protlib.delete_prototype(self.args) - 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?)" - ) - ) + prototype_detail = self._get_prototype_detail(self.args) + if not prototype_detail: return + + string = f"|rDeleting prototype:|n\n{prototype_detail}" + 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 = protlib.delete_prototype(self.args) + except protlib.PermissionError as err: + retmsg = f"|rError deleting:|R {err}|n" else: - caller.msg("Could not find prototype '{}'".format(key)) + retmsg = ("Deletion successful" if success else + "Deletion failed (does the prototype exist?)") + caller.msg(retmsg) + return if "update" in self.switches: # update existing prototypes - key = self.args.strip().lower() - 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 "" - 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)) + prototype_key = self.args.strip().lower() + self._update_existing_objects(self.caller, prototype_key) + return - # A direct creation of an object from a given prototype + # If we get to this point, we use not switches but are trying a + # direct creation of an object from a given prototype or -key - prototype = _parse_prototype( + prototype = self._parse_prototype( self.args, expect=dict if self.args.strip().startswith("{") else str ) if not prototype: @@ -3559,35 +3672,20 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): key = "" if isinstance(prototype, str): # A prototype key we are looking to apply - key = prototype - prototypes = protlib.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(proto.get("prototype_key", "") for proto in prototypes), - ) - ) - return - # we have a prototype, check access - prototype = prototypes[0] - if not caller.locks.check_lockstring( - caller, prototype.get("prototype_locks", ""), access_type="spawn", default=True - ): - caller.msg("You don't have access to use this prototype.") - return + prototype_key = prototype + prototype = self._search_prototype(prototype_key) - if "noloc" not in self.switches and "location" not in prototype: - prototype["location"] = self.caller.location + if not prototype: + return # proceed to spawning try: for obj in spawner.spawn(prototype): self.caller.msg("Spawned %s." % obj.get_display_name(self.caller)) + if not noloc and "location" not in prototype: + # we don't hardcode the location in the prototype (unless the user + # did so manually) - that would lead to it having to be 'removed' every + # time we try to update objects with this prototype in the future. + obj.location = caller.location except RuntimeError as err: caller.msg(err) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 94a3e04eb0..26fd99f1fd 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -56,6 +56,8 @@ _PROTOTYPE_TAG_CATEGORY = "from_prototype" _PROTOTYPE_TAG_META_CATEGORY = "db_prototype" PROT_FUNCS = {} +_PROTOTYPE_FALLBACK_LOCK = "spawn:all();edit:perm(Admin)" + class PermissionError(RuntimeError): pass @@ -84,8 +86,19 @@ def homogenize_prototype(prototype, custom_keys=None): homogenizations like adding missing prototype_keys and setting a default typeclass. """ + if not prototype or not isinstance(prototype, dict): + return {} + reserved = _PROTOTYPE_RESERVED_KEYS + (custom_keys or ()) + # correct cases of setting None for certain values + for protkey in prototype: + if prototype[protkey] is None: + if protkey in ("attrs", "tags", "prototype_tags"): + prototype[protkey] = [] + elif protkey in ("prototype_key", "prototype_desc"): + prototype[protkey] = "" + attrs = list(prototype.get("attrs", [])) # break reference tags = make_iter(prototype.get("tags", [])) homogenized_tags = [] @@ -111,12 +124,14 @@ def homogenize_prototype(prototype, custom_keys=None): # add required missing parts that had defaults before - if "prototype_key" not in prototype: + homogenized["prototype_key"] = homogenized.get("prototype_key", # assign a random hash as key - homogenized["prototype_key"] = "prototype-{}".format( - hashlib.md5(bytes(str(time.time()), "utf-8")).hexdigest()[:7] - ) - + "prototype-{}".format( + hashlib.md5(bytes(str(time.time()), "utf-8")).hexdigest()[:7])) + homogenized["prototype_tags"] = homogenized.get("prototype_tags", []) + homogenized["prototype_locks"] = homogenized.get( + "prototype_lock", _PROTOTYPE_FALLBACK_LOCK) + homogenized["prototype_desc"] = homogenized.get("prototype_desc", "") if "typeclass" not in prototype and "prototype_parent" not in prototype: homogenized["typeclass"] = settings.BASE_OBJECT_TYPECLASS @@ -230,7 +245,7 @@ def save_prototype(prototype): "prototype_desc", prototype.get("prototype_desc", "") ) prototype_locks = in_prototype.get( - "prototype_locks", prototype.get("prototype_locks", "spawn:all();edit:perm(Admin)") + "prototype_locks", prototype.get("prototype_locks", _PROTOTYPE_FALLBACK_LOCK) ) is_valid, err = validate_lockstring(prototype_locks) if not is_valid: diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index fab4d2cfdc..98fe2526d7 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -322,9 +322,9 @@ def prototype_from_object(obj): return prot -def prototype_diff(prototype1, prototype2, maxdepth=2): +def prototype_diff(prototype1, prototype2, maxdepth=2, homogenize=False): """ - A 'detailed' diff specifies differences down to individual sub-sectiions + A 'detailed' diff specifies differences down to individual sub-sections of the prototype, like individual attributes, permissions etc. It is used by the menu to allow a user to customize what should be kept. @@ -334,6 +334,8 @@ def prototype_diff(prototype1, prototype2, maxdepth=2): 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. + homogenize (bool, optional): Auto-homogenize both prototypes for the best comparison. + This is most useful for displaying. Returns: diff (dict): A structure detailing how to convert prototype1 to prototype2. All @@ -350,7 +352,10 @@ def prototype_diff(prototype1, prototype2, maxdepth=2): old_type = type(old) new_type = type(new) - if old_type != new_type: + if old_type == new_type and not (old and new): + # both old and new are unset, like [] or None + return (old, new, "KEEP") + elif 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()} @@ -387,7 +392,10 @@ def prototype_diff(prototype1, prototype2, maxdepth=2): else: return (old, new, "KEEP") - diff = _recursive_diff(prototype1, prototype2) + prot1 = protlib.homogenize_prototype(prototype1) if homogenize else prototype1 + prot2 = protlib.homogenize_prototype(prototype2) if homogenize else prototype2 + + diff = _recursive_diff(prot1, prot2) return diff @@ -490,6 +498,80 @@ def prototype_diff_from_object(prototype, obj): return diff, obj_prototype +def format_diff(diff, minimal=True): + """ + Reformat a diff for presentation. This is a shortened version + of the olc _format_diff_text_and_options without the options. + + Args: + diff (dict): A diff as produced by `prototype_diff`. + minimal (bool, optional): Only show changes (remove KEEPs) + + Returns: + texts (str): The formatted text. + + """ + valid_instructions = ("KEEP", "REMOVE", "ADD", "UPDATE") + + def _visualize(obj, rootname, get_name=False): + if is_iter(obj): + if not obj: + return str(obj) + if get_name: + return obj[0] if obj[0] else "" + if rootname == "attrs": + return "{} |w=|n {} |w(category:|n |n{}|w, locks:|n {}|w)|n".format(*obj) + elif rootname == "tags": + return "{} |w(category:|n {}|w)|n".format(obj[0], obj[1]) + return "{}".format(obj) + + def _parse_diffpart(diffpart, rootname): + typ = type(diffpart) + texts = [] + if typ == tuple and len(diffpart) == 3 and diffpart[2] in valid_instructions: + old, new, instruction = diffpart + if instruction == "KEEP": + if not minimal: + texts.append(" |gKEEP|n: {old}".format(old=_visualize(old, rootname))) + elif instruction == "ADD": + texts.append(" |yADD|n: {new}".format(new=_visualize(new, rootname))) + elif instruction == "REMOVE" and not new: + texts.append(" |rREMOVE|n: {old}".format(old=_visualize(old, rootname))) + else: + 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) + varrow = "|r->|n" if instruction == "REMOVE" else "|y->|n" + texts.append( + " {inst}|W:|n {old} |W{varrow}|n{sep} {new}".format( + inst=vinst, old=vold, varrow=varrow, sep=vsep, new=vnew + ) + ) + else: + for key in sorted(list(diffpart.keys())): + subdiffpart = diffpart[key] + text = _parse_diffpart(subdiffpart, rootname) + texts.extend(text) + return texts + + texts = [] + + for root_key in sorted(diff): + diffpart = diff[root_key] + text = _parse_diffpart(diffpart, root_key) + if text or not minimal: + heading = "- |w{}:|n\n".format(root_key) + if text: + text = [heading + text[0]] + text[1:] + else: + text = [heading] + + texts.extend(text) + + return "\n ".join(line for line in texts if line) + + def batch_update_objects_with_prototype(prototype, diff=None, objects=None): """ Update existing objects with the latest version of the prototype. @@ -525,15 +607,13 @@ 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 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(): if directive in ("UPDATE", "REPLACE"): @@ -623,6 +703,11 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None): pass else: obj.attributes.remove(key) + + # we must always make sure to re-add the prototype tag + obj.tags.clear(category=_PROTOTYPE_TAG_CATEGORY) + obj.tags.add(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + if do_save: changed += 1 obj.save() @@ -707,7 +792,7 @@ def spawn(*prototypes, **kwargs): Args: prototypes (str or dict): Each argument should either be a prototype_key (will be used to find the prototype) or a full prototype - dictionary. These will be batched-spawned as one object each. + dictionary. These will be batched-spawned as one object each. 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 diff --git a/evennia/server/webserver.py b/evennia/server/webserver.py index 4dc3f71e35..ff7f61baa0 100644 --- a/evennia/server/webserver.py +++ b/evennia/server/webserver.py @@ -70,14 +70,15 @@ class HTTPChannelWithXForwardedFor(http.HTTPChannel): Check to see if this is a reverse proxied connection. """ - CLIENT = 0 - http.HTTPChannel.allHeadersReceived(self) - req = self.requests[-1] - client_ip, port = self.transport.client - proxy_chain = req.getHeader("X-FORWARDED-FOR") - if proxy_chain and client_ip in _UPSTREAM_IPS: - forwarded = proxy_chain.split(", ", 1)[CLIENT] - self.transport.client = (forwarded, port) + if self.requests: + CLIENT = 0 + http.HTTPChannel.allHeadersReceived(self) + req = self.requests[-1] + client_ip, port = self.transport.client + proxy_chain = req.getHeader("X-FORWARDED-FOR") + if proxy_chain and client_ip in _UPSTREAM_IPS: + forwarded = proxy_chain.split(", ", 1)[CLIENT] + self.transport.client = (forwarded, port) # Monkey-patch Twisted to handle X-Forwarded-For. From 6c1a77eeccb0dd79ba97a1206a320d0d34ac8282 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 23 Mar 2020 23:20:27 +0100 Subject: [PATCH 03/13] Fix traceback in x-forward-for in cases of localhost --- evennia/server/webserver.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/evennia/server/webserver.py b/evennia/server/webserver.py index 4dc3f71e35..ff7f61baa0 100644 --- a/evennia/server/webserver.py +++ b/evennia/server/webserver.py @@ -70,14 +70,15 @@ class HTTPChannelWithXForwardedFor(http.HTTPChannel): Check to see if this is a reverse proxied connection. """ - CLIENT = 0 - http.HTTPChannel.allHeadersReceived(self) - req = self.requests[-1] - client_ip, port = self.transport.client - proxy_chain = req.getHeader("X-FORWARDED-FOR") - if proxy_chain and client_ip in _UPSTREAM_IPS: - forwarded = proxy_chain.split(", ", 1)[CLIENT] - self.transport.client = (forwarded, port) + if self.requests: + CLIENT = 0 + http.HTTPChannel.allHeadersReceived(self) + req = self.requests[-1] + client_ip, port = self.transport.client + proxy_chain = req.getHeader("X-FORWARDED-FOR") + if proxy_chain and client_ip in _UPSTREAM_IPS: + forwarded = proxy_chain.split(", ", 1)[CLIENT] + self.transport.client = (forwarded, port) # Monkey-patch Twisted to handle X-Forwarded-For. From 9782196b3c2119e5f847cd096e7db904bbb979cd Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 23 Mar 2020 23:44:32 +0100 Subject: [PATCH 04/13] Fix home kwarg not being honored by Character.create --- evennia/objects/objects.py | 3 --- evennia/utils/create.py | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 04bbad6206..de9d09abae 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -2066,9 +2066,6 @@ class DefaultCharacter(DefaultObject): # Set the supplied key as the name of the intended object kwargs["key"] = key - # Get home for character - kwargs["home"] = ObjectDB.objects.get_id(kwargs.get("home", settings.DEFAULT_HOME)) - # Get permissions kwargs["permissions"] = kwargs.get("permissions", settings.PERMISSION_ACCOUNT_DEFAULT) diff --git a/evennia/utils/create.py b/evennia/utils/create.py index f4a5810fbd..5076f60ee3 100644 --- a/evennia/utils/create.py +++ b/evennia/utils/create.py @@ -486,8 +486,8 @@ def create_account( Args: key (str): The account's name. This should be unique. - email (str or None): Email on valid addr@addr.domain form. If - the empty string, will be set to None. + email (str or None): Email on valid addr@addr.domain form. If + the empty string, will be set to None. password (str): Password in cleartext. Kwargs: From bea61b289ee1b46477eac5e4c308d8688d0fc73b Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 28 Mar 2020 17:57:04 +0100 Subject: [PATCH 05/13] Extensive cleanup and refactoring of the spawn command and obj-update functionality, as per #1879 --- CHANGELOG.md | 1 + evennia/commands/default/building.py | 24 +-- evennia/prototypes/menus.py | 39 +++-- evennia/prototypes/prototypes.py | 16 +- evennia/prototypes/spawner.py | 219 ++++++++++++++++----------- 5 files changed, 176 insertions(+), 123 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5252f8f67a..0b22fcd398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ without arguments starts a full interactive Python console. bugfixes. - Remove `dummy@example.com` as a default account email when unset, a string is no longer required by Django. +- Fixes to `spawn`, make updating an existing prototype/object work better. ## Evennia 0.9 (2018-2019) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 0f8756440b..ab931370f8 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -7,7 +7,7 @@ from django.db.models import Q, Min, Max 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 import create, utils, search, logger from evennia.utils.utils import ( inherits_from, class_from_module, @@ -3479,8 +3479,11 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): "Use spawn/update to apply later as needed.|n" ) return - n_updated = spawner.batch_update_objects_with_prototype( - prototype, objects=existing_objects) + try: + n_updated = spawner.batch_update_objects_with_prototype( + prototype, objects=existing_objects) + except Exception: + logger.log_trace() caller.msg(f"{n_updated} objects were updated.") return @@ -3585,17 +3588,18 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # check for existing prototype (exact match) old_prototype = self._search_prototype(prototype_key, quiet=True) - print("old_prototype", old_prototype) - print("new_prototype", prototype) diff = spawner.prototype_diff(old_prototype, prototype, homogenize=True) - print("diff", diff) diffstr = spawner.format_diff(diff) new_prototype_detail = self._get_prototype_detail(prototypes=[prototype]) if old_prototype: - string = (f"|yExisting prototype \"{prototype_key}\" found. Change:|n\n{diffstr}\n" - f"|yNew changed prototype:|n\n{new_prototype_detail}") - question = "\n|yDo you want to apply the change to the existing prototype?|n [Y]/N" + if not diffstr: + string = f"|yAlready existing Prototype:|n\n{new_prototype_detail}\n" + question = "\nThere seems to be no changes. Do you still want to (re)save? [Y]/N" + else: + string = (f"|yExisting prototype \"{prototype_key}\" found. Change:|n\n{diffstr}\n" + f"|yNew changed prototype:|n\n{new_prototype_detail}") + question = "\n|yDo you want to apply the change to the existing prototype?|n [Y]/N" else: string = f"|yCreating new prototype:|n\n{new_prototype_detail}" question = "\nDo you want to continue saving? [Y]/N" @@ -3682,7 +3686,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): try: for obj in spawner.spawn(prototype): self.caller.msg("Spawned %s." % obj.get_display_name(self.caller)) - if not noloc and "location" not in prototype: + if not prototype.get('location') and not noloc: # we don't hardcode the location in the prototype (unless the user # did so manually) - that would lead to it having to be 'removed' every # time we try to update objects with this prototype in the future. diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index bba55b1eee..abaed9ef58 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -2131,12 +2131,13 @@ def _keep_diff(caller, **kwargs): tmp[path[-1]] = tuple(list(tmp[path[-1]][:-1]) + ["KEEP"]) -def _format_diff_text_and_options(diff, **kwargs): +def _format_diff_text_and_options(diff, minimal=True, **kwargs): """ Reformat the diff in a way suitable for the olc menu. Args: diff (dict): A diff as produced by `prototype_diff`. + minimal (bool, optional): Don't show KEEPs. Kwargs: any (any): Forwarded into the generated options as arguments to the callable. @@ -2150,12 +2151,15 @@ def _format_diff_text_and_options(diff, **kwargs): def _visualize(obj, rootname, get_name=False): if utils.is_iter(obj): + if not obj: + return str(obj) if get_name: 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 "{}".format(obj) def _parse_diffpart(diffpart, optnum, *args): @@ -2166,17 +2170,33 @@ 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=_visualize(old, rootname))) + if not minimal: + texts.append(" |gKEEP|W:|n {old}".format(old=_visualize(old, rootname))) else: + # instructions we should be able to revert by a menu choice 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 + + if instruction == "ADD": + texts.append(" |c[{optnum}] |yADD|n: {new}".format( + optnum=optnum, new=_visualize(new, rootname))) + elif instruction == "REMOVE" and not new: + if rootname == "tags" and old[1] == protlib._PROTOTYPE_TAG_CATEGORY: + # special exception for the prototype-tag mechanism + # this is added post-spawn automatically and should + # not be listed as REMOVE. + return texts, options, optnum + + texts.append(" |c[{optnum}] |rREMOVE|n: {old}".format( + optnum=optnum, old=_visualize(old, rootname))) + else: + vinst = "|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), @@ -2203,11 +2223,8 @@ def _format_diff_text_and_options(diff, **kwargs): 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: + if text: text = [heading + text[0]] + text[1:] else: text = [heading] diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 26fd99f1fd..b80d972e36 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -238,9 +238,6 @@ def save_prototype(prototype): ) # make sure meta properties are included with defaults - stored_prototype = DbPrototype.objects.filter(db_key=prototype_key) - prototype = stored_prototype[0].prototype if stored_prototype else {} - in_prototype["prototype_desc"] = in_prototype.get( "prototype_desc", prototype.get("prototype_desc", "") ) @@ -260,17 +257,16 @@ def save_prototype(prototype): ] in_prototype["prototype_tags"] = prototype_tags - prototype.update(in_prototype) - + 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.desc = in_prototype["prototype_desc"] 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) + stored_prototype.tags.batch_add(*in_prototype["prototype_tags"]) + stored_prototype.locks.add(in_prototype["prototype_locks"]) + stored_prototype.attributes.add("prototype", in_prototype) else: # create a new prototype stored_prototype = create_script( @@ -280,7 +276,7 @@ def save_prototype(prototype): persistent=True, locks=prototype_locks, tags=prototype["prototype_tags"], - attributes=[("prototype", prototype)], + attributes=[("prototype", in_prototype)], ) return stored_prototype.prototype diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 98fe2526d7..a4625f16f0 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -138,6 +138,7 @@ from django.conf import settings import evennia from evennia.objects.models import ObjectDB +from evennia.utils import logger from evennia.utils.utils import make_iter, is_iter from evennia.prototypes import prototypes as protlib from evennia.prototypes.prototypes import ( @@ -322,7 +323,7 @@ def prototype_from_object(obj): return prot -def prototype_diff(prototype1, prototype2, maxdepth=2, homogenize=False): +def prototype_diff(prototype1, prototype2, maxdepth=2, homogenize=False, implicit_keep=False): """ A 'detailed' diff specifies differences down to individual sub-sections of the prototype, like individual attributes, permissions etc. It is used @@ -336,6 +337,10 @@ def prototype_diff(prototype1, prototype2, maxdepth=2, homogenize=False): attr/tag (for example) are represented by a tuple. homogenize (bool, optional): Auto-homogenize both prototypes for the best comparison. This is most useful for displaying. + implicit_keep (bool, optional): If set, the resulting diff will assume KEEP unless the new + prototype explicitly change them. That is, if a key exists in `prototype1` and + not in `prototype2`, it will not be REMOVEd but set to KEEP instead. This is particularly + useful for auto-generated prototypes when updating objects. Returns: diff (dict): A structure detailing how to convert prototype1 to prototype2. All @@ -346,6 +351,10 @@ def prototype_diff(prototype1, prototype2, maxdepth=2, homogenize=False): instruction can be one of "REMOVE", "ADD", "UPDATE" or "KEEP". """ + class Unset: + def __bool__(self): + return False + _unset = Unset() def _recursive_diff(old, new, depth=0): @@ -363,6 +372,9 @@ def prototype_diff(prototype1, prototype2, maxdepth=2, homogenize=False): return { part[0] if is_iter(part) else part: (part, None, "REMOVE") for part in old } + if isinstance(new, Unset) and implicit_keep: + # the new does not define any change, use implicit-keep + return (old, None, "KEEP") return (old, new, "REMOVE") elif not old and new: if depth < maxdepth and new_type == dict: @@ -376,7 +388,7 @@ def prototype_diff(prototype1, prototype2, maxdepth=2, homogenize=False): elif depth < maxdepth and new_type == dict: all_keys = set(list(old.keys()) + list(new.keys())) return { - key: _recursive_diff(old.get(key), new.get(key), depth=depth + 1) + key: _recursive_diff(old.get(key), new.get(key, _unset), depth=depth + 1) for key in all_keys } elif depth < maxdepth and is_iter(new): @@ -384,7 +396,7 @@ def prototype_diff(prototype1, prototype2, maxdepth=2, homogenize=False): new_map = {part[0] if is_iter(part) else part: part for part in new} all_keys = set(list(old_map.keys()) + list(new_map.keys())) return { - key: _recursive_diff(old_map.get(key), new_map.get(key), depth=depth + 1) + key: _recursive_diff(old_map.get(key), new_map.get(key, _unset), depth=depth + 1) for key in all_keys } elif old != new: @@ -468,7 +480,7 @@ def flatten_diff(diff): return flat_diff -def prototype_diff_from_object(prototype, obj): +def prototype_diff_from_object(prototype, obj, implicit_keep=True): """ 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 @@ -482,6 +494,11 @@ def prototype_diff_from_object(prototype, obj): diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...} obj_prototype (dict): The prototype calculated for the given object. The diff is how to convert this prototype into the new prototype. + implicit_keep (bool, optional): This is usually what one wants for object updating. When + set, this means the prototype diff will assume KEEP on differences + between the object-generated prototype and that which is not explicitly set in the + new prototype. This means e.g. that even though the object has a location, and the + prototype does not specify the location, it will not be unset. Notes: The `diff` is on the following form: @@ -494,7 +511,8 @@ def prototype_diff_from_object(prototype, obj): """ obj_prototype = prototype_from_object(obj) - diff = prototype_diff(obj_prototype, protlib.homogenize_prototype(prototype)) + diff = prototype_diff(obj_prototype, protlib.homogenize_prototype(prototype), + implicit_keep=implicit_keep) return diff, obj_prototype @@ -511,6 +529,7 @@ def format_diff(diff, minimal=True): texts (str): The formatted text. """ + valid_instructions = ("KEEP", "REMOVE", "ADD", "UPDATE") def _visualize(obj, rootname, get_name=False): @@ -572,7 +591,7 @@ def format_diff(diff, minimal=True): return "\n ".join(line for line in texts if line) -def batch_update_objects_with_prototype(prototype, diff=None, objects=None): +def batch_update_objects_with_prototype(prototype, diff=None, objects=None, exact=False): """ Update existing objects with the latest version of the prototype. @@ -583,6 +602,12 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None): 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`. + exact (bool, optional): By default (`False`), keys not explicitly in the prototype will + not be applied to the object, but will be retained as-is. This is usually what is + expected - for example, one usually do not want to remove the object's location even + if it's not set in the prototype. With `exact=True`, all un-specified properties of the + objects will be removed if they exist. This will lead to a more accurate 1:1 correlation + between the object and the prototype but is usually impractical. Returns: changed (int): The number of objects that had changes applied to them. @@ -615,98 +640,108 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None): 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 - for key, directive in diff.items(): - if directive in ("UPDATE", "REPLACE"): + try: + for key, directive in diff.items(): - if key in _PROTOTYPE_META_NAMES: - # prototype meta keys are not stored on-object + if key not in new_prototype and not exact: + # we don't update the object if the prototype does not actually + # contain the key (the diff will report REMOVE but we ignore it + # since exact=False) continue - val = new_prototype[key] - do_save = True + if directive in ("UPDATE", "REPLACE"): - 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(perm, str) for perm in val)) - elif key == "aliases": - if directive == "REPLACE": - obj.aliases.clear() - 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(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(akey, str), - init_spawn_value(aval, value_to_obj), - acategory, - alocks, + 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": + 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(perm, str) for perm in val)) + elif key == "aliases": + if directive == "REPLACE": + obj.aliases.clear() + 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(ttag, str), tcategory, tdata) + for ttag, tcategory, tdata in val ) - for akey, aval, acategory, alocks in val ) - ) - 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) - - # we must always make sure to re-add the prototype tag - obj.tags.clear(category=_PROTOTYPE_TAG_CATEGORY) - obj.tags.add(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + elif key == "attrs": + if directive == "REPLACE": + obj.attributes.clear() + 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 + 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) + except Exception: + logger.log_trace(f"Failed to apply prototype '{prototype_key}' to {obj}.") + finally: + # we must always make sure to re-add the prototype tag + obj.tags.clear(category=_PROTOTYPE_TAG_CATEGORY) + obj.tags.add(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) if do_save: changed += 1 From 8c44766c0a9622557a41cd87b46063af59094882 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 28 Mar 2020 20:34:56 +0100 Subject: [PATCH 06/13] Handle going from location=None to unset and back in prototype, as per #2005 --- CHANGELOG.md | 3 +- evennia/commands/default/building.py | 32 +++++++++++++++++---- evennia/prototypes/menus.py | 3 +- evennia/prototypes/prototypes.py | 42 ++++++++++++++-------------- evennia/prototypes/spawner.py | 13 +++++---- 5 files changed, 59 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b22fcd398..ac78239c78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +54,8 @@ without arguments starts a full interactive Python console. bugfixes. - Remove `dummy@example.com` as a default account email when unset, a string is no longer required by Django. -- Fixes to `spawn`, make updating an existing prototype/object work better. +- Fixes to `spawn`, make updating an existing prototype/object work better. Add `/raw` switch + to `spawn` command to extract the raw prototype dict for manual editing. ## Evennia 0.9 (2018-2019) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index ab931370f8..e0f125079e 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -13,7 +13,8 @@ from evennia.utils.utils import ( class_from_module, get_all_typeclasses, variable_from_module, - dbref, interactive + dbref, interactive, + list_to_string ) from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore @@ -3253,6 +3254,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): search - search prototype by name or tags. list - list available prototypes, optionally limit by tags. show, examine - inspect prototype by key. If not given, acts like list. + raw - show the raw dict of the prototype as a one-line string for manual editing. 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 @@ -3301,6 +3303,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): "search", "list", "show", + "raw", "examine", "save", "delete", @@ -3433,9 +3436,12 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): def _list_prototypes(self, key=None, tags=None): """Display prototypes as a list, optionally limited by key/tags. """ + table = protlib.list_prototypes(self.caller, key=key, tags=tags) + if not table: + return True EvMore( self.caller, - str(protlib.list_prototypes(self.caller, key=key, tags=tags)), + str(table), exit_on_lastpage=True, justify_kwargs=False, ) @@ -3492,8 +3498,8 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): Parse ;-separated input list. """ key, desc, tags = "", "", [] - if ";" in self.args: - parts = (part.strip().lower() for part in self.args.split(";")) + if ";" in argstring: + parts = [part.strip().lower() for part in argstring.split(";")] if len(parts) > 1 and desc: key = parts[0] desc = parts[1] @@ -3537,10 +3543,20 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): return # search for key;tag combinations - key, _, tags = self._parse_key_desc_tags(self.args, desc=False) + key, _, tags = self._parse_key_desc_tags(self.args, desc=False) self._list_prototypes(key, tags) return + if "raw" in self.switches: + # query for key match and return the prototype as a safe one-liner string. + if not self.args: + caller.msg("You need to specify a prototype-key to get the raw data for.") + prototype = self._search_prototype(self.args) + if not prototype: + return + caller.msg(str(prototype)) + return + if "show" in self.switches or "examine" in self.switches: # show a specific prot detail. The argument is a search query or empty. if not self.args: @@ -3556,7 +3572,11 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if "list" in self.switches: # for list, all optional arguments are tags. - self._list_prototypes(tags=self.lhslist) + tags = self.lhslist + err = self._list_prototypes(tags=tags) + if err: + caller.msg("No prototypes found with prototype-tag(s): {}".format( + list_to_string(tags, "or"))) return if "save" in self.switches: diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index abaed9ef58..854f9e7ced 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -2294,7 +2294,8 @@ def node_apply_diff(caller, **kwargs): if not custom_location: diff.pop("location", None) - txt, options = _format_diff_text_and_options(diff, objects=update_objects, base_obj=base_obj) + txt, options = _format_diff_text_and_options(diff, objects=update_objects, + base_obj=base_obj, prototype=prototype) if options: text = [ diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index b80d972e36..7e76ee515e 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -272,10 +272,10 @@ def save_prototype(prototype): stored_prototype = create_script( DbPrototype, key=prototype_key, - desc=prototype["prototype_desc"], + desc=in_prototype["prototype_desc"], persistent=True, locks=prototype_locks, - tags=prototype["prototype_tags"], + tags=in_prototype["prototype_tags"], attributes=[("prototype", in_prototype)], ) return stored_prototype.prototype @@ -724,15 +724,15 @@ def prototype_to_str(prototype): prototype_desc=prototype.get("prototype_desc", "|wNone|n"), prototype_parent=prototype.get("prototype_parent", "|wNone|n"), ) - - key = prototype.get("key", "") - if key: + key = aliases = attrs = tags = locks = permissions = location = home = destination = "" + if "key" in prototype: + key = prototype["key"] key = "|ckey:|n {key}".format(key=key) - aliases = prototype.get("aliases", "") - if aliases: + if "aliases" in prototype: + aliases = prototype["aliases"] aliases = "|caliases:|n {aliases}".format(aliases=", ".join(aliases)) - attrs = prototype.get("attrs", "") - if attrs: + if "attrs" in prototype: + attrs = prototype["attrs"] out = [] for (attrkey, value, category, locks) in attrs: locks = ", ".join(lock for lock in locks if lock) @@ -751,8 +751,8 @@ def prototype_to_str(prototype): ) ) attrs = "|cattrs:|n\n {attrs}".format(attrs="\n ".join(out)) - tags = prototype.get("tags", "") - if tags: + if "tags" in prototype: + tags = prototype['tags'] out = [] for (tagkey, category, data) in tags: out.append( @@ -761,20 +761,20 @@ def prototype_to_str(prototype): ) ) tags = "|ctags:|n\n {tags}".format(tags=", ".join(out)) - locks = prototype.get("locks", "") - if locks: + if "locks" in prototype: + locks = prototype["locks"] locks = "|clocks:|n\n {locks}".format(locks=locks) - permissions = prototype.get("permissions", "") - if permissions: + if "permissions" in prototype: + permissions = prototype["permissions"] permissions = "|cpermissions:|n {perms}".format(perms=", ".join(permissions)) - location = prototype.get("location", "") - if location: + if "location" in prototype: + location = prototype["location"] location = "|clocation:|n {location}".format(location=location) - home = prototype.get("home", "") - if home: + if "home" in prototype: + home = prototype["home"] home = "|chome:|n {home}".format(home=home) - destination = prototype.get("destination", "") - if destination: + if "destination" in prototype: + destination = prototype["destination"] destination = "|cdestination:|n {destination}".format(destination=destination) body = "\n".join( diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index a4625f16f0..5ba3cd4b62 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -354,6 +354,9 @@ def prototype_diff(prototype1, prototype2, maxdepth=2, homogenize=False, implici class Unset: def __bool__(self): return False + def __str__(self): + return "" + _unset = Unset() def _recursive_diff(old, new, depth=0): @@ -361,10 +364,10 @@ def prototype_diff(prototype1, prototype2, maxdepth=2, homogenize=False, implici old_type = type(old) new_type = type(new) - if old_type == new_type and not (old and new): + if old_type == new_type and not (old or new): # both old and new are unset, like [] or None - return (old, new, "KEEP") - elif old_type != new_type: + return (None, None, "KEEP") + 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()} @@ -388,7 +391,7 @@ def prototype_diff(prototype1, prototype2, maxdepth=2, homogenize=False, implici elif depth < maxdepth and new_type == dict: all_keys = set(list(old.keys()) + list(new.keys())) return { - key: _recursive_diff(old.get(key), new.get(key, _unset), depth=depth + 1) + key: _recursive_diff(old.get(key, _unset), new.get(key, _unset), depth=depth + 1) for key in all_keys } elif depth < maxdepth and is_iter(new): @@ -396,7 +399,7 @@ def prototype_diff(prototype1, prototype2, maxdepth=2, homogenize=False, implici new_map = {part[0] if is_iter(part) else part: part for part in new} all_keys = set(list(old_map.keys()) + list(new_map.keys())) return { - key: _recursive_diff(old_map.get(key), new_map.get(key, _unset), depth=depth + 1) + key: _recursive_diff(old_map.get(key, _unset), new_map.get(key, _unset), depth=depth + 1) for key in all_keys } elif old != new: From 15f1eaaac0c528d5d19b18dec498d5800c207d97 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 28 Mar 2020 20:56:01 +0100 Subject: [PATCH 07/13] Hide prototypes from CmdScripts output. Resolves #2067 --- evennia/commands/default/system.py | 4 ++++ evennia/prototypes/menus.py | 4 ++-- evennia/prototypes/prototypes.py | 6 +++--- evennia/prototypes/spawner.py | 14 +++++++------- evennia/prototypes/tests.py | 2 +- 5 files changed, 17 insertions(+), 13 deletions(-) diff --git a/evennia/commands/default/system.py b/evennia/commands/default/system.py index b6ff6ba25c..4124d0423e 100644 --- a/evennia/commands/default/system.py +++ b/evennia/commands/default/system.py @@ -487,6 +487,8 @@ class CmdScripts(COMMAND_DEFAULT_CLASS): locks = "cmd:perm(listscripts) or perm(Admin)" help_category = "System" + excluded_typeclass_paths = ["evennia.prototypes.prototypes.DbPrototype"] + def func(self): """implement method""" @@ -519,6 +521,8 @@ class CmdScripts(COMMAND_DEFAULT_CLASS): if not scripts: caller.msg("No scripts are running.") return + # filter any found scripts by tag category. + scripts = scripts.exclude(db_typeclass_path__in=self.excluded_typeclass_paths) if not scripts: string = "No scripts found with a key '%s', or on an object named '%s'." % (args, args) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 854f9e7ced..1024b9d63f 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -1488,7 +1488,7 @@ def node_tags(caller): 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( - tag_category=protlib._PROTOTYPE_TAG_CATEGORY + tag_category=protlib.PROTOTYPE_TAG_CATEGORY ) text = (text, helptext) @@ -2182,7 +2182,7 @@ def _format_diff_text_and_options(diff, minimal=True, **kwargs): texts.append(" |c[{optnum}] |yADD|n: {new}".format( optnum=optnum, new=_visualize(new, rootname))) elif instruction == "REMOVE" and not new: - if rootname == "tags" and old[1] == protlib._PROTOTYPE_TAG_CATEGORY: + if rootname == "tags" and old[1] == protlib.PROTOTYPE_TAG_CATEGORY: # special exception for the prototype-tag mechanism # this is added post-spawn automatically and should # not be listed as REMOVE. diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 7e76ee515e..fcadc55488 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -52,7 +52,7 @@ _PROTOTYPE_RESERVED_KEYS = _PROTOTYPE_META_NAMES + ( "tags", "attrs", ) -_PROTOTYPE_TAG_CATEGORY = "from_prototype" +PROTOTYPE_TAG_CATEGORY = "from_prototype" _PROTOTYPE_TAG_META_CATEGORY = "db_prototype" PROT_FUNCS = {} @@ -263,7 +263,7 @@ def save_prototype(prototype): stored_prototype = stored_prototype[0] stored_prototype.desc = in_prototype["prototype_desc"] if prototype_tags: - stored_prototype.tags.clear(category=_PROTOTYPE_TAG_CATEGORY) + stored_prototype.tags.clear(category=PROTOTYPE_TAG_CATEGORY) stored_prototype.tags.batch_add(*in_prototype["prototype_tags"]) stored_prototype.locks.add(in_prototype["prototype_locks"]) stored_prototype.attributes.add("prototype", in_prototype) @@ -421,7 +421,7 @@ def search_objects_with_prototype(prototype_key): matches (Queryset): All matching objects spawned from this prototype. """ - return ObjectDB.objects.get_by_tag(key=prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + return ObjectDB.objects.get_by_tag(key=prototype_key, category=PROTOTYPE_TAG_CATEGORY) def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 5ba3cd4b62..222e4ed49c 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -145,7 +145,7 @@ from evennia.prototypes.prototypes import ( value_to_obj, value_to_obj_or_any, init_spawn_value, - _PROTOTYPE_TAG_CATEGORY, + PROTOTYPE_TAG_CATEGORY, ) @@ -269,7 +269,7 @@ 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 = obj.tags.get(category=PROTOTYPE_TAG_CATEGORY, return_list=True) if prot: prot = protlib.search_prototype(prot[0]) @@ -625,7 +625,7 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None, exac prototype_key = new_prototype["prototype_key"] if not objects: - objects = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + objects = ObjectDB.objects.get_by_tag(prototype_key, category=PROTOTYPE_TAG_CATEGORY) if not objects: return 0 @@ -640,7 +640,7 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None, exac for obj in objects: do_save = False - old_prot_key = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) + 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 try: @@ -743,8 +743,8 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None, exac logger.log_trace(f"Failed to apply prototype '{prototype_key}' to {obj}.") finally: # we must always make sure to re-add the prototype tag - obj.tags.clear(category=_PROTOTYPE_TAG_CATEGORY) - obj.tags.add(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + obj.tags.clear(category=PROTOTYPE_TAG_CATEGORY) + obj.tags.add(prototype_key, category=PROTOTYPE_TAG_CATEGORY) if do_save: changed += 1 @@ -927,7 +927,7 @@ def spawn(*prototypes, **kwargs): 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)) + tags.append((prototype_key, PROTOTYPE_TAG_CATEGORY)) val = prot.pop("exec", "") execs = init_spawn_value(val, make_iter) diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 107e2a127f..2c7d5f0426 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -851,7 +851,7 @@ 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"] + obj.tags.get(category=spawner.PROTOTYPE_TAG_CATEGORY), self.test_prot["prototype_key"] ) # update helpers From 21f76560b8061259a8c8ed068acae0bf8b9d7ba9 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 29 Mar 2020 00:12:16 +0100 Subject: [PATCH 08/13] Fix unit tests --- evennia/commands/default/building.py | 40 +++++++++++++++++------ evennia/commands/default/tests.py | 20 ++++++++---- evennia/prototypes/prototypes.py | 2 +- evennia/prototypes/spawner.py | 18 +++++++---- evennia/prototypes/tests.py | 48 ++++++++++++++-------------- 5 files changed, 81 insertions(+), 47 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index e0f125079e..8ce409300f 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -3330,7 +3330,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # handle the search result err = None if not prototypes: - err = f"No prototype named '{prototype_key}'." + err = f"No prototype named '{prototype_key}' was found." elif nprots > 1: err = "Found {} prototypes matching '{}':\n {}".format( nprots, @@ -3430,7 +3430,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if prototypes: return "\n".join(protlib.prototype_to_str(prot) for prot in prototypes) elif query: - self.caller.msg(f"No prototype found to match the query '{query}'.") + self.caller.msg(f"No prototype named '{query}' was found.") else: self.caller.msg(f"No prototypes found.") @@ -3583,24 +3583,44 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # store a prototype to the database store if not self.args: caller.msg( - "Usage: spawn/save [;desc[;tag,tag[,...][;lockstring]]] = " + "Usage: spawn/save [[;desc[;tag,tag[,...][;lockstring]]]] = " ) return + if self.rhs: + # input on the form key = prototype + prototype_key, prototype_desc, prototype_tags = self._parse_key_desc_tags(self.lhs) + prototype_key = None if not prototype_key else prototype_key + prototype_desc = None if not prototype_desc else prototype_desc + prototype_tags = None if not prototype_tags else prototype_tags + prototype_input = self.rhs.strip() + else: + prototype_key = prototype_desc = None + prototype_tags = None + prototype_input = self.lhs.strip() - prototype_key, prototype_desc, prototype_tags = self._parse_key_desc_tags(self.lhs) - - # handle rhs: - prototype = self._parse_prototype(self.rhs.strip()) + # handle parsing + prototype = self._parse_prototype(prototype_input) if not prototype: return - if prototype.get("prototype_key") != prototype_key: + prot_prototype_key = prototype.get("prototype_key") + + if not (prototype_key or prot_prototype_key): + caller.msg("A prototype_key must be given, either as `prototype_key = ` " + "or as a key 'prototype_key' inside the prototype structure.") + return + + if prototype_key is None: + prototype_key = prot_prototype_key + + if prot_prototype_key != prototype_key: caller.msg("(Replacing `prototype_key` in prototype with given key.)") prototype['prototype_key'] = prototype_key - if prototype_desc and prototype.get("prototype_desc") != prototype_desc: + + if prototype_desc is not None and prot_prototype_key != prototype_desc: caller.msg("(Replacing `prototype_desc` in prototype with given desc.)") prototype['prototype_desc'] = prototype_desc - if prototype_tags and prototype.get("prototype_tags") != prototype_tags: + if prototype_tags is not None and prototype.get("prototype_tags") != prototype_tags: caller.msg("(Replacing `prototype_tags` in prototype with given tag(s))" ) prototype['prototype_tags'] = prototype_tags diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index d41a1ca4ca..4edaccb51d 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -1228,13 +1228,22 @@ class TestBuilding(CommandTest): inputs=["y"], ) + self.call( + building.CmdSpawn(), + "/save testprot2 = {'key':'Test Char', " + "'typeclass':'evennia.objects.objects.DefaultCharacter'}", + "(Replacing `prototype_key` in prototype with given key.)|Saved prototype: testprot2", + inputs=["y"], + ) + self.call(building.CmdSpawn(), "/search ", "Key ") self.call(building.CmdSpawn(), "/search test;test2", "") self.call( building.CmdSpawn(), "/save {'key':'Test Char', " "'typeclass':'evennia.objects.objects.DefaultCharacter'}", - "To save a prototype it must have the 'prototype_key' set.", + "A prototype_key must be given, either as `prototype_key = ` or as " + "a key 'prototype_key' inside the prototype structure.", ) self.call(building.CmdSpawn(), "/list", "Key ") @@ -1312,7 +1321,7 @@ class TestBuilding(CommandTest): 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' was found.") # Test listing commands self.call(building.CmdSpawn(), "/list", "Key ") @@ -1343,13 +1352,12 @@ class TestBuilding(CommandTest): # spawn/edit with invalid prototype msg = self.call( - building.CmdSpawn(), "/edit NO_EXISTS", "No prototype 'NO_EXISTS' was found." + building.CmdSpawn(), "/edit NO_EXISTS", "No prototype named 'NO_EXISTS' was found." ) # spawn/examine (missing prototype) # lists all prototypes that exist - msg = self.call(building.CmdSpawn(), "/examine") - assert "testball" in msg and "testprot" in msg + self.call(building.CmdSpawn(), "/examine", "You need to specify a prototype-key to show.") # spawn/examine with valid prototype # prints the prototype @@ -1358,7 +1366,7 @@ class TestBuilding(CommandTest): # spawn/examine with invalid prototype # shows error - self.call(building.CmdSpawn(), "/examine NO_EXISTS", "No prototype 'NO_EXISTS' was found.") + self.call(building.CmdSpawn(), "/examine NO_EXISTS", "No prototype named 'NO_EXISTS' was found.") class TestComms(CommandTest): diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index fcadc55488..5d29d15b44 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -56,7 +56,7 @@ PROTOTYPE_TAG_CATEGORY = "from_prototype" _PROTOTYPE_TAG_META_CATEGORY = "db_prototype" PROT_FUNCS = {} -_PROTOTYPE_FALLBACK_LOCK = "spawn:all();edit:perm(Admin)" +_PROTOTYPE_FALLBACK_LOCK = "spawn:all();edit:all()" class PermissionError(RuntimeError): diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 222e4ed49c..fc8015bfbd 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -166,6 +166,18 @@ _PROTOTYPE_ROOT_NAMES = ( _NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES +class Unset: + """ + Helper class representing a non-set diff element. + + """ + def __bool__(self): + return False + def __str__(self): + return "" + + + # Helper @@ -351,12 +363,6 @@ def prototype_diff(prototype1, prototype2, maxdepth=2, homogenize=False, implici instruction can be one of "REMOVE", "ADD", "UPDATE" or "KEEP". """ - class Unset: - def __bool__(self): - return False - def __str__(self): - return "" - _unset = Unset() def _recursive_diff(old, new, depth=0): diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 2c7d5f0426..dfa592adbb 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -11,7 +11,7 @@ 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 import protfuncs as protofuncs +from evennia.prototypes import protfuncs as protofuncs, spawner from evennia.prototypes.prototypes import _PROTOTYPE_TAG_META_CATEGORY @@ -212,22 +212,21 @@ class TestUtils(EvenniaTest): "puppet:pperm(Developer);tell:perm(Admin);view:all()", "KEEP", ), - "prototype_tags": {}, + "prototype_tags": (None, None, 'KEEP'), "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"), + "desc": (("desc", "changed desc", None, ""), None, "KEEP"), + "fooattr": (Something, ("fooattr", "fooattrval", None, ""), "ADD"), "test": ( ("test", "testval", None, ""), ("test", "testval_changed", None, ""), "UPDATE", ), - "new": (None, ("new", "new_val", None, ""), "ADD"), + "new": (Something, ("new", "new_val", None, ""), "ADD"), }, "key": ("Obj", "Obj", "KEEP"), "typeclass": ( @@ -246,7 +245,7 @@ class TestUtils(EvenniaTest): spawner.flatten_diff(pdiff), { "aliases": "REMOVE", - "attrs": "REPLACE", + "attrs": "UPDATE", "home": "KEEP", "key": "KEEP", "location": "KEEP", @@ -270,7 +269,9 @@ class TestUtils(EvenniaTest): new_prot = spawner.prototype_from_object(self.obj1) self.assertEqual( { + "aliases": ['foo'], "attrs": [ + ("desc", "changed desc", None, ""), ("fooattr", "fooattrval", None, ""), ("new", "new_val", None, ""), ("oldtest", "to_keep", None, ""), @@ -293,6 +294,9 @@ class TestUtils(EvenniaTest): "view:all()", ] ), + 'tags': [ + ('footag', 'foocategory', None), + (Something, 'from_prototype', None)], "permissions": ["builder"], "prototype_desc": "Built from Obj", "prototype_key": Something, @@ -912,24 +916,20 @@ class TestMenuModule(EvenniaTest): texts, options = olc_menus._format_diff_text_and_options(obj_diff) self.assertEqual( - "\n".join(texts), - "- |wattrs:|n \n" - " |gKEEP|W:|n desc |W=|n This is User #1. |W(category:|n None|W, locks:|n |W)|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" - "- |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", + "\n".join(txt.strip() for txt in texts), + "- |wattrs:|n |c[1] |yADD|n: foo |W=|n bar |W(category:|n None|W, locks:|n |W)|n" + "\n- |whome:|n" + "\n- |wkey:|n" + "\n- |wlocks:|n" + "\n- |wpermissions:|n" + "\n- |wprototype_desc:|n |c[2] |rREMOVE|n: Testobject build" + "\n- |wprototype_key:|n" + "\n- |wprototype_locks:|n" + "\n- |wprototype_tags:|n" + "\n- |wtags:|n |c[3] |yADD|n: foo |W(category:|n None|W)|n" + "\n- |wtypeclass:|n" ) + self.assertEqual( options, [ From a623fa0ee3eb9398fbbc3c006752caf2f8f50f3d Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 29 Mar 2020 10:06:07 +0200 Subject: [PATCH 09/13] Don't raise but instead return HttpResponseBadRequest object --- evennia/web/website/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/web/website/views.py b/evennia/web/website/views.py index 3092a6db9f..02b1d21d56 100644 --- a/evennia/web/website/views.py +++ b/evennia/web/website/views.py @@ -1113,7 +1113,7 @@ class HelpDetailView(HelpMixin, EvenniaDetailView): # Check if this object was requested in a valid manner if not obj: - raise HttpResponseBadRequest( + return HttpResponseBadRequest( "No %(verbose_name)s found matching the query" % {"verbose_name": queryset.model._meta.verbose_name} ) From f41034e6a725587c42615c0772b259683956a5bf Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 29 Mar 2020 14:50:01 +0200 Subject: [PATCH 10/13] Refactor EvMore to handle queryset pagination. Resolves #1994. --- CHANGELOG.md | 6 +- evennia/commands/default/system.py | 9 +- evennia/utils/evmore.py | 217 ++++++++++++++++++++--------- 3 files changed, 159 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac78239c78..92c3758b52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,11 +34,13 @@ without arguments starts a full interactive Python console. - Allow running Evennia test suite from core repo with `make test`. - Return `store_key` from `TickerHandler.add` and add `store_key` as a kwarg to the `TickerHandler.remove` method. This makes it easier to manage tickers. -- EvMore `text` argument can now also be a list - each entry in the list is run - through str(eval()) and ends up on its own line. Good for paginated object lists. - EvMore auto-justify now defaults to False since this works better with all types of texts (such as tables). New `justify` bool. Old `justify_kwargs` remains but is now only used to pass extra kwargs into the justify function. +- EvMore `text` argument can now also be a list or a queryset. Querysets will be + sliced to only return the required data per page. EvMore takes a new kwarg + `page_formatter` which will be called for each page. This allows to customize + the display of queryset data, build a new EvTable per page etc. - Improve performance of `find` and `objects` commands on large data sets (strikaco) - New `CHANNEL_HANDLER_CLASS` setting allows for replacing the ChannelHandler entirely. - Made `py` interactive mode support regular quit() and more verbose. diff --git a/evennia/commands/default/system.py b/evennia/commands/default/system.py index 4124d0423e..2c67951f73 100644 --- a/evennia/commands/default/system.py +++ b/evennia/commands/default/system.py @@ -542,19 +542,20 @@ class CmdScripts(COMMAND_DEFAULT_CLASS): # import pdb # DEBUG # pdb.set_trace() # DEBUG ScriptDB.objects.validate() # just to be sure all is synced + caller.msg(string) else: # multiple matches. - string = "Multiple script matches. Please refine your search:\n" - string += format_script_list(scripts) + EvMore(caller, scripts, page_formatter=format_script_list) + caller.msg("Multiple script matches. Please refine your search") elif self.switches and self.switches[0] in ("validate", "valid", "val"): # run validation on all found scripts nr_started, nr_stopped = ScriptDB.objects.validate(scripts=scripts) string = "Validated %s scripts. " % ScriptDB.objects.all().count() string += "Started %s and stopped %s scripts." % (nr_started, nr_stopped) + caller.msg(string) else: # No stopping or validation. We just want to view things. - string = format_script_list(scripts) - EvMore(caller, string) + EvMore(caller, scripts, page_formatter=format_script_list) class CmdObjects(COMMAND_DEFAULT_CLASS): diff --git a/evennia/utils/evmore.py b/evennia/utils/evmore.py index 62fd47654a..1e0e6e93aa 100644 --- a/evennia/utils/evmore.py +++ b/evennia/utils/evmore.py @@ -28,9 +28,10 @@ caller.msg() construct every time the page is updated. """ from django.conf import settings +from django.db.models.query import QuerySet from evennia import Command, CmdSet from evennia.commands import cmdhandler -from evennia.utils.utils import justify, make_iter +from evennia.utils.utils import make_iter, inherits_from, justify _CMD_NOMATCH = cmdhandler.CMD_NOMATCH _CMD_NOINPUT = cmdhandler.CMD_NOINPUT @@ -117,6 +118,11 @@ class CmdSetMore(CmdSet): self.add(CmdMoreLook()) +# resources for handling queryset inputs +def queryset_maxsize(qs): + return qs.count() + + class EvMore(object): """ The main pager object @@ -132,6 +138,7 @@ class EvMore(object): justify_kwargs=None, exit_on_lastpage=False, exit_cmd=None, + page_formatter=str, **kwargs, ): @@ -149,7 +156,7 @@ class EvMore(object): decorations will be considered in the size of the page. - Otherwise `text` is converted to an iterator, where each step is expected to be a line in the final display. Each line - will be run through repr() (so one could pass a list of objects). + will be run through `iter_callable`. always_page (bool, optional): If `False`, the pager will only kick in if `text` is too big to fit the screen. @@ -168,6 +175,12 @@ class EvMore(object): 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). + page_formatter (callable, optional): If given, this function will be passed the + contents of each extracted page. This is useful when paginating + data consisting something other than a string or a list of strings. Especially + queryset data is likely to always need this argument specified. Note however, + that all size calculations assume this function to return one single line + per element on the page! kwargs (any, optional): These will be passed on to the `caller.msg` method. Examples: @@ -186,13 +199,7 @@ class EvMore(object): """ self._caller = caller - self._kwargs = kwargs - self._pages = [] - self._npages = 1 - self._npos = 0 - self.exit_on_lastpage = exit_on_lastpage - self.exit_cmd = exit_cmd - self._exit_msg = "Exited |wmore|n pager." + self._always_page = always_page if not session: # if not supplied, use the first session to @@ -203,81 +210,141 @@ class EvMore(object): session = sessions[0] self._session = session + self._justify = justify + self._justify_kwargs = justify_kwargs + self.exit_on_lastpage = exit_on_lastpage + self.exit_cmd = exit_cmd + self._exit_msg = "Exited |wmore|n pager." + self._page_formatter = page_formatter + self._kwargs = kwargs + + self._data = None + self._paginator = None + self._pages = [] + self._npages = 1 + self._npos = 0 + # set up individual pages for different sessions height = max(4, session.protocol_flags.get("SCREENHEIGHT", {0: _SCREEN_HEIGHT})[0] - 4) - width = session.protocol_flags.get("SCREENWIDTH", {0: _SCREEN_WIDTH})[0] + self.width = session.protocol_flags.get("SCREENWIDTH", {0: _SCREEN_WIDTH})[0] + # always limit number of chars to 10 000 per page + self.height = min(10000 // max(1, self.width), height) - if hasattr(text, "table") and hasattr(text, "get"): - # This is an EvTable. - - table = text - - if table.height: - # enforced height of each paged table, plus space for evmore extras - height = table.height - 4 - - # convert table to string - text = str(text) - justify_kwargs = None # enforce - - if not isinstance(text, str): - # not a string - pre-set pages of some form - text = "\n".join(str(repr(element)) for element in make_iter(text)) - - if "\f" in text: - # we use \f to indicate the user wants to enforce their line breaks - # on their own. If so, we do no automatic line-breaking/justification - # at all. - self._pages = text.split("\f") - self._npages = len(self._pages) + if inherits_from(text, "evennia.utils.evtable.EvTable"): + # an EvTable + self.init_evtable(text) + elif isinstance(text, QuerySet): + # a queryset + self.init_queryset(text) + elif not isinstance(text, str): + # anything else not a str + self.init_iterable(text) + elif "\f" in text: + # string with \f line-break markers in it + self.init_f_str(text) else: - if justify: - # we must break very long lines into multiple ones. Note that this - # will also remove spurious whitespace. - justify_kwargs = justify_kwargs or {} - width = justify_kwargs.get("width", width) - justify_kwargs["width"] = width - justify_kwargs["align"] = justify_kwargs.get("align", "l") - justify_kwargs["indent"] = justify_kwargs.get("indent", 0) + # a string + self.init_str(text) - lines = [] - for line in text.split("\n"): - if len(line) > width: - lines.extend(justify(line, **justify_kwargs).split("\n")) - else: - lines.append(line) - else: - # no justification. Simple division by line - lines = text.split("\n") + # kick things into gear + self.start() - # always limit number of chars to 10 000 per page - height = min(10000 // max(1, width), height) + # page formatter - # figure out the pagination - self._pages = ["\n".join(lines[i : i + height]) for i in range(0, len(lines), height)] - self._npages = len(self._pages) + def format_page(self, page): + """ + Page formatter. Uses the page_formatter callable by default. + This allows to easier override the class if needed. + """ + return self._page_formatter(page) - if self._npages <= 1 and not always_page: - # no need for paging; just pass-through. - caller.msg(text=self._get_page(0), session=self._session, **kwargs) + # paginators - responsible for extracting a specific page number + + def paginator_index(self, pageno): + """Paginate to specific, known index""" + return self._data[pageno] + + def paginator_slice(self, pageno): + """ + Paginate by slice. This is done with an eye on memory efficiency (usually for + querysets); to avoid fetching all objects at the same time. + """ + return self._data[pageno * self.height: pageno * self.height + self.height] + + # inits for different input types + + def init_evtable(self, table): + """The input is an EvTable.""" + if table.height: + # enforced height of each paged table, plus space for evmore extras + self.height = table.height - 4 + + # convert table to string + text = str(table) + self._justify = False + self._justify_kwargs = None # enforce + self.init_str(text) + + def init_queryset(self, qs): + """The input is a queryset""" + nsize = qs.count() # we assume each will be a line + self._npages = nsize // self.height + (0 if nsize % self.height == 0 else 1) + self._data = qs + self._paginator = self.paginator_slice + + def init_iterable(self, inp): + """The input is something other than a string - convert to iterable of strings""" + inp = make_iter(inp) + nsize = len(inp) + self._npages = nsize // self.height + (0 if nsize % self.height == 0 else 1) + self._data = inp + self._paginator_slice + + def init_f_str(self, text): + """ + The input contains \f markers. We use \f to indicate the user wants to + enforce their line breaks on their own. If so, we do no automatic + line-breaking/justification at all. + """ + self._data = text.split("\f") + self._npages = len(self._data) + self._paginator = self.paginator_index + + def init_str(self, text): + """The input is a string""" + + if self._justify: + # we must break very long lines into multiple ones. Note that this + # will also remove spurious whitespace. + justify_kwargs = self._justify_kwargs or {} + width = self._justify_kwargs.get("width", self.width) + justify_kwargs["width"] = width + justify_kwargs["align"] = self._justify_kwargs.get("align", "l") + justify_kwargs["indent"] = self._justify_kwargs.get("indent", 0) + + lines = [] + for line in text.split("\n"): + if len(line) > width: + lines.extend(justify(line, **justify_kwargs).split("\n")) + else: + lines.append(line) else: - # go into paging mode - # first pass on the msg kwargs - caller.ndb._more = self - caller.cmdset.add(CmdSetMore) + # no justification. Simple division by line + lines = text.split("\n") - # goto top of the text - self.page_top() + self._data = ["\n".join(lines[i: i + self.height]) + for i in range(0, len(lines), self.height)] + self._npages = len(self._data) + self._paginator = self.paginator_index - def _get_page(self, pos): - return self._pages[pos] + # display helpers and navigation def display(self, show_footer=True): """ Pretty-print the page. """ pos = self._npos - text = self._get_page(pos) + text = self.format_page(self._paginator(pos)) if show_footer: page = _DISPLAY.format(text=text, pageno=pos + 1, pagemax=self._npages) else: @@ -340,6 +407,22 @@ class EvMore(object): if self.exit_cmd: self._caller.execute_cmd(self.exit_cmd, session=self._session) + def start(self): + """ + Starts the pagination + """ + if self._npages <= 1 and not self._always_page: + # no need for paging; just pass-through. + self.display(show_footer=False) + else: + # go into paging mode + # first pass on the msg kwargs + self._caller.ndb._more = self + self._caller.cmdset.add(CmdSetMore) + + # goto top of the text + self.page_top() + # helper function From 6c3a7367abf447feb9b59fb787ac6bb2ba988b3d Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 29 Mar 2020 15:30:28 +0200 Subject: [PATCH 11/13] Fix shift bug in ANSIString.__mult__. Resolves #2030 --- evennia/utils/ansi.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/evennia/utils/ansi.py b/evennia/utils/ansi.py index 21d1abd180..7129cc8627 100644 --- a/evennia/utils/ansi.py +++ b/evennia/utils/ansi.py @@ -821,6 +821,8 @@ class ANSIString(str, metaclass=ANSIMeta): by a number. """ + if not offset: + return [] return [i + offset for i in iterable] @classmethod @@ -1063,7 +1065,7 @@ class ANSIString(str, metaclass=ANSIMeta): clean_string = self._clean_string * other code_indexes = self._code_indexes[:] char_indexes = self._char_indexes[:] - for i in range(1, other + 1): + for i in range(other): code_indexes.extend(self._shifter(self._code_indexes, i * len(self._raw_string))) char_indexes.extend(self._shifter(self._char_indexes, i * len(self._raw_string))) return ANSIString( From b94767b85a1001d3ea1750e8842601a1ab8b39be Mon Sep 17 00:00:00 2001 From: trhr Date: Mon, 6 Apr 2020 01:00:06 -0500 Subject: [PATCH 12/13] Typo --- 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 2cb7ac8843..6fb4870dae 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -59,7 +59,7 @@ class Attribute(SharedMemoryModel): # Attribute Database Model setup # # These database fields are all set using their corresponding properties, - # named same as the field, but withtout the db_* prefix. + # named same as the field, but without the db_* prefix. db_key = models.CharField("key", max_length=255, db_index=True) db_value = PickledObjectField( "value", From fefe56741e1f8bbd629c283b0921e3ba6e5c1fef Mon Sep 17 00:00:00 2001 From: trhr Date: Mon, 6 Apr 2020 01:18:38 -0500 Subject: [PATCH 13/13] Typo --- evennia/accounts/accounts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index f3217e88ea..11a0aa41d6 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -4,7 +4,7 @@ Typeclass for Account objects Note that this object is primarily intended to store OOC information, not game info! This object represents the actual user (not their -character) and has NO actual precence in the +character) and has NO actual presence in the game world (this is handled by the associated character object, so you should customize that instead for most things).