From a3995f5b673f0067ed75deb3a2fcc5f4d4fd1be8 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 18 Jul 2021 23:28:28 +0200 Subject: [PATCH] Fix remaining map contrib issues --- evennia/contrib/xyzgrid/commands.py | 22 ++++++- evennia/contrib/xyzgrid/launchcmd.py | 35 ++++++++++- evennia/contrib/xyzgrid/map_example.py | 14 ++--- evennia/contrib/xyzgrid/map_legend.py | 23 +++++-- evennia/contrib/xyzgrid/xymap.py | 31 ++++++--- evennia/contrib/xyzgrid/xyzgrid.py | 87 +++++++++++++++++--------- evennia/prototypes/prototypes.py | 9 ++- evennia/prototypes/spawner.py | 3 +- 8 files changed, 165 insertions(+), 59 deletions(-) diff --git a/evennia/contrib/xyzgrid/commands.py b/evennia/contrib/xyzgrid/commands.py index 912894606a..ab38e54060 100644 --- a/evennia/contrib/xyzgrid/commands.py +++ b/evennia/contrib/xyzgrid/commands.py @@ -191,6 +191,7 @@ class CmdGoto(COMMAND_DEFAULT_CLASS): # how quickly to step (seconds) auto_step_delay = 2 + default_xyz_path_interrupt_msg = "Pathfinding interrupted here." def _search_by_xyz(self, inp, xyz_start): inp = inp.strip("()") @@ -286,10 +287,14 @@ class CmdGoto(COMMAND_DEFAULT_CLASS): step_sequence=step_sequence, task=None ) + # the map can itself tell the stepper to stop the auto-step prematurely + interrupt_node_or_link = None # pop any extra links up until the next node - these are # not useful when dealing with exits while step_sequence: + if not interrupt_node_or_link and step_sequence[0].interrupt_path: + interrupt_node_or_link = step_sequence[0] if hasattr(step_sequence[0], "node_index"): break step_sequence.pop(0) @@ -298,13 +303,28 @@ class CmdGoto(COMMAND_DEFAULT_CLASS): exit_name, *_ = first_link.spawn_aliases.get( direction, current_node.direction_spawn_defaults.get(direction, ('unknown', ))) - if not caller.search(exit_name): + exit_obj = caller.search(exit_name) + if not exit_obj: # extra safety measure to avoid trying to walk over and over # if there's something wrong with the exit's name caller.msg(f"No exit '{exit_name}' found at current location. Aborting goto.") caller.ndb.xy_path_data = None return + if interrupt_node_or_link: + # premature stop of pathfind-step because of map node/link of interrupt type + if hasattr(interrupt_node_or_link, "node_index"): + message = exit_obj.destination.attributes.get( + "xyz_path_interrupt_msg", default=self.default_xyz_path_interrupt_msg) + # we move into the node/room and then stop + caller.execute_cmd(exit_name, session=session) + else: + # if the link is interrupted we don't cross it at all + message = exit_obj.attributes.get( + "xyz_path_interrupt_msg", default=self.default_xyz_path_interrupt_msg) + caller.msg(message) + return + # do the actual move - we use the command to allow for more obvious overrides caller.execute_cmd(exit_name, session=session) diff --git a/evennia/contrib/xyzgrid/launchcmd.py b/evennia/contrib/xyzgrid/launchcmd.py index d3506f3aa0..f6f4ca9b21 100644 --- a/evennia/contrib/xyzgrid/launchcmd.py +++ b/evennia/contrib/xyzgrid/launchcmd.py @@ -178,7 +178,14 @@ def _option_list(*suboptions): List/view grid. """ + xyzgrid = get_xyzgrid() + + # override grid's logger to echo directly to console + def _log(msg): + print(msg) + xyzgrid.log = _log + xymap_data = xyzgrid.grid if not xymap_data: print("The XYZgrid is currently empty. Use 'add' to add paths to your map data.") @@ -220,6 +227,7 @@ def _option_list(*suboptions): print("\nDisplayed map (as appearing in-game):\n\n" + ansi.parse_ansi(str(xymap))) print("\nRaw map string (including axes and invisible nodes/links):\n" + str(xymap.mapstring)) + print(f"\nCustom map options: {xymap.options}\n") legend = [] for key, node_or_link in xymap.legend.items(): legend.append(f"{key} - {node_or_link.__doc__.strip()}") @@ -241,6 +249,12 @@ def _option_add(*suboptions): """ grid = get_xyzgrid() + + # override grid's logger to echo directly to console + def _log(msg): + print(msg) + grid.log = _log + xymap_data_list = [] for path in suboptions: maps = grid.maps_from_module(path) @@ -291,7 +305,8 @@ def _option_build(*suboptions): print("Starting build ...") grid.spawn(xyz=(x, y, z)) - print("... build complete!") + print("... build complete!\nIt's recommended to reload the server to refresh caches if this " + "modified an existing grid.") def _option_initpath(*suboptions): @@ -300,6 +315,12 @@ def _option_initpath(*suboptions): """ grid = get_xyzgrid() + + # override grid's logger to echo directly to console + def _log(msg): + print(msg) + grid.log = _log + xymaps = grid.all_maps() nmaps = len(xymaps) for inum, xymap in enumerate(xymaps): @@ -317,6 +338,12 @@ def _option_delete(*suboptions): """ grid = get_xyzgrid() + + # override grid's logger to echo directly to console + def _log(msg): + print(msg) + grid.log = _log + if not suboptions: repl = input("WARNING: This will delete the ENTIRE Grid and wipe all rooms/exits!" "\nObjects/Chars inside deleted rooms will be moved to their home locations." @@ -326,7 +353,8 @@ def _option_delete(*suboptions): return print("Deleting grid ...") grid.delete() - print("... done.") + print("... done.\nPlease reload the server now; otherwise " + "removed rooms may linger in cache.") return zcoords = (part.strip() for part in suboptions) @@ -349,7 +377,8 @@ def _option_delete(*suboptions): print("Deleting selected xymaps ...") grid.remove_map(*zcoords, remove_objects=True) - print("... done. Remember to remove any links from remaining maps pointing to deleted maps.") + print("... done.\nPlease reload the server to refresh room caches." + "\nAlso remember to remove any links from remaining maps pointing to deleted maps.") def xyzcommand(*args): diff --git a/evennia/contrib/xyzgrid/map_example.py b/evennia/contrib/xyzgrid/map_example.py index 1fd206066d..289f7927b3 100644 --- a/evennia/contrib/xyzgrid/map_example.py +++ b/evennia/contrib/xyzgrid/map_example.py @@ -45,13 +45,13 @@ MAP1 = r""" 8 #-------#-#-------I \ / - 7 #-#---# #-t + 7 #-#---# t-# |\ | 6 #i#-#b--#-t | | 5 o-#---# \ / - 4 o-o-#-# + 4 o---#-# / d 3 #-----+-------# | d @@ -59,7 +59,7 @@ MAP1 = r""" v u 1 #---#>#-# / - 0 T-# + 0 #-T + 0 1 2 3 4 5 6 7 8 9 0 1 @@ -87,11 +87,11 @@ PROTOTYPES_MAP1 = { # node/room prototypes (3, 0): { "key": "Dungeon Entrance", - "desc": "To the west, a narrow opening leads into darkness." + "desc": "To the east, a narrow opening leads into darkness." }, (4, 1): { "key": "Under the foilage of a giant tree", - "desc": "High above the branches of a giant tree blocs out the sunlight. A slide " + "desc": "High above the branches of a giant tree blocks out the sunlight. A slide " "leading down from the upper branches ends here." }, (4, 4): { @@ -117,11 +117,11 @@ PROTOTYPES_MAP1 = { }, (5, 6): { "key": "On a huge branch", - "desc": "To the east is a glowing light, may be a teleporter." + "desc": "To the east is a glowing light, may be a teleporter to a higher branch." }, (9, 7): { "key": "On an enormous branch", - "desc": "To the east is a glowing light, may be a teleporter." + "desc": "To the west is a glowing light. It may be a teleporter to a lower branch." }, (10, 8): { "key": "A gorgeous view", diff --git a/evennia/contrib/xyzgrid/map_legend.py b/evennia/contrib/xyzgrid/map_legend.py index 123f4f4afa..39d0bfd9dd 100644 --- a/evennia/contrib/xyzgrid/map_legend.py +++ b/evennia/contrib/xyzgrid/map_legend.py @@ -15,6 +15,7 @@ except ImportError as err: "the SciPy package. Install with `pip install scipy'.") import uuid +from collections import defaultdict from evennia.prototypes import spawner from evennia.utils.utils import make_iter from .utils import MAPSCAN, REVERSE_DIRECTIONS, MapParserError, BIGVAL @@ -141,7 +142,7 @@ class MapNode: def log(self, msg): """log messages using the xygrid parent""" - self.xymap.xyzgrid.log(msg) + self.xymap.log(msg) def generate_prototype_key(self): """ @@ -301,17 +302,19 @@ class MapNode: xyz = self.get_spawn_xyz() - self.log(f" spawning/updating room at xyz={xyz}") try: 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}") nodeobj, err = NodeTypeclass.create( self.prototype.get('key', 'An empty room'), xyz=xyz ) if err: raise RuntimeError(err) + else: + self.log(f" updating existing room (if changed) at xyz={xyz}") if not self.prototype.get('prototype_key'): # make sure there is a prototype_key in prototype @@ -356,6 +359,17 @@ 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): + linkobjs[exitobj.key].append(exitobj) + for exitkey, exitobjs in linkobjs.items(): + for exitobj in exitobjs[1:]: + self.log(f" deleting duplicate {exitkey}") + exitobj.delete() + # we need to search for exits in all directions since some # may have been removed since last sync linkobjs = {exi.db_key.lower(): exi @@ -365,10 +379,11 @@ 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 - self.log(f" deleting exit at xyz={xyz}, direction={direction}") + self.log(f" deleting exit at xyz={xyz}, direction={differing_key}") linkobjs.pop(differing_key).delete() else: @@ -388,7 +403,7 @@ class MapNode: if err: raise RuntimeError(err) linkobjs[key.lower()] = exi - self.log(f" spawning/updating exit xyz={xyz}, direction={key}") + self.log(f" spawning/updating exit xyz={xyz}, direction={key}") # apply prototypes to catch any changes for key, linkobj in linkobjs.items(): diff --git a/evennia/contrib/xyzgrid/xymap.py b/evennia/contrib/xyzgrid/xymap.py index c988b758d6..e019d84297 100644 --- a/evennia/contrib/xyzgrid/xymap.py +++ b/evennia/contrib/xyzgrid/xymap.py @@ -115,9 +115,10 @@ from . import map_legend _CACHE_DIR = settings.CACHE_DIR _LOADED_PROTOTYPES = None +_XYZROOMCLASS = None MAP_DATA_KEYS = [ - "zcoord", "map", "legend", "prototypes", "options" + "zcoord", "map", "legend", "prototypes", "options", "module_path" ] # these are all symbols used for x,y coordinate spots @@ -280,6 +281,12 @@ class XYMap: return (f"") + def log(self, msg): + if self.xyzgrid: + self.xyzgrid.log(msg) + else: + logger.log_info(msg) + def reload(self, map_module_or_dict=None): """ (Re)Load a map. @@ -629,10 +636,23 @@ class XYMap: list: A list of nodes that were spawned. """ + global _XYZROOMCLASS + if not _XYZROOMCLASS: + from evennia.contrib.xyzgrid.xyzroom import XYZRoom as _XYZROOMCLASS x, y = xy wildcard = '*' 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))) + 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: + self.log(f" deleting room at {existing_room.xyz} (not found on map).") + existing_room.delete() + + # (re)build nodes (will not build already existing rooms) for node in sorted(self.node_index_map.values(), key=lambda n: (n.Y, n.X)): if (x in (wildcard, node.X)) and (y in (wildcard, node.Y)): node.spawn() @@ -758,15 +778,6 @@ class XYMap: directions.append(shortest_route_to[0]) path.extend(shortest_route_to[1][::-1] + [nextnode]) - if any(1 for step in shortest_route_to[1] if step.interrupt_path): - # detected an interrupt in linkage - discard what we have so far - directions = [] - path = [nextnode] - - if nextnode.interrupt_path and nextnode is not startnode: - directions = [] - path = [nextnode] - # we have the path - reverse to get the correct order directions = directions[::-1] path = path[::-1] diff --git a/evennia/contrib/xyzgrid/xyzgrid.py b/evennia/contrib/xyzgrid/xyzgrid.py index 0bf3f5f5a3..32663ea7a2 100644 --- a/evennia/contrib/xyzgrid/xyzgrid.py +++ b/evennia/contrib/xyzgrid/xyzgrid.py @@ -111,51 +111,77 @@ class XYZGrid(DefaultScript): """ - def reload(self): - """ - Reload and rebuild the grid. This is done on a server reload and is also necessary if adding - a new map since this may introduce new between-map traversals. - - """ - self.log("(Re)loading grid ...") - self.ndb.grid = {} - nmaps = 0 - # generate all Maps - this will also initialize their components - # and bake any pathfinding paths (or load from disk-cache) - for zcoord, mapdata in self.db.map_data.items(): - - self.log(f"Loading map '{zcoord}'...") - xymap = XYMap(dict(mapdata), Z=zcoord, xyzgrid=self) - xymap.parse() - xymap.calculate_path_matrix() - self.ndb.grid[zcoord] = xymap - nmaps += 1 - - # store - self.log(f"Loaded and linked {nmaps} map(s).") - self.ndb.loaded = True - - def maps_from_module(self, module): + def maps_from_module(self, module_path): """ Load map data from module. The loader will look for a dict XYMAP_DATA or a list of XYMAP_DATA_LIST (a list of XYMAP_DATA dicts). Each XYMAP_DATA dict should contain `{"xymap": mapstring, "zcoord": mapname/zcoord, "legend": dict, "prototypes": dict}`. Args: - module (module or str): A module or python-path to a module containing + module_path (module_path): A python-path to a module containing map data as either `XYMAP_DATA` or `XYMAP_DATA_LIST` variables. Returns: list: List of zero, one or more xy-map data dicts loaded from the module. """ - map_data_list = variable_from_module(module, "XYMAP_DATA_LIST") + map_data_list = variable_from_module(module_path, "XYMAP_DATA_LIST") if not map_data_list: - map_data_list = variable_from_module(module, "XYMAP_DATA") + map_data_list = variable_from_module(module_path, "XYMAP_DATA") if map_data_list: map_data_list = make_iter(map_data_list) + # inject the python path in the map data + for mapdata in map_data_list: + mapdata['module_path'] = module_path return map_data_list + def reload(self): + """ + Reload and rebuild the grid. This is done on a server reload. + + """ + self.log("(Re)loading grid ...") + self.ndb.grid = {} + nmaps = 0 + loaded_mapdata = {} + changed = [] + + # 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(): + + self.log(f"Loading map '{zcoord}'...") + + # we reload the map from module + new_mapdata = loaded_mapdata.get(zcoord) + if not new_mapdata: + if 'module_path' in old_mapdata: + for mapdata in self.maps_from_module(old_mapdata['module_path']): + loaded_mapdata[mapdata['zcoord']] = mapdata + else: + # nowhere to reload from - use what we have + loaded_mapdata[zcoord] = old_mapdata + + new_mapdata = loaded_mapdata.get(zcoord) + + if new_mapdata != old_mapdata: + self.log(f" XYMap data for Z='{zcoord}' has changed.") + changed.append(zcoord) + + xymap = XYMap(dict(new_mapdata), Z=zcoord, xyzgrid=self) + xymap.parse() + xymap.calculate_path_matrix() + self.ndb.grid[zcoord] = xymap + nmaps += 1 + + # re-store changed data + for zcoord in changed: + self.db.map_data[zcoord] = loaded_mapdata['zcoord'] + + # store + self.log(f"Loaded and linked {nmaps} map(s).") + self.ndb.loaded = True + def add_maps(self, *mapdatas): """ Add map or maps to the grid. @@ -163,9 +189,10 @@ class XYZGrid(DefaultScript): Args: *mapdatas (dict): Each argument is a dict structure `{"map": , "legend": , "name": , - "prototypes": }`. The `prototypes are + "prototypes": , "module_path": }`. The `prototypes are coordinate-specific overrides for nodes/links on the map, keyed with their - (X,Y) coordinate within that map. + (X,Y) coordinate within that map. The `module_path` is injected automatically + by self.maps_from_module. Raises: RuntimeError: If mapdata is malformed. diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 6e56ad6c63..c81ffe7363 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -484,7 +484,7 @@ def search_prototype(key=None, tags=None, require_single=False, return_iterators if key: if key in mod_matches: # exact match - module_prototypes = [mod_matches[key]] + module_prototypes = [mod_matches[key].copy()] allow_fuzzy = False else: # fuzzy matching @@ -494,7 +494,9 @@ def search_prototype(key=None, tags=None, require_single=False, return_iterators if key in prototype_key ] else: - module_prototypes = [match for match in mod_matches.values()] + # note - we return a copy of the prototype dict, otherwise using this with e.g. + # 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 @@ -1053,7 +1055,8 @@ def value_to_obj(value, force=True): stype = type(value) if is_iter(value): if stype == dict: - return {value_to_obj_or_any(key): value_to_obj_or_any(val) for key, val in value.iter()} + return {value_to_obj_or_any(key): value_to_obj_or_any(val) + for key, val in value.items()} else: return stype([value_to_obj_or_any(val) for val in value]) return dbid_to_obj(value, ObjectDB) diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 116f67dc12..bb6b749f0b 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -154,7 +154,8 @@ from evennia.prototypes.prototypes import ( _CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") -_PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") +_PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", + "prototype_locks", "prototype_parent") _PROTOTYPE_ROOT_NAMES = ( "typeclass", "key",