diff --git a/CHANGELOG.md b/CHANGELOG.md index a05d65fdc0..37ff5bfef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,53 @@ -# Evennia Changelog +# Changelog -# Sept 2017: -Release of Evennia 0.7; upgrade to Django 1.11, change 'Player' to +## Evennia 0.8 (2018) + +### Prototype changes + +- A new form of prototype - database-stored prototypes, editable from in-game. The old, + module-created prototypes remain as read-only prototypes. +- All prototypes must have a key `prototype_key` identifying the prototype in listings. This is + checked to be server-unique. Prototypes created in a module will use the global variable name they + are assigned to if no `prototype_key` is given. +- Prototype field `prototype` was renamed to `prototype_parent` to avoid mixing terms. +- All prototypes must either have `typeclass` or `prototype_parent` defined. If using + `prototype_parent`, `typeclass` must be defined somewhere in the inheritance chain. This is a + change from Evennia 0.7 which allowed 'mixin' prototypes without `typeclass`/`prototype_key`. To + make a mixin now, give it a default typeclass, like `evennia.objects.objects.DefaultObject` and just + override in the child as needed. +- The spawn command was extended to accept a full prototype on one line. +- The spawn command got the /save switch to save the defined prototype and its key. +- The command spawn/menu will now start an OLC (OnLine Creation) menu to load/save/edit/spawn prototypes. + +### EvMenu + +- Added `EvMenu.helptext_formatter(helptext)` to allow custom formatting of per-node help. +- Added `evennia.utils.evmenu.list_node` decorator for turning an EvMenu node into a multi-page listing. +- A `goto` option callable returning None (rather than the name of the next node) will now rerun the + current node instead of failing. +- Better error handling of in-node syntax errors. +- Improve dedent of default text/helptext formatter. Right-strip whitespace. + + +### Utils + +- Added new `columnize` function for easily splitting text into multiple columns. At this point it + is not working too well with ansi-colored text however. +- Extend the `dedent` function with a new `baseline_index` kwarg. This allows to force all lines to + the indentation given by the given line regardless of if other lines were already a 0 indentation. + This removes a problem with the original `textwrap.dedent` which will only dedent to the least + indented part of a text. +- Added `exit_cmd` to EvMore pager, to allow for calling a command (e.g. 'look') when leaving the pager. + +### Genaral + +- Start structuring the `CHANGELOG` to list features in more detail. + + +# Overviews + +## Sept 2017: +Release of Evennia 0.7; upgrade to Django 1.11, change 'Player' to 'Account', rework the website template and a slew of other updates. Info on what changed and how to migrate is found here: https://groups.google.com/forum/#!msg/evennia/0JYYNGY-NfE/cDFaIwmPBAAJ @@ -14,9 +60,9 @@ Lots of bugfixes and considerable uptick in contributors. Unittest coverage and PEP8 adoption and refactoring. ## May 2016: -Evennia 0.6 with completely reworked Out-of-band system, making +Evennia 0.6 with completely reworked Out-of-band system, making the message path completely flexible and built around input/outputfuncs. -A completely new webclient, split into the evennia.js library and a +A completely new webclient, split into the evennia.js library and a gui library, making it easier to customize. ## Feb 2016: @@ -33,15 +79,15 @@ library format with a stand-alone launcher, in preparation for making an 'evennia' pypy package and using versioning. The version we will merge with will likely be 0.5. There is also work with an expanded testing structure and the use of threading for saves. We also now -use Travis for automatic build checking. +use Travis for automatic build checking. ## Sept 2014: Updated to Django 1.7+ which means South dependency was dropped and minimum Python version upped to 2.7. MULTISESSION_MODE=3 was added -and the web customization system was overhauled using the latest -functionality of django. Otherwise, mostly bug-fixes and +and the web customization system was overhauled using the latest +functionality of django. Otherwise, mostly bug-fixes and implementation of various smaller feature requests as we got used -to github. Many new users have appeared. +to github. Many new users have appeared. ## Jan 2014: Moved Evennia project from Google Code to github.com/evennia/evennia. diff --git a/evennia/__init__.py b/evennia/__init__.py index 6fdc4aaece..fc916351ad 100644 --- a/evennia/__init__.py +++ b/evennia/__init__.py @@ -174,7 +174,7 @@ def _init(): from .utils import logger from .utils import gametime from .utils import ansi - from .utils.spawner import spawn + from .prototypes.spawner import spawn from . import contrib from .utils.evmenu import EvMenu from .utils.evtable import EvTable diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index d83b14f856..c4c8c37df7 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -633,10 +633,31 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): # this will only be set if the utils.create_account # function was used to create the object. cdict = self._createdict + updates = [] + if not cdict.get("key"): + if not self.db_key: + self.db_key = "#%i" % self.dbid + updates.append("db_key") + elif self.key != cdict.get("key"): + updates.append("db_key") + self.db_key = cdict["key"] + if updates: + self.save(update_fields=updates) + if cdict.get("locks"): self.locks.add(cdict["locks"]) if cdict.get("permissions"): permissions = cdict["permissions"] + if cdict.get("tags"): + # this should be a list of tags, tuples (key, category) or (key, category, data) + self.tags.batch_add(*cdict["tags"]) + if cdict.get("attributes"): + # this should be tuples (key, val, ...) + self.attributes.batch_add(*cdict["attributes"]) + if cdict.get("nattributes"): + # this should be a dict of nattrname:value + for key, value in cdict["nattributes"]: + self.nattributes.add(key, value) del self._createdict self.permissions.batch_add(*permissions) diff --git a/evennia/accounts/manager.py b/evennia/accounts/manager.py index c612cf930d..5d9bda2ab9 100644 --- a/evennia/accounts/manager.py +++ b/evennia/accounts/manager.py @@ -35,7 +35,6 @@ class AccountDBManager(TypedObjectManager, UserManager): get_account_from_uid get_account_from_name account_search (equivalent to evennia.search_account) - #swap_character """ diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 3cda726881..bd4fb5e188 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -10,9 +10,10 @@ from evennia.objects.models import ObjectDB from evennia.locks.lockhandler import LockException from evennia.commands.cmdhandler import get_and_merge_cmdsets from evennia.utils import create, utils, search -from evennia.utils.utils import inherits_from, class_from_module +from evennia.utils.utils import inherits_from, class_from_module, get_all_typeclasses from evennia.utils.eveditor import EvEditor -from evennia.utils.spawner import spawn +from evennia.utils.evmore import EvMore +from evennia.prototypes import spawner, prototypes as protlib, menus as olc_menus from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -26,12 +27,8 @@ __all__ = ("ObjManipCommand", "CmdSetObjAlias", "CmdCopy", "CmdLock", "CmdExamine", "CmdFind", "CmdTeleport", "CmdScript", "CmdTag", "CmdSpawn") -try: - # used by @set - from ast import literal_eval as _LITERAL_EVAL -except ImportError: - # literal_eval is not available before Python 2.6 - _LITERAL_EVAL = None +# used by @set +from ast import literal_eval as _LITERAL_EVAL # used by @find CHAR_TYPECLASS = settings.BASE_CHARACTER_TYPECLASS @@ -1458,17 +1455,16 @@ def _convert_from_string(cmd, strobj): # if nothing matches, return as-is return obj - if _LITERAL_EVAL: - # Use literal_eval to parse python structure exactly. - try: - return _LITERAL_EVAL(strobj) - except (SyntaxError, ValueError): - # treat as string - strobj = utils.to_str(strobj) - string = "|RNote: name \"|r%s|R\" was converted to a string. " \ - "Make sure this is acceptable." % strobj - cmd.caller.msg(string) - return strobj + # Use literal_eval to parse python structure exactly. + try: + return _LITERAL_EVAL(strobj) + except (SyntaxError, ValueError): + # treat as string + strobj = utils.to_str(strobj) + string = "|RNote: name \"|r%s|R\" was converted to a string. " \ + "Make sure this is acceptable." % strobj + cmd.caller.msg(string) + return strobj else: # fall back to old recursive solution (does not support # nested lists/dicts) @@ -1702,17 +1698,22 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): @typeclass[/switch] [= typeclass.path] @type '' @parent '' + @typeclass/list/show [typeclass.path] @swap - this is a shorthand for using /force/reset flags. @update - this is a shorthand for using the /force/reload flag. Switch: - show - display the current typeclass of object (default) + show, examine - display the current typeclass of object (default) or, if + given a typeclass path, show the docstring of that typeclass. update - *only* re-run at_object_creation on this object meaning locks or other properties set later may remain. reset - clean out *all* the attributes and properties on the object - basically making this a new clean object. force - change to the typeclass also if the object already has a typeclass of the same name. + list - show available typeclasses. + + Example: @type button = examples.red_button.RedButton @@ -1736,6 +1737,7 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): key = "@typeclass" aliases = ["@type", "@parent", "@swap", "@update"] + switch_options = ("show", "examine", "update", "reset", "force", "list") locks = "cmd:perm(typeclass) or perm(Builder)" help_category = "Building" @@ -1744,10 +1746,56 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): caller = self.caller + if 'list' in self.switches: + tclasses = get_all_typeclasses() + contribs = [key for key in sorted(tclasses) + if key.startswith("evennia.contrib")] or [""] + core = [key for key in sorted(tclasses) + if key.startswith("evennia") and key not in contribs] or [""] + game = [key for key in sorted(tclasses) + if not key.startswith("evennia")] or [""] + string = ("|wCore typeclasses|n\n" + " {core}\n" + "|wLoaded Contrib typeclasses|n\n" + " {contrib}\n" + "|wGame-dir typeclasses|n\n" + " {game}").format(core="\n ".join(core), + contrib="\n ".join(contribs), + game="\n ".join(game)) + EvMore(caller, string, exit_on_lastpage=True) + return + if not self.args: caller.msg("Usage: %s [= typeclass]" % self.cmdstring) return + if "show" in self.switches or "examine" in self.switches: + oquery = self.lhs + obj = caller.search(oquery, quiet=True) + if not obj: + # no object found to examine, see if it's a typeclass-path instead + tclasses = get_all_typeclasses() + matches = [(key, tclass) + for key, tclass in tclasses.items() if key.endswith(oquery)] + nmatches = len(matches) + if nmatches > 1: + caller.msg("Multiple typeclasses found matching {}:\n {}".format( + oquery, "\n ".join(tup[0] for tup in matches))) + elif not matches: + caller.msg("No object or typeclass path found to match '{}'".format(oquery)) + else: + # one match found + caller.msg("Docstring for typeclass '{}':\n{}".format( + oquery, matches[0][1].__doc__)) + else: + # do the search again to get the error handling in case of multi-match + obj = caller.search(oquery) + if not obj: + return + caller.msg("{}'s current typeclass is '{}.{}'".format( + obj.name, obj.__class__.__module__, obj.__class__.__name__)) + return + # get object to swap on obj = caller.search(self.lhs) if not obj: @@ -1760,7 +1808,7 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): new_typeclass = self.rhs or obj.path - if "show" in self.switches: + if "show" in self.switches or "examine" in self.switches: string = "%s's current typeclass is %s." % (obj.name, obj.__class__) caller.msg(string) return @@ -2179,12 +2227,15 @@ class CmdExamine(ObjManipCommand): else: things.append(content) if exits: - string += "\n|wExits|n: %s" % ", ".join(["%s(%s)" % (exit.name, exit.dbref) for exit in exits]) + string += "\n|wExits|n: %s" % ", ".join( + ["%s(%s)" % (exit.name, exit.dbref) for exit in exits]) if pobjs: - string += "\n|wCharacters|n: %s" % ", ".join(["|c%s|n(%s)" % (pobj.name, pobj.dbref) for pobj in pobjs]) + string += "\n|wCharacters|n: %s" % ", ".join( + ["|c%s|n(%s)" % (pobj.name, pobj.dbref) for pobj in pobjs]) if things: - string += "\n|wContents|n: %s" % ", ".join(["%s(%s)" % (cont.name, cont.dbref) for cont in obj.contents - if cont not in exits and cont not in pobjs]) + string += "\n|wContents|n: %s" % ", ".join( + ["%s(%s)" % (cont.name, cont.dbref) for cont in obj.contents + if cont not in exits and cont not in pobjs]) separator = "-" * _DEFAULT_WIDTH # output info return '%s\n%s\n%s' % (separator, string.strip(), separator) @@ -2738,101 +2789,312 @@ class CmdTag(COMMAND_DEFAULT_CLASS): string = "No tags attached to %s." % obj self.caller.msg(string) -# -# To use the prototypes with the @spawn function set -# PROTOTYPE_MODULES = ["commands.prototypes"] -# Reload the server and the prototypes should be available. -# - class CmdSpawn(COMMAND_DEFAULT_CLASS): """ spawn objects from prototype Usage: - @spawn - @spawn[/switch] - @spawn[/switch] {prototype dictionary} + @spawn[/noloc] + @spawn[/noloc] - Switch: + @spawn/search [prototype_keykey][;tag[,tag]] + @spawn/list [tag, tag, ...] + @spawn/show [] + @spawn/update + + @spawn/save + @spawn/edit [] + @olc - equivalent to @spawn/edit + + Switches: noloc - allow location to be None if not specified explicitly. Otherwise, location will default to caller's current location. + search - search prototype by name or tags. + list - list available prototypes, optionally limit by tags. + show, examine - inspect prototype by key. If not given, acts like list. + save - save a prototype to the database. It will be listable by /list. + delete - remove a prototype from database, if allowed to. + update - find existing objects with the same prototype_key and update + them with latest version of given prototype. If given with /save, + will auto-update all objects with the old version of the prototype + without asking first. + edit, olc - create/manipulate prototype in a menu interface. Example: @spawn GOBLIN @spawn {"key":"goblin", "typeclass":"monster.Monster", "location":"#2"} + @spawn/save {"key": "grunt", prototype: "goblin"};;mobs;edit:all() Dictionary keys: - |wprototype |n - name of parent prototype to use. Can be a list for - multiple inheritance (inherits left to right) + |wprototype_parent |n - name of parent prototype to use. Required if typeclass is + not set. Can be a path or a list for multiple inheritance (inherits + left to right). If set one of the parents must have a typeclass. + |wtypeclass |n - string. Required if prototype_parent is not set. |wkey |n - string, the main object identifier - |wtypeclass |n - string, if not set, will use settings.BASE_OBJECT_TYPECLASS |wlocation |n - this should be a valid object or #dbref |whome |n - valid object or #dbref |wdestination|n - only valid for exits (object or dbref) |wpermissions|n - string or list of permission strings |wlocks |n - a lock-string - |waliases |n - string or list of strings + |waliases |n - string or list of strings. |wndb_|n - value of a nattribute (ndb_ is stripped) + + |wprototype_key|n - name of this prototype. Unique. Used to store/retrieve from db + and update existing prototyped objects if desired. + |wprototype_desc|n - desc of this prototype. Used in listings + |wprototype_locks|n - locks of this prototype. Limits who may use prototype + |wprototype_tags|n - tags of this prototype. Used to find prototype + any other keywords are interpreted as Attributes and their values. The available prototypes are defined globally in modules set in settings.PROTOTYPE_MODULES. If @spawn is used without arguments it displays a list of available prototypes. + """ key = "@spawn" - switch_options = ("noloc", ) + aliases = ["olc"] + switch_options = ("noloc", "search", "list", "show", "save", "delete", "menu", "olc", "update") locks = "cmd:perm(spawn) or perm(Builder)" help_category = "Building" def func(self): """Implements the spawner""" - def _show_prototypes(prototypes): - """Helper to show a list of available prototypes""" - prots = ", ".join(sorted(prototypes.keys())) - return "\nAvailable prototypes (case sensitive): %s" % ( - "\n" + utils.fill(prots) if prots else "None") + def _parse_prototype(inp, expect=dict): + err = None + try: + prototype = _LITERAL_EVAL(inp) + except (SyntaxError, ValueError) as err: + # treat as string + prototype = utils.to_str(inp) + finally: + if not isinstance(prototype, expect): + if err: + string = ("{}\n|RCritical Python syntax error in argument. Only primitive " + "Python structures are allowed. \nYou also need to use correct " + "Python syntax. Remember especially to put quotes around all " + "strings inside lists and dicts.|n For more advanced uses, embed " + "inline functions in the strings.".format(err)) + else: + string = "Expected {}, got {}.".format(expect, type(prototype)) + self.caller.msg(string) + return None + if expect == dict: + # an actual prototype. We need to make sure it's safe. Don't allow exec + if "exec" in prototype and not self.caller.check_permstring("Developer"): + self.caller.msg("Spawn aborted: You are not allowed to " + "use the 'exec' prototype key.") + return None + try: + protlib.validate_prototype(prototype) + except RuntimeError as err: + self.caller.msg(str(err)) + return + return prototype - prototypes = spawn(return_prototypes=True) - if not self.args: - string = "Usage: @spawn {key:value, key, value, ... }" - self.caller.msg(string + _show_prototypes(prototypes)) - return - try: - # make use of _convert_from_string from the SetAttribute command - prototype = _convert_from_string(self, self.args) - except SyntaxError: - # this means literal_eval tried to parse a faulty string - string = "|RCritical Python syntax error in argument. " - string += "Only primitive Python structures are allowed. " - string += "\nYou also need to use correct Python syntax. " - string += "Remember especially to put quotes around all " - string += "strings inside lists and dicts.|n" - self.caller.msg(string) + def _search_show_prototype(query, prototypes=None): + # prototype detail + if not prototypes: + prototypes = protlib.search_prototype(key=query) + if prototypes: + return "\n".join(protlib.prototype_to_str(prot) for prot in prototypes) + else: + return False + + caller = self.caller + + if self.cmdstring == "olc" or 'menu' in self.switches or 'olc' in self.switches: + # OLC menu mode + prototype = None + if self.lhs: + key = self.lhs + prototype = spawner.search_prototype(key=key, return_meta=True) + if len(prototype) > 1: + caller.msg("More than one match for {}:\n{}".format( + key, "\n".join(proto.get('prototype_key', '') for proto in prototype))) + return + elif prototype: + # one match + prototype = prototype[0] + olc_menus.start_olc(caller, session=self.session, prototype=prototype) return - if isinstance(prototype, basestring): - # A prototype key - keystr = prototype - prototype = prototypes.get(prototype, None) + if 'search' in self.switches: + # query for a key match + if not self.args: + self.switches.append("list") + else: + key, tags = self.args.strip(), None + if ';' in self.args: + key, tags = (part.strip().lower() for part in self.args.split(";", 1)) + tags = [tag.strip() for tag in tags.split(",")] if tags else None + EvMore(caller, unicode(protlib.list_prototypes(caller, key=key, tags=tags)), + exit_on_lastpage=True) + return + + if 'show' in self.switches or 'examine' in self.switches: + # the argument is a key in this case (may be a partial key) + if not self.args: + self.switches.append('list') + else: + matchstring = _search_show_prototype(self.args) + if matchstring: + caller.msg(matchstring) + else: + caller.msg("No prototype '{}' was found.".format(self.args)) + return + + if 'list' in self.switches: + # for list, all optional arguments are tags + # import pudb; pudb.set_trace() + + EvMore(caller, unicode(protlib.list_prototypes(caller, + tags=self.lhslist)), exit_on_lastpage=True) + return + + if 'save' in self.switches: + # store a prototype to the database store + if not self.args: + caller.msg( + "Usage: @spawn/save [;desc[;tag,tag[,...][;lockstring]]] = ") + return + + # handle rhs: + prototype = _parse_prototype(self.lhs.strip()) if not prototype: - string = "No prototype named '%s'." % keystr - self.caller.msg(string + _show_prototypes(prototypes)) return - elif isinstance(prototype, dict): - # we got the prototype on the command line. We must make sure to not allow - # the 'exec' key unless we are developers or higher. - if "exec" in prototype and not self.caller.check_permstring("Developer"): - self.caller.msg("Spawn aborted: You don't have access to use the 'exec' prototype key.") + + # present prototype to save + new_matchstring = _search_show_prototype("", prototypes=[prototype]) + string = "|yCreating new prototype:|n\n{}".format(new_matchstring) + question = "\nDo you want to continue saving? [Y]/N" + + prototype_key = prototype.get("prototype_key") + if not prototype_key: + caller.msg("\n|yTo save a prototype it must have the 'prototype_key' set.") return - else: - self.caller.msg("The prototype must be a prototype key or a Python dictionary.") + + # check for existing prototype, + old_matchstring = _search_show_prototype(prototype_key) + + if old_matchstring: + string += "\n|yExisting saved prototype found:|n\n{}".format(old_matchstring) + question = "\n|yDo you want to replace the existing prototype?|n [Y]/N" + + answer = yield(string + question) + if answer.lower() in ["n", "no"]: + caller.msg("|rSave cancelled.|n") + return + + # all seems ok. Try to save. + try: + prot = protlib.save_prototype(**prototype) + if not prot: + caller.msg("|rError saving:|R {}.|n".format(prototype_key)) + return + except protlib.PermissionError as err: + caller.msg("|rError saving:|R {}|n".format(err)) + return + caller.msg("|gSaved prototype:|n {}".format(prototype_key)) + + # check if we want to update existing objects + existing_objects = protlib.search_objects_with_prototype(prototype_key) + if existing_objects: + if 'update' not in self.switches: + n_existing = len(existing_objects) + slow = " (note that this may be slow)" if n_existing > 10 else "" + string = ("There are {} objects already created with an older version " + "of prototype {}. Should it be re-applied to them{}? [Y]/N".format( + n_existing, prototype_key, slow)) + answer = yield(string) + if answer.lower() in ["n", "no"]: + caller.msg("|rNo update was done of existing objects. " + "Use @spawn/update to apply later as needed.|n") + return + n_updated = spawner.batch_update_objects_with_prototype(existing_objects, key) + caller.msg("{} objects were updated.".format(n_updated)) return + if not self.args: + ncount = len(protlib.search_prototype()) + caller.msg("Usage: @spawn or {{key: value, ...}}" + "\n ({} existing prototypes. Use /list to inspect)".format(ncount)) + return + + if 'delete' in self.switches: + # remove db-based prototype + matchstring = _search_show_prototype(self.args) + if matchstring: + string = "|rDeleting prototype:|n\n{}".format(matchstring) + question = "\nDo you want to continue deleting? [Y]/N" + answer = yield(string + question) + if answer.lower() in ["n", "no"]: + caller.msg("|rDeletion cancelled.|n") + return + try: + success = protlib.delete_db_prototype(caller, self.args) + except protlib.PermissionError as err: + caller.msg("|rError deleting:|R {}|n".format(err)) + caller.msg("Deletion {}.".format( + 'successful' if success else 'failed (does the prototype exist?)')) + return + else: + caller.msg("Could not find prototype '{}'".format(key)) + + if 'update' in self.switches: + # update existing prototypes + key = self.args.strip().lower() + existing_objects = protlib.search_objects_with_prototype(key) + if existing_objects: + n_existing = len(existing_objects) + slow = " (note that this may be slow)" if n_existing > 10 else "" + string = ("There are {} objects already created with an older version " + "of prototype {}. Should it be re-applied to them{}? [Y]/N".format( + n_existing, key, slow)) + answer = yield(string) + if answer.lower() in ["n", "no"]: + caller.msg("|rUpdate cancelled.") + return + n_updated = spawner.batch_update_objects_with_prototype(existing_objects, key) + caller.msg("{} objects were updated.".format(n_updated)) + + # A direct creation of an object from a given prototype + + prototype = _parse_prototype( + self.args, expect=dict if self.args.strip().startswith("{") else basestring) + if not prototype: + # this will only let through dicts or strings + return + + key = '' + if isinstance(prototype, basestring): + # A prototype key we are looking to apply + key = prototype + prototypes = protlib.search_prototype(prototype) + nprots = len(prototypes) + if not prototypes: + caller.msg("No prototype named '%s'." % prototype) + return + elif nprots > 1: + caller.msg("Found {} prototypes matching '{}':\n {}".format( + nprots, prototype, ", ".join(prot.get('prototype_key', '') + for proto in prototypes))) + return + # we have a prototype, check access + prototype = prototypes[0] + if not caller.locks.check_lockstring(caller, prototype.get('prototype_locks', ''), access_type='spawn'): + caller.msg("You don't have access to use this prototype.") + return + if "noloc" not in self.switches and "location" not in prototype: prototype["location"] = self.caller.location - for obj in spawn(prototype): - self.caller.msg("Spawned %s." % obj.get_display_name(self.caller)) + # proceed to spawning + try: + for obj in spawner.spawn(prototype): + self.caller.msg("Spawned %s." % obj.get_display_name(self.caller)) + except RuntimeError as err: + caller.msg(err) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 625af4b5ac..3fb762910d 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -28,6 +28,7 @@ from evennia.utils import ansi, utils, gametime from evennia.server.sessionhandler import SESSIONS from evennia import search_object from evennia import DefaultObject, DefaultCharacter +from evennia.prototypes import prototypes as protlib # set up signal here since we are not starting the server @@ -45,7 +46,7 @@ class CommandTest(EvenniaTest): Tests a command """ def call(self, cmdobj, args, msg=None, cmdset=None, noansi=True, caller=None, - receiver=None, cmdstring=None, obj=None): + receiver=None, cmdstring=None, obj=None, inputs=None): """ Test a command by assigning all the needed properties to cmdobj and running @@ -74,14 +75,31 @@ class CommandTest(EvenniaTest): cmdobj.obj = obj or (caller if caller else self.char1) # test old_msg = receiver.msg + inputs = inputs or [] + try: receiver.msg = Mock() if cmdobj.at_pre_cmd(): return cmdobj.parse() ret = cmdobj.func() + + # handle func's with yield in them (generators) if isinstance(ret, types.GeneratorType): - ret.next() + while True: + try: + inp = inputs.pop() if inputs else None + if inp: + try: + ret.send(inp) + except TypeError: + ret.next() + ret = ret.send(inp) + else: + ret.next() + except StopIteration: + break + cmdobj.at_post_cmd() except StopIteration: pass @@ -362,6 +380,7 @@ class TestBuilding(CommandTest): # check that it exists in the process. query = search_object(objKeyStr) commandTest.assertIsNotNone(query) + commandTest.assertTrue(bool(query)) obj = query[0] commandTest.assertIsNotNone(obj) return obj @@ -370,17 +389,20 @@ class TestBuilding(CommandTest): self.call(building.CmdSpawn(), " ", "Usage: @spawn") # Tests "@spawn " without specifying location. + self.call(building.CmdSpawn(), - "{'key':'goblin', 'typeclass':'evennia.DefaultCharacter'}", "Spawned goblin") - goblin = getObject(self, "goblin") + "/save {'prototype_key': 'testprot', 'key':'Test Char', " + "'typeclass':'evennia.objects.objects.DefaultCharacter'}", + "Saved prototype: testprot", inputs=['y']) - # Tests that the spawned object's type is a DefaultCharacter. - self.assertIsInstance(goblin, DefaultCharacter) + self.call(building.CmdSpawn(), "/list", "Key ") + self.call(building.CmdSpawn(), 'testprot', "Spawned Test Char") # Tests that the spawned object's location is the same as the caharacter's location, since # we did not specify it. - self.assertEqual(goblin.location, self.char1.location) - goblin.delete() + testchar = getObject(self, "Test Char") + self.assertEqual(testchar.location, self.char1.location) + testchar.delete() # Test "@spawn " with a location other than the character's. spawnLoc = self.room2 @@ -390,14 +412,23 @@ class TestBuilding(CommandTest): spawnLoc = self.room1 self.call(building.CmdSpawn(), - "{'prototype':'GOBLIN', 'key':'goblin', 'location':'%s'}" - % spawnLoc.dbref, "Spawned goblin") + "{'prototype_key':'GOBLIN', 'typeclass':'evennia.objects.objects.DefaultCharacter', " + "'key':'goblin', 'location':'%s'}" % spawnLoc.dbref, "Spawned goblin") goblin = getObject(self, "goblin") + # Tests that the spawned object's type is a DefaultCharacter. + self.assertIsInstance(goblin, DefaultCharacter) self.assertEqual(goblin.location, spawnLoc) + goblin.delete() + # create prototype + protlib.create_prototype(**{'key': 'Ball', + 'typeclass': 'evennia.objects.objects.DefaultCharacter', + 'prototype_key': 'testball'}) + # Tests "@spawn " - self.call(building.CmdSpawn(), "'BALL'", "Spawned Ball") + self.call(building.CmdSpawn(), "testball", "Spawned Ball") + ball = getObject(self, "Ball") self.assertEqual(ball.location, self.char1.location) self.assertIsInstance(ball, DefaultObject) @@ -410,10 +441,14 @@ class TestBuilding(CommandTest): self.assertIsNone(ball.location) ball.delete() + self.call(building.CmdSpawn(), + "/noloc {'prototype_parent':'TESTBALL', 'prototype_key': 'testball', 'location':'%s'}" + % spawnLoc.dbref, "Error: Prototype testball tries to parent itself.") + # Tests "@spawn/noloc ...", but DO specify a location. # Location should be the specified location. self.call(building.CmdSpawn(), - "/noloc {'prototype':'BALL', 'location':'%s'}" + "/noloc {'prototype_parent':'TESTBALL', 'key': 'Ball', 'prototype_key': 'foo', 'location':'%s'}" % spawnLoc.dbref, "Spawned Ball") ball = getObject(self, "Ball") self.assertEqual(ball.location, spawnLoc) @@ -422,6 +457,9 @@ class TestBuilding(CommandTest): # test calling spawn with an invalid prototype. self.call(building.CmdSpawn(), "'NO_EXIST'", "No prototype named 'NO_EXIST'") + # Test listing commands + self.call(building.CmdSpawn(), "/list", "Key ") + class TestComms(CommandTest): diff --git a/evennia/contrib/tutorial_world/objects.py b/evennia/contrib/tutorial_world/objects.py index 0561f98d03..f83462ad6b 100644 --- a/evennia/contrib/tutorial_world/objects.py +++ b/evennia/contrib/tutorial_world/objects.py @@ -24,7 +24,7 @@ import random from evennia import DefaultObject, DefaultExit, Command, CmdSet from evennia.utils import search, delay -from evennia.utils.spawner import spawn +from evennia.prototypes.spawner import spawn # ------------------------------------------------------------- # @@ -905,19 +905,19 @@ WEAPON_PROTOTYPES = { "magic": False, "desc": "A generic blade."}, "knife": { - "prototype": "weapon", + "prototype_parent": "weapon", "aliases": "sword", "key": "Kitchen knife", "desc": "A rusty kitchen knife. Better than nothing.", "damage": 3}, "dagger": { - "prototype": "knife", + "prototype_parent": "knife", "key": "Rusty dagger", "aliases": ["knife", "dagger"], "desc": "A double-edged dagger with a nicked edge and a wooden handle.", "hit": 0.25}, "sword": { - "prototype": "weapon", + "prototype_parent": "weapon", "key": "Rusty sword", "aliases": ["sword"], "desc": "A rusty shortsword. It has a leather-wrapped handle covered i food grease.", @@ -925,28 +925,28 @@ WEAPON_PROTOTYPES = { "damage": 5, "parry": 0.5}, "club": { - "prototype": "weapon", + "prototype_parent": "weapon", "key": "Club", "desc": "A heavy wooden club, little more than a heavy branch.", "hit": 0.4, "damage": 6, "parry": 0.2}, "axe": { - "prototype": "weapon", + "prototype_parent": "weapon", "key": "Axe", "desc": "A woodcutter's axe with a keen edge.", "hit": 0.4, "damage": 6, "parry": 0.2}, "ornate longsword": { - "prototype": "sword", + "prototype_parent": "sword", "key": "Ornate longsword", "desc": "A fine longsword with some swirling patterns on the handle.", "hit": 0.5, "magic": True, "damage": 5}, "warhammer": { - "prototype": "club", + "prototype_parent": "club", "key": "Silver Warhammer", "aliases": ["hammer", "warhammer", "war"], "desc": "A heavy war hammer with silver ornaments. This huge weapon causes massive damage - if you can hit.", @@ -954,21 +954,21 @@ WEAPON_PROTOTYPES = { "magic": True, "damage": 8}, "rune axe": { - "prototype": "axe", + "prototype_parent": "axe", "key": "Runeaxe", "aliases": ["axe"], "hit": 0.4, "magic": True, "damage": 6}, "thruning": { - "prototype": "ornate longsword", + "prototype_parent": "ornate longsword", "key": "Broadsword named Thruning", "desc": "This heavy bladed weapon is marked with the name 'Thruning'. It is very powerful in skilled hands.", "hit": 0.6, "parry": 0.6, "damage": 7}, "slayer waraxe": { - "prototype": "rune axe", + "prototype_parent": "rune axe", "key": "Slayer waraxe", "aliases": ["waraxe", "war", "slayer"], "desc": "A huge double-bladed axe marked with the runes for 'Slayer'." @@ -976,7 +976,7 @@ WEAPON_PROTOTYPES = { "hit": 0.7, "damage": 8}, "ghostblade": { - "prototype": "ornate longsword", + "prototype_parent": "ornate longsword", "key": "The Ghostblade", "aliases": ["blade", "ghost"], "desc": "This massive sword is large as you are tall, yet seems to weigh almost nothing." @@ -985,7 +985,7 @@ WEAPON_PROTOTYPES = { "parry": 0.8, "damage": 10}, "hawkblade": { - "prototype": "ghostblade", + "prototype_parent": "ghostblade", "key": "The Hawkblade", "aliases": ["hawk", "blade"], "desc": "The weapon of a long-dead heroine and a more civilized age," diff --git a/evennia/game_template/typeclasses/accounts.py b/evennia/game_template/typeclasses/accounts.py index bbab3d4f22..99d861bf0b 100644 --- a/evennia/game_template/typeclasses/accounts.py +++ b/evennia/game_template/typeclasses/accounts.py @@ -65,7 +65,6 @@ class Account(DefaultAccount): * Helper methods msg(text=None, **kwargs) - swap_character(new_character, delete_old_character=False) execute_cmd(raw_string, session=None) search(ostring, global_search=False, attribute_name=None, use_nicks=False, location=None, ignore_errors=False, account=False) is_typeclass(typeclass, exact=False) diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py index 14556579d7..19bfbec707 100644 --- a/evennia/locks/lockhandler.py +++ b/evennia/locks/lockhandler.py @@ -287,7 +287,7 @@ class LockHandler(object): """ self.lock_bypass = hasattr(obj, "is_superuser") and obj.is_superuser - def add(self, lockstring): + def add(self, lockstring, validate_only=False): """ Add a new lockstring to handler. @@ -296,10 +296,12 @@ class LockHandler(object): `":"`. Multiple access types should be separated by semicolon (`;`). Alternatively, a list with lockstrings. - + validate_only (bool, optional): If True, validate the lockstring but + don't actually store it. Returns: success (bool): The outcome of the addition, `False` on - error. + error. If `validate_only` is True, this will be a tuple + (bool, error), for pass/fail and a string error. """ if isinstance(lockstring, basestring): @@ -308,21 +310,41 @@ class LockHandler(object): lockdefs = [lockdef for locks in lockstring for lockdef in locks.split(";")] lockstring = ";".join(lockdefs) + err = "" # sanity checks for lockdef in lockdefs: if ':' not in lockdef: - self._log_error(_("Lock: '%s' contains no colon (:).") % lockdef) - return False + err = _("Lock: '{lockdef}' contains no colon (:).").format(lockdef=lockdef) + if validate_only: + return False, err + else: + self._log_error(err) + return False access_type, rhs = [part.strip() for part in lockdef.split(':', 1)] if not access_type: - self._log_error(_("Lock: '%s' has no access_type (left-side of colon is empty).") % lockdef) - return False + err = _("Lock: '{lockdef}' has no access_type " + "(left-side of colon is empty).").format(lockdef=lockdef) + if validate_only: + return False, err + else: + self._log_error(err) + return False if rhs.count('(') != rhs.count(')'): - self._log_error(_("Lock: '%s' has mismatched parentheses.") % lockdef) - return False + err = _("Lock: '{lockdef}' has mismatched parentheses.").format(lockdef=lockdef) + if validate_only: + return False, err + else: + self._log_error(err) + return False if not _RE_FUNCS.findall(rhs): - self._log_error(_("Lock: '%s' has no valid lock functions.") % lockdef) - return False + err = _("Lock: '{lockdef}' has no valid lock functions.").format(lockdef=lockdef) + if validate_only: + return False, err + else: + self._log_error(err) + return False + if validate_only: + return True, None # get the lock string storage_lockstring = self.obj.lock_storage if storage_lockstring: @@ -334,6 +356,18 @@ class LockHandler(object): self._save_locks() return True + def validate(self, lockstring): + """ + Validate lockstring syntactically, without saving it. + + Args: + lockstring (str): Lockstring to validate. + Returns: + valid (bool): If validation passed or not. + + """ + return self.add(lockstring, validate_only=True) + def replace(self, lockstring): """ Replaces the lockstring entirely. @@ -421,6 +455,28 @@ class LockHandler(object): self._cache_locks(self.obj.lock_storage) self.cache_lock_bypass(self.obj) + def append(self, access_type, lockstring, op='or'): + """ + Append a lock definition to access_type if it doesn't already exist. + + Args: + access_type (str): Access type. + lockstring (str): A valid lockstring, without the operator to + link it to an eventual existing lockstring. + op (str): An operator 'and', 'or', 'and not', 'or not' used + for appending the lockstring to an existing access-type. + Note: + The most common use of this method is for use in commands where + the user can specify their own lockstrings. This method allows + the system to auto-add things like Admin-override access. + + """ + old_lockstring = self.get(access_type) + if not lockstring.strip().lower() in old_lockstring.lower(): + lockstring = "{old} {op} {new}".format( + old=old_lockstring, op=op, new=lockstring.strip()) + self.add(lockstring) + def check(self, accessing_obj, access_type, default=False, no_superuser_bypass=False): """ Checks a lock of the correct type by passing execution off to @@ -459,9 +515,13 @@ class LockHandler(object): return True except AttributeError: # happens before session is initiated. - if not no_superuser_bypass and ((hasattr(accessing_obj, 'is_superuser') and accessing_obj.is_superuser) or - (hasattr(accessing_obj, 'account') and hasattr(accessing_obj.account, 'is_superuser') and accessing_obj.account.is_superuser) or - (hasattr(accessing_obj, 'get_account') and (not accessing_obj.get_account() or accessing_obj.get_account().is_superuser))): + if not no_superuser_bypass and ( + (hasattr(accessing_obj, 'is_superuser') and accessing_obj.is_superuser) or + (hasattr(accessing_obj, 'account') and + hasattr(accessing_obj.account, 'is_superuser') and + accessing_obj.account.is_superuser) or + (hasattr(accessing_obj, 'get_account') and + (not accessing_obj.get_account() or accessing_obj.get_account().is_superuser))): return True # no superuser or bypass -> normal lock operation @@ -469,7 +529,8 @@ class LockHandler(object): # we have a lock, test it. evalstring, func_tup, raw_string = self.locks[access_type] # execute all lock funcs in the correct order, producing a tuple of True/False results. - true_false = tuple(bool(tup[0](accessing_obj, self.obj, *tup[1], **tup[2])) for tup in func_tup) + true_false = tuple(bool( + tup[0](accessing_obj, self.obj, *tup[1], **tup[2])) for tup in func_tup) # the True/False tuple goes into evalstring, which combines them # with AND/OR/NOT in order to get the final result. return eval(evalstring % true_false) @@ -520,9 +581,13 @@ class LockHandler(object): if accessing_obj.locks.lock_bypass and not no_superuser_bypass: return True except AttributeError: - if no_superuser_bypass and ((hasattr(accessing_obj, 'is_superuser') and accessing_obj.is_superuser) or - (hasattr(accessing_obj, 'account') and hasattr(accessing_obj.account, 'is_superuser') and accessing_obj.account.is_superuser) or - (hasattr(accessing_obj, 'get_account') and (not accessing_obj.get_account() or accessing_obj.get_account().is_superuser))): + if no_superuser_bypass and ( + (hasattr(accessing_obj, 'is_superuser') and accessing_obj.is_superuser) or + (hasattr(accessing_obj, 'account') and + hasattr(accessing_obj.account, 'is_superuser') and + accessing_obj.account.is_superuser) or + (hasattr(accessing_obj, 'get_account') and + (not accessing_obj.get_account() or accessing_obj.get_account().is_superuser))): return True if ":" not in lockstring: lockstring = "%s:%s" % ("_dummy", lockstring) @@ -538,7 +603,8 @@ class LockHandler(object): else: # if no access types was given and multiple locks were # embedded in the lockstring we assume all must be true - return all(self._eval_access_type(accessing_obj, locks, access_type) for access_type in locks) + return all(self._eval_access_type( + accessing_obj, locks, access_type) for access_type in locks) # convenience access function @@ -581,6 +647,35 @@ 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.validate(lockstring) + + +def get_all_lockfuncs(): + """ + Get a dict of available lock funcs. + + Returns: + lockfuncs (dict): Mapping {lockfuncname:func}. + + """ + if not _LOCKFUNCS: + _cache_lockfuncs() + return _LOCKFUNCS + + def _test(): # testing diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 8d8a29c703..d42f20c9ae 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -1003,14 +1003,14 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): cdict["location"].at_object_receive(self, None) self.at_after_move(None) if cdict.get("tags"): - # this should be a list of tags + # this should be a list of tags, tuples (key, category) or (key, category, data) self.tags.batch_add(*cdict["tags"]) if cdict.get("attributes"): - # this should be a dict of attrname:value + # this should be tuples (key, val, ...) self.attributes.batch_add(*cdict["attributes"]) if cdict.get("nattributes"): # this should be a dict of nattrname:value - for key, value in cdict["nattributes"].items(): + for key, value in cdict["nattributes"]: self.nattributes.add(key, value) del self._createdict @@ -1753,6 +1753,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): else: msg_self = '{self} say, "{speech}"' if msg_self is True else msg_self msg_location = msg_location or '{object} says, "{speech}"' + msg_receivers = msg_receivers or message custom_mapping = kwargs.get('mapping', {}) receivers = make_iter(receivers) if receivers else None diff --git a/evennia/prototypes/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..0e4f59ffbc --- /dev/null +++ b/evennia/prototypes/menus.py @@ -0,0 +1,2399 @@ +""" + +OLC Prototype menu nodes + +""" + +import json +import re +from random import choice +from django.db.models import Q +from django.conf import settings +from evennia.objects.models import ObjectDB +from evennia.utils.evmenu import EvMenu, list_node +from evennia.utils import evmore +from evennia.utils.ansi import strip_ansi +from evennia.utils import utils +from evennia.locks.lockhandler import get_all_lockfuncs +from evennia.prototypes import prototypes as protlib +from evennia.prototypes import spawner + +# ------------------------------------------------------------ +# +# OLC Prototype design menu +# +# ------------------------------------------------------------ + +_MENU_CROP_WIDTH = 15 +_MENU_ATTR_LITERAL_EVAL_ERROR = ( + "|rCritical Python syntax error in your value. Only primitive Python structures are allowed.\n" + "You also need to use correct Python syntax. Remember especially to put quotes around all " + "strings inside lists and dicts.|n") + + +# Helper functions + + +def _get_menu_prototype(caller): + """Return currently active menu prototype.""" + prototype = None + if hasattr(caller.ndb._menutree, "olc_prototype"): + prototype = caller.ndb._menutree.olc_prototype + if not prototype: + caller.ndb._menutree.olc_prototype = prototype = {} + caller.ndb._menutree.olc_new = True + return prototype + + +def _get_flat_menu_prototype(caller, refresh=False, validate=False): + """Return prototype where parent values are included""" + flat_prototype = None + if not refresh and hasattr(caller.ndb._menutree, "olc_flat_prototype"): + flat_prototype = caller.ndb._menutree.olc_flat_prototype + if not flat_prototype: + prot = _get_menu_prototype(caller) + caller.ndb._menutree.olc_flat_prototype = \ + flat_prototype = spawner.flatten_prototype(prot, validate=validate) + return flat_prototype + + +def _get_unchanged_inherited(caller, protname): + """Return prototype values inherited from parent(s), which are not replaced in child""" + protototype = _get_menu_prototype(caller) + if protname in prototype: + return protname[protname], False + else: + flattened = _get_flat_menu_prototype(caller) + if protname in flattened: + return protname[protname], True + return None, False + + +def _set_menu_prototype(caller, prototype): + """Set the prototype with existing one""" + caller.ndb._menutree.olc_prototype = prototype + caller.ndb._menutree.olc_new = False + return prototype + + +def _is_new_prototype(caller): + """Check if prototype is marked as new or was loaded from a saved one.""" + return hasattr(caller.ndb._menutree, "olc_new") + + +def _format_option_value(prop, required=False, prototype=None, cropper=None): + """ + Format wizard option values. + + Args: + prop (str): Name or value to format. + required (bool, optional): The option is required. + prototype (dict, optional): If given, `prop` will be considered a key in this prototype. + cropper (callable, optional): A function to crop the value to a certain width. + + Returns: + value (str): The formatted value. + """ + if prototype is not None: + prop = prototype.get(prop, '') + + out = prop + if callable(prop): + if hasattr(prop, '__name__'): + out = "<{}>".format(prop.__name__) + else: + out = repr(prop) + if utils.is_iter(prop): + out = ", ".join(str(pr) for pr in prop) + if not out and required: + out = "|rrequired" + if out: + return " ({}|n)".format(cropper(out) if cropper else utils.crop(out, _MENU_CROP_WIDTH)) + return "" + + +def _set_prototype_value(caller, field, value, parse=True): + """Set prototype's field in a safe way.""" + prototype = _get_menu_prototype(caller) + prototype[field] = value + caller.ndb._menutree.olc_prototype = prototype + return prototype + + +def _set_property(caller, raw_string, **kwargs): + """ + Add or update a property. To be called by the 'goto' option variable. + + Args: + caller (Object, Account): The user of the wizard. + raw_string (str): Input from user on given node - the new value to set. + + Kwargs: + test_parse (bool): If set (default True), parse raw_string for protfuncs and obj-refs and + try to run result through literal_eval. The parser will be run in 'testing' mode and any + parsing errors will shown to the user. Note that this is just for testing, the original + given string will be what is inserted. + prop (str): Property name to edit with `raw_string`. + processor (callable): Converts `raw_string` to a form suitable for saving. + next_node (str): Where to redirect to after this has run. + + Returns: + next_node (str): Next node to go to. + + """ + prop = kwargs.get("prop", "prototype_key") + processor = kwargs.get("processor", None) + next_node = kwargs.get("next_node", None) + + if callable(processor): + try: + value = processor(raw_string) + except Exception as err: + caller.msg("Could not set {prop} to {value} ({err})".format( + prop=prop.replace("_", "-").capitalize(), value=raw_string, err=str(err))) + # this means we'll re-run the current node. + return None + else: + value = raw_string + + if not value: + return next_node + + prototype = _set_prototype_value(caller, prop, value) + caller.ndb._menutree.olc_prototype = prototype + + try: + # TODO simple way to get rid of the u'' markers in list reprs, remove this when on py3. + repr_value = json.dumps(value) + except Exception: + repr_value = value + + out = [" Set {prop} to {value} ({typ}).".format(prop=prop, value=repr_value, typ=type(value))] + + if kwargs.get("test_parse", True): + out.append(" Simulating prototype-func parsing ...") + err, parsed_value = protlib.protfunc_parser(value, testing=True) + if err: + out.append(" |yPython `literal_eval` warning: {}|n".format(err)) + if parsed_value != value: + out.append(" |g(Example-)value when parsed ({}):|n {}".format( + type(parsed_value), parsed_value)) + else: + out.append(" |gNo change when parsed.") + + caller.msg("\n".join(out)) + + return next_node + + +def _wizard_options(curr_node, prev_node, next_node, color="|W", search=False): + """Creates default navigation options available in the wizard.""" + options = [] + if prev_node: + options.append({"key": ("|wB|Wack", "b"), + "desc": "{color}({node})|n".format( + color=color, node=prev_node.replace("_", "-")), + "goto": "node_{}".format(prev_node)}) + if next_node: + options.append({"key": ("|wF|Worward", "f"), + "desc": "{color}({node})|n".format( + color=color, node=next_node.replace("_", "-")), + "goto": "node_{}".format(next_node)}) + + 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", "validate", "v"), + "goto": ("node_validate_prototype", {"back": curr_node})}) + if search: + options.append({"key": ("|wSE|Warch objects", "search object", "search", "se"), + "goto": ("node_search_object", {"back": curr_node})}) + + return options + + +def _set_actioninfo(caller, string): + caller.ndb._menutree.actioninfo = string + + +def _path_cropper(pythonpath): + "Crop path to only the last component" + return pythonpath.split('.')[-1] + + +def _validate_prototype(prototype): + """Run validation on prototype""" + + txt = protlib.prototype_to_str(prototype) + errors = "\n\n|g No validation errors found.|n (but errors could still happen at spawn-time)" + err = False + try: + # validate, don't spawn + spawner.spawn(prototype, only_validate=True) + except RuntimeError as err: + errors = "\n\n|r{}|n".format(err) + err = True + except RuntimeWarning as err: + errors = "\n\n|y{}|n".format(err) + err = True + + text = (txt + errors) + return err, text + + +def _format_protfuncs(): + out = [] + sorted_funcs = [(key, func) for key, func in + sorted(protlib.PROT_FUNCS.items(), key=lambda tup: tup[0])] + for protfunc_name, protfunc in sorted_funcs: + out.append("- |c${name}|n - |W{docs}".format( + name=protfunc_name, + docs=utils.justify(protfunc.__doc__.strip(), align='l', indent=10).strip())) + return "\n ".join(out) + + +def _format_lockfuncs(): + out = [] + sorted_funcs = [(key, func) for key, func in + sorted(get_all_lockfuncs().items(), key=lambda tup: tup[0])] + for lockfunc_name, lockfunc in sorted_funcs: + doc = (lockfunc.__doc__ or "").strip() + out.append("- |c${name}|n - |W{docs}".format( + name=lockfunc_name, + docs=utils.justify(doc, align='l', indent=10).strip())) + return "\n".join(out) + + +def _format_list_actions(*args, **kwargs): + """Create footer text for nodes with extra list actions + + Args: + actions (str): Available actions. The first letter of the action name will be assumed + to be a shortcut. + Kwargs: + prefix (str): Default prefix to use. + Returns: + string (str): Formatted footer for adding to the node text. + + """ + actions = [] + prefix = kwargs.get('prefix', "|WSelect with |w|W. Other actions:|n ") + for action in args: + actions.append("|w{}|n|W{} |w|n".format(action[0], action[1:])) + return prefix + " |W|||n ".join(actions) + + +def _get_current_value(caller, keyname, comparer=None, formatter=str, only_inherit=False): + """ + Return current value, marking if value comes from parent or set in this prototype. + + Args: + keyname (str): Name of prototoype key to get current value of. + comparer (callable, optional): This will be called as comparer(prototype_value, + flattened_value) and is expected to return the value to show as the current + or inherited one. If not given, a straight comparison is used and what is returned + depends on the only_inherit setting. + formatter (callable, optional)): This will be called with the result of comparer. + only_inherit (bool, optional): If a current value should only be shown if all + the values are inherited from the prototype parent (otherwise, show an empty string). + Returns: + current (str): The current value. + + """ + def _default_comparer(protval, flatval): + if only_inherit: + return "" if protval else flatval + else: + return protval if protval else flatval + + if not callable(comparer): + comparer = _default_comparer + + prot = _get_menu_prototype(caller) + flat_prot = _get_flat_menu_prototype(caller) + + out = "" + if keyname in prot: + if keyname in flat_prot: + out = formatter(comparer(prot[keyname], flat_prot[keyname])) + if only_inherit: + if out: + return "|WCurrent|n {} |W(|binherited|W):|n {}".format(keyname, out) + return "" + else: + if out: + return "|WCurrent|n {}|W:|n {}".format(keyname, out) + return "|W[No {} set]|n".format(keyname) + elif only_inherit: + return "" + else: + out = formatter(prot[keyname]) + return "|WCurrent|n {}|W:|n {}".format(keyname, out) + elif keyname in flat_prot: + out = formatter(flat_prot[keyname]) + if out: + return "|WCurrent|n {} |W(|n|binherited|W):|n {}".format(keyname, out) + else: + return "" + elif only_inherit: + return "" + else: + return "|W[No {} set]|n".format(keyname) + + +def _default_parse(raw_inp, choices, *args): + """ + Helper to parse default input to a node decorated with the node_list decorator on + the form l1, l 2, look 1, etc. Spaces are ignored, as is case. + + Args: + raw_inp (str): Input from the user. + choices (list): List of available options on the node listing (list of strings). + args (tuples): The available actions, each specifed as a tuple (name, alias, ...) + Returns: + choice (str): A choice among the choices, or None if no match was found. + action (str): The action operating on the choice, or None. + + """ + raw_inp = raw_inp.lower().strip() + mapping = {t.lower(): tup[0] for tup in args for t in tup} + match = re.match(r"(%s)\s*?(\d+)$" % "|".join(mapping.keys()), raw_inp) + if match: + action = mapping.get(match.group(1), None) + num = int(match.group(2)) - 1 + num = num if 0 <= num < len(choices) else None + if action is not None and num is not None: + return choices[num], action + return None, None + + +# Menu nodes ------------------------------ + +# helper nodes + +# validate prototype (available as option from all nodes) + +def node_validate_prototype(caller, raw_string, **kwargs): + """General node to view and validate a protototype""" + prototype = _get_flat_menu_prototype(caller, validate=False) + prev_node = kwargs.get("back", "index") + + _, text = _validate_prototype(prototype) + + helptext = """ + The validator checks if the prototype's various values are on the expected form. It also tests + any $protfuncs. + + """ + + text = (text, helptext) + + options = _wizard_options(None, prev_node, None) + options.append({"key": "_default", + "goto": "node_" + prev_node}) + + return text, options + + +# node examine_entity + +def node_examine_entity(caller, raw_string, **kwargs): + """ + General node to view a text and then return to previous node. Kwargs should contain "text" for + the text to show and 'back" pointing to the node to return to. + """ + text = kwargs.get("text", "Nothing was found here.") + helptext = "Use |wback|n to return to the previous node." + prev_node = kwargs.get('back', 'index') + + text = (text, helptext) + + options = _wizard_options(None, prev_node, None) + options.append({"key": "_default", + "goto": "node_" + prev_node}) + + return text, options + + +# node object_search + +def _search_object(caller): + "update search term based on query stored on menu; store match too" + try: + searchstring = caller.ndb._menutree.olc_search_object_term.strip() + caller.ndb._menutree.olc_search_object_matches = [] + except AttributeError: + return [] + + if not searchstring: + caller.msg("Must specify a search criterion.") + return [] + + is_dbref = utils.dbref(searchstring) + is_account = searchstring.startswith("*") + + if is_dbref or is_account: + + if is_dbref: + # a dbref search + results = caller.search(searchstring, global_search=True, quiet=True) + else: + # an account search + searchstring = searchstring.lstrip("*") + results = caller.search_account(searchstring, quiet=True) + else: + keyquery = Q(db_key__istartswith=searchstring) + aliasquery = Q(db_tags__db_key__istartswith=searchstring, + db_tags__db_tagtype__iexact="alias") + results = ObjectDB.objects.filter(keyquery | aliasquery).distinct() + + caller.msg("Searching for '{}' ...".format(searchstring)) + caller.ndb._menutree.olc_search_object_matches = results + return ["{}(#{})".format(obj.key, obj.id) for obj in results] + + +def _object_search_select(caller, obj_entry, **kwargs): + choices = kwargs['available_choices'] + num = choices.index(obj_entry) + matches = caller.ndb._menutree.olc_search_object_matches + obj = matches[num] + + if not obj.access(caller, 'examine'): + caller.msg("|rYou don't have 'examine' access on this object.|n") + del caller.ndb._menutree.olc_search_object_term + return "node_search_object" + + prot = spawner.prototype_from_object(obj) + txt = protlib.prototype_to_str(prot) + return "node_examine_entity", {"text": txt, "back": "search_object"} + + +def _object_search_actions(caller, raw_inp, **kwargs): + "All this does is to queue a search query" + choices = kwargs['available_choices'] + obj_entry, action = _default_parse( + raw_inp, choices, ("examine", "e"), ("create prototype from object", "create", "c")) + + raw_inp = raw_inp.strip() + + if obj_entry: + + num = choices.index(obj_entry) + matches = caller.ndb._menutree.olc_search_object_matches + obj = matches[num] + prot = spawner.prototype_from_object(obj) + + if action == "examine": + + if not obj.access(caller, 'examine'): + caller.msg("\n|rYou don't have 'examine' access on this object.|n") + del caller.ndb._menutree.olc_search_object_term + return "node_search_object" + + txt = protlib.prototype_to_str(prot) + return "node_examine_entity", {"text": txt, "back": "search_object"} + else: + # load prototype + + if not obj.access(caller, 'control'): + caller.msg("|rYou don't have access to do this with this object.|n") + del caller.ndb._menutree.olc_search_object_term + return "node_search_object" + + _set_menu_prototype(caller, prot) + caller.msg("Created prototype from object.") + return "node_index" + elif raw_inp: + caller.ndb._menutree.olc_search_object_term = raw_inp + return "node_search_object", kwargs + else: + # empty input - exit back to previous node + prev_node = "node_" + kwargs.get("back", "index") + return prev_node + + +@list_node(_search_object, _object_search_select) +def node_search_object(caller, raw_inp, **kwargs): + """ + Node for searching for an existing object. + """ + try: + matches = caller.ndb._menutree.olc_search_object_matches + except AttributeError: + matches = [] + nmatches = len(matches) + prev_node = kwargs.get("back", "index") + + if matches: + text = """ + Found {num} match{post}. + + (|RWarning: creating a prototype will |roverwrite|r |Rthe current prototype!)|n""".format( + num=nmatches, post="es" if nmatches > 1 else "") + _set_actioninfo(caller, _format_list_actions( + "examine", "create prototype from object", prefix="Actions: ")) + else: + text = "Enter search criterion." + + helptext = """ + You can search objects by specifying partial key, alias or its exact #dbref. Use *query to + search for an Account instead. + + Once having found any matches you can choose to examine it or use |ccreate prototype from + object|n. If doing the latter, a prototype will be calculated from the selected object and + loaded as the new 'current' prototype. This is useful for having a base to build from but be + careful you are not throwing away any existing, unsaved, prototype work! + """ + + text = (text, helptext) + + options = _wizard_options(None, prev_node, None) + options.append({"key": "_default", + "goto": (_object_search_actions, {"back": prev_node})}) + + return text, options + +# main index (start page) node + + +def node_index(caller): + prototype = _get_menu_prototype(caller) + + text = """ + |c --- Prototype wizard --- |n + + A |cprototype|n is a 'template' for |wspawning|n an in-game entity. A field of the prototype + can either be hard-coded, left empty or scripted using |w$protfuncs|n - for example to + randomize the value every time a new entity is spawned. The fields whose names start with + 'Prototype-' are not fields on the object itself but are used for prototype-inheritance, or + when saving and loading. + + Select prototype field to edit. If you are unsure, start from [|w1|n]. Enter [|wh|n]elp at + any menu node for more info. + + """ + helptxt = """ + |c- prototypes |n + + A prototype is really just a Python dictionary. When spawning, this dictionary is essentially + passed into `|wevennia.utils.create.create_object(**prototype)|n` to create a new object. By + using different prototypes you can customize instances of objects without having to do code + changes to their typeclass (something which requires code access). The classical example is + to spawn goblins with different names, looks, equipment and skill, each based on the same + `Goblin` typeclass. + + At any time you can [|wV|n]alidate that the prototype works correctly and use it to + [|wSP|n]awn a new entity. You can also [|wSA|n]ve|n your work, [|wLO|n]oad an existing + prototype to [|wSE|n]arch for existing objects to use as a base. Use [|wL|n]ook to re-show a + menu node. [|wQ|n]uit will always exit the menu and [|wH|n]elp will show context-sensitive + help. + + + |c- $protfuncs |n + + Prototype-functions (protfuncs) allow for limited scripting within a prototype. These are + entered as a string $funcname(arg, arg, ...) and are evaluated |wat the time of spawning|n + only. They can also be nested for combined effects. + + {pfuncs} + """.format(pfuncs=_format_protfuncs()) + + text = (text, helptxt) + + options = [] + options.append( + {"desc": "|WPrototype-Key|n|n{}".format( + _format_option_value("Key", "prototype_key" not in prototype, prototype, None)), + "goto": "node_prototype_key"}) + for key in ('Prototype_Parent', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', + 'Permissions', 'Location', 'Home', 'Destination'): + required = False + cropper = None + if key in ("Prototype_Parent", "Typeclass"): + required = ("prototype_parent" not in prototype) and ("typeclass" not in prototype) + if key == 'Typeclass': + cropper = _path_cropper + options.append( + {"desc": "|w{}|n{}".format( + key.replace("_", "-"), + _format_option_value(key, required, prototype, cropper=cropper)), + "goto": "node_{}".format(key.lower())}) + required = False + for key in ('Desc', 'Tags', 'Locks'): + options.append( + {"desc": "|WPrototype-{}|n|n{}".format( + key, _format_option_value(key, required, prototype, None)), + "goto": "node_prototype_{}".format(key.lower())}) + + options.extend(( + {"key": ("|wV|Walidate prototype", "validate", "v"), + "goto": "node_validate_prototype"}, + {"key": ("|wSA|Wve prototype", "save", "sa"), + "goto": "node_prototype_save"}, + {"key": ("|wSP|Wawn prototype", "spawn", "sp"), + "goto": "node_prototype_spawn"}, + {"key": ("|wLO|Wad prototype", "load", "lo"), + "goto": "node_prototype_load"}, + {"key": ("|wSE|Warch objects|n", "search", "se"), + "goto": "node_search_object"})) + + return text, options + + +# prototype_key node + + +def _check_prototype_key(caller, key): + old_prototype = protlib.search_prototype(key) + olc_new = _is_new_prototype(caller) + key = key.strip().lower() + if old_prototype: + old_prototype = old_prototype[0] + # we are starting a new prototype that matches an existing + if not caller.locks.check_lockstring( + caller, old_prototype['prototype_locks'], access_type='edit'): + # return to the node_prototype_key to try another key + caller.msg("Prototype '{key}' already exists and you don't " + "have permission to edit it.".format(key=key)) + return "node_prototype_key" + elif olc_new: + # we are selecting an existing prototype to edit. Reset to index. + del caller.ndb._menutree.olc_new + caller.ndb._menutree.olc_prototype = old_prototype + caller.msg("Prototype already exists. Reloading.") + return "node_index" + + return _set_property(caller, key, prop='prototype_key') + + +def node_prototype_key(caller): + + text = """ + The |cPrototype-Key|n uniquely identifies the prototype and is |wmandatory|n. It is used to + find and use the prototype to spawn new entities. It is not case sensitive. + + {current}""".format(current=_get_current_value(caller, "prototype_key")) + + helptext = """ + The prototype-key is not itself used when spawnng the new object, but is only used for + managing, storing and loading the prototype. It must be globally unique, so existing keys + will be checked before a new key is accepted. If an existing key is picked, the existing + prototype will be loaded. + """ + + options = _wizard_options("prototype_key", "index", "prototype_parent") + options.append({"key": "_default", + "goto": _check_prototype_key}) + + text = (text, helptext) + return text, options + + +# prototype_parents node + + +def _all_prototype_parents(caller): + """Return prototype_key of all available prototypes for listing in menu""" + return [prototype["prototype_key"] + for prototype in protlib.search_prototype() if "prototype_key" in prototype] + + +def _prototype_parent_actions(caller, raw_inp, **kwargs): + """Parse the default Convert prototype to a string representation for closer inspection""" + choices = kwargs.get("available_choices", []) + prototype_parent, action = _default_parse( + raw_inp, choices, ("examine", "e", "l"), ("add", "a"), ("remove", "r", 'delete', 'd')) + + if prototype_parent: + # a selection of parent was made + prototype_parent = protlib.search_prototype(key=prototype_parent)[0] + prototype_parent_key = prototype_parent['prototype_key'] + + # which action to apply on the selection + if action == 'examine': + # examine the prototype + txt = protlib.prototype_to_str(prototype_parent) + kwargs['text'] = txt + kwargs['back'] = 'prototype_parent' + return "node_examine_entity", kwargs + elif action == 'add': + # add/append parent + prot = _get_menu_prototype(caller) + current_prot_parent = prot.get('prototype_parent', None) + if current_prot_parent: + current_prot_parent = utils.make_iter(current_prot_parent) + if prototype_parent_key in current_prot_parent: + caller.msg("Prototype_parent {} is already used.".format(prototype_parent_key)) + return "node_prototype_parent" + else: + current_prot_parent.append(prototype_parent_key) + caller.msg("Add prototype parent for multi-inheritance.") + else: + current_prot_parent = prototype_parent_key + try: + if prototype_parent: + spawner.flatten_prototype(prototype_parent, validate=True) + else: + raise RuntimeError("Not found.") + except RuntimeError as err: + caller.msg("Selected prototype-parent {} " + "caused Error(s):\n|r{}|n".format(prototype_parent, err)) + return "node_prototype_parent" + _set_prototype_value(caller, "prototype_parent", current_prot_parent) + _get_flat_menu_prototype(caller, refresh=True) + elif action == "remove": + # remove prototype parent + prot = _get_menu_prototype(caller) + current_prot_parent = prot.get('prototype_parent', None) + if current_prot_parent: + current_prot_parent = utils.make_iter(current_prot_parent) + try: + current_prot_parent.remove(prototype_parent_key) + _set_prototype_value(caller, 'prototype_parent', current_prot_parent) + _get_flat_menu_prototype(caller, refresh=True) + caller.msg("Removed prototype parent {}.".format(prototype_parent_key)) + except ValueError: + caller.msg("|rPrototype-parent {} could not be removed.".format( + prototype_parent_key)) + return 'node_prototype_parent' + + +def _prototype_parent_select(caller, new_parent): + + ret = None + prototype_parent = protlib.search_prototype(new_parent) + try: + if prototype_parent: + spawner.flatten_prototype(prototype_parent[0], validate=True) + else: + raise RuntimeError("Not found.") + except RuntimeError as err: + caller.msg("Selected prototype-parent {} " + "caused Error(s):\n|r{}|n".format(new_parent, err)) + else: + ret = _set_property(caller, new_parent, + prop="prototype_parent", + processor=str, next_node="node_prototype_parent") + _get_flat_menu_prototype(caller, refresh=True) + caller.msg("Selected prototype parent |c{}|n.".format(new_parent)) + return ret + + +@list_node(_all_prototype_parents, _prototype_parent_select) +def node_prototype_parent(caller): + prototype = _get_menu_prototype(caller) + + prot_parent_keys = prototype.get('prototype_parent') + + text = """ + The |cPrototype Parent|n allows you to |winherit|n prototype values from another named + prototype (given as that prototype's |wprototype_key|n). If not changing these values in + the current prototype, the parent's value will be used. Pick the available prototypes below. + + Note that somewhere in the prototype's parentage, a |ctypeclass|n must be specified. If no + parent is given, this prototype must define the typeclass (next menu node). + + {current} + """ + helptext = """ + Prototypes can inherit from one another. Changes in the child replace any values set in a + parent. The |wtypeclass|n key must exist |wsomewhere|n in the parent chain for the + prototype to be valid. + """ + + _set_actioninfo(caller, _format_list_actions("examine", "add", "remove")) + + ptexts = [] + if prot_parent_keys: + for pkey in utils.make_iter(prot_parent_keys): + prot_parent = protlib.search_prototype(pkey) + if prot_parent: + prot_parent = prot_parent[0] + ptexts.append("|c -- {pkey} -- |n\n{prot}".format( + pkey=pkey, + prot=protlib.prototype_to_str(prot_parent))) + else: + ptexts.append("Prototype parent |r{pkey} was not found.".format(pkey=pkey)) + + if not ptexts: + ptexts.append("[No prototype_parent set]") + + text = text.format(current="\n\n".join(ptexts)) + + text = (text, helptext) + + options = _wizard_options("prototype_parent", "prototype_key", "typeclass", color="|W") + options.append({"key": "_default", + "goto": _prototype_parent_actions}) + + return text, options + + +# typeclasses node + +def _all_typeclasses(caller): + """Get name of available typeclasses.""" + return list(name for name in + sorted(utils.get_all_typeclasses("evennia.objects.models.ObjectDB").keys()) + if name != "evennia.objects.models.ObjectDB") + + +def _typeclass_actions(caller, raw_inp, **kwargs): + """Parse actions for typeclass listing""" + + choices = kwargs.get("available_choices", []) + typeclass_path, action = _default_parse( + raw_inp, choices, ("examine", "e", "l"), ("remove", "r", "delete", "d")) + + if typeclass_path: + if action == 'examine': + typeclass = utils.get_all_typeclasses().get(typeclass_path) + if typeclass: + docstr = [] + for line in typeclass.__doc__.split("\n"): + if line.strip(): + docstr.append(line) + elif docstr: + break + docstr = '\n'.join(docstr) if docstr else "" + txt = "Typeclass |c{typeclass_path}|n; " \ + "First paragraph of docstring:\n\n{docstring}".format( + typeclass_path=typeclass_path, docstring=docstr) + else: + txt = "This is typeclass |y{}|n.".format(typeclass) + return "node_examine_entity", {"text": txt, "back": "typeclass"} + elif action == 'remove': + prototype = _get_menu_prototype(caller) + old_typeclass = prototype.pop('typeclass', None) + if old_typeclass: + _set_menu_prototype(caller, prototype) + caller.msg("Cleared typeclass {}.".format(old_typeclass)) + else: + caller.msg("No typeclass to remove.") + return "node_typeclass" + + +def _typeclass_select(caller, typeclass): + """Select typeclass from list and add it to prototype. Return next node to go to.""" + ret = _set_property(caller, typeclass, prop='typeclass', processor=str) + caller.msg("Selected typeclass |c{}|n.".format(typeclass)) + return ret + + +@list_node(_all_typeclasses, _typeclass_select) +def node_typeclass(caller): + text = """ + The |cTypeclass|n defines what 'type' of object this is - the actual working code to use. + + All spawned objects must have a typeclass. If not given here, the typeclass must be set in + one of the prototype's |cparents|n. + + {current} + """.format(current=_get_current_value(caller, "typeclass"), + actions="|WSelect with |w|W. Other actions: " + "|we|Wxamine |w|W, |wr|Wemove selection") + + helptext = """ + A |nTypeclass|n is specified by the actual python-path to the class definition in the + Evennia code structure. + + Which |cAttributes|n, |cLocks|n and other properties have special + effects or expects certain values depend greatly on the code in play. + """ + + text = (text, helptext) + + options = _wizard_options("typeclass", "prototype_parent", "key", color="|W") + options.append({"key": "_default", + "goto": _typeclass_actions}) + return text, options + + +# key node + + +def node_key(caller): + text = """ + The |cKey|n is the given name of the object to spawn. This will retain the given case. + + {current} + """.format(current=_get_current_value(caller, "key")) + + helptext = """ + The key should often not be identical for every spawned object. Using a randomising + $protfunc can be used, for example |c$choice(Alan, Tom, John)|n will give one of the three + names every time an object of this prototype is spawned. + + |c$protfuncs|n + {pfuncs} + """.format(pfuncs=_format_protfuncs()) + + text = (text, helptext) + + options = _wizard_options("key", "typeclass", "aliases") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="key", + processor=lambda s: s.strip()))}) + return text, options + + +# aliases node + + +def _all_aliases(caller): + "Get aliases in prototype" + prototype = _get_menu_prototype(caller) + return prototype.get("aliases", []) + + +def _aliases_select(caller, alias): + "Add numbers as aliases" + aliases = _all_aliases(caller) + try: + ind = str(aliases.index(alias) + 1) + if ind not in aliases: + aliases.append(ind) + _set_prototype_value(caller, "aliases", aliases) + caller.msg("Added alias '{}'.".format(ind)) + except (IndexError, ValueError) as err: + caller.msg("Error: {}".format(err)) + + return "node_aliases" + + +def _aliases_actions(caller, raw_inp, **kwargs): + """Parse actions for aliases listing""" + choices = kwargs.get("available_choices", []) + alias, action = _default_parse( + raw_inp, choices, ("remove", "r", "delete", "d")) + + aliases = _all_aliases(caller) + if alias and action == 'remove': + try: + aliases.remove(alias) + _set_prototype_value(caller, "aliases", aliases) + caller.msg("Removed alias '{}'.".format(alias)) + except ValueError: + caller.msg("No matching alias found to remove.") + else: + # if not a valid remove, add as a new alias + alias = raw_inp.lower().strip() + if alias and alias not in aliases: + aliases.append(alias) + _set_prototype_value(caller, "aliases", aliases) + caller.msg("Added alias '{}'.".format(alias)) + else: + caller.msg("Alias '{}' was already set.".format(alias)) + return "node_aliases" + + +@list_node(_all_aliases, _aliases_select) +def node_aliases(caller): + + text = """ + |cAliases|n are alternative ways to address an object, next to its |cKey|n. Aliases are not + case sensitive. + + {current} + """.format(current=_get_current_value( + caller, 'aliases', + comparer=lambda propval, flatval: [al for al in flatval if al not in propval], + formatter=lambda lst: "\n" + ", ".join(lst), only_inherit=True)) + _set_actioninfo(caller, + _format_list_actions( + "remove", + prefix="|w|W to add new alias. Other action: ")) + + helptext = """ + Aliases are fixed alternative identifiers and are stored with the new object. + + |c$protfuncs|n + + {pfuncs} + """.format(pfuncs=_format_protfuncs()) + + text = (text, helptext) + + options = _wizard_options("aliases", "key", "attrs") + options.append({"key": "_default", + "goto": _aliases_actions}) + return text, options + + +# attributes node + + +def _caller_attrs(caller): + prototype = _get_menu_prototype(caller) + attrs = ["{}={}".format(tup[0], utils.crop(utils.to_str(tup[1], force_string=True), width=10)) + for tup in prototype.get("attrs", [])] + return attrs + + +def _get_tup_by_attrname(caller, attrname): + prototype = _get_menu_prototype(caller) + attrs = prototype.get("attrs", []) + try: + inp = [tup[0] for tup in attrs].index(attrname) + return attrs[inp] + except ValueError: + return None + + +def _display_attribute(attr_tuple): + """Pretty-print attribute tuple""" + attrkey, value, category, locks = attr_tuple + value = protlib.protfunc_parser(value) + typ = type(value) + out = ("{attrkey} |c=|n {value} |W({typ}{category}{locks})|n".format( + attrkey=attrkey, + value=value, + typ=typ, + category=", category={}".format(category) if category else '', + locks=", locks={}".format(";".join(locks)) if any(locks) else '')) + + return out + + +def _add_attr(caller, attr_string, **kwargs): + """ + Add new attribute, parsing input. + + Args: + caller (Object): Caller of menu. + attr_string (str): Input from user + attr is entered on these forms + attr = value + attr;category = value + attr;category;lockstring = value + Kwargs: + delete (str): If this is set, attr_string is + considered the name of the attribute to delete and + no further parsing happens. + Returns: + result (str): Result string of action. + """ + attrname = '' + value = '' + category = None + locks = '' + + if 'delete' in kwargs: + attrname = attr_string.lower().strip() + elif '=' in attr_string: + attrname, value = (part.strip() for part in attr_string.split('=', 1)) + attrname = attrname.lower() + nameparts = attrname.split(";", 2) + nparts = len(nameparts) + if nparts == 2: + attrname, category = nameparts + elif nparts > 2: + attrname, category, locks = nameparts + attr_tuple = (attrname, value, category, locks) + + if attrname: + prot = _get_menu_prototype(caller) + attrs = prot.get('attrs', []) + + if 'delete' in kwargs: + try: + ind = [tup[0] for tup in attrs].index(attrname) + del attrs[ind] + _set_prototype_value(caller, "attrs", attrs) + return "Removed Attribute '{}'".format(attrname) + except IndexError: + return "Attribute to delete not found." + + try: + # replace existing attribute with the same name in the prototype + ind = [tup[0] for tup in attrs].index(attrname) + attrs[ind] = attr_tuple + text = "Edited Attribute '{}'.".format(attrname) + except ValueError: + attrs.append(attr_tuple) + text = "Added Attribute " + _display_attribute(attr_tuple) + + _set_prototype_value(caller, "attrs", attrs) + else: + text = "Attribute must be given as 'attrname[;category;locks] = '." + + return text + + +def _attr_select(caller, attrstr): + attrname, _ = attrstr.split("=", 1) + attrname = attrname.strip() + + attr_tup = _get_tup_by_attrname(caller, attrname) + if attr_tup: + return "node_examine_entity", \ + {"text": _display_attribute(attr_tup), "back": "attrs"} + else: + caller.msg("Attribute not found.") + return "node_attrs" + + +def _attrs_actions(caller, raw_inp, **kwargs): + """Parse actions for attribute listing""" + choices = kwargs.get("available_choices", []) + attrstr, action = _default_parse( + raw_inp, choices, ('examine', 'e'), ('remove', 'r', 'delete', 'd')) + if attrstr is None: + attrstr = raw_inp + try: + attrname, _ = attrstr.split("=", 1) + except ValueError: + caller.msg("|rNeed to enter the attribute on the form attrname=value.|n") + return "node_attrs" + + attrname = attrname.strip() + attr_tup = _get_tup_by_attrname(caller, attrname) + + if action and attr_tup: + if action == 'examine': + return "node_examine_entity", \ + {"text": _display_attribute(attr_tup), "back": "attrs"} + elif action == 'remove': + res = _add_attr(caller, attrname, delete=True) + caller.msg(res) + else: + res = _add_attr(caller, raw_inp) + caller.msg(res) + return "node_attrs" + + +@list_node(_caller_attrs, _attr_select) +def node_attrs(caller): + + def _currentcmp(propval, flatval): + "match by key + category" + cmp1 = [(tup[0].lower(), tup[2].lower() if tup[2] else None) for tup in propval] + return [tup for tup in flatval if (tup[0].lower(), tup[2].lower() + if tup[2] else None) not in cmp1] + + text = """ + |cAttributes|n are custom properties of the object. Enter attributes on one of these forms: + + attrname=value + attrname;category=value + attrname;category;lockstring=value + + To give an attribute without a category but with a lockstring, leave that spot empty + (attrname;;lockstring=value). Attribute values can have embedded $protfuncs. + + {current} + """.format( + current=_get_current_value( + caller, "attrs", + comparer=_currentcmp, + formatter=lambda lst: "\n" + "\n".join(_display_attribute(tup) for tup in lst), + only_inherit=True)) + _set_actioninfo(caller, _format_list_actions("examine", "remove", prefix="Actions: ")) + + helptext = """ + Most commonly, Attributes don't need any categories or locks. If using locks, the lock-types + 'attredit' and 'attrread' are used to limit editing and viewing of the Attribute. Putting + the lock-type `attrcreate` in the |clocks|n prototype key can be used to restrict builders + from adding new Attributes. + + |c$protfuncs + + {pfuncs} + """.format(pfuncs=_format_protfuncs()) + + text = (text, helptext) + + options = _wizard_options("attrs", "aliases", "tags") + options.append({"key": "_default", + "goto": _attrs_actions}) + return text, options + + +# tags node + + +def _caller_tags(caller): + prototype = _get_menu_prototype(caller) + tags = [tup[0] for tup in prototype.get("tags", [])] + return tags + + +def _get_tup_by_tagname(caller, tagname): + prototype = _get_menu_prototype(caller) + tags = prototype.get("tags", []) + try: + inp = [tup[0] for tup in tags].index(tagname) + return tags[inp] + except ValueError: + return None + + +def _display_tag(tag_tuple): + """Pretty-print tag tuple""" + tagkey, category, data = tag_tuple + out = ("Tag: '{tagkey}' (category: {category}{dat})".format( + tagkey=tagkey, category=category, dat=", data: {}".format(data) if data else "")) + return out + + +def _add_tag(caller, tag_string, **kwargs): + """ + Add tags to the system, parsing input + + Args: + caller (Object): Caller of menu. + tag_string (str): Input from user on one of these forms + tagname + tagname;category + tagname;category;data + + Kwargs: + delete (str): If this is set, tag_string is considered + the name of the tag to delete. + + Returns: + result (str): Result string of action. + + """ + tag = tag_string.strip().lower() + category = None + data = "" + + if 'delete' in kwargs: + tag = tag_string.lower().strip() + else: + nameparts = tag.split(";", 2) + ntuple = len(nameparts) + if ntuple == 2: + tag, category = nameparts + elif ntuple > 2: + tag, category, data = nameparts[:3] + + tag_tuple = (tag.lower(), category.lower() if category else None, data) + + if tag: + prot = _get_menu_prototype(caller) + tags = prot.get('tags', []) + + old_tag = _get_tup_by_tagname(caller, tag) + + if 'delete' in kwargs: + + if old_tag: + tags.pop(tags.index(old_tag)) + text = "Removed Tag '{}'.".format(tag) + else: + text = "Found no Tag to remove." + elif not old_tag: + # a fresh, new tag + tags.append(tag_tuple) + text = "Added Tag '{}'".format(tag) + else: + # old tag exists; editing a tag means replacing old with new + ind = tags.index(old_tag) + tags[ind] = tag_tuple + text = "Edited Tag '{}'".format(tag) + + _set_prototype_value(caller, "tags", tags) + else: + text = "Tag must be given as 'tag[;category;data]'." + + return text + + +def _tag_select(caller, tagname): + tag_tup = _get_tup_by_tagname(caller, tagname) + if tag_tup: + return "node_examine_entity", \ + {"text": _display_tag(tag_tup), "back": "attrs"} + else: + caller.msg("Tag not found.") + return "node_attrs" + + +def _tags_actions(caller, raw_inp, **kwargs): + """Parse actions for tags listing""" + choices = kwargs.get("available_choices", []) + tagname, action = _default_parse( + raw_inp, choices, ('examine', 'e'), ('remove', 'r', 'delete', 'd')) + + if tagname is None: + tagname = raw_inp.lower().strip() + + tag_tup = _get_tup_by_tagname(caller, tagname) + + if tag_tup: + if action == 'examine': + return "node_examine_entity", \ + {"text": _display_tag(tag_tup), 'back': 'tags'} + elif action == 'remove': + res = _add_tag(caller, tagname, delete=True) + caller.msg(res) + else: + res = _add_tag(caller, raw_inp) + caller.msg(res) + return "node_tags" + + +@list_node(_caller_tags, _tag_select) +def node_tags(caller): + + def _currentcmp(propval, flatval): + "match by key + category" + cmp1 = [(tup[0].lower(), tup[1].lower() if tup[2] else None) for tup in propval] + return [tup for tup in flatval if (tup[0].lower(), tup[1].lower() + if tup[1] else None) not in cmp1] + + text = """ + |cTags|n are used to group objects so they can quickly be found later. Enter tags on one of + the following forms: + tagname + tagname;category + tagname;category;data + + {current} + """.format( + current=_get_current_value( + caller, 'tags', + comparer=_currentcmp, + formatter=lambda lst: "\n" + "\n".join(_display_tag(tup) for tup in lst), + only_inherit=True)) + _set_actioninfo(caller, _format_list_actions("examine", "remove", prefix="Actions: ")) + + helptext = """ + Tags are shared between all objects with that tag. So the 'data' field (which is not + commonly used) can only hold eventual info about the Tag itself, not about the individual + object on which it sits. + + All objects created with this prototype will automatically get assigned a tag named the same + as the |cprototype_key|n and with a category "{tag_category}". This allows the spawner to + optionally update previously spawned objects when their prototype changes. + """.format(tag_category=protlib._PROTOTYPE_TAG_CATEGORY) + + text = (text, helptext) + options = _wizard_options("tags", "attrs", "locks") + options.append({"key": "_default", + "goto": _tags_actions}) + return text, options + + +# locks node + +def _caller_locks(caller): + locks = _get_menu_prototype(caller).get("locks", "") + return [lck for lck in locks.split(";") if lck] + + +def _locks_display(caller, lock): + return lock + + +def _lock_select(caller, lockstr): + return "node_examine_entity", {"text": _locks_display(caller, lockstr), "back": "locks"} + + +def _lock_add(caller, lock, **kwargs): + locks = _caller_locks(caller) + + try: + locktype, lockdef = lock.split(":", 1) + except ValueError: + return "Lockstring lacks ':'." + + locktype = locktype.strip().lower() + + if 'delete' in kwargs: + try: + ind = locks.index(lock) + locks.pop(ind) + _set_prototype_value(caller, "locks", ";".join(locks), parse=False) + ret = "Lock {} deleted.".format(lock) + except ValueError: + ret = "No lock found to delete." + return ret + try: + locktypes = [lck.split(":", 1)[0].strip().lower() for lck in locks] + ind = locktypes.index(locktype) + locks[ind] = lock + ret = "Lock with locktype '{}' updated.".format(locktype) + except ValueError: + locks.append(lock) + ret = "Added lock '{}'.".format(lock) + _set_prototype_value(caller, "locks", ";".join(locks)) + return ret + + +def _locks_actions(caller, raw_inp, **kwargs): + choices = kwargs.get("available_choices", []) + lock, action = _default_parse( + raw_inp, choices, ("examine", "e"), ("remove", "r", "delete", "d")) + + if lock: + if action == 'examine': + return "node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"} + elif action == 'remove': + ret = _lock_add(caller, lock, delete=True) + caller.msg(ret) + else: + ret = _lock_add(caller, raw_inp) + caller.msg(ret) + + return "node_locks" + + +@list_node(_caller_locks, _lock_select) +def node_locks(caller): + + def _currentcmp(propval, flatval): + "match by locktype" + cmp1 = [lck.split(":", 1)[0] for lck in propval.split(';')] + return ";".join(lstr for lstr in flatval.split(';') if lstr.split(':', 1)[0] not in cmp1) + + text = """ + The |cLock string|n defines limitations for accessing various properties of the object once + it's spawned. The string should be on one of the following forms: + + locktype:[NOT] lockfunc(args) + locktype: [NOT] lockfunc(args) [AND|OR|NOT] lockfunc(args) [AND|OR|NOT] ... + + {current}{action} + """.format( + current=_get_current_value( + caller, 'locks', + comparer=_currentcmp, + formatter=lambda lockstr: "\n".join(_locks_display(caller, lstr) + for lstr in lockstr.split(';')), + only_inherit=True), + action=_format_list_actions("examine", "remove", prefix="Actions: ")) + + helptext = """ + Here is an example of two lock strings: + + edit:false() + call:tag(Foo) OR perm(Builder) + + Above locks limit two things, 'edit' and 'call'. Which lock types are actually checked + depend on the typeclass of the object being spawned. Here 'edit' is never allowed by anyone + while 'call' is allowed to all accessors with a |ctag|n 'Foo' OR which has the + |cPermission|n 'Builder'. + + |cAvailable lockfuncs:|n + + {lfuncs} + """.format(lfuncs=_format_lockfuncs()) + + text = (text, helptext) + + options = _wizard_options("locks", "tags", "permissions") + options.append({"key": "_default", + "goto": _locks_actions}) + + return text, options + + +# permissions node + +def _caller_permissions(caller): + prototype = _get_menu_prototype(caller) + perms = prototype.get("permissions", []) + return perms + + +def _display_perm(caller, permission, only_hierarchy=False): + hierarchy = settings.PERMISSION_HIERARCHY + perm_low = permission.lower() + txt = '' + if perm_low in [prm.lower() for prm in hierarchy]: + txt = "Permission (in hieararchy): {}".format( + ", ".join( + ["|w[{}]|n".format(prm) + if prm.lower() == perm_low else "|W{}|n".format(prm) + for prm in hierarchy])) + elif not only_hierarchy: + txt = "Permission: '{}'".format(permission) + return txt + + +def _permission_select(caller, permission, **kwargs): + return "node_examine_entity", {"text": _display_perm(caller, permission), "back": "permissions"} + + +def _add_perm(caller, perm, **kwargs): + if perm: + perm_low = perm.lower() + perms = _caller_permissions(caller) + perms_low = [prm.lower() for prm in perms] + if 'delete' in kwargs: + try: + ind = perms_low.index(perm_low) + del perms[ind] + text = "Removed Permission '{}'.".format(perm) + except ValueError: + text = "Found no Permission to remove." + else: + if perm_low in perms_low: + text = "Permission already set." + else: + perms.append(perm) + _set_prototype_value(caller, "permissions", perms) + text = "Added Permission '{}'".format(perm) + return text + + +def _permissions_actions(caller, raw_inp, **kwargs): + """Parse actions for permission listing""" + choices = kwargs.get("available_choices", []) + perm, action = _default_parse( + raw_inp, choices, ('examine', 'e'), ('remove', 'r', 'delete', 'd')) + + if perm: + if action == 'examine': + return "node_examine_entity", \ + {"text": _display_perm(caller, perm), "back": "permissions"} + elif action == 'remove': + res = _add_perm(caller, perm, delete=True) + caller.msg(res) + else: + res = _add_perm(caller, raw_inp.strip()) + caller.msg(res) + return "node_permissions" + + +@list_node(_caller_permissions, _permission_select) +def node_permissions(caller): + + def _currentcmp(pval, fval): + cmp1 = [perm.lower() for perm in pval] + return [perm for perm in fval if perm.lower() not in cmp1] + + text = """ + |cPermissions|n are simple strings used to grant access to this object. A permission is used + when a |clock|n is checked that contains the |wperm|n or |wpperm|n lock functions. Certain + permissions belong in the |cpermission hierarchy|n together with the |Wperm()|n lock + function. + + {current} + """.format( + current=_get_current_value( + caller, 'permissions', + comparer=_currentcmp, + formatter=lambda lst: "\n" + "\n".join(prm for prm in lst), only_inherit=True)) + _set_actioninfo(caller, _format_list_actions("examine", "remove", prefix="Actions: ")) + + helptext = """ + Any string can act as a permission as long as a lock is set to look for it. Depending on the + lock, having a permission could even be negative (i.e. the lock is only passed if you + |wdon't|n have the 'permission'). The most common permissions are the hierarchical + permissions: + + {permissions}. + + For example, a |clock|n string like "edit:perm(Builder)" will grant access to accessors + having the |cpermission|n "Builder" or higher. + """.format(permissions=", ".join(settings.PERMISSION_HIERARCHY)) + + text = (text, helptext) + + options = _wizard_options("permissions", "locks", "location") + options.append({"key": "_default", + "goto": _permissions_actions}) + + return text, options + + +# location node + + +def node_location(caller): + + text = """ + The |cLocation|n of this object in the world. If not given, the object will spawn in the + inventory of |c{caller}|n by default. + + {current} + """.format(caller=caller.key, current=_get_current_value(caller, "location")) + + helptext = """ + You get the most control by not specifying the location - you can then teleport the spawned + objects as needed later. Setting the location may be useful for quickly populating a given + location. One could also consider randomizing the location using a $protfunc. + + |c$protfuncs|n + {pfuncs} + """.format(pfuncs=_format_protfuncs()) + + text = (text, helptext) + + options = _wizard_options("location", "permissions", "home", search=True) + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="location", + processor=lambda s: s.strip()))}) + return text, options + + +# home node + + +def node_home(caller): + + text = """ + The |cHome|n location of an object is often only used as a backup - this is where the object + will be moved to if its location is deleted. The home location can also be used as an actual + home for characters to quickly move back to. + + If unset, the global home default (|w{default}|n) will be used. + + {current} + """.format(default=settings.DEFAULT_HOME, + current=_get_current_value(caller, "home")) + helptext = """ + The home can be given as a #dbref but can also be specified using the protfunc + '$obj(name)'. Use |wSE|nearch to find objects in the database. + + The home location is commonly not used except as a backup; using the global default is often + enough. + + |c$protfuncs|n + {pfuncs} + """.format(pfuncs=_format_protfuncs()) + + text = (text, helptext) + + options = _wizard_options("home", "location", "destination", search=True) + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="home", + processor=lambda s: s.strip()))}) + return text, options + + +# destination node + + +def node_destination(caller): + + text = """ + The object's |cDestination|n is generally only used by Exit-like objects to designate where + the exit 'leads to'. It's usually unset for all other types of objects. + + {current} + """.format(current=_get_current_value(caller, "destination")) + + helptext = """ + The destination can be given as a #dbref but can also be specified using the protfunc + '$obj(name)'. Use |wSEearch to find objects in the database. + + |c$protfuncs|n + {pfuncs} + """.format(pfuncs=_format_protfuncs()) + + text = (text, helptext) + + options = _wizard_options("destination", "home", "prototype_desc", search=True) + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="destination", + processor=lambda s: s.strip()))}) + return text, options + + +# prototype_desc node + + +def node_prototype_desc(caller): + + text = """ + The |cPrototype-Description|n briefly describes the prototype when it's viewed in listings. + + {current} + """.format(current=_get_current_value(caller, "prototype_desc")) + + helptext = """ + Giving a brief description helps you and others to locate the prototype for use later. + """ + + text = (text, helptext) + + options = _wizard_options("prototype_desc", "prototype_key", "prototype_tags") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop='prototype_desc', + processor=lambda s: s.strip(), + next_node="node_prototype_desc"))}) + + return text, options + + +# prototype_tags node + + +def _caller_prototype_tags(caller): + prototype = _get_menu_prototype(caller) + tags = prototype.get("prototype_tags", []) + return tags + + +def _add_prototype_tag(caller, tag_string, **kwargs): + """ + Add prototype_tags to the system. We only support straight tags, no + categories (category is assigned automatically). + + Args: + caller (Object): Caller of menu. + tag_string (str): Input from user - only tagname + + Kwargs: + delete (str): If this is set, tag_string is considered + the name of the tag to delete. + + Returns: + result (str): Result string of action. + + """ + tag = tag_string.strip().lower() + + if tag: + prot = _get_menu_prototype(caller) + tags = prot.get('prototype_tags', []) + exists = tag in tags + + if 'delete' in kwargs: + if exists: + tags.pop(tags.index(tag)) + text = "Removed Prototype-Tag '{}'.".format(tag) + else: + text = "Found no Prototype-Tag to remove." + elif not exists: + # a fresh, new tag + tags.append(tag) + text = "Added Prototype-Tag '{}'.".format(tag) + else: + text = "Prototype-Tag already added." + + _set_prototype_value(caller, "prototype_tags", tags) + else: + text = "No Prototype-Tag specified." + + return text + + +def _prototype_tag_select(caller, tagname): + caller.msg("Prototype-Tag: {}".format(tagname)) + return "node_prototype_tags" + + +def _prototype_tags_actions(caller, raw_inp, **kwargs): + """Parse actions for tags listing""" + choices = kwargs.get("available_choices", []) + tagname, action = _default_parse( + raw_inp, choices, ('remove', 'r', 'delete', 'd')) + + if tagname: + if action == 'remove': + res = _add_prototype_tag(caller, tagname, delete=True) + caller.msg(res) + else: + res = _add_prototype_tag(caller, raw_inp.lower().strip()) + caller.msg(res) + return "node_prototype_tags" + + +@list_node(_caller_prototype_tags, _prototype_tag_select) +def node_prototype_tags(caller): + + text = """ + |cPrototype-Tags|n can be used to classify and find prototypes in listings Tag names are not + case-sensitive and can have not have a custom category. + + {current} + """.format( + current=_get_current_value( + caller, 'prototype_tags', + formatter=lambda lst: ", ".join(tg for tg in lst), only_inherit=True)) + _set_actioninfo(caller, _format_list_actions( + "remove", prefix="|w|n|W to add Tag. Other Action:|n ")) + helptext = """ + Using prototype-tags is a good way to organize and group large numbers of prototypes by + genre, type etc. Under the hood, prototypes' tags will all be stored with the category + '{tagmetacategory}'. + """.format(tagmetacategory=protlib._PROTOTYPE_TAG_META_CATEGORY) + + text = (text, helptext) + + options = _wizard_options("prototype_tags", "prototype_desc", "prototype_locks") + options.append({"key": "_default", + "goto": _prototype_tags_actions}) + + return text, options + + +# prototype_locks node + + +def _caller_prototype_locks(caller): + locks = _get_menu_prototype(caller).get("prototype_locks", "") + return [lck for lck in locks.split(";") if lck] + + +def _prototype_lock_select(caller, lockstr): + return "node_examine_entity", {"text": _locks_display(caller, lockstr), "back": "prototype_locks"} + + +def _prototype_lock_add(caller, lock, **kwargs): + locks = _caller_prototype_locks(caller) + + try: + locktype, lockdef = lock.split(":", 1) + except ValueError: + return "Lockstring lacks ':'." + + locktype = locktype.strip().lower() + + if 'delete' in kwargs: + try: + ind = locks.index(lock) + locks.pop(ind) + _set_prototype_value(caller, "prototype_locks", ";".join(locks), parse=False) + ret = "Prototype-lock {} deleted.".format(lock) + except ValueError: + ret = "No Prototype-lock found to delete." + return ret + try: + locktypes = [lck.split(":", 1)[0].strip().lower() for lck in locks] + ind = locktypes.index(locktype) + locks[ind] = lock + ret = "Prototype-lock with locktype '{}' updated.".format(locktype) + except ValueError: + locks.append(lock) + ret = "Added Prototype-lock '{}'.".format(lock) + _set_prototype_value(caller, "prototype_locks", ";".join(locks)) + return ret + + +def _prototype_locks_actions(caller, raw_inp, **kwargs): + choices = kwargs.get("available_choices", []) + lock, action = _default_parse( + raw_inp, choices, ("examine", "e"), ("remove", "r", "delete", "d")) + + if lock: + if action == 'examine': + return "node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"} + elif action == 'remove': + ret = _prototype_lock_add(caller, lock.strip(), delete=True) + caller.msg(ret) + else: + ret = _prototype_lock_add(caller, raw_inp.strip()) + caller.msg(ret) + + return "node_prototype_locks" + + +@list_node(_caller_prototype_locks, _prototype_lock_select) +def node_prototype_locks(caller): + + text = """ + |cPrototype-Locks|n are used to limit access to this prototype when someone else is trying + to access it. By default any prototype can be edited only by the creator and by Admins while + they can be used by anyone with access to the spawn command. There are two valid lock types + the prototype access tools look for: + + - 'edit': Who can edit the prototype. + - 'spawn': Who can spawn new objects with this prototype. + + If unsure, keep the open defaults. + + {current} + """.format( + current=_get_current_value( + caller, 'prototype_locks', + formatter=lambda lstring: "\n".join(_locks_display(caller, lstr) + for lstr in lstring.split(';')), + only_inherit=True)) + _set_actioninfo(caller, _format_list_actions('examine', "remove", prefix="Actions: ")) + + helptext = """ + Prototype locks can be used to vary access for different tiers of builders. It also allows + developers to produce 'base prototypes' only meant for builders to inherit and expand on + rather than tweak in-place. + """ + + text = (text, helptext) + + options = _wizard_options("prototype_locks", "prototype_tags", "index") + options.append({"key": "_default", + "goto": _prototype_locks_actions}) + + return text, options + + +# update existing objects node + + +def _apply_diff(caller, **kwargs): + """update existing objects""" + prototype = kwargs['prototype'] + objects = kwargs['objects'] + back_node = kwargs['back_node'] + diff = kwargs.get('diff', None) + num_changed = spawner.batch_update_objects_with_prototype(prototype, diff=diff, objects=objects) + caller.msg("|g{num} objects were updated successfully.|n".format(num=num_changed)) + return back_node + + +def _keep_diff(caller, **kwargs): + key = kwargs['key'] + diff = kwargs['diff'] + diff[key] = "KEEP" + + +def node_apply_diff(caller, **kwargs): + """Offer options for updating objects""" + + def _keep_option(keyname, prototype, base_obj, obj_prototype, diff, objects, back_node): + """helper returning an option dict""" + options = {"desc": "Keep {} as-is".format(keyname), + "goto": (_keep_diff, + {"key": keyname, "prototype": prototype, + "base_obj": base_obj, "obj_prototype": obj_prototype, + "diff": diff, "objects": objects, "back_node": back_node})} + return options + + prototype = kwargs.get("prototype", None) + update_objects = kwargs.get("objects", None) + back_node = kwargs.get("back_node", "node_index") + obj_prototype = kwargs.get("obj_prototype", None) + base_obj = kwargs.get("base_obj", None) + diff = kwargs.get("diff", None) + + if not update_objects: + text = "There are no existing objects to update." + options = {"key": "_default", + "goto": back_node} + return text, options + + if not diff: + # use one random object as a reference to calculate a diff + base_obj = choice(update_objects) + diff, obj_prototype = spawner.prototype_diff_from_object(prototype, base_obj) + + text = ["Suggested changes to {} objects. ".format(len(update_objects)), + "Showing random example obj to change: {name} ({dbref}))\n".format( + name=base_obj.key, dbref=base_obj.dbref)] + + helptext = """ + Be careful with this operation! The upgrade mechanism will try to automatically estimate + what changes need to be applied. But the estimate is |wonly based on the analysis of one + randomly selected object|n among all objects spawned by this prototype. If that object + happens to be unusual in some way the estimate will be off and may lead to unexpected + results for other objects. Always test your objects carefully after an upgrade and + consider being conservative (switch to KEEP) or even do the update manually if you are + unsure that the results will be acceptable. """ + + options = [] + + ichanges = 0 + for (key, inst) in sorted(((key, val) for key, val in diff.items()), key=lambda tup: tup[0]): + + if key in protlib._PROTOTYPE_META_NAMES: + continue + + line = "{iopt} |w{key}|n: {old}{sep}{new} {change}" + old_val = str(obj_prototype.get(key, "")) + + if inst == "KEEP": + inst = "|b{}|n".format(inst) + text.append(line.format(iopt='', key=key, old=old_val, + sep=" ", new='', change=inst)) + continue + + if key in prototype: + new_val = str(spawner.init_spawn_value(prototype[key])) + else: + new_val = "" + ichanges += 1 + if inst in ("UPDATE", "REPLACE"): + inst = "|y{}|n".format(inst) + text.append(line.format(iopt=ichanges, key=key, old=old_val, + sep=" |y->|n ", new=new_val, change=inst)) + options.append(_keep_option(key, prototype, + base_obj, obj_prototype, diff, update_objects, back_node)) + elif inst == "REMOVE": + inst = "|r{}|n".format(inst) + text.append(line.format(iopt=ichanges, key=key, old=old_val, + sep=" |r->|n ", new='', change=inst)) + options.append(_keep_option(key, prototype, + base_obj, obj_prototype, diff, update_objects, back_node)) + options.extend( + [{"key": ("|wu|Wpdate {} objects".format(len(update_objects)), "update", "u"), + "desc": "Update {} objects".format(len(update_objects)), + "goto": (_apply_diff, {"prototype": prototype, "objects": update_objects, + "back_node": back_node, "diff": diff, "base_obj": base_obj})}, + {"key": ("|wr|Weset changes", "reset", "r"), + "goto": ("node_apply_diff", {"prototype": prototype, "back_node": back_node, + "objects": update_objects})}]) + + if ichanges < 1: + text = ["Analyzed a random sample object (out of {}) - " + "found no changes to apply.".format(len(update_objects))] + + options.extend(_wizard_options("update_objects", back_node[5:], None)) + options.append({"key": "_default", + "goto": back_node}) + + text = "\n".join(text) + + text = (text, helptext) + + return text, options + + +# prototype save node + + +def node_prototype_save(caller, **kwargs): + """Save prototype to disk """ + # these are only set if we selected 'yes' to save on a previous pass + prototype = kwargs.get("prototype", None) + # set to True/False if answered, None if first pass + accept_save = kwargs.get("accept_save", None) + + if accept_save and prototype: + # we already validated and accepted the save, so this node acts as a goto callback and + # should now only return the next node + prototype_key = prototype.get("prototype_key") + protlib.save_prototype(**prototype) + + spawned_objects = protlib.search_objects_with_prototype(prototype_key) + nspawned = spawned_objects.count() + + text = ["|gPrototype saved.|n"] + + if nspawned: + text.append("\nDo you want to update {} object(s) " + "already using this prototype?".format(nspawned)) + options = ( + {"key": ("|wY|Wes|n", "yes", "y"), + "desc": "Go to updating screen", + "goto": ("node_apply_diff", + {"accept_update": True, "objects": spawned_objects, + "prototype": prototype, "back_node": "node_prototype_save"})}, + {"key": ("[|wN|Wo|n]", "n"), + "desc": "Return to index", + "goto": "node_index"}, + {"key": "_default", + "goto": "node_index"}) + else: + text.append("(press Return to continue)") + options = {"key": "_default", + "goto": "node_index"} + + text = "\n".join(text) + + helptext = """ + Updating objects means that the spawner will find all objects previously created by this + prototype. You will be presented with a list of the changes the system will try to apply to + each of these objects and you can choose to customize that change if needed. If you have + done a lot of manual changes to your objects after spawning, you might want to update those + objects manually instead. + """ + + text = (text, helptext) + + return text, options + + # not validated yet + prototype = _get_menu_prototype(caller) + error, text = _validate_prototype(prototype) + + text = [text] + + if error: + # abort save + text.append( + "\n|yValidation errors were found. They need to be corrected before this prototype " + "can be saved (or used to spawn).|n") + options = _wizard_options("prototype_save", "index", None) + options.append({"key": "_default", + "goto": "node_index"}) + return "\n".join(text), options + + prototype_key = prototype['prototype_key'] + if protlib.search_prototype(prototype_key): + text.append("\nDo you want to save/overwrite the existing prototype '{name}'?".format( + name=prototype_key)) + else: + text.append("\nDo you want to save the prototype as '{name}'?".format(name=prototype_key)) + + text = "\n".join(text) + + helptext = """ + Saving the prototype makes it available for use later. It can also be used to inherit from, + by name. Depending on |cprototype-locks|n it also makes the prototype usable and/or + editable by others. Consider setting good |cPrototype-tags|n and to give a useful, brief + |cPrototype-desc|n to make the prototype easy to find later. + + """ + + text = (text, helptext) + + options = ( + {"key": ("[|wY|Wes|n]", "yes", "y"), + "desc": "Save prototype", + "goto": ("node_prototype_save", + {"accept_save": True, "prototype": prototype})}, + {"key": ("|wN|Wo|n", "n"), + "desc": "Abort and return to Index", + "goto": "node_index"}, + {"key": "_default", + "goto": ("node_prototype_save", + {"accept_save": True, "prototype": prototype})}) + + return text, options + + +# spawning node + + +def _spawn(caller, **kwargs): + """Spawn prototype""" + prototype = kwargs["prototype"].copy() + new_location = kwargs.get('location', None) + if new_location: + prototype['location'] = new_location + if not prototype.get('location'): + prototype['location'] = caller + + obj = spawner.spawn(prototype) + if obj: + obj = obj[0] + text = "|gNew instance|n {key} ({dbref}) |gspawned at location |n{loc}|n|g.|n".format( + key=obj.key, dbref=obj.dbref, loc=prototype['location']) + else: + text = "|rError: Spawner did not return a new instance.|n" + return "node_examine_entity", {"text": text, "back": "prototype_spawn"} + + +def node_prototype_spawn(caller, **kwargs): + """Submenu for spawning the prototype""" + + prototype = _get_menu_prototype(caller) + + already_validated = kwargs.get("already_validated", False) + + if already_validated: + error, text = None, [] + else: + error, text = _validate_prototype(prototype) + text = [text] + + if error: + text.append("\n|rPrototype validation failed. Correct the errors before spawning.|n") + options = _wizard_options("prototype_spawn", "index", None) + return "\n".join(text), options + + text = "\n".join(text) + + helptext = """ + Spawning is the act of instantiating a prototype into an actual object. As a new object is + spawned, every $protfunc in the prototype is called anew. Since this is a common thing to + do, you may also temporarily change the |clocation|n of this prototype to bypass whatever + value is set in the prototype. + + """ + text = (text, helptext) + + # show spawn submenu options + options = [] + prototype_key = prototype['prototype_key'] + location = prototype.get('location', None) + + if location: + options.append( + {"desc": "Spawn in prototype's defined location ({loc})".format(loc=location), + "goto": (_spawn, + dict(prototype=prototype))}) + caller_loc = caller.location + if location != caller_loc: + options.append( + {"desc": "Spawn in {caller}'s location ({loc})".format( + caller=caller, loc=caller_loc), + "goto": (_spawn, + dict(prototype=prototype, location=caller_loc))}) + if location != caller_loc != caller: + options.append( + {"desc": "Spawn in {caller}'s inventory".format(caller=caller), + "goto": (_spawn, + dict(prototype=prototype, location=caller))}) + + spawned_objects = protlib.search_objects_with_prototype(prototype_key) + nspawned = spawned_objects.count() + if spawned_objects: + options.append( + {"desc": "Update {num} existing objects with this prototype".format(num=nspawned), + "goto": ("node_apply_diff", + {"objects": list(spawned_objects), + "prototype": prototype, + "back_node": "node_prototype_spawn"})}) + options.extend(_wizard_options("prototype_spawn", "index", None)) + options.append({"key": "_default", + "goto": "node_index"}) + + return text, options + + +# prototype load node + + +def _prototype_load_select(caller, prototype_key): + matches = protlib.search_prototype(key=prototype_key) + if matches: + prototype = matches[0] + _set_menu_prototype(caller, prototype) + return "node_examine_entity", \ + {"text": "|gLoaded prototype {}.|n".format(prototype['prototype_key']), + "back": "index"} + else: + caller.msg("|rFailed to load prototype '{}'.".format(prototype_key)) + return None + + +def _prototype_load_actions(caller, raw_inp, **kwargs): + """Parse the default Convert prototype to a string representation for closer inspection""" + choices = kwargs.get("available_choices", []) + prototype, action = _default_parse( + raw_inp, choices, ("examine", "e", "l"), ("delete", "del", "d")) + + if prototype: + + # which action to apply on the selection + if action == 'examine': + # examine the prototype + prototype = protlib.search_prototype(key=prototype)[0] + txt = protlib.prototype_to_str(prototype) + return "node_examine_entity", {"text": txt, "back": 'prototype_load'} + elif action == 'delete': + # delete prototype from disk + try: + protlib.delete_prototype(prototype, caller=caller) + except protlib.PermissionError as err: + txt = "|rDeletion error:|n {}".format(err) + else: + txt = "|gPrototype {} was deleted.|n".format(prototype) + return "node_examine_entity", {"text": txt, "back": "prototype_load"} + + return 'node_prototype_load' + + +@list_node(_all_prototype_parents, _prototype_load_select) +def node_prototype_load(caller, **kwargs): + """Load prototype""" + + text = """ + Select a prototype to load. This will replace any prototype currently being edited! + """ + _set_actioninfo(caller, _format_list_actions("examine", "delete")) + + helptext = """ + Loading a prototype will load it and return you to the main index. It can be a good idea + to examine the prototype before loading it. + """ + + text = (text, helptext) + + options = _wizard_options("prototype_load", "index", None) + options.append({"key": "_default", + "goto": _prototype_load_actions}) + + return text, options + + +# EvMenu definition, formatting and access functions + + +class OLCMenu(EvMenu): + """ + A custom EvMenu with a different formatting for the options. + + """ + def nodetext_formatter(self, nodetext): + """ + Format the node text itself. + + """ + return super(OLCMenu, self).nodetext_formatter(nodetext) + + def options_formatter(self, optionlist): + """ + Split the options into two blocks - olc options and normal options + + """ + olc_keys = ("index", "forward", "back", "previous", "next", "validate prototype", + "save prototype", "load prototype", "spawn prototype", "search objects") + actioninfo = self.actioninfo + "\n" if hasattr(self, 'actioninfo') else '' + olc_options = [] + other_options = [] + for key, desc in optionlist: + raw_key = strip_ansi(key).lower() + if raw_key in olc_keys: + desc = " {}".format(desc) if desc else "" + olc_options.append("|lc{}|lt{}|le{}".format(raw_key, key, desc)) + else: + other_options.append((key, desc)) + + olc_options = actioninfo + \ + " |W|||n ".join(olc_options) + " |W|||n " + "|wQ|Wuit" if olc_options else "" + other_options = super(OLCMenu, self).options_formatter(other_options) + sep = "\n\n" if olc_options and other_options else "" + + return "{}{}{}".format(olc_options, sep, other_options) + + def helptext_formatter(self, helptext): + """ + Show help text + """ + return "|c --- Help ---|n\n" + utils.dedent(helptext) + + def display_helptext(self): + evmore.msg(self.caller, self.helptext, session=self._session, exit_cmd='look') + + +def start_olc(caller, session=None, prototype=None): + """ + Start menu-driven olc system for prototypes. + + Args: + caller (Object or Account): The entity starting the menu. + session (Session, optional): The individual session to get data. + prototype (dict, optional): Given when editing an existing + prototype rather than creating a new one. + + """ + menudata = {"node_index": node_index, + "node_validate_prototype": node_validate_prototype, + "node_examine_entity": node_examine_entity, + "node_search_object": node_search_object, + "node_prototype_key": node_prototype_key, + "node_prototype_parent": node_prototype_parent, + "node_typeclass": node_typeclass, + "node_key": node_key, + "node_aliases": node_aliases, + "node_attrs": node_attrs, + "node_tags": node_tags, + "node_locks": node_locks, + "node_permissions": node_permissions, + "node_location": node_location, + "node_home": node_home, + "node_destination": node_destination, + "node_apply_diff": node_apply_diff, + "node_prototype_desc": node_prototype_desc, + "node_prototype_tags": node_prototype_tags, + "node_prototype_locks": node_prototype_locks, + "node_prototype_load": node_prototype_load, + "node_prototype_save": node_prototype_save, + "node_prototype_spawn": node_prototype_spawn + } + OLCMenu(caller, menudata, startnode='node_index', session=session, olc_prototype=prototype) diff --git a/evennia/prototypes/protfuncs.py b/evennia/prototypes/protfuncs.py new file mode 100644 index 0000000000..6dff62ef96 --- /dev/null +++ b/evennia/prototypes/protfuncs.py @@ -0,0 +1,317 @@ +""" +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.PROT_FUNC_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 (dict): The dict this protfunc is a part of. + - current_key (str): The active key this value belongs to in the prototype. + - testing (bool): This is set if this function is called as part of the prototype validation; if + set, the protfunc should take care not to perform any persistent actions, such as operate on + objects or add things to the database. + +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 ast import literal_eval +from random import randint as base_randint, random as base_random + +from evennia.utils import search +from evennia.utils.utils import justify as base_justify, is_iter, to_str + +_PROTLIB = None + + +# default protfuncs + +def random(*args, **kwargs): + """ + Usage: $random() + Returns a random value in the interval [0, 1) + + """ + return base_random() + + +def randint(*args, **kwargs): + """ + Usage: $randint(start, end) + Returns random integer in interval [start, end] + + """ + if len(args) != 2: + raise TypeError("$randint needs two arguments - start and end.") + start, end = int(args[0]), int(args[1]) + return base_randint(start, end) + + +def left_justify(*args, **kwargs): + """ + Usage: $left_justify() + Returns left-justified. + + """ + if args: + return base_justify(args[0], align='l') + return "" + + +def right_justify(*args, **kwargs): + """ + Usage: $right_justify() + Returns right-justified across screen width. + + """ + if args: + return base_justify(args[0], align='r') + return "" + + +def center_justify(*args, **kwargs): + + """ + Usage: $center_justify() + Returns centered in screen width. + + """ + if args: + return base_justify(args[0], align='c') + return "" + + +def full_justify(*args, **kwargs): + + """ + Usage: $full_justify() + Returns filling up screen width by adding extra space. + + """ + if args: + return base_justify(args[0], align='f') + return "" + + +def protkey(*args, **kwargs): + """ + Usage: $protkey() + Returns the value of another key in this prototoype. Will raise an error if + the key is not found in this prototype. + + """ + if args: + prototype = kwargs['prototype'] + return prototype[args[0].strip()] + + +def add(*args, **kwargs): + """ + Usage: $add(val1, val2) + Returns the result of val1 + val2. Values must be + valid simple Python structures possible to add, + such as numbers, lists etc. + + """ + if len(args) > 1: + val1, val2 = args[0], args[1] + # try to convert to python structures, otherwise, keep as strings + try: + val1 = literal_eval(val1.strip()) + except Exception: + pass + try: + val2 = literal_eval(val2.strip()) + except Exception: + pass + return val1 + val2 + raise ValueError("$add requires two arguments.") + + +def sub(*args, **kwargs): + """ + Usage: $del(val1, val2) + Returns the value of val1 - val2. Values must be + valid simple Python structures possible to + subtract. + + """ + if len(args) > 1: + val1, val2 = args[0], args[1] + # try to convert to python structures, otherwise, keep as strings + try: + val1 = literal_eval(val1.strip()) + except Exception: + pass + try: + val2 = literal_eval(val2.strip()) + except Exception: + pass + return val1 - val2 + raise ValueError("$sub requires two arguments.") + + +def mult(*args, **kwargs): + """ + Usage: $mul(val1, val2) + Returns the value of val1 * val2. The values must be + valid simple Python structures possible to + multiply, like strings and/or numbers. + + """ + if len(args) > 1: + val1, val2 = args[0], args[1] + # try to convert to python structures, otherwise, keep as strings + try: + val1 = literal_eval(val1.strip()) + except Exception: + pass + try: + val2 = literal_eval(val2.strip()) + except Exception: + pass + return val1 * val2 + raise ValueError("$mul requires two arguments.") + + +def div(*args, **kwargs): + """ + Usage: $div(val1, val2) + Returns the value of val1 / val2. Values must be numbers and + the result is always a float. + + """ + if len(args) > 1: + val1, val2 = args[0], args[1] + # try to convert to python structures, otherwise, keep as strings + try: + val1 = literal_eval(val1.strip()) + except Exception: + pass + try: + val2 = literal_eval(val2.strip()) + except Exception: + pass + return val1 / float(val2) + raise ValueError("$mult requires two arguments.") + + +def toint(*args, **kwargs): + """ + Usage: $toint() + Returns as an integer. + """ + if args: + val = args[0] + try: + return int(literal_eval(val.strip())) + except ValueError: + return val + raise ValueError("$toint requires one argument.") + + +def eval(*args, **kwargs): + """ + Usage $eval() + Returns evaluation of a simple Python expression. The string may *only* consist of the following + Python literal structures: strings, numbers, tuples, lists, dicts, booleans, + and None. The strings can also contain #dbrefs. Escape embedded protfuncs as $$protfunc(..) + - those will then be evaluated *after* $eval. + + """ + global _PROTLIB + if not _PROTLIB: + from evennia.prototypes import prototypes as _PROTLIB + + string = ",".join(args) + struct = literal_eval(string) + + if isinstance(struct, basestring): + # we must shield the string, otherwise it will be merged as a string and future + # literal_evas will pick up e.g. '2' as something that should be converted to a number + struct = '"{}"'.format(struct) + + # convert any #dbrefs to objects (also in nested structures) + struct = _PROTLIB.value_to_obj_or_any(struct) + + return struct + + +def _obj_search(*args, **kwargs): + "Helper function to search for an object" + + query = "".join(args) + session = kwargs.get("session", None) + return_list = kwargs.pop("return_list", False) + account = None + + if session: + account = session.account + + targets = search.search_object(query) + + if return_list: + retlist = [] + if account: + for target in targets: + if target.access(account, target, 'control'): + retlist.append(target) + else: + retlist = targets + return retlist + else: + # single-match + if not targets: + raise ValueError("$obj: Query '{}' gave no matches.".format(query)) + if len(targets) > 1: + raise ValueError("$obj: Query '{query}' gave {nmatches} matches. Limit your " + "query or use $objlist instead.".format( + query=query, nmatches=len(targets))) + target = targets[0] + if account: + if not target.access(account, target, 'control'): + raise ValueError("$obj: Obj {target}(#{dbref} cannot be added - " + "Account {account} does not have 'control' access.".format( + target=target.key, dbref=target.id, account=account)) + return target + + +def obj(*args, **kwargs): + """ + Usage $obj() + Returns one Object searched globally by key, alias or #dbref. Error if more than one. + + """ + obj = _obj_search(return_list=False, *args, **kwargs) + if obj: + return "#{}".format(obj.id) + return "".join(args) + + +def objlist(*args, **kwargs): + """ + Usage $objlist() + Returns list with one or more Objects searched globally by key, alias or #dbref. + + """ + return ["#{}".format(obj.id) for obj in _obj_search(return_list=True, *args, **kwargs)] diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py new file mode 100644 index 0000000000..0cc016300f --- /dev/null +++ b/evennia/prototypes/prototypes.py @@ -0,0 +1,695 @@ +""" + +Handling storage of prototypes, both database-based ones (DBPrototypes) and those defined in modules +(Read-only prototypes). + +""" + +import re +from ast import literal_eval +from django.conf import settings +from evennia.scripts.scripts import DefaultScript +from evennia.objects.models import ObjectDB +from evennia.utils.create import create_script +from evennia.utils.utils import ( + all_from_module, make_iter, is_iter, dbid_to_obj, callables_from_module, + get_all_typeclasses, to_str, dbref, justify) +from evennia.locks.lockhandler import validate_lockstring, check_lockstring +from evennia.utils import logger +from evennia.utils import inlinefuncs +from evennia.utils.evtable import EvTable + + +_MODULE_PROTOTYPE_MODULES = {} +_MODULE_PROTOTYPES = {} +_PROTOTYPE_META_NAMES = ( + "prototype_key", "prototype_desc", "prototype_tags", "prototype_locks", "prototype_parent") +_PROTOTYPE_RESERVED_KEYS = _PROTOTYPE_META_NAMES + ( + "key", "aliases", "typeclass", "location", "home", "destination", + "permissions", "locks", "exec", "tags", "attrs") +_PROTOTYPE_TAG_CATEGORY = "from_prototype" +_PROTOTYPE_TAG_META_CATEGORY = "db_prototype" +PROT_FUNCS = {} + + +_RE_DBREF = re.compile(r"(? 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) + + +def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): + """ + Collate a list of found prototypes based on search criteria and access. + + Args: + caller (Account or Object): The object requesting the list. + key (str, optional): Exact or partial prototype key to query for. + tags (str or list, optional): Tag key or keys to query for. + show_non_use (bool, optional): Show also prototypes the caller may not use. + show_non_edit (bool, optional): Show also prototypes the caller may not edit. + Returns: + table (EvTable or None): An EvTable representation of the prototypes. None + if no prototypes were found. + + """ + # this allows us to pass lists of empty strings + tags = [tag for tag in make_iter(tags) if tag] + + # get prototypes for readonly and db-based prototypes + prototypes = search_prototype(key, tags) + + # get use-permissions of readonly attributes (edit is always False) + display_tuples = [] + for prototype in sorted(prototypes, key=lambda d: d.get('prototype_key', '')): + lock_use = caller.locks.check_lockstring( + caller, prototype.get('prototype_locks', ''), access_type='spawn') + if not show_non_use and not lock_use: + continue + if prototype.get('prototype_key', '') in _MODULE_PROTOTYPES: + lock_edit = False + else: + lock_edit = caller.locks.check_lockstring( + caller, prototype.get('prototype_locks', ''), access_type='edit') + if not show_non_edit and not lock_edit: + continue + ptags = [] + for ptag in prototype.get('prototype_tags', []): + if is_iter(ptag): + if len(ptag) > 1: + ptags.append("{} (category: {}".format(ptag[0], ptag[1])) + else: + ptags.append(ptag[0]) + else: + ptags.append(str(ptag)) + + display_tuples.append( + (prototype.get('prototype_key', ''), + prototype.get('prototype_desc', ''), + "{}/{}".format('Y' if lock_use else 'N', 'Y' if lock_edit else 'N'), + ",".join(ptags))) + + if not display_tuples: + return "" + + table = [] + width = 78 + for i in range(len(display_tuples[0])): + table.append([str(display_tuple[i]) for display_tuple in display_tuples]) + table = EvTable("Key", "Desc", "Spawn/Edit", "Tags", table=table, crop=True, width=width) + table.reformat_column(0, width=22) + table.reformat_column(1, width=29) + table.reformat_column(2, width=11, align='c') + table.reformat_column(3, width=16) + return table + + +def validate_prototype(prototype, protkey=None, protparents=None, + is_prototype_base=True, strict=True, _flags=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. + is_prototype_base (bool, optional): We are trying to create a new object *based on this + object*. This means we can't allow 'mixin'-style prototypes without typeclass/parent + etc. + strict (bool, optional): If unset, don't require needed keys, only check against infinite + recursion etc. + _flags (dict, optional): Internal work dict that should not be set externally. + Raises: + RuntimeError: If prototype has invalid structure. + RuntimeWarning: If prototype has issues that would make it unsuitable to build an object + with (it may still be useful as a mix-in prototype). + + """ + assert isinstance(prototype, dict) + + if _flags is None: + _flags = {"visited": [], "depth": 0, "typeclass": False, "errors": [], "warnings": []} + + if not protparents: + protparents = {prototype.get('prototype_key', "").lower(): prototype + for prototype in search_prototype()} + + protkey = protkey and protkey.lower() or prototype.get('prototype_key', None) + + if strict and not bool(protkey): + _flags['errors'].append("Prototype lacks a `prototype_key`.") + protkey = "[UNSET]" + + typeclass = prototype.get('typeclass') + prototype_parent = prototype.get('prototype_parent', []) + + if strict and not (typeclass or prototype_parent): + if is_prototype_base: + _flags['errors'].append("Prototype {} requires `typeclass` " + "or 'prototype_parent'.".format(protkey)) + else: + _flags['warnings'].append("Prototype {} can only be used as a mixin since it lacks " + "a typeclass or a prototype_parent.".format(protkey)) + + if strict and typeclass and typeclass not in get_all_typeclasses("evennia.objects.models.ObjectDB"): + _flags['errors'].append( + "Prototype {} is based on typeclass {}, which could not be imported!".format( + protkey, typeclass)) + + # recursively traverese prototype_parent chain + + for protstring in make_iter(prototype_parent): + protstring = protstring.lower() + if protkey is not None and protstring == protkey: + _flags['errors'].append("Prototype {} tries to parent itself.".format(protkey)) + protparent = protparents.get(protstring) + if not protparent: + _flags['errors'].append("Prototype {}'s prototype_parent '{}' was not found.".format( + (protkey, protstring))) + if id(prototype) in _flags['visited']: + _flags['errors'].append( + "{} has infinite nesting of prototypes.".format(protkey or prototype)) + + if _flags['errors']: + raise RuntimeError("Error: " + "\nError: ".join(_flags['errors'])) + _flags['visited'].append(id(prototype)) + _flags['depth'] += 1 + validate_prototype(protparent, protstring, protparents, + is_prototype_base=is_prototype_base, _flags=_flags) + _flags['visited'].pop() + _flags['depth'] -= 1 + + if typeclass and not _flags['typeclass']: + _flags['typeclass'] = typeclass + + # if we get back to the current level without a typeclass it's an error. + if strict and is_prototype_base and _flags['depth'] <= 0 and not _flags['typeclass']: + _flags['errors'].append("Prototype {} has no `typeclass` defined anywhere in its parent\n " + "chain. Add `typeclass`, or a `prototype_parent` pointing to a " + "prototype with a typeclass.".format(protkey)) + + if _flags['depth'] <= 0: + if _flags['errors']: + raise RuntimeError("Error: " + "\nError: ".join(_flags['errors'])) + if _flags['warnings']: + raise RuntimeWarning("Warning: " + "\nWarning: ".join(_flags['warnings'])) + + # make sure prototype_locks are set to defaults + prototype_locks = [lstring.split(":", 1) + for lstring in prototype.get("prototype_locks", "").split(';') if ":" in lstring] + locktypes = [tup[0].strip() for tup in prototype_locks] + if "spawn" not in locktypes: + prototype_locks.append(("spawn", "all()")) + if "edit" not in locktypes: + prototype_locks.append(("edit", "all()")) + prototype_locks = ";".join(":".join(tup) for tup in prototype_locks) + prototype['prototype_locks'] = prototype_locks diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py new file mode 100644 index 0000000000..f250287c8f --- /dev/null +++ b/evennia/prototypes/spawner.py @@ -0,0 +1,602 @@ +""" +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 + prototype_parent (str, tuple or callable, optional): name (prototype_key) of eventual parent prototype, or + a list of parents, for multiple left-to-right inheritance. + prototype: Deprecated. Same meaning as 'parent'. + + typeclass (str or callable, optional): if not set, will use typeclass of parent prototype or use + `settings.BASE_OBJECT_TYPECLASS` + key (str or callable, optional): the name of the spawned object. If not given this will set to a + 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_parent": GOBLIN, + "key": "goblin wizard", + "spells": ["fire ball", "lighting bolt"] + } + +GOBLIN_ARCHER = { + "prototype_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" + "prototype_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 django.conf import settings +import evennia +from evennia.objects.models import ObjectDB +from evennia.utils.utils import make_iter, is_iter +from evennia.prototypes import prototypes as protlib +from evennia.prototypes.prototypes import ( + value_to_obj, value_to_obj_or_any, init_spawn_value, _PROTOTYPE_TAG_CATEGORY) + + +_CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") +_PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") +_NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES + + +# Helper + +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. + + """ + # we don't overload the prototype_key + prototype_key = prot.get('prototype_key', None) + if "prototype_parent" in dic: + # move backwards through the inheritance + for prototype in make_iter(dic["prototype_parent"]): + # Build the prot dictionary in reverse order, overloading + new_prot = _get_prototype(protparents.get(prototype.lower(), {}), prot, protparents) + prot.update(new_prot) + prot.update(dic) + prot['prototype_key'] = prototype_key + prot.pop("prototype_parent", None) # we don't need this anymore + return prot + + +def flatten_prototype(prototype, validate=False): + """ + Produce a 'flattened' prototype, where all prototype parents in the inheritance tree have been + merged into a final prototype. + + Args: + prototype (dict): Prototype to flatten. Its `prototype_parent` field will be parsed. + validate (bool, optional): Validate for valid keys etc. + + Returns: + flattened (dict): The final, flattened prototype. + + """ + if prototype: + protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()} + protlib.validate_prototype(prototype, None, protparents, + is_prototype_base=validate, strict=validate) + return _get_prototype(prototype, {}, protparents) + return {} + + +# obj-related prototype functions + +def prototype_from_object(obj): + """ + Guess a minimal prototype from an existing object. + + Args: + obj (Object): An object to analyze. + + Returns: + prototype (dict): A prototype estimating the current state of the object. + + """ + # first, check if this object already has a prototype + + prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) + if prot: + prot = protlib.search_prototype(prot[0]) + + if not prot or len(prot) > 1: + # no unambiguous prototype found - build new prototype + prot = {} + prot['prototype_key'] = "From-Object-{}-{}".format( + obj.key, hashlib.md5(str(time.time())).hexdigest()[:7]) + prot['prototype_desc'] = "Built from {}".format(str(obj)) + prot['prototype_locks'] = "spawn:all();edit:all()" + prot['prototype_tags'] = [] + else: + prot = prot[0] + + prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6] + prot['typeclass'] = obj.db_typeclass_path + + location = obj.db_location + if location: + prot['location'] = location.dbref + home = obj.db_home + if home: + prot['home'] = home.dbref + destination = obj.db_destination + if destination: + prot['destination'] = destination.dbref + locks = obj.locks.all() + if locks: + prot['locks'] = ";".join(locks) + perms = obj.permissions.get() + if perms: + prot['permissions'] = make_iter(perms) + aliases = obj.aliases.get() + if aliases: + prot['aliases'] = aliases + tags = [(tag.db_key, tag.db_category, tag.db_data) + for tag in obj.tags.get(return_tagobj=True, return_list=True) if tag] + if tags: + prot['tags'] = tags + attrs = [(attr.key, attr.value, attr.category, attr.locks.all()) + for attr in obj.attributes.get(return_obj=True, return_list=True) if attr] + if attrs: + prot['attrs'] = attrs + + return prot + + +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", ...} + other_prototype (dict): The prototype for the given object. The diff is a how to convert + this prototype into the new prototype. + + """ + prot1 = prototype + prot2 = prototype_from_object(obj) + + diff = {} + for key, value in prot1.items(): + diff[key] = "KEEP" + if key in prot2: + if callable(prot2[key]) or value != prot2[key]: + if key in ('attrs', 'tags', 'permissions', 'locks', 'aliases'): + diff[key] = 'REPLACE' + else: + diff[key] = "UPDATE" + elif key not in prot2: + diff[key] = "UPDATE" + for key in prot2: + if key not in diff and key not in prot1: + diff[key] = "REMOVE" + + return diff, prot2 + + +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. + + """ + if isinstance(prototype, basestring): + new_prototype = protlib.search_prototype(prototype) + else: + new_prototype = prototype + + prototype_key = new_prototype['prototype_key'] + + if not objects: + objects = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + + if not objects: + return 0 + + if not diff: + diff, _ = prototype_diff_from_object(new_prototype, objects[0]) + + changed = 0 + for obj in objects: + do_save = False + + old_prot_key = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) + old_prot_key = old_prot_key[0] if old_prot_key else None + if prototype_key != old_prot_key: + obj.tags.clear(category=_PROTOTYPE_TAG_CATEGORY) + obj.tags.add(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + + for key, directive in diff.items(): + if directive in ('UPDATE', 'REPLACE'): + + if key in _PROTOTYPE_META_NAMES: + # prototype meta keys are not stored on-object + continue + + val = new_prototype[key] + do_save = True + + if key == 'key': + obj.db_key = init_spawn_value(val, str) + elif key == 'typeclass': + obj.db_typeclass_path = init_spawn_value(val, str) + elif key == 'location': + obj.db_location = init_spawn_value(val, value_to_obj) + elif key == 'home': + obj.db_home = init_spawn_value(val, value_to_obj) + elif key == 'destination': + obj.db_destination = init_spawn_value(val, value_to_obj) + elif key == 'locks': + if directive == 'REPLACE': + obj.locks.clear() + obj.locks.add(init_spawn_value(val, str)) + elif key == 'permissions': + if directive == 'REPLACE': + obj.permissions.clear() + obj.permissions.batch_add(*init_spawn_value(val, make_iter)) + elif key == 'aliases': + if directive == 'REPLACE': + obj.aliases.clear() + obj.aliases.batch_add(*init_spawn_value(val, make_iter)) + elif key == 'tags': + if directive == 'REPLACE': + obj.tags.clear() + obj.tags.batch_add(*init_spawn_value(val, make_iter)) + elif key == 'attrs': + if directive == 'REPLACE': + obj.attributes.clear() + obj.attributes.batch_add(*init_spawn_value(val, make_iter)) + elif key == 'exec': + # we don't auto-rerun exec statements, it would be huge security risk! + pass + else: + obj.attributes.add(key, init_spawn_value(val, value_to_obj)) + elif directive == 'REMOVE': + do_save = True + if key == 'key': + obj.db_key = '' + elif key == 'typeclass': + # fall back to default + obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS + elif key == 'location': + obj.db_location = None + elif key == 'home': + obj.db_home = None + elif key == 'destination': + obj.db_destination = None + elif key == 'locks': + obj.locks.clear() + elif key == 'permissions': + obj.permissions.clear() + elif key == 'aliases': + obj.aliases.clear() + elif key == 'tags': + obj.tags.clear() + elif key == 'attrs': + obj.attributes.clear() + elif key == 'exec': + # we don't auto-rerun exec statements, it would be huge security risk! + pass + else: + obj.attributes.remove(key) + if do_save: + changed += 1 + obj.save() + + return changed + + +def batch_create_object(*objparams): + """ + This is a cut-down version of the create_object() function, + optimized for speed. It does NOT check and convert various input + so make sure the spawned Typeclass works before using this! + + Args: + objsparams (tuple): Each paremter tuple will create one object instance using the parameters + within. + The parameters should be given in the following order: + - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`. + - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`. + - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`. + - `aliases` (list): A list of alias strings for + adding with `new_object.aliases.batch_add(*aliases)`. + - `nattributes` (list): list of tuples `(key, value)` to be loop-added to + add with `new_obj.nattributes.add(*tuple)`. + - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for + adding with `new_obj.attributes.batch_add(*attributes)`. + - `tags` (list): list of tuples `(key, category)` for adding + with `new_obj.tags.batch_add(*tags)`. + - `execs` (list): Code strings to execute together with the creation + of each object. They will be executed with `evennia` and `obj` + (the newly created object) available in the namespace. Execution + will happend after all other properties have been assigned and + is intended for calling custom handlers etc. + + Returns: + objects (list): A list of created objects + + Notes: + The `exec` list will execute arbitrary python code so don't allow this to be available to + unprivileged users! + + """ + + # bulk create all objects in one go + + # unfortunately this doesn't work since bulk_create doesn't creates pks; + # the result would be duplicate objects at the next stage, so we comment + # it out for now: + # dbobjs = _ObjectDB.objects.bulk_create(dbobjs) + + dbobjs = [ObjectDB(**objparam[0]) for objparam in objparams] + objs = [] + for iobj, obj in enumerate(dbobjs): + # call all setup hooks on each object + objparam = objparams[iobj] + # setup + obj._createdict = {"permissions": make_iter(objparam[1]), + "locks": objparam[2], + "aliases": make_iter(objparam[3]), + "nattributes": objparam[4], + "attributes": objparam[5], + "tags": make_iter(objparam[6])} + # this triggers all hooks + obj.save() + # run eventual extra code + for code in objparam[7]: + if code: + exec(code, {}, {"evennia": evennia, "obj": obj}) + objs.append(obj) + return objs + + +# Spawner mechanism + +def spawn(*prototypes, **kwargs): + """ + 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_parents (bool): Only return a dict of the + prototype-parents (no object creation happens) + only_validate (bool): Only run validation of prototype/parents + (no object creation) and return the create-kwargs. + + Returns: + object (Object, dict or list): Spawned object. If `only_validate` is given, return + a list of the creation kwargs to build the object(s) without actually creating it. If + `return_parents` is set, return dict of prototype parents. + + """ + # get available protparents + protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()} + + # overload module's protparents with specifically given protparents + # we allow prototype_key to be the key of the protparent dict, to allow for module-level + # prototype imports. We need to insert prototype_key in this case + for key, protparent in kwargs.get("prototype_parents", {}).items(): + key = str(key).lower() + protparent['prototype_key'] = str(protparent.get("prototype_key", key)).lower() + protparents[key] = protparent + + if "return_parents" in kwargs: + # only return the parents + return copy.deepcopy(protparents) + + objsparams = [] + for prototype in prototypes: + + protlib.validate_prototype(prototype, None, protparents, is_prototype_base=True) + 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"] = init_spawn_value(val, str) + + val = prot.pop("location", None) + create_kwargs["db_location"] = init_spawn_value(val, value_to_obj) + + val = prot.pop("home", settings.DEFAULT_HOME) + create_kwargs["db_home"] = init_spawn_value(val, value_to_obj) + + val = prot.pop("destination", None) + create_kwargs["db_destination"] = init_spawn_value(val, value_to_obj) + + val = prot.pop("typeclass", settings.BASE_OBJECT_TYPECLASS) + create_kwargs["db_typeclass_path"] = init_spawn_value(val, str) + + # extract calls to handlers + val = prot.pop("permissions", []) + permission_string = init_spawn_value(val, make_iter) + val = prot.pop("locks", "") + lock_string = init_spawn_value(val, str) + val = prot.pop("aliases", []) + alias_string = init_spawn_value(val, make_iter) + + val = prot.pop("tags", []) + tags = [] + for (tag, category, data) in tags: + tags.append((init_spawn_value(val, str), category, data)) + + prototype_key = prototype.get('prototype_key', None) + if prototype_key: + # 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 = init_spawn_value(val, make_iter) + + # extract ndb assignments + nattributes = dict((key.split("_", 1)[1], init_spawn_value(val, value_to_obj)) + for key, val in prot.items() if key.startswith("ndb_")) + + # the rest are attribute tuples (attrname, value, category, locks) + val = make_iter(prot.pop("attrs", [])) + attributes = [] + for (attrname, value, category, locks) in val: + attributes.append((attrname, init_spawn_value(val), category, locks)) + + simple_attributes = [] + for key, value in ((key, value) for key, value in prot.items() + if not (key.startswith("ndb_"))): + if key in _PROTOTYPE_META_NAMES: + continue + + if is_iter(value) and len(value) > 1: + # (value, category) + simple_attributes.append((key, + init_spawn_value(value[0], value_to_obj_or_any), + init_spawn_value(value[1], str))) + else: + simple_attributes.append((key, + init_spawn_value(value, value_to_obj_or_any))) + + 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)) + + if kwargs.get("only_validate"): + return objsparams + return batch_create_object(*objsparams) diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py new file mode 100644 index 0000000000..1c77fd85c3 --- /dev/null +++ b/evennia/prototypes/tests.py @@ -0,0 +1,531 @@ +""" +Unit tests for the prototypes and spawner + +""" + +from random import randint +import mock +from anything import Something +from django.test.utils import override_settings +from evennia.utils.test_resources import EvenniaTest +from evennia.utils.tests.test_evmenu import TestEvMenu +from evennia.prototypes import spawner, prototypes as protlib +from evennia.prototypes import menus as olc_menus + +from evennia.prototypes.prototypes import _PROTOTYPE_TAG_META_CATEGORY + +_PROTPARENTS = { + "NOBODY": {}, + "GOBLIN": { + "prototype_key": "GOBLIN", + "typeclass": "evennia.objects.objects.DefaultObject", + "key": "goblin grunt", + "health": lambda: randint(1, 1), + "resists": ["cold", "poison"], + "attacks": ["fists"], + "weaknesses": ["fire", "light"] + }, + "GOBLIN_WIZARD": { + "prototype_parent": "GOBLIN", + "key": "goblin wizard", + "spells": ["fire ball", "lighting bolt"] + }, + "GOBLIN_ARCHER": { + "prototype_parent": "GOBLIN", + "key": "goblin archer", + "attacks": ["short bow"] + }, + "ARCHWIZARD": { + "prototype_parent": "GOBLIN", + "attacks": ["archwizard staff"], + }, + "GOBLIN_ARCHWIZARD": { + "key": "goblin archwizard", + "prototype_parent": ("GOBLIN_WIZARD", "ARCHWIZARD") + } +} + + +class TestSpawner(EvenniaTest): + + def setUp(self): + super(TestSpawner, self).setUp() + self.prot1 = {"prototype_key": "testprototype", + "typeclass": "evennia.objects.objects.DefaultObject"} + + def test_spawn(self): + obj1 = spawner.spawn(self.prot1) + # check spawned objects have the right tag + self.assertEqual(list(protlib.search_objects_with_prototype("testprototype")), obj1) + self.assertEqual([o.key for o in spawner.spawn( + _PROTPARENTS["GOBLIN"], _PROTPARENTS["GOBLIN_ARCHWIZARD"], + prototype_parents=_PROTPARENTS)], ['goblin grunt', 'goblin archwizard']) + + +class TestUtils(EvenniaTest): + + def test_prototype_from_object(self): + self.maxDiff = None + self.obj1.attributes.add("test", "testval") + self.obj1.tags.add('foo') + new_prot = spawner.prototype_from_object(self.obj1) + self.assertEqual( + {'attrs': [('test', 'testval', None, [''])], + 'home': Something, + 'key': 'Obj', + 'location': Something, + 'locks': ";".join([ + 'call:true()', + 'control:perm(Developer)', + 'delete:perm(Admin)', + 'edit:perm(Admin)', + 'examine:perm(Builder)', + 'get:all()', + 'puppet:pperm(Developer)', + 'tell:perm(Admin)', + 'view:all()']), + 'prototype_desc': 'Built from Obj', + 'prototype_key': Something, + 'prototype_locks': 'spawn:all();edit:all()', + 'prototype_tags': [], + 'tags': [(u'foo', None, None)], + 'typeclass': 'evennia.objects.objects.DefaultObject'}, new_prot) + + def test_update_objects_from_prototypes(self): + + self.maxDiff = None + self.obj1.attributes.add('oldtest', 'to_remove') + + old_prot = spawner.prototype_from_object(self.obj1) + + # modify object away from prototype + self.obj1.attributes.add('test', 'testval') + self.obj1.aliases.add('foo') + self.obj1.key = 'NewObj' + + # modify prototype + old_prot['new'] = 'new_val' + old_prot['test'] = 'testval_changed' + old_prot['permissions'] = 'Builder' + # this will not update, since we don't update the prototype on-disk + old_prot['prototype_desc'] = 'New version of prototype' + + # diff obj/prototype + pdiff = spawner.prototype_diff_from_object(old_prot, self.obj1) + + self.assertEqual( + pdiff, + ({'aliases': 'REMOVE', + 'attrs': 'REPLACE', + 'home': 'KEEP', + 'key': 'UPDATE', + 'location': 'KEEP', + 'locks': 'KEEP', + 'new': 'UPDATE', + 'permissions': 'UPDATE', + 'prototype_desc': 'UPDATE', + 'prototype_key': 'UPDATE', + 'prototype_locks': 'KEEP', + 'prototype_tags': 'KEEP', + 'test': 'UPDATE', + 'typeclass': 'KEEP'}, + {'attrs': [('oldtest', 'to_remove', None, ['']), + ('test', 'testval', None, [''])], + 'prototype_locks': 'spawn:all();edit:all()', + 'prototype_key': Something, + 'locks': ";".join([ + 'call:true()', 'control:perm(Developer)', + 'delete:perm(Admin)', 'edit:perm(Admin)', + 'examine:perm(Builder)', 'get:all()', + 'puppet:pperm(Developer)', 'tell:perm(Admin)', + 'view:all()']), + 'prototype_tags': [], + 'location': "#1", + 'key': 'NewObj', + 'home': '#1', + 'typeclass': 'evennia.objects.objects.DefaultObject', + 'prototype_desc': 'Built from NewObj', + 'aliases': 'foo'}) + ) + + # apply diff + count = spawner.batch_update_objects_with_prototype( + old_prot, diff=pdiff[0], objects=[self.obj1]) + self.assertEqual(count, 1) + + new_prot = spawner.prototype_from_object(self.obj1) + self.assertEqual({'attrs': [('test', 'testval_changed', None, ['']), + ('new', 'new_val', None, [''])], + 'home': Something, + 'key': 'Obj', + 'location': Something, + 'locks': ";".join([ + 'call:true()', + 'control:perm(Developer)', + 'delete:perm(Admin)', + 'edit:perm(Admin)', + 'examine:perm(Builder)', + 'get:all()', + 'puppet:pperm(Developer)', + 'tell:perm(Admin)', + 'view:all()']), + 'permissions': ['builder'], + 'prototype_desc': 'Built from Obj', + 'prototype_key': Something, + 'prototype_locks': 'spawn:all();edit:all()', + 'prototype_tags': [], + 'typeclass': 'evennia.objects.objects.DefaultObject'}, + new_prot) + + +class TestProtLib(EvenniaTest): + + def setUp(self): + super(TestProtLib, self).setUp() + self.obj1.attributes.add("testattr", "testval") + self.prot = spawner.prototype_from_object(self.obj1) + + def test_prototype_to_str(self): + prstr = protlib.prototype_to_str(self.prot) + self.assertTrue(prstr.startswith("|cprototype-key:|n")) + + def test_check_permission(self): + pass + + +@override_settings(PROT_FUNC_MODULES=['evennia.prototypes.protfuncs'], CLIENT_DEFAULT_WIDTH=20) +class TestProtFuncs(EvenniaTest): + + def setUp(self): + super(TestProtFuncs, self).setUp() + self.prot = {"prototype_key": "test_prototype", + "prototype_desc": "testing prot", + "key": "ExampleObj"} + + @mock.patch("evennia.prototypes.protfuncs.base_random", new=mock.MagicMock(return_value=0.5)) + @mock.patch("evennia.prototypes.protfuncs.base_randint", new=mock.MagicMock(return_value=5)) + def test_protfuncs(self): + self.assertEqual(protlib.protfunc_parser("$random()"), 0.5) + self.assertEqual(protlib.protfunc_parser("$randint(1, 10)"), 5) + self.assertEqual(protlib.protfunc_parser("$left_justify( foo )"), "foo ") + self.assertEqual(protlib.protfunc_parser("$right_justify( foo )"), " foo") + self.assertEqual(protlib.protfunc_parser("$center_justify(foo )"), " foo ") + self.assertEqual(protlib.protfunc_parser( + "$full_justify(foo bar moo too)"), 'foo bar moo too') + self.assertEqual( + protlib.protfunc_parser("$right_justify( foo )", testing=True), + ('unexpected indent (, line 1)', ' foo')) + + test_prot = {"key1": "value1", + "key2": 2} + + self.assertEqual(protlib.protfunc_parser( + "$protkey(key1)", testing=True, prototype=test_prot), (None, "value1")) + self.assertEqual(protlib.protfunc_parser( + "$protkey(key2)", testing=True, prototype=test_prot), (None, 2)) + + self.assertEqual(protlib.protfunc_parser("$add(1, 2)"), 3) + self.assertEqual(protlib.protfunc_parser("$add(10, 25)"), 35) + self.assertEqual(protlib.protfunc_parser( + "$add('''[1,2,3]''', '''[4,5,6]''')"), [1, 2, 3, 4, 5, 6]) + self.assertEqual(protlib.protfunc_parser("$add(foo, bar)"), "foo bar") + + self.assertEqual(protlib.protfunc_parser("$sub(5, 2)"), 3) + self.assertRaises(TypeError, protlib.protfunc_parser, "$sub(5, test)") + + self.assertEqual(protlib.protfunc_parser("$mult(5, 2)"), 10) + self.assertEqual(protlib.protfunc_parser("$mult( 5 , 10)"), 50) + self.assertEqual(protlib.protfunc_parser("$mult('foo',3)"), "foofoofoo") + self.assertEqual(protlib.protfunc_parser("$mult(foo,3)"), "foofoofoo") + self.assertRaises(TypeError, protlib.protfunc_parser, "$mult(foo, foo)") + + self.assertEqual(protlib.protfunc_parser("$toint(5.3)"), 5) + + self.assertEqual(protlib.protfunc_parser("$div(5, 2)"), 2.5) + self.assertEqual(protlib.protfunc_parser("$toint($div(5, 2))"), 2) + self.assertEqual(protlib.protfunc_parser("$sub($add(5, 3), $add(10, 2))"), -4) + + self.assertEqual(protlib.protfunc_parser("$eval('2')"), '2') + + self.assertEqual(protlib.protfunc_parser( + "$eval(['test', 1, '2', 3.5, \"foo\"])"), ['test', 1, '2', 3.5, 'foo']) + self.assertEqual(protlib.protfunc_parser( + "$eval({'test': '1', 2:3, 3: $toint(3.5)})"), {'test': '1', 2: 3, 3: 3}) + + self.assertEqual(protlib.protfunc_parser("$obj(#1)", session=self.session), '#1') + self.assertEqual(protlib.protfunc_parser("#1", session=self.session), '#1') + self.assertEqual(protlib.protfunc_parser("$obj(Char)", session=self.session), '#6') + self.assertEqual(protlib.protfunc_parser("$obj(Char)", session=self.session), '#6') + self.assertEqual(protlib.protfunc_parser("$objlist(#1)", session=self.session), ['#1']) + + self.assertEqual(protlib.value_to_obj( + protlib.protfunc_parser("#6", session=self.session)), self.char1) + self.assertEqual(protlib.value_to_obj_or_any( + protlib.protfunc_parser("#6", session=self.session)), self.char1) + self.assertEqual(protlib.value_to_obj_or_any( + protlib.protfunc_parser("[1,2,3,'#6',5]", session=self.session)), + [1, 2, 3, self.char1, 5]) + + +class TestPrototypeStorage(EvenniaTest): + + def setUp(self): + super(TestPrototypeStorage, self).setUp() + self.maxDiff = None + + self.prot1 = spawner.prototype_from_object(self.obj1) + self.prot1['prototype_key'] = 'testprototype1' + self.prot1['prototype_desc'] = 'testdesc1' + self.prot1['prototype_tags'] = [('foo1', _PROTOTYPE_TAG_META_CATEGORY)] + + self.prot2 = self.prot1.copy() + self.prot2['prototype_key'] = 'testprototype2' + self.prot2['prototype_desc'] = 'testdesc2' + self.prot2['prototype_tags'] = [('foo1', _PROTOTYPE_TAG_META_CATEGORY)] + + self.prot3 = self.prot2.copy() + self.prot3['prototype_key'] = 'testprototype3' + self.prot3['prototype_desc'] = 'testdesc3' + self.prot3['prototype_tags'] = [('foo1', _PROTOTYPE_TAG_META_CATEGORY)] + + def test_prototype_storage(self): + + prot1 = protlib.create_prototype(**self.prot1) + + self.assertTrue(bool(prot1)) + self.assertEqual(prot1, self.prot1) + + self.assertEqual(prot1['prototype_desc'], "testdesc1") + + self.assertEqual(prot1['prototype_tags'], [("foo1", _PROTOTYPE_TAG_META_CATEGORY)]) + self.assertEqual( + protlib.DbPrototype.objects.get_by_tag( + "foo1", _PROTOTYPE_TAG_META_CATEGORY)[0].db.prototype, prot1) + + prot2 = protlib.create_prototype(**self.prot2) + self.assertEqual( + [pobj.db.prototype + for pobj in protlib.DbPrototype.objects.get_by_tag( + "foo1", _PROTOTYPE_TAG_META_CATEGORY)], + [prot1, prot2]) + + # add to existing prototype + prot1b = protlib.create_prototype( + prototype_key='testprototype1', foo='bar', prototype_tags=['foo2']) + + self.assertEqual( + [pobj.db.prototype + for pobj in protlib.DbPrototype.objects.get_by_tag( + "foo2", _PROTOTYPE_TAG_META_CATEGORY)], + [prot1b]) + + self.assertEqual(list(protlib.search_prototype("testprototype2")), [prot2]) + self.assertNotEqual(list(protlib.search_prototype("testprototype1")), [prot1]) + self.assertEqual(list(protlib.search_prototype("testprototype1")), [prot1b]) + + prot3 = protlib.create_prototype(**self.prot3) + + # partial match + self.assertEqual(list(protlib.search_prototype("prot")), [prot1b, prot2, prot3]) + self.assertEqual(list(protlib.search_prototype(tags="foo1")), [prot1b, prot2, prot3]) + + self.assertTrue(str(unicode(protlib.list_prototypes(self.char1)))) + + +class _MockMenu(object): + pass + + +class TestMenuModule(EvenniaTest): + + def setUp(self): + super(TestMenuModule, self).setUp() + + # set up fake store + self.caller = self.char1 + menutree = _MockMenu() + self.caller.ndb._menutree = menutree + + self.test_prot = {"prototype_key": "test_prot", + "typeclass": "evennia.objects.objects.DefaultObject", + "prototype_locks": "edit:all();spawn:all()"} + + def test_helpers(self): + + caller = self.caller + + # general helpers + + self.assertEqual(olc_menus._get_menu_prototype(caller), {}) + self.assertEqual(olc_menus._is_new_prototype(caller), True) + + self.assertEqual(olc_menus._set_menu_prototype(caller, {}), {}) + + self.assertEqual( + olc_menus._set_prototype_value(caller, "key", "TestKey"), {"key": "TestKey"}) + self.assertEqual(olc_menus._get_menu_prototype(caller), {"key": "TestKey"}) + + self.assertEqual(olc_menus._format_option_value( + "key", required=True, prototype=olc_menus._get_menu_prototype(caller)), " (TestKey|n)") + self.assertEqual(olc_menus._format_option_value( + [1, 2, 3, "foo"], required=True), ' (1, 2, 3, foo|n)') + + self.assertEqual(olc_menus._set_property( + caller, "ChangedKey", prop="key", processor=str, next_node="foo"), "foo") + self.assertEqual(olc_menus._get_menu_prototype(caller), {"key": "ChangedKey"}) + + self.assertEqual(olc_menus._wizard_options( + "ThisNode", "PrevNode", "NextNode"), + [{'goto': 'node_PrevNode', 'key': ('|wB|Wack', 'b'), 'desc': '|W(PrevNode)|n'}, + {'goto': 'node_NextNode', 'key': ('|wF|Worward', 'f'), 'desc': '|W(NextNode)|n'}, + {'goto': 'node_index', 'key': ('|wI|Wndex', 'i')}, + {'goto': ('node_validate_prototype', {'back': 'ThisNode'}), + 'key': ('|wV|Walidate prototype', 'validate', 'v')}]) + + self.assertEqual(olc_menus._validate_prototype(self.test_prot), (False, Something)) + self.assertEqual(olc_menus._validate_prototype( + {"prototype_key": "testthing", "key": "mytest"}), + (True, Something)) + + choices = ["test1", "test2", "test3", "test4"] + actions = (("examine", "e", "l"), ("add", "a"), ("foo", "f")) + self.assertEqual(olc_menus._default_parse("l4", choices, *actions), ('test4', 'examine')) + self.assertEqual(olc_menus._default_parse("add 2", choices, *actions), ('test2', 'add')) + self.assertEqual(olc_menus._default_parse("foo3", choices, *actions), ('test3', 'foo')) + self.assertEqual(olc_menus._default_parse("f3", choices, *actions), ('test3', 'foo')) + self.assertEqual(olc_menus._default_parse("f5", choices, *actions), (None, None)) + + def test_node_helpers(self): + + caller = self.caller + + with mock.patch("evennia.prototypes.menus.protlib.search_prototype", + new=mock.MagicMock(return_value=[self.test_prot])): + # prototype_key helpers + self.assertEqual(olc_menus._check_prototype_key(caller, "test_prot"), None) + caller.ndb._menutree.olc_new = True + self.assertEqual(olc_menus._check_prototype_key(caller, "test_prot"), "node_index") + + # prototype_parent helpers + self.assertEqual(olc_menus._all_prototype_parents(caller), ['test_prot']) + # self.assertEqual(olc_menus._prototype_parent_parse( + # caller, 'test_prot'), + # "|cprototype key:|n test_prot, |ctags:|n None, |clocks:|n edit:all();spawn:all() " + # "\n|cdesc:|n None \n|cprototype:|n " + # "{\n 'typeclass': 'evennia.objects.objects.DefaultObject', \n}") + + with mock.patch("evennia.prototypes.menus.protlib.search_prototype", + new=mock.MagicMock(return_value=[_PROTPARENTS['GOBLIN']])): + self.assertEqual(olc_menus._prototype_parent_select(caller, "goblin"), "node_prototype_parent") + + self.assertEqual(olc_menus._get_menu_prototype(caller), + {'prototype_key': 'test_prot', + 'prototype_locks': 'edit:all();spawn:all()', + 'prototype_parent': 'goblin', + 'typeclass': 'evennia.objects.objects.DefaultObject'}) + + # typeclass helpers + with mock.patch("evennia.utils.utils.get_all_typeclasses", + new=mock.MagicMock(return_value={"foo": None, "bar": None})): + self.assertEqual(olc_menus._all_typeclasses(caller), ["bar", "foo"]) + + self.assertEqual(olc_menus._typeclass_select( + caller, "evennia.objects.objects.DefaultObject"), None) + # prototype_parent should be popped off here + self.assertEqual(olc_menus._get_menu_prototype(caller), + {'prototype_key': 'test_prot', + 'prototype_locks': 'edit:all();spawn:all()', + 'prototype_parent': 'goblin', + 'typeclass': 'evennia.objects.objects.DefaultObject'}) + + # attr helpers + self.assertEqual(olc_menus._caller_attrs(caller), []) + self.assertEqual(olc_menus._add_attr(caller, "test1=foo1"), Something) + self.assertEqual(olc_menus._add_attr(caller, "test2;cat1=foo2"), Something) + self.assertEqual(olc_menus._add_attr(caller, "test3;cat2;edit:false()=foo3"), Something) + self.assertEqual(olc_menus._add_attr(caller, "test4;cat3;set:true();edit:false()=foo4"), Something) + self.assertEqual(olc_menus._add_attr(caller, "test5;cat4;set:true();edit:false()=123"), Something) + self.assertEqual(olc_menus._add_attr(caller, "test1=foo1_changed"), Something) + self.assertEqual(olc_menus._get_menu_prototype(caller)['attrs'], + [("test1", "foo1_changed", None, ''), + ("test2", "foo2", "cat1", ''), + ("test3", "foo3", "cat2", "edit:false()"), + ("test4", "foo4", "cat3", "set:true();edit:false()"), + ("test5", '123', "cat4", "set:true();edit:false()")]) + + # tag helpers + self.assertEqual(olc_menus._caller_tags(caller), []) + self.assertEqual(olc_menus._add_tag(caller, "foo1"), Something) + self.assertEqual(olc_menus._add_tag(caller, "foo2;cat1"), Something) + self.assertEqual(olc_menus._add_tag(caller, "foo3;cat2;dat1"), Something) + self.assertEqual(olc_menus._caller_tags(caller), ['foo1', 'foo2', 'foo3']) + self.assertEqual(olc_menus._get_menu_prototype(caller)['tags'], + [('foo1', None, ""), + ('foo2', 'cat1', ""), + ('foo3', 'cat2', "dat1")]) + self.assertEqual(olc_menus._add_tag(caller, "foo1", delete=True), "Removed Tag 'foo1'.") + self.assertEqual(olc_menus._get_menu_prototype(caller)['tags'], + [('foo2', 'cat1', ""), + ('foo3', 'cat2', "dat1")]) + + self.assertEqual(olc_menus._display_tag(olc_menus._get_menu_prototype(caller)['tags'][0]), Something) + self.assertEqual(olc_menus._caller_tags(caller), ["foo2", "foo3"]) + + protlib.save_prototype(**self.test_prot) + + # locks helpers + self.assertEqual(olc_menus._lock_add(caller, "foo:false()"), "Added lock 'foo:false()'.") + self.assertEqual(olc_menus._lock_add(caller, "foo2:false()"), "Added lock 'foo2:false()'.") + self.assertEqual(olc_menus._lock_add(caller, "foo2:true()"), "Lock with locktype 'foo2' updated.") + self.assertEqual(olc_menus._get_menu_prototype(caller)["locks"], "foo:false();foo2:true()") + + # perm helpers + self.assertEqual(olc_menus._add_perm(caller, "foo"), "Added Permission 'foo'") + self.assertEqual(olc_menus._add_perm(caller, "foo2"), "Added Permission 'foo2'") + self.assertEqual(olc_menus._get_menu_prototype(caller)["permissions"], ["foo", "foo2"]) + + # prototype_tags helpers + self.assertEqual(olc_menus._add_prototype_tag(caller, "foo"), "Added Prototype-Tag 'foo'.") + self.assertEqual(olc_menus._add_prototype_tag(caller, "foo2"), "Added Prototype-Tag 'foo2'.") + self.assertEqual(olc_menus._get_menu_prototype(caller)["prototype_tags"], ["foo", "foo2"]) + + # spawn helpers + with mock.patch("evennia.prototypes.menus.protlib.search_prototype", + new=mock.MagicMock(return_value=[_PROTPARENTS['GOBLIN']])): + self.assertEqual(olc_menus._spawn(caller, prototype=self.test_prot), Something) + obj = caller.contents[0] + + self.assertEqual(obj.typeclass_path, "evennia.objects.objects.DefaultObject") + self.assertEqual(obj.tags.get(category=spawner._PROTOTYPE_TAG_CATEGORY), self.test_prot['prototype_key']) + + # update helpers + self.assertEqual(olc_menus._apply_diff( + caller, prototype=self.test_prot, back_node="foo", objects=[obj]), 'foo') # no changes to apply + self.test_prot['key'] = "updated key" # change prototype + self.assertEqual(olc_menus._apply_diff( + caller, prototype=self.test_prot, objects=[obj], back_node='foo'), 'foo') # apply change to the one obj + + # load helpers + self.assertEqual(olc_menus._prototype_load_select(caller, self.test_prot['prototype_key']), + ('node_examine_entity', {'text': '|gLoaded prototype test_prot.|n', 'back': 'index'}) ) + + +@mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock( + return_value=[{"prototype_key": "TestPrototype", + "typeclass": "TypeClassTest", "key": "TestObj"}])) +@mock.patch("evennia.utils.utils.get_all_typeclasses", new=mock.MagicMock( + return_value={"TypeclassTest": None})) +class TestOLCMenu(TestEvMenu): + + maxDiff = None + menutree = "evennia.prototypes.menus" + startnode = "node_index" + + # debug_output = True + expect_all_nodes = True + + expected_node_texts = { + "node_index": "|c --- Prototype wizard --- |n" + } + + expected_tree = ['node_index', ['node_prototype_key', ['node_index', 'node_index', 'node_validate_prototype', ['node_index', 'node_index'], 'node_index'], 'node_prototype_parent', ['node_prototype_parent', 'node_prototype_key', 'node_prototype_parent', 'node_index', 'node_validate_prototype', 'node_index'], 'node_typeclass', ['node_typeclass', 'node_prototype_parent', 'node_typeclass', 'node_index', 'node_validate_prototype', 'node_index'], 'node_key', ['node_typeclass', 'node_key', 'node_index', 'node_validate_prototype', 'node_index'], 'node_aliases', ['node_key', 'node_aliases', 'node_index', 'node_validate_prototype', 'node_index'], 'node_attrs', ['node_aliases', 'node_attrs', 'node_index', 'node_validate_prototype', 'node_index'], 'node_tags', ['node_attrs', 'node_tags', 'node_index', 'node_validate_prototype', 'node_index'], 'node_locks', ['node_tags', 'node_locks', 'node_index', 'node_validate_prototype', 'node_index'], 'node_permissions', ['node_locks', 'node_permissions', 'node_index', 'node_validate_prototype', 'node_index'], 'node_location', ['node_permissions', 'node_location', 'node_index', 'node_validate_prototype', 'node_index', 'node_index'], 'node_home', ['node_location', 'node_home', 'node_index', 'node_validate_prototype', 'node_index', 'node_index'], 'node_destination', ['node_home', 'node_destination', 'node_index', 'node_validate_prototype', 'node_index', 'node_index'], 'node_prototype_desc', ['node_prototype_key', 'node_prototype_parent', 'node_index', 'node_validate_prototype', 'node_index'], 'node_prototype_tags', ['node_prototype_desc', 'node_prototype_tags', 'node_index', 'node_validate_prototype', 'node_index'], 'node_prototype_locks', ['node_examine_entity', ['node_prototype_locks', 'node_prototype_locks', 'node_prototype_locks'], 'node_examine_entity', 'node_prototype_locks', 'node_index', 'node_validate_prototype', 'node_index'], 'node_validate_prototype', 'node_index', 'node_prototype_spawn', ['node_index', 'node_validate_prototype'], 'node_index', 'node_search_object', ['node_index', 'node_index']]] diff --git a/evennia/scripts/scripts.py b/evennia/scripts/scripts.py index 2b4bea3569..8bff161cf5 100644 --- a/evennia/scripts/scripts.py +++ b/evennia/scripts/scripts.py @@ -152,15 +152,6 @@ class ScriptBase(with_metaclass(TypeclassBase, ScriptDB)): """ objects = ScriptManager() - -class DefaultScript(ScriptBase): - """ - This is the base TypeClass for all Scripts. Scripts describe - events, timers and states in game, they can have a time component - or describe a state that changes under certain conditions. - - """ - def __eq__(self, other): """ Compares two Scripts. Compares dbids. @@ -250,7 +241,96 @@ class DefaultScript(ScriptBase): logger.log_trace() return None - # Public methods + def at_script_creation(self): + """ + Should be overridden in child. + + """ + pass + + def at_first_save(self, **kwargs): + """ + This is called after very first time this object is saved. + Generally, you don't need to overload this, but only the hooks + called by this method. + + Args: + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + self.at_script_creation() + + if hasattr(self, "_createdict"): + # this will only be set if the utils.create_script + # function was used to create the object. We want + # the create call's kwargs to override the values + # set by hooks. + cdict = self._createdict + updates = [] + if not cdict.get("key"): + if not self.db_key: + self.db_key = "#%i" % self.dbid + updates.append("db_key") + elif self.db_key != cdict["key"]: + self.db_key = cdict["key"] + updates.append("db_key") + if cdict.get("interval") and self.interval != cdict["interval"]: + self.db_interval = cdict["interval"] + updates.append("db_interval") + if cdict.get("start_delay") and self.start_delay != cdict["start_delay"]: + self.db_start_delay = cdict["start_delay"] + updates.append("db_start_delay") + if cdict.get("repeats") and self.repeats != cdict["repeats"]: + self.db_repeats = cdict["repeats"] + updates.append("db_repeats") + if cdict.get("persistent") and self.persistent != cdict["persistent"]: + self.db_persistent = cdict["persistent"] + updates.append("db_persistent") + if cdict.get("desc") and self.desc != cdict["desc"]: + self.db_desc = cdict["desc"] + updates.append("db_desc") + if updates: + self.save(update_fields=updates) + + if cdict.get("permissions"): + self.permissions.batch_add(*cdict["permissions"]) + if cdict.get("locks"): + self.locks.add(cdict["locks"]) + if cdict.get("tags"): + # this should be a list of tags, tuples (key, category) or (key, category, data) + self.tags.batch_add(*cdict["tags"]) + if cdict.get("attributes"): + # this should be tuples (key, val, ...) + self.attributes.batch_add(*cdict["attributes"]) + if cdict.get("nattributes"): + # this should be a dict of nattrname:value + for key, value in cdict["nattributes"]: + self.nattributes.add(key, value) + + if not cdict.get("autostart"): + # don't auto-start the script + return + + # auto-start script (default) + self.start() + + +class DefaultScript(ScriptBase): + """ + This is the base TypeClass for all Scripts. Scripts describe + events, timers and states in game, they can have a time component + or describe a state that changes under certain conditions. + + """ + + def at_script_creation(self): + """ + Only called once, when script is first created. + + """ + pass + def time_until_next_repeat(self): """ @@ -514,61 +594,6 @@ class DefaultScript(ScriptBase): if task: task.force_repeat() - def at_first_save(self, **kwargs): - """ - This is called after very first time this object is saved. - Generally, you don't need to overload this, but only the hooks - called by this method. - - Args: - **kwargs (dict): Arbitrary, optional arguments for users - overriding the call (unused by default). - - """ - self.at_script_creation() - - if hasattr(self, "_createdict"): - # this will only be set if the utils.create_script - # function was used to create the object. We want - # the create call's kwargs to override the values - # set by hooks. - cdict = self._createdict - updates = [] - if not cdict.get("key"): - if not self.db_key: - self.db_key = "#%i" % self.dbid - updates.append("db_key") - elif self.db_key != cdict["key"]: - self.db_key = cdict["key"] - updates.append("db_key") - if cdict.get("interval") and self.interval != cdict["interval"]: - self.db_interval = cdict["interval"] - updates.append("db_interval") - if cdict.get("start_delay") and self.start_delay != cdict["start_delay"]: - self.db_start_delay = cdict["start_delay"] - updates.append("db_start_delay") - if cdict.get("repeats") and self.repeats != cdict["repeats"]: - self.db_repeats = cdict["repeats"] - updates.append("db_repeats") - if cdict.get("persistent") and self.persistent != cdict["persistent"]: - self.db_persistent = cdict["persistent"] - updates.append("db_persistent") - if updates: - self.save(update_fields=updates) - if not cdict.get("autostart"): - # don't auto-start the script - return - - # auto-start script (default) - self.start() - - def at_script_creation(self): - """ - Only called once, by the create function. - - """ - pass - def is_valid(self): """ Is called to check if the script is valid to run at this time. diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index c82b922ca0..2f9206b4c1 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -1037,9 +1037,11 @@ def tail_log_files(filename1, filename2, start_lines1=20, start_lines2=20, rate= new_linecount = sum(blck.count("\n") for blck in _block(filehandle)) if new_linecount < old_linecount: - # this could happen if the file was manually deleted or edited - print("Log file has shrunk. Restart log reader.") - sys.exit() + # this happens if the file was cycled or manually deleted/edited. + print(" ** Log file {filename} has cycled or been edited. " + "Restarting log. ".format(filehandle.name)) + new_linecount = 0 + old_linecount = 0 lines_to_get = max(0, new_linecount - old_linecount) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 4bbcb966f6..9c34a6165a 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -354,6 +354,9 @@ LOCK_FUNC_MODULES = ("evennia.locks.lockfuncs", "server.conf.lockfuncs",) INPUT_FUNC_MODULES = ["evennia.server.inputfuncs", "server.conf.inputfuncs"] # Modules that contain prototypes for use with the spawner mechanism. PROTOTYPE_MODULES = ["world.prototypes"] +# Modules containining Prototype functions able to be embedded in prototype +# definitions from in-game. +PROT_FUNC_MODULES = ["evennia.prototypes.protfuncs"] # Module holding settings/actions for the dummyrunner program (see the # dummyrunner for more information) DUMMYRUNNER_SETTINGS_MODULE = "evennia.server.profiling.dummyrunner_settings" @@ -513,7 +516,7 @@ TIME_GAME_EPOCH = None TIME_IGNORE_DOWNTIMES = False ###################################################################### -# Inlinefunc +# Inlinefunc & PrototypeFuncs ###################################################################### # Evennia supports inline function preprocessing. This allows users # to supply inline calls on the form $func(arg, arg, ...) to do @@ -525,6 +528,10 @@ INLINEFUNC_ENABLED = False # is loaded from left-to-right, same-named functions will overload INLINEFUNC_MODULES = ["evennia.utils.inlinefuncs", "server.conf.inlinefuncs"] +# Module holding handlers for OLCFuncs. These allow for embedding +# functional code in prototypes +PROTOTYPEFUNC_MODULES = ["evennia.utils.prototypefuncs", + "server.conf.prototypefuncs"] ###################################################################### # Default Account setup and access diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index a97a81b1be..eb698e6f0e 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -435,6 +435,7 @@ class AttributeHandler(object): def __init__(self): self.key = None self.value = default + self.category = None self.strvalue = str(default) if default is not None else None ret = [] @@ -530,8 +531,8 @@ class AttributeHandler(object): repeat-calling add when having many Attributes to add. Args: - indata (tuple): Tuples of varying length representing the - Attribute to add to this object. + indata (list): List of tuples of varying length representing the + Attribute to add to this object. Supported tuples are - `(key, value)` - `(key, value, category)` - `(key, value, category, lockstring)` diff --git a/evennia/utils/create.py b/evennia/utils/create.py index 1404e4caaa..36db7e5a60 100644 --- a/evennia/utils/create.py +++ b/evennia/utils/create.py @@ -54,7 +54,8 @@ _GA = object.__getattribute__ def create_object(typeclass=None, key=None, location=None, home=None, permissions=None, locks=None, aliases=None, tags=None, - destination=None, report_to=None, nohome=False): + destination=None, report_to=None, nohome=False, attributes=None, + nattributes=None): """ Create a new in-game object. @@ -68,13 +69,18 @@ def create_object(typeclass=None, key=None, location=None, home=None, permissions (list): A list of permission strings or tuples (permstring, category). locks (str): one or more lockstrings, separated by semicolons. aliases (list): A list of alternative keys or tuples (aliasstring, category). - tags (list): List of tag keys or tuples (tagkey, category). + tags (list): List of tag keys or tuples (tagkey, category) or (tagkey, category, data). destination (Object or str): Obj or #dbref to use as an Exit's target. report_to (Object): The object to return error messages to. nohome (bool): This allows the creation of objects without a default home location; only used when creating the default location itself or during unittests. + attributes (list): Tuples on the form (key, value) or (key, value, category), + (key, value, lockstring) or (key, value, lockstring, default_access). + to set as Attributes on the new object. + nattributes (list): Non-persistent tuples on the form (key, value). Note that + adding this rarely makes sense since this data will not survive a reload. Returns: object (Object): A newly created object of the given typeclass. @@ -95,6 +101,7 @@ def create_object(typeclass=None, key=None, location=None, home=None, locks = make_iter(locks) if locks is not None else None aliases = make_iter(aliases) if aliases is not None else None tags = make_iter(tags) if tags is not None else None + attributes = make_iter(attributes) if attributes is not None else None if isinstance(typeclass, basestring): @@ -122,7 +129,8 @@ def create_object(typeclass=None, key=None, location=None, home=None, # store the call signature for the signal new_object._createdict = dict(key=key, location=location, destination=destination, home=home, typeclass=typeclass.path, permissions=permissions, locks=locks, - aliases=aliases, tags=tags, report_to=report_to, nohome=nohome) + aliases=aliases, tags=tags, report_to=report_to, nohome=nohome, + attributes=attributes, nattributes=nattributes) # this will trigger the save signal which in turn calls the # at_first_save hook on the typeclass, where the _createdict can be # used. @@ -139,7 +147,8 @@ object = create_object def create_script(typeclass=None, key=None, obj=None, account=None, locks=None, interval=None, start_delay=None, repeats=None, - persistent=None, autostart=True, report_to=None, desc=None): + persistent=None, autostart=True, report_to=None, desc=None, + tags=None, attributes=None): """ Create a new script. All scripts are a combination of a database object that communicates with the database, and an typeclass that @@ -169,7 +178,9 @@ def create_script(typeclass=None, key=None, obj=None, account=None, locks=None, created or if the `start` method must be called explicitly. report_to (Object): The object to return error messages to. desc (str): Optional description of script - + tags (list): List of tags or tuples (tag, category). + attributes (list): List if tuples (key, value) or (key, value, category) + (key, value, lockstring) or (key, value, lockstring, default_access). See evennia.scripts.manager for methods to manipulate existing scripts in the database. @@ -190,9 +201,9 @@ def create_script(typeclass=None, key=None, obj=None, account=None, locks=None, if key: kwarg["db_key"] = key if account: - kwarg["db_account"] = dbid_to_obj(account, _ScriptDB) + kwarg["db_account"] = dbid_to_obj(account, _AccountDB) if obj: - kwarg["db_obj"] = dbid_to_obj(obj, _ScriptDB) + kwarg["db_obj"] = dbid_to_obj(obj, _ObjectDB) if interval: kwarg["db_interval"] = interval if start_delay: @@ -203,6 +214,8 @@ def create_script(typeclass=None, key=None, obj=None, account=None, locks=None, kwarg["db_persistent"] = persistent if desc: kwarg["db_desc"] = desc + tags = make_iter(tags) if tags is not None else None + attributes = make_iter(attributes) if attributes is not None else None # create new instance new_script = typeclass(**kwarg) @@ -210,7 +223,8 @@ def create_script(typeclass=None, key=None, obj=None, account=None, locks=None, # store the call signature for the signal new_script._createdict = dict(key=key, obj=obj, account=account, locks=locks, interval=interval, start_delay=start_delay, repeats=repeats, persistent=persistent, - autostart=autostart, report_to=report_to) + autostart=autostart, report_to=report_to, desc=desc, + tags=tags, attributes=attributes) # this will trigger the save signal which in turn calls the # at_first_save hook on the typeclass, where the _createdict # can be used. diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 0de33de348..0297170da2 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -167,14 +167,13 @@ from __future__ import print_function import random from builtins import object, range -from textwrap import dedent from inspect import isfunction, getargspec from django.conf import settings from evennia import Command, CmdSet from evennia.utils import logger from evennia.utils.evtable import EvTable from evennia.utils.ansi import strip_ansi -from evennia.utils.utils import mod_import, make_iter, pad, m_len, is_iter +from evennia.utils.utils import mod_import, make_iter, pad, m_len, is_iter, dedent from evennia.commands import cmdhandler # read from protocol NAWS later? @@ -796,7 +795,7 @@ class EvMenu(object): # handle the helptext if helptext: - self.helptext = helptext + self.helptext = self.helptext_formatter(helptext) elif options: self.helptext = _HELP_FULL if self.auto_quit else _HELP_NO_QUIT else: @@ -896,7 +895,20 @@ class EvMenu(object): nodetext (str): The formatted node text. """ - return dedent(nodetext).strip() + return dedent(nodetext.strip('\n'), baseline_index=0).rstrip() + + def helptext_formatter(self, helptext): + """ + Format the node's help text + + Args: + helptext (str): The unformatted help text for the node. + + Returns: + helptext (str): The formatted help text. + + """ + return dedent(helptext.strip('\n'), baseline_index=0).rstrip() def options_formatter(self, optionlist): """ @@ -1042,7 +1054,10 @@ def list_node(option_generator, select=None, pagesize=10): else: if callable(select): try: - return select(caller, selection) + if bool(getargspec(select).keywords): + return select(caller, selection, available_choices=available_choices) + else: + return select(caller, selection) except Exception: logger.log_trace() elif select: @@ -1101,22 +1116,31 @@ def list_node(option_generator, select=None, pagesize=10): # add data from the decorated node decorated_options = [] + supports_kwargs = bool(getargspec(func).keywords) try: - text, decorated_options = func(caller, raw_string) + if supports_kwargs: + text, decorated_options = func(caller, raw_string, **kwargs) + else: + text, decorated_options = func(caller, raw_string) except TypeError: try: - text, decorated_options = func(caller) + if supports_kwargs: + text, decorated_options = func(caller, **kwargs) + else: + text, decorated_options = func(caller) except Exception: raise except Exception: logger.log_trace() else: - if isinstance(decorated_options, {}): + if isinstance(decorated_options, dict): decorated_options = [decorated_options] else: decorated_options = make_iter(decorated_options) extra_options = [] + if isinstance(decorated_options, dict): + decorated_options = [decorated_options] for eopt in decorated_options: cback = ("goto" in eopt and "goto") or ("exec" in eopt and "exec") or None if cback: diff --git a/evennia/utils/evmore.py b/evennia/utils/evmore.py index 169091396b..94173b9eca 100644 --- a/evennia/utils/evmore.py +++ b/evennia/utils/evmore.py @@ -122,7 +122,8 @@ class EvMore(object): """ def __init__(self, caller, text, always_page=False, session=None, - justify_kwargs=None, exit_on_lastpage=False, **kwargs): + justify_kwargs=None, exit_on_lastpage=False, + exit_cmd=None, **kwargs): """ Initialization of the text handler. @@ -141,6 +142,10 @@ class EvMore(object): page being completely filled, exit pager immediately. If unset, another move forward is required to exit. If set, the pager exit message will not be shown. + exit_cmd (str, optional): If given, this command-string will be executed on + the caller when the more page exits. Note that this will be using whatever + cmdset the user had *before* the evmore pager was activated (so none of + the evmore commands will be available when this is run). kwargs (any, optional): These will be passed on to the `caller.msg` method. @@ -151,6 +156,7 @@ class EvMore(object): self._npages = [] self._npos = [] self.exit_on_lastpage = exit_on_lastpage + self.exit_cmd = exit_cmd self._exit_msg = "Exited |wmore|n pager." if not session: # if not supplied, use the first session to @@ -202,15 +208,18 @@ class EvMore(object): # goto top of the text self.page_top() - def display(self): + def display(self, show_footer=True): """ Pretty-print the page. """ pos = self._pos text = self._pages[pos] - page = _DISPLAY.format(text=text, - pageno=pos + 1, - pagemax=self._npages) + if show_footer: + page = _DISPLAY.format(text=text, + pageno=pos + 1, + pagemax=self._npages) + else: + page = text # check to make sure our session is still valid sessions = self._caller.sessions.get() if not sessions: @@ -245,9 +254,11 @@ class EvMore(object): self.page_quit() else: self._pos += 1 - self.display() - if self.exit_on_lastpage and self._pos >= self._npages - 1: - self.page_quit() + if self.exit_on_lastpage and self._pos >= (self._npages - 1): + self.display(show_footer=False) + self.page_quit(quiet=True) + else: + self.display() def page_back(self): """ @@ -256,16 +267,20 @@ class EvMore(object): self._pos = max(0, self._pos - 1) self.display() - def page_quit(self): + def page_quit(self, quiet=False): """ Quit the pager """ del self._caller.ndb._more - self._caller.msg(text=self._exit_msg, **self._kwargs) + if not quiet: + self._caller.msg(text=self._exit_msg, **self._kwargs) self._caller.cmdset.remove(CmdSetMore) + if self.exit_cmd: + self._caller.execute_cmd(self.exit_cmd, session=self._session) -def msg(caller, text="", always_page=False, session=None, justify_kwargs=None, **kwargs): +def msg(caller, text="", always_page=False, session=None, + justify_kwargs=None, exit_on_lastpage=True, **kwargs): """ More-supported version of msg, mimicking the normal msg method. @@ -280,9 +295,10 @@ def msg(caller, text="", always_page=False, session=None, justify_kwargs=None, * justify_kwargs (dict, bool or None, optional): If given, this should be valid keyword arguments to the utils.justify() function. If False, no justification will be done. + exit_on_lastpage (bool, optional): Immediately exit pager when reaching the last page. kwargs (any, optional): These will be passed on to the `caller.msg` method. """ EvMore(caller, text, always_page=always_page, session=session, - justify_kwargs=justify_kwargs, **kwargs) + justify_kwargs=justify_kwargs, exit_on_lastpage=exit_on_lastpage, **kwargs) diff --git a/evennia/utils/inlinefuncs.py b/evennia/utils/inlinefuncs.py index b09cc432e7..3012347541 100644 --- a/evennia/utils/inlinefuncs.py +++ b/evennia/utils/inlinefuncs.py @@ -1,467 +1,528 @@ -""" -Inline functions (nested form). - -This parser accepts nested inlinefunctions on the form - -``` -$funcname(arg, arg, ...) -``` - -embedded in any text where any arg can be another $funcname{} call. -This functionality is turned off by default - to activate, -`settings.INLINEFUNC_ENABLED` must be set to `True`. - -Each token starts with "$funcname(" where there must be no space -between the $funcname and (. It ends with a matched ending parentesis. -")". - -Inside the inlinefunc definition, one can use `\` to escape. This is -mainly needed for escaping commas in flowing text (which would -otherwise be interpreted as an argument separator), or to escape `}` -when not intended to close the function block. Enclosing text in -matched `\"\"\"` (triple quotes) or `'''` (triple single-quotes) will -also escape *everything* within without needing to escape individual -characters. - -The available inlinefuncs are defined as global-level functions in -modules defined by `settings.INLINEFUNC_MODULES`. They are identified -by their function name (and ignored if this name starts with `_`). They -should be on the following form: - -```python -def funcname (*args, **kwargs): - # ... -``` - -Here, the arguments given to `$funcname(arg1,arg2)` will appear as the -`*args` tuple. This will be populated by the arguments given to the -inlinefunc in-game - the only part that will be available from -in-game. `**kwargs` are not supported from in-game but are only used -internally by Evennia to make details about the caller available to -the function. The kwarg passed to all functions is `session`, the -Sessionobject for the object seeing the string. This may be `None` if -the string is sent to a non-puppetable object. The inlinefunc should -never raise an exception. - -There are two reserved function names: -- "nomatch": This is called if the user uses a functionname that is - not registered. The nomatch function will get the name of the - not-found function as its first argument followed by the normal - arguments to the given function. If not defined the default effect is - to print `` to replace the unknown function. -- "stackfull": This is called when the maximum nested function stack is reached. - When this happens, the original parsed string is returned and the result of - the `stackfull` inlinefunc is appended to the end. By default this is an - error message. - -Error handling: - Syntax errors, notably not completely closing all inlinefunc - blocks, will lead to the entire string remaining unparsed. - -""" - -import re -from django.conf import settings -from evennia.utils import utils - - -# example/testing inline functions - -def pad(*args, **kwargs): - """ - Inlinefunc. Pads text to given width. - - Args: - text (str, optional): Text to pad. - width (str, optional): Will be converted to integer. Width - of padding. - align (str, optional): Alignment of padding; one of 'c', 'l' or 'r'. - fillchar (str, optional): Character used for padding. Defaults to a space. - - Kwargs: - session (Session): Session performing the pad. - - Example: - `$pad(text, width, align, fillchar)` - - """ - text, width, align, fillchar = "", 78, 'c', ' ' - nargs = len(args) - if nargs > 0: - text = args[0] - if nargs > 1: - width = int(args[1]) if args[1].strip().isdigit() else 78 - if nargs > 2: - align = args[2] if args[2] in ('c', 'l', 'r') else 'c' - if nargs > 3: - fillchar = args[3] - return utils.pad(text, width=width, align=align, fillchar=fillchar) - - -def crop(*args, **kwargs): - """ - Inlinefunc. Crops ingoing text to given widths. - - Args: - text (str, optional): Text to crop. - width (str, optional): Will be converted to an integer. Width of - crop in characters. - suffix (str, optional): End string to mark the fact that a part - of the string was cropped. Defaults to `[...]`. - Kwargs: - session (Session): Session performing the crop. - - Example: - `$crop(text, width=78, suffix='[...]')` - - """ - text, width, suffix = "", 78, "[...]" - nargs = len(args) - if nargs > 0: - text = args[0] - if nargs > 1: - width = int(args[1]) if args[1].strip().isdigit() else 78 - if nargs > 2: - suffix = args[2] - return utils.crop(text, width=width, suffix=suffix) - - -def clr(*args, **kwargs): - """ - Inlinefunc. Colorizes nested text. - - Args: - startclr (str, optional): An ANSI color abbreviation without the - prefix `|`, such as `r` (red foreground) or `[r` (red background). - text (str, optional): Text - endclr (str, optional): The color to use at the end of the string. Defaults - to `|n` (reset-color). - Kwargs: - session (Session): Session object triggering inlinefunc. - - Example: - `$clr(startclr, text, endclr)` - - """ - text = "" - nargs = len(args) - if nargs > 0: - color = args[0].strip() - if nargs > 1: - text = args[1] - text = "|" + color + text - if nargs > 2: - text += "|" + args[2].strip() - else: - text += "|n" - return text - - -# we specify a default nomatch function to use if no matching func was -# found. This will be overloaded by any nomatch function defined in -# the imported modules. -_INLINE_FUNCS = {"nomatch": lambda *args, **kwargs: "", - "stackfull": lambda *args, **kwargs: "\n (not parsed: inlinefunc stack size exceeded.)"} - - -# load custom inline func modules. -for module in utils.make_iter(settings.INLINEFUNC_MODULES): - try: - _INLINE_FUNCS.update(utils.callables_from_module(module)) - except ImportError as err: - if module == "server.conf.inlinefuncs": - # a temporary warning since the default module changed name - raise ImportError("Error: %s\nPossible reason: mygame/server/conf/inlinefunc.py should " - "be renamed to mygame/server/conf/inlinefuncs.py (note the S at the end)." % err) - else: - raise - - -# remove the core function if we include examples in this module itself -#_INLINE_FUNCS.pop("inline_func_parse", None) - - -# The stack size is a security measure. Set to <=0 to disable. -try: - _STACK_MAXSIZE = settings.INLINEFUNC_STACK_MAXSIZE -except AttributeError: - _STACK_MAXSIZE = 20 - -# regex definitions - -_RE_STARTTOKEN = re.compile(r"(?.*?)(?.*?)(?(?(?(? # escaped tokens to re-insert sans backslash - \\\'|\\\"|\\\)|\\\$\w+\()| - (?P # everything else to re-insert verbatim - \$(?!\w+\()|\'{1}|\"{1}|\\{1}|[^),$\'\"\\]+)""", - re.UNICODE | re.IGNORECASE | re.VERBOSE | re.DOTALL) - - -# Cache for function lookups. -_PARSING_CACHE = utils.LimitedSizeOrderedDict(size_limit=1000) - - -class ParseStack(list): - """ - Custom stack that always concatenates strings together when the - strings are added next to one another. Tuples are stored - separately and None is used to mark that a string should be broken - up into a new chunk. Below is the resulting stack after separately - appending 3 strings, None, 2 strings, a tuple and finally 2 - strings: - - [string + string + string, - None - string + string, - tuple, - string + string] - - """ - - def __init__(self, *args, **kwargs): - super(ParseStack, self).__init__(*args, **kwargs) - # always start stack with the empty string - list.append(self, "") - # indicates if the top of the stack is a string or not - self._string_last = True - - def __eq__(self, other): - return (super(ParseStack).__eq__(other) and - hasattr(other, "_string_last") and self._string_last == other._string_last) - - def __ne__(self, other): - return not self.__eq__(other) - - def append(self, item): - """ - The stack will merge strings, add other things as normal - """ - if isinstance(item, basestring): - if self._string_last: - self[-1] += item - else: - list.append(self, item) - self._string_last = True - else: - # everything else is added as normal - list.append(self, item) - self._string_last = False - - -class InlinefuncError(RuntimeError): - pass - - -def parse_inlinefunc(string, strip=False, **kwargs): - """ - Parse the incoming string. - - Args: - string (str): The incoming string to parse. - strip (bool, optional): Whether to strip function calls rather than - execute them. - Kwargs: - session (Session): This is sent to this function by Evennia when triggering - it. It is passed to the inlinefunc. - kwargs (any): All other kwargs are also passed on to the inlinefunc. - - - """ - global _PARSING_CACHE - if string in _PARSING_CACHE: - # stack is already cached - stack = _PARSING_CACHE[string] - elif not _RE_STARTTOKEN.search(string): - # if there are no unescaped start tokens at all, return immediately. - return string - else: - # no cached stack; build a new stack and continue - stack = ParseStack() - - # process string on stack - ncallable = 0 - for match in _RE_TOKEN.finditer(string): - gdict = match.groupdict() - if gdict["singlequote"]: - stack.append(gdict["singlequote"]) - elif gdict["doublequote"]: - stack.append(gdict["doublequote"]) - elif gdict["end"]: - if ncallable <= 0: - stack.append(")") - continue - args = [] - while stack: - operation = stack.pop() - if callable(operation): - if not strip: - stack.append((operation, [arg for arg in reversed(args)])) - ncallable -= 1 - break - else: - args.append(operation) - elif gdict["start"]: - funcname = _RE_STARTTOKEN.match(gdict["start"]).group(1) - try: - # try to fetch the matching inlinefunc from storage - stack.append(_INLINE_FUNCS[funcname]) - except KeyError: - stack.append(_INLINE_FUNCS["nomatch"]) - stack.append(funcname) - ncallable += 1 - elif gdict["escaped"]: - # escaped tokens - token = gdict["escaped"].lstrip("\\") - stack.append(token) - elif gdict["comma"]: - if ncallable > 0: - # commas outside strings and inside a callable are - # used to mark argument separation - we use None - # in the stack to indicate such a separation. - stack.append(None) - else: - # no callable active - just a string - stack.append(",") - else: - # the rest - stack.append(gdict["rest"]) - - if ncallable > 0: - # this means not all inlinefuncs were complete - return string - - if _STACK_MAXSIZE > 0 and _STACK_MAXSIZE < len(stack): - # if stack is larger than limit, throw away parsing - return string + gdict["stackfull"](*args, **kwargs) - else: - # cache the stack - _PARSING_CACHE[string] = stack - - # run the stack recursively - def _run_stack(item, depth=0): - retval = item - if isinstance(item, tuple): - if strip: - return "" - else: - func, arglist = item - args = [""] - for arg in arglist: - if arg is None: - # an argument-separating comma - start a new arg - args.append("") - else: - # all other args should merge into one string - args[-1] += _run_stack(arg, depth=depth + 1) - # execute the inlinefunc at this point or strip it. - kwargs["inlinefunc_stack_depth"] = depth - retval = "" if strip else func(*args, **kwargs) - return utils.to_str(retval, force_string=True) - - # execute the stack from the cache - return "".join(_run_stack(item) for item in _PARSING_CACHE[string]) - -# -# Nick templating -# - - -""" -This supports the use of replacement templates in nicks: - -This happens in two steps: - -1) The user supplies a template that is converted to a regex according - to the unix-like templating language. -2) This regex is tested against nicks depending on which nick replacement - strategy is considered (most commonly inputline). -3) If there is a template match and there are templating markers, - these are replaced with the arguments actually given. - -@desc $1 $2 $3 - -This will be converted to the following regex: - -\@desc (?P<1>\w+) (?P<2>\w+) $(?P<3>\w+) - -Supported template markers (through fnmatch) - * matches anything (non-greedy) -> .*? - ? matches any single character -> - [seq] matches any entry in sequence - [!seq] matches entries not in sequence -Custom arg markers - $N argument position (1-99) - -""" -import fnmatch -_RE_NICK_ARG = re.compile(r"\\(\$)([1-9][0-9]?)") -_RE_NICK_TEMPLATE_ARG = re.compile(r"(\$)([1-9][0-9]?)") -_RE_NICK_SPACE = re.compile(r"\\ ") - - -class NickTemplateInvalid(ValueError): - pass - - -def initialize_nick_templates(in_template, out_template): - """ - Initialize the nick templates for matching and remapping a string. - - Args: - in_template (str): The template to be used for nick recognition. - out_template (str): The template to be used to replace the string - matched by the in_template. - - Returns: - regex (regex): Regex to match against strings - template (str): Template with markers {arg1}, {arg2}, etc for - replacement using the standard .format method. - - Raises: - NickTemplateInvalid: If the in/out template does not have a matching - number of $args. - - """ - # create the regex for in_template - regex_string = fnmatch.translate(in_template) - n_inargs = len(_RE_NICK_ARG.findall(regex_string)) - regex_string = _RE_NICK_SPACE.sub("\s+", regex_string) - regex_string = _RE_NICK_ARG.sub(lambda m: "(?P.+?)" % m.group(2), regex_string) - - # create the out_template - template_string = _RE_NICK_TEMPLATE_ARG.sub(lambda m: "{arg%s}" % m.group(2), out_template) - - # validate the tempaltes - they should at least have the same number of args - n_outargs = len(_RE_NICK_TEMPLATE_ARG.findall(out_template)) - if n_inargs != n_outargs: - print n_inargs, n_outargs - raise NickTemplateInvalid - - return re.compile(regex_string), template_string - - -def parse_nick_template(string, template_regex, outtemplate): - """ - Parse a text using a template and map it to another template - - Args: - string (str): The input string to processj - template_regex (regex): A template regex created with - initialize_nick_template. - outtemplate (str): The template to which to map the matches - produced by the template_regex. This should have $1, $2, - etc to match the regex. - - """ - match = template_regex.match(string) - if match: - return outtemplate.format(**match.groupdict()) - return string +""" +Inline functions (nested form). + +This parser accepts nested inlinefunctions on the form + +``` +$funcname(arg, arg, ...) +``` + +embedded in any text where any arg can be another $funcname{} call. +This functionality is turned off by default - to activate, +`settings.INLINEFUNC_ENABLED` must be set to `True`. + +Each token starts with "$funcname(" where there must be no space +between the $funcname and (. It ends with a matched ending parentesis. +")". + +Inside the inlinefunc definition, one can use `\` to escape. This is +mainly needed for escaping commas in flowing text (which would +otherwise be interpreted as an argument separator), or to escape `}` +when not intended to close the function block. Enclosing text in +matched `\"\"\"` (triple quotes) or `'''` (triple single-quotes) will +also escape *everything* within without needing to escape individual +characters. + +The available inlinefuncs are defined as global-level functions in +modules defined by `settings.INLINEFUNC_MODULES`. They are identified +by their function name (and ignored if this name starts with `_`). They +should be on the following form: + +```python +def funcname (*args, **kwargs): + # ... +``` + +Here, the arguments given to `$funcname(arg1,arg2)` will appear as the +`*args` tuple. This will be populated by the arguments given to the +inlinefunc in-game - the only part that will be available from +in-game. `**kwargs` are not supported from in-game but are only used +internally by Evennia to make details about the caller available to +the function. The kwarg passed to all functions is `session`, the +Sessionobject for the object seeing the string. This may be `None` if +the string is sent to a non-puppetable object. The inlinefunc should +never raise an exception. + +There are two reserved function names: +- "nomatch": This is called if the user uses a functionname that is + not registered. The nomatch function will get the name of the + not-found function as its first argument followed by the normal + arguments to the given function. If not defined the default effect is + to print `` to replace the unknown function. +- "stackfull": This is called when the maximum nested function stack is reached. + When this happens, the original parsed string is returned and the result of + the `stackfull` inlinefunc is appended to the end. By default this is an + error message. + +Error handling: + Syntax errors, notably not completely closing all inlinefunc + blocks, will lead to the entire string remaining unparsed. + +""" + +import re +import fnmatch +from django.conf import settings + +from evennia.utils import utils, logger + + +# example/testing inline functions + +def pad(*args, **kwargs): + """ + Inlinefunc. Pads text to given width. + + Args: + text (str, optional): Text to pad. + width (str, optional): Will be converted to integer. Width + of padding. + align (str, optional): Alignment of padding; one of 'c', 'l' or 'r'. + fillchar (str, optional): Character used for padding. Defaults to a space. + + Kwargs: + session (Session): Session performing the pad. + + Example: + `$pad(text, width, align, fillchar)` + + """ + text, width, align, fillchar = "", 78, 'c', ' ' + nargs = len(args) + if nargs > 0: + text = args[0] + if nargs > 1: + width = int(args[1]) if args[1].strip().isdigit() else 78 + if nargs > 2: + align = args[2] if args[2] in ('c', 'l', 'r') else 'c' + if nargs > 3: + fillchar = args[3] + return utils.pad(text, width=width, align=align, fillchar=fillchar) + + +def crop(*args, **kwargs): + """ + Inlinefunc. Crops ingoing text to given widths. + + Args: + text (str, optional): Text to crop. + width (str, optional): Will be converted to an integer. Width of + crop in characters. + suffix (str, optional): End string to mark the fact that a part + of the string was cropped. Defaults to `[...]`. + Kwargs: + session (Session): Session performing the crop. + + Example: + `$crop(text, width=78, suffix='[...]')` + + """ + text, width, suffix = "", 78, "[...]" + nargs = len(args) + if nargs > 0: + text = args[0] + if nargs > 1: + width = int(args[1]) if args[1].strip().isdigit() else 78 + if nargs > 2: + suffix = args[2] + return utils.crop(text, width=width, suffix=suffix) + + +def clr(*args, **kwargs): + """ + Inlinefunc. Colorizes nested text. + + Args: + startclr (str, optional): An ANSI color abbreviation without the + prefix `|`, such as `r` (red foreground) or `[r` (red background). + text (str, optional): Text + endclr (str, optional): The color to use at the end of the string. Defaults + to `|n` (reset-color). + Kwargs: + session (Session): Session object triggering inlinefunc. + + Example: + `$clr(startclr, text, endclr)` + + """ + text = "" + nargs = len(args) + if nargs > 0: + color = args[0].strip() + if nargs > 1: + text = args[1] + text = "|" + color + text + if nargs > 2: + text += "|" + args[2].strip() + else: + text += "|n" + return text + + +def null(*args, **kwargs): + return args[0] if args else '' + + +def nomatch(name, *args, **kwargs): + """ + Default implementation of nomatch returns the function as-is as a string. + + """ + kwargs.pop("inlinefunc_stack_depth", None) + kwargs.pop("session") + + return "${name}({args}{kwargs})".format( + name=name, + args=",".join(args), + kwargs=",".join("{}={}".format(key, val) for key, val in kwargs.items())) + +_INLINE_FUNCS = {} + +# we specify a default nomatch function to use if no matching func was +# found. This will be overloaded by any nomatch function defined in +# the imported modules. +_DEFAULT_FUNCS = {"nomatch": lambda *args, **kwargs: "", + "stackfull": lambda *args, **kwargs: "\n (not parsed: "} + +_INLINE_FUNCS.update(_DEFAULT_FUNCS) + +# load custom inline func modules. +for module in utils.make_iter(settings.INLINEFUNC_MODULES): + try: + _INLINE_FUNCS.update(utils.callables_from_module(module)) + except ImportError as err: + if module == "server.conf.inlinefuncs": + # a temporary warning since the default module changed name + raise ImportError("Error: %s\nPossible reason: mygame/server/conf/inlinefunc.py should " + "be renamed to mygame/server/conf/inlinefuncs.py (note " + "the S at the end)." % err) + else: + raise + + +# The stack size is a security measure. Set to <=0 to disable. +try: + _STACK_MAXSIZE = settings.INLINEFUNC_STACK_MAXSIZE +except AttributeError: + _STACK_MAXSIZE = 20 + +# regex definitions + +_RE_STARTTOKEN = re.compile(r"(?.*?)(?.*?)(?(?(?(?(? # escaped tokens to re-insert sans backslash + \\\'|\\\"|\\\)|\\\$\w+\(|\\\()| + (?P # everything else to re-insert verbatim + \$(?!\w+\()|\'|\"|\\|[^),$\'\"\\\(]+)""", + re.UNICODE | re.IGNORECASE | re.VERBOSE | re.DOTALL) + +# Cache for function lookups. +_PARSING_CACHE = utils.LimitedSizeOrderedDict(size_limit=1000) + + +class ParseStack(list): + """ + Custom stack that always concatenates strings together when the + strings are added next to one another. Tuples are stored + separately and None is used to mark that a string should be broken + up into a new chunk. Below is the resulting stack after separately + appending 3 strings, None, 2 strings, a tuple and finally 2 + strings: + + [string + string + string, + None + string + string, + tuple, + string + string] + + """ + + def __init__(self, *args, **kwargs): + super(ParseStack, self).__init__(*args, **kwargs) + # always start stack with the empty string + list.append(self, "") + # indicates if the top of the stack is a string or not + self._string_last = True + + def __eq__(self, other): + return (super(ParseStack).__eq__(other) and + hasattr(other, "_string_last") and self._string_last == other._string_last) + + def __ne__(self, other): + return not self.__eq__(other) + + def append(self, item): + """ + The stack will merge strings, add other things as normal + """ + if isinstance(item, basestring): + if self._string_last: + self[-1] += item + else: + list.append(self, item) + self._string_last = True + else: + # everything else is added as normal + list.append(self, item) + self._string_last = False + + +class InlinefuncError(RuntimeError): + pass + + +def parse_inlinefunc(string, strip=False, available_funcs=None, stacktrace=False, **kwargs): + """ + Parse the incoming string. + + Args: + string (str): The incoming string to parse. + strip (bool, optional): Whether to strip function calls rather than + execute them. + available_funcs (dict, optional): Define an alternative source of functions to parse for. + If unset, use the functions found through `settings.INLINEFUNC_MODULES`. + stacktrace (bool, optional): If set, print the stacktrace to log. + Kwargs: + session (Session): This is sent to this function by Evennia when triggering + it. It is passed to the inlinefunc. + kwargs (any): All other kwargs are also passed on to the inlinefunc. + + + """ + global _PARSING_CACHE + usecache = False + if not available_funcs: + available_funcs = _INLINE_FUNCS + usecache = True + else: + # make sure the default keys are available, but also allow overriding + tmp = _DEFAULT_FUNCS.copy() + tmp.update(available_funcs) + available_funcs = tmp + + if usecache and string in _PARSING_CACHE: + # stack is already cached + stack = _PARSING_CACHE[string] + elif not _RE_STARTTOKEN.search(string): + # if there are no unescaped start tokens at all, return immediately. + return string + else: + # no cached stack; build a new stack and continue + stack = ParseStack() + + # process string on stack + ncallable = 0 + nlparens = 0 + nvalid = 0 + + if stacktrace: + out = "STRING: {} =>".format(string) + print(out) + logger.log_info(out) + + for match in _RE_TOKEN.finditer(string): + gdict = match.groupdict() + + if stacktrace: + out = " MATCH: {}".format({key: val for key, val in gdict.items() if val}) + print(out) + logger.log_info(out) + + if gdict["singlequote"]: + stack.append(gdict["singlequote"]) + elif gdict["doublequote"]: + stack.append(gdict["doublequote"]) + elif gdict["leftparens"]: + # we have a left-parens inside a callable + if ncallable: + nlparens += 1 + stack.append("(") + elif gdict["end"]: + if nlparens > 0: + nlparens -= 1 + stack.append(")") + continue + if ncallable <= 0: + stack.append(")") + continue + args = [] + while stack: + operation = stack.pop() + if callable(operation): + if not strip: + stack.append((operation, [arg for arg in reversed(args)])) + ncallable -= 1 + break + else: + args.append(operation) + elif gdict["start"]: + funcname = _RE_STARTTOKEN.match(gdict["start"]).group(1) + try: + # try to fetch the matching inlinefunc from storage + stack.append(available_funcs[funcname]) + nvalid += 1 + except KeyError: + stack.append(available_funcs["nomatch"]) + stack.append(funcname) + stack.append(None) + ncallable += 1 + elif gdict["escaped"]: + # escaped tokens + token = gdict["escaped"].lstrip("\\") + stack.append(token) + elif gdict["comma"]: + if ncallable > 0: + # commas outside strings and inside a callable are + # used to mark argument separation - we use None + # in the stack to indicate such a separation. + stack.append(None) + else: + # no callable active - just a string + stack.append(",") + else: + # the rest + stack.append(gdict["rest"]) + + if ncallable > 0: + # this means not all inlinefuncs were complete + return string + + if _STACK_MAXSIZE > 0 and _STACK_MAXSIZE < nvalid: + # if stack is larger than limit, throw away parsing + return string + available_funcs["stackfull"](*args, **kwargs) + elif usecache: + # cache the stack - we do this also if we don't check the cache above + _PARSING_CACHE[string] = stack + + # run the stack recursively + def _run_stack(item, depth=0): + retval = item + if isinstance(item, tuple): + if strip: + return "" + else: + func, arglist = item + args = [""] + for arg in arglist: + if arg is None: + # an argument-separating comma - start a new arg + args.append("") + else: + # all other args should merge into one string + args[-1] += _run_stack(arg, depth=depth + 1) + # execute the inlinefunc at this point or strip it. + kwargs["inlinefunc_stack_depth"] = depth + retval = "" if strip else func(*args, **kwargs) + return utils.to_str(retval, force_string=True) + retval = "".join(_run_stack(item) for item in stack) + if stacktrace: + out = "STACK: \n{} => {}\n".format(stack, retval) + print(out) + logger.log_info(out) + + # execute the stack + return retval + +# +# Nick templating +# + + +""" +This supports the use of replacement templates in nicks: + +This happens in two steps: + +1) The user supplies a template that is converted to a regex according + to the unix-like templating language. +2) This regex is tested against nicks depending on which nick replacement + strategy is considered (most commonly inputline). +3) If there is a template match and there are templating markers, + these are replaced with the arguments actually given. + +@desc $1 $2 $3 + +This will be converted to the following regex: + +\@desc (?P<1>\w+) (?P<2>\w+) $(?P<3>\w+) + +Supported template markers (through fnmatch) + * matches anything (non-greedy) -> .*? + ? matches any single character -> + [seq] matches any entry in sequence + [!seq] matches entries not in sequence +Custom arg markers + $N argument position (1-99) + +""" + +_RE_NICK_ARG = re.compile(r"\\(\$)([1-9][0-9]?)") +_RE_NICK_TEMPLATE_ARG = re.compile(r"(\$)([1-9][0-9]?)") +_RE_NICK_SPACE = re.compile(r"\\ ") + + +class NickTemplateInvalid(ValueError): + pass + + +def initialize_nick_templates(in_template, out_template): + """ + Initialize the nick templates for matching and remapping a string. + + Args: + in_template (str): The template to be used for nick recognition. + out_template (str): The template to be used to replace the string + matched by the in_template. + + Returns: + regex (regex): Regex to match against strings + template (str): Template with markers {arg1}, {arg2}, etc for + replacement using the standard .format method. + + Raises: + NickTemplateInvalid: If the in/out template does not have a matching + number of $args. + + """ + # create the regex for in_template + regex_string = fnmatch.translate(in_template) + n_inargs = len(_RE_NICK_ARG.findall(regex_string)) + regex_string = _RE_NICK_SPACE.sub("\s+", regex_string) + regex_string = _RE_NICK_ARG.sub(lambda m: "(?P.+?)" % m.group(2), regex_string) + + # create the out_template + template_string = _RE_NICK_TEMPLATE_ARG.sub(lambda m: "{arg%s}" % m.group(2), out_template) + + # validate the tempaltes - they should at least have the same number of args + n_outargs = len(_RE_NICK_TEMPLATE_ARG.findall(out_template)) + if n_inargs != n_outargs: + raise NickTemplateInvalid + + return re.compile(regex_string), template_string + + +def parse_nick_template(string, template_regex, outtemplate): + """ + Parse a text using a template and map it to another template + + Args: + string (str): The input string to processj + template_regex (regex): A template regex created with + initialize_nick_template. + outtemplate (str): The template to which to map the matches + produced by the template_regex. This should have $1, $2, + etc to match the regex. + + """ + match = template_regex.match(string) + if match: + return outtemplate.format(**match.groupdict()) + return string diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py deleted file mode 100644 index 86b24e5e4f..0000000000 --- a/evennia/utils/spawner.py +++ /dev/null @@ -1,342 +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 - string parent prototype - key - string, the main object identifier - typeclass - string, if not set, will use `settings.BASE_OBJECT_TYPECLASS` - location - this should be a valid object or #dbref - home - valid object or #dbref - destination - only valid for exits (object or dbref) - - permissions - string or list of permission strings - locks - a lock-string - aliases - string or list of strings - exec - this is a string of python code to execute or a list of such codes. - This can be used e.g. to trigger custom handlers on the object. The - execution namespace contains 'evennia' for the library and 'obj' - tags - string or list of strings or tuples `(tagstr, category)`. Plain - strings will be result in tags with no category (default tags). - attrs - tuple or list of tuples of Attributes to add. This form allows - more complex Attributes to be set. Tuples at least specify `(key, value)` - but can also specify up to `(key, value, category, lockstring)`. If - you want to specify a lockstring but not a category, set the category - to `None`. - ndb_ - value of a nattribute (ndb_ is stripped) - other - any other name is interpreted as the key of an Attribute with - 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 -GOBLIN_WIZARD = { - "prototype": GOBLIN, - "key": "goblin wizard", - "spells": ["fire ball", "lighting bolt"] - } - -GOBLIN_ARCHER = { - "prototype": GOBLIN, - "key": "goblin archer", - "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*. - -""" -from __future__ import print_function - -import copy -from django.conf import settings -from random import randint -import evennia -from evennia.objects.models import ObjectDB -from evennia.utils.utils import make_iter, all_from_module, dbid_to_obj - -_CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") - - -def _handle_dbref(inp): - return dbid_to_obj(inp, ObjectDB) - - -def _validate_prototype(key, prototype, protparents, visited): - """ - Run validation on a prototype, checking for inifinite regress. - - """ - assert isinstance(prototype, dict) - if id(prototype) in visited: - raise RuntimeError("%s has infinite nesting of prototypes." % key or prototype) - visited.append(id(prototype)) - protstrings = prototype.get("prototype") - if protstrings: - for protstring in make_iter(protstrings): - if key is not None and protstring == key: - raise RuntimeError("%s tries to prototype itself." % key or prototype) - protparent = protparents.get(protstring) - if not protparent: - raise RuntimeError("%s's prototype '%s' was not found." % (key or prototype, protstring)) - _validate_prototype(protstring, protparent, protparents, visited) - - -def _get_prototype(dic, prot, protparents): - """ - Recursively traverse a prototype dictionary, including multiple - inheritance. Use _validate_prototype before this, we don't check - for infinite recursion here. - - """ - if "prototype" in dic: - # move backwards through the inheritance - for prototype in make_iter(dic["prototype"]): - # Build the prot dictionary in reverse order, overloading - new_prot = _get_prototype(protparents.get(prototype, {}), prot, protparents) - prot.update(new_prot) - prot.update(dic) - prot.pop("prototype", None) # we don't need this anymore - return prot - - -def _batch_create_object(*objparams): - """ - This is a cut-down version of the create_object() function, - optimized for speed. It does NOT check and convert various input - so make sure the spawned Typeclass works before using this! - - Args: - objsparams (tuple): Parameters for the respective creation/add - handlers in the following order: - - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`. - - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`. - - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`. - - `aliases` (list): A list of alias strings for - adding with `new_object.aliases.batch_add(*aliases)`. - - `nattributes` (list): list of tuples `(key, value)` to be loop-added to - add with `new_obj.nattributes.add(*tuple)`. - - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for - adding with `new_obj.attributes.batch_add(*attributes)`. - - `tags` (list): list of tuples `(key, category)` for adding - with `new_obj.tags.batch_add(*tags)`. - - `execs` (list): Code strings to execute together with the creation - of each object. They will be executed with `evennia` and `obj` - (the newly created object) available in the namespace. Execution - will happend after all other properties have been assigned and - is intended for calling custom handlers etc. - for the respective creation/add handlers in the following - order: (create_kwargs, permissions, locks, aliases, nattributes, - attributes, tags, execs) - - Returns: - objects (list): A list of created objects - - Notes: - The `exec` list will execute arbitrary python code so don't allow this to be available to - unprivileged users! - - """ - - # bulk create all objects in one go - - # unfortunately this doesn't work since bulk_create doesn't creates pks; - # the result would be duplicate objects at the next stage, so we comment - # it out for now: - # dbobjs = _ObjectDB.objects.bulk_create(dbobjs) - - dbobjs = [ObjectDB(**objparam[0]) for objparam in objparams] - objs = [] - for iobj, obj in enumerate(dbobjs): - # call all setup hooks on each object - objparam = objparams[iobj] - # setup - obj._createdict = {"permissions": make_iter(objparam[1]), - "locks": objparam[2], - "aliases": make_iter(objparam[3]), - "nattributes": objparam[4], - "attributes": objparam[5], - "tags": make_iter(objparam[6])} - # this triggers all hooks - obj.save() - # run eventual extra code - for code in objparam[7]: - if code: - exec(code, {}, {"evennia": evennia, "obj": obj}) - objs.append(obj) - return objs - - -def spawn(*prototypes, **kwargs): - """ - Spawn a number of prototyped objects. - - Args: - prototypes (dict): Each argument should be a prototype - dictionary. - Kwargs: - prototype_modules (str or list): A python-path to a prototype - module, or a list of such paths. These will be used to build - the global protparents dictionary accessible by the input - prototypes. If not given, it will instead look for modules - defined by settings.PROTOTYPE_MODULES. - prototype_parents (dict): A dictionary holding a custom - prototype-parent dictionary. Will overload same-named - prototypes from prototype_modules. - return_prototypes (bool): Only return a list of the - prototype-parents (no object creation happens) - - """ - - protparents = {} - protmodules = make_iter(kwargs.get("prototype_modules", [])) - if not protmodules and hasattr(settings, "PROTOTYPE_MODULES"): - protmodules = make_iter(settings.PROTOTYPE_MODULES) - for prototype_module in protmodules: - protparents.update(dict((key, val) for key, val in - all_from_module(prototype_module).items() if isinstance(val, dict))) - # overload module's protparents with specifically given protparents - protparents.update(kwargs.get("prototype_parents", {})) - for key, prototype in protparents.items(): - _validate_prototype(key, prototype, protparents, []) - - if "return_prototypes" in kwargs: - # only return the parents - return copy.deepcopy(protparents) - - objsparams = [] - for prototype in prototypes: - - _validate_prototype(None, prototype, protparents, []) - prot = _get_prototype(prototype, {}, protparents) - if not prot: - continue - - # extract the keyword args we need to create the object itself. If we get a callable, - # call that to get the value (don't catch errors) - create_kwargs = {} - keyval = prot.pop("key", "Spawned Object %06i" % randint(1, 100000)) - create_kwargs["db_key"] = keyval() if callable(keyval) else keyval - - locval = prot.pop("location", None) - create_kwargs["db_location"] = locval() if callable(locval) else _handle_dbref(locval) - - homval = prot.pop("home", settings.DEFAULT_HOME) - create_kwargs["db_home"] = homval() if callable(homval) else _handle_dbref(homval) - - destval = prot.pop("destination", None) - create_kwargs["db_destination"] = destval() if callable(destval) else _handle_dbref(destval) - - typval = prot.pop("typeclass", settings.BASE_OBJECT_TYPECLASS) - create_kwargs["db_typeclass_path"] = typval() if callable(typval) else typval - - # extract calls to handlers - permval = prot.pop("permissions", []) - permission_string = permval() if callable(permval) else permval - lockval = prot.pop("locks", "") - lock_string = lockval() if callable(lockval) else lockval - aliasval = prot.pop("aliases", "") - alias_string = aliasval() if callable(aliasval) else aliasval - tagval = prot.pop("tags", []) - tags = tagval() if callable(tagval) else tagval - attrval = prot.pop("attrs", []) - attributes = attrval() if callable(tagval) else attrval - - exval = prot.pop("exec", "") - execs = make_iter(exval() if callable(exval) else exval) - - # extract ndb assignments - nattributes = dict((key.split("_", 1)[1], value() if callable(value) else value) - for key, value in prot.items() if key.startswith("ndb_")) - - # the rest are attributes - simple_attributes = [(key, value()) if callable(value) else (key, value) - for key, value in prot.items() if not key.startswith("ndb_")] - attributes = attributes + simple_attributes - attributes = [tup for tup in attributes if not tup[0] in _CREATE_OBJECT_KWARGS] - - # pack for call into _batch_create_object - objsparams.append((create_kwargs, permission_string, lock_string, - alias_string, nattributes, attributes, tags, execs)) - - return _batch_create_object(*objsparams) - - -if __name__ == "__main__": - # testing - - protparents = { - "NOBODY": {}, - # "INFINITE" : { - # "prototype":"INFINITE" - # }, - "GOBLIN": { - "key": "goblin grunt", - "health": lambda: randint(20, 30), - "resists": ["cold", "poison"], - "attacks": ["fists"], - "weaknesses": ["fire", "light"] - }, - "GOBLIN_WIZARD": { - "prototype": "GOBLIN", - "key": "goblin wizard", - "spells": ["fire ball", "lighting bolt"] - }, - "GOBLIN_ARCHER": { - "prototype": "GOBLIN", - "key": "goblin archer", - "attacks": ["short bow"] - }, - "ARCHWIZARD": { - "attacks": ["archwizard staff"], - }, - "GOBLIN_ARCHWIZARD": { - "key": "goblin archwizard", - "prototype": ("GOBLIN_WIZARD", "ARCHWIZARD") - } - } - # test - print([o.key for o in spawn(protparents["GOBLIN"], - protparents["GOBLIN_ARCHWIZARD"], - prototype_parents=protparents)]) diff --git a/evennia/utils/tests/test_evmenu.py b/evennia/utils/tests/test_evmenu.py index 04310c90ed..a6959c0509 100644 --- a/evennia/utils/tests/test_evmenu.py +++ b/evennia/utils/tests/test_evmenu.py @@ -58,7 +58,7 @@ class TestEvMenu(TestCase): def _debug_output(self, indent, msg): if self.debug_output: - print(" " * indent + msg) + print(" " * indent + ansi.strip_ansi(msg)) def _test_menutree(self, menu): """ @@ -82,6 +82,8 @@ class TestEvMenu(TestCase): self.assertIsNotNone( bool(node_text), "node: {}: node-text is None, which was not expected.".format(nodename)) + if isinstance(node_text, tuple): + node_text, helptext = node_text node_text = ansi.strip_ansi(node_text.strip()) self.assertTrue( node_text.startswith(compare_text), @@ -168,6 +170,7 @@ class TestEvMenu(TestCase): self.caller2.msg = MagicMock() self.session = MagicMock() self.session2 = MagicMock() + self.menu = evmenu.EvMenu(self.caller, self.menutree, startnode=self.startnode, cmdset_mergetype=self.cmdset_mergetype, cmdset_priority=self.cmdset_priority, diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 10621f0feb..cd6c57a21f 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -20,18 +20,20 @@ import textwrap import random from os.path import join as osjoin from importlib import import_module -from inspect import ismodule, trace, getmembers, getmodule +from inspect import ismodule, trace, getmembers, getmodule, getmro from collections import defaultdict, OrderedDict from twisted.internet import threads, reactor, task from django.conf import settings from django.utils import timezone from django.utils.translation import ugettext as _ +from django.apps import apps from evennia.utils import logger _MULTIMATCH_TEMPLATE = settings.SEARCH_MULTIMATCH_TEMPLATE _EVENNIA_DIR = settings.EVENNIA_DIR _GAME_DIR = settings.GAME_DIR + try: import cPickle as pickle except ImportError: @@ -42,8 +44,6 @@ _GA = object.__getattribute__ _SA = object.__setattr__ _DA = object.__delattr__ -_DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH - def is_iter(iterable): """ @@ -79,7 +79,7 @@ def make_iter(obj): return not hasattr(obj, '__iter__') and [obj] or obj -def wrap(text, width=_DEFAULT_WIDTH, indent=0): +def wrap(text, width=None, indent=0): """ Safely wrap text to a certain number of characters. @@ -92,6 +92,7 @@ def wrap(text, width=_DEFAULT_WIDTH, indent=0): text (str): Properly wrapped text. """ + width = width if width else settings.CLIENT_DEFAULT_WIDTH if not text: return "" text = to_unicode(text) @@ -103,7 +104,7 @@ def wrap(text, width=_DEFAULT_WIDTH, indent=0): fill = wrap -def pad(text, width=_DEFAULT_WIDTH, align="c", fillchar=" "): +def pad(text, width=None, align="c", fillchar=" "): """ Pads to a given width. @@ -118,6 +119,7 @@ def pad(text, width=_DEFAULT_WIDTH, align="c", fillchar=" "): text (str): The padded text. """ + width = width if width else settings.CLIENT_DEFAULT_WIDTH align = align if align in ('c', 'l', 'r') else 'c' fillchar = fillchar[0] if fillchar else " " if align == 'l': @@ -128,7 +130,7 @@ def pad(text, width=_DEFAULT_WIDTH, align="c", fillchar=" "): return text.center(width, fillchar) -def crop(text, width=_DEFAULT_WIDTH, suffix="[...]"): +def crop(text, width=None, suffix="[...]"): """ Crop text to a certain width, throwing away text from too-long lines. @@ -146,7 +148,7 @@ def crop(text, width=_DEFAULT_WIDTH, suffix="[...]"): text (str): The cropped text. """ - + width = width if width else settings.CLIENT_DEFAULT_WIDTH utext = to_unicode(text) ltext = len(utext) if ltext <= width: @@ -157,12 +159,16 @@ def crop(text, width=_DEFAULT_WIDTH, suffix="[...]"): return to_str(utext) -def dedent(text): +def dedent(text, baseline_index=None): """ Safely clean all whitespace at the left of a paragraph. Args: text (str): The text to dedent. + baseline_index (int or None, optional): Which row to use as a 'base' + for the indentation. Lines will be dedented to this level but + no further. If None, indent so as to completely deindent the + least indented text. Returns: text (str): Dedented string. @@ -175,10 +181,17 @@ def dedent(text): """ if not text: return "" - return textwrap.dedent(text) + if baseline_index is None: + return textwrap.dedent(text) + else: + lines = text.split('\n') + baseline = lines[baseline_index] + spaceremove = len(baseline) - len(baseline.lstrip(' ')) + return "\n".join(line[min(spaceremove, len(line) - len(line.lstrip(' '))):] + for line in lines) -def justify(text, width=_DEFAULT_WIDTH, align="f", indent=0): +def justify(text, width=None, align="f", indent=0): """ Fully justify a text so that it fits inside `width`. When using full justification (default) this will be done by padding between @@ -197,6 +210,7 @@ def justify(text, width=_DEFAULT_WIDTH, align="f", indent=0): justified (str): The justified and indented block of text. """ + width = width if width else settings.CLIENT_DEFAULT_WIDTH def _process_line(line): """ @@ -208,18 +222,27 @@ def justify(text, width=_DEFAULT_WIDTH, align="f", indent=0): gap = " " # minimum gap between words if line_rest > 0: if align == 'l': - line[-1] += " " * line_rest + if line[-1] == "\n\n": + line[-1] = " " * (line_rest-1) + "\n" + " " * width + "\n" + " " * width + else: + line[-1] += " " * line_rest elif align == 'r': line[0] = " " * line_rest + line[0] elif align == 'c': pad = " " * (line_rest // 2) line[0] = pad + line[0] - line[-1] = line[-1] + pad + " " * (line_rest % 2) + if line[-1] == "\n\n": + line[-1] += pad + " " * (line_rest % 2 - 1) + \ + "\n" + " " * width + "\n" + " " * width + else: + line[-1] = line[-1] + pad + " " * (line_rest % 2) else: # align 'f' gap += " " * (line_rest // max(1, ngaps)) rest_gap = line_rest % max(1, ngaps) for i in range(rest_gap): line[i] += " " + elif not any(line): + return [" " * width] return gap.join(line) # split into paragraphs and words @@ -260,6 +283,62 @@ def justify(text, width=_DEFAULT_WIDTH, align="f", indent=0): return "\n".join([indentstring + line for line in lines]) +def columnize(string, columns=2, spacing=4, align='l', width=None): + """ + Break a string into a number of columns, using as little + vertical space as possible. + + Args: + string (str): The string to columnize. + columns (int, optional): The number of columns to use. + spacing (int, optional): How much space to have between columns. + width (int, optional): The max width of the columns. + Defaults to client's default width. + + Returns: + columns (str): Text divided into columns. + + Raises: + RuntimeError: If given invalid values. + + """ + columns = max(1, columns) + spacing = max(1, spacing) + width = width if width else settings.CLIENT_DEFAULT_WIDTH + + w_spaces = (columns - 1) * spacing + w_txt = max(1, width - w_spaces) + + if w_spaces + columns > width: # require at least 1 char per column + raise RuntimeError("Width too small to fit columns") + + colwidth = int(w_txt / (1.0 * columns)) + + # first make a single column which we then split + onecol = justify(string, width=colwidth, align=align) + onecol = onecol.split("\n") + + nrows, dangling = divmod(len(onecol), columns) + nrows = [nrows + 1 if i < dangling else nrows for i in range(columns)] + + height = max(nrows) + cols = [] + istart = 0 + for irows in nrows: + cols.append(onecol[istart:istart+irows]) + istart = istart + irows + for col in cols: + if len(col) < height: + col.append(" " * colwidth) + + sep = " " * spacing + rows = [] + for irow in range(height): + rows.append(sep.join(col[irow] for col in cols)) + + return "\n".join(rows) + + def list_to_string(inlist, endsep="and", addquote=False): """ This pretty-formats a list as string output, adding an optional @@ -1546,6 +1625,7 @@ def format_table(table, extra_space=1): Examples: ```python + ftable = format_table([[...], [...], ...]) for ir, row in enumarate(ftable): if ir == 0: # make first row white @@ -1879,3 +1959,29 @@ def get_game_dir_path(): else: os.chdir(os.pardir) raise RuntimeError("server/conf/settings.py not found: Must start from inside game dir.") + + +def get_all_typeclasses(parent=None): + """ + List available typeclasses from all available modules. + + Args: + parent (str, optional): If given, only return typeclasses inheriting (at any distance) + from this parent. + + Returns: + typeclasses (dict): On the form {"typeclass.path": typeclass, ...} + + Notes: + This will dynamicall retrieve all abstract django models inheriting at any distance + from the TypedObject base (aka a Typeclass) so it will work fine with any custom + classes being added. + + """ + from evennia.typeclasses.models import TypedObject + typeclasses = {"{}.{}".format(model.__module__, model.__name__): model + for model in apps.get_models() if TypedObject in getmro(model)} + if parent: + typeclasses = {name: typeclass for name, typeclass in typeclasses.items() + if inherits_from(typeclass, parent)} + return typeclasses diff --git a/requirements.txt b/requirements.txt index 7f4b94726f..72df29b9d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,9 +2,11 @@ django > 1.10, < 2.0 twisted == 16.0.0 -mock >= 1.0.1 pillow == 2.9.0 pytz future >= 0.15.2 django-sekizai inflect + +mock >= 1.0.1 +anything