From 50c54501f1941dcd5ce876975a97ab3f4b91a2e8 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 26 Jul 2018 23:41:00 +0200 Subject: [PATCH] Refactor menu up until attrs --- evennia/prototypes/menus.py | 376 +++++++++++++++++++++++++------ evennia/prototypes/prototypes.py | 4 +- evennia/prototypes/tests.py | 31 ++- evennia/utils/evmenu.py | 2 +- 4 files changed, 329 insertions(+), 84 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 34f8eaf648..54ec054340 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -5,6 +5,7 @@ OLC Prototype menu nodes """ import json +import re from random import choice from django.conf import settings from evennia.utils.evmenu import EvMenu, list_node @@ -242,6 +243,25 @@ def _format_lockfuncs(): docs=utils.justify(lockfunc.__doc__.strip(), align='l', indent=10).strip())) +def _format_list_actions(*args, **kwargs): + """Create footer text for nodes with extra list actions + + Args: + actions (str): Available actions. The first letter of the action name will be assumed + to be a shortcut. + Kwargs: + prefix (str): Default prefix to use. + Returns: + string (str): Formatted footer for adding to the node text. + + """ + actions = [] + prefix = kwargs.get('prefix', "|WSelect with |w|W. Other actions:|n ") + for action in args: + actions.append("|w{}|n|W{} |w|n".format(action[0], action[1:])) + return prefix + "|W,|n ".join(actions) + + def _get_current_value(caller, keyname, formatter=str): "Return current value, marking if value comes from parent or set in this prototype" prot = _get_menu_prototype(caller) @@ -255,6 +275,32 @@ def _get_current_value(caller, keyname, formatter=str): return "[No {} set]".format(keyname) +def _default_parse(raw_inp, choices, *args): + """ + Helper to parse default input to a node decorated with the node_list decorator on + the form l1, l 2, look 1, etc. Spaces are ignored, as is case. + + Args: + raw_inp (str): Input from the user. + choices (list): List of available options on the node listing (list of strings). + args (tuples): The available actions, each specifed as a tuple (name, alias, ...) + Returns: + choice (str): A choice among the choices, or None if no match was found. + action (str): The action operating on the choice, or None. + + """ + raw_inp = raw_inp.lower().strip() + mapping = {t.lower(): tup[0] for tup in args for t in tup} + match = re.match(r"(%s)\s*?(\d+)$" % "|".join(mapping.keys()), raw_inp) + if match: + action = mapping.get(match.group(1), None) + num = int(match.group(2)) - 1 + num = num if 0 <= num < len(choices) else None + if action is not None and num is not None: + return choices[num], action + return None, None + + # Menu nodes ------------------------------ @@ -357,6 +403,26 @@ def node_validate_prototype(caller, raw_string, **kwargs): text = (text, helptext) options = _wizard_options(None, prev_node, None) + options.append({"key": "_default", + "goto": "node_" + prev_node}) + + return text, options + + +def node_examine_entity(caller, raw_string, **kwargs): + """ + General node to view a text and then return to previous node. Kwargs should contain "text" for + the text to show and 'back" pointing to the node to return to. + """ + text = kwargs.get("text", "Nothing was found here.") + helptext = "Use |wback|n to return to the previous node." + prev_node = kwargs.get('back', 'index') + + text = (text, helptext) + + options = _wizard_options(None, prev_node, None) + options.append({"key": "_default", + "goto": "node_" + prev_node}) return text, options @@ -419,15 +485,64 @@ def _all_prototype_parents(caller): for prototype in protlib.search_prototype() if "prototype_key" in prototype] -def _prototype_parent_examine(caller, prototype_name): - """Convert prototype to a string representation for closer inspection""" - prototypes = protlib.search_prototype(key=prototype_name) - if prototypes: - ret = protlib.prototype_to_str(prototypes[0]) - caller.msg(ret) - return ret - else: - caller.msg("Prototype not registered.") +def _prototype_parent_actions(caller, raw_inp, **kwargs): + """Parse the default Convert prototype to a string representation for closer inspection""" + choices = kwargs.get("available_choices", []) + prototype_parent, action = _default_parse( + raw_inp, choices, ("examine", "e", "l"), ("add", "a"), ("remove", "r", 'delete', 'd')) + + if prototype_parent: + # a selection of parent was made + prototype_parent = protlib.search_prototype(key=prototype_parent)[0] + prototype_parent_key = prototype_parent['prototype_key'] + + # which action to apply on the selection + if action == 'examine': + # examine the prototype + txt = protlib.prototype_to_str(prototype_parent) + kwargs['text'] = txt + kwargs['back'] = 'prototype_parent' + return "node_examine_entity", kwargs + elif action == 'add': + # add/append parent + prot = _get_menu_prototype(caller) + current_prot_parent = prot.get('prototype_parent', None) + if current_prot_parent: + current_prot_parent = utils.make_iter(current_prot_parent) + if prototype_parent_key in current_prot_parent: + caller.msg("Prototype_parent {} is already used.".format(prototype_parent_key)) + return "node_prototype_parent" + else: + current_prot_parent.append(prototype_parent_key) + caller.msg("Add prototype parent for multi-inheritance.") + else: + current_prot_parent = prototype_parent_key + try: + if prototype_parent: + spawner.flatten_prototype(prototype_parent, validate=True) + else: + raise RuntimeError("Not found.") + except RuntimeError as err: + caller.msg("Selected prototype-parent {} " + "caused Error(s):\n|r{}|n".format(prototype_parent, err)) + return "node_prototype_parent" + _set_prototype_value(caller, "prototype_parent", current_prot_parent) + _get_flat_menu_prototype(caller, refresh=True) + elif action == "remove": + # remove prototype parent + prot = _get_menu_prototype(caller) + current_prot_parent = prot.get('prototype_parent', None) + if current_prot_parent: + current_prot_parent = utils.make_iter(current_prot_parent) + try: + current_prot_parent.remove(prototype_parent_key) + _set_prototype_value(caller, 'prototype_parent', current_prot_parent) + _get_flat_menu_prototype(caller, refresh=True) + caller.msg("Removed prototype parent {}.".format(prototype_parent_key)) + except ValueError: + caller.msg("|rPrototype-parent {} could not be removed.".format( + prototype_parent_key)) + return 'node_prototype_parent' def _prototype_parent_select(caller, new_parent): @@ -440,7 +555,7 @@ def _prototype_parent_select(caller, new_parent): else: raise RuntimeError("Not found.") except RuntimeError as err: - caller.msg("Selected prototype parent {} " + caller.msg("Selected prototype-parent {} " "caused Error(s):\n|r{}|n".format(new_parent, err)) else: ret = _set_property(caller, new_parent, @@ -466,6 +581,8 @@ def node_prototype_parent(caller): parent is given, this prototype must define the typeclass (next menu node). {current} + + {actions} """ helptext = """ Prototypes can inherit from one another. Changes in the child replace any values set in a @@ -488,13 +605,14 @@ def node_prototype_parent(caller): if not ptexts: ptexts.append("[No prototype_parent set]") - text = text.format(current="\n\n".join(ptexts)) + text = text.format(current="\n\n".join(ptexts), + actions=_format_list_actions("examine", "add", "remove")) text = (text, helptext) options = _wizard_options("prototype_parent", "prototype_key", "typeclass", color="|W") options.append({"key": "_default", - "goto": _prototype_parent_examine}) + "goto": _prototype_parent_actions}) return text, options @@ -508,33 +626,45 @@ def _all_typeclasses(caller): if name != "evennia.objects.models.ObjectDB") -def _typeclass_examine(caller, typeclass_path): - """Show info (docstring) about given typeclass.""" - if typeclass_path is None: - # this means we are exiting the listing - return "node_key" +def _typeclass_actions(caller, raw_inp, **kwargs): + """Parse actions for typeclass listing""" - typeclass = utils.get_all_typeclasses().get(typeclass_path) - if typeclass: - docstr = [] - for line in typeclass.__doc__.split("\n"): - if line.strip(): - docstr.append(line) - elif docstr: - break - docstr = '\n'.join(docstr) if docstr else "" - txt = "Typeclass |y{typeclass_path}|n; First paragraph of docstring:\n\n{docstring}".format( - typeclass_path=typeclass_path, docstring=docstr) - else: - txt = "This is typeclass |y{}|n.".format(typeclass) - caller.msg(txt) - return txt + choices = kwargs.get("available_choices", []) + typeclass_path, action = _default_parse( + raw_inp, choices, ("examine", "e", "l"), ("remove", "r", "delete", "d")) + + if typeclass_path: + if action == 'examine': + typeclass = utils.get_all_typeclasses().get(typeclass_path) + if typeclass: + docstr = [] + for line in typeclass.__doc__.split("\n"): + if line.strip(): + docstr.append(line) + elif docstr: + break + docstr = '\n'.join(docstr) if docstr else "" + txt = "Typeclass |c{typeclass_path}|n; " \ + "First paragraph of docstring:\n\n{docstring}".format( + typeclass_path=typeclass_path, docstring=docstr) + else: + txt = "This is typeclass |y{}|n.".format(typeclass) + return "node_examine_entity", {"text": txt, "back": "typeclass"} + elif action == 'remove': + prototype = _get_menu_prototype(caller) + old_typeclass = prototype.pop('typeclass', None) + if old_typeclass: + _set_menu_prototype(caller, prototype) + caller.msg("Cleared typeclass {}.".format(old_typeclass)) + else: + caller.msg("No typeclass to remove.") + return "node_typeclass" def _typeclass_select(caller, typeclass): """Select typeclass from list and add it to prototype. Return next node to go to.""" ret = _set_property(caller, typeclass, prop='typeclass', processor=str, next_node="node_key") - caller.msg("Selected typeclass |y{}|n.".format(typeclass)) + caller.msg("Selected typeclass |c{}|n.".format(typeclass)) return ret @@ -547,7 +677,10 @@ def node_typeclass(caller): one of the prototype's |cparents|n. {current} - """.format(current=_get_current_value(caller, "typeclass")) + + {actions} + """.format(current=_get_current_value(caller, "typeclass"), + actions=_format_list_actions("examine", "remove")) helptext = """ A |nTypeclass|n is specified by the actual python-path to the class definition in the @@ -561,7 +694,7 @@ def node_typeclass(caller): options = _wizard_options("typeclass", "prototype_parent", "key", color="|W") options.append({"key": "_default", - "goto": _typeclass_examine}) + "goto": _typeclass_actions}) return text, options @@ -598,16 +731,62 @@ def node_key(caller): # aliases node +def _all_aliases(caller): + "Get aliases in prototype" + prototype = _get_menu_prototype(caller) + return prototype.get("aliases", []) + + +def _aliases_select(caller, alias): + "Add numbers as aliases" + aliases = _all_aliases(caller) + try: + ind = str(aliases.index(alias) + 1) + if ind not in aliases: + aliases.append(ind) + _set_prototype_value(caller, "aliases", aliases) + caller.msg("Added alias '{}'.".format(ind)) + except (IndexError, ValueError) as err: + caller.msg("Error: {}".format(err)) + + return "node_aliases" + + +def _aliases_actions(caller, raw_inp, **kwargs): + """Parse actions for aliases listing""" + choices = kwargs.get("available_choices", []) + alias, action = _default_parse( + raw_inp, choices, ("remove", "r", "delete", "d")) + + aliases = _all_aliases(caller) + if alias and action == 'remove': + try: + aliases.remove(alias) + _set_prototype_value(caller, "aliases", aliases) + caller.msg("Removed alias '{}'.".format(alias)) + except ValueError: + caller.msg("No matching alias found to remove.") + else: + # if not a valid remove, add as a new alias + alias = raw_inp.lower().strip() + if alias not in aliases: + aliases.append(alias) + _set_prototype_value(caller, "aliases", aliases) + caller.msg("Added alias '{}'.".format(alias)) + else: + caller.msg("Alias '{}' was already set.".format(alias)) + return "node_aliases" + + +@list_node(_all_aliases, _aliases_select) def node_aliases(caller): text = """ |cAliases|n are alternative ways to address an object, next to its |cKey|n. Aliases are not case sensitive. - Add multiple aliases separating with commas. - - {current} - """.format(current=_get_current_value(caller, "aliases")) + {actions} + """.format(_format_list_actions("remove", prefix="|w|W to add new alias. Other action: ")) helptext = """ Aliases are fixed alternative identifiers and are stored with the new object. @@ -621,10 +800,7 @@ def node_aliases(caller): options = _wizard_options("aliases", "key", "attrs") options.append({"key": "_default", - "goto": (_set_property, - dict(prop="aliases", - processor=lambda s: [part.strip() for part in s.split(",")], - next_node="node_attrs"))}) + "goto": _aliases_actions}) return text, options @@ -633,38 +809,62 @@ def node_aliases(caller): def _caller_attrs(caller): prototype = _get_menu_prototype(caller) - attrs = prototype.get("attrs", []) + attrs = ["{}={}".format(tup[0], utils.crop(utils.to_str(tup[1]), width=10)) + for tup in prototype.get("attrs", [])] return attrs +def _get_tup_by_attrname(caller, attrname): + prototype = _get_menu_prototype(caller) + attrs = prototype.get("attrs", []) + try: + inp = [tup[0] for tup in attrs].index(attrname) + return attrs[inp] + except ValueError: + return None + + def _display_attribute(attr_tuple): """Pretty-print attribute tuple""" attrkey, value, category, locks = attr_tuple value = protlib.protfunc_parser(value) typ = type(value) - out = ("Attribute key: '{attrkey}' (category: {category}, " - "locks: {locks})\n" - "Value (parsed to {typ}): {value}").format( + out = ("|cAttribute key:|n '{attrkey}' " + "(|ccategory:|n {category}, " + "|clocks:|n {locks})\n" + "|cValue|n |W(parsed to {typ})|n:\n{value}").format( attrkey=attrkey, - category=category, locks=locks, + category=category if category else "|wNone|n", + locks=locks if locks else "|wNone|n", typ=typ, value=value) return out def _add_attr(caller, attr_string, **kwargs): """ - Add new attrubute, parsing input. - attr is entered on these forms - attr = value - attr;category = value - attr;category;lockstring = value + Add new attribute, parsing input. + Args: + caller (Object): Caller of menu. + attr_string (str): Input from user + attr is entered on these forms + attr = value + attr;category = value + attr;category;lockstring = value + Kwargs: + delete (str): If this is set, attr_string is + considered the name of the attribute to delete and + no further parsing happens. + Returns: + result (str): Result string of action. """ attrname = '' category = None locks = '' - if '=' in attr_string: + if 'delete' in kwargs: + attrname = attr_string + elif '=' in attr_string: attrname, value = (part.strip() for part in attr_string.split('=', 1)) attrname = attrname.lower() nameparts = attrname.split(";", 2) @@ -679,6 +879,15 @@ def _add_attr(caller, attr_string, **kwargs): prot = _get_menu_prototype(caller) attrs = prot.get('attrs', []) + if 'delete' in kwargs: + try: + ind = [tup[0] for tup in attrs].index(attrname) + del attrs[ind] + _set_prototype_value(caller, "attrs", attrs) + return "Removed Attribute '{}'".format(attrname) + except IndexError: + return "Attribute to delete not found." + try: # replace existing attribute with the same name in the prototype ind = [tup[0] for tup in attrs].index(attrname) @@ -697,26 +906,47 @@ def _add_attr(caller, attr_string, **kwargs): else: text = "Attribute must be given as 'attrname[;category;locks] = '." - options = {"key": "_default", - "goto": lambda caller: None} - return text, options + return text -def _edit_attr(caller, attrname, new_value, **kwargs): +def _attr_select(caller, attrstr): + attrname, _ = attrstr.split("=", 1) + attrname = attrname.strip() - attr_string = "{}={}".format(attrname, new_value) - - return _add_attr(caller, attr_string, edit=True) + attr_tup = _get_tup_by_attrname(caller, attrname) + if attr_tup: + return "node_examine_entity", \ + {"text": _display_attribute(attr_tup), "back": "attrs"} + else: + caller.msg("Attribute not found.") + return "node_attrs" -def _examine_attr(caller, selection): - prot = _get_menu_prototype(caller) - ind = [part[0] for part in prot['attrs']].index(selection) - attr_tuple = prot['attrs'][ind] - return _display_attribute(attr_tuple) +def _attrs_actions(caller, raw_inp, **kwargs): + """Parse actions for attribute listing""" + choices = kwargs.get("available_choices", []) + attrstr, action = _default_parse( + raw_inp, choices, ('examine', 'e'), ('remove', 'r', 'delete', 'd')) + if attrstr is None: + attrstr = raw_inp + attrname, _ = attrstr.split("=", 1) + attrname = attrname.strip() + attr_tup = _get_tup_by_attrname(caller, attrname) + + if attr_tup: + if action == 'examine': + return "node_examine_entity", \ + {"text": _display_attribute(attr_tup), "back": "attrs"} + elif action == 'remove': + res = _add_attr(caller, attr_tup, delete=True) + caller.msg(res) + else: + res = _add_attr(caller, raw_inp) + caller.msg(res) + return "node_attrs" -@list_node(_caller_attrs) +@list_node(_caller_attrs, _attr_select) def node_attrs(caller): text = """ @@ -729,8 +959,8 @@ def node_attrs(caller): To give an attribute without a category but with a lockstring, leave that spot empty (attrname;;lockstring=value). Attribute values can have embedded $protfuncs. - {current} - """.format(current=_get_current_value(caller, "attrs")) + {actions} + """.format(actions=_format_list_actions("examine", "remove", prefix="Actions: ")) helptext = """ Most commonly, Attributes don't need any categories or locks. If using locks, the lock-types @@ -747,10 +977,7 @@ def node_attrs(caller): options = _wizard_options("attrs", "aliases", "tags") options.append({"key": "_default", - "goto": (_set_property, - dict(prop="attrs", - processor=lambda s: [part.strip() for part in s.split(",")], - next_node="node_tags"))}) + "goto": _attrs_actions}) return text, options @@ -1410,7 +1637,7 @@ def node_prototype_load(caller, **kwargs): options = _wizard_options("prototype_load", "prototype_save", "index") options.append({"key": "_default", - "goto": _prototype_parent_examine}) + "goto": _prototype_parent_actions}) return text, options @@ -1468,6 +1695,7 @@ def start_olc(caller, session=None, prototype=None): """ menudata = {"node_index": node_index, "node_validate_prototype": node_validate_prototype, + "node_examine_entity": node_examine_entity, "node_prototype_key": node_prototype_key, "node_prototype_parent": node_prototype_parent, "node_typeclass": node_typeclass, diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 767919a7a9..4c0a2d3186 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -606,6 +606,8 @@ def validate_prototype(prototype, protkey=None, protparents=None, _flags['errors'].append( "{} has infinite nesting of prototypes.".format(protkey or prototype)) + if _flags['errors']: + raise RuntimeError("Error: " + "\nError: ".join(_flags['errors'])) _flags['visited'].append(id(prototype)) _flags['depth'] += 1 validate_prototype(protparent, protstring, protparents, @@ -618,7 +620,7 @@ def validate_prototype(prototype, protkey=None, protparents=None, # if we get back to the current level without a typeclass it's an error. if strict and is_prototype_base and _flags['depth'] <= 0 and not _flags['typeclass']: - _flags['errors'].append("Prototype {} has no `typeclass` defined anywhere in its parent " + _flags['errors'].append("Prototype {} has no `typeclass` defined anywhere in its parent\n " "chain. Add `typeclass`, or a `prototype_parent` pointing to a " "prototype with a typeclass.".format(protkey)) diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 221200672d..4b16ad9ab2 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -384,6 +384,14 @@ class TestMenuModule(EvenniaTest): {"prototype_key": "testthing", "key": "mytest"}), (True, Something)) + choices = ["test1", "test2", "test3", "test4"] + actions = (("examine", "e", "l"), ("add", "a"), ("foo", "f")) + self.assertEqual(olc_menus._default_parse("l4", choices, *actions), ('test4', 'examine')) + self.assertEqual(olc_menus._default_parse("add 2", choices, *actions), ('test2', 'add')) + self.assertEqual(olc_menus._default_parse("foo3", choices, *actions), ('test3', 'foo')) + self.assertEqual(olc_menus._default_parse("f3", choices, *actions), ('test3', 'foo')) + self.assertEqual(olc_menus._default_parse("f5", choices, *actions), (None, None)) + def test_node_helpers(self): caller = self.caller @@ -399,15 +407,20 @@ class TestMenuModule(EvenniaTest): # prototype_parent helpers self.assertEqual(olc_menus._all_prototype_parents(caller), ['test_prot']) - self.assertEqual(olc_menus._prototype_parent_examine( - caller, 'test_prot'), - "|cprototype key:|n test_prot, |ctags:|n None, |clocks:|n edit:all();spawn:all() " - "\n|cdesc:|n None \n|cprototype:|n " - "{\n 'typeclass': 'evennia.objects.objects.DefaultObject', \n}") - self.assertEqual(olc_menus._prototype_parent_select(caller, self.test_prot), "node_key") + # self.assertEqual(olc_menus._prototype_parent_parse( + # caller, 'test_prot'), + # "|cprototype key:|n test_prot, |ctags:|n None, |clocks:|n edit:all();spawn:all() " + # "\n|cdesc:|n None \n|cprototype:|n " + # "{\n 'typeclass': 'evennia.objects.objects.DefaultObject', \n}") + + with mock.patch("evennia.prototypes.menus.protlib.search_prototype", + new=mock.MagicMock(return_value=[_PROTPARENTS['GOBLIN']])): + self.assertEqual(olc_menus._prototype_parent_select(caller, "goblin"), "node_prototype_parent") + self.assertEqual(olc_menus._get_menu_prototype(caller), {'prototype_key': 'test_prot', 'prototype_locks': 'edit:all();spawn:all()', + 'prototype_parent': 'goblin', 'typeclass': 'evennia.objects.objects.DefaultObject'}) # typeclass helpers @@ -423,6 +436,7 @@ class TestMenuModule(EvenniaTest): self.assertEqual(olc_menus._get_menu_prototype(caller), {'prototype_key': 'test_prot', 'prototype_locks': 'edit:all();spawn:all()', + 'prototype_parent': 'goblin', 'typeclass': 'evennia.objects.objects.DefaultObject'}) # attr helpers @@ -459,7 +473,9 @@ class TestMenuModule(EvenniaTest): protlib.save_prototype(**self.test_prot) # spawn helpers - obj = olc_menus._spawn(caller, prototype=self.test_prot) + with mock.patch("evennia.prototypes.menus.protlib.search_prototype", + new=mock.MagicMock(return_value=[_PROTPARENTS['GOBLIN']])): + obj = olc_menus._spawn(caller, prototype=self.test_prot) self.assertEqual(obj.typeclass_path, "evennia.objects.objects.DefaultObject") self.assertEqual(obj.tags.get(category=spawner._PROTOTYPE_TAG_CATEGORY), self.test_prot['prototype_key']) @@ -475,7 +491,6 @@ class TestMenuModule(EvenniaTest): self.assertEqual(olc_menus._prototype_load_select(caller, self.test_prot['prototype_key']), "node_index") - @mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock( return_value=[{"prototype_key": "TestPrototype", "typeclass": "TypeClassTest", "key": "TestObj"}])) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 2f1b7d64fa..d21aec2c56 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -938,7 +938,7 @@ class EvMenu(object): for key, desc in optionlist: if not (key or desc): continue - desc_string = ": %s" % desc if desc else "" + desc_string = ": %s" % (desc if desc else "") table_width_max = max(table_width_max, max(m_len(p) for p in key.split("\n")) + max(m_len(p) for p in desc_string.split("\n")) + colsep)