Note

You are reading an old version of the Evennia documentation. The latest version is here

.

# Source code for evennia.prototypes.menus

    """

    OLC Prototype menu nodes

    """

    import json
    import re
    from random import choice

    from django.conf import settings
    from django.db.models import Q

    from evennia.locks.lockhandler import get_all_lockfuncs
    from evennia.objects.models import ObjectDB
    from evennia.prototypes import prototypes as protlib
    from evennia.prototypes import spawner
    from evennia.utils import evmore, utils
    from evennia.utils.ansi import strip_ansi
    from evennia.utils.evmenu import EvMenu, list_node

    # ------------------------------------------------------------
    #
    # OLC Prototype design menu
    #
    # ------------------------------------------------------------

    _MENU_CROP_WIDTH = 15
    _MENU_ATTR_LITERAL_EVAL_ERROR = (
        "|rCritical Python syntax error in your value. Only primitive Python structures are allowed.\n"
        "You also need to use correct Python syntax. Remember especially to put quotes around all "
        "strings inside lists and dicts.|n"
    )

    # Helper functions

    def _get_menu_prototype(caller):
        """Return currently active menu prototype."""
        prototype = None
        if hasattr(caller.ndb._menutree, "olc_prototype"):
            prototype = caller.ndb._menutree.olc_prototype
        if not prototype:
            caller.ndb._menutree.olc_prototype = prototype = {}
            caller.ndb._menutree.olc_new = True
        return prototype

    def _get_flat_menu_prototype(caller, refresh=False, validate=False):
        """Return prototype where parent values are included"""
        flat_prototype = None
        if not refresh and hasattr(caller.ndb._menutree, "olc_flat_prototype"):
            flat_prototype = caller.ndb._menutree.olc_flat_prototype
        if not flat_prototype:
            prot = _get_menu_prototype(caller)
            caller.ndb._menutree.olc_flat_prototype = flat_prototype = spawner.flatten_prototype(
                prot, validate=validate
            )
        return flat_prototype

    def _get_unchanged_inherited(caller, protname):
        """Return prototype values inherited from parent(s), which are not replaced in child"""
        prototype = _get_menu_prototype(caller)
        if protname in prototype:
            return protname[protname], False
        else:
            flattened = _get_flat_menu_prototype(caller)
            if protname in flattened:
                return protname[protname], True
        return None, False

    def _set_menu_prototype(caller, prototype):
        """Set the prototype with existing one"""
        caller.ndb._menutree.olc_prototype = prototype
        caller.ndb._menutree.olc_new = False
        return prototype

    def _is_new_prototype(caller):
        """Check if prototype is marked as new or was loaded from a saved one."""
        return hasattr(caller.ndb._menutree, "olc_new")

    def _format_option_value(prop, required=False, prototype=None, cropper=None):
        """
        Format wizard option values.

        Args:
            prop (str): Name or value to format.
            required (bool, optional): The option is required.
            prototype (dict, optional): If given, `prop` will be considered a key in this prototype.
            cropper (callable, optional): A function to crop the value to a certain width.

        Returns:
            value (str): The formatted value.
        """
        if prototype is not None:
            prop = prototype.get(prop, "")

        out = prop
        if callable(prop):
            if hasattr(prop, "__name__"):
                out = "<{}>".format(prop.__name__)
            else:
                out = repr(prop)
        if utils.is_iter(prop):
            out = ", ".join(str(pr) for pr in prop)
        if not out and required:
            out = "|runset"
        if out:
            return " ({}|n)".format(cropper(out) if cropper else utils.crop(out, _MENU_CROP_WIDTH))
        return ""

    def _set_prototype_value(caller, field, value, parse=True):
        """Set prototype's field in a safe way."""
        prototype = _get_menu_prototype(caller)
        prototype[field] = value
        caller.ndb._menutree.olc_prototype = prototype
        return prototype

    def _set_property(caller, raw_string, **kwargs):
        """
        Add or update a property. To be called by the 'goto' option variable.

        Args:
            caller (Object, Account): The user of the wizard.
            raw_string (str): Input from user on given node - the new value to set.

        Keyword Args:
            test_parse (bool): If set (default True), parse raw_string for protfuncs and obj-refs and
                try to run result through literal_eval. The parser will be run in 'testing' mode and any
                parsing errors will shown to the user. Note that this is just for testing, the original
                given string will be what is inserted.
            prop (str): Property name to edit with `raw_string`.
            processor (callable): Converts `raw_string` to a form suitable for saving.
            next_node (str): Where to redirect to after this has run.

        Returns:
            next_node (str): Next node to go to.

        """
        prop = kwargs.get("prop", "prototype_key")
        processor = kwargs.get("processor", None)
        next_node = kwargs.get("next_node", None)

        if callable(processor):
            try:
                value = processor(raw_string)
            except Exception as err:
                caller.msg(
                    "Could not set {prop} to {value} ({err})".format(
                        prop=prop.replace("_", "-").capitalize(), value=raw_string, err=str(err)
                    )
                )
                # this means we'll re-run the current node.
                return None
        else:
            value = raw_string

        if not value:
            return next_node

        prototype = _set_prototype_value(caller, prop, value)
        caller.ndb._menutree.olc_prototype = prototype

        try:
            # TODO simple way to get rid of the u'' markers in list reprs, remove this when on py3.
            repr_value = json.dumps(value)
        except Exception:
            repr_value = value

        out = [" Set {prop} to {value} ({typ}).".format(prop=prop, value=repr_value, typ=type(value))]

        if kwargs.get("test_parse", True):
            out.append(" Simulating prototype-func parsing ...")
            parsed_value = protlib.protfunc_parser(value, testing=True, prototype=prototype)
            if parsed_value != value:
                out.append(
                    " |g(Example-)value when parsed ({}):|n {}".format(type(parsed_value), parsed_value)
                )
            else:
                out.append(" |gNo change when parsed.")

        caller.msg("\n".join(out))

        return next_node

    def _wizard_options(curr_node, prev_node, next_node, color="|W", search=False):
        """Creates default navigation options available in the wizard."""
        options = []
        if prev_node:
            options.append(
                {
                    "key": ("|wB|Wack", "b"),
                    "desc": "{color}({node})|n".format(color=color, node=prev_node.replace("_", "-")),
                    "goto": "node_{}".format(prev_node),
                }
            )
        if next_node:
            options.append(
                {
                    "key": ("|wF|Worward", "f"),
                    "desc": "{color}({node})|n".format(color=color, node=next_node.replace("_", "-")),
                    "goto": "node_{}".format(next_node),
                }
            )

        options.append({"key": ("|wI|Wndex", "i"), "goto": "node_index"})

        if curr_node:
            options.append(
                {
                    "key": ("|wV|Walidate prototype", "validate", "v"),
                    "goto": ("node_validate_prototype", {"back": curr_node}),
                }
            )
            if search:
                options.append(
                    {
                        "key": ("|wSE|Warch objects", "search object", "search", "se"),
                        "goto": ("node_search_object", {"back": curr_node}),
                    }
                )

        return options

    def _set_actioninfo(caller, string):
        caller.ndb._menutree.actioninfo = string

    def _path_cropper(pythonpath):
        "Crop path to only the last component"
        return pythonpath.split(".")[-1]

    def _validate_prototype(prototype):
        """Run validation on prototype"""

        txt = protlib.prototype_to_str(prototype)
        errors = "\n\n|g No validation errors found.|n (but errors could still happen at spawn-time)"
        err = False
        try:
            # validate, don't spawn
            spawner.spawn(prototype, only_validate=True)
        except RuntimeError as exc:
            errors = "\n\n|r{}|n".format(exc)
            err = True
        except RuntimeWarning as exc:
            errors = "\n\n|y{}|n".format(exc)
            err = True

        text = txt + errors
        return err, text

    def _format_protfuncs():
        out = []
        sorted_funcs = [
            (key, func)
            for key, func in sorted(protlib.FUNC_PARSER.callables.items(), key=lambda tup: tup[0])
        ]
        for protfunc_name, protfunc in sorted_funcs:
            out.append(
                "- |c${name}|n - |W{docs}".format(
                    name=protfunc_name,
                    docs=utils.justify(protfunc.__doc__.strip(), align="l", indent=10).strip(),
                )
            )
        return "\n       ".join(out)

    def _format_lockfuncs():
        out = []
        sorted_funcs = [
            (key, func) for key, func in sorted(get_all_lockfuncs().items(), key=lambda tup: tup[0])
        ]
        for lockfunc_name, lockfunc in sorted_funcs:
            doc = (lockfunc.__doc__ or "").strip()
            out.append(
                "- |c${name}|n - |W{docs}".format(
                    name=lockfunc_name, docs=utils.justify(doc, align="l", indent=10).strip()
                )
            )
        return "\n".join(out)

    def _format_list_actions(*args, **kwargs):
        """Create footer text for nodes with extra list actions

        Args:
            actions (str): Available actions. The first letter of the action name will be assumed
                to be a shortcut.
        Keyword Args:
            prefix (str): Default prefix to use.
        Returns:
            string (str): Formatted footer for adding to the node text.

        """
        actions = []
        prefix = kwargs.get("prefix", "|WSelect with |w<num>|W. Other actions:|n ")
        for action in args:
            actions.append("|w{}|n|W{} |w<num>|n".format(action[0], action[1:]))
        return prefix + " |W|||n ".join(actions)

    def _get_current_value(caller, keyname, comparer=None, formatter=str, only_inherit=False):
        """
        Return current value, marking if value comes from parent or set in this prototype.

        Args:
            keyname (str): Name of prototoype key to get current value of.
            comparer (callable, optional): This will be called as comparer(prototype_value,
                flattened_value) and is expected to return the value to show as the current
                or inherited one. If not given, a straight comparison is used and what is returned
                depends on the only_inherit setting.
            formatter (callable, optional)): This will be called with the result of comparer.
            only_inherit (bool, optional): If a current value should only be shown if all
                the values are inherited from the prototype parent (otherwise, show an empty string).
        Returns:
            current (str): The current value.

        """

        def _default_comparer(protval, flatval):
            if only_inherit:
                return "" if protval else flatval
            else:
                return protval if protval else flatval

        if not callable(comparer):
            comparer = _default_comparer

        prot = _get_menu_prototype(caller)
        flat_prot = _get_flat_menu_prototype(caller)

        out = ""
        if keyname in prot:
            if keyname in flat_prot:
                out = formatter(comparer(prot[keyname], flat_prot[keyname]))
                if only_inherit:
                    if str(out).strip():
                        return "|WCurrent|n {} |W(|binherited|W):|n {}".format(keyname, out)
                    return ""
                else:
                    if out:
                        return "|WCurrent|n {}|W:|n {}".format(keyname, out)
                    return "|W[No {} set]|n".format(keyname)
            elif only_inherit:
                return ""
            else:
                out = formatter(prot[keyname])
                return "|WCurrent|n {}|W:|n {}".format(keyname, out)
        elif keyname in flat_prot:
            out = formatter(flat_prot[keyname])
            if out:
                return "|WCurrent|n {} |W(|n|binherited|W):|n {}".format(keyname, out)
            else:
                return ""
        elif only_inherit:
            return ""
        else:
            return "|W[No {} set]|n".format(keyname)

    def _default_parse(raw_inp, choices, *args):
        """
        Helper to parse default input to a node decorated with the node_list decorator on
        the form l1, l 2, look 1, etc. Spaces are ignored, as is case.

        Args:
            raw_inp (str): Input from the user.
            choices (list): List of available options on the node listing (list of strings).
            args (tuples): The available actions, each specifed as a tuple (name, alias, ...)
        Returns:
            choice (str): A choice among the choices, or None if no match was found.
            action (str): The action operating on the choice, or None.

        """
        raw_inp = raw_inp.lower().strip()
        mapping = {t.lower(): tup[0] for tup in args for t in tup}
        match = re.match(r"(%s)\s*?(\d+)$" % "|".join(mapping.keys()), raw_inp)
        if match:
            action = mapping.get(match.group(1), None)
            num = int(match.group(2)) - 1
            num = num if 0 <= num < len(choices) else None
            if action is not None and num is not None:
                return choices[num], action
        return None, None

    # Menu nodes ------------------------------

    # helper nodes

    # validate prototype (available as option from all nodes)

    def node_validate_prototype(caller, raw_string, **kwargs):
        """General node to view and validate a protototype"""
        prototype = _get_flat_menu_prototype(caller, refresh=True, validate=False)
        prev_node = kwargs.get("back", "index")

        _, text = _validate_prototype(prototype)

        helptext = """
        The validator checks if the prototype's various values are on the expected form. It also tests
        any $protfuncs.

        """

        text = (text, helptext)

        options = _wizard_options(None, prev_node, None)
        options.append({"key": "_default", "goto": "node_" + prev_node})

        return text, options

    # node examine_entity

    def node_examine_entity(caller, raw_string, **kwargs):
        """
        General node to view a text and then return to previous node.  Kwargs should contain "text" for
        the text to show and 'back" pointing to the node to return to.
        """
        text = kwargs.get("text", "Nothing was found here.")
        helptext = "Use |wback|n to return to the previous node."
        prev_node = kwargs.get("back", "index")

        text = (text, helptext)

        options = _wizard_options(None, prev_node, None)
        options.append({"key": "_default", "goto": "node_" + prev_node})

        return text, options

    # node object_search

    def _search_object(caller):
        "update search term based on query stored on menu; store match too"
        try:
            searchstring = caller.ndb._menutree.olc_search_object_term.strip()
            caller.ndb._menutree.olc_search_object_matches = []
        except AttributeError:
            return []

        if not searchstring:
            caller.msg("Must specify a search criterion.")
            return []

        is_dbref = utils.dbref(searchstring)
        is_account = searchstring.startswith("*")

        if is_dbref or is_account:
            if is_dbref:
                # a dbref search
                results = caller.search(searchstring, global_search=True, quiet=True)
            else:
                # an account search
                searchstring = searchstring.lstrip("*")
                results = caller.search_account(searchstring, quiet=True)
        else:
            keyquery = Q(db_key__istartswith=searchstring)
            aliasquery = Q(
                db_tags__db_key__istartswith=searchstring, db_tags__db_tagtype__iexact="alias"
            )
            results = ObjectDB.objects.filter(keyquery | aliasquery).distinct()

        caller.msg("Searching for '{}' ...".format(searchstring))
        caller.ndb._menutree.olc_search_object_matches = results
        return ["{}(#{})".format(obj.key, obj.id) for obj in results]

    def _object_search_select(caller, obj_entry, **kwargs):
        choices = kwargs["available_choices"]
        num = choices.index(obj_entry)
        matches = caller.ndb._menutree.olc_search_object_matches
        obj = matches[num]

        if not obj.access(caller, "examine"):
            caller.msg("|rYou don't have 'examine' access on this object.|n")
            del caller.ndb._menutree.olc_search_object_term
            return "node_search_object"

        prot = spawner.prototype_from_object(obj)
        txt = protlib.prototype_to_str(prot)
        return "node_examine_entity", {"text": txt, "back": "search_object"}

    def _object_search_actions(caller, raw_inp, **kwargs):
        "All this does is to queue a search query"
        choices = kwargs["available_choices"]
        obj_entry, action = _default_parse(
            raw_inp, choices, ("examine", "e"), ("create prototype from object", "create", "c")
        )

        raw_inp = raw_inp.strip()

        if obj_entry:
            num = choices.index(obj_entry)
            matches = caller.ndb._menutree.olc_search_object_matches
            obj = matches[num]
            prot = spawner.prototype_from_object(obj)

            if action == "examine":
                if not obj.access(caller, "examine"):
                    caller.msg("\n|rYou don't have 'examine' access on this object.|n")
                    del caller.ndb._menutree.olc_search_object_term
                    return "node_search_object"

                txt = protlib.prototype_to_str(prot)
                return "node_examine_entity", {"text": txt, "back": "search_object"}
            else:
                # load prototype

                if not obj.access(caller, "edit"):
                    caller.msg("|rYou don't have access to do this with this object.|n")
                    del caller.ndb._menutree.olc_search_object_term
                    return "node_search_object"

                _set_menu_prototype(caller, prot)
                caller.msg("Created prototype from object.")
                return "node_index"
        elif raw_inp:
            caller.ndb._menutree.olc_search_object_term = raw_inp
            return "node_search_object", kwargs
        else:
            # empty input - exit back to previous node
            prev_node = "node_" + kwargs.get("back", "index")
            return prev_node

    @list_node(_search_object, _object_search_select)
    def node_search_object(caller, raw_inp, **kwargs):
        """
        Node for searching for an existing object.
        """
        try:
            matches = caller.ndb._menutree.olc_search_object_matches
        except AttributeError:
            matches = []
        nmatches = len(matches)
        prev_node = kwargs.get("back", "index")

        if matches:
            text = """
            Found {num} match{post}.

             (|RWarning: creating a prototype will |roverwrite|r |Rthe current prototype!)|n""".format(
                num=nmatches, post="es" if nmatches > 1 else ""
            )
            _set_actioninfo(
                caller,
                _format_list_actions("examine", "create prototype from object", prefix="Actions: "),
            )
        else:
            text = "Enter search criterion."

        helptext = """
            You can search objects by specifying partial key, alias or its exact #dbref. Use *query to
            search for an Account instead.

            Once having found any matches you can choose to examine it or use |ccreate prototype from
            object|n. If doing the latter, a prototype will be calculated from the selected object and
            loaded as the new 'current' prototype. This is useful for having a base to build from but be
            careful you are not throwing away any existing, unsaved, prototype work!
            """

        text = (text, helptext)

        options = _wizard_options(None, prev_node, None)
        options.append({"key": "_default", "goto": (_object_search_actions, {"back": prev_node})})

        return text, options

    # main index (start page) node

    def node_index(caller):
        prototype = _get_menu_prototype(caller)

        text = """
           |c --- Prototype wizard --- |n
           %s

           A |cprototype|n is a 'template' for |wspawning|n an in-game entity. A field of the prototype
           can either be hard-coded, left empty or scripted using |w$protfuncs|n - for example to
           randomize the value every time a new entity is spawned. The fields whose names start with
           'Prototype-' are not fields on the object itself but are used for prototype-inheritance, or
           when saving and loading.

           Select prototype field to edit. If you are unsure, start from [|w1|n]. Enter [|wh|n]elp at
           any menu node for more info.

           """
        helptxt = """
           |c- prototypes |n

           A prototype is really just a Python dictionary. When spawning, this dictionary is essentially
           passed into `|wevennia.utils.create.create_object(**prototype)|n` to create a new object. By
           using different prototypes you can customize instances of objects without having to do code
           changes to their typeclass (something which requires code access). The classical example is
           to spawn goblins with different names, looks, equipment and skill, each based on the same
           `Goblin` typeclass.

           At any time you can [|wV|n]alidate that the prototype works correctly and use it to
           [|wSP|n]awn a new entity. You can also [|wSA|n]ve|n your work, [|wLO|n]oad an existing
           prototype to [|wSE|n]arch for existing objects to use as a base. Use [|wL|n]ook to re-show a
           menu node. [|wQ|n]uit will always exit the menu and [|wH|n]elp will show context-sensitive
           help.

           |c- $protfuncs |n

           Prototype-functions (protfuncs) allow for limited scripting within a prototype. These are
           entered as a string $funcname(arg, arg, ...) and are evaluated |wat the time of spawning|n
           only.  They can also be nested for combined effects.

           {pfuncs}
           """.format(
            pfuncs=_format_protfuncs()
        )

        # If a prototype is being edited, show its key and
        # prototype_key under the title
        loaded_prototype = ""
        if "prototype_key" in prototype or "key" in prototype:
            loaded_prototype = " --- Editing: |y{}({})|n --- ".format(
                prototype.get("key", ""), prototype.get("prototype_key", "")
            )
        text = text % (loaded_prototype)

        text = (text, helptxt)

        options = []
        options.append(
            {
                "desc": "|WPrototype-Key|n|n{}".format(
                    _format_option_value("Key", "prototype_key" not in prototype, prototype, None)
                ),
                "goto": "node_prototype_key",
            }
        )
        for key in (
            "Prototype_Parent",
            "Typeclass",
            "Key",
            "Aliases",
            "Attrs",
            "Tags",
            "Locks",
            "Permissions",
            "Location",
            "Home",
            "Destination",
        ):
            required = False
            cropper = None
            if key in ("Prototype_Parent", "Typeclass"):
                required = ("prototype_parent" not in prototype) and ("typeclass" not in prototype)
            if key == "Typeclass":
                cropper = _path_cropper
            options.append(
                {
                    "desc": "{}{}|n{}".format(
                        "|W" if key == "Prototype_Parent" else "|w",
                        key.replace("_", "-"),
                        _format_option_value(key, required, prototype, cropper=cropper),
                    ),
                    "goto": "node_{}".format(key.lower()),
                }
            )
        required = False
        for key in ("Desc", "Tags", "Locks"):
            options.append(
                {
                    "desc": "|WPrototype-{}|n|n{}".format(
                        key, _format_option_value(key, required, prototype, None)
                    ),
                    "goto": "node_prototype_{}".format(key.lower()),
                }
            )

        options.extend(
            (
                {"key": ("|wV|Walidate prototype", "validate", "v"), "goto": "node_validate_prototype"},
                {"key": ("|wSA|Wve prototype", "save", "sa"), "goto": "node_prototype_save"},
                {"key": ("|wSP|Wawn prototype", "spawn", "sp"), "goto": "node_prototype_spawn"},
                {"key": ("|wLO|Wad prototype", "load", "lo"), "goto": "node_prototype_load"},
                {"key": ("|wSE|Warch objects|n", "search", "se"), "goto": "node_search_object"},
            )
        )

        return text, options

    # prototype_key node

    def _check_prototype_key(caller, key):
        old_prototype = protlib.search_prototype(key)
        olc_new = _is_new_prototype(caller)
        key = key.strip().lower()
        if old_prototype:
            old_prototype = old_prototype[0]
            # we are starting a new prototype that matches an existing
            if not caller.locks.check_lockstring(
                caller, old_prototype["prototype_locks"], access_type="edit"
            ):
                # return to the node_prototype_key to try another key
                caller.msg(
                    "Prototype '{key}' already exists and you don't "
                    "have permission to edit it.".format(key=key)
                )
                return "node_prototype_key"
            elif olc_new:
                # we are selecting an existing prototype to edit. Reset to index.
                del caller.ndb._menutree.olc_new
                caller.ndb._menutree.olc_prototype = old_prototype
                caller.msg("Prototype already exists. Reloading.")
                return "node_index"

        return _set_property(caller, key, prop="prototype_key")

    def node_prototype_key(caller):
        text = """
            The |cPrototype-Key|n uniquely identifies the prototype and is |wmandatory|n. It is used to
            find and use the prototype to spawn new entities. It is not case sensitive.

            (To set a new value, just write it and press enter)

            {current}""".format(
            current=_get_current_value(caller, "prototype_key")
        )

        helptext = """
            The prototype-key is not itself used when spawnng the new object, but is only used for
            managing, storing and loading the prototype. It must be globally unique, so existing keys
            will be checked before a new key is accepted. If an existing key is picked, the existing
            prototype will be loaded.
            """

        options = _wizard_options("prototype_key", "index", "prototype_parent")
        options.append({"key": "_default", "goto": _check_prototype_key})

        text = (text, helptext)
        return text, options

    # prototype_parents node

    def _all_prototype_parents(caller):
        """Return prototype_key of all available prototypes for listing in menu"""
        return [
            prototype["prototype_key"]
            for prototype in protlib.search_prototype()
            if "prototype_key" in prototype
        ]

    def _prototype_parent_actions(caller, raw_inp, **kwargs):
        """Parse the default Convert prototype to a string representation for closer inspection"""
        choices = kwargs.get("available_choices", [])
        prototype_parent, action = _default_parse(
            raw_inp, choices, ("examine", "e", "l"), ("add", "a"), ("remove", "r", "delete", "d")
        )

        if prototype_parent:
            # a selection of parent was made
            prototype_parent = protlib.search_prototype(key=prototype_parent)[0]
            prototype_parent_key = prototype_parent["prototype_key"]

            # which action to apply on the selection
            if action == "examine":
                # examine the prototype
                txt = protlib.prototype_to_str(prototype_parent)
                kwargs["text"] = txt
                kwargs["back"] = "prototype_parent"
                return "node_examine_entity", kwargs
            elif action == "add":
                # add/append parent
                prot = _get_menu_prototype(caller)
                current_prot_parent = prot.get("prototype_parent", None)
                if current_prot_parent:
                    current_prot_parent = utils.make_iter(current_prot_parent)
                    if prototype_parent_key in current_prot_parent:
                        caller.msg("Prototype_parent {} is already used.".format(prototype_parent_key))
                        return "node_prototype_parent"
                    else:
                        current_prot_parent.append(prototype_parent_key)
                        caller.msg("Add prototype parent for multi-inheritance.")
                else:
                    current_prot_parent = prototype_parent_key
                try:
                    if prototype_parent:
                        spawner.flatten_prototype(prototype_parent, validate=True)
                    else:
                        raise RuntimeError("Not found.")
                except RuntimeError as err:
                    caller.msg(
                        "Selected prototype-parent {} "
                        "caused Error(s):\n|r{}|n".format(prototype_parent, err)
                    )
                    return "node_prototype_parent"
                _set_prototype_value(caller, "prototype_parent", current_prot_parent)
                _get_flat_menu_prototype(caller, refresh=True)
            elif action == "remove":
                # remove prototype parent
                prot = _get_menu_prototype(caller)
                current_prot_parent = prot.get("prototype_parent", None)
                if current_prot_parent:
                    current_prot_parent = utils.make_iter(current_prot_parent)
                    try:
                        current_prot_parent.remove(prototype_parent_key)
                        _set_prototype_value(caller, "prototype_parent", current_prot_parent)
                        _get_flat_menu_prototype(caller, refresh=True)
                        caller.msg("Removed prototype parent {}.".format(prototype_parent_key))
                    except ValueError:
                        caller.msg(
                            "|rPrototype-parent {} could not be removed.".format(prototype_parent_key)
                        )
            return "node_prototype_parent"

    def _prototype_parent_select(caller, new_parent):
        ret = None
        prototype_parent = protlib.search_prototype(new_parent)
        try:
            if prototype_parent:
                spawner.flatten_prototype(prototype_parent[0], validate=True)
            else:
                raise RuntimeError("Not found.")
        except RuntimeError as err:
            caller.msg(
                "Selected prototype-parent {} " "caused Error(s):\n|r{}|n".format(new_parent, err)
            )
        else:
            ret = _set_property(
                caller,
                new_parent,
                prop="prototype_parent",
                processor=str,
                next_node="node_prototype_parent",
            )
            _get_flat_menu_prototype(caller, refresh=True)
            caller.msg("Selected prototype parent |c{}|n.".format(new_parent))
        return ret

    @list_node(_all_prototype_parents, _prototype_parent_select)
    def node_prototype_parent(caller):
        prototype = _get_menu_prototype(caller)

        prot_parent_keys = prototype.get("prototype_parent")

        text = """
            The |cPrototype Parent|n allows you to |winherit|n prototype values from another named
            prototype (given as that prototype's |wprototype_key|n).  If not changing these values in
            the current prototype, the parent's value will be used. Pick the available prototypes below.

            Note that somewhere in the prototype's parentage, a |ctypeclass|n must be specified. If no
            parent is given, this prototype must define the typeclass (next menu node).

            {current}
            """
        helptext = """
            Prototypes can inherit from one another. Changes in the child replace any values set in a
            parent. The |wtypeclass|n key must exist |wsomewhere|n in the parent chain for the
            prototype to be valid.
            """

        _set_actioninfo(caller, _format_list_actions("examine", "add", "remove"))

        ptexts = []
        if prot_parent_keys:
            for pkey in utils.make_iter(prot_parent_keys):
                prot_parent = protlib.search_prototype(pkey)
                if prot_parent:
                    prot_parent = prot_parent[0]
                    ptexts.append(
                        "|c -- {pkey} -- |n\n{prot}".format(
                            pkey=pkey, prot=protlib.prototype_to_str(prot_parent)
                        )
                    )
                else:
                    ptexts.append("Prototype parent |r{pkey} was not found.".format(pkey=pkey))

        if not ptexts:
            ptexts.append("[No prototype_parent set]")

        text = text.format(current="\n\n".join(ptexts))

        text = (text, helptext)

        options = _wizard_options("prototype_parent", "prototype_key", "typeclass", color="|W")
        options.append({"key": "_default", "goto": _prototype_parent_actions})

        return text, options

    # typeclasses node

    def _all_typeclasses(caller):
        """Get name of available typeclasses."""
        return list(
            name
            for name in sorted(utils.get_all_typeclasses("evennia.objects.models.ObjectDB").keys())
            if name != "evennia.objects.models.ObjectDB"
        )

    def _typeclass_actions(caller, raw_inp, **kwargs):
        """Parse actions for typeclass listing"""

        choices = kwargs.get("available_choices", [])
        typeclass_path, action = _default_parse(
            raw_inp, choices, ("examine", "e", "l"), ("remove", "r", "delete", "d")
        )

        if typeclass_path:
            if action == "examine":
                typeclass = utils.get_all_typeclasses().get(typeclass_path)
                if typeclass:
                    docstr = []
                    for line in typeclass.__doc__.split("\n"):
                        if line.strip():
                            docstr.append(line)
                        elif docstr:
                            break
                    docstr = "\n".join(docstr) if docstr else "<empty>"
                    txt = (
                        "Typeclass |c{typeclass_path}|n; "
                        "First paragraph of docstring:\n\n{docstring}".format(
                            typeclass_path=typeclass_path, docstring=docstr
                        )
                    )
                else:
                    txt = "This is typeclass |y{}|n.".format(typeclass)
                return "node_examine_entity", {"text": txt, "back": "typeclass"}
            elif action == "remove":
                prototype = _get_menu_prototype(caller)
                old_typeclass = prototype.pop("typeclass", None)
                if old_typeclass:
                    _set_menu_prototype(caller, prototype)
                    caller.msg("Cleared typeclass {}.".format(old_typeclass))
                else:
                    caller.msg("No typeclass to remove.")
            return "node_typeclass"

    def _typeclass_select(caller, typeclass, **kwargs):
        """Select typeclass from list and add it to prototype. Return next node to go to."""
        ret = _set_property(caller, typeclass, prop="typeclass", processor=str)
        caller.msg("Selected typeclass |c{}|n.".format(typeclass))
        return ret

    @list_node(_all_typeclasses, _typeclass_select)
    def node_typeclass(caller):
        text = """
            The |cTypeclass|n defines what 'type' of object this is - the actual working code to use.

            All spawned objects must have a typeclass. If not given here, the typeclass must be set in
            one of the prototype's |cparents|n.

            {current}
        """.format(
            current=_get_current_value(caller, "typeclass"),
            actions="|WSelect with |w<num>|W. Other actions: "
            "|we|Wxamine |w<num>|W, |wr|Wemove selection",
        )

        helptext = """
            A |nTypeclass|n is specified by the actual python-path to the class definition in the
            Evennia code structure.

            Which |cAttributes|n, |cLocks|n and other properties have special
            effects or expects certain values depend greatly on the code in play.
        """

        text = (text, helptext)

        options = _wizard_options("typeclass", "prototype_parent", "key", color="|W")
        options.append({"key": "_default", "goto": _typeclass_actions})
        return text, options

    # key node

    def node_key(caller):
        text = """
            The |cKey|n is the given name of the object to spawn. This will retain the given case.

            {current}
        """.format(
            current=_get_current_value(caller, "key")
        )

        helptext = """
            The key should often not be identical for every spawned object. Using a randomising
            $protfunc can be used, for example |c$choice(Alan, Tom, John)|n will give one of the three
            names every time an object of this prototype is spawned.

            |c$protfuncs|n
            {pfuncs}
        """.format(
            pfuncs=_format_protfuncs()
        )

        text = (text, helptext)

        options = _wizard_options("key", "typeclass", "aliases")
        options.append(
            {
                "key": "_default",
                "goto": (_set_property, dict(prop="key", processor=lambda s: s.strip())),
            }
        )
        return text, options

    # aliases node

    def _all_aliases(caller):
        "Get aliases in prototype"
        prototype = _get_menu_prototype(caller)
        return prototype.get("aliases", [])

    def _aliases_select(caller, alias):
        "Add numbers as aliases"
        aliases = _all_aliases(caller)
        try:
            ind = str(aliases.index(alias) + 1)
            if ind not in aliases:
                aliases.append(ind)
                _set_prototype_value(caller, "aliases", aliases)
                caller.msg("Added alias '{}'.".format(ind))
        except (IndexError, ValueError) as err:
            caller.msg("Error: {}".format(err))

        return "node_aliases"

    def _aliases_actions(caller, raw_inp, **kwargs):
        """Parse actions for aliases listing"""
        choices = kwargs.get("available_choices", [])
        alias, action = _default_parse(raw_inp, choices, ("remove", "r", "delete", "d"))

        aliases = _all_aliases(caller)
        if alias and action == "remove":
            try:
                aliases.remove(alias)
                _set_prototype_value(caller, "aliases", aliases)
                caller.msg("Removed alias '{}'.".format(alias))
            except ValueError:
                caller.msg("No matching alias found to remove.")
        else:
            # if not a valid remove, add as a new alias
            alias = raw_inp.lower().strip()
            if alias and alias not in aliases:
                aliases.append(alias)
                _set_prototype_value(caller, "aliases", aliases)
                caller.msg("Added alias '{}'.".format(alias))
            else:
                caller.msg("Alias '{}' was already set.".format(alias))
        return "node_aliases"

    @list_node(_all_aliases, _aliases_select)
    def node_aliases(caller):
        text = """
            |cAliases|n are alternative ways to address an object, next to its |cKey|n.  Aliases are not
            case sensitive.

            {current}
        """.format(
            current=_get_current_value(
                caller,
                "aliases",
                comparer=lambda propval, flatval: [al for al in flatval if al not in propval],
                formatter=lambda lst: "\n" + ", ".join(lst),
                only_inherit=True,
            )
        )
        _set_actioninfo(
            caller, _format_list_actions("remove", prefix="|w<text>|W to add new alias. Other action: ")
        )

        helptext = """
            Aliases are fixed alternative identifiers and are stored with the new object.

            |c$protfuncs|n

            {pfuncs}
        """.format(
            pfuncs=_format_protfuncs()
        )

        text = (text, helptext)

        options = _wizard_options("aliases", "key", "attrs")
        options.append({"key": "_default", "goto": _aliases_actions})
        return text, options

    # attributes node

    def _caller_attrs(caller):
        prototype = _get_menu_prototype(caller)
        attrs = [
            "{}={}".format(tup[0], utils.crop(utils.to_str(tup[1]), width=10))
            for tup in prototype.get("attrs", [])
        ]
        return attrs

    def _get_tup_by_attrname(caller, attrname):
        prototype = _get_menu_prototype(caller)
        attrs = prototype.get("attrs", [])
        try:
            inp = [tup[0] for tup in attrs].index(attrname)
            return attrs[inp]
        except ValueError:
            return None

    def _display_attribute(attr_tuple):
        """Pretty-print attribute tuple"""
        attrkey, value, category, locks = attr_tuple
        value = protlib.protfunc_parser(value)
        typ = type(value)
        out = "{attrkey} |c=|n {value} |W({typ}{category}{locks})|n".format(
            attrkey=attrkey,
            value=value,
            typ=typ,
            category=", category={}".format(category) if category else "",
            locks=", locks={}".format(";".join(locks)) if any(locks) else "",
        )

        return out

    def _add_attr(caller, attr_string, **kwargs):
        """
        Add new attribute, parsing input.

        Args:
            caller (Object): Caller of menu.
            attr_string (str): Input from user
                attr is entered on these forms
                    attr = value
                    attr;category = value
                    attr;category;lockstring = value
        Keyword Args:
            delete (str): If this is set, attr_string is
                considered the name of the attribute to delete and
                no further parsing happens.
        Returns:
            result (str): Result string of action.
        """
        attrname = ""
        value = ""
        category = None
        locks = ""

        if "delete" in kwargs:
            attrname = attr_string.lower().strip()
        elif "=" in attr_string:
            attrname, value = (part.strip() for part in attr_string.split("=", 1))
            attrname = attrname.lower()
            nameparts = attrname.split(";", 2)
            nparts = len(nameparts)
            if nparts == 2:
                attrname, category = nameparts
            elif nparts > 2:
                attrname, category, locks = nameparts
        attr_tuple = (attrname, value, category, str(locks))

        if attrname:
            prot = _get_menu_prototype(caller)
            attrs = prot.get("attrs", [])

            if "delete" in kwargs:
                try:
                    ind = [tup[0] for tup in attrs].index(attrname)
                    del attrs[ind]
                    _set_prototype_value(caller, "attrs", attrs)
                    return "Removed Attribute '{}'".format(attrname)
                except IndexError:
                    return "Attribute to delete not found."

            try:
                # replace existing attribute with the same name in the prototype
                ind = [tup[0] for tup in attrs].index(attrname)
                attrs[ind] = attr_tuple
                text = "Edited Attribute '{}'.".format(attrname)
            except ValueError:
                attrs.append(attr_tuple)
                text = "Added Attribute " + _display_attribute(attr_tuple)

            _set_prototype_value(caller, "attrs", attrs)
        else:
            text = "Attribute must be given as 'attrname[;category;locks] = <value>'."

        return text

    def _attr_select(caller, attrstr):
        attrname, _ = attrstr.split("=", 1)
        attrname = attrname.strip()

        attr_tup = _get_tup_by_attrname(caller, attrname)
        if attr_tup:
            return ("node_examine_entity", {"text": _display_attribute(attr_tup), "back": "attrs"})
        else:
            caller.msg("Attribute not found.")
            return "node_attrs"

    def _attrs_actions(caller, raw_inp, **kwargs):
        """Parse actions for attribute listing"""
        choices = kwargs.get("available_choices", [])
        attrstr, action = _default_parse(
            raw_inp, choices, ("examine", "e"), ("remove", "r", "delete", "d")
        )
        if attrstr is None:
            attrstr = raw_inp
        try:
            attrname, _ = attrstr.split("=", 1)
        except ValueError:
            caller.msg("|rNeed to enter the attribute on the form attrname=value.|n")
            return "node_attrs"

        attrname = attrname.strip()
        attr_tup = _get_tup_by_attrname(caller, attrname)

        if action and attr_tup:
            if action == "examine":
                return ("node_examine_entity", {"text": _display_attribute(attr_tup), "back": "attrs"})
            elif action == "remove":
                res = _add_attr(caller, attrname, delete=True)
                caller.msg(res)
        else:
            res = _add_attr(caller, raw_inp)
            caller.msg(res)
        return "node_attrs"

    @list_node(_caller_attrs, _attr_select)
    def node_attrs(caller):
        def _currentcmp(propval, flatval):
            "match by key + category"
            cmp1 = [(tup[0].lower(), tup[2].lower() if tup[2] else None) for tup in propval]
            return [
                tup
                for tup in flatval
                if (tup[0].lower(), tup[2].lower() if tup[2] else None) not in cmp1
            ]

        text = """
            |cAttributes|n are custom properties of the object. Enter attributes on one of these forms:

            attrname=value
            attrname;category=value
            attrname;category;lockstring=value

            To give an attribute without a category but with a lockstring, leave that spot empty
            (attrname;;lockstring=value). Attribute values can have embedded $protfuncs.

            {current}
        """.format(
            current=_get_current_value(
                caller,
                "attrs",
                comparer=_currentcmp,
                formatter=lambda lst: "\n" + "\n".join(_display_attribute(tup) for tup in lst),
                only_inherit=True,
            )
        )
        _set_actioninfo(caller, _format_list_actions("examine", "remove", prefix="Actions: "))

        helptext = """
            Most commonly, Attributes don't need any categories or locks. If using locks, the lock-types
            'attredit' and 'attrread' are used to limit editing and viewing of the Attribute. Putting
            the lock-type `attrcreate` in the |clocks|n prototype key can be used to restrict builders
            from adding new Attributes.

            |c$protfuncs

            {pfuncs}
        """.format(
            pfuncs=_format_protfuncs()
        )

        text = (text, helptext)

        options = _wizard_options("attrs", "aliases", "tags")
        options.append({"key": "_default", "goto": _attrs_actions})
        return text, options

    # tags node

    def _caller_tags(caller):
        prototype = _get_menu_prototype(caller)
        tags = [tup[0] for tup in prototype.get("tags", [])]
        return tags

    def _get_tup_by_tagname(caller, tagname):
        prototype = _get_menu_prototype(caller)
        tags = prototype.get("tags", [])
        try:
            inp = [tup[0] for tup in tags].index(tagname)
            return tags[inp]
        except ValueError:
            return None

    def _display_tag(tag_tuple):
        """Pretty-print tag tuple"""
        tagkey, category, data = tag_tuple
        out = "Tag: '{tagkey}' (category: {category}{dat})".format(
            tagkey=tagkey, category=category, dat=", data: {}".format(data) if data else ""
        )
        return out

    def _add_tag(caller, tag_string, **kwargs):
        """
        Add tags to the system, parsing input

        Args:
            caller (Object): Caller of menu.
            tag_string (str): Input from user on one of these forms
                tagname
                tagname;category
                tagname;category;data

        Keyword Args:
            delete (str): If this is set, tag_string is considered
                the name of the tag to delete.

        Returns:
            result (str): Result string of action.

        """
        tag = tag_string.strip().lower()
        category = None
        data = ""

        if "delete" in kwargs:
            tag = tag_string.lower().strip()
        else:
            nameparts = tag.split(";", 2)
            ntuple = len(nameparts)
            if ntuple == 2:
                tag, category = nameparts
            elif ntuple > 2:
                tag, category, data = nameparts[:3]

        tag_tuple = (tag.lower(), category.lower() if category else None, data)

        if tag:
            prot = _get_menu_prototype(caller)
            tags = prot.get("tags", [])

            old_tag = _get_tup_by_tagname(caller, tag)

            if "delete" in kwargs:
                if old_tag:
                    tags.pop(tags.index(old_tag))
                    text = "Removed Tag '{}'.".format(tag)
                else:
                    text = "Found no Tag to remove."
            elif not old_tag:
                # a fresh, new tag
                tags.append(tag_tuple)
                text = "Added Tag '{}'".format(tag)
            else:
                # old tag exists; editing a tag means replacing old with new
                ind = tags.index(old_tag)
                tags[ind] = tag_tuple
                text = "Edited Tag '{}'".format(tag)

            _set_prototype_value(caller, "tags", tags)
        else:
            text = "Tag must be given as 'tag[;category;data]'."

        return text

    def _tag_select(caller, tagname):
        tag_tup = _get_tup_by_tagname(caller, tagname)
        if tag_tup:
            return "node_examine_entity", {"text": _display_tag(tag_tup), "back": "attrs"}
        else:
            caller.msg("Tag not found.")
            return "node_attrs"

    def _tags_actions(caller, raw_inp, **kwargs):
        """Parse actions for tags listing"""
        choices = kwargs.get("available_choices", [])
        tagname, action = _default_parse(
            raw_inp, choices, ("examine", "e"), ("remove", "r", "delete", "d")
        )

        if tagname is None:
            tagname = raw_inp.lower().strip()

        tag_tup = _get_tup_by_tagname(caller, tagname)

        if tag_tup:
            if action == "examine":
                return ("node_examine_entity", {"text": _display_tag(tag_tup), "back": "tags"})
            elif action == "remove":
                res = _add_tag(caller, tagname, delete=True)
                caller.msg(res)
        else:
            res = _add_tag(caller, raw_inp)
            caller.msg(res)
        return "node_tags"

    @list_node(_caller_tags, _tag_select)
    def node_tags(caller):
        def _currentcmp(propval, flatval):
            "match by key + category"
            cmp1 = [(tup[0].lower(), tup[1].lower() if tup[2] else None) for tup in propval]
            return [
                tup
                for tup in flatval
                if (tup[0].lower(), tup[1].lower() if tup[1] else None) not in cmp1
            ]

        text = """
            |cTags|n are used to group objects so they can quickly be found later. Enter tags on one of
            the following forms:
                tagname
                tagname;category
                tagname;category;data

            {current}
        """.format(
            current=_get_current_value(
                caller,
                "tags",
                comparer=_currentcmp,
                formatter=lambda lst: "\n" + "\n".join(_display_tag(tup) for tup in lst),
                only_inherit=True,
            )
        )
        _set_actioninfo(caller, _format_list_actions("examine", "remove", prefix="Actions: "))

        helptext = """
            Tags are shared between all objects with that tag. So the 'data' field (which is not
            commonly used) can only hold eventual info about the Tag itself, not about the individual
            object on which it sits.

            All objects created with this prototype will automatically get assigned a tag named the same
            as the |cprototype_key|n and with a category "{tag_category}". This allows the spawner to
            optionally update previously spawned objects when their prototype changes.
        """.format(
            tag_category=protlib.PROTOTYPE_TAG_CATEGORY
        )

        text = (text, helptext)
        options = _wizard_options("tags", "attrs", "locks")
        options.append({"key": "_default", "goto": _tags_actions})
        return text, options

    # locks node

    def _caller_locks(caller):
        locks = _get_menu_prototype(caller).get("locks", "")
        return [lck for lck in locks.split(";") if lck]

    def _locks_display(caller, lock):
        return lock

    def _lock_select(caller, lockstr):
        return ("node_examine_entity", {"text": _locks_display(caller, lockstr), "back": "locks"})

    def _lock_add(caller, lock, **kwargs):
        locks = _caller_locks(caller)

        try:
            locktype, lockdef = lock.split(":", 1)
        except ValueError:
            return "Lockstring lacks ':'."

        locktype = locktype.strip().lower()

        if "delete" in kwargs:
            try:
                ind = locks.index(lock)
                locks.pop(ind)
                _set_prototype_value(caller, "locks", ";".join(locks), parse=False)
                ret = "Lock {} deleted.".format(lock)
            except ValueError:
                ret = "No lock found to delete."
            return ret
        try:
            locktypes = [lck.split(":", 1)[0].strip().lower() for lck in locks]
            ind = locktypes.index(locktype)
            locks[ind] = lock
            ret = "Lock with locktype '{}' updated.".format(locktype)
        except ValueError:
            locks.append(lock)
            ret = "Added lock '{}'.".format(lock)
        _set_prototype_value(caller, "locks", ";".join(locks))
        return ret

    def _locks_actions(caller, raw_inp, **kwargs):
        choices = kwargs.get("available_choices", [])
        lock, action = _default_parse(
            raw_inp, choices, ("examine", "e"), ("remove", "r", "delete", "d")
        )

        if lock:
            if action == "examine":
                return ("node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"})
            elif action == "remove":
                ret = _lock_add(caller, lock, delete=True)
                caller.msg(ret)
        else:
            ret = _lock_add(caller, raw_inp)
            caller.msg(ret)

        return "node_locks"

    @list_node(_caller_locks, _lock_select)
    def node_locks(caller):
        def _currentcmp(propval, flatval):
            "match by locktype"
            cmp1 = [lck.split(":", 1)[0] for lck in propval.split(";")]
            return ";".join(lstr for lstr in flatval.split(";") if lstr.split(":", 1)[0] not in cmp1)

        text = """
            The |cLock string|n defines limitations for accessing various properties of the object once
            it's spawned. The string should be on one of the following forms:

                locktype:[NOT] lockfunc(args)
                locktype: [NOT] lockfunc(args) [AND|OR|NOT] lockfunc(args) [AND|OR|NOT] ...

            {current}{action}
            """.format(
            current=_get_current_value(
                caller,
                "locks",
                comparer=_currentcmp,
                formatter=lambda lockstr: "\n".join(
                    _locks_display(caller, lstr) for lstr in lockstr.split(";")
                ),
                only_inherit=True,
            ),
            action=_format_list_actions("examine", "remove", prefix="Actions: "),
        )

        helptext = """
            Here is an example of two lock strings:

                edit:false()
                call:tag(Foo) OR perm(Builder)

            Above locks limit two things, 'edit' and 'call'. Which lock types are actually checked
            depend on the typeclass of the object being spawned. Here 'edit' is never allowed by anyone
            while 'call' is allowed to all accessors with a |ctag|n 'Foo' OR which has the
            |cPermission|n 'Builder'.

            |cAvailable lockfuncs:|n

            {lfuncs}
        """.format(
            lfuncs=_format_lockfuncs()
        )

        text = (text, helptext)

        options = _wizard_options("locks", "tags", "permissions")
        options.append({"key": "_default", "goto": _locks_actions})

        return text, options

    # permissions node

    def _caller_permissions(caller):
        prototype = _get_menu_prototype(caller)
        perms = prototype.get("permissions", [])
        return perms

    def _display_perm(caller, permission, only_hierarchy=False):
        hierarchy = settings.PERMISSION_HIERARCHY
        perm_low = permission.lower()
        txt = ""
        if perm_low in [prm.lower() for prm in hierarchy]:
            txt = "Permission (in hieararchy): {}".format(
                ", ".join(
                    [
                        "|w[{}]|n".format(prm) if prm.lower() == perm_low else "|W{}|n".format(prm)
                        for prm in hierarchy
                    ]
                )
            )
        elif not only_hierarchy:
            txt = "Permission: '{}'".format(permission)
        return txt

    def _permission_select(caller, permission, **kwargs):
        return (
            "node_examine_entity",
            {"text": _display_perm(caller, permission), "back": "permissions"},
        )

    def _add_perm(caller, perm, **kwargs):
        if perm:
            perm_low = perm.lower()
            perms = _caller_permissions(caller)
            perms_low = [prm.lower() for prm in perms]
            if "delete" in kwargs:
                try:
                    ind = perms_low.index(perm_low)
                    del perms[ind]
                    text = "Removed Permission '{}'.".format(perm)
                except ValueError:
                    text = "Found no Permission to remove."
            else:
                if perm_low in perms_low:
                    text = "Permission already set."
                else:
                    perms.append(perm)
                    _set_prototype_value(caller, "permissions", perms)
                    text = "Added Permission '{}'".format(perm)
            return text

    def _permissions_actions(caller, raw_inp, **kwargs):
        """Parse actions for permission listing"""
        choices = kwargs.get("available_choices", [])
        perm, action = _default_parse(
            raw_inp, choices, ("examine", "e"), ("remove", "r", "delete", "d")
        )

        if perm:
            if action == "examine":
                return (
                    "node_examine_entity",
                    {"text": _display_perm(caller, perm), "back": "permissions"},
                )
            elif action == "remove":
                res = _add_perm(caller, perm, delete=True)
                caller.msg(res)
        else:
            res = _add_perm(caller, raw_inp.strip())
            caller.msg(res)
        return "node_permissions"

    @list_node(_caller_permissions, _permission_select)
    def node_permissions(caller):
        def _currentcmp(pval, fval):
            cmp1 = [perm.lower() for perm in pval]
            return [perm for perm in fval if perm.lower() not in cmp1]

        text = """
            |cPermissions|n are simple strings used to grant access to this object. A permission is used
            when a |clock|n is checked that contains the |wperm|n or |wpperm|n lock functions. Certain
            permissions belong in the |cpermission hierarchy|n together with the |Wperm()|n lock
            function.

            {current}
        """.format(
            current=_get_current_value(
                caller,
                "permissions",
                comparer=_currentcmp,
                formatter=lambda lst: "\n" + "\n".join(prm for prm in lst),
                only_inherit=True,
            )
        )
        _set_actioninfo(caller, _format_list_actions("examine", "remove", prefix="Actions: "))

        helptext = """
            Any string can act as a permission as long as a lock is set to look for it. Depending on the
            lock, having a permission could even be negative (i.e. the lock is only passed if you
            |wdon't|n have the 'permission'). The most common permissions are the hierarchical
            permissions:

                {permissions}.

            For example, a |clock|n string like "edit:perm(Builder)" will grant access to accessors
            having the |cpermission|n "Builder" or higher.
        """.format(
            permissions=", ".join(settings.PERMISSION_HIERARCHY)
        )

        text = (text, helptext)

        options = _wizard_options("permissions", "locks", "location")
        options.append({"key": "_default", "goto": _permissions_actions})

        return text, options

    # location node

    def node_location(caller):
        text = """
            The |cLocation|n of this object in the world. If not given, the object will spawn in the
            inventory of |c{caller}|n by default.

            {current}
        """.format(
            caller=caller.key, current=_get_current_value(caller, "location")
        )

        helptext = """
            You get the most control by not specifying the location - you can then teleport the spawned
            objects as needed later. Setting the location may be useful for quickly populating a given
            location. One could also consider randomizing the location using a $protfunc.

            |c$protfuncs|n
            {pfuncs}
        """.format(
            pfuncs=_format_protfuncs()
        )

        text = (text, helptext)

        options = _wizard_options("location", "permissions", "home", search=True)
        options.append(
            {
                "key": "_default",
                "goto": (_set_property, dict(prop="location", processor=lambda s: s.strip())),
            }
        )
        return text, options

    # home node

    def node_home(caller):
        text = """
            The |cHome|n location of an object is often only used as a backup - this is where the object
            will be moved to if its location is deleted. The home location can also be used as an actual
            home for characters to quickly move back to.

            If unset, the global home default (|w{default}|n) will be used.

            {current}
            """.format(
            default=settings.DEFAULT_HOME, current=_get_current_value(caller, "home")
        )
        helptext = """
            The home can be given as a #dbref but can also be specified using the protfunc
            '$obj(name)'. Use |wSE|nearch to find objects in the database.

            The home location is commonly not used except as a backup; using the global default is often
            enough.

            |c$protfuncs|n
            {pfuncs}
        """.format(
            pfuncs=_format_protfuncs()
        )

        text = (text, helptext)

        options = _wizard_options("home", "location", "destination", search=True)
        options.append(
            {
                "key": "_default",
                "goto": (_set_property, dict(prop="home", processor=lambda s: s.strip())),
            }
        )
        return text, options

    # destination node

    def node_destination(caller):
        text = """
            The object's |cDestination|n is generally only used by Exit-like objects to designate where
            the exit 'leads to'. It's usually unset for all other types of objects.

            {current}
        """.format(
            current=_get_current_value(caller, "destination")
        )

        helptext = """
            The destination can be given as a #dbref but can also be specified using the protfunc
            '$obj(name)'. Use |wSEearch to find objects in the database.

            |c$protfuncs|n
            {pfuncs}
        """.format(
            pfuncs=_format_protfuncs()
        )

        text = (text, helptext)

        options = _wizard_options("destination", "home", "prototype_desc", search=True)
        options.append(
            {
                "key": "_default",
                "goto": (_set_property, dict(prop="destination", processor=lambda s: s.strip())),
            }
        )
        return text, options

    # prototype_desc node

    def node_prototype_desc(caller):
        text = """
            The |cPrototype-Description|n briefly describes the prototype when it's viewed in listings.

            {current}
            """.format(
            current=_get_current_value(caller, "prototype_desc")
        )

        helptext = """
            Giving a brief description helps you and others to locate the prototype for use later.
        """

        text = (text, helptext)

        options = _wizard_options("prototype_desc", "prototype_key", "prototype_tags")
        options.append(
            {
                "key": "_default",
                "goto": (
                    _set_property,
                    dict(
                        prop="prototype_desc",
                        processor=lambda s: s.strip(),
                        next_node="node_prototype_desc",
                    ),
                ),
            }
        )

        return text, options

    # prototype_tags node

    def _caller_prototype_tags(caller):
        prototype = _get_menu_prototype(caller)
        tags = prototype.get("prototype_tags", [])
        tags = [tag[0] if isinstance(tag, tuple) else tag for tag in tags]
        return tags

    def _add_prototype_tag(caller, tag_string, **kwargs):
        """
        Add prototype_tags to the system. We only support straight tags, no
        categories (category is assigned automatically).

        Args:
            caller (Object): Caller of menu.
            tag_string (str): Input from user - only tagname

        Keyword Args:
            delete (str): If this is set, tag_string is considered
                the name of the tag to delete.

        Returns:
            result (str): Result string of action.

        """
        tag = tag_string.strip().lower()

        if tag:
            tags = _caller_prototype_tags(caller)
            exists = tag in tags

            if "delete" in kwargs:
                if exists:
                    tags.pop(tags.index(tag))
                    text = "Removed Prototype-Tag '{}'.".format(tag)
                else:
                    text = "Found no Prototype-Tag to remove."
            elif not exists:
                # a fresh, new tag
                tags.append(tag)
                text = "Added Prototype-Tag '{}'.".format(tag)
            else:
                text = "Prototype-Tag already added."

            _set_prototype_value(caller, "prototype_tags", tags)
        else:
            text = "No Prototype-Tag specified."

        return text

    def _prototype_tag_select(caller, tagname):
        caller.msg("Prototype-Tag: {}".format(tagname))
        return "node_prototype_tags"

    def _prototype_tags_actions(caller, raw_inp, **kwargs):
        """Parse actions for tags listing"""
        choices = kwargs.get("available_choices", [])
        tagname, action = _default_parse(raw_inp, choices, ("remove", "r", "delete", "d"))

        if tagname:
            if action == "remove":
                res = _add_prototype_tag(caller, tagname, delete=True)
                caller.msg(res)
        else:
            res = _add_prototype_tag(caller, raw_inp.lower().strip())
            caller.msg(res)
        return "node_prototype_tags"

    @list_node(_caller_prototype_tags, _prototype_tag_select)
    def node_prototype_tags(caller):
        text = """
            |cPrototype-Tags|n can be used to classify and find prototypes in listings Tag names are not
            case-sensitive and can have not have a custom category.

            {current}
            """.format(
            current=_get_current_value(
                caller,
                "prototype_tags",
                formatter=lambda lst: ", ".join(tg for tg in lst),
                only_inherit=True,
            )
        )
        _set_actioninfo(
            caller, _format_list_actions("remove", prefix="|w<text>|n|W to add Tag. Other Action:|n ")
        )
        helptext = """
            Using prototype-tags is a good way to organize and group large numbers of prototypes by
            genre, type etc. Under the hood, prototypes' tags will all be stored with the category
            '{tagmetacategory}'.
        """.format(
            tagmetacategory=protlib._PROTOTYPE_TAG_META_CATEGORY
        )

        text = (text, helptext)

        options = _wizard_options("prototype_tags", "prototype_desc", "prototype_locks")
        options.append({"key": "_default", "goto": _prototype_tags_actions})

        return text, options

    # prototype_locks node

    def _caller_prototype_locks(caller):
        locks = _get_menu_prototype(caller).get("prototype_locks", "")
        return [lck for lck in locks.split(";") if lck]

    def _prototype_lock_select(caller, lockstr):
        return (
            "node_examine_entity",
            {"text": _locks_display(caller, lockstr), "back": "prototype_locks"},
        )

    def _prototype_lock_add(caller, lock, **kwargs):
        locks = _caller_prototype_locks(caller)

        try:
            locktype, lockdef = lock.split(":", 1)
        except ValueError:
            return "Lockstring lacks ':'."

        locktype = locktype.strip().lower()

        if "delete" in kwargs:
            try:
                ind = locks.index(lock)
                locks.pop(ind)
                _set_prototype_value(caller, "prototype_locks", ";".join(locks), parse=False)
                ret = "Prototype-lock {} deleted.".format(lock)
            except ValueError:
                ret = "No Prototype-lock found to delete."
            return ret
        try:
            locktypes = [lck.split(":", 1)[0].strip().lower() for lck in locks]
            ind = locktypes.index(locktype)
            locks[ind] = lock
            ret = "Prototype-lock with locktype '{}' updated.".format(locktype)
        except ValueError:
            locks.append(lock)
            ret = "Added Prototype-lock '{}'.".format(lock)
        _set_prototype_value(caller, "prototype_locks", ";".join(locks))
        return ret

    def _prototype_locks_actions(caller, raw_inp, **kwargs):
        choices = kwargs.get("available_choices", [])
        lock, action = _default_parse(
            raw_inp, choices, ("examine", "e"), ("remove", "r", "delete", "d")
        )

        if lock:
            if action == "examine":
                return ("node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"})
            elif action == "remove":
                ret = _prototype_lock_add(caller, lock.strip(), delete=True)
                caller.msg(ret)
        else:
            ret = _prototype_lock_add(caller, raw_inp.strip())
            caller.msg(ret)

        return "node_prototype_locks"

    @list_node(_caller_prototype_locks, _prototype_lock_select)
    def node_prototype_locks(caller):
        text = """
            |cPrototype-Locks|n are used to limit access to this prototype when someone else is trying
            to access it. By default any prototype can be edited only by the creator and by Admins while
            they can be used by anyone with access to the spawn command. There are two valid lock types
            the prototype access tools look for:

                - 'edit': Who can edit the prototype.
                - 'spawn': Who can spawn new objects with this prototype.

            If unsure, keep the open defaults.

            {current}
        """.format(
            current=_get_current_value(
                caller,
                "prototype_locks",
                formatter=lambda lstring: "\n".join(
                    _locks_display(caller, lstr) for lstr in lstring.split(";")
                ),
                only_inherit=True,
            )
        )
        _set_actioninfo(caller, _format_list_actions("examine", "remove", prefix="Actions: "))

        helptext = """
            Prototype locks can be used to vary access for different tiers of builders. It also allows
            developers to produce 'base prototypes' only meant for builders to inherit and expand on
            rather than tweak in-place.
            """

        text = (text, helptext)

        options = _wizard_options("prototype_locks", "prototype_tags", "index")
        options.append({"key": "_default", "goto": _prototype_locks_actions})

        return text, options

    # update existing objects node

    def _apply_diff(caller, **kwargs):
        """update existing objects"""
        prototype = kwargs["prototype"]
        objects = kwargs["objects"]
        back_node = kwargs["back_node"]
        diff = kwargs.get("diff", None)
        num_changed = spawner.batch_update_objects_with_prototype(
            prototype, diff=diff, objects=objects, caller=caller
        )
        caller.msg("|g{num} objects were updated successfully.|n".format(num=num_changed))
        return back_node

    def _keep_diff(caller, **kwargs):
        """Change to KEEP setting for a given section of a diff"""
        # from evennia import set_trace;set_trace(term_size=(182, 50))
        path = kwargs["path"]
        diff = kwargs["diff"]
        tmp = diff
        for key in path[:-1]:
            tmp = tmp[key]
        tmp[path[-1]] = tuple(list(tmp[path[-1]][:-1]) + ["KEEP"])

    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.

        Keyword Args:
            any (any): Forwarded into the generated options as arguments to the callable.

        Returns:
            texts (list): List of texts.
            options (list): List of options dict.

        """
        valid_instructions = ("KEEP", "REMOVE", "ADD", "UPDATE")

        def _visualize(obj, rootname, get_name=False):
            if utils.is_iter(obj):
                if not obj:
                    return str(obj)
                if get_name:
                    return obj[0] if obj[0] else "<unset>"
                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):
            typ = type(diffpart)
            texts = []
            options = []
            if typ == tuple and len(diffpart) == 3 and diffpart[2] in valid_instructions:
                rootname = args[0]
                old, new, instruction = diffpart
                if instruction == "KEEP":
                    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"

                    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),
                            "desc": "|gKEEP|n ({}) {}".format(
                                rootname, _visualize(old, args[-1], get_name=True)
                            ),
                            "goto": (_keep_diff, dict((("path", args), ("diff", diff)), **kwargs)),
                        }
                    )
                    optnum += 1
            else:
                for key in sorted(list(diffpart.keys())):
                    subdiffpart = diffpart[key]
                    text, option, optnum = _parse_diffpart(subdiffpart, optnum, *(args + (key,)))
                    texts.extend(text)
                    options.extend(option)
            return texts, options, optnum

        texts = []
        options = []
        # we use this to allow for skipping full KEEP instructions
        optnum = 1

        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 text:
                text = [heading + text[0]] + text[1:]
            else:
                text = [heading]

            texts.extend(text)
            options.extend(option)

        return texts, options

    def node_apply_diff(caller, **kwargs):
        """Offer options for updating objects"""

        def _keep_option(keyname, prototype, base_obj, obj_prototype, diff, objects, back_node):
            """helper returning an option dict"""
            options = {
                "desc": "Keep {} as-is".format(keyname),
                "goto": (
                    _keep_diff,
                    {
                        "key": keyname,
                        "prototype": prototype,
                        "base_obj": base_obj,
                        "obj_prototype": obj_prototype,
                        "diff": diff,
                        "objects": objects,
                        "back_node": back_node,
                    },
                ),
            }
            return options

        prototype = kwargs.get("prototype", None)
        update_objects = kwargs.get("objects", None)
        back_node = kwargs.get("back_node", "node_index")
        obj_prototype = kwargs.get("obj_prototype", None)
        base_obj = kwargs.get("base_obj", None)
        diff = kwargs.get("diff", None)
        custom_location = kwargs.get("custom_location", None)

        if not update_objects:
            text = "There are no existing objects to update."
            options = {"key": "_default", "goto": back_node}
            return text, options

        if not diff:
            # use one random object as a reference to calculate a diff
            base_obj = choice(update_objects)

            diff, obj_prototype = spawner.prototype_diff_from_object(prototype, base_obj)

        helptext = """
            This will go through all existing objects and apply the changes you accept.

            Be careful with this operation! The upgrade mechanism will try to automatically estimate
            what changes need to be applied. But the estimate is |wonly based on the analysis of one
            randomly selected object|n among all objects spawned by this prototype. If that object
            happens to be unusual in some way the estimate will be off and may lead to unexpected
            results for other objects. Always test your objects carefully after an upgrade and consider
            being conservative (switch to KEEP) for things you are unsure of. For complex upgrades it
            may be better to get help from an administrator with access to the `@py` command for doing
            this manually.

            Note that the `location` will never be auto-adjusted because it's so rare to want to
            homogenize the location of all object instances."""

        if not custom_location:
            diff.pop("location", None)

        txt, options = _format_diff_text_and_options(
            diff, objects=update_objects, base_obj=base_obj, prototype=prototype
        )

        if options:
            text = [
                "Suggested changes to {} objects. ".format(len(update_objects)),
                "Showing random example obj to change: {name} ({dbref}))\n".format(
                    name=base_obj.key, dbref=base_obj.dbref
                ),
            ] + txt
            options.extend(
                [
                    {
                        "key": ("|wu|Wpdate {} objects".format(len(update_objects)), "update", "u"),
                        "desc": "Update {} objects".format(len(update_objects)),
                        "goto": (
                            _apply_diff,
                            {
                                "prototype": prototype,
                                "objects": update_objects,
                                "back_node": back_node,
                                "diff": diff,
                                "base_obj": base_obj,
                            },
                        ),
                    },
                    {
                        "key": ("|wr|Weset changes", "reset", "r"),
                        "goto": (
                            "node_apply_diff",
                            {"prototype": prototype, "back_node": back_node, "objects": update_objects},
                        ),
                    },
                ]
            )
        else:
            text = [
                "Analyzed a random sample object (out of {}) - "
                "found no changes to apply.".format(len(update_objects))
            ]

        options.extend(_wizard_options("update_objects", back_node[5:], None))
        options.append({"key": "_default", "goto": back_node})

        text = "\n".join(text)
        text = (text, helptext)

        return text, options

    # prototype save node

    def node_prototype_save(caller, **kwargs):
        """Save prototype to disk"""
        # these are only set if we selected 'yes' to save on a previous pass
        prototype = kwargs.get("prototype", None)
        # set to True/False if answered, None if first pass
        accept_save = kwargs.get("accept_save", None)

        if accept_save and prototype:
            # we already validated and accepted the save, so this node acts as a goto callback and
            # should now only return the next node
            prototype_key = prototype.get("prototype_key")
            try:
                protlib.save_prototype(prototype)
            except Exception as exc:
                text = "|rCould not save:|n {}\n(press Return to continue)".format(exc)
                options = {"key": "_default", "goto": "node_index"}
                return text, options

            spawned_objects = protlib.search_objects_with_prototype(prototype_key)
            nspawned = spawned_objects.count()

            text = ["|gPrototype saved.|n"]

            if nspawned:
                text.append(
                    "\nDo you want to update {} object(s) "
                    "already using this prototype?".format(nspawned)
                )
                options = (
                    {
                        "key": ("|wY|Wes|n", "yes", "y"),
                        "desc": "Go to updating screen",
                        "goto": (
                            "node_apply_diff",
                            {
                                "accept_update": True,
                                "objects": spawned_objects,
                                "prototype": prototype,
                                "back_node": "node_prototype_save",
                            },
                        ),
                    },
                    {"key": ("[|wN|Wo|n]", "n"), "desc": "Return to index", "goto": "node_index"},
                    {"key": "_default", "goto": "node_index"},
                )
            else:
                text.append("(press Return to continue)")
                options = {"key": "_default", "goto": "node_index"}

            text = "\n".join(text)

            helptext = """
            Updating objects means that the spawner will find all objects previously created by this
            prototype. You will be presented with a list of the changes the system will try to apply to
            each of these objects and you can choose to customize that change if needed. If you have
            done a lot of manual changes to your objects after spawning, you might want to update those
            objects manually instead.
            """

            text = (text, helptext)

            return text, options

        # not validated yet
        prototype = _get_menu_prototype(caller)
        error, text = _validate_prototype(prototype)

        text = [text]

        if error:
            # abort save
            text.append(
                "\n|yValidation errors were found. They need to be corrected before this prototype "
                "can be saved (or used to spawn).|n"
            )
            options = _wizard_options("prototype_save", "index", None)
            options.append({"key": "_default", "goto": "node_index"})
            return "\n".join(text), options

        prototype_key = prototype["prototype_key"]
        if protlib.search_prototype(prototype_key):
            text.append(
                "\nDo you want to save/overwrite the existing prototype '{name}'?".format(
                    name=prototype_key
                )
            )
        else:
            text.append("\nDo you want to save the prototype as '{name}'?".format(name=prototype_key))

        text = "\n".join(text)

        helptext = """
            Saving the prototype makes it available for use later. It can also be used to inherit from,
            by name.  Depending on |cprototype-locks|n it also makes the prototype usable and/or
            editable by others. Consider setting good |cPrototype-tags|n and to give a useful, brief
            |cPrototype-desc|n to make the prototype easy to find later.

        """

        text = (text, helptext)

        options = (
            {
                "key": ("[|wY|Wes|n]", "yes", "y"),
                "desc": "Save prototype",
                "goto": ("node_prototype_save", {"accept_save": True, "prototype": prototype}),
            },
            {"key": ("|wN|Wo|n", "n"), "desc": "Abort and return to Index", "goto": "node_index"},
            {
                "key": "_default",
                "goto": ("node_prototype_save", {"accept_save": True, "prototype": prototype}),
            },
        )

        return text, options

    # spawning node

    def _spawn(caller, **kwargs):
        """Spawn prototype"""
        prototype = kwargs["prototype"].copy()
        new_location = kwargs.get("location", None)
        if new_location:
            prototype["location"] = new_location
        if not prototype.get("location"):
            prototype["location"] = caller

        obj = spawner.spawn(prototype, caller=caller)
        if obj:
            obj = obj[0]
            text = "|gNew instance|n {key} ({dbref}) |gspawned at location |n{loc}|n|g.|n".format(
                key=obj.key, dbref=obj.dbref, loc=prototype["location"]
            )
        else:
            text = "|rError: Spawner did not return a new instance.|n"
        return "node_examine_entity", {"text": text, "back": "prototype_spawn"}

    def node_prototype_spawn(caller, **kwargs):
        """Submenu for spawning the prototype"""

        prototype = _get_menu_prototype(caller)

        already_validated = kwargs.get("already_validated", False)

        if already_validated:
            error, text = None, []
        else:
            error, text = _validate_prototype(prototype)
            text = [text]

        if error:
            text.append("\n|rPrototype validation failed. Correct the errors before spawning.|n")
            options = _wizard_options("prototype_spawn", "index", None)
            return "\n".join(text), options

        text = "\n".join(text)

        helptext = """
            Spawning is the act of instantiating a prototype into an actual object. As a new object is
            spawned, every $protfunc in the prototype is called anew. Since this is a common thing to
            do, you may also temporarily change the |clocation|n of this prototype to bypass whatever
            value is set in the prototype.

        """
        text = (text, helptext)

        # show spawn submenu options
        options = []
        prototype_key = prototype["prototype_key"]
        location = prototype.get("location", None)

        if location:
            options.append(
                {
                    "desc": "Spawn in prototype's defined location ({loc})".format(loc=location),
                    "goto": (
                        _spawn,
                        dict(prototype=prototype, location=location, custom_location=True),
                    ),
                }
            )
        caller_loc = caller.location
        if location != caller_loc:
            options.append(
                {
                    "desc": "Spawn in {caller}'s location ({loc})".format(
                        caller=caller, loc=caller_loc
                    ),
                    "goto": (_spawn, dict(prototype=prototype, location=caller_loc)),
                }
            )
        if location != caller_loc != caller:
            options.append(
                {
                    "desc": "Spawn in {caller}'s inventory".format(caller=caller),
                    "goto": (_spawn, dict(prototype=prototype, location=caller)),
                }
            )

        spawned_objects = protlib.search_objects_with_prototype(prototype_key)
        nspawned = spawned_objects.count()
        if spawned_objects:
            options.append(
                {
                    "desc": "Update {num} existing objects with this prototype".format(num=nspawned),
                    "goto": (
                        "node_apply_diff",
                        {
                            "objects": list(spawned_objects),
                            "prototype": prototype,
                            "back_node": "node_prototype_spawn",
                        },
                    ),
                }
            )
        options.extend(_wizard_options("prototype_spawn", "index", None))
        options.append({"key": "_default", "goto": "node_index"})

        return text, options

    # prototype load node

    def _prototype_load_select(caller, prototype_key, **kwargs):
        matches = protlib.search_prototype(key=prototype_key)
        if matches:
            prototype = matches[0]
            _set_menu_prototype(caller, prototype)
            return (
                "node_examine_entity",
                {
                    "text": "|gLoaded prototype {}.|n".format(prototype["prototype_key"]),
                    "back": "index",
                },
            )
        else:
            caller.msg("|rFailed to load prototype '{}'.".format(prototype_key))
            return None

    def _prototype_load_actions(caller, raw_inp, **kwargs):
        """Parse the default Convert prototype to a string representation for closer inspection"""
        choices = kwargs.get("available_choices", [])
        prototype, action = _default_parse(
            raw_inp, choices, ("examine", "e", "l"), ("delete", "del", "d")
        )

        if prototype:
            # which action to apply on the selection
            if action == "examine":
                # examine the prototype
                prototype = protlib.search_prototype(key=prototype)[0]
                txt = protlib.prototype_to_str(prototype)
                return "node_examine_entity", {"text": txt, "back": "prototype_load"}
            elif action == "delete":
                # delete prototype from disk
                try:
                    protlib.delete_prototype(prototype, caller=caller)
                except protlib.PermissionError as err:
                    txt = "|rDeletion error:|n {}".format(err)
                else:
                    txt = "|gPrototype {} was deleted.|n".format(prototype)
                return "node_examine_entity", {"text": txt, "back": "prototype_load"}

        return "node_prototype_load"

    @list_node(_all_prototype_parents, _prototype_load_select)
    def node_prototype_load(caller, **kwargs):
        """Load prototype"""

        text = """
            Select a prototype to load. This will replace any prototype currently being edited!
        """
        _set_actioninfo(caller, _format_list_actions("examine", "delete"))

        helptext = """
            Loading a prototype will load it and return you to the main index. It can be a good idea
            to examine the prototype before loading it.
        """

        text = (text, helptext)

        options = _wizard_options("prototype_load", "index", None)
        options.append({"key": "_default", "goto": _prototype_load_actions})

        return text, options

    # EvMenu definition, formatting and access functions

    class OLCMenu(EvMenu):
        """
        A custom EvMenu with a different formatting for the options.

        """

        def nodetext_formatter(self, nodetext):
            """
            Format the node text itself.

            """
            return super().nodetext_formatter(nodetext)

        def options_formatter(self, optionlist):
            """
            Split the options into two blocks - olc options and normal options

            """
            olc_keys = (
                "index",
                "forward",
                "back",
                "previous",
                "next",
                "validate prototype",
                "save prototype",
                "load prototype",
                "spawn prototype",
                "search objects",
            )
            actioninfo = self.actioninfo + "\n" if hasattr(self, "actioninfo") else ""
            self.actioninfo = ""  # important, or this could bleed over to other nodes
            olc_options = []
            other_options = []
            for key, desc in optionlist:
                raw_key = strip_ansi(key).lower()
                if raw_key in olc_keys:
                    desc = " {}".format(desc) if desc else ""
                    olc_options.append("|lc{}|lt{}|le{}".format(raw_key, key, desc))
                else:
                    other_options.append((key, desc))

            olc_options = (
                actioninfo + " |W|||n ".join(olc_options) + " |W|||n " + "|wQ|Wuit"
                if olc_options
                else ""
            )
            other_options = super().options_formatter(other_options)
            sep = "\n\n" if olc_options and other_options else ""

            return "{}{}{}".format(olc_options, sep, other_options)

        def helptext_formatter(self, helptext):
            """
            Show help text
            """
            return "|c --- Help ---|n\n" + utils.dedent(helptext)

        def display_helptext(self):
            evmore.msg(self.caller, self.helptext, session=self._session, exit_cmd="look")

    def start_olc(caller, session=None, prototype=None):
        """
        Start menu-driven olc system for prototypes.

        Args:
            caller (Object or Account): The entity starting the menu.
            session (Session, optional): The individual session to get data.
            prototype (dict, optional): Given when editing an existing
                prototype rather than creating a new one.

        """
        menudata = {
            "node_index": node_index,
            "node_validate_prototype": node_validate_prototype,
            "node_examine_entity": node_examine_entity,
            "node_search_object": node_search_object,
            "node_prototype_key": node_prototype_key,
            "node_prototype_parent": node_prototype_parent,
            "node_typeclass": node_typeclass,
            "node_key": node_key,
            "node_aliases": node_aliases,
            "node_attrs": node_attrs,
            "node_tags": node_tags,
            "node_locks": node_locks,
            "node_permissions": node_permissions,
            "node_location": node_location,
            "node_home": node_home,
            "node_destination": node_destination,
            "node_apply_diff": node_apply_diff,
            "node_prototype_desc": node_prototype_desc,
            "node_prototype_tags": node_prototype_tags,
            "node_prototype_locks": node_prototype_locks,
            "node_prototype_load": node_prototype_load,
            "node_prototype_save": node_prototype_save,
            "node_prototype_spawn": node_prototype_spawn,
        }
        OLCMenu(
            caller,
            menudata,
            startnode="node_index",
            session=session,
            olc_prototype=prototype,
            debug=True,
        )

Note

You are reading an old version of the Evennia documentation. The latest version is here

.