From 054cba42bf834bc8fc3e1a00ac543f8e9028abb3 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 6 Jun 2018 19:15:20 +0200 Subject: [PATCH] Refactor prototype-functionality into its own package --- evennia/locks/lockhandler.py | 16 + evennia/prototypes/README.md | 145 +++ evennia/prototypes/__init__.py | 0 evennia/prototypes/menus.py | 709 ++++++++++++ evennia/prototypes/protfuncs.py | 78 ++ evennia/prototypes/prototypes.py | 280 +++++ evennia/prototypes/spawner.py | 600 ++++++++++ evennia/prototypes/utils.py | 150 +++ evennia/utils/spawner.py | 1752 ------------------------------ 9 files changed, 1978 insertions(+), 1752 deletions(-) create mode 100644 evennia/prototypes/README.md create mode 100644 evennia/prototypes/__init__.py create mode 100644 evennia/prototypes/menus.py create mode 100644 evennia/prototypes/protfuncs.py create mode 100644 evennia/prototypes/prototypes.py create mode 100644 evennia/prototypes/spawner.py create mode 100644 evennia/prototypes/utils.py delete mode 100644 evennia/utils/spawner.py diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py index c65b30c131..4822dde1b6 100644 --- a/evennia/locks/lockhandler.py +++ b/evennia/locks/lockhandler.py @@ -647,6 +647,22 @@ def check_lockstring(self, accessing_obj, lockstring, no_superuser_bypass=False, default=default, access_type=access_type) +def validate_lockstring(lockstring): + """ + Validate so lockstring is on a valid form. + + Args: + lockstring (str): Lockstring to validate. + + Returns: + is_valid (bool): If the lockstring is valid or not. + error (str or None): A string describing the error, or None + if no error was found. + + """ + return _LOCK_HANDLER.valdate(lockstring) + + def _test(): # testing diff --git a/evennia/prototypes/README.md b/evennia/prototypes/README.md new file mode 100644 index 0000000000..0f4139aa3e --- /dev/null +++ b/evennia/prototypes/README.md @@ -0,0 +1,145 @@ +# Prototypes + +A 'Prototype' is a normal Python dictionary describing unique features of individual instance of a +Typeclass. The prototype is used to 'spawn' a new instance with custom features detailed by said +prototype. This allows for creating variations without having to create a large number of actual +Typeclasses. It is a good way to allow Builders more freedom of creation without giving them full +Python access to create Typeclasses. + +For example, if a Typeclass 'Cat' describes all the coded differences between a Cat and +other types of animals, then prototypes could be used to quickly create unique individual cats with +different Attributes/properties (like different colors, stats, names etc) without having to make a new +Typeclass for each. Prototypes have inheritance and can be scripted when they are applied to create +a new instance of a typeclass - a common example would be to randomize stats and name. + +The prototype is a normal dictionary with specific keys. Almost all values can be callables +triggered when the prototype is used to spawn a new instance. Below is an example: + +``` +{ +# meta-keys - these are used only when listing prototypes in-game. Only prototype_key is mandatory, +# but it must be globally unique. + + "prototype_key": "base_goblin", + "prototype_desc": "A basic goblin", + "prototype_locks": "edit:all();spawn:all()", + "prototype_tags": "mobs", + +# fixed-meaning keys, modifying the spawned instance. 'typeclass' may be +# replaced by 'parent', referring to the prototype_key of an existing prototype +# to inherit from. + + "typeclass": "types.objects.Monster", + "key": "goblin grunt", + "tags": ["mob", "evil", ('greenskin','mob')] # tags as well as tags with category etc + "attrs": [("weapon", "sword")] # this allows to set Attributes with categories etc + +# non-fixed keys are interpreted as Attributes and their + + "health": lambda: randint(20,30), + "resists": ["cold", "poison"], + "attacks": ["fists"], + "weaknesses": ["fire", "light"] + } + +``` +## Using prototypes + +Prototypes are generally used as inputs to the `spawn` command: + + @spawn prototype_key + +This will spawn a new instance of the prototype in the caller's current location unless the +`location` key of the prototype was set (see below). The caller must pass the prototype's 'spawn' +lock to be able to use it. + + @spawn/list [prototype_key] + +will show all available prototypes along with meta info, or look at a specific prototype in detail. + + +## Creating prototypes + +The `spawn` command can also be used to directly create/update prototypes from in-game. + + spawn/save {"prototype_key: "goblin", ... } + +but it is probably more convenient to use the menu-driven prototype wizard: + + spawn/menu goblin + +In code: + +```python + +from evennia import prototypes + +goblin = {"prototype_key": "goblin:, ... } + +prototype = prototypes.save_prototype(caller, **goblin) + +``` + +Prototypes will normally be stored in the database (internally this is done using a Script, holding +the meta-info and the prototype). One can also define prototypes outside of the game by assigning +the prototype dictionary to a global variable in a module defined by `settings.PROTOTYPE_MODULES`: + +```python +# in e.g. mygame/world/prototypes.py + +GOBLIN = { + "prototype_key": "goblin", + ... + } + +``` + +Such prototypes cannot be modified from inside the game no matter what `edit` lock they are given +(we refer to them as 'readonly') but can be a fast and efficient way to give builders a starting +library of prototypes to inherit from. + +## Valid Prototype keys + +Every prototype key also accepts a callable (taking no arguments) for producing its value or a +string with an $protfunc definition. That callable/protfunc must then return a value on a form the +prototype key expects. + + - `prototype_key` (str): name of this prototype. This is used when storing prototypes and should + be unique. This should always be defined but for prototypes defined in modules, the + variable holding the prototype dict will become the prototype_key if it's not explicitly + given. + - `prototype_desc` (str, optional): describes prototype in listings + - `prototype_locks` (str, optional): locks for restricting access to this prototype. Locktypes + supported are 'edit' and 'use'. + - `prototype_tags` (list, optional): List of tags or tuples (tag, category) used to group prototype + in listings + + - `parent` (str or tuple, optional): name (`prototype_key`) of eventual parent prototype, or a + list of parents for multiple left-to-right inheritance. + - `prototype`: Deprecated. Same meaning as 'parent'. + - `typeclass` (str, optional): if not set, will use typeclass of parent prototype or use + `settings.BASE_OBJECT_TYPECLASS` + - `key` (str, optional): the name of the spawned object. If not given this will set to a + random hash + - `location` (obj, optional): location of the object - a valid object or #dbref + - `home` (obj or str, optional): valid object or #dbref + - `destination` (obj or str, optional): only valid for exits (object or #dbref) + + - `permissions` (str or list, optional): which permissions for spawned object to have + - `locks` (str, optional): lock-string for the spawned object + - `aliases` (str or list, optional): Aliases for the spawned object. + - `exec` (str, optional): this is a string of python code to execute or a list of such + codes. This can be used e.g. to trigger custom handlers on the object. The execution + namespace contains 'evennia' for the library and 'obj'. All default spawn commands limit + this functionality to Developer/superusers. Usually it's better to use callables or + prototypefuncs instead of this. + - `tags` (str, tuple or list, optional): string or list of strings or tuples + `(tagstr, category)`. Plain strings will be result in tags with no category (default tags). + - `attrs` (tuple or list, optional): tuple or list of tuples of Attributes to add. This + form allows more complex Attributes to be set. Tuples at least specify `(key, value)` + but can also specify up to `(key, value, category, lockstring)`. If you want to specify a + lockstring but not a category, set the category to `None`. + - `ndb_` (any): value of a nattribute (`ndb_` is stripped). This is usually not useful to + put in a prototype unless the NAttribute is used immediately upon spawning. + - `other` (any): any other name is interpreted as the key of an Attribute with + its value. Such Attributes have no categories. diff --git a/evennia/prototypes/__init__.py b/evennia/prototypes/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py new file mode 100644 index 0000000000..85e7f3f574 --- /dev/null +++ b/evennia/prototypes/menus.py @@ -0,0 +1,709 @@ +""" + +OLC Prototype menu nodes + +""" + +from evennia.utils.evmenu import EvMenu, list_node +from evennia.utils.ansi import strip_ansi + +# ------------------------------------------------------------ +# +# OLC Prototype design menu +# +# ------------------------------------------------------------ + +# Helper functions + + +def _get_menu_prototype(caller): + + prototype = None + if hasattr(caller.ndb._menutree, "olc_prototype"): + prototype = caller.ndb._menutree.olc_prototype + if not prototype: + caller.ndb._menutree.olc_prototype = prototype = {} + caller.ndb._menutree.olc_new = True + return prototype + + +def _is_new_prototype(caller): + return hasattr(caller.ndb._menutree, "olc_new") + + +def _set_menu_prototype(caller, field, value): + prototype = _get_menu_prototype(caller) + prototype[field] = value + caller.ndb._menutree.olc_prototype = prototype + + +def _format_property(prop, required=False, prototype=None, cropper=None): + + if prototype is not None: + prop = prototype.get(prop, '') + + out = prop + if callable(prop): + if hasattr(prop, '__name__'): + out = "<{}>".format(prop.__name__) + else: + out = repr(prop) + if is_iter(prop): + out = ", ".join(str(pr) for pr in prop) + if not out and required: + out = "|rrequired" + return " ({}|n)".format(cropper(out) if cropper else crop(out, _MENU_CROP_WIDTH)) + + +def _set_property(caller, raw_string, **kwargs): + """ + Update a property. To be called by the 'goto' option variable. + + Args: + caller (Object, Account): The user of the wizard. + raw_string (str): Input from user on given node - the new value to set. + Kwargs: + prop (str): Property name to edit with `raw_string`. + processor (callable): Converts `raw_string` to a form suitable for saving. + next_node (str): Where to redirect to after this has run. + Returns: + next_node (str): Next node to go to. + + """ + prop = kwargs.get("prop", "prototype_key") + processor = kwargs.get("processor", None) + next_node = kwargs.get("next_node", "node_index") + + propname_low = prop.strip().lower() + + if callable(processor): + try: + value = processor(raw_string) + except Exception as err: + caller.msg("Could not set {prop} to {value} ({err})".format( + prop=prop.replace("_", "-").capitalize(), value=raw_string, err=str(err))) + # this means we'll re-run the current node. + return None + else: + value = raw_string + + if not value: + return next_node + + prototype = _get_menu_prototype(caller) + + # typeclass and prototype can't co-exist + if propname_low == "typeclass": + prototype.pop("prototype", None) + if propname_low == "prototype": + prototype.pop("typeclass", None) + + caller.ndb._menutree.olc_prototype = prototype + + caller.msg("Set {prop} to '{value}'.".format(prop, value=str(value))) + + return next_node + + +def _wizard_options(curr_node, prev_node, next_node, color="|W"): + options = [] + if prev_node: + options.append({"key": ("|wb|Wack", "b"), + "desc": "{color}({node})|n".format( + color=color, node=prev_node.replace("_", "-")), + "goto": "node_{}".format(prev_node)}) + if next_node: + options.append({"key": ("|wf|Worward", "f"), + "desc": "{color}({node})|n".format( + color=color, node=next_node.replace("_", "-")), + "goto": "node_{}".format(next_node)}) + + if "index" not in (prev_node, next_node): + options.append({"key": ("|wi|Wndex", "i"), + "goto": "node_index"}) + + if curr_node: + options.append({"key": ("|wv|Walidate prototype", "v"), + "goto": ("node_validate_prototype", {"back": curr_node})}) + + return options + + +def _path_cropper(pythonpath): + "Crop path to only the last component" + return pythonpath.split('.')[-1] + + +# Menu nodes + +def node_index(caller): + prototype = _get_menu_prototype(caller) + + text = ("|c --- Prototype wizard --- |n\n\n" + "Define the |yproperties|n of the prototype. All prototype values can be " + "over-ridden at the time of spawning an instance of the prototype, but some are " + "required.\n\n'|wMeta'-properties|n are not used in the prototype itself but are used " + "to organize and list prototypes. The 'Meta-Key' uniquely identifies the prototype " + "and allows you to edit an existing prototype or save a new one for use by you or " + "others later.\n\n(make choice; q to abort. If unsure, start from 1.)") + + options = [] + options.append( + {"desc": "|WPrototype-Key|n|n{}".format(_format_property("Key", True, prototype, None)), + "goto": "node_prototype_key"}) + for key in ('Prototype', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', + 'Permissions', 'Location', 'Home', 'Destination'): + required = False + cropper = None + if key in ("Prototype", "Typeclass"): + required = "prototype" not in prototype and "typeclass" not in prototype + if key == 'Typeclass': + cropper = _path_cropper + options.append( + {"desc": "|w{}|n{}".format( + key, _format_property(key, required, prototype, cropper=cropper)), + "goto": "node_{}".format(key.lower())}) + required = False + for key in ('Desc', 'Tags', 'Locks'): + options.append( + {"desc": "|WPrototype-{}|n|n{}".format(key, _format_property(key, required, prototype, None)), + "goto": "node_prototype_{}".format(key.lower())}) + + return text, options + + +def node_validate_prototype(caller, raw_string, **kwargs): + prototype = _get_menu_prototype(caller) + + txt = prototype_to_str(prototype) + errors = "\n\n|g No validation errors found.|n (but errors could still happen at spawn-time)" + try: + # validate, don't spawn + spawn(prototype, return_prototypes=True) + except RuntimeError as err: + errors = "\n\n|rError: {}|n".format(err) + text = (txt + errors) + + options = _wizard_options(None, kwargs.get("back"), None) + + return text, options + + +def _check_prototype_key(caller, key): + old_prototype = search_prototype(key) + olc_new = _is_new_prototype(caller) + key = key.strip().lower() + if old_prototype: + # we are starting a new prototype that matches an existing + if not caller.locks.check_lockstring( + caller, old_prototype['prototype_locks'], access_type='edit'): + # return to the node_prototype_key to try another key + caller.msg("Prototype '{key}' already exists and you don't " + "have permission to edit it.".format(key=key)) + return "node_prototype_key" + elif olc_new: + # we are selecting an existing prototype to edit. Reset to index. + del caller.ndb._menutree.olc_new + caller.ndb._menutree.olc_prototype = old_prototype + caller.msg("Prototype already exists. Reloading.") + return "node_index" + + return _set_property(caller, key, prop='prototype_key', next_node="node_prototype") + + +def node_prototype_key(caller): + prototype = _get_menu_prototype(caller) + text = ["The prototype name, or |wMeta-Key|n, uniquely identifies the prototype. " + "It is used to find and use the prototype to spawn new entities. " + "It is not case sensitive."] + old_key = prototype.get('prototype_key', None) + if old_key: + text.append("Current key is '|w{key}|n'".format(key=old_key)) + else: + text.append("The key is currently unset.") + text.append("Enter text or make a choice (q for quit)") + text = "\n\n".join(text) + options = _wizard_options("prototype_key", "index", "prototype") + options.append({"key": "_default", + "goto": _check_prototype_key}) + return text, options + + +def _all_prototypes(caller): + return [prototype["prototype_key"] + for prototype in search_prototype() if "prototype_key" in prototype] + + +def _prototype_examine(caller, prototype_name): + prototypes = search_prototype(key=prototype_name) + if prototypes: + caller.msg(prototype_to_str(prototypes[0])) + caller.msg("Prototype not registered.") + return None + + +def _prototype_select(caller, prototype): + ret = _set_property(caller, prototype, prop="prototype", processor=str, next_node="node_key") + caller.msg("Selected prototype |y{}|n. Removed any set typeclass parent.".format(prototype)) + return ret + + +@list_node(_all_prototypes, _prototype_select) +def node_prototype(caller): + prototype = _get_menu_prototype(caller) + + prot_parent_key = prototype.get('prototype') + + text = ["Set the prototype's |yParent Prototype|n. If this is unset, Typeclass will be used."] + if prot_parent_key: + prot_parent = search_prototype(prot_parent_key) + if prot_parent: + text.append("Current parent prototype is {}:\n{}".format(prototype_to_str(prot_parent))) + else: + text.append("Current parent prototype |r{prototype}|n " + "does not appear to exist.".format(prot_parent_key)) + else: + text.append("Parent prototype is not set") + text = "\n\n".join(text) + options = _wizard_options("prototype", "prototype_key", "typeclass", color="|W") + options.append({"key": "_default", + "goto": _prototype_examine}) + + return text, options + + +def _all_typeclasses(caller): + return list(sorted(get_all_typeclasses().keys())) + + +def _typeclass_examine(caller, typeclass_path): + if typeclass_path is None: + # this means we are exiting the listing + return "node_key" + + typeclass = get_all_typeclasses().get(typeclass_path) + if typeclass: + docstr = [] + for line in typeclass.__doc__.split("\n"): + if line.strip(): + docstr.append(line) + elif docstr: + break + docstr = '\n'.join(docstr) if docstr else "" + txt = "Typeclass |y{typeclass_path}|n; First paragraph of docstring:\n\n{docstring}".format( + typeclass_path=typeclass_path, docstring=docstr) + else: + txt = "This is typeclass |y{}|n.".format(typeclass) + caller.msg(txt) + return None + + +def _typeclass_select(caller, typeclass): + ret = _set_property(caller, typeclass, prop='typeclass', processor=str, next_node="node_key") + caller.msg("Selected typeclass |y{}|n. Removed any set prototype parent.".format(typeclass)) + return ret + + +@list_node(_all_typeclasses, _typeclass_select) +def node_typeclass(caller): + prototype = _get_menu_prototype(caller) + typeclass = prototype.get("typeclass") + + text = ["Set the typeclass's parent |yTypeclass|n."] + if typeclass: + text.append("Current typeclass is |y{typeclass}|n.".format(typeclass=typeclass)) + else: + text.append("Using default typeclass {typeclass}.".format( + typeclass=settings.BASE_OBJECT_TYPECLASS)) + text = "\n\n".join(text) + options = _wizard_options("typeclass", "prototype", "key", color="|W") + options.append({"key": "_default", + "goto": _typeclass_examine}) + return text, options + + +def node_key(caller): + prototype = _get_menu_prototype(caller) + key = prototype.get("key") + + text = ["Set the prototype's |yKey|n. This will retain case sensitivity."] + if key: + text.append("Current key value is '|y{key}|n'.".format(key=key)) + else: + text.append("Key is currently unset.") + text = "\n\n".join(text) + options = _wizard_options("key", "typeclass", "aliases") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="key", + processor=lambda s: s.strip(), + next_node="node_aliases"))}) + return text, options + + +def node_aliases(caller): + prototype = _get_menu_prototype(caller) + aliases = prototype.get("aliases") + + text = ["Set the prototype's |yAliases|n. Separate multiple aliases with commas. " + "ill retain case sensitivity."] + if aliases: + text.append("Current aliases are '|y{aliases}|n'.".format(aliases=aliases)) + else: + text.append("No aliases are set.") + text = "\n\n".join(text) + options = _wizard_options("aliases", "key", "attrs") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="aliases", + processor=lambda s: [part.strip() for part in s.split(",")], + next_node="node_attrs"))}) + return text, options + + +def _caller_attrs(caller): + prototype = _get_menu_prototype(caller) + attrs = prototype.get("attrs", []) + return attrs + + +def _attrparse(caller, attr_string): + "attr is entering on the form 'attr = value'" + + if '=' in attr_string: + attrname, value = (part.strip() for part in attr_string.split('=', 1)) + attrname = attrname.lower() + if attrname: + try: + value = literal_eval(value) + except SyntaxError: + caller.msg(_MENU_ATTR_LITERAL_EVAL_ERROR) + else: + return attrname, value + else: + return None, None + + +def _add_attr(caller, attr_string, **kwargs): + attrname, value = _attrparse(caller, attr_string) + if attrname: + prot = _get_menu_prototype(caller) + prot['attrs'][attrname] = value + _set_menu_prototype(caller, "prototype", prot) + text = "Added" + else: + text = "Attribute must be given as 'attrname = ' where uses valid Python." + options = {"key": "_default", + "goto": lambda caller: None} + return text, options + + +def _edit_attr(caller, attrname, new_value, **kwargs): + attrname, value = _attrparse("{}={}".format(caller, attrname, new_value)) + if attrname: + prot = _get_menu_prototype(caller) + prot['attrs'][attrname] = value + text = "Edited Attribute {} = {}".format(attrname, value) + else: + text = "Attribute value must be valid Python." + options = {"key": "_default", + "goto": lambda caller: None} + return text, options + + +def _examine_attr(caller, selection): + prot = _get_menu_prototype(caller) + value = prot['attrs'][selection] + return "Attribute {} = {}".format(selection, value) + + +@list_node(_caller_attrs) +def node_attrs(caller): + prot = _get_menu_prototype(caller) + attrs = prot.get("attrs") + + text = ["Set the prototype's |yAttributes|n. Separate multiple attrs with commas. " + "Will retain case sensitivity."] + if attrs: + text.append("Current attrs are '|y{attrs}|n'.".format(attrs=attrs)) + else: + text.append("No attrs are set.") + text = "\n\n".join(text) + options = _wizard_options("attrs", "aliases", "tags") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="attrs", + processor=lambda s: [part.strip() for part in s.split(",")], + next_node="node_tags"))}) + return text, options + + +def _caller_tags(caller): + prototype = _get_menu_prototype(caller) + tags = prototype.get("tags") + return tags + + +def _add_tag(caller, tag, **kwargs): + tag = tag.strip().lower() + prototype = _get_menu_prototype(caller) + tags = prototype.get('tags', []) + if tags: + if tag not in tags: + tags.append(tag) + else: + tags = [tag] + prot['tags'] = tags + _set_menu_prototype(caller, "prototype", prot) + text = kwargs.get("text") + if not text: + text = "Added tag {}. (return to continue)".format(tag) + options = {"key": "_default", + "goto": lambda caller: None} + return text, options + + +def _edit_tag(caller, old_tag, new_tag, **kwargs): + prototype = _get_menu_prototype(caller) + tags = prototype.get('tags', []) + + old_tag = old_tag.strip().lower() + new_tag = new_tag.strip().lower() + tags[tags.index(old_tag)] = new_tag + prototype['tags'] = tags + _set_menu_prototype(caller, 'prototype', prototype) + + text = kwargs.get('text') + if not text: + text = "Changed tag {} to {}.".format(old_tag, new_tag) + options = {"key": "_default", + "goto": lambda caller: None} + return text, options + + +@list_node(_caller_tags) +def node_tags(caller): + text = "Set the prototype's |yTags|n." + options = _wizard_options("tags", "attrs", "locks") + return text, options + + +def node_locks(caller): + prototype = _get_menu_prototype(caller) + locks = prototype.get("locks") + + text = ["Set the prototype's |yLock string|n. Separate multiple locks with semi-colons. " + "Will retain case sensitivity."] + if locks: + text.append("Current locks are '|y{locks}|n'.".format(locks=locks)) + else: + text.append("No locks are set.") + text = "\n\n".join(text) + options = _wizard_options("locks", "tags", "permissions") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="locks", + processor=lambda s: s.strip(), + next_node="node_permissions"))}) + return text, options + + +def node_permissions(caller): + prototype = _get_menu_prototype(caller) + permissions = prototype.get("permissions") + + text = ["Set the prototype's |yPermissions|n. Separate multiple permissions with commas. " + "Will retain case sensitivity."] + if permissions: + text.append("Current permissions are '|y{permissions}|n'.".format(permissions=permissions)) + else: + text.append("No permissions are set.") + text = "\n\n".join(text) + options = _wizard_options("permissions", "destination", "location") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="permissions", + processor=lambda s: [part.strip() for part in s.split(",")], + next_node="node_location"))}) + return text, options + + +def node_location(caller): + prototype = _get_menu_prototype(caller) + location = prototype.get("location") + + text = ["Set the prototype's |yLocation|n"] + if location: + text.append("Current location is |y{location}|n.".format(location=location)) + else: + text.append("Default location is {}'s inventory.".format(caller)) + text = "\n\n".join(text) + options = _wizard_options("location", "permissions", "home") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="location", + processor=lambda s: s.strip(), + next_node="node_home"))}) + return text, options + + +def node_home(caller): + prototype = _get_menu_prototype(caller) + home = prototype.get("home") + + text = ["Set the prototype's |yHome location|n"] + if home: + text.append("Current home location is |y{home}|n.".format(home=home)) + else: + text.append("Default home location (|y{home}|n) used.".format(home=settings.DEFAULT_HOME)) + text = "\n\n".join(text) + options = _wizard_options("home", "aliases", "destination") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="home", + processor=lambda s: s.strip(), + next_node="node_destination"))}) + return text, options + + +def node_destination(caller): + prototype = _get_menu_prototype(caller) + dest = prototype.get("dest") + + text = ["Set the prototype's |yDestination|n. This is usually only used for Exits."] + if dest: + text.append("Current destination is |y{dest}|n.".format(dest=dest)) + else: + text.append("No destination is set (default).") + text = "\n\n".join(text) + options = _wizard_options("destination", "home", "prototype_desc") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="dest", + processor=lambda s: s.strip(), + next_node="node_prototype_desc"))}) + return text, options + + +def node_prototype_desc(caller): + + prototype = _get_menu_prototype(caller) + text = ["The |wMeta-Description|n briefly describes the prototype for viewing in listings."] + desc = prototype.get("prototype_desc", None) + + if desc: + text.append("The current meta desc is:\n\"|w{desc}|n\"".format(desc=desc)) + else: + text.append("Description is currently unset.") + text = "\n\n".join(text) + options = _wizard_options("prototype_desc", "prototype_key", "prototype_tags") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop='prototype_desc', + processor=lambda s: s.strip(), + next_node="node_prototype_tags"))}) + + return text, options + + +def node_prototype_tags(caller): + prototype = _get_menu_prototype(caller) + text = ["|wMeta-Tags|n can be used to classify and find prototypes. Tags are case-insensitive. " + "Separate multiple by tags by commas."] + tags = prototype.get('prototype_tags', []) + + if tags: + text.append("The current tags are:\n|w{tags}|n".format(tags=tags)) + else: + text.append("No tags are currently set.") + text = "\n\n".join(text) + options = _wizard_options("prototype_tags", "prototype_desc", "prototype_locks") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="prototype_tags", + processor=lambda s: [ + str(part.strip().lower()) for part in s.split(",")], + next_node="node_prototype_locks"))}) + return text, options + + +def node_prototype_locks(caller): + prototype = _get_menu_prototype(caller) + text = ["Set |wMeta-Locks|n on the prototype. There are two valid lock types: " + "'edit' (who can edit the prototype) and 'use' (who can apply the prototype)\n" + "(If you are unsure, leave as default.)"] + locks = prototype.get('prototype_locks', '') + if locks: + text.append("Current lock is |w'{lockstring}'|n".format(lockstring=locks)) + else: + text.append("Lock unset - if not changed the default lockstring will be set as\n" + " |w'use:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) + text = "\n\n".join(text) + options = _wizard_options("prototype_locks", "prototype_tags", "index") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="prototype_locks", + processor=lambda s: s.strip().lower(), + next_node="node_index"))}) + return text, options + + +class OLCMenu(EvMenu): + """ + A custom EvMenu with a different formatting for the options. + + """ + def options_formatter(self, optionlist): + """ + Split the options into two blocks - olc options and normal options + + """ + olc_keys = ("index", "forward", "back", "previous", "next", "validate prototype") + olc_options = [] + other_options = [] + for key, desc in optionlist: + raw_key = strip_ansi(key) + if raw_key in olc_keys: + desc = " {}".format(desc) if desc else "" + olc_options.append("|lc{}|lt{}|le{}".format(raw_key, key, desc)) + else: + other_options.append((key, desc)) + + olc_options = " | ".join(olc_options) + " | " + "|wq|Wuit" if olc_options else "" + other_options = super(OLCMenu, self).options_formatter(other_options) + sep = "\n\n" if olc_options and other_options else "" + + return "{}{}{}".format(olc_options, sep, other_options) + + +def start_olc(caller, session=None, prototype=None): + """ + Start menu-driven olc system for prototypes. + + Args: + caller (Object or Account): The entity starting the menu. + session (Session, optional): The individual session to get data. + prototype (dict, optional): Given when editing an existing + prototype rather than creating a new one. + + """ + menudata = {"node_index": node_index, + "node_validate_prototype": node_validate_prototype, + "node_prototype_key": node_prototype_key, + "node_prototype": node_prototype, + "node_typeclass": node_typeclass, + "node_key": node_key, + "node_aliases": node_aliases, + "node_attrs": node_attrs, + "node_tags": node_tags, + "node_locks": node_locks, + "node_permissions": node_permissions, + "node_location": node_location, + "node_home": node_home, + "node_destination": node_destination, + "node_prototype_desc": node_prototype_desc, + "node_prototype_tags": node_prototype_tags, + "node_prototype_locks": node_prototype_locks, + } + OLCMenu(caller, menudata, startnode='node_index', session=session, olc_prototype=prototype) + diff --git a/evennia/prototypes/protfuncs.py b/evennia/prototypes/protfuncs.py new file mode 100644 index 0000000000..057f5f770f --- /dev/null +++ b/evennia/prototypes/protfuncs.py @@ -0,0 +1,78 @@ +""" +Protfuncs are function-strings embedded in a prototype and allows for a builder to create a +prototype with custom logics without having access to Python. The Protfunc is parsed using the +inlinefunc parser but is fired at the moment the spawning happens, using the creating object's +session as input. + +In the prototype dict, the protfunc is specified as a string inside the prototype, e.g.: + + { ... + + "key": "$funcname(arg1, arg2, ...)" + + ... } + +and multiple functions can be nested (no keyword args are supported). The result will be used as the +value for that prototype key for that individual spawn. + +Available protfuncs are callables in one of the modules of `settings.PROTOTYPEFUNC_MODULES`. They +are specified as functions + + def funcname (*args, **kwargs) + +where *args are the arguments given in the prototype, and **kwargs are inserted by Evennia: + + - session (Session): The Session of the entity spawning using this prototype. + - prototype_key (str): The currently spawning prototype-key. + - prototype (dict): The dict this protfunc is a part of. + +Any traceback raised by this function will be handled at the time of spawning and abort the spawn +before any object is created/updated. It must otherwise return the value to store for the specified +prototype key (this value must be possible to serialize in an Attribute). + +""" + +from django.conf import settings +from evennia.utils import inlinefuncs +from evennia.utils.utils import callables_from_module + + +_PROTOTYPEFUNCS = {} + +for mod in settings.PROTOTYPEFUNC_MODULES: + try: + callables = callables_from_module(mod) + if mod == __name__: + callables.pop("protfunc_parser") + _PROTOTYPEFUNCS.update(callables) + except ImportError: + pass + + +def protfunc_parser(value, available_functions=None, **kwargs): + """ + Parse a prototype value string for a protfunc and process it. + + Available protfuncs are specified as callables in one of the modules of + `settings.PROTOTYPEFUNC_MODULES`, or specified on the command line. + + Args: + value (string): The value to test for a parseable protfunc. + available_functions (dict, optional): Mapping of name:protfunction to use for this parsing. + + Kwargs: + any (any): Passed on to the inlinefunc. + + Returns: + any (any): A structure to replace the string on the prototype level. If this is a + callable or a (callable, (args,)) structure, it will be executed as if one had supplied + it to the prototype directly. + + """ + if not isinstance(value, basestring): + return value + available_functions = _PROTOTYPEFUNCS if available_functions is None else available_functions + return inlinefuncs.parse_inlinefunc(value, _available_funcs=available_functions, **kwargs) + + +# default protfuncs diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py new file mode 100644 index 0000000000..60e194861b --- /dev/null +++ b/evennia/prototypes/prototypes.py @@ -0,0 +1,280 @@ +""" + +Handling storage of prototypes, both database-based ones (DBPrototypes) and those defined in modules +(Read-only prototypes). + +""" + +from django.conf import settings +from evennia.scripts.scripts import DefaultScript +from evennia.objects.models import ObjectDB +from evennia.utils.create import create_script +from evennia.utils.utils import all_from_module, make_iter, callables_from_module, is_iter +from evennia.locks.lockhandler import validate_lockstring, check_lockstring +from evennia.utils import logger + + +_MODULE_PROTOTYPE_MODULES = {} +_MODULE_PROTOTYPES = {} + + +class ValidationError(RuntimeError): + """ + Raised on prototype validation errors + """ + pass + + +# module-based prototypes + +for mod in settings.PROTOTYPE_MODULES: + # to remove a default prototype, override it with an empty dict. + # internally we store as (key, desc, locks, tags, prototype_dict) + prots = [(prototype_key, prot) for prototype_key, prot in all_from_module(mod).items() + if prot and isinstance(prot, dict)] + # assign module path to each prototype_key for easy reference + _MODULE_PROTOTYPE_MODULES.update({prototype_key: mod for prototype_key, _ in prots}) + # make sure the prototype contains all meta info + for prototype_key, prot in prots: + actual_prot_key = prot.get('prototype_key', prototype_key).lower() + prot.update({ + "prototype_key": actual_prot_key, + "prototype_desc": prot['prototype_desc'] if 'prototype_desc' in prot else mod, + "prototype_locks": (prot['prototype_locks'] + if 'prototype_locks' in prot else "use:all();edit:false()"), + "prototype_tags": list(set(make_iter(prot.get('prototype_tags', [])) + ["module"]))}) + _MODULE_PROTOTYPES[actual_prot_key] = prot + + +# Db-based prototypes + + +class DbPrototype(DefaultScript): + """ + This stores a single prototype, in an Attribute `prototype`. + """ + def at_script_creation(self): + self.key = "empty prototype" # prototype_key + self.desc = "A prototype" # prototype_desc + self.db.prototype = {} # actual prototype + + +# General prototype functions + +def check_permission(prototype_key, action, default=True): + """ + Helper function to check access to actions on given prototype. + + Args: + prototype_key (str): The prototype to affect. + action (str): One of "spawn" or "edit". + default (str): If action is unknown or prototype has no locks + + Returns: + passes (bool): If permission for action is granted or not. + + """ + if action == 'edit': + if prototype_key in _MODULE_PROTOTYPES: + mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key, "N/A") + logger.log_err("{} is a read-only prototype " + "(defined as code in {}).".format(prototype_key, mod)) + return False + + prototype = search_prototype(key=prototype_key) + if not prototype: + logger.log_err("Prototype {} not found.".format(prototype_key)) + return False + + lockstring = prototype.get("prototype_locks") + + if lockstring: + return check_lockstring(None, lockstring, default=default, access_type=action) + return default + + +def create_prototype(**kwargs): + """ + Store a prototype persistently. + + Kwargs: + prototype_key (str): This is required for any storage. + All other kwargs are considered part of the new prototype dict. + + Returns: + prototype (dict or None): The prototype stored using the given kwargs, None if deleting. + + Raises: + prototypes.ValidationError: If prototype does not validate. + + Note: + No edit/spawn locks will be checked here - if this function is called the caller + is expected to have valid permissions. + + """ + + def _to_batchtuple(inp, *args): + "build tuple suitable for batch-creation" + if is_iter(inp): + # already a tuple/list, use as-is + return inp + return (inp, ) + args + + prototype_key = kwargs.get("prototype_key") + if not prototype_key: + raise ValidationError("Prototype requires a prototype_key") + + prototype_key = str(prototype_key).lower() + + # we can't edit a prototype defined in a module + if prototype_key in _MODULE_PROTOTYPES: + mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key, "N/A") + raise PermissionError("{} is a read-only prototype " + "(defined as code in {}).".format(prototype_key, mod)) + + # want to create- or edit + prototype = kwargs + + # make sure meta properties are included with defaults + prototype['prototype_desc'] = prototype.get('prototype_desc', '') + locks = prototype.get('prototype_locks', "spawn:all();edit:perm(Admin)") + is_valid, err = validate_lockstring(locks) + if not is_valid: + raise ValidationError("Lock error: {}".format(err)) + prototype["prototype_locks"] = locks + prototype["prototype_tags"] = [ + _to_batchtuple(tag, "db_prototype") + for tag in make_iter(prototype.get("prototype_tags", []))] + + stored_prototype = DbPrototype.objects.filter(db_key=prototype_key) + + if stored_prototype: + # edit existing prototype + stored_prototype = stored_prototype[0] + + stored_prototype.desc = prototype['prototype_desc'] + stored_prototype.tags.batch_add(*prototype['prototype_tags']) + stored_prototype.locks.add(prototype['prototype_locks']) + stored_prototype.attributes.add('prototype', prototype) + else: + # create a new prototype + stored_prototype = create_script( + DbPrototype, key=prototype_key, desc=prototype['prototype_desc'], persistent=True, + locks=locks, tags=prototype['prototype_tags'], attributes=[("prototype", prototype)]) + return stored_prototype + + +def delete_prototype(key, caller=None): + """ + Delete a stored prototype + + Args: + key (str): The persistent prototype to delete. + caller (Account or Object, optionsl): Caller aiming to delete a prototype. + Note that no locks will be checked if`caller` is not passed. + Returns: + success (bool): If deletion worked or not. + Raises: + PermissionError: If 'edit' lock was not passed or deletion failed for some other reason. + + """ + if prototype_key in _MODULE_PROTOTYPES: + mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key, "N/A") + raise PermissionError("{} is a read-only prototype " + "(defined as code in {}).".format(prototype_key, mod)) + + stored_prototype = DbPrototype.objects.filter(db_key=prototype_key) + + if not stored_prototype: + raise PermissionError("Prototype {} was not found.".format(prototype_key)) + if caller: + if not stored_prototype.access(caller, 'edit'): + raise PermissionError("{} does not have permission to " + "delete prototype {}.".format(caller, prototype_key)) + stored_prototype.delete() + return True + + +def search_prototype(key=None, tags=None): + """ + Find prototypes based on key and/or tags, or all prototypes. + + Kwargs: + key (str): An exact or partial key to query for. + tags (str or list): Tag key or keys to query for. These + will always be applied with the 'db_protototype' + tag category. + + Return: + matches (list): All found prototype dicts. If no keys + or tags are given, all available prototypes will be returned. + + Note: + The available prototypes is a combination of those supplied in + PROTOTYPE_MODULES and those stored in the database. Note that if + tags are given and the prototype has no tags defined, it will not + be found as a match. + + """ + # search module prototypes + + mod_matches = {} + if tags: + # use tags to limit selection + tagset = set(tags) + mod_matches = {prototype_key: prototype + for prototype_key, prototype in _MODULE_PROTOTYPES.items() + if tagset.intersection(prototype.get("prototype_tags", []))} + else: + mod_matches = _MODULE_PROTOTYPES + if key: + if key in mod_matches: + # exact match + module_prototypes = [mod_matches[key]] + else: + # fuzzy matching + module_prototypes = [prototype for prototype_key, prototype in mod_matches.items() + if key in prototype_key] + else: + module_prototypes = [match for match in mod_matches.values()] + + # search db-stored prototypes + + if tags: + # exact match on tag(s) + tags = make_iter(tags) + tag_categories = ["db_prototype" for _ in tags] + db_matches = DbPrototype.objects.get_by_tag(tags, tag_categories) + else: + db_matches = DbPrototype.objects.all() + if key: + # exact or partial match on key + db_matches = db_matches.filter(db_key=key) or db_matches.filter(db_key__icontains=key) + # return prototype + db_prototypes = [dict(dbprot.attributes.get("prototype", {})) for dbprot in db_matches] + + matches = db_prototypes + module_prototypes + nmatches = len(matches) + if nmatches > 1 and key: + key = key.lower() + # avoid duplicates if an exact match exist between the two types + filter_matches = [mta for mta in matches + if mta.get('prototype_key') and mta['prototype_key'] == key] + if filter_matches and len(filter_matches) < nmatches: + matches = filter_matches + + return matches + + +def search_objects_with_prototype(prototype_key): + """ + Retrieve all object instances created by a given prototype. + + Args: + prototype_key (str): The exact (and unique) prototype identifier to query for. + + Returns: + matches (Queryset): All matching objects spawned from this prototype. + + """ + return ObjectDB.objects.get_by_tag(key=prototype_key, category=_PROTOTYPE_TAG_CATEGORY) diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py new file mode 100644 index 0000000000..062e15ee92 --- /dev/null +++ b/evennia/prototypes/spawner.py @@ -0,0 +1,600 @@ +""" +Spawner + +The spawner takes input files containing object definitions in +dictionary forms. These use a prototype architecture to define +unique objects without having to make a Typeclass for each. + +The main function is `spawn(*prototype)`, where the `prototype` +is a dictionary like this: + +```python +GOBLIN = { + "typeclass": "types.objects.Monster", + "key": "goblin grunt", + "health": lambda: randint(20,30), + "resists": ["cold", "poison"], + "attacks": ["fists"], + "weaknesses": ["fire", "light"] + "tags": ["mob", "evil", ('greenskin','mob')] + "attrs": [("weapon", "sword")] + } +``` + +Possible keywords are: + prototype_key (str): name of this prototype. This is used when storing prototypes and should + be unique. This should always be defined but for prototypes defined in modules, the + variable holding the prototype dict will become the prototype_key if it's not explicitly + given. + prototype_desc (str, optional): describes prototype in listings + prototype_locks (str, optional): locks for restricting access to this prototype. Locktypes + supported are 'edit' and 'use'. + prototype_tags(list, optional): List of tags or tuples (tag, category) used to group prototype + in listings + + parent (str, tuple or callable, optional): name (prototype_key) of eventual parent prototype, or + a list of parents, for multiple left-to-right inheritance. + prototype: Deprecated. Same meaning as 'parent'. + typeclass (str or callable, optional): if not set, will use typeclass of parent prototype or use + `settings.BASE_OBJECT_TYPECLASS` + key (str or callable, optional): the name of the spawned object. If not given this will set to a + random hash + location (obj, str or callable, optional): location of the object - a valid object or #dbref + home (obj, str or callable, optional): valid object or #dbref + destination (obj, str or callable, optional): only valid for exits (object or #dbref) + + permissions (str, list or callable, optional): which permissions for spawned object to have + locks (str or callable, optional): lock-string for the spawned object + aliases (str, list or callable, optional): Aliases for the spawned object + exec (str or callable, optional): this is a string of python code to execute or a list of such + codes. This can be used e.g. to trigger custom handlers on the object. The execution + namespace contains 'evennia' for the library and 'obj'. All default spawn commands limit + this functionality to Developer/superusers. Usually it's better to use callables or + prototypefuncs instead of this. + tags (str, tuple, list or callable, optional): string or list of strings or tuples + `(tagstr, category)`. Plain strings will be result in tags with no category (default tags). + attrs (tuple, list or callable, optional): tuple or list of tuples of Attributes to add. This + form allows more complex Attributes to be set. Tuples at least specify `(key, value)` + but can also specify up to `(key, value, category, lockstring)`. If you want to specify a + lockstring but not a category, set the category to `None`. + ndb_ (any): value of a nattribute (ndb_ is stripped) + other (any): any other name is interpreted as the key of an Attribute with + its value. Such Attributes have no categories. + +Each value can also be a callable that takes no arguments. It should +return the value to enter into the field and will be called every time +the prototype is used to spawn an object. Note, if you want to store +a callable in an Attribute, embed it in a tuple to the `args` keyword. + +By specifying the "prototype" key, the prototype becomes a child of +that prototype, inheritng all prototype slots it does not explicitly +define itself, while overloading those that it does specify. + +```python +import random + + +GOBLIN_WIZARD = { + "parent": GOBLIN, + "key": "goblin wizard", + "spells": ["fire ball", "lighting bolt"] + } + +GOBLIN_ARCHER = { + "parent": GOBLIN, + "key": "goblin archer", + "attack_skill": (random, (5, 10))" + "attacks": ["short bow"] +} +``` + +One can also have multiple prototypes. These are inherited from the +left, with the ones further to the right taking precedence. + +```python +ARCHWIZARD = { + "attack": ["archwizard staff", "eye of doom"] + +GOBLIN_ARCHWIZARD = { + "key" : "goblin archwizard" + "parent": (GOBLIN_WIZARD, ARCHWIZARD), +} +``` + +The *goblin archwizard* will have some different attacks, but will +otherwise have the same spells as a *goblin wizard* who in turn shares +many traits with a normal *goblin*. + + +Storage mechanism: + +This sets up a central storage for prototypes. The idea is to make these +available in a repository for buildiers to use. Each prototype is stored +in a Script so that it can be tagged for quick sorting/finding and locked for limiting +access. + +This system also takes into consideration prototypes defined and stored in modules. +Such prototypes are considered 'read-only' to the system and can only be modified +in code. To replace a default prototype, add the same-name prototype in a +custom module read later in the settings.PROTOTYPE_MODULES list. To remove a default +prototype, override its name with an empty dict. + + +""" +from __future__ import print_function + +import copy +import hashlib +import time +from ast import literal_eval +from django.conf import settings +from random import randint +import evennia +from evennia.objects.models import ObjectDB +from evennia.utils.utils import ( + make_iter, dbid_to_obj, + is_iter, crop, get_all_typeclasses) + +from evennia.utils.evtable import EvTable + + +_CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") +_PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") +_NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES +_MENU_CROP_WIDTH = 15 +_PROTOTYPE_TAG_CATEGORY = "spawned_by_prototype" + +_MENU_ATTR_LITERAL_EVAL_ERROR = ( + "|rCritical Python syntax error in your value. Only primitive Python structures are allowed.\n" + "You also need to use correct Python syntax. Remember especially to put quotes around all " + "strings inside lists and dicts.|n") + + +# Helper functions + +def _to_obj(value, force=True): + return dbid_to_obj(value, ObjectDB) + + +def _to_obj_or_any(value): + obj = dbid_to_obj(value, ObjectDB) + return obj if obj is not None else value + + +def validate_spawn_value(value, validator=None): + """ + Analyze the value and produce a value for use at the point of spawning. + + Args: + value (any): This can be: + callable - will be called as callable() + (callable, (args,)) - will be called as callable(*args) + other - will be assigned depending on the variable type + validator (callable, optional): If given, this will be called with the value to + check and guarantee the outcome is of a given type. + + Returns: + any (any): The (potentially pre-processed value to use for this prototype key) + + """ + value = protfunc_parser(value) + validator = validator if validator else lambda o: o + if callable(value): + return validator(value()) + elif value and is_iter(value) and callable(value[0]): + # a structure (callable, (args, )) + args = value[1:] + return validator(value[0](*make_iter(args))) + else: + return validator(value) + +# Spawner mechanism + + +def validate_prototype(prototype, protkey=None, protparents=None, _visited=None): + """ + Run validation on a prototype, checking for inifinite regress. + + Args: + prototype (dict): Prototype to validate. + protkey (str, optional): The name of the prototype definition. If not given, the prototype + dict needs to have the `prototype_key` field set. + protpartents (dict, optional): The available prototype parent library. If + note given this will be determined from settings/database. + _visited (list, optional): This is an internal work array and should not be set manually. + Raises: + RuntimeError: If prototype has invalid structure. + + """ + if not protparents: + protparents = get_protparent_dict() + if _visited is None: + _visited = [] + + protkey = protkey and protkey.lower() or prototype.get('prototype_key', None) + + assert isinstance(prototype, dict) + + if id(prototype) in _visited: + raise RuntimeError("%s has infinite nesting of prototypes." % protkey or prototype) + + _visited.append(id(prototype)) + protstrings = prototype.get("prototype") + + if protstrings: + for protstring in make_iter(protstrings): + protstring = protstring.lower() + if protkey is not None and protstring == protkey: + raise RuntimeError("%s tries to prototype itself." % protkey or prototype) + protparent = protparents.get(protstring) + if not protparent: + raise RuntimeError( + "%s's prototype '%s' was not found." % (protkey or prototype, protstring)) + validate_prototype(protparent, protstring, protparents, _visited) + + +def _get_prototype(dic, prot, protparents): + """ + Recursively traverse a prototype dictionary, including multiple + inheritance. Use validate_prototype before this, we don't check + for infinite recursion here. + + """ + if "prototype" in dic: + # move backwards through the inheritance + for prototype in make_iter(dic["prototype"]): + # Build the prot dictionary in reverse order, overloading + new_prot = _get_prototype(protparents.get(prototype.lower(), {}), prot, protparents) + prot.update(new_prot) + prot.update(dic) + prot.pop("prototype", None) # we don't need this anymore + return prot + + +def prototype_diff_from_object(prototype, obj): + """ + Get a simple diff for a prototype compared to an object which may or may not already have a + prototype (or has one but changed locally). For more complex migratations a manual diff may be + needed. + + Args: + prototype (dict): Prototype. + obj (Object): Object to + + Returns: + diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...} + + """ + prot1 = prototype + prot2 = prototype_from_object(obj) + + diff = {} + for key, value in prot1.items(): + diff[key] = "KEEP" + if key in prot2: + if callable(prot2[key]) or value != prot2[key]: + diff[key] = "UPDATE" + elif key not in prot2: + diff[key] = "REMOVE" + + return diff + + +def batch_update_objects_with_prototype(prototype, diff=None, objects=None): + """ + Update existing objects with the latest version of the prototype. + + Args: + prototype (str or dict): Either the `prototype_key` to use or the + prototype dict itself. + diff (dict, optional): This a diff structure that describes how to update the protototype. + If not given this will be constructed from the first object found. + objects (list, optional): List of objects to update. If not given, query for these + objects using the prototype's `prototype_key`. + Returns: + changed (int): The number of objects that had changes applied to them. + + """ + prototype_key = prototype if isinstance(prototype, basestring) else prototype['prototype_key'] + prototype_obj = search_db_prototype(prototype_key, return_queryset=True) + prototype_obj = prototype_obj[0] if prototype_obj else None + new_prototype = prototype_obj.db.prototype + objs = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + + if not objs: + return 0 + + if not diff: + diff = prototype_diff_from_object(new_prototype, objs[0]) + + changed = 0 + for obj in objs: + do_save = False + for key, directive in diff.items(): + val = new_prototype[key] + if directive in ('UPDATE', 'REPLACE'): + do_save = True + if key == 'key': + obj.db_key = validate_spawn_value(val, str) + elif key == 'typeclass': + obj.db_typeclass_path = validate_spawn_value(val, str) + elif key == 'location': + obj.db_location = validate_spawn_value(val, _to_obj) + elif key == 'home': + obj.db_home = validate_spawn_value(val, _to_obj) + elif key == 'destination': + obj.db_destination = validate_spawn_value(val, _to_obj) + elif key == 'locks': + if directive == 'REPLACE': + obj.locks.clear() + obj.locks.add(validate_spawn_value(val, str)) + elif key == 'permissions': + if directive == 'REPLACE': + obj.permissions.clear() + obj.permissions.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'aliases': + if directive == 'REPLACE': + obj.aliases.clear() + obj.aliases.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'tags': + if directive == 'REPLACE': + obj.tags.clear() + obj.tags.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'attrs': + if directive == 'REPLACE': + obj.attributes.clear() + obj.attributes.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'exec': + # we don't auto-rerun exec statements, it would be huge security risk! + pass + else: + obj.attributes.add(key, validate_spawn_value(val, _to_obj)) + elif directive == 'REMOVE': + do_save = True + if key == 'key': + obj.db_key = '' + elif key == 'typeclass': + # fall back to default + obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS + elif key == 'location': + obj.db_location = None + elif key == 'home': + obj.db_home = None + elif key == 'destination': + obj.db_destination = None + elif key == 'locks': + obj.locks.clear() + elif key == 'permissions': + obj.permissions.clear() + elif key == 'aliases': + obj.aliases.clear() + elif key == 'tags': + obj.tags.clear() + elif key == 'attrs': + obj.attributes.clear() + elif key == 'exec': + # we don't auto-rerun exec statements, it would be huge security risk! + pass + else: + obj.attributes.remove(key) + if do_save: + changed += 1 + obj.save() + + return changed + + +def _batch_create_object(*objparams): + """ + This is a cut-down version of the create_object() function, + optimized for speed. It does NOT check and convert various input + so make sure the spawned Typeclass works before using this! + + Args: + objsparams (tuple): Each paremter tuple will create one object instance using the parameters within. + The parameters should be given in the following order: + - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`. + - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`. + - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`. + - `aliases` (list): A list of alias strings for + adding with `new_object.aliases.batch_add(*aliases)`. + - `nattributes` (list): list of tuples `(key, value)` to be loop-added to + add with `new_obj.nattributes.add(*tuple)`. + - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for + adding with `new_obj.attributes.batch_add(*attributes)`. + - `tags` (list): list of tuples `(key, category)` for adding + with `new_obj.tags.batch_add(*tags)`. + - `execs` (list): Code strings to execute together with the creation + of each object. They will be executed with `evennia` and `obj` + (the newly created object) available in the namespace. Execution + will happend after all other properties have been assigned and + is intended for calling custom handlers etc. + + Returns: + objects (list): A list of created objects + + Notes: + The `exec` list will execute arbitrary python code so don't allow this to be available to + unprivileged users! + + """ + + # bulk create all objects in one go + + # unfortunately this doesn't work since bulk_create doesn't creates pks; + # the result would be duplicate objects at the next stage, so we comment + # it out for now: + # dbobjs = _ObjectDB.objects.bulk_create(dbobjs) + + dbobjs = [ObjectDB(**objparam[0]) for objparam in objparams] + objs = [] + for iobj, obj in enumerate(dbobjs): + # call all setup hooks on each object + objparam = objparams[iobj] + # setup + obj._createdict = {"permissions": make_iter(objparam[1]), + "locks": objparam[2], + "aliases": make_iter(objparam[3]), + "nattributes": objparam[4], + "attributes": objparam[5], + "tags": make_iter(objparam[6])} + # this triggers all hooks + obj.save() + # run eventual extra code + for code in objparam[7]: + if code: + exec(code, {}, {"evennia": evennia, "obj": obj}) + objs.append(obj) + return objs + + +def spawn(*prototypes, **kwargs): + """ + Spawn a number of prototyped objects. + + Args: + prototypes (dict): Each argument should be a prototype + dictionary. + Kwargs: + prototype_modules (str or list): A python-path to a prototype + module, or a list of such paths. These will be used to build + the global protparents dictionary accessible by the input + prototypes. If not given, it will instead look for modules + defined by settings.PROTOTYPE_MODULES. + prototype_parents (dict): A dictionary holding a custom + prototype-parent dictionary. Will overload same-named + prototypes from prototype_modules. + return_prototypes (bool): Only return a list of the + prototype-parents (no object creation happens) + + Returns: + object (Object): Spawned object. + + """ + # get available protparents + protparents = get_protparent_dict() + + # overload module's protparents with specifically given protparents + protparents.update(kwargs.get("prototype_parents", {})) + for key, prototype in protparents.items(): + validate_prototype(prototype, key.lower(), protparents) + + if "return_prototypes" in kwargs: + # only return the parents + return copy.deepcopy(protparents) + + objsparams = [] + for prototype in prototypes: + + validate_prototype(prototype, None, protparents) + prot = _get_prototype(prototype, {}, protparents) + if not prot: + continue + + # extract the keyword args we need to create the object itself. If we get a callable, + # call that to get the value (don't catch errors) + create_kwargs = {} + # we must always add a key, so if not given we use a shortened md5 hash. There is a (small) + # chance this is not unique but it should usually not be a problem. + val = prot.pop("key", "Spawned-{}".format( + hashlib.md5(str(time.time())).hexdigest()[:6])) + create_kwargs["db_key"] = validate_spawn_value(val, str) + + val = prot.pop("location", None) + create_kwargs["db_location"] = validate_spawn_value(val, _to_obj) + + val = prot.pop("home", settings.DEFAULT_HOME) + create_kwargs["db_home"] = validate_spawn_value(val, _to_obj) + + val = prot.pop("destination", None) + create_kwargs["db_destination"] = validate_spawn_value(val, _to_obj) + + val = prot.pop("typeclass", settings.BASE_OBJECT_TYPECLASS) + create_kwargs["db_typeclass_path"] = validate_spawn_value(val, str) + + # extract calls to handlers + val = prot.pop("permissions", []) + permission_string = validate_spawn_value(val, make_iter) + val = prot.pop("locks", "") + lock_string = validate_spawn_value(val, str) + val = prot.pop("aliases", []) + alias_string = validate_spawn_value(val, make_iter) + + val = prot.pop("tags", []) + tags = validate_spawn_value(val, make_iter) + + prototype_key = prototype.get('prototype_key', None) + if prototype_key: + # we make sure to add a tag identifying which prototype created this object + tags.append((prototype_key, _PROTOTYPE_TAG_CATEGORY)) + + val = prot.pop("exec", "") + execs = validate_spawn_value(val, make_iter) + + # extract ndb assignments + nattribute = dict((key.split("_", 1)[1], validate_spawn_value(val, _to_obj)) + for key, val in prot.items() if key.startswith("ndb_")) + + # the rest are attributes + val = prot.pop("attrs", []) + attributes = validate_spawn_value(val, list) + + simple_attributes = [] + for key, value in ((key, value) for key, value in prot.items() + if not (key.startswith("ndb_"))): + if is_iter(value) and len(value) > 1: + # (value, category) + simple_attributes.append((key, + validate_spawn_value(value[0], _to_obj_or_any), + validate_spawn_value(value[1], str))) + else: + simple_attributes.append((key, + validate_spawn_value(value, _to_obj_or_any))) + + attributes = attributes + simple_attributes + attributes = [tup for tup in attributes if not tup[0] in _NON_CREATE_KWARGS] + + # pack for call into _batch_create_object + objsparams.append((create_kwargs, permission_string, lock_string, + alias_string, nattributes, attributes, tags, execs)) + + return _batch_create_object(*objsparams) + + +# Testing + +if __name__ == "__main__": + protparents = { + "NOBODY": {}, + # "INFINITE" : { + # "prototype":"INFINITE" + # }, + "GOBLIN": { + "key": "goblin grunt", + "health": lambda: randint(20, 30), + "resists": ["cold", "poison"], + "attacks": ["fists"], + "weaknesses": ["fire", "light"] + }, + "GOBLIN_WIZARD": { + "prototype": "GOBLIN", + "key": "goblin wizard", + "spells": ["fire ball", "lighting bolt"] + }, + "GOBLIN_ARCHER": { + "prototype": "GOBLIN", + "key": "goblin archer", + "attacks": ["short bow"] + }, + "ARCHWIZARD": { + "attacks": ["archwizard staff"], + }, + "GOBLIN_ARCHWIZARD": { + "key": "goblin archwizard", + "prototype": ("GOBLIN_WIZARD", "ARCHWIZARD") + } + } + # test + print([o.key for o in spawn(protparents["GOBLIN"], + protparents["GOBLIN_ARCHWIZARD"], + prototype_parents=protparents)]) diff --git a/evennia/prototypes/utils.py b/evennia/prototypes/utils.py new file mode 100644 index 0000000000..74eaef169f --- /dev/null +++ b/evennia/prototypes/utils.py @@ -0,0 +1,150 @@ +""" + +Prototype utilities + +""" + + +class PermissionError(RuntimeError): + pass + + + + + +def get_protparent_dict(): + """ + Get prototype parents. + + Returns: + parent_dict (dict): A mapping {prototype_key: prototype} for all available prototypes. + + """ + return {prototype['prototype_key']: prototype for prototype in search_prototype()} + + +def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): + """ + Collate a list of found prototypes based on search criteria and access. + + Args: + caller (Account or Object): The object requesting the list. + key (str, optional): Exact or partial prototype key to query for. + tags (str or list, optional): Tag key or keys to query for. + show_non_use (bool, optional): Show also prototypes the caller may not use. + show_non_edit (bool, optional): Show also prototypes the caller may not edit. + Returns: + table (EvTable or None): An EvTable representation of the prototypes. None + if no prototypes were found. + + """ + # this allows us to pass lists of empty strings + tags = [tag for tag in make_iter(tags) if tag] + + # get prototypes for readonly and db-based prototypes + prototypes = search_prototype(key, tags) + + # get use-permissions of readonly attributes (edit is always False) + display_tuples = [] + for prototype in sorted(prototypes, key=lambda d: d.get('prototype_key', '')): + lock_use = caller.locks.check_lockstring( + caller, prototype.get('prototype_locks', ''), access_type='use') + if not show_non_use and not lock_use: + continue + if prototype.get('prototype_key', '') in _MODULE_PROTOTYPES: + lock_edit = False + else: + lock_edit = caller.locks.check_lockstring( + caller, prototype.get('prototype_locks', ''), access_type='edit') + if not show_non_edit and not lock_edit: + continue + ptags = [] + for ptag in prototype.get('prototype_tags', []): + if is_iter(ptag): + if len(ptag) > 1: + ptags.append("{} (category: {}".format(ptag[0], ptag[1])) + else: + ptags.append(ptag[0]) + else: + ptags.append(str(ptag)) + + display_tuples.append( + (prototype.get('prototype_key', ''), + prototype.get('prototype_desc', ''), + "{}/{}".format('Y' if lock_use else 'N', 'Y' if lock_edit else 'N'), + ",".join(ptags))) + + if not display_tuples: + return None + + table = [] + width = 78 + for i in range(len(display_tuples[0])): + table.append([str(display_tuple[i]) for display_tuple in display_tuples]) + table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=width) + table.reformat_column(0, width=22) + table.reformat_column(1, width=29) + table.reformat_column(2, width=11, align='c') + table.reformat_column(3, width=16) + return table + + +def prototype_to_str(prototype): + """ + Format a prototype to a nice string representation. + + Args: + prototype (dict): The prototype. + """ + + header = ( + "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" + "|cdesc:|n {} \n|cprototype:|n ".format( + prototype['prototype_key'], + ", ".join(prototype['prototype_tags']), + prototype['prototype_locks'], + prototype['prototype_desc'])) + proto = ("{{\n {} \n}}".format( + "\n ".join( + "{!r}: {!r},".format(key, value) for key, value in + sorted(prototype.items()) if key not in _PROTOTYPE_META_NAMES)).rstrip(",")) + return header + proto + + +def prototype_from_object(obj): + """ + Guess a minimal prototype from an existing object. + + Args: + obj (Object): An object to analyze. + + Returns: + prototype (dict): A prototype estimating the current state of the object. + + """ + # first, check if this object already has a prototype + + prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) + prot = search_prototype(prot) + if not prot or len(prot) > 1: + # no unambiguous prototype found - build new prototype + prot = {} + prot['prototype_key'] = "From-Object-{}-{}".format( + obj.key, hashlib.md5(str(time.time())).hexdigest()[:6]) + prot['prototype_desc'] = "Built from {}".format(str(obj)) + prot['prototype_locks'] = "use:all();edit:all()" + + prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6] + prot['location'] = obj.db_location + prot['home'] = obj.db_home + prot['destination'] = obj.db_destination + prot['typeclass'] = obj.db_typeclass_path + prot['locks'] = obj.locks.all() + prot['permissions'] = obj.permissions.get() + prot['aliases'] = obj.aliases.get() + prot['tags'] = [(tag.key, tag.category, tag.data) + for tag in obj.tags.get(return_tagobj=True, return_list=True)] + prot['attrs'] = [(attr.key, attr.value, attr.category, attr.locks) + for attr in obj.attributes.get(return_obj=True, return_list=True)] + + return prot diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py deleted file mode 100644 index 3c269ca742..0000000000 --- a/evennia/utils/spawner.py +++ /dev/null @@ -1,1752 +0,0 @@ -""" -Spawner - -The spawner takes input files containing object definitions in -dictionary forms. These use a prototype architecture to define -unique objects without having to make a Typeclass for each. - -The main function is `spawn(*prototype)`, where the `prototype` -is a dictionary like this: - -```python -GOBLIN = { - "typeclass": "types.objects.Monster", - "key": "goblin grunt", - "health": lambda: randint(20,30), - "resists": ["cold", "poison"], - "attacks": ["fists"], - "weaknesses": ["fire", "light"] - "tags": ["mob", "evil", ('greenskin','mob')] - "args": [("weapon", "sword")] - } -``` - -Possible keywords are: - prototype_key (str): name of this prototype. This is used when storing prototypes and should - be unique. This should always be defined but for prototypes defined in modules, the - variable holding the prototype dict will become the prototype_key if it's not explicitly - given. - prototype_desc (str, optional): describes prototype in listings - prototype_locks (str, optional): locks for restricting access to this prototype. Locktypes - supported are 'edit' and 'use'. - prototype_tags(list, optional): List of tags or tuples (tag, category) used to group prototype - in listings - - prototype (str or callable, optional): bame (prototype_key) of eventual parent prototype - typeclass (str or callable, optional): if not set, will use typeclass of parent prototype or use - `settings.BASE_OBJECT_TYPECLASS` - key (str or callable, optional): the name of the spawned object. If not given this will set to a - random hash - location (obj, str or callable, optional): location of the object - a valid object or #dbref - home (obj, str or callable, optional): valid object or #dbref - destination (obj, str or callable, optional): only valid for exits (object or #dbref) - - permissions (str, list or callable, optional): which permissions for spawned object to have - locks (str or callable, optional): lock-string for the spawned object - aliases (str, list or callable, optional): Aliases for the spawned object - exec (str or callable, optional): this is a string of python code to execute or a list of such - codes. This can be used e.g. to trigger custom handlers on the object. The execution - namespace contains 'evennia' for the library and 'obj'. All default spawn commands limit - this functionality to Developer/superusers. Usually it's better to use callables or - prototypefuncs instead of this. - tags (str, tuple, list or callable, optional): string or list of strings or tuples - `(tagstr, category)`. Plain strings will be result in tags with no category (default tags). - attrs (tuple, list or callable, optional): tuple or list of tuples of Attributes to add. This - form allows more complex Attributes to be set. Tuples at least specify `(key, value)` - but can also specify up to `(key, value, category, lockstring)`. If you want to specify a - lockstring but not a category, set the category to `None`. - ndb_ (any): value of a nattribute (ndb_ is stripped) - other (any): any other name is interpreted as the key of an Attribute with - its value. Such Attributes have no categories. - -Each value can also be a callable that takes no arguments. It should -return the value to enter into the field and will be called every time -the prototype is used to spawn an object. Note, if you want to store -a callable in an Attribute, embed it in a tuple to the `args` keyword. - -By specifying the "prototype" key, the prototype becomes a child of -that prototype, inheritng all prototype slots it does not explicitly -define itself, while overloading those that it does specify. - -```python -import random - - -GOBLIN_WIZARD = { - "prototype": GOBLIN, - "key": "goblin wizard", - "spells": ["fire ball", "lighting bolt"] - } - -GOBLIN_ARCHER = { - "prototype": GOBLIN, - "key": "goblin archer", - "attack_skill": (random, (5, 10))" - "attacks": ["short bow"] -} -``` - -One can also have multiple prototypes. These are inherited from the -left, with the ones further to the right taking precedence. - -```python -ARCHWIZARD = { - "attack": ["archwizard staff", "eye of doom"] - -GOBLIN_ARCHWIZARD = { - "key" : "goblin archwizard" - "prototype": (GOBLIN_WIZARD, ARCHWIZARD), -} -``` - -The *goblin archwizard* will have some different attacks, but will -otherwise have the same spells as a *goblin wizard* who in turn shares -many traits with a normal *goblin*. - - -Storage mechanism: - -This sets up a central storage for prototypes. The idea is to make these -available in a repository for buildiers to use. Each prototype is stored -in a Script so that it can be tagged for quick sorting/finding and locked for limiting -access. - -This system also takes into consideration prototypes defined and stored in modules. -Such prototypes are considered 'read-only' to the system and can only be modified -in code. To replace a default prototype, add the same-name prototype in a -custom module read later in the settings.PROTOTYPE_MODULES list. To remove a default -prototype, override its name with an empty dict. - - -""" -from __future__ import print_function - -import copy -import hashlib -import time -from ast import literal_eval -from django.conf import settings -from random import randint -import evennia -from evennia.objects.models import ObjectDB -from evennia.utils.utils import ( - make_iter, all_from_module, callables_from_module, dbid_to_obj, - is_iter, crop, get_all_typeclasses) -from evennia.utils import inlinefuncs - -from evennia.scripts.scripts import DefaultScript -from evennia.utils.create import create_script -from evennia.utils.evtable import EvTable -from evennia.utils.evmenu import EvMenu, list_node -from evennia.utils.ansi import strip_ansi - - -_CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") -_PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") -_NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES -_MODULE_PROTOTYPES = {} -_MODULE_PROTOTYPE_MODULES = {} -_PROTOTYPEFUNCS = {} -_MENU_CROP_WIDTH = 15 -_PROTOTYPE_TAG_CATEGORY = "spawned_by_prototype" - -_MENU_ATTR_LITERAL_EVAL_ERROR = ( - "|rCritical Python syntax error in your value. Only primitive Python structures are allowed.\n" - "You also need to use correct Python syntax. Remember especially to put quotes around all " - "strings inside lists and dicts.|n") - - -class PermissionError(RuntimeError): - pass - - -# load resources - - -for mod in settings.PROTOTYPE_MODULES: - # to remove a default prototype, override it with an empty dict. - # internally we store as (key, desc, locks, tags, prototype_dict) - prots = [(prototype_key, prot) for prototype_key, prot in all_from_module(mod).items() - if prot and isinstance(prot, dict)] - # assign module path to each prototype_key for easy reference - _MODULE_PROTOTYPE_MODULES.update({prototype_key: mod for prototype_key, _ in prots}) - # make sure the prototype contains all meta info - for prototype_key, prot in prots: - actual_prot_key = prot.get('prototype_key', prototype_key).lower() - prot.update({ - "prototype_key": actual_prot_key, - "prototype_desc": prot['prototype_desc'] if 'prototype_desc' in prot else mod, - "prototype_locks": (prot['prototype_locks'] - if 'prototype_locks' in prot else "use:all();edit:false()"), - "prototype_tags": list(set(make_iter(prot.get('prototype_tags', [])) + ["module"]))}) - _MODULE_PROTOTYPES[actual_prot_key] = prot - - -for mod in settings.PROTOTYPEFUNC_MODULES: - try: - _PROTOTYPEFUNCS.update(callables_from_module(mod)) - except ImportError: - pass - - -# Helper functions - - -def protfunc_parser(value, available_functions=None, **kwargs): - """ - This is intended to be used by the in-game olc mechanism. It will parse the prototype - value for function tokens like `$protfunc(arg, arg, ...)`. These functions behave all the - parameters of `inlinefuncs` but they are *not* passed a Session since this is not guaranteed to - be available at the time of spawning. They may also return other structures than strings. - - Available protfuncs are specified as callables in one of the modules of - `settings.PROTOTYPEFUNC_MODULES`, or specified on the command line. - - Args: - value (string): The value to test for a parseable protfunc. - available_functions (dict, optional): Mapping of name:protfunction to use for this parsing. - - Kwargs: - any (any): Passed on to the inlinefunc. - - Returns: - any (any): A structure to replace the string on the prototype level. If this is a - callable or a (callable, (args,)) structure, it will be executed as if one had supplied - it to the prototype directly. - - """ - if not isinstance(value, basestring): - return value - available_functions = _PROTOTYPEFUNCS if available_functions is None else available_functions - return inlinefuncs.parse_inlinefunc(value, _available_funcs=available_functions) - - -def _to_obj(value, force=True): - return dbid_to_obj(value, ObjectDB) - - -def _to_obj_or_any(value): - obj = dbid_to_obj(value, ObjectDB) - return obj if obj is not None else value - - -def validate_spawn_value(value, validator=None): - """ - Analyze the value and produce a value for use at the point of spawning. - - Args: - value (any): This can be: - callable - will be called as callable() - (callable, (args,)) - will be called as callable(*args) - other - will be assigned depending on the variable type - validator (callable, optional): If given, this will be called with the value to - check and guarantee the outcome is of a given type. - - Returns: - any (any): The (potentially pre-processed value to use for this prototype key) - - """ - value = protfunc_parser(value) - validator = validator if validator else lambda o: o - if callable(value): - return validator(value()) - elif value and is_iter(value) and callable(value[0]): - # a structure (callable, (args, )) - args = value[1:] - return validator(value[0](*make_iter(args))) - else: - return validator(value) - - -# Prototype storage mechanisms - - -class DbPrototype(DefaultScript): - """ - This stores a single prototype - """ - def at_script_creation(self): - self.key = "empty prototype" # prototype_key - self.desc = "A prototype" # prototype_desc - - - - - -def save_db_prototype(caller, prototype, key=None, desc=None, tags=None, locks="", delete=False): - """ - Store a prototype persistently. - - Args: - caller (Account or Object): Caller aiming to store prototype. At this point - the caller should have permission to 'add' new prototypes, but to edit - an existing prototype, the 'edit' lock must be passed on that prototype. - prototype (dict): Prototype dict. - key (str): Name of prototype to store. Will be inserted as `prototype_key` in the prototype. - desc (str, optional): Description of prototype, to use in listing. Will be inserted - as `prototype_desc` in the prototype. - tags (list, optional): Tag-strings to apply to prototype. These are always - applied with the 'db_prototype' category. Will be inserted as `prototype_tags`. - locks (str, optional): Locks to apply to this prototype. Used locks - are 'use' and 'edit'. Will be inserted as `prototype_locks` in the prototype. - delete (bool, optional): Delete an existing prototype identified by 'key'. - This requires `caller` to pass the 'edit' lock of the prototype. - Returns: - stored (StoredPrototype or None): The resulting prototype (new or edited), - or None if deleting. - Raises: - PermissionError: If edit lock was not passed by caller. - - - """ - key_orig = key or prototype.get('prototype_key', None) - if not key_orig: - caller.msg("This prototype requires a prototype_key.") - return False - key = str(key).lower() - - # we can't edit a prototype defined in a module - if key in _MODULE_PROTOTYPES: - mod = _MODULE_PROTOTYPE_MODULES.get(key, "N/A") - raise PermissionError("{} is a read-only prototype " - "(defined as code in {}).".format(key_orig, mod)) - - prototype['prototype_key'] = key - - if desc: - desc = prototype['prototype_desc'] = desc - else: - desc = prototype.get('prototype_desc', '') - - # set up locks and check they are on a valid form - locks = locks or prototype.get( - "prototype_locks", "use:all();edit:id({}) or perm(Admin)".format(caller.id)) - prototype['prototype_locks'] = locks - - is_valid, err = caller.locks.validate(locks) - if not is_valid: - caller.msg("Lock error: {}".format(err)) - return False - - if tags: - tags = [(tag, "db_prototype") for tag in make_iter(tags)] - else: - tags = prototype.get('prototype_tags', []) - prototype['prototype_tags'] = tags - - stored_prototype = DbPrototype.objects.filter(db_key=key) - - if stored_prototype: - # edit existing prototype - stored_prototype = stored_prototype[0] - if not stored_prototype.access(caller, 'edit'): - raise PermissionError("{} does not have permission to " - "edit prototype {}.".format(caller, key)) - - if delete: - # delete prototype - stored_prototype.delete() - return True - - if desc: - stored_prototype.desc = desc - if tags: - stored_prototype.tags.batch_add(*tags) - if locks: - stored_prototype.locks.add(locks) - if prototype: - stored_prototype.attributes.add("prototype", prototype) - elif delete: - # didn't find what to delete - return False - else: - # create a new prototype - stored_prototype = create_script( - DbPrototype, key=key, desc=desc, persistent=True, - locks=locks, tags=tags, attributes=[("prototype", prototype)]) - return stored_prototype - - -def delete_db_prototype(caller, key): - """ - Delete a stored prototype - - Args: - caller (Account or Object): Caller aiming to delete a prototype. - key (str): The persistent prototype to delete. - Returns: - success (bool): If deletion worked or not. - Raises: - PermissionError: If 'edit' lock was not passed. - - """ - return save_db_prototype(caller, key, None, delete=True) - - -def search_db_prototype(key=None, tags=None, return_queryset=False): - """ - Find persistent (database-stored) prototypes based on key and/or tags. - - Kwargs: - key (str): An exact or partial key to query for. - tags (str or list): Tag key or keys to query for. These - will always be applied with the 'db_protototype' - tag category. - return_queryset (bool, optional): Return the database queryset. - Return: - matches (queryset or list): All found DbPrototypes. If `return_queryset` - is not set, this is a list of prototype dicts. - - Note: - This does not include read-only prototypes defined in modules; use - `search_module_prototype` for those. - - """ - if tags: - # exact match on tag(s) - tags = make_iter(tags) - tag_categories = ["db_prototype" for _ in tags] - matches = DbPrototype.objects.get_by_tag(tags, tag_categories) - else: - matches = DbPrototype.objects.all() - if key: - # exact or partial match on key - matches = matches.filter(db_key=key) or matches.filter(db_key__icontains=key) - if not return_queryset: - # return prototype - matches = [dict(dbprot.attributes.get("prototype", {})) for dbprot in matches] - return matches - - -def search_module_prototype(key=None, tags=None): - """ - Find read-only prototypes, defined in modules. - - Kwargs: - key (str): An exact or partial key to query for. - tags (str or list): Tag key to query for. - - Return: - matches (list): List of prototypes matching the search criterion. - - """ - matches = {} - if tags: - # use tags to limit selection - tagset = set(tags) - matches = {prototype_key: prototype - for prototype_key, prototype in _MODULE_PROTOTYPES.items() - if tagset.intersection(prototype.get("prototype_tags", []))} - else: - matches = _MODULE_PROTOTYPES - - if key: - if key in matches: - # exact match - return [matches[key]] - else: - # fuzzy matching - return [prototype for prototype_key, prototype in matches.items() - if key in prototype_key] - else: - return [match for match in matches.values()] - - -def search_prototype(key=None, tags=None): - """ - Find prototypes based on key and/or tags, or all prototypes. - - Kwargs: - key (str): An exact or partial key to query for. - tags (str or list): Tag key or keys to query for. These - will always be applied with the 'db_protototype' - tag category. - - Return: - matches (list): All found prototype dicts. If no keys - or tags are given, all available prototypes will be returned. - - Note: - The available prototypes is a combination of those supplied in - PROTOTYPE_MODULES and those stored from in-game. For the latter, - this will use the tags to make a subselection before attempting - to match on the key. So if key/tags don't match up nothing will - be found. - - """ - module_prototypes = search_module_prototype(key, tags) - db_prototypes = search_db_prototype(key, tags) - - matches = db_prototypes + module_prototypes - if len(matches) > 1 and key: - key = key.lower() - # avoid duplicates if an exact match exist between the two types - filter_matches = [mta for mta in matches - if mta.get('prototype_key') and mta['prototype_key'] == key] - if filter_matches and len(filter_matches) < len(matches): - matches = filter_matches - - return matches - - -def search_objects_with_prototype(prototype_key): - """ - Retrieve all object instances created by a given prototype. - - Args: - prototype_key (str): The exact (and unique) prototype identifier to query for. - - Returns: - matches (Queryset): All matching objects spawned from this prototype. - - """ - return ObjectDB.objects.get_by_tag(key=prototype_key, category=_PROTOTYPE_TAG_CATEGORY) - - -def get_protparent_dict(): - """ - Get prototype parents. - - Returns: - parent_dict (dict): A mapping {prototype_key: prototype} for all available prototypes. - - """ - return {prototype['prototype_key']: prototype for prototype in search_prototype()} - - -def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): - """ - Collate a list of found prototypes based on search criteria and access. - - Args: - caller (Account or Object): The object requesting the list. - key (str, optional): Exact or partial prototype key to query for. - tags (str or list, optional): Tag key or keys to query for. - show_non_use (bool, optional): Show also prototypes the caller may not use. - show_non_edit (bool, optional): Show also prototypes the caller may not edit. - Returns: - table (EvTable or None): An EvTable representation of the prototypes. None - if no prototypes were found. - - """ - # this allows us to pass lists of empty strings - tags = [tag for tag in make_iter(tags) if tag] - - # get prototypes for readonly and db-based prototypes - prototypes = search_prototype(key, tags) - - # get use-permissions of readonly attributes (edit is always False) - display_tuples = [] - for prototype in sorted(prototypes, key=lambda d: d.get('prototype_key', '')): - lock_use = caller.locks.check_lockstring( - caller, prototype.get('prototype_locks', ''), access_type='use') - if not show_non_use and not lock_use: - continue - if prototype.get('prototype_key', '') in _MODULE_PROTOTYPES: - lock_edit = False - else: - lock_edit = caller.locks.check_lockstring( - caller, prototype.get('prototype_locks', ''), access_type='edit') - if not show_non_edit and not lock_edit: - continue - ptags = [] - for ptag in prototype.get('prototype_tags', []): - if is_iter(ptag): - if len(ptag) > 1: - ptags.append("{} (category: {}".format(ptag[0], ptag[1])) - else: - ptags.append(ptag[0]) - else: - ptags.append(str(ptag)) - - display_tuples.append( - (prototype.get('prototype_key', ''), - prototype.get('prototype_desc', ''), - "{}/{}".format('Y' if lock_use else 'N', 'Y' if lock_edit else 'N'), - ",".join(ptags))) - - if not display_tuples: - return None - - table = [] - width = 78 - for i in range(len(display_tuples[0])): - table.append([str(display_tuple[i]) for display_tuple in display_tuples]) - table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=width) - table.reformat_column(0, width=22) - table.reformat_column(1, width=29) - table.reformat_column(2, width=11, align='c') - table.reformat_column(3, width=16) - return table - - -def prototype_to_str(prototype): - """ - Format a prototype to a nice string representation. - - Args: - prototype (dict): The prototype. - """ - - header = ( - "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" - "|cdesc:|n {} \n|cprototype:|n ".format( - prototype['prototype_key'], - ", ".join(prototype['prototype_tags']), - prototype['prototype_locks'], - prototype['prototype_desc'])) - proto = ("{{\n {} \n}}".format( - "\n ".join( - "{!r}: {!r},".format(key, value) for key, value in - sorted(prototype.items()) if key not in _PROTOTYPE_META_NAMES)).rstrip(",")) - return header + proto - - -def prototype_from_object(obj): - """ - Guess a minimal prototype from an existing object. - - Args: - obj (Object): An object to analyze. - - Returns: - prototype (dict): A prototype estimating the current state of the object. - - """ - # first, check if this object already has a prototype - - prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) - prot = search_prototype(prot) - if not prot or len(prot) > 1: - # no unambiguous prototype found - build new prototype - prot = {} - prot['prototype_key'] = "From-Object-{}-{}".format( - obj.key, hashlib.md5(str(time.time())).hexdigest()[:6]) - prot['prototype_desc'] = "Built from {}".format(str(obj)) - prot['prototype_locks'] = "use:all();edit:all()" - - prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6] - prot['location'] = obj.db_location - prot['home'] = obj.db_home - prot['destination'] = obj.db_destination - prot['typeclass'] = obj.db_typeclass_path - prot['locks'] = obj.locks.all() - prot['permissions'] = obj.permissions.get() - prot['aliases'] = obj.aliases.get() - prot['tags'] = [(tag.key, tag.category, tag.data) - for tag in obj.tags.get(return_tagobj=True, return_list=True)] - prot['attrs'] = [(attr.key, attr.value, attr.category, attr.locks) - for attr in obj.attributes.get(return_obj=True, return_list=True)] - - return prot - -# Spawner mechanism - - -def validate_prototype(prototype, protkey=None, protparents=None, _visited=None): - """ - Run validation on a prototype, checking for inifinite regress. - - Args: - prototype (dict): Prototype to validate. - protkey (str, optional): The name of the prototype definition. If not given, the prototype - dict needs to have the `prototype_key` field set. - protpartents (dict, optional): The available prototype parent library. If - note given this will be determined from settings/database. - _visited (list, optional): This is an internal work array and should not be set manually. - Raises: - RuntimeError: If prototype has invalid structure. - - """ - if not protparents: - protparents = get_protparent_dict() - if _visited is None: - _visited = [] - - protkey = protkey and protkey.lower() or prototype.get('prototype_key', None) - - assert isinstance(prototype, dict) - - if id(prototype) in _visited: - raise RuntimeError("%s has infinite nesting of prototypes." % protkey or prototype) - - _visited.append(id(prototype)) - protstrings = prototype.get("prototype") - - if protstrings: - for protstring in make_iter(protstrings): - protstring = protstring.lower() - if protkey is not None and protstring == protkey: - raise RuntimeError("%s tries to prototype itself." % protkey or prototype) - protparent = protparents.get(protstring) - if not protparent: - raise RuntimeError( - "%s's prototype '%s' was not found." % (protkey or prototype, protstring)) - validate_prototype(protparent, protstring, protparents, _visited) - - -def _get_prototype(dic, prot, protparents): - """ - Recursively traverse a prototype dictionary, including multiple - inheritance. Use validate_prototype before this, we don't check - for infinite recursion here. - - """ - if "prototype" in dic: - # move backwards through the inheritance - for prototype in make_iter(dic["prototype"]): - # Build the prot dictionary in reverse order, overloading - new_prot = _get_prototype(protparents.get(prototype.lower(), {}), prot, protparents) - prot.update(new_prot) - prot.update(dic) - prot.pop("prototype", None) # we don't need this anymore - return prot - - -def prototype_diff_from_object(prototype, obj): - """ - Get a simple diff for a prototype compared to an object which may or may not already have a - prototype (or has one but changed locally). For more complex migratations a manual diff may be - needed. - - Args: - prototype (dict): Prototype. - obj (Object): Object to - - Returns: - diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...} - - """ - prot1 = prototype - prot2 = prototype_from_object(obj) - - diff = {} - for key, value in prot1.items(): - diff[key] = "KEEP" - if key in prot2: - if callable(prot2[key]) or value != prot2[key]: - diff[key] = "UPDATE" - elif key not in prot2: - diff[key] = "REMOVE" - - return diff - - -def batch_update_objects_with_prototype(prototype, diff=None, objects=None): - """ - Update existing objects with the latest version of the prototype. - - Args: - prototype (str or dict): Either the `prototype_key` to use or the - prototype dict itself. - diff (dict, optional): This a diff structure that describes how to update the protototype. If - not given this will be constructed from the first object found. - objects (list, optional): List of objects to update. If not given, query for these - objects using the prototype's `prototype_key`. - Returns: - changed (int): The number of objects that had changes applied to them. - - """ - prototype_key = prototype if isinstance(prototype, basestring) else prototype['prototype_key'] - prototype_obj = search_db_prototype(prototype_key, return_queryset=True) - prototype_obj = prototype_obj[0] if prototype_obj else None - new_prototype = prototype_obj.db.prototype - objs = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) - - if not objs: - return 0 - - if not diff: - diff = prototype_diff_from_object(new_prototype, objs[0]) - - changed = 0 - for obj in objs: - do_save = False - for key, directive in diff.items(): - val = new_prototype[key] - if directive in ('UPDATE', 'REPLACE'): - do_save = True - if key == 'key': - obj.db_key = validate_spawn_value(val, str) - elif key == 'typeclass': - obj.db_typeclass_path = validate_spawn_value(val, str) - elif key == 'location': - obj.db_location = validate_spawn_value(val, _to_obj) - elif key == 'home': - obj.db_home = validate_spawn_value(val, _to_obj) - elif key == 'destination': - obj.db_destination = validate_spawn_value(val, _to_obj) - elif key == 'locks': - if directive == 'REPLACE': - obj.locks.clear() - obj.locks.add(validate_spawn_value(val, str)) - elif key == 'permissions': - if directive == 'REPLACE': - obj.permissions.clear() - obj.permissions.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'aliases': - if directive == 'REPLACE': - obj.aliases.clear() - obj.aliases.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'tags': - if directive == 'REPLACE': - obj.tags.clear() - obj.tags.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'attrs': - if directive == 'REPLACE': - obj.attributes.clear() - obj.attributes.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'exec': - # we don't auto-rerun exec statements, it would be huge security risk! - pass - else: - obj.attributes.add(key, validate_spawn_value(val, _to_obj)) - elif directive == 'REMOVE': - do_save = True - if key == 'key': - obj.db_key = '' - elif key == 'typeclass': - # fall back to default - obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS - elif key == 'location': - obj.db_location = None - elif key == 'home': - obj.db_home = None - elif key == 'destination': - obj.db_destination = None - elif key == 'locks': - obj.locks.clear() - elif key == 'permissions': - obj.permissions.clear() - elif key == 'aliases': - obj.aliases.clear() - elif key == 'tags': - obj.tags.clear() - elif key == 'attrs': - obj.attributes.clear() - elif key == 'exec': - # we don't auto-rerun exec statements, it would be huge security risk! - pass - else: - obj.attributes.remove(key) - if do_save: - changed += 1 - obj.save() - - return changed - - -def _batch_create_object(*objparams): - """ - This is a cut-down version of the create_object() function, - optimized for speed. It does NOT check and convert various input - so make sure the spawned Typeclass works before using this! - - Args: - objsparams (tuple): Each paremter tuple will create one object instance using the parameters within. - The parameters should be given in the following order: - - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`. - - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`. - - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`. - - `aliases` (list): A list of alias strings for - adding with `new_object.aliases.batch_add(*aliases)`. - - `nattributes` (list): list of tuples `(key, value)` to be loop-added to - add with `new_obj.nattributes.add(*tuple)`. - - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for - adding with `new_obj.attributes.batch_add(*attributes)`. - - `tags` (list): list of tuples `(key, category)` for adding - with `new_obj.tags.batch_add(*tags)`. - - `execs` (list): Code strings to execute together with the creation - of each object. They will be executed with `evennia` and `obj` - (the newly created object) available in the namespace. Execution - will happend after all other properties have been assigned and - is intended for calling custom handlers etc. - - Returns: - objects (list): A list of created objects - - Notes: - The `exec` list will execute arbitrary python code so don't allow this to be available to - unprivileged users! - - """ - - # bulk create all objects in one go - - # unfortunately this doesn't work since bulk_create doesn't creates pks; - # the result would be duplicate objects at the next stage, so we comment - # it out for now: - # dbobjs = _ObjectDB.objects.bulk_create(dbobjs) - - dbobjs = [ObjectDB(**objparam[0]) for objparam in objparams] - objs = [] - for iobj, obj in enumerate(dbobjs): - # call all setup hooks on each object - objparam = objparams[iobj] - # setup - obj._createdict = {"permissions": make_iter(objparam[1]), - "locks": objparam[2], - "aliases": make_iter(objparam[3]), - "nattributes": objparam[4], - "attributes": objparam[5], - "tags": make_iter(objparam[6])} - # this triggers all hooks - obj.save() - # run eventual extra code - for code in objparam[7]: - if code: - exec(code, {}, {"evennia": evennia, "obj": obj}) - objs.append(obj) - return objs - - -def spawn(*prototypes, **kwargs): - """ - Spawn a number of prototyped objects. - - Args: - prototypes (dict): Each argument should be a prototype - dictionary. - Kwargs: - prototype_modules (str or list): A python-path to a prototype - module, or a list of such paths. These will be used to build - the global protparents dictionary accessible by the input - prototypes. If not given, it will instead look for modules - defined by settings.PROTOTYPE_MODULES. - prototype_parents (dict): A dictionary holding a custom - prototype-parent dictionary. Will overload same-named - prototypes from prototype_modules. - return_prototypes (bool): Only return a list of the - prototype-parents (no object creation happens) - - Returns: - object (Object): Spawned object. - - """ - # get available protparents - protparents = get_protparent_dict() - - # overload module's protparents with specifically given protparents - protparents.update(kwargs.get("prototype_parents", {})) - for key, prototype in protparents.items(): - validate_prototype(prototype, key.lower(), protparents) - - if "return_prototypes" in kwargs: - # only return the parents - return copy.deepcopy(protparents) - - objsparams = [] - for prototype in prototypes: - - validate_prototype(prototype, None, protparents) - prot = _get_prototype(prototype, {}, protparents) - if not prot: - continue - - # extract the keyword args we need to create the object itself. If we get a callable, - # call that to get the value (don't catch errors) - create_kwargs = {} - # we must always add a key, so if not given we use a shortened md5 hash. There is a (small) - # chance this is not unique but it should usually not be a problem. - val = prot.pop("key", "Spawned-{}".format( - hashlib.md5(str(time.time())).hexdigest()[:6])) - create_kwargs["db_key"] = validate_spawn_value(val, str) - - val = prot.pop("location", None) - create_kwargs["db_location"] = validate_spawn_value(val, _to_obj) - - val = prot.pop("home", settings.DEFAULT_HOME) - create_kwargs["db_home"] = validate_spawn_value(val, _to_obj) - - val = prot.pop("destination", None) - create_kwargs["db_destination"] = validate_spawn_value(val, _to_obj) - - val = prot.pop("typeclass", settings.BASE_OBJECT_TYPECLASS) - create_kwargs["db_typeclass_path"] = validate_spawn_value(val, str) - - # extract calls to handlers - val = prot.pop("permissions", []) - permission_string = validate_spawn_value(val, make_iter) - val = prot.pop("locks", "") - lock_string = validate_spawn_value(val, str) - val = prot.pop("aliases", []) - alias_string = validate_spawn_value(val, make_iter) - - val = prot.pop("tags", []) - tags = validate_spawn_value(val, make_iter) - - prototype_key = prototype.get('prototype_key', None) - if prototype_key: - # we make sure to add a tag identifying which prototype created this object - tags.append((prototype_key, _PROTOTYPE_TAG_CATEGORY)) - - val = prot.pop("exec", "") - execs = validate_spawn_value(val, make_iter) - - # extract ndb assignments - nattribute = dict((key.split("_", 1)[1], validate_spawn_value(val, _to_obj)) - for key, val in prot.items() if key.startswith("ndb_")) - - # the rest are attributes - val = prot.pop("attrs", []) - attributes = validate_spawn_value(val, list) - - simple_attributes = [] - for key, value in ((key, value) for key, value in prot.items() - if not (key.startswith("ndb_"))): - if is_iter(value) and len(value) > 1: - # (value, category) - simple_attributes.append((key, - validate_spawn_value(value[0], _to_obj_or_any), - validate_spawn_value(value[1], str))) - else: - simple_attributes.append((key, - validate_spawn_value(value, _to_obj_or_any))) - - attributes = attributes + simple_attributes - attributes = [tup for tup in attributes if not tup[0] in _NON_CREATE_KWARGS] - - # pack for call into _batch_create_object - objsparams.append((create_kwargs, permission_string, lock_string, - alias_string, nattributes, attributes, tags, execs)) - - return _batch_create_object(*objsparams) - - -# ------------------------------------------------------------ -# -# OLC Prototype design menu -# -# ------------------------------------------------------------ - -# Helper functions - -def _get_menu_prototype(caller): - - prototype = None - if hasattr(caller.ndb._menutree, "olc_prototype"): - prototype = caller.ndb._menutree.olc_prototype - if not prototype: - caller.ndb._menutree.olc_prototype = prototype = {} - caller.ndb._menutree.olc_new = True - return prototype - - -def _is_new_prototype(caller): - return hasattr(caller.ndb._menutree, "olc_new") - - -def _set_menu_prototype(caller, field, value): - prototype = _get_menu_prototype(caller) - prototype[field] = value - caller.ndb._menutree.olc_prototype = prototype - - -def _format_property(prop, required=False, prototype=None, cropper=None): - - if prototype is not None: - prop = prototype.get(prop, '') - - out = prop - if callable(prop): - if hasattr(prop, '__name__'): - out = "<{}>".format(prop.__name__) - else: - out = repr(prop) - if is_iter(prop): - out = ", ".join(str(pr) for pr in prop) - if not out and required: - out = "|rrequired" - return " ({}|n)".format(cropper(out) if cropper else crop(out, _MENU_CROP_WIDTH)) - - -def _set_property(caller, raw_string, **kwargs): - """ - Update a property. To be called by the 'goto' option variable. - - Args: - caller (Object, Account): The user of the wizard. - raw_string (str): Input from user on given node - the new value to set. - Kwargs: - prop (str): Property name to edit with `raw_string`. - processor (callable): Converts `raw_string` to a form suitable for saving. - next_node (str): Where to redirect to after this has run. - Returns: - next_node (str): Next node to go to. - - """ - prop = kwargs.get("prop", "prototype_key") - processor = kwargs.get("processor", None) - next_node = kwargs.get("next_node", "node_index") - - propname_low = prop.strip().lower() - - if callable(processor): - try: - value = processor(raw_string) - except Exception as err: - caller.msg("Could not set {prop} to {value} ({err})".format( - prop=prop.replace("_", "-").capitalize(), value=raw_string, err=str(err))) - # this means we'll re-run the current node. - return None - else: - value = raw_string - - if not value: - return next_node - - prototype = _get_menu_prototype(caller) - - # typeclass and prototype can't co-exist - if propname_low == "typeclass": - prototype.pop("prototype", None) - if propname_low == "prototype": - prototype.pop("typeclass", None) - - caller.ndb._menutree.olc_prototype = prototype - - caller.msg("Set {prop} to '{value}'.".format(prop, value=str(value))) - - return next_node - - -def _wizard_options(curr_node, prev_node, next_node, color="|W"): - options = [] - if prev_node: - options.append({"key": ("|wb|Wack", "b"), - "desc": "{color}({node})|n".format( - color=color, node=prev_node.replace("_", "-")), - "goto": "node_{}".format(prev_node)}) - if next_node: - options.append({"key": ("|wf|Worward", "f"), - "desc": "{color}({node})|n".format( - color=color, node=next_node.replace("_", "-")), - "goto": "node_{}".format(next_node)}) - - if "index" not in (prev_node, next_node): - options.append({"key": ("|wi|Wndex", "i"), - "goto": "node_index"}) - - if curr_node: - options.append({"key": ("|wv|Walidate prototype", "v"), - "goto": ("node_validate_prototype", {"back": curr_node})}) - - return options - - -def _path_cropper(pythonpath): - "Crop path to only the last component" - return pythonpath.split('.')[-1] - - -# Menu nodes - -def node_index(caller): - prototype = _get_menu_prototype(caller) - - text = ("|c --- Prototype wizard --- |n\n\n" - "Define the |yproperties|n of the prototype. All prototype values can be " - "over-ridden at the time of spawning an instance of the prototype, but some are " - "required.\n\n'|wMeta'-properties|n are not used in the prototype itself but are used " - "to organize and list prototypes. The 'Meta-Key' uniquely identifies the prototype " - "and allows you to edit an existing prototype or save a new one for use by you or " - "others later.\n\n(make choice; q to abort. If unsure, start from 1.)") - - options = [] - options.append( - {"desc": "|WPrototype-Key|n|n{}".format(_format_property("Key", True, prototype, None)), - "goto": "node_prototype_key"}) - for key in ('Prototype', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', - 'Permissions', 'Location', 'Home', 'Destination'): - required = False - cropper = None - if key in ("Prototype", "Typeclass"): - required = "prototype" not in prototype and "typeclass" not in prototype - if key == 'Typeclass': - cropper = _path_cropper - options.append( - {"desc": "|w{}|n{}".format( - key, _format_property(key, required, prototype, cropper=cropper)), - "goto": "node_{}".format(key.lower())}) - required = False - for key in ('Desc', 'Tags', 'Locks'): - options.append( - {"desc": "|WPrototype-{}|n|n{}".format(key, _format_property(key, required, prototype, None)), - "goto": "node_prototype_{}".format(key.lower())}) - - return text, options - - -def node_validate_prototype(caller, raw_string, **kwargs): - prototype = _get_menu_prototype(caller) - - txt = prototype_to_str(prototype) - errors = "\n\n|g No validation errors found.|n (but errors could still happen at spawn-time)" - try: - # validate, don't spawn - spawn(prototype, return_prototypes=True) - except RuntimeError as err: - errors = "\n\n|rError: {}|n".format(err) - text = (txt + errors) - - options = _wizard_options(None, kwargs.get("back"), None) - - return text, options - - -def _check_prototype_key(caller, key): - old_prototype = search_prototype(key) - olc_new = _is_new_prototype(caller) - key = key.strip().lower() - if old_prototype: - # we are starting a new prototype that matches an existing - if not caller.locks.check_lockstring( - caller, old_prototype['prototype_locks'], access_type='edit'): - # return to the node_prototype_key to try another key - caller.msg("Prototype '{key}' already exists and you don't " - "have permission to edit it.".format(key=key)) - return "node_prototype_key" - elif olc_new: - # we are selecting an existing prototype to edit. Reset to index. - del caller.ndb._menutree.olc_new - caller.ndb._menutree.olc_prototype = old_prototype - caller.msg("Prototype already exists. Reloading.") - return "node_index" - - return _set_property(caller, key, prop='prototype_key', next_node="node_prototype") - - -def node_prototype_key(caller): - prototype = _get_menu_prototype(caller) - text = ["The prototype name, or |wMeta-Key|n, uniquely identifies the prototype. " - "It is used to find and use the prototype to spawn new entities. " - "It is not case sensitive."] - old_key = prototype.get('prototype_key', None) - if old_key: - text.append("Current key is '|w{key}|n'".format(key=old_key)) - else: - text.append("The key is currently unset.") - text.append("Enter text or make a choice (q for quit)") - text = "\n\n".join(text) - options = _wizard_options("prototype_key", "index", "prototype") - options.append({"key": "_default", - "goto": _check_prototype_key}) - return text, options - - -def _all_prototypes(caller): - return [prototype["prototype_key"] - for prototype in search_prototype() if "prototype_key" in prototype] - - -def _prototype_examine(caller, prototype_name): - prototypes = search_prototype(key=prototype_name) - if prototypes: - caller.msg(prototype_to_str(prototypes[0])) - caller.msg("Prototype not registered.") - return None - - -def _prototype_select(caller, prototype): - ret = _set_property(caller, prototype, prop="prototype", processor=str, next_node="node_key") - caller.msg("Selected prototype |y{}|n. Removed any set typeclass parent.".format(prototype)) - return ret - - -@list_node(_all_prototypes, _prototype_select) -def node_prototype(caller): - prototype = _get_menu_prototype(caller) - - prot_parent_key = prototype.get('prototype') - - text = ["Set the prototype's |yParent Prototype|n. If this is unset, Typeclass will be used."] - if prot_parent_key: - prot_parent = search_prototype(prot_parent_key) - if prot_parent: - text.append("Current parent prototype is {}:\n{}".format(prototype_to_str(prot_parent))) - else: - text.append("Current parent prototype |r{prototype}|n " - "does not appear to exist.".format(prot_parent_key)) - else: - text.append("Parent prototype is not set") - text = "\n\n".join(text) - options = _wizard_options("prototype", "prototype_key", "typeclass", color="|W") - options.append({"key": "_default", - "goto": _prototype_examine}) - - return text, options - - -def _all_typeclasses(caller): - return list(sorted(get_all_typeclasses().keys())) - - -def _typeclass_examine(caller, typeclass_path): - if typeclass_path is None: - # this means we are exiting the listing - return "node_key" - - typeclass = get_all_typeclasses().get(typeclass_path) - if typeclass: - docstr = [] - for line in typeclass.__doc__.split("\n"): - if line.strip(): - docstr.append(line) - elif docstr: - break - docstr = '\n'.join(docstr) if docstr else "" - txt = "Typeclass |y{typeclass_path}|n; First paragraph of docstring:\n\n{docstring}".format( - typeclass_path=typeclass_path, docstring=docstr) - else: - txt = "This is typeclass |y{}|n.".format(typeclass) - caller.msg(txt) - return None - - -def _typeclass_select(caller, typeclass): - ret = _set_property(caller, typeclass, prop='typeclass', processor=str, next_node="node_key") - caller.msg("Selected typeclass |y{}|n. Removed any set prototype parent.".format(typeclass)) - return ret - - -@list_node(_all_typeclasses, _typeclass_select) -def node_typeclass(caller): - prototype = _get_menu_prototype(caller) - typeclass = prototype.get("typeclass") - - text = ["Set the typeclass's parent |yTypeclass|n."] - if typeclass: - text.append("Current typeclass is |y{typeclass}|n.".format(typeclass=typeclass)) - else: - text.append("Using default typeclass {typeclass}.".format( - typeclass=settings.BASE_OBJECT_TYPECLASS)) - text = "\n\n".join(text) - options = _wizard_options("typeclass", "prototype", "key", color="|W") - options.append({"key": "_default", - "goto": _typeclass_examine}) - return text, options - - -def node_key(caller): - prototype = _get_menu_prototype(caller) - key = prototype.get("key") - - text = ["Set the prototype's |yKey|n. This will retain case sensitivity."] - if key: - text.append("Current key value is '|y{key}|n'.".format(key=key)) - else: - text.append("Key is currently unset.") - text = "\n\n".join(text) - options = _wizard_options("key", "typeclass", "aliases") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="key", - processor=lambda s: s.strip(), - next_node="node_aliases"))}) - return text, options - - -def node_aliases(caller): - prototype = _get_menu_prototype(caller) - aliases = prototype.get("aliases") - - text = ["Set the prototype's |yAliases|n. Separate multiple aliases with commas. " - "ill retain case sensitivity."] - if aliases: - text.append("Current aliases are '|y{aliases}|n'.".format(aliases=aliases)) - else: - text.append("No aliases are set.") - text = "\n\n".join(text) - options = _wizard_options("aliases", "key", "attrs") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="aliases", - processor=lambda s: [part.strip() for part in s.split(",")], - next_node="node_attrs"))}) - return text, options - - -def _caller_attrs(caller): - prototype = _get_menu_prototype(caller) - attrs = prototype.get("attrs", []) - return attrs - - -def _attrparse(caller, attr_string): - "attr is entering on the form 'attr = value'" - - if '=' in attr_string: - attrname, value = (part.strip() for part in attr_string.split('=', 1)) - attrname = attrname.lower() - if attrname: - try: - value = literal_eval(value) - except SyntaxError: - caller.msg(_MENU_ATTR_LITERAL_EVAL_ERROR) - else: - return attrname, value - else: - return None, None - - -def _add_attr(caller, attr_string, **kwargs): - attrname, value = _attrparse(caller, attr_string) - if attrname: - prot = _get_menu_prototype(caller) - prot['attrs'][attrname] = value - _set_menu_prototype(caller, "prototype", prot) - text = "Added" - else: - text = "Attribute must be given as 'attrname = ' where uses valid Python." - options = {"key": "_default", - "goto": lambda caller: None} - return text, options - - -def _edit_attr(caller, attrname, new_value, **kwargs): - attrname, value = _attrparse("{}={}".format(caller, attrname, new_value)) - if attrname: - prot = _get_menu_prototype(caller) - prot['attrs'][attrname] = value - text = "Edited Attribute {} = {}".format(attrname, value) - else: - text = "Attribute value must be valid Python." - options = {"key": "_default", - "goto": lambda caller: None} - return text, options - - -def _examine_attr(caller, selection): - prot = _get_menu_prototype(caller) - value = prot['attrs'][selection] - return "Attribute {} = {}".format(selection, value) - - -@list_node(_caller_attrs) -def node_attrs(caller): - prot = _get_menu_prototype(caller) - attrs = prot.get("attrs") - - text = ["Set the prototype's |yAttributes|n. Separate multiple attrs with commas. " - "Will retain case sensitivity."] - if attrs: - text.append("Current attrs are '|y{attrs}|n'.".format(attrs=attrs)) - else: - text.append("No attrs are set.") - text = "\n\n".join(text) - options = _wizard_options("attrs", "aliases", "tags") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="attrs", - processor=lambda s: [part.strip() for part in s.split(",")], - next_node="node_tags"))}) - return text, options - - -def _caller_tags(caller): - prototype = _get_menu_prototype(caller) - tags = prototype.get("tags") - return tags - - -def _add_tag(caller, tag, **kwargs): - tag = tag.strip().lower() - prototype = _get_menu_prototype(caller) - tags = prototype.get('tags', []) - if tags: - if tag not in tags: - tags.append(tag) - else: - tags = [tag] - prot['tags'] = tags - _set_menu_prototype(caller, "prototype", prot) - text = kwargs.get("text") - if not text: - text = "Added tag {}. (return to continue)".format(tag) - options = {"key": "_default", - "goto": lambda caller: None} - return text, options - - -def _edit_tag(caller, old_tag, new_tag, **kwargs): - prototype = _get_menu_prototype(caller) - tags = prototype.get('tags', []) - - old_tag = old_tag.strip().lower() - new_tag = new_tag.strip().lower() - tags[tags.index(old_tag)] = new_tag - prototype['tags'] = tags - _set_menu_prototype(caller, 'prototype', prototype) - - text = kwargs.get('text') - if not text: - text = "Changed tag {} to {}.".format(old_tag, new_tag) - options = {"key": "_default", - "goto": lambda caller: None} - return text, options - - -@list_node(_caller_tags) -def node_tags(caller): - text = "Set the prototype's |yTags|n." - options = _wizard_options("tags", "attrs", "locks") - return text, options - - -def node_locks(caller): - prototype = _get_menu_prototype(caller) - locks = prototype.get("locks") - - text = ["Set the prototype's |yLock string|n. Separate multiple locks with semi-colons. " - "Will retain case sensitivity."] - if locks: - text.append("Current locks are '|y{locks}|n'.".format(locks=locks)) - else: - text.append("No locks are set.") - text = "\n\n".join(text) - options = _wizard_options("locks", "tags", "permissions") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="locks", - processor=lambda s: s.strip(), - next_node="node_permissions"))}) - return text, options - - -def node_permissions(caller): - prototype = _get_menu_prototype(caller) - permissions = prototype.get("permissions") - - text = ["Set the prototype's |yPermissions|n. Separate multiple permissions with commas. " - "Will retain case sensitivity."] - if permissions: - text.append("Current permissions are '|y{permissions}|n'.".format(permissions=permissions)) - else: - text.append("No permissions are set.") - text = "\n\n".join(text) - options = _wizard_options("permissions", "destination", "location") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="permissions", - processor=lambda s: [part.strip() for part in s.split(",")], - next_node="node_location"))}) - return text, options - - -def node_location(caller): - prototype = _get_menu_prototype(caller) - location = prototype.get("location") - - text = ["Set the prototype's |yLocation|n"] - if location: - text.append("Current location is |y{location}|n.".format(location=location)) - else: - text.append("Default location is {}'s inventory.".format(caller)) - text = "\n\n".join(text) - options = _wizard_options("location", "permissions", "home") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="location", - processor=lambda s: s.strip(), - next_node="node_home"))}) - return text, options - - -def node_home(caller): - prototype = _get_menu_prototype(caller) - home = prototype.get("home") - - text = ["Set the prototype's |yHome location|n"] - if home: - text.append("Current home location is |y{home}|n.".format(home=home)) - else: - text.append("Default home location (|y{home}|n) used.".format(home=settings.DEFAULT_HOME)) - text = "\n\n".join(text) - options = _wizard_options("home", "aliases", "destination") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="home", - processor=lambda s: s.strip(), - next_node="node_destination"))}) - return text, options - - -def node_destination(caller): - prototype = _get_menu_prototype(caller) - dest = prototype.get("dest") - - text = ["Set the prototype's |yDestination|n. This is usually only used for Exits."] - if dest: - text.append("Current destination is |y{dest}|n.".format(dest=dest)) - else: - text.append("No destination is set (default).") - text = "\n\n".join(text) - options = _wizard_options("destination", "home", "prototype_desc") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="dest", - processor=lambda s: s.strip(), - next_node="node_prototype_desc"))}) - return text, options - - -def node_prototype_desc(caller): - - prototype = _get_menu_prototype(caller) - text = ["The |wMeta-Description|n briefly describes the prototype for viewing in listings."] - desc = prototype.get("prototype_desc", None) - - if desc: - text.append("The current meta desc is:\n\"|w{desc}|n\"".format(desc=desc)) - else: - text.append("Description is currently unset.") - text = "\n\n".join(text) - options = _wizard_options("prototype_desc", "prototype_key", "prototype_tags") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop='prototype_desc', - processor=lambda s: s.strip(), - next_node="node_prototype_tags"))}) - - return text, options - - -def node_prototype_tags(caller): - prototype = _get_menu_prototype(caller) - text = ["|wMeta-Tags|n can be used to classify and find prototypes. Tags are case-insensitive. " - "Separate multiple by tags by commas."] - tags = prototype.get('prototype_tags', []) - - if tags: - text.append("The current tags are:\n|w{tags}|n".format(tags=tags)) - else: - text.append("No tags are currently set.") - text = "\n\n".join(text) - options = _wizard_options("prototype_tags", "prototype_desc", "prototype_locks") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="prototype_tags", - processor=lambda s: [ - str(part.strip().lower()) for part in s.split(",")], - next_node="node_prototype_locks"))}) - return text, options - - -def node_prototype_locks(caller): - prototype = _get_menu_prototype(caller) - text = ["Set |wMeta-Locks|n on the prototype. There are two valid lock types: " - "'edit' (who can edit the prototype) and 'use' (who can apply the prototype)\n" - "(If you are unsure, leave as default.)"] - locks = prototype.get('prototype_locks', '') - if locks: - text.append("Current lock is |w'{lockstring}'|n".format(lockstring=locks)) - else: - text.append("Lock unset - if not changed the default lockstring will be set as\n" - " |w'use:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) - text = "\n\n".join(text) - options = _wizard_options("prototype_locks", "prototype_tags", "index") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="prototype_locks", - processor=lambda s: s.strip().lower(), - next_node="node_index"))}) - return text, options - - -class OLCMenu(EvMenu): - """ - A custom EvMenu with a different formatting for the options. - - """ - def options_formatter(self, optionlist): - """ - Split the options into two blocks - olc options and normal options - - """ - olc_keys = ("index", "forward", "back", "previous", "next", "validate prototype") - olc_options = [] - other_options = [] - for key, desc in optionlist: - raw_key = strip_ansi(key) - if raw_key in olc_keys: - desc = " {}".format(desc) if desc else "" - olc_options.append("|lc{}|lt{}|le{}".format(raw_key, key, desc)) - else: - other_options.append((key, desc)) - - olc_options = " | ".join(olc_options) + " | " + "|wq|Wuit" if olc_options else "" - other_options = super(OLCMenu, self).options_formatter(other_options) - sep = "\n\n" if olc_options and other_options else "" - - return "{}{}{}".format(olc_options, sep, other_options) - - -def start_olc(caller, session=None, prototype=None): - """ - Start menu-driven olc system for prototypes. - - Args: - caller (Object or Account): The entity starting the menu. - session (Session, optional): The individual session to get data. - prototype (dict, optional): Given when editing an existing - prototype rather than creating a new one. - - """ - menudata = {"node_index": node_index, - "node_validate_prototype": node_validate_prototype, - "node_prototype_key": node_prototype_key, - "node_prototype": node_prototype, - "node_typeclass": node_typeclass, - "node_key": node_key, - "node_aliases": node_aliases, - "node_attrs": node_attrs, - "node_tags": node_tags, - "node_locks": node_locks, - "node_permissions": node_permissions, - "node_location": node_location, - "node_home": node_home, - "node_destination": node_destination, - "node_prototype_desc": node_prototype_desc, - "node_prototype_tags": node_prototype_tags, - "node_prototype_locks": node_prototype_locks, - } - OLCMenu(caller, menudata, startnode='node_index', session=session, olc_prototype=prototype) - - -# Testing - -if __name__ == "__main__": - protparents = { - "NOBODY": {}, - # "INFINITE" : { - # "prototype":"INFINITE" - # }, - "GOBLIN": { - "key": "goblin grunt", - "health": lambda: randint(20, 30), - "resists": ["cold", "poison"], - "attacks": ["fists"], - "weaknesses": ["fire", "light"] - }, - "GOBLIN_WIZARD": { - "prototype": "GOBLIN", - "key": "goblin wizard", - "spells": ["fire ball", "lighting bolt"] - }, - "GOBLIN_ARCHER": { - "prototype": "GOBLIN", - "key": "goblin archer", - "attacks": ["short bow"] - }, - "ARCHWIZARD": { - "attacks": ["archwizard staff"], - }, - "GOBLIN_ARCHWIZARD": { - "key": "goblin archwizard", - "prototype": ("GOBLIN_WIZARD", "ARCHWIZARD") - } - } - # test - print([o.key for o in spawn(protparents["GOBLIN"], - protparents["GOBLIN_ARCHWIZARD"], - prototype_parents=protparents)])