Fix spawn issues in xyzgrid. Allow prototype_parent to be a dict itself. Resolve #2494.

This commit is contained in:
Griatch 2021-08-22 20:30:22 +02:00
parent fc323e1ca7
commit ddaf22ea58
12 changed files with 207 additions and 97 deletions

View file

@ -84,6 +84,8 @@ Up requirements to Django 3.2+
on-object Scripts. Moved `CmdScripts` and `CmdObjects` to `commands/default/building.py`.
- Keep GMCP function case if outputfunc starts with capital letter (so `cmd_name` -> `Cmd.Name`
but `Cmd_nAmE` -> `Cmd.nAmE`). This helps e.g Mudlet's legacy `Client_GUI` implementation)
- Prototypes now allow setting `prototype_parent` directly to a prototype-dict.
This makes it easier when dynamically building in-module prototypes.
### Evennia 0.9.5 (2019-2020)

View file

@ -56,6 +56,12 @@ Exits: northeast and east
command line. It will also make the `xyz_room` and `xyz_exit` prototypes
available for use as prototype-parents when spawning the grid.
3. Run `evennia xyzgrid help` for available options.
4. (Optional): By default, the xyzgrid will only spawn module-based
[prototypes](Prototypes). This is an optimization and usually makes sense
since the grid is entirely defined outside the game anyway. If you want to
also make use of in-game (db-) created prototypes, add
`XYZGRID_USE_DB_PROTOTYPES = True` to settings.
## Overview
@ -1002,8 +1008,8 @@ should be included as `prototype_parents` for prototypes on the map. Would it
not be nice to be able to change these and have the change apply to all of the
grid? You can, by adding the following to your `mygame/server/conf/settings.py`:
XYZROOM_PARENT_PROTOTYPE_OVERRIDE = {"typeclass": "myxyzroom.MyXYZRoom"}
XYZEXIT_PARENT_PROTOTYPE_OVERRIDE = {...}
XYZROOM_PROTOTYPE_OVERRIDE = {"typeclass": "myxyzroom.MyXYZRoom"}
XYZEXIT_PROTOTYPE_OVERRIDE = {...}
> If you override the typeclass in your prototypes, the typeclass used **MUST**

View file

@ -23,16 +23,24 @@ from evennia.contrib.xyzgrid import xymap_legend
# the typeclass inherits from the XYZRoom (or XYZExit)
# if adding the evennia.contrib.xyzgrid.prototypes to
# settings.PROTOTYPE_MODULES, one could just set the
# prototype_parent to 'xyz_room' and 'xyz_exit' respectively
# prototype_parent to 'xyz_room' and 'xyz_exit' here
# instead.
PARENT = {
ROOM_PARENT = {
"key": "An empty room",
"prototype_key": "xyzmap_room_map1",
"typeclass": "evennia.contrib.xyzgrid.xyzroom.XYZRoom",
"prototype_key": "xyz_exit_prototype",
"prototype_parent": "xyz_room",
# "typeclass": "evennia.contrib.xyzgrid.xyzroom.XYZRoom",
"desc": "An empty room.",
}
EXIT_PARENT = {
"prototype_key": "xyz_exit_prototype",
"prototype_parent": "xyz_exit",
# "typeclass": "evennia.contrib.xyzgrid.xyzroom.XYZExit",
"desc": "A path to the next location.",
}
# ---------------------------------------- map1
# The large tree
@ -134,13 +142,17 @@ PROTOTYPES_MAP1 = {
"desc": "These branches are wide enough to easily walk on. There's green all around."
},
# directional prototypes
(3, 0, 'w'): {
(3, 0, 'e'): {
"desc": "A dark passage into the underworld."
},
}
for prot in PROTOTYPES_MAP1.values():
prot['prototype_parent'] = PARENT
for key, prot in PROTOTYPES_MAP1.items():
if len(key) == 2:
# we don't want to give exits the room typeclass!
prot['prototype_parent'] = ROOM_PARENT
else:
prot['prototype_parent'] = EXIT_PARENT
XYMAP_DATA_MAP1 = {
@ -253,8 +265,12 @@ PROTOTYPES_MAP2 = {
# this is required by the prototypes, but we add it all at once so we don't
# need to add it to every line above
for prot in PROTOTYPES_MAP2.values():
prot['prototype_parent'] = PARENT
for key, prot in PROTOTYPES_MAP2.items():
if len(key) == 2:
# we don't want to give exits the room typeclass!
prot['prototype_parent'] = ROOM_PARENT
else:
prot['prototype_parent'] = EXIT_PARENT
XYMAP_DATA_MAP2 = {

View file

@ -18,9 +18,11 @@ Use `evennia xyzgrid help` for usage help.
from os.path import join as pathjoin
from django.conf import settings
import evennia
from evennia.utils import ansi
from evennia.contrib.xyzgrid.xyzgrid import get_xyzgrid
_HELP_SHORT = """
evennia xyzgrid help | list | init | add | spawn | initpath | delete [<options>]
Manages the XYZ grid. Use 'xyzgrid help <option>' for documentation.
@ -161,6 +163,8 @@ _TOPICS_MAP = {
"delete": _HELP_DELETE
}
evennia._init()
def _option_help(*suboptions):
"""
Show help <command> aid.

View file

@ -1057,19 +1057,21 @@ class TestMapStressTest(TestCase):
return f"{edge}\n{(l1 + l2) * Ysize}{l1}\n\n{edge}"
@parameterized.expand([
((10, 10), 0.01),
((100, 100), 1),
((10, 10), 0.03),
((100, 100), 5),
])
def test_grid_creation(self, gridsize, max_time):
"""
Test of grid-creataion performance for Nx, Ny grid.
"""
# import cProfile
Xmax, Ymax = gridsize
grid = self._get_grid(Xmax, Ymax)
t0 = time()
mapobj = xymap.XYMap({'map': grid}, Z="testmap")
t0 = time()
mapobj.parse()
# cProfile.runctx('mapobj.parse()', globals(), locals())
t1 = time()
self.assertLess(t1 - t0, max_time, f"Map creation of ({Xmax}x{Ymax}) grid slower "
f"than expected {max_time}s.")

View file

@ -109,10 +109,15 @@ from django.conf import settings
from evennia.utils.utils import variable_from_module, mod_import, is_iter
from evennia.utils import logger
from evennia.prototypes import prototypes as protlib
from evennia.prototypes.spawner import flatten_prototype
from .utils import MapError, MapParserError, BIGVAL
from . import xymap_legend
_NO_DB_PROTOTYPES = True
if hasattr(settings, "XYZGRID_USE_DB_PROTOTYPES"):
_NO_DB_PROTOTYPES = not settings.XYZGRID_USE_DB_PROTOTYPES
_CACHE_DIR = settings.CACHE_DIR
_LOADED_PROTOTYPES = None
_XYZROOMCLASS = None
@ -351,8 +356,9 @@ class XYMap:
if not prototype or isinstance(prototype, dict):
# nothing more to do
continue
# we need to load the prototype dict onto each for ease of access
proto = protlib.search_prototype(prototype, require_single=True)[0]
# we need to load the prototype dict onto each for ease of access. Note that
proto = protlib.search_prototype(prototype, require_single=True,
no_db=_NO_DB_PROTOTYPES)[0]
node_or_link_class.prototype = proto
def parse(self):
@ -492,13 +498,24 @@ class XYMap:
if node.prototype:
node_coord = (node.X, node.Y)
# load prototype from override, or use default
node.prototype = self.prototypes.get(
node_coord, self.prototypes.get(('*', '*'), node.prototype))
try:
node.prototype = flatten_prototype(self.prototypes.get(
node_coord,
self.prototypes.get(('*', '*'), node.prototype)),
no_db=_NO_DB_PROTOTYPES
)
except Exception as err:
raise MapParserError(f"Room prototype malformed: {err}", node)
# do the same for links (x, y, direction) coords
for direction, maplink in node.first_links.items():
maplink.prototype = self.prototypes.get(
node_coord + (direction,),
self.prototypes.get(('*', '*', '*'), maplink.prototype))
try:
maplink.prototype = flatten_prototype(self.prototypes.get(
node_coord + (direction,),
self.prototypes.get(('*', '*', '*'), maplink.prototype)),
no_db=_NO_DB_PROTOTYPES
)
except Exception as err:
raise MapParserError(f"Exit prototype malformed: {err}", maplink)
# store
self.display_map = display_map
@ -625,8 +642,8 @@ class XYMap:
spawned = []
# find existing nodes, in case some rooms need to be removed
map_coords = ((node.X, node.Y) for node in
sorted(self.node_index_map.values(), key=lambda n: (n.Y, n.X)))
map_coords = [(node.X, node.Y) for node in
sorted(self.node_index_map.values(), key=lambda n: (n.Y, n.X))]
for existing_room in _XYZROOMCLASS.objects.filter_xyz(xyz=(x, y, self.Z)):
roomX, roomY, _ = existing_room.xyz
if (roomX, roomY) not in map_coords:

View file

@ -311,7 +311,11 @@ class MapNode:
nodeobj = NodeTypeclass.objects.get_xyz(xyz=xyz)
except NodeTypeclass.DoesNotExist:
# create a new entity with proper coordinates etc
self.log(f" spawning room at xyz={xyz}")
tclass = self.prototype['typeclass']
tclass = (f' ({tclass})'
if tclass != 'evennia.contrib.xyzgrid.xyzroom.XYZRoom'
else '')
self.log(f" spawning room at xyz={xyz}{tclass}")
nodeobj, err = NodeTypeclass.create(
self.prototype.get('key', 'An empty room'),
xyz=xyz
@ -327,7 +331,6 @@ class MapNode:
# apply prototype to node. This will not override the XYZ tags since
# these are not in the prototype and exact=False
spawner.batch_update_objects_with_prototype(
self.prototype, objects=[nodeobj], exact=False)
@ -364,8 +367,6 @@ class MapNode:
link.prototype['prototype_key'] = self.generate_prototype_key()
maplinks[key.lower()] = (key, aliases, direction, link)
# if xyz == (8, 1, 'the large tree'):
# from evennia import set_trace;set_trace()
# remove duplicates
linkobjs = defaultdict(list)
for exitobj in ExitTypeclass.objects.filter_xyz(xyz=xyz):
@ -384,7 +385,6 @@ class MapNode:
# build all exits first run)
differing_keys = set(maplinks.keys()).symmetric_difference(set(linkobjs.keys()))
for differing_key in differing_keys:
# from evennia import set_trace;set_trace()
if differing_key not in maplinks:
# an exit without a maplink - delete the exit-object
@ -408,7 +408,12 @@ class MapNode:
if err:
raise RuntimeError(err)
linkobjs[key.lower()] = exi
self.log(f" spawning/updating exit xyz={xyz}, direction={key}")
prot = maplinks[key.lower()][3].prototype
tclass = prot['typeclass']
tclass = (f' ({tclass})'
if tclass != 'evennia.contrib.xyzgrid.xyzroom.XYZExit'
else '')
self.log(f" spawning/updating exit xyz={xyz}, direction={key}{tclass}")
# apply prototypes to catch any changes
for key, linkobj in linkobjs.items():

View file

@ -124,6 +124,9 @@ class XYZGrid(DefaultScript):
map_data_list = [variable_from_module(module_path, "XYMAP_DATA")]
# inject the python path in the map data
for mapdata in map_data_list:
if not mapdata:
self.log(f"Could not find or load map from {module_path}.")
return
mapdata['module_path'] = module_path
return map_data_list
@ -137,10 +140,14 @@ class XYZGrid(DefaultScript):
nmaps = 0
loaded_mapdata = {}
changed = []
mapdata = self.db.map_data
if not mapdata:
self.db.mapdata = mapdata = {}
# generate all Maps - this will also initialize their components
# and bake any pathfinding paths (or load from disk-cache)
for zcoord, old_mapdata in self.db.map_data.items():
for zcoord, old_mapdata in mapdata.items():
self.log(f"Loading map '{zcoord}'...")
@ -168,7 +175,7 @@ class XYZGrid(DefaultScript):
# re-store changed data
for zcoord in changed:
self.db.map_data[zcoord] = loaded_mapdata['zcoord']
self.db.map_data[zcoord] = loaded_mapdata[zcoord]
# store
self.log(f"Loaded and linked {nmaps} map(s).")
@ -222,7 +229,9 @@ class XYZGrid(DefaultScript):
Clear the entire grid, including database entities, then the grid too.
"""
self.remove_map(*(zcoord for zcoord in self.db.map_data), remove_objects=True)
mapdata = self.db.map_data
if mapdata:
self.remove_map(*(zcoord for zcoord in self.db.map_data), remove_objects=True)
super().delete()
def spawn(self, xyz=('*', '*', '*'), directions=None):
@ -291,6 +300,7 @@ def get_xyzgrid(print_errors=True):
if not xyzgrid.ndb.loaded:
xyzgrid.reload()
except Exception as err:
raise
if print_errors:
print(err)
else:

View file

@ -86,27 +86,20 @@ class XYZManager(ObjectManager):
possible with a unique combination of x,y,z).
"""
# filter by tags, then figure out of we got a single match or not
query = self.filter_xyz(xyz=xyz, **kwargs)
ncount = query.count()
if ncount == 1:
return query.first()
# error - mimic default get() behavior but with a little more info
x, y, z = xyz
# mimic get_family
paths = [self.model.path] + [
"%s.%s" % (cls.__module__, cls.__name__) for cls in self._get_subclasses(self.model)
]
kwargs["db_typeclass_path__in"] = paths
try:
return (
self
.filter(db_tags__db_key=str(x), db_tags__db_category=MAP_X_TAG_CATEGORY)
.filter(db_tags__db_key=str(y), db_tags__db_category=MAP_Y_TAG_CATEGORY)
.filter(db_tags__db_key=str(z), db_tags__db_category=MAP_Z_TAG_CATEGORY)
.get(**kwargs)
)
except self.model.DoesNotExist:
inp = (f"xyz=({x},{y},{z}), " +
",".join(f"{key}={val}" for key, val in kwargs.items()))
raise self.model.DoesNotExist(f"{self.model.__name__} "
f"matching query {inp} does not exist.")
inp = (f"Query: xyz=({x},{y},{z}), " +
",".join(f"{key}={val}" for key, val in kwargs.items()))
if ncount > 1:
raise self.model.MultipleObjectsReturned(inp)
else:
raise self.model.DoesNotExist(inp)
class XYZExitManager(XYZManager):

View file

@ -2580,7 +2580,7 @@ def node_prototype_spawn(caller, **kwargs):
# prototype load node
def _prototype_load_select(caller, prototype_key):
def _prototype_load_select(caller, prototype_key, **kwargs):
matches = protlib.search_prototype(key=prototype_key)
if matches:
prototype = matches[0]

View file

@ -105,17 +105,17 @@ def homogenize_prototype(prototype, custom_keys=None):
elif protkey in ("prototype_key", "prototype_desc"):
prototype[protkey] = ""
attrs = list(prototype.get("attrs", [])) # break reference
tags = make_iter(prototype.get("tags", []))
homogenized = {}
homogenized_tags = []
homogenized_attrs = []
homogenized_parents = []
homogenized = {}
for key, val in prototype.items():
if key in reserved:
# check all reserved keys
if key == "tags":
# tags must be on form [(tag, category, data), ...]
tags = make_iter(prototype.get("tags", []))
for tag in tags:
if not is_iter(tag):
homogenized_tags.append((tag, None, None))
@ -127,7 +127,9 @@ def homogenize_prototype(prototype, custom_keys=None):
homogenized_tags.append((tag[0], tag[1], None))
else:
homogenized_tags.append(tag[:3])
if key == "attrs":
elif key == "attrs":
attrs = list(prototype.get("attrs", [])) # break reference
for attr in attrs:
# attrs must be on form [(key, value, category, lockstr)]
if not is_iter(attr):
@ -144,6 +146,21 @@ def homogenize_prototype(prototype, custom_keys=None):
homogenized_attrs.append(attr[0], attr[1], attr[2], "")
else:
homogenized_attrs.append(attr[:4])
elif key == "prototype_parent":
# homogenize any prototype-parents embedded directly as dicts
protparents = prototype.get('prototype_parent', [])
if isinstance(protparents, dict):
protparents = [protparents]
for parent in make_iter(protparents):
if isinstance(parent, dict):
# recursively homogenize directly embedded prototype parents
homogenized_parents.append(
homogenize_prototype(parent, custom_keys=custom_keys))
else:
# normal prototype-parent names are added as-is
homogenized_parents.append(parent)
else:
# another reserved key
homogenized[key] = val
@ -154,6 +171,8 @@ def homogenize_prototype(prototype, custom_keys=None):
homogenized["attrs"] = homogenized_attrs
if homogenized_tags:
homogenized["tags"] = homogenized_tags
if homogenized_parents:
homogenized['prototype_parent'] = homogenized_parents
# add required missing parts that had defaults before
@ -460,7 +479,8 @@ def delete_prototype(prototype_key, caller=None):
return True
def search_prototype(key=None, tags=None, require_single=False, return_iterators=False):
def search_prototype(key=None, tags=None, require_single=False, return_iterators=False,
no_db=False):
"""
Find prototypes based on key and/or tags, or all prototypes.
@ -474,6 +494,9 @@ def search_prototype(key=None, tags=None, require_single=False, return_iterators
return_iterators (bool): Optimized return for large numbers of db-prototypes.
If set, separate returns of module based prototypes and paginate
the db-prototype return.
no_db (bool): Optimization. If set, skip querying for database-generated prototypes and only
include module-based prototypes. This can lead to a dramatic speedup since
module-prototypes are static and require no db-lookup.
Return:
matches (list): Default return, all found prototype dicts. Empty list if
@ -525,35 +548,38 @@ def search_prototype(key=None, tags=None, require_single=False, return_iterators
# prototype_from_object will modify the base prototype for every object
module_prototypes = [match.copy() for match in mod_matches.values()]
# search db-stored prototypes
if tags:
# exact match on tag(s)
tags = make_iter(tags)
tag_categories = ["db_prototype" for _ in tags]
db_matches = DbPrototype.objects.get_by_tag(tags, tag_categories)
if no_db:
db_matches = []
else:
db_matches = DbPrototype.objects.all()
if key:
# exact or partial match on key
exact_match = db_matches.filter(Q(db_key__iexact=key)).order_by("db_key")
if not exact_match and allow_fuzzy:
# try with partial match instead
db_matches = db_matches.filter(Q(db_key__icontains=key)).order_by("db_key")
# search db-stored prototypes
if tags:
# exact match on tag(s)
tags = make_iter(tags)
tag_categories = ["db_prototype" for _ in tags]
db_matches = DbPrototype.objects.get_by_tag(tags, tag_categories)
else:
db_matches = exact_match
db_matches = DbPrototype.objects.all()
if key:
# exact or partial match on key
exact_match = db_matches.filter(Q(db_key__iexact=key)).order_by("db_key")
if not exact_match and allow_fuzzy:
# try with partial match instead
db_matches = db_matches.filter(Q(db_key__icontains=key)).order_by("db_key")
else:
db_matches = exact_match
# convert to prototype
db_ids = db_matches.values_list("id", flat=True)
db_matches = (
Attribute.objects.filter(scriptdb__pk__in=db_ids, db_key="prototype")
.values_list("db_value", flat=True)
.order_by("scriptdb__db_key")
)
# convert to prototype
db_ids = db_matches.values_list("id", flat=True)
db_matches = (
Attribute.objects.filter(scriptdb__pk__in=db_ids, db_key="prototype")
.values_list("db_value", flat=True)
.order_by("scriptdb__db_key")
)
if key and require_single:
nmodules = len(module_prototypes)
ndbprots = db_matches.count()
ndbprots = db_matches.count() if db_matches else 0
if nmodules + ndbprots != 1:
raise KeyError(_(
"Found {num} matching prototypes among {module_prototypes}.").format(
@ -795,19 +821,29 @@ def validate_prototype(
err=err, protkey=protkey, typeclass=typeclass)
)
# recursively traverse prototype_parent chain
if prototype_parent and isinstance(prototype_parent, dict):
# the protparent is already embedded as a dict;
prototype_parent = [prototype_parent]
# recursively traverse 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 {protkey} tries to parent itself.").format(
protkey=protkey))
protparent = protparents.get(protstring)
if not protparent:
_flags["errors"].append(
_("Prototype {protkey}'s prototype_parent '{parent}' was not found.").format(
protkey=protkey, parent=protstring)
)
if isinstance(protstring, dict):
# an already embedded prototype_parent
protparent = protstring
protstring = None
else:
protstring = protstring.lower()
if protkey is not None and protstring == protkey:
_flags["errors"].append(_("Prototype {protkey} tries to parent itself.").format(
protkey=protkey))
protparent = protparents.get(protstring)
if not protparent:
_flags["errors"].append(
_("Prototype {protkey}'s `prototype_parent` (named '{parent}') "
"was not found.").format(protkey=protkey, parent=protstring)
)
# check for infinite recursion
if id(prototype) in _flags["visited"]:
_flags["errors"].append(
_("{protkey} has infinite nesting of prototypes.").format(
@ -818,9 +854,12 @@ def validate_prototype(
raise RuntimeError(f"{_ERRSTR}: " + f"\n{_ERRSTR}: ".join(_flags["errors"]))
_flags["visited"].append(id(prototype))
_flags["depth"] += 1
# next step of recursive validation
validate_prototype(
protparent, protstring, protparents, is_prototype_base=is_prototype_base, _flags=_flags
)
_flags["visited"].pop()
_flags["depth"] -= 1

View file

@ -220,10 +220,23 @@ def _get_prototype(inprot, protparents, uninherited=None, _workprot=None):
_workprot = {} if _workprot is None else _workprot
if "prototype_parent" in inprot:
# move backwards through the inheritance
for prototype in make_iter(inprot["prototype_parent"]):
prototype_parents = inprot["prototype_parent"]
if isinstance(prototype_parents, dict):
# protparent already embedded as-is
prototype_parents = [prototype_parents]
for prototype in make_iter(prototype_parents):
if isinstance(prototype, dict):
# protparent already embedded as-is
parent_prototype = prototype
else:
# protparent given by-name
parent_prototype = protparents.get(prototype.lower(), {})
# Build the prot dictionary in reverse order, overloading
new_prot = _get_prototype(
protparents.get(prototype.lower(), {}), protparents, _workprot=_workprot
parent_prototype, protparents, _workprot=_workprot
)
# attrs, tags have internal structure that should be inherited separately
@ -245,7 +258,7 @@ def _get_prototype(inprot, protparents, uninherited=None, _workprot=None):
return _workprot
def flatten_prototype(prototype, validate=False):
def flatten_prototype(prototype, validate=False, no_db=False):
"""
Produce a 'flattened' prototype, where all prototype parents in the inheritance tree have been
merged into a final prototype.
@ -253,6 +266,8 @@ def flatten_prototype(prototype, validate=False):
Args:
prototype (dict): Prototype to flatten. Its `prototype_parent` field will be parsed.
validate (bool, optional): Validate for valid keys etc.
no_db (bool, optional): Don't search db-based prototypes. This can speed up
searching dramatically since module-based prototypes are static.
Returns:
flattened (dict): The final, flattened prototype.
@ -261,7 +276,8 @@ def flatten_prototype(prototype, validate=False):
if prototype:
prototype = protlib.homogenize_prototype(prototype)
protparents = {prot["prototype_key"].lower(): prot for prot in protlib.search_prototype()}
protparents = {prot["prototype_key"].lower(): prot
for prot in protlib.search_prototype(no_db=no_db)}
protlib.validate_prototype(
prototype, None, protparents, is_prototype_base=validate, strict=validate
)