Add functionality for object-update menu node, untested

This commit is contained in:
Griatch 2018-07-03 23:56:55 +02:00
parent c004c6678b
commit ec9813c256
5 changed files with 265 additions and 64 deletions

View file

@ -1,7 +1,29 @@
# 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.
# 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 +36,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 +55,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.

View file

@ -5,6 +5,7 @@ OLC Prototype menu nodes
"""
import json
from random import choice
from django.conf import settings
from evennia.utils.evmenu import EvMenu, list_node
from evennia.utils.ansi import strip_ansi
@ -469,7 +470,7 @@ def _caller_attrs(caller):
def _display_attribute(attr_tuple):
"""Pretty-print attribute tuple"""
attrkey, value, category, locks, default_access = attr_tuple
attrkey, value, category, locks = attr_tuple
value = protlib.protfunc_parser(value)
typ = type(value)
out = ("Attribute key: '{attrkey}' (category: {category}, "
@ -503,7 +504,7 @@ def _add_attr(caller, attr_string, **kwargs):
attrname, category = nameparts
elif nparts > 2:
attrname, category, locks = nameparts
attr_tuple = (attrname, category, locks)
attr_tuple = (attrname, value, category, locks)
if attrname:
prot = _get_menu_prototype(caller)
@ -513,7 +514,7 @@ def _add_attr(caller, attr_string, **kwargs):
# replace existing attribute with the same name in the prototype
ind = [tup[0] for tup in attrs].index(attrname)
attrs[ind] = attr_tuple
except IndexError:
except ValueError:
attrs.append(attr_tuple)
_set_prototype_value(caller, "attrs", attrs)
@ -541,7 +542,8 @@ def _edit_attr(caller, attrname, new_value, **kwargs):
def _examine_attr(caller, selection):
prot = _get_menu_prototype(caller)
attr_tuple = prot['attrs'][selection]
ind = [part[0] for part in prot['attrs']].index(selection)
attr_tuple = prot['attrs'][ind]
return _display_attribute(attr_tuple)
@ -572,15 +574,15 @@ def node_attrs(caller):
def _caller_tags(caller):
prototype = _get_menu_prototype(caller)
tags = prototype.get("tags")
tags = prototype.get("tags", [])
return tags
def _display_tag(tag_tuple):
"""Pretty-print attribute tuple"""
tagkey, category, data = tag_tuple
out = ("Tag: '{tagkey}' (category: {category}{})".format(
tagkey=tagkey, category=category, data=", data: {}".format(data) if data else ""))
out = ("Tag: '{tagkey}' (category: {category}{dat})".format(
tagkey=tagkey, category=category, dat=", data: {}".format(data) if data else ""))
return out
@ -613,16 +615,21 @@ def _add_tag(caller, tag, **kwargs):
old_tag = kwargs.get("edit", None)
if old_tag:
# editing a tag means removing the old and replacing with new
if not old_tag:
# a fresh, new tag
tags.append(tag_tuple)
else:
# old tag exists; editing a tag means removing the old and replacing with new
try:
ind = [tup[0] for tup in tags].index(old_tag)
del tags[ind]
if tags:
tags.insert(ind, tag_tuple)
else:
tags = [tag_tuple]
except IndexError:
pass
tags.append(tag_tuple)
_set_prototype_value(caller, "tags", tags)
text = kwargs.get('text')
@ -814,18 +821,121 @@ def node_prototype_locks(caller):
return text, options
def _update_spawned(caller, **kwargs):
"""update existing objects"""
prototype = kwargs['prototype']
objects = kwargs['objects']
back_node = kwargs['back_key']
num_changed = spawner.batch_update_objects_with_prototype(prototype, objects=objects)
caller.msg("|g{num} objects were updated successfully.|n".format(num=num_changed))
return back_key
def _keep_diff(caller, **kwargs):
key = kwargs['key']
diff = kwargs['diff']
diff[key] = "KEEP"
def node_update_objects(caller, **kwargs):
"""Offer options for updating objects"""
def _keep_option(keyname, prototype, 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,
"obj": 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)
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
obj = choice(update_objects)
diff, obj_prototype = spawner.prototype_diff_from_object(prototype, obj)
text = ["Suggested changes to {} objects".format(len(update_objects)),
"Showing random example obj to change: {name} (#{dbref}))\n".format(obj.key, obj.dbref)]
options = []
io = 0
for (key, inst) in sorted(((key, val) for key, val in diff.items()), key=lambda tup: tup[0]):
line = "{iopt} |w{key}|n: {old}{sep}{new} {change}"
old_val = utils.crop(str(obj_prototype[key]), width=20)
if inst == "KEEP":
text.append(line.format(iopt='', key=key, old=old_val, sep=" ", new='', change=inst))
continue
new_val = utils.crop(str(spawner.init_spawn_value(prototype[key])), width=20)
io += 1
if inst in ("UPDATE", "REPLACE"):
text.append(line.format(iopt=io, key=key, old=old_val,
sep=" |y->|n ", new=new_val, change=inst))
options.append(_keep_option(key, prototype,
obj, obj_prototype, diff, objects, back_node))
elif inst == "REMOVE":
text.append(line.format(iopt=io, key=key, old=old_val,
sep=" |r->|n ", new='', change=inst))
options.append(_keep_option(key, prototype,
obj, obj_prototype, diff, objects, back_node))
options.extend(
[{"key": ("|wu|r update {} objects".format(len(update_objects)), "update", "u"),
"goto": (_update_spawned, {"prototype": prototype, "objects": objects,
"back_node": back_node, "diff": diff})},
{"key": ("|wr|neset changes", "reset", "r"),
"goto": ("node_update_objects", {"prototype": prototype, "back_node": back_node,
"objects": update_objects})},
{"key": "|wb|rack ({})".format(back_node[5:], 'b'),
"goto": back_node}])
return text, options
def node_prototype_save(caller, **kwargs):
"""Save prototype to disk """
# these are only set if we selected 'yes' to save on a previous pass
accept_save = kwargs.get("accept", False)
prototype = kwargs.get("prototype", None)
accept_save = kwargs.get("accept_save", False)
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)
caller.msg("|gPrototype saved.|n")
return "node_spawn"
spawned_objects = protlib.search_objects_with_prototype(prototype_key)
nspawned = spawned_objects.count()
if nspawned:
text = ("Do you want to update {} object(s) "
"already using this prototype?".format(nspawned))
options = (
{"key": ("|wY|Wes|n", "yes", "y"),
"goto": ("node_update_objects",
{"accept_update": True, "objects": spawned_objects,
"prototype": prototype, "back_node": "node_prototype_save"})},
{"key": ("[|wN|Wo|n]", "n"),
"goto": "node_spawn"},
{"key": "_default",
"goto": "node_spawn"})
else:
text = "|gPrototype saved.|n"
options = {"key": "_default",
"goto": "node_spawn"}
return text, options
# not validated yet
prototype = _get_menu_prototype(caller)
@ -850,15 +960,13 @@ def node_prototype_save(caller, **kwargs):
options = (
{"key": ("[|wY|Wes|n]", "yes", "y"),
"goto": lambda caller:
node_prototype_save(caller,
{"accept": True, "prototype": prototype})},
"goto": ("node_prototype_save",
{"accept": True, "prototype": prototype})},
{"key": ("|wN|Wo|n", "n"),
"goto": "node_spawn"},
{"key": "_default",
"goto": lambda caller:
node_prototype_save(caller,
{"accept": True, "prototype": prototype})})
"goto": ("node_prototype_save",
{"accept": True, "prototype": prototype})})
return "\n".join(text), options
@ -869,20 +977,15 @@ def _spawn(caller, **kwargs):
new_location = kwargs.get('location', None)
if new_location:
prototype['location'] = new_location
obj = spawner.spawn(prototype)
if obj:
obj = obj[0]
caller.msg("|gNew instance|n {key} ({dbref}) |gspawned.|n".format(
key=obj.key, dbref=obj.dbref))
else:
caller.msg("|rError: Spawner did not return a new instance.|n")
def _update_spawned(caller, **kwargs):
"""update existing objects"""
prototype = kwargs['prototype']
objects = kwargs['objects']
num_changed = spawner.batch_update_objects_with_prototype(prototype, objects=objects)
caller.msg("|g{num} objects were updated successfully.|n".format(num=num_changed))
return obj
def node_prototype_spawn(caller, **kwargs):
@ -926,9 +1029,9 @@ def node_prototype_spawn(caller, **kwargs):
if spawned_objects:
options.append(
{"desc": "Update {num} existing objects with this prototype".format(num=nspawned),
"goto": (_update_spawned,
dict(prototype=prototype,
opjects=spawned_objects))})
"goto": ("node_update_objects",
dict(prototype=prototype, opjects=spawned_objects,
back_node="node_prototype_spawn"))})
options.extend(_wizard_options("prototype_spawn", "prototype_save", "index"))
return text, options
@ -1008,6 +1111,7 @@ def start_olc(caller, session=None, prototype=None):
"node_location": node_location,
"node_home": node_home,
"node_destination": node_destination,
"node_update_objects": node_o
"node_prototype_desc": node_prototype_desc,
"node_prototype_tags": node_prototype_tags,
"node_prototype_locks": node_prototype_locks,

View file

@ -547,7 +547,8 @@ def validate_prototype(prototype, protkey=None, protparents=None,
_flags = {"visited": [], "depth": 0, "typeclass": False, "errors": [], "warnings": []}
if not protparents:
protparents = {prototype['prototype_key']: prototype for prototype in search_prototype()}
protparents = {prototype.get('prototype_key', "").lower(): prototype
for prototype in search_prototype()}
protkey = protkey and protkey.lower() or prototype.get('prototype_key', None)
@ -568,17 +569,11 @@ def validate_prototype(prototype, protkey=None, protparents=None,
if 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(
"Prototype {} is based on typeclass {}, which could not be imported!".format(
protkey, typeclass))
# recursively traverese prototype_parent chain
if id(prototype) in _flags['visited']:
_flags['errors'].append(
"{} has infinite nesting of prototypes.".format(protkey or prototype))
_flags['visited'].append(id(prototype))
for protstring in make_iter(prototype_parent):
protstring = protstring.lower()
if protkey is not None and protstring == protkey:
@ -587,8 +582,15 @@ def validate_prototype(prototype, protkey=None, protparents=None,
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))
_flags['visited'].append(id(prototype))
_flags['depth'] += 1
validate_prototype(protparent, protstring, protparents, _flags)
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']:

View file

@ -179,6 +179,7 @@ def prototype_from_object(obj):
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 = {}
@ -187,6 +188,8 @@ def prototype_from_object(obj):
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
@ -233,6 +236,8 @@ def prototype_diff_from_object(prototype, obj):
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
@ -253,7 +258,7 @@ def prototype_diff_from_object(prototype, obj):
if key not in diff and key not in prot1:
diff[key] = "REMOVE"
return diff
return diff, prot2
def batch_update_objects_with_prototype(prototype, diff=None, objects=None):
@ -475,8 +480,12 @@ def spawn(*prototypes, **kwargs):
protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()}
# overload module's protparents with specifically given protparents
protparents.update(
{key.lower(): value for key, value in kwargs.get("prototype_parents", {}).items()})
# 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
@ -541,6 +550,9 @@ def spawn(*prototypes, **kwargs):
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,

View file

@ -17,6 +17,8 @@ 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"],
@ -24,21 +26,22 @@ _PROTPARENTS = {
"weaknesses": ["fire", "light"]
},
"GOBLIN_WIZARD": {
"prototype": "GOBLIN",
"prototype_parent": "GOBLIN",
"key": "goblin wizard",
"spells": ["fire ball", "lighting bolt"]
},
"GOBLIN_ARCHER": {
"prototype": "GOBLIN",
"prototype_parent": "GOBLIN",
"key": "goblin archer",
"attacks": ["short bow"]
},
"ARCHWIZARD": {
"prototype_parent": "GOBLIN",
"attacks": ["archwizard staff"],
},
"GOBLIN_ARCHWIZARD": {
"key": "goblin archwizard",
"prototype": ("GOBLIN_WIZARD", "ARCHWIZARD")
"prototype_parent": ("GOBLIN_WIZARD", "ARCHWIZARD")
}
}
@ -47,7 +50,8 @@ class TestSpawner(EvenniaTest):
def setUp(self):
super(TestSpawner, self).setUp()
self.prot1 = {"prototype_key": "testprototype"}
self.prot1 = {"prototype_key": "testprototype",
"typeclass": "evennia.objects.objects.DefaultObject"}
def test_spawn(self):
obj1 = spawner.spawn(self.prot1)
@ -323,6 +327,7 @@ class TestMenuModule(EvenniaTest):
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):
@ -334,6 +339,8 @@ class TestMenuModule(EvenniaTest):
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"})
@ -349,13 +356,16 @@ class TestMenuModule(EvenniaTest):
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_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', 'v')}])
'key': ('|wV|Walidate prototype', 'validate', 'v')}])
self.assertEqual(olc_menus._validate_prototype(self.test_prot, (False, Something)))
self.assertEqual(olc_menus._validate_prototype(self.test_prot), (False, Something))
self.assertEqual(olc_menus._validate_prototype(
{"prototype_key": "testthing", "key": "mytest"}),
(True, Something))
def test_node_helpers(self):
@ -363,23 +373,27 @@ class TestMenuModule(EvenniaTest):
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"),
"node_prototype_parent")
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_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 \n}')
"|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._get_menu_prototype(caller),
{'prototype_key': 'test_prot',
'prototype_locks': 'edit:all();spawn:all()',
'prototype_parent': "test_prot"})
'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"])
@ -394,6 +408,53 @@ class TestMenuModule(EvenniaTest):
'prototype_locks': 'edit:all();spawn:all()',
'typeclass': 'evennia.objects.objects.DefaultObject'})
# attr helpers
self.assertEqual(olc_menus._caller_attrs(caller), [])
self.assertEqual(olc_menus._add_attr(caller, "test1=foo1"), (Something, {"key": "_default", "goto": Something}))
self.assertEqual(olc_menus._add_attr(caller, "test2;cat1=foo2"), (Something, {"key": "_default", "goto": Something}))
self.assertEqual(olc_menus._add_attr(caller, "test3;cat2;edit:false()=foo3"), (Something, {"key": "_default", "goto": Something}))
self.assertEqual(olc_menus._add_attr(caller, "test4;cat3;set:true();edit:false()=foo4"), (Something, {"key": "_default", "goto": Something}))
self.assertEqual(olc_menus._add_attr(caller, "test5;cat4;set:true();edit:false()=123"), (Something, {"key": "_default", "goto": Something}))
self.assertEqual(olc_menus._caller_attrs(
caller),
[("test1", "foo1", None, ''),
("test2", "foo2", "cat1", ''),
("test3", "foo3", "cat2", "edit:false()"),
("test4", "foo4", "cat3", "set:true();edit:false()"),
("test5", '123', "cat4", "set:true();edit:false()")])
self.assertEqual(olc_menus._edit_attr(caller, "test1", "1;cat5;edit:all()"), (Something, {"key": "_default", "goto": Something}))
self.assertEqual(olc_menus._examine_attr(caller, "test1"), Something)
# tag helpers
self.assertEqual(olc_menus._caller_tags(caller), [])
self.assertEqual(olc_menus._add_tag(caller, "foo1"), (Something, {"key": "_default", "goto": Something}))
self.assertEqual(olc_menus._add_tag(caller, "foo2;cat1"), (Something, {"key": "_default", "goto": Something}))
self.assertEqual(olc_menus._add_tag(caller, "foo3;cat2;dat1"), (Something, {"key": "_default", "goto": Something}))
self.assertEqual(olc_menus._caller_tags(
caller),
[('foo1', None, ""),
('foo2', 'cat1', ""),
('foo3', 'cat2', "dat1")])
self.assertEqual(olc_menus._edit_tag(caller, "foo1", "bar1;cat1"), (Something, {"key": "_default", "goto": Something}))
self.assertEqual(olc_menus._display_tag(olc_menus._caller_tags(caller)[0]), Something)
self.assertEqual(olc_menus._caller_tags(caller)[0], ("bar1", "cat1", ""))
protlib.save_prototype(**self.test_prot)
# spawn helpers
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'])
self.assertEqual(olc_menus._update_spawned(caller, prototype=self.test_prot, objects=[obj]), 0) # no changes to apply
self.test_prot['key'] = "updated key" # change prototype
self.assertEqual(self._update_spawned(caller, prototype=self.test_prot, objects=[obj]), 1) # apply change to the one obj
# load helpers
@mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock(
return_value=[{"prototype_key": "TestPrototype",