From 61ab313ee35cd4c24f3c0de8326937398df7fdcf Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 3 Jul 2021 18:05:49 +0200 Subject: [PATCH] Refactoring of map spawner mechanism. Still not working correctly --- evennia/contrib/xyzgrid/map_legend.py | 291 +++++++++++------- evennia/contrib/xyzgrid/tests.py | 79 +++-- evennia/contrib/xyzgrid/xymap.py | 244 ++++++++++----- evennia/contrib/xyzgrid/xyzgrid.py | 75 ++--- .../contrib/xyzgrid/{room.py => xyzroom.py} | 86 ++++-- evennia/objects/objects.py | 18 +- evennia/prototypes/prototypes.py | 161 +++++++--- evennia/typeclasses/managers.py | 4 +- evennia/utils/evmenu.py | 2 +- 9 files changed, 647 insertions(+), 313 deletions(-) rename evennia/contrib/xyzgrid/{room.py => xyzroom.py} (78%) diff --git a/evennia/contrib/xyzgrid/map_legend.py b/evennia/contrib/xyzgrid/map_legend.py index a7e0be2e76..0ab08816a0 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'.") from evennia.prototypes import spawner +from evennia.utils.utils import make_iter from .utils import MAPSCAN, REVERSE_DIRECTIONS, MapParserError, BIGVAL NodeTypeclass = None @@ -48,12 +49,9 @@ class MapNode: expected to be able to continue without some further in-game action not covered by the map (such as a guard or locked gate etc). - `prototype` (dict) - The default `prototype` dict to use for reproducing this map component - on the game grid. This is used if not overridden specifically for this coordinate. - - `deferred` (bool): A deferred node is used to indicate a link (currently) pointing to nowhere - because the end node is not yet available - usually because that node is on another map - and won't be available until the full grid has loaded. A deferred node doesn't need a symbol - but is returned from links. Links pointing to deferred nodes will be re-parsed once the entire - grid has been built, in order to correctly link maps together. + on the game grid. This is used if not overridden specifically for this coordinate. If this + is not given, nothing will be spawned for this coordinate (a 'virtual' node can be useful + for various reasons, mostly map-transitions). """ # symbol used to identify this link on the map @@ -61,26 +59,38 @@ class MapNode: # if printing this node should show another symbol. If set # to the empty string, use `symbol`. display_symbol = None - - # internal use. Set during generation, but is also used for identification of the node - node_index = None - - # this should always be left True and avoids inifinite loops during querying. - multilink = True # this will interrupt a shortest-path step (useful for 'points' of interest, stop before # a door etc). interrupt_path = False # the prototype to use for mapping this to the grid. prototype = None + # internal use. Set during generation, but is also used for identification of the node + node_index = None + # this should always be left True for Nodes and avoids inifinite loops during querying. + multilink = True + # default values to use if the exit doesn't have a 'spawn_aliases' iterable + direction_spawn_defaults = { + 'n': ('north', 'n'), + 'ne': ('northeast', 'ne', 'north-east'), + 'e': ('east',), + 'se': ('southeast', 'se', 'south-east'), + 's': ('south', 's'), + 'sw': ('southwest', 'sw', 'south-west'), + 'w': ('west', 'w'), + 'nw': ('northwest', 'nw', 'north-west'), + 'd' : ('down', 'd', 'do'), + 'u' : ('up', 'u'), + } - def __init__(self, x, y, node_index=0, xymap=None): + def __init__(self, x, y, Z, node_index=0, xymap=None): """ Initialize the mapnode. Args: x (int): Coordinate on xygrid. y (int): Coordinate on xygrid. + Z (int or str): Name/Z-pos of this map. node_index (int): This identifies this node with a running index number required for pathfinding. This is used internally and should not be set manually. @@ -97,6 +107,7 @@ class MapNode: # XYgrid coordinate self.X = x // 2 self.Y = y // 2 + self.Z = Z self.node_index = node_index @@ -124,22 +135,21 @@ class MapNode: def __repr__(self): return str(self) - def build_links(self, xygrid): + def build_links(self): """ This is called by the map parser when this node is encountered. It tells the node to scan in all directions and follow any found links to other nodes. Since there could be multiple steps to reach another node, the system will iterate down each path and store it once and for all. - Args: - xygrid (dict): A 2d dict-of-dicts with x,y coordinates as keys and nodes as values. - Notes: This sets up all data needed for later use of this node in pathfinding and other operations. The method can't run immediately when the node is created since a complete parsed xygrid is required. """ + xygrid = self.xymap.xygrid + # we must use the xygrid coordinates x, y = self.x, self.y @@ -153,7 +163,7 @@ class MapNode: # just because there is a link here, doesn't mean it has a # connection in this direction. If so, the `end_node` will be None. - end_node, weight, steps = link.traverse(REVERSE_DIRECTIONS[direction], xygrid) + end_node, weight, steps = link.traverse(REVERSE_DIRECTIONS[direction]) if end_node: # the link could be followed to an end node! @@ -207,14 +217,10 @@ class MapNode: link_graph[node_index] = weight return link_graph - def get_display_symbol(self, xygrid, xymap=None, **kwargs): + def get_display_symbol(self): """ Hook to override for customizing how the display_symbol is determined. - Args: - xygrid (dict): 2D dict with x,y coordinates as keys. - xymap (XYMap): Main Map object. - Returns: str: The display-symbol to use. This must visually be a single character but could have color markers, use a unicode font etc. @@ -225,8 +231,21 @@ class MapNode: """ return self.symbol if self.display_symbol is None else self.display_symbol - def sync_node_to_grid(self): + def get_spawn_coords(self): """ + This should return the XYZ-coordinates for spawning this node. This normally + the XYZ of the current map, but for traversal-nodes, it can also be the location + on another map. + + Returns: + tuple: The (X, Y, Z) coords to spawn this node at. + """ + return self.X, self.Y, self.Z + + def spawn(self): + """ + Build an actual in-game room from this node. + This should be called as part of the node-sync step of the map sync. The reason is that the exits (next step) requires all nodes to exist before they can link up to their destinations. @@ -234,77 +253,120 @@ class MapNode: """ global NodeTypeclass if not NodeTypeclass: - from .room import XYZRoom as NodeTypeclass + from .xyzroom import XYZRoom as NodeTypeclass - coord = (self.X, self.Y, self.xymap.name) + if not self.prototype: + # no prototype means we can't spawn anything - + # a 'virtual' node. + return + + coord = self.get_spawn_coords() try: nodeobj = NodeTypeclass.objects.get_xyz(coord=coord) except NodeTypeclass.DoesNotExist: # create a new entity with proper coordinates etc - nodeobj = NodeTypeclass.create( + nodeobj, err = NodeTypeclass.create( self.prototype.get('key', 'An Empty room'), coord=coord ) + if err: + raise RuntimeError(err) # 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) - def sync_links_to_grid(self): + def spawn_links(self, only_directions=None): """ + Build actual in-game exits based on the links out of this room. + + Args: + only_directions (list, optional): If given, this should be a list of supported + directions (n, ne, etc). Only links in these directions will be spawned + for this node. + This should be called after all `sync_node_to_grid` operations have finished across the entire XYZgrid. This creates/syncs all exits to their locations and destinations. """ - coord = (self.X, self.Y, self.xymap.name) + coord = (self.X, self.Y, self.Z) global ExitTypeclass if not ExitTypeclass: - from .room import XYZExit as ExitTypeclass + from .xyzroom import XYZExit as ExitTypeclass + + maplinks = {} + for direction, link in self.first_links.items(): + key, *aliases = ( + make_iter(link.spawn_aliases) + if link.spawn_aliases + else self.direction_spawn_defaults.get(direction, ('unknown',)) + ) + maplinks[key.lower()] = (key, aliases, direction, link) - maplinks = self.first_links # we need to search for exits in all directions since some # may have been removed since last sync - linkobjs = {exi.db_key: exi for exi in ExitTypeclass.filter_xyz(coord=coord)} + linkobjs = {exi.db_key.lower(): exi + for exi in ExitTypeclass.objects.filter_xyz(coord=coord)} # figure out if the topology changed between grid and map (will always # build all exits first run) - differing_directions = set(maplinks.keys()).symmetric_difference(set(linkobjs.keys())) - for direction in differing_directions: - if direction in linkobjs: - # an exit without a maplink - delete the exit - linkobjs.pop(direction).delete() - else: - # a maplink without an exit - create the exit + differing_keys = set(maplinks.keys()).symmetric_difference(set(linkobjs.keys())) + for differing_key in differing_keys: - link = maplinks[direction] + if differing_key not in maplinks: + # an exit without a maplink - delete the exit-object + linkobjs.pop(differing_key).delete() + else: + # missing in linkobjs - create a new exit + key, aliases, direction, link = maplinks[differing_key] exitnode = self.links[direction] linkobjs[direction] = ExitTypeclass.create( - link.prototype.get('key', direction), + # either get name from the prototype or use our custom set + key, coord=coord, - destination_coord=(exitnode.X, exitnode.Y, exitnode.xymap.name) + destination_coord=exitnode.get_spawn_coords(), + aliases=aliases, ) + # apply prototypes to catch any changes for direction, linkobj in linkobjs: spawner.batch_update_objects_with_prototype( maplinks[direction].prototype, objects=[linkobj], exact=False) + def unspawn(self): + """ + Remove all spawned objects related to this node and all links. + + """ + global NodeTypeclass + if not NodeTypeclass: + from .room import XYZRoom as NodeTypeclass + + try: + nodeobj = NodeTypeclass.objects.get_xyz(coord=coord) + except NodeTypeclass.DoesNotExist: + # no object exists + pass + else: + nodeobj.delete() + class TransitionMapNode(MapNode): """ - Entering this node teleports the user to another Map (this is completely handled by the - prototyped Room class). This teleportation is not understood by the pathfinder, so why it will - be possible to pathfind to this node, it really represents a map transition. Only a single link - must ever be connected to this node. + This node acts as an end-node for a link that actually leads to a specific node on another + map. It is not actually represented by a separate room in-game. + + This teleportation is not understood by the pathfinder, so why it will be possible to pathfind + to this node, it really represents a map transition. Only a single link must ever be connected + to this node. Properties: - - `linked_map_name` (str) - the map you will move to when entering this node. - - `linked_coords` (tuple) - the XY coordinates *on the linked* map this node - will teleport to. This must be another node that is not a TransitionMapNode. - Note that for the trip to be two-way, a similar set up must be created from the - other map. + - `target_map_coord` (tuple) - the (X, Y, Z) coordinate of a node on the other map to teleport + to when moving to this node. This should not be another TransitionMapNode (see below for + how to make a two-way link). Examples: :: @@ -312,18 +374,26 @@ class TransitionMapNode(MapNode): map1 map2 #-T #- - one-way transition from map1 -> map2. - #-T T-# - two-way. Both ExternalMapNodes links to the coords of the - `#` (NOT the `T`) on the other map! + #-T T-# - two-way. Both TransitionMapNodes links to the coords of the + actual rooms (`#`) on the other map (NOT to the `T`s)! """ symbol = 'T' display_symbol = ' ' - linked_map_name = "" - linked_map_coords = None + # X,Y,Z coordinates of target node (not a transitionalmapnode) + taget_map_coord = (None, None, None) - def build_links(self, xygrid): + def get_spawn_coords(self): + """ + Make sure to return the coord of the *target* - this will be used when building + the exit to this node (since the prototype is None, this node itself will not be built). + + """ + return self.target_map_coord + + def build_links(self): """Check so we don't have too many links""" - super().build_links(xygrid) + super().build_links() if len(self.links) > 1: raise MapParserError("may have at most one link connecting to it.", self) @@ -349,9 +419,7 @@ class MapLink: - `display_symbol` (str or None) - This is what is used to visualize this node later. This symbol must still only have a visual size of 1, but you could e.g. use some fancy unicode character (be aware of encodings to different clients though) or, commonly, add color - tags around it. For further customization, the `.get_display_symbol` method receives - the full grid and can return a dynamically determined display symbol. If `None`, the - `symbol` is used. + tags around it. For further customization, the `.get_display_symbol` can be used. - `default_weight` (int) - Each link direction covered by this link can have its seprate weight, this is used if none is specified in a particular direction. This value must be >= 1, and can be higher than 1 if a link should be less favored. @@ -380,10 +448,9 @@ class MapLink: on the game grid. This is only relevant for the *first* link out of a Node (the continuation of the link is only used to determine its destination). This can be overridden on a per-direction basis. - - `requires_grid` (bool): If set, it indicates this component requires the full grid (multiple - maps to be available before it can be processed. This is usually only needed for - inter-map traversal links where the other map must already be ready. Note that this is - *only* relevant for the *first* link out of a node. + - `spawn_aliases` (list): A list of [key, alias, alias, ...] for the node to use when spawning + exits from this link. If not given, a sane set of defaults (n=north etc) will be used. This + is required if you use any custom directions outside of the cardinal directions + up/down. """ # symbol for identifying this link on the map @@ -421,15 +488,19 @@ class MapLink: interrupt_path = False # prototype for the first link out of a node. prototype = None + # used for spawning, if the exit prototype doesn't contain an explicit key. + # if neither that nor this is not given, the central node's direction_aliases will be used. + # the first element of this list is the key, the others are the aliases. + spawn_aliases = [] - - def __init__(self, x, y, xymap=None): + def __init__(self, x, y, Z, xymap=None): """ Initialize the link. Args: x (int): The xygrid x coordinate y (int): The xygrid y coordinate. + X (int or str): The name/Z-coord of this map we are on. xymap (XYMap, optional): The map object this sits on. """ @@ -440,6 +511,7 @@ class MapLink: self.X = x / 2 self.Y = y / 2 + self.Z = Z def __str__(self): return f"" @@ -447,14 +519,13 @@ class MapLink: def __repr__(self): return str(self) - def traverse(self, start_direction, xygrid, _weight=0, _linklen=1, _steps=None): + def traverse(self, start_direction, _weight=0, _linklen=1, _steps=None): """ Recursively traverse the links out of this LinkNode. Args: start_direction (str): The direction (n, ne etc) from which this traversal originates for this link. - xygrid (dict): 2D dict with x,y coordinates as keys. Kwargs: _weight (int): Internal use. _linklen (int): Internal use. @@ -469,7 +540,9 @@ class MapLink: MapParserError: If a link lead to nowhere. """ - end_direction = self.get_direction(start_direction, xygrid) + xygrid = self.xymap.xygrid + + end_direction = self.get_direction(start_direction) if not end_direction: if _steps is None: # is perfectly okay to not be linking back on the first step (to a node) @@ -486,19 +559,13 @@ class MapLink: next_target = xygrid[end_x][end_y] except KeyError: # check if we have some special action up our sleeve - next_target = self.at_empty_target(start_direction, end_direction, xygrid) + next_target = self.at_empty_target(start_direction, end_direction) if not next_target: raise MapParserError( f"points to empty space in the direction {end_direction}!", self) - if ((hasattr(next_target, "deferred") and next_target.deferred) - or (next_target.xymap.name != self.xymap.name)): - # this target is either deferred until grid exists, or sits on another map. Immediately - # exit the traversal and set a high weight. - return (next_target, BIGVAL, [self]) - - _weight += self.get_weight(start_direction, xygrid, _weight) + _weight += self.get_weight(start_direction, _weight) if _steps is None: _steps = [] _steps.append(self) @@ -515,15 +582,14 @@ class MapLink: # we hit another link. Progress recursively. return next_target.traverse( REVERSE_DIRECTIONS.get(end_direction, end_direction), - xygrid, _weight=_weight, _linklen=_linklen + 1, _steps=_steps) + _weight=_weight, _linklen=_linklen + 1, _steps=_steps) - def get_linked_neighbors(self, xygrid, directions=None): + def get_linked_neighbors(self, directions=None): """ A helper to get all directions to which there appears to be a visual link/node. This does not trace the length of the link and check weights etc. Args: - xygrid (dict): 2D dict with x,y coordinates as keys. directions (list, optional): Only scan in these directions. Returns: @@ -533,6 +599,7 @@ class MapLink: if not directions: directions = REVERSE_DIRECTIONS.keys() + xygrid = self.xymap.xygrid links = {} for direction in directions: dx, dy = MAPSCAN[direction] @@ -542,11 +609,11 @@ class MapLink: # a map node or a link connecting in our direction node_or_link = xygrid[end_x][end_y] if (node_or_link.multilink - or node_or_link.get_direction(direction, xygrid)): + or node_or_link.get_direction(direction)): links[direction] = node_or_link return links - def at_empty_target(self, start_direction, end_direction, xygrid): + def at_empty_target(self, start_direction, end_direction): """ This is called by `.traverse` when it finds this link pointing to nowhere. @@ -554,7 +621,6 @@ class MapLink: start_direction (str): The direction (n, ne etc) from which this traversal originates for this link. end_direction (str): The direction found from `get_direction` earlier. - xygrid (dict): 2D dict with x,y coordinates as keys. Returns: MapNode, MapLink or None: The next target to go to from here. `None` if this @@ -567,14 +633,13 @@ class MapLink: """ return None - def get_direction(self, start_direction, xygrid, **kwargs): + def get_direction(self, start_direction, **kwargs): """ Hook to override for customizing how the directions are determined. Args: start_direction (str): The starting direction (n, ne etc). - xygrid (dict): 2D dict with x,y coordinates as keys. Returns: str: The 'out' direction side of the link - where the link @@ -588,13 +653,12 @@ class MapLink: """ return self.directions.get(start_direction) - def get_weight(self, start_direction, xygrid, current_weight, **kwargs): + def get_weight(self, start_direction, current_weight, **kwargs): """ Hook to override for customizing how the weights are determined. Args: start_direction (str): The starting direction (n, ne etc). - xygrid (dict): 2D dict with x,y coordinates as keys. current_weight (int): This can have an existing value if we are progressing down a multi-step path. @@ -604,15 +668,11 @@ class MapLink: """ return self.weights.get(start_direction, self.default_weight) - def get_display_symbol(self, xygrid, xymap=None, **kwargs): + def get_display_symbol(self): """ Hook to override for customizing how the display_symbol is determined. This is called after all other hooks, at map visualization. - Args: - xygrid (dict): 2D dict with x,y coordinates as keys. - xymap (XYMap): The map object this sits on. - Returns: str: The display-symbol to use. This must visually be a single character but could have color markers, use a unicode font etc. @@ -657,7 +717,7 @@ class SmartRerouterMapLink(MapLink): """ multilink = True - def get_direction(self, start_direction, xygrid): + def get_direction(self, start_direction): """ Dynamically determine the direction based on a source direction and grid topology. @@ -665,7 +725,7 @@ class SmartRerouterMapLink(MapLink): # get all visually connected links if not self.directions: directions = {} - unhandled_links = list(self.get_linked_neighbors(xygrid).keys()) + unhandled_links = list(self.get_linked_neighbors().keys()) # get all straight lines (n-s, sw-ne etc) we can trace through # the dynamic link and remove them from the unhandled_links list @@ -726,7 +786,7 @@ class TeleporterMapLink(MapLink): super().__init__(*args, **kwargs) self.paired_teleporter = None - def at_empty_target(self, start_direction, end_direction, xygrid): + def at_empty_target(self, start_direction, end_direction): """ Called during traversal, when finding an unknown direction out of the link (same as targeting a link at an empty spot on the grid). This will also search for @@ -735,7 +795,6 @@ class TeleporterMapLink(MapLink): Args: start_direction (str): The direction (n, ne etc) from which this traversal originates for this link. - xygrid (dict): 2D dict with x,y coordinates as keys. Returns: TeleporterMapLink: The paired teleporter. @@ -746,6 +805,7 @@ class TeleporterMapLink(MapLink): 'pointing to an empty space' error we'd get if returning `None`. """ + xygrid = self.xymap.xygrid if not self.paired_teleporter: # scan for another teleporter symbol = self.symbol @@ -769,13 +829,13 @@ class TeleporterMapLink(MapLink): return self.paired_teleporter - def get_direction(self, start_direction, xygrid): + def get_direction(self, start_direction): """ Figure out the connected link and paired teleport. """ if not self.directions: - neighbors = self.get_linked_neighbors(xygrid) + neighbors = self.get_linked_neighbors() if len(neighbors) != 1: raise MapParserError("must have exactly one link connected to it.", self) @@ -846,7 +906,7 @@ class SmartMapLink(MapLink): """ multilink = True - def get_direction(self, start_direction, xygrid): + def get_direction(self, start_direction): """ Figure out the direction from a specific source direction based on grid topology. @@ -854,7 +914,7 @@ class SmartMapLink(MapLink): # get all visually connected links if not self.directions: directions = {} - neighbors = self.get_linked_neighbors(xygrid) + neighbors = self.get_linked_neighbors() nodes = [direction for direction, neighbor in neighbors.items() if hasattr(neighbor, 'node_index')] @@ -914,7 +974,7 @@ class InvisibleSmartMapLink(SmartMapLink): (('ne', 'sw'), ('sw', 'ne')): '/', } - def get_display_symbol(self, xygrid, xymap=None, **kwargs): + def get_display_symbol(self): """ The SmartMapLink already calculated the directions before this, so we just need to figure out what to replace this with in order to make this 'invisible' @@ -924,7 +984,7 @@ class InvisibleSmartMapLink(SmartMapLink): """ if not hasattr(self, "_cached_display_symbol"): - legend = xymap.legend + legend = self.xymap.legend default_symbol = ( self.symbol if self.display_symbol is None else self.display_symbol) self._cached_display_symbol = default_symbol @@ -939,8 +999,8 @@ class InvisibleSmartMapLink(SmartMapLink): if node_or_link_class: # initiate class in the current location and run get_display_symbol # to get what it would show. - self._cached_display_symbol = node_or_link_class( - self.x, self.y).get_display_symbol(xygrid, xymap=xymap, **kwargs) + self._cached_display_symbol = ( + node_or_link_class(self.x, self.y, self.Z).get_display_symbol()) return self._cached_display_symbol @@ -950,15 +1010,15 @@ class InvisibleSmartMapLink(SmartMapLink): class BasicMapNode(MapNode): """Basic map Node""" symbol = "#" + prototype = "xyz_room_prototype" class MapTransitionMapNode(TransitionMapNode): - """Teleports entering players to other map""" + """Transition-target to other map""" symbol = "T" display_symbol = " " - interrupt_path = True - linked_map_name = "" - linked_map_coords = None + target_map_coords = (0, 0, 'unset') # must be changed + prototype = None # important! class InterruptMapNode(MapNode): @@ -966,30 +1026,35 @@ class InterruptMapNode(MapNode): symbol = "I" display_symbol = "#" interrupt_path = True + prototype = "xyz_room_prototype" class NSMapLink(MapLink): """Two-way, North-South link""" symbol = "|" directions = {"n": "s", "s": "n"} + prototype = "xyz_exit_prototype" class EWMapLink(MapLink): """Two-way, East-West link""" symbol = "-" directions = {"e": "w", "w": "e"} + prototype = "xyz_exit_prototype" class NESWMapLink(MapLink): """Two-way, NorthWest-SouthWest link""" symbol = "/" directions = {"ne": "sw", "sw": "ne"} + prototype = "xyz_exit_prototype" class SENWMapLink(MapLink): """Two-way, SouthEast-NorthWest link""" symbol = "\\" directions = {"se": "nw", "nw": "se"} + prototype = "xyz_exit_prototype" class PlusMapLink(MapLink): @@ -997,6 +1062,7 @@ class PlusMapLink(MapLink): symbol = "+" directions = {"s": "n", "n": "s", "e": "w", "w": "e"} + prototype = "xyz_exit_prototype" class CrossMapLink(MapLink): @@ -1004,30 +1070,35 @@ class CrossMapLink(MapLink): symbol = "x" directions = {"ne": "sw", "sw": "ne", "se": "nw", "nw": "se"} + prototype = "xyz_exit_prototype" class NSOneWayMapLink(MapLink): """One-way North-South link""" symbol = "v" directions = {"n": "s"} + prototype = "xyz_exit_prototype" class SNOneWayMapLink(MapLink): """One-way South-North link""" symbol = "^" directions = {"s": "n"} + prototype = "xyz_exit_prototype" class EWOneWayMapLink(MapLink): """One-way East-West link""" symbol = "<" directions = {"e": "w"} + prototype = "xyz_exit_prototype" class WEOneWayMapLink(MapLink): """One-way West-East link""" symbol = ">" directions = {"w": "e"} + prototype = "xyz_exit_prototype" class UpMapLink(SmartMapLink): @@ -1037,6 +1108,7 @@ class UpMapLink(SmartMapLink): # all movement over this link is 'up', regardless of where on the xygrid we move. direction_aliases = {'n': symbol, 'ne': symbol, 'e': symbol, 'se': symbol, 's': symbol, 'sw': symbol, 'w': symbol, 'nw': symbol} + prototype = "xyz_exit_prototype" class DownMapLink(UpMapLink): @@ -1045,12 +1117,14 @@ class DownMapLink(UpMapLink): # all movement over this link is 'down', regardless of where on the xygrid we move. direction_aliases = {'n': symbol, 'ne': symbol, 'e': symbol, 'se': symbol, 's': symbol, 'sw': symbol, 'w': symbol, 'nw': symbol} + prototype = "xyz_exit_prototype" class InterruptMapLink(InvisibleSmartMapLink): """A (still passable) link that causes the pathfinder to stop before crossing.""" symbol = "i" interrupt_path = True + prototype = "xyz_exit_prototype" class BlockedMapLink(InvisibleSmartMapLink): @@ -1063,6 +1137,7 @@ class BlockedMapLink(InvisibleSmartMapLink): symbol = 'b' weights = {'n': BIGVAL, 'ne': BIGVAL, 'e': BIGVAL, 'se': BIGVAL, 's': BIGVAL, 'sw': BIGVAL, 'w': BIGVAL, 'nw': BIGVAL} + prototype = "xyz_exit_prototype" class RouterMapLink(SmartRerouterMapLink): diff --git a/evennia/contrib/xyzgrid/tests.py b/evennia/contrib/xyzgrid/tests.py index 064fb682bb..7e716568f1 100644 --- a/evennia/contrib/xyzgrid/tests.py +++ b/evennia/contrib/xyzgrid/tests.py @@ -8,6 +8,7 @@ from time import time from random import randint from unittest import TestCase from parameterized import parameterized +from django.test import override_settings from . import xymap, xyzgrid, map_legend @@ -346,7 +347,7 @@ class TestMap1(TestCase): """ def setUp(self): - self.map = xymap.XYMap({"map": MAP1}, name="testmap") + self.map = xymap.XYMap({"map": MAP1}, Z="testmap") self.map.parse() def test_str_output(self): @@ -425,13 +426,14 @@ class TestMap1(TestCase): mapstr = self.map.get_visual_range(coord, dist=dist, mode='nodes', character='@') self.assertEqual(expected, mapstr) + class TestMap2(TestCase): """ Test with Map2 - a bigger map with multi-step links """ def setUp(self): - self.map = xymap.XYMap({"map": MAP2}, name="testmap") + self.map = xymap.XYMap({"map": MAP2}, Z="testmap") self.map.parse() def test_str_output(self): @@ -542,7 +544,7 @@ class TestMap3(TestCase): """ def setUp(self): - self.map = xymap.XYMap({"map": MAP3}, name="testmap") + self.map = xymap.XYMap({"map": MAP3}, Z="testmap") self.map.parse() def test_str_output(self): @@ -592,7 +594,7 @@ class TestMap4(TestCase): """ def setUp(self): - self.map = xymap.XYMap({"map": MAP4}, name="testmap") + self.map = xymap.XYMap({"map": MAP4}, Z="testmap") self.map.parse() def test_str_output(self): @@ -623,7 +625,7 @@ class TestMap5(TestCase): """ def setUp(self): - self.map = xymap.XYMap({"map": MAP5}, name="testmap") + self.map = xymap.XYMap({"map": MAP5}, Z="testmap") self.map.parse() def test_str_output(self): @@ -652,7 +654,7 @@ class TestMap6(TestCase): """ def setUp(self): - self.map = xymap.XYMap({"map": MAP6}, name="testmap") + self.map = xymap.XYMap({"map": MAP6}, Z="testmap") self.map.parse() def test_str_output(self): @@ -685,7 +687,7 @@ class TestMap7(TestCase): """ def setUp(self): - self.map = xymap.XYMap({"map": MAP7}, name="testmap") + self.map = xymap.XYMap({"map": MAP7}, Z="testmap") self.map.parse() def test_str_output(self): @@ -714,7 +716,7 @@ class TestMap8(TestCase): """ def setUp(self): - self.map = xymap.XYMap({"map": MAP8}, name="testmap") + self.map = xymap.XYMap({"map": MAP8}, Z="testmap") self.map.parse() def test_str_output(self): @@ -781,7 +783,7 @@ class TestMap9(TestCase): """ def setUp(self): - self.map = xymap.XYMap({"map": MAP9}, name="testmap") + self.map = xymap.XYMap({"map": MAP9}, Z="testmap") self.map.parse() def test_str_output(self): @@ -811,7 +813,7 @@ class TestMap10(TestCase): """ def setUp(self): - self.map = xymap.XYMap({"map": MAP10}, name="testmap") + self.map = xymap.XYMap({"map": MAP10}, Z="testmap") self.map.parse() def test_str_output(self): @@ -860,7 +862,7 @@ class TestMap11(TestCase): """ def setUp(self): - self.map = xymap.XYMap({"map": MAP11}, name="testmap") + self.map = xymap.XYMap({"map": MAP11}, Z="testmap") self.map.parse() def test_str_output(self): @@ -951,7 +953,7 @@ class TestMapStressTest(TestCase): grid = self._get_grid(Xmax, Ymax) # print(f"\n\n{grid}\n") t0 = time() - mapobj = xymap.XYMap({'map': grid}, name="testmap") + mapobj = xymap.XYMap({'map': grid}, Z="testmap") mapobj.parse() t1 = time() self.assertLess(t1 - t0, max_time, f"Map creation of ({Xmax}x{Ymax}) grid slower " @@ -968,7 +970,7 @@ class TestMapStressTest(TestCase): """ Xmax, Ymax = gridsize grid = self._get_grid(Xmax, Ymax) - mapobj = xymap.XYMap({'map': grid}, name="testmap") + mapobj = xymap.XYMap({'map': grid}, Z="testmap") mapobj.parse() t0 = time() @@ -1001,7 +1003,7 @@ class TestMapStressTest(TestCase): """ Xmax, Ymax = gridsize grid = self._get_grid(Xmax, Ymax) - mapobj = xymap.XYMap({'map': grid}, name="testmap") + mapobj = xymap.XYMap({'map': grid}, Z="testmap") mapobj.parse() t0 = time() @@ -1026,20 +1028,49 @@ class TestMapStressTest(TestCase): f"slower than expected {max_time}s.") +class TestXYZGrid(TestCase): + """ + Test base grid class with a single map, including spawning objects. + """ + + zcoord = "map1" + + def setUp(self): + self.grid, err = xyzgrid.XYZGrid.create("testgrid") + + self.map_data1 = { + "map": MAP1, + "zcoord": self.zcoord + } + + self.grid.add_maps(self.map_data1) + + def tearDown(self): + self.grid.delete() + + def test_str_output(self): + """Check the display_map""" + xymap = self.grid.get(self.zcoord) + stripped_map = "\n".join(line.rstrip() for line in str(xymap).split('\n')) + self.assertEqual(MAP1_DISPLAY, stripped_map) + + def test_spawn(self): + """Spawn objects for the grid""" + self.grid.spawn() + + # map transitions class Map12aTransition(map_legend.MapTransitionMapNode): symbol = "T" - linked_map_name = "map12b" - linked_map_coords = (1, 0) + target_map_coords = (1, 0, "map12b") class Map12bTransition(map_legend.MapTransitionMapNode): symbol = "T" - linked_map_name= "map12a" - linked_map_coords = (0, 1) + target_map_coords = (0, 1, "map12a") -class TestXYZGrid(TestCase): +class TestXYZGridTransition(TestCase): """ Test the XYZGrid class and transitions between maps. @@ -1049,12 +1080,12 @@ class TestXYZGrid(TestCase): self.map_data12a = { "map": MAP12a, - "name": "map12a", + "zcoord": "map12a", "legend": {"T": Map12aTransition} } self.map_data12b = { "map": MAP12b, - "name": "map12b", + "zcoord": "map12b", "legend": {"T": Map12bTransition} } @@ -1076,9 +1107,9 @@ class TestXYZGrid(TestCase): directions, _ = self.grid.get('map12a').get_shortest_path(startcoord, endcoord) self.assertEqual(expected_directions, tuple(directions)) - def test_transition(self): + def test_spawn(self): """ - Test transition. + Spawn the two maps into actual objects. """ - + self.grid.spawn() diff --git a/evennia/contrib/xyzgrid/xymap.py b/evennia/contrib/xyzgrid/xymap.py index e4bf13274d..bfb2ab5eed 100644 --- a/evennia/contrib/xyzgrid/xymap.py +++ b/evennia/contrib/xyzgrid/xymap.py @@ -53,7 +53,7 @@ as up and down. These are indicated in code as 'n', 'ne', 'e', 'se', 's', 'sw', MAP_DATA = { "map": MAP, "legend": LEGEND, - "name": "City of Foo", + "zcoord": "City of Foo", "prototypes": { (0,1): { ... }, (1,3): { ... }, @@ -104,12 +104,17 @@ except ImportError as err: from django.conf import settings from evennia.utils.utils import variable_from_module, mod_import from evennia.utils import logger +from evennia.prototypes import prototypes as protlib from .utils import MapError, MapParserError, BIGVAL from . import map_legend _CACHE_DIR = settings.CACHE_DIR +_LOADED_PROTOTYPES = None +MAP_DATA_KEYS = [ + "zcoord", "map", "legend", "prototypes" +] # these are all symbols used for x,y coordinate spots DEFAULT_LEGEND = { @@ -134,6 +139,8 @@ DEFAULT_LEGEND = { 't': map_legend.TeleporterMapLink, } + + # -------------------------------------------- # Map parser implementation @@ -184,7 +191,7 @@ class XYMap: # we normally only accept one single character for the legend key legend_key_exceptions = ("\\") - def __init__(self, map_module_or_dict, name="map", grid=None): + def __init__(self, map_module_or_dict, Z="map", xyzgrid=None): """ Initialize the map parser by feeding it the map. @@ -192,31 +199,41 @@ class XYMap: map_module_or_dict (str, module or dict): Path or module pointing to a map. If a dict, this should be a dict with a MAP_DATA key 'map' and optionally a 'legend' dicts to specify the map structure. - name (str, optional): Unique identifier for this map. Needed if the game uses - more than one map. Used when referencing this map during map transitions, - baking of pathfinding matrices etc. This will be overridden by any 'name' given - in the MAP_DATA itself. - grid (.xyzgrid.XYZgrid): A top-level grid this map is a part of. + Z (int or str, optional): Name or Z-coord for for this map. Needed if the game uses + more than one map. If not given, it can also be embedded in the + `map_module_or_dict`. Used when referencing this map during map transitions, + baking of pathfinding matrices etc. + xyzgrid (.xyzgrid.XYZgrid): A top-level grid this map is a part of. Notes: - The map deals with two sets of coorinate systems: + Interally, the map deals with two sets of coordinate systems: - grid-coordinates x,y are the character positions in the map string. - world-coordinates X,Y are the in-world coordinates of nodes/rooms. There are fewer of these since they ignore the 'link' spaces between - the nodes in the grid, so + the nodes in the grid, s X = x // 2 Y = y // 2 + - The Z-coordinate, if given, is only used when transitioning between maps + on the supplied `grid`. + """ - self.name = name + global _LOADED_PROTOTYPES + if not _LOADED_PROTOTYPES: + # inject default prototypes, but don't override prototype-keys loaded from + # settings, if they exist (that means the user wants to replace the defaults) + protlib.load_module_prototypes("evennia.contrib.xyzgrid.prototypes", override=False) + _LOADED_PROTOTYPES = True + + self.Z = Z + self.xyzgrid = xyzgrid self.mapstring = "" # store so we can reload self.map_module_or_dict = map_module_or_dict - self.grid = grid self.prototypes = None # transitional mapping @@ -237,10 +254,10 @@ class XYMap: self.pathfinding_routes = None self.pathfinder_baked_filename = None - if name: + if Z: if not isdir(_CACHE_DIR): mkdir(_CACHE_DIR) - self.pathfinder_baked_filename = pathjoin(_CACHE_DIR, f"{name}.P") + self.pathfinder_baked_filename = pathjoin(_CACHE_DIR, f"{Z}.P") # load data and parse it self.reload() @@ -257,6 +274,88 @@ class XYMap: def __repr__(self): return f"" + def reload(self, map_module_or_dict=None): + """ + (Re)Load a map. + + Args: + map_module_or_dict (str, module or dict, optional): See description for the variable + in the class' `__init__` function. If given, replace the already loaded + map with a new one. If not given, the existing one given on class creation + will be reloaded. + parse (bool, optional): If set, auto-run `.parse()` on the newly loaded data. + + Notes: + This will both (re)load the data and parse it into a new map structure, replacing any + existing one. The valid mapstructure is: + :: + + { + "map": , + "zcoord": , # optional + "legend": , # optional + "prototypes": # optional + } + + """ + if not map_module_or_dict: + map_module_or_dict = self.map_module_or_dict + + mapdata = {} + if isinstance(map_module_or_dict, dict): + # map-=structure provided directly + mapdata = map_module_or_dict + else: + # read from contents of module + mod = mod_import(map_module_or_dict) + mapdata = variable_from_module(mod, "MAP_DATA") + if not mapdata: + # try to read mapdata directly from global variables + mapdata['zcoord'] = variable_from_module(mod, "ZCOORD", default=self.name) + mapdata['map'] = variable_from_module(mod, "MAP") + mapdata['legend'] = variable_from_module(mod, "LEGEND", default=DEFAULT_LEGEND) + mapdata['prototypes'] = variable_from_module(mod, "PROTOTYPES", default={}) + + # validate + if any(key for key in mapdata if key not in MAP_DATA_KEYS): + raise MapError(f"Mapdata has keys {list(mapdata)}, but only " + f"keys {MAP_DATA_KEYS} are allowed.") + + for key in mapdata.get('legend', DEFAULT_LEGEND): + if not key or len(key) > 1: + if key not in self.legend_key_exceptions: + raise MapError(f"Map-legend key '{key}' is invalid: All keys must " + "be exactly one character long. Use the node/link's " + "`.display_symbol` property to change how it is " + "displayed.") + if 'map' not in mapdata or not mapdata['map']: + raise MapError("No map found. Add 'map' key to map-data (MAP_DATA) dict or " + "add variable MAP to a module passed into the parser.") + for key, prototype in mapdata.get('prototypes', {}).items(): + if not is_iter(key) and (2 <= len(key) <= 3): + raise MapError(f"Prototype override key {key} is malformed: It must be a " + "coordinate (X, Y) for nodes or (X, Y, direction) for links; " + "where direction is a supported direction string ('n', 'ne', etc).") + + # store/update result + self.Z = mapdata.get('zcoord', self.Z) + self.mapstring = mapdata['map'] + self.prototypes = mapdata.get('prototypes', {}) + + # merge the custom legend onto the default legend to allow easily + # overriding only parts of it + self.legend = {**DEFAULT_LEGEND, **map_module_or_dict.get("legend", DEFAULT_LEGEND)} + + # initialize any prototypes on the legend entities + for char, node_or_link_class in self.legend.items(): + prototype = node_or_link_class.prototype + 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] + node_or_link_class.prototype = proto + def parse(self): """ Parses the numerical grid from the string. The first pass means parsing out @@ -359,26 +458,33 @@ class XYMap: node_index += 1 xygrid[ix][iy] = XYgrid[iX][iY] = node_index_map[node_index] = \ - mapnode_or_link_class(node_index=node_index, x=ix, y=iy, xymap=self) + mapnode_or_link_class(x=ix, y=iy, Z=self.Z, + node_index=node_index, xymap=self) else: # we have a link at this xygrid position (this is ok everywhere) - xygrid[ix][iy] = mapnode_or_link_class(ix, iy, xymap=self) + xygrid[ix][iy] = mapnode_or_link_class(x=ix, y=iy, Z=self.Z, xymap=self) # store the symbol mapping for transition lookups symbol_map[char].append(xygrid[ix][iy]) - # second pass - link all nodes of the map except the inter-map traversals. + # store before building links + self.max_x, self.max_y = max_x, max_y + self.max_X, self.max_Y = max_X, max_Y + self.xygrid = xygrid + self.XYgrid = XYgrid + self.node_index_map = node_index_map + self.symbol_map = symbol_map - # build all links except the transitional links + # build all links for node in node_index_map.values(): - node.build_links(xygrid) + node.build_links() # build display map display_map = [[" "] * (max_x + 1) for _ in range(max_y + 1)] for ix, ydct in xygrid.items(): for iy, node_or_link in ydct.items(): - display_map[iy][ix] = node_or_link.get_display_symbol(xygrid, xymap=self) + display_map[iy][ix] = node_or_link.get_display_symbol() # validate and make sure all nodes/links have prototypes for node in node_index_map.values(): @@ -389,16 +495,7 @@ class XYMap: for direction, maplink in node.first_links.items(): maplink.prototype = self.prototypes.get(node_coord + (direction,), maplink.prototype) - # store results - self.max_x, self.max_y = max_x, max_y - self.xygrid = xygrid - - self.max_X, self.max_Y = max_X, max_Y - self.XYgrid = XYgrid - - self.node_index_map = node_index_map - self.symbol_map = symbol_map - + # store self.display_map = display_map def _get_topology_around_coord(self, coord, dist=2): @@ -494,60 +591,59 @@ class XYMap: pickle.dump((self.mapstring, self.dist_matrix, self.pathfinding_routes), fil, protocol=4) - def reload(self, map_module_or_dict=None): + def spawn_nodes(self, coord=(None, None)): """ - (Re)Load a map. + Convert the nodes of this XYMap into actual in-world rooms by spawning their + related prototypes in the correct coordinate positions. This must be done *first* + before spawning links (with `spawn_links` because exits require the target destination + to exist. It's also possible to only spawn a subset of the map Args: - map_module_or_dict (str, module or dict, optional): See description for the variable - in the class' `__init__` function. If given, replace the already loaded - map with a new one. If not given, the existing one given on class creation - will be reloaded. - parse (bool, optional): If set, auto-run `.parse()` on the newly loaded data. + coord (tuple, optional): An (X,Y) coordinate of node(s). `None` acts as a wildcard. - Notes: - This will both (re)load the data and parse it into a new map structure, replacing any - existing one. + Examples: + - `coord=(1, 3) - spawn (1,3) coordinate only. + - `coord=(None, 1) - spawn all nodes in the first row of the map only. + - `coord=(None, None)` - spawn all nodes + + Returns: + list: A list of nodes that were spawned. """ - if not map_module_or_dict: - map_module_or_dict = self.map_module_or_dict + x, y = coord - mapdata = {} - if isinstance(map_module_or_dict, dict): - mapdata = map_module_or_dict - else: - mod = mod_import(map_module_or_dict) - mapdata = variable_from_module(mod, "MAP_DATA") - if not mapdata: - # try to read mapdata directly from global variables - mapdata['name'] = variable_from_module(mod, "NAME", default=self.name) - mapdata['map'] = variable_from_module(mod, "MAP") - mapdata['legend'] = variable_from_module(mod, "LEGEND", default=DEFAULT_LEGEND) - mapdata['rooms'] = variable_from_module(mod, "ROOMS") - mapdata['prototypes'] = variable_from_module(mod, "PROTOTYPES", default={}) + spawned = [] + for node in self.node_index_map.values(): + if (x is None or x == node.X) and (y is None or y == node.Y): + node.spawn() + spawned.append(node) + return spawned - # validate - for key in mapdata.get('legend', DEFAULT_LEGEND): - if not key or len(key) > 1: - if key not in self.legend_key_exceptions: - raise MapError(f"Map-legend key '{key}' is invalid: All keys must " - "be exactly one character long. Use the node/link's " - "`.display_symbol` property to change how it is " - "displayed.") - if 'map' not in mapdata or not mapdata['map']: - raise MapError("No map found. Add 'map' key to map-data (MAP_DATA) dict or " - "add variable MAP to a module passed into the parser.") + def spawn_links(self, coord=(None, None), nodes=None, only_directions=None): + """ + Convert links of this XYMap into actual in-game exits by spawning their related + prototypes. It's possible to only spawn a specic exit by specifying the node and - self.room_prototypes = mapdata.get('rooms') + Args: + coord (tuple, optional): An (X,Y) coordinate of node(s). `None` acts as a wildcard. + nodes (list, optional): If given, only consider links out of these nodes. This also + affects `coords`, so that if there are no nodes of given coords in `nodes`, no + links will be spawned at all. + directions (list, optional): A list of cardinal directions ('n', 'ne' etc). If given, + sync only the exit in the given directions (`coords` limits which links out of which + nodes should be considered). `None` acts as a wildcard. + Examples: + - `coord=(1, 3 )`, `direction='ne'` - sync only the north-eastern exit + out of the (1, 3) node. - # store/update result - self.name = mapdata.get('name', self.name) - self.mapstring = mapdata['map'] - self.prototypes = mapdata.get('prototypes', {}) - # merge the custom legend onto the default legend to allow easily - # overriding only parts of it - self.legend = {**DEFAULT_LEGEND, **map_module_or_dict.get("legend", DEFAULT_LEGEND)} + """ + x, y = coord + if not nodes: + nodes = self.node_index_map.values() + + for node in nodes: + if (x is None or x == node.X) and (y is None or y == node.Y): + node.spawn_links(only_directions=only_directions) def get_node_from_coord(self, coords): """ @@ -775,7 +871,7 @@ class XYMap: def _default_callable(node): return target_path_style.format( - display_symbol=node.get_display_symbol(self.xygrid)) + display_symbol=node.get_display_symbol()) if callable(target_path_style): _target_path_style = target_path_style diff --git a/evennia/contrib/xyzgrid/xyzgrid.py b/evennia/contrib/xyzgrid/xyzgrid.py index e7bb83673d..ec9508ed18 100644 --- a/evennia/contrib/xyzgrid/xyzgrid.py +++ b/evennia/contrib/xyzgrid/xyzgrid.py @@ -20,6 +20,7 @@ import itertools from evennia.scripts.scripts import DefaultScript from evennia.utils import logger from .xymap import XYMap +from .xyzroom import XYZRoom, XYZExit class XYZGrid(DefaultScript): @@ -54,12 +55,13 @@ class XYZGrid(DefaultScript): nmaps = 0 # generate all Maps - this will also initialize their components # and bake any pathfinding paths (or load from disk-cache) - for mapname, mapdata in self.db.map_data.items(): - logger.log_info(f"[grid] Loading map '{mapname}'...") - xymap = XYMap(dict(mapdata), name=mapname, grid=self) + for zcoord, mapdata in self.db.map_data.items(): + + logger.log_info(f"[grid] Loading map '{zcoord}'...") + xymap = XYMap(dict(mapdata), Z=zcoord, xyzgrid=self) xymap.parse() xymap.calculate_path_matrix() - self.ndb.grid[mapname] = xymap + self.ndb.grid[zcoord] = xymap nmaps += 1 # store @@ -82,43 +84,44 @@ class XYZGrid(DefaultScript): `{"map": , "legend": , "name": , "prototypes": }`. The `prototypes are coordinate-specific overrides for nodes/links on the map, keyed with their - (X,Y) coordinate. + (X,Y) coordinate within that map. Raises: RuntimeError: If mapdata is malformed. - Notes: - This will assume that all added maps produce a complete set (that is, they are correctly - and completely linked together with each other and/or with existing maps). So - this will automatically trigger `.reload()` to rebuild the grid. - After this, you need to run `.sync_to_grid` to make the new map actually - available in-game. - """ for mapdata in mapdatas: - name = mapdata.get('name') - if not name: - raise RuntimeError("XYZGrid.add_map data must contain 'name'.") + zcoord = mapdata.get('zcoord') + if not zcoord: + raise RuntimeError("XYZGrid.add_map data must contain 'zcoord'.") - self.db.map_data[name] = mapdata + self.db.map_data[zcoord] = mapdata - def remove_map(self, mapname, remove_objects=False): + def remove_map(self, *zcoords, remove_objects=True): """ - Remove a map from the grid. + Remove an XYmap from the grid. Args: - mapname (str): The map to remove. + *zoords (str): The zcoords/XYmaps to remove. remove_objects (bool, optional): If the synced database objects (rooms/exits) should be removed alongside this map. """ - if mapname in self.db.map_data: - self.db.map_data.pop(zcoord) - self.reload() + for zcoord in zcoords: + if zcoord in self.db.map_data: + self.db.map_data.pop(zcoord) + if remove_objects: + # this should also remove all exits automatically + XYZRoom.objects.filter_xyz(coord=(None, None, zcoord)).delete() + self.reload() - if remove_objects: - pass + def delete(self): + """ + Clear the entire grid, including database entities. - def sync_to_grid(self, coord=(None, None, None), direction=None): + """ + self.remove_map(*(zcoord for zcoord in self.db.map_data), remove_objects=True) + + def spawn(self, coord=(None, None, None), only_directions=None): """ Create/recreate/update the in-game grid based on the stored Maps or for a specific Map or coordinate. @@ -126,8 +129,8 @@ class XYZGrid(DefaultScript): Args: coord (tuple, optional): An (X,Y,Z) coordinate, where Z is the name of the map. `None` acts as a wildcard. - direction (str, optional): A cardinal direction ('n', 'ne' etc). If given, sync only the - exit in the given direction. `None` acts as a wildcard. + only_directions (list, optional): A list of cardinal directions ('n', 'ne' etc). + If given, spawn exits only the given direction. `None` acts as a wildcard. Examples: - `coord=(1, 3, 'foo')` - sync a specific element of map 'foo' only. @@ -147,14 +150,12 @@ class XYZGrid(DefaultScript): else: raise RuntimeError(f"The 'z' coordinate/name '{z}' is not found on the grid.") - # first syncing pass (only nodes/rooms) - synced = [] - for xymap in xymaps: - for node in xymap.node_index_map.values(): - if (x is None or x == node.X) and (y is None or y == node.Y): - node.sync_node_to_grid() - synced.append(node) - # second pass (links/exits) - for node in synced: - node.sync_links_to_grid() + # first build all nodes/rooms + for zcoord, xymap in xymaps.items(): + logger.log_info(f"[grid] spawning/updating nodes for {zcoord} ...") + xymap.spawn_nodes(coord=(x, y)) + # next build all links between nodes (including between maps) + for zcoord, xymap in xymaps.items(): + logger.log_info(f"[grid] spawning/updating links for {zcoord} ...") + xymap.spawn_links(coord=(x, y), only_directions=only_directions) diff --git a/evennia/contrib/xyzgrid/room.py b/evennia/contrib/xyzgrid/xyzroom.py similarity index 78% rename from evennia/contrib/xyzgrid/room.py rename to evennia/contrib/xyzgrid/xyzroom.py index f42ec2e539..667faec3a4 100644 --- a/evennia/contrib/xyzgrid/room.py +++ b/evennia/contrib/xyzgrid/xyzroom.py @@ -10,6 +10,7 @@ used as stand-alone XYZ-coordinate-aware rooms. from django.db.models import Q from evennia.objects.objects import DefaultRoom, DefaultExit from evennia.objects.manager import ObjectManager +from evennia.utils.utils import inherits_from # name of all tag categories. Note that the Z-coordinate is # the `map_name` of the XYZgrid @@ -185,6 +186,22 @@ class XYZRoom(DefaultRoom): # makes the `room.objects.filter_xymap` available objects = XYZManager() + def __str__(self): + return repr(self) + + def __repr__(self): + x, y, z = self.xyzcoords + return f"" + + @property + def xyzcoords(self): + if not hasattr(self, "_xyzcoords"): + x = self.tags.get(category=MAP_X_TAG_CATEGORY, return_list=False) + y = self.tags.get(category=MAP_Y_TAG_CATEGORY, return_list=False) + z = self.tags.get(category=MAP_Z_TAG_CATEGORY, return_list=False) + self._xyzcoords = (x, y, z) + return self._xyzcoords + @classmethod def create(cls, key, account=None, coord=(0, 0, 'map'), **kwargs): """ @@ -215,7 +232,7 @@ class XYZRoom(DefaultRoom): return None, [f"XYRroom.create got `coord={coord}` - needs a valid (X,Y,Z) " "coordinate of ints/strings."] - existing_query = cls.objects.filter_xy(x=x, y=y, z=z) + existing_query = cls.objects.filter_xyz(coord=(x, y, z)) if existing_query.exists(): existing_room = existing_query.first() return None, [f"XYRoom XYZ={coord} already exists " @@ -227,7 +244,7 @@ class XYZRoom(DefaultRoom): (str(z), MAP_Z_TAG_CATEGORY), ) - return DefaultRoom.create(key, account=account, tags=tags, **kwargs) + return DefaultRoom.create(key, account=account, tags=tags, typeclass=cls, **kwargs) class XYZExit(DefaultExit): @@ -238,6 +255,32 @@ class XYZExit(DefaultExit): objects = XYZExitManager() + def __str__(self): + return repr(self) + + def __repr__(self): + x, y, z = self.xyzcoords + xd, yd, zd = self.xyzdestcoords + return f"({xd},{yd},{zd})>" + + @property + def xyzcoords(self): + if not hasattr(self, "_xyzcoords"): + x = self.tags.get(category=MAP_X_TAG_CATEGORY, return_list=False) + y = self.tags.get(category=MAP_Y_TAG_CATEGORY, return_list=False) + z = self.tags.get(category=MAP_Z_TAG_CATEGORY, return_list=False) + self._xyzcoords = (x, y, z) + return self._xyzcoords + + @property + def xyzdestcoords(self): + if not hasattr(self, "_xyzdestcoords"): + xd = self.tags.get(category=MAP_XDEST_TAG_CATEGORY, return_list=False) + yd = self.tags.get(category=MAP_YDEST_TAG_CATEGORY, return_list=False) + zd = self.tags.get(category=MAP_ZDEST_TAG_CATEGORY, return_list=False) + self._xyzdestcoords = (xd, yd, zd) + return self._xyzdestcoords + @classmethod def create(cls, key, account=None, coord=(0, 0, 'map'), destination_coord=(0, 0, 'map'), location=None, destination=None, **kwargs): @@ -246,8 +289,7 @@ class XYZExit(DefaultExit): Args: key (str): New name of object to create. - account (Account, optional): Any Account to tie to this entity (usually not used for - rooms). + account (Account, optional): Any Account to tie to this entity (unused for exits). coords (tuple or None, optional): A 3D coordinate (X, Y, Z) for this room's location on a map grid. Each element can theoretically be either `int` or `str`, but for the XYZgrid contrib, the X, Y are always integers while the `Z` coordinate is used for @@ -255,15 +297,17 @@ class XYZExit(DefaultExit): `location`. destination_coord (tuple or None, optional): Works as `coords`, but for destination of the exit. Set to `None` if using the `destination` kwarg to point to room directly. + destination_coord (tuple, optional): The XYZ coordinate of the place the exit + leads to. Will be ignored if `destination` is given directly. location (Object, optional): Only used if `coord` is not given. This can be used to place this exit in any room, including non-XYRoom type rooms. - destination (Object, optional): Only used if `destination_coord` is not given. This can + destination (Object, optional): If given, overrides `destination_coord`. This can be any room (including non-XYRooms) and is not checked for XY coordinates. **kwargs: Will be passed into the normal `DefaultRoom.create` method. Returns: - room (Object): A newly created Room of the given typeclass. - errors (list): A list of errors in string form, if any. + tuple: A tuple `(exit, errors)`, where the errors is a list containing all found + errors (in which case the returned exit will be `None`). """ tags = [] @@ -274,22 +318,28 @@ class XYZExit(DefaultExit): return None, ["XYExit.create need either a `coord` or a `location`."] source = location else: - source = cls.objects.get_xyz(x=x, y=y, z=z) + print("rooms:", XYZRoom.objects.all().count(), XYZRoom.objects.all()) + print("exits:", XYZExit.objects.all().count(), XYZExit.objects.all()) + source = XYZRoom.objects.get_xyz(coord=(x, y, z)) tags.extend(((str(x), MAP_X_TAG_CATEGORY), (str(y), MAP_Y_TAG_CATEGORY), (str(z), MAP_Z_TAG_CATEGORY))) - try: - xdest, ydest, zdest = destination_coord - except ValueError: - if not destination: - return None, ["XYExit.create need either a `destination_coord` or a `destination`."] + if destination: dest = destination else: - dest = cls.objects.get_xyz(x=xdest, y=ydest, z=zdest) - tags.extend(((str(xdest), MAP_XDEST_TAG_CATEGORY), - (str(ydest), MAP_YDEST_TAG_CATEGORY), - (str(zdest), MAP_ZDEST_TAG_CATEGORY))) + try: + xdest, ydest, zdest = destination_coord + except ValueError: + if not destination: + return None, ["XYExit.create need either a `destination_coord` or " + "a `destination`."] + dest = destination + else: + dest = XYZRoom.objects.get_xyz(coord=(xdest, ydest, zdest)) + tags.extend(((str(xdest), MAP_XDEST_TAG_CATEGORY), + (str(ydest), MAP_YDEST_TAG_CATEGORY), + (str(zdest), MAP_ZDEST_TAG_CATEGORY))) return DefaultExit.create( key, source, dest, account=account, - location=location, destination=destination, tags=tags, **kwargs) + location=location, tags=tags, typeclass=cls, **kwargs) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index db7060116a..1294e7e0ec 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -816,6 +816,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): use_destination=True, to_none=False, move_hooks=True, + alternative_source=None, **kwargs, ): """ @@ -837,6 +838,10 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): move_hooks (bool): If False, turn off the calling of move-related hooks (at_before/after_move etc) with quiet=True, this is as quiet a move as can be done. + alternative_source (Object, optional): Normally, the current `self.location` is + assumed the 'source' of the move. This allows for replacing this + with a custom source (for example to create a teleporter room that + retains the original source when moving to another place). Keyword Args: Passed on to announce_move_to and announce_move_from hooks. @@ -861,7 +866,6 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): 7. `self.at_after_move(source_location)` """ - def logerr(string="", err=None): """Simple log helper method""" logger.log_trace() @@ -872,6 +876,8 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): if not emit_to_obj: emit_to_obj = self + source_location = alternative_source or self.location + if not destination: if to_none: # immediately move to None. There can be no hooks called since @@ -887,15 +893,12 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): # Before the move, call eventual pre-commands. if move_hooks: try: - if not self.at_before_move(destination, **kwargs): + if not source_location.at_before_move(destination, **kwargs): return False except Exception as err: logerr(errtxt.format(err="at_before_move()"), err) return False - # Save the old location - source_location = self.location - # Call hook on source location if move_hooks and source_location: try: @@ -2446,7 +2449,7 @@ class DefaultRoom(DefaultObject): if not locks and account: locks = cls.lockstring.format(**{"id": account.id}) elif not locks and not account: - locks = cls.lockstring(**{"id": obj.id}) + locks = cls.lockstring.format(**{"id": obj.id}) obj.locks.add(locks) @@ -2461,6 +2464,7 @@ class DefaultRoom(DefaultObject): obj.db.desc = description if description else _("This is a room.") except Exception as e: + raise errors.append("An error occurred while creating this '%s' object." % key) logger.log_err(e) @@ -2667,7 +2671,7 @@ class DefaultExit(DefaultObject): obj.db.desc = description if description else _("This is an exit.") except Exception as e: - errors.append("An error occurred while creating this '%s' object." % key) + errors.append("An error occurred while creating this '%s' object (%s)." % key) logger.log_err(e) return obj, errors diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 9189d5abd9..cad32c7258 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -144,19 +144,45 @@ def homogenize_prototype(prototype, custom_keys=None): return homogenized -# module-based prototypes +# module/dict-based prototypes -def load_module_prototypes(): +def load_module_prototypes(*mod_or_prototypes, override=True): """ - This is called by `evennia.__init__` as Evennia initializes. It's important - to do this late so as to not interfere with evennia initialization. + Load module prototypes. Also prototype-dicts passed directly to this function are considered + 'module' prototypes (they are impossible to change) but will have a module of None. + + Args: + *mod_or_prototypes (module or dict): Each arg should be a separate module or + prototype-dict to load. If none are given, `settings.PROTOTYPE_MODULES` will be used. + override (bool, optional): If prototypes should override existing ones already loaded. + Disabling this can allow for injecting prototypes into the system dynamically while + still allowing same prototype-keys to be overridden from settings (even though settings + is usually loaded before dynamic loading). + + Note: + This is called (without arguments) by `evennia.__init__` as Evennia initializes. It's + important to do this late so as to not interfere with evennia initialization. But it can + also be used later to add more prototypes to the library on the fly. This is requried + before a module-based prototype can be accessed by prototype-key. """ - for mod in settings.PROTOTYPE_MODULES: - # to remove a default prototype, override it with an empty dict. - # internally we store as (key, desc, locks, tags, prototype_dict) + global _MODULE_PROTOTYPE_MODULES, _MODULE_PROTOTYPES + + def _prototypes_from_module(mod): + """ + Load prototypes from a module, first by looking for a global list PROTOTYPE_LIST (a list of + dict-prototypes), and if not found, assuming all global-level dicts in the module are + prototypes. + + Args: + mod (module): The module to load from.evennia + + Returns: + list: A list of tuples `(prototype_key, prototype-dict)` where the prototype + has been homogenized. + + """ prots = [] - prototype_list = variable_from_module(mod, "PROTOTYPE_LIST") if prototype_list: # found mod.PROTOTYPE_LIST - this should be a list of valid @@ -179,27 +205,74 @@ def load_module_prototypes(): if "prototype_key" not in prot: prot["prototype_key"] = variable_name.lower() prots.append((prot["prototype_key"], homogenize_prototype(prot))) + return prots - # assign module path to each prototype_key for easy reference - _MODULE_PROTOTYPE_MODULES.update({prototype_key.lower(): mod for prototype_key, _ in prots}) - # make sure the prototype contains all meta info + def _cleanup_prototype(prototype_key, prototype, mod=None): + """ + We need to handle externally determined prototype-keys and to make sure + the prototype contains all needed meta information. + + Args: + prototype_key (str): The determined name of the prototype. + prototype (dict): The prototype itself. + mod (module, optional): The module the prototype was loaded from, if any. + + Returns: + dict: The cleaned up prototype. + + """ + actual_prot_key = prototype.get("prototype_key", prototype_key).lower() + prototype.update( + { + "prototype_key": actual_prot_key, + "prototype_desc": ( + prototype["prototype_desc"] if "prototype_desc" in prototype else (mod or "N/A")), + "prototype_locks": ( + prototype["prototype_locks"] + if "prototype_locks" in prototype + else "use:all();edit:false()" + ), + "prototype_tags": list( + set(list(make_iter(prototype.get("prototype_tags", []))) + ["module"]) + ), + } + ) + return prototype + + if not mod_or_prototypes: + # in principle this means PROTOTYPE_MODULES could also contain prototypes, but that is + # rarely useful ... + mod_or_prototypes = settings.PROTOTYPE_MODULES + + for mod_or_dict in mod_or_prototypes: + + if isinstance(mod_or_dict, dict): + # a single prototype; we must make sure it has its key + prototype_key = mod_or_dict.get('prototype_key') + if not prototype_key: + raise ValidationError(f"The prototype {mod_or_prototype} does not contain a 'prototype_key'") + prots = [(prototype_key, mod_or_dict)] + mod = None + else: + # a module (or path to module). This can contain many prototypes; they can be keyed by + # variable-name too + prots = _prototypes_from_module(mod_or_dict) + mod = repr(mod_or_dict) + + # store all found prototypes for prototype_key, prot in prots: - actual_prot_key = prot.get("prototype_key", prototype_key).lower() - prot.update( - { - "prototype_key": actual_prot_key, - "prototype_desc": prot["prototype_desc"] if "prototype_desc" in prot else mod, - "prototype_locks": ( - prot["prototype_locks"] - if "prototype_locks" in prot - else "use:all();edit:false()" - ), - "prototype_tags": list( - set(list(make_iter(prot.get("prototype_tags", []))) + ["module"]) - ), - } - ) - _MODULE_PROTOTYPES[actual_prot_key] = prot + prototype = _cleanup_prototype(prototype_key, prot, mod=mod) + # the key can change since in-proto key is given prio over variable-name-based keys + actual_prototype_key = prototype['prototype_key'] + + if actual_prototype_key in _MODULE_PROTOTYPES and not override: + # don't override - useful to still let settings replace dynamic inserts + continue + + # make sure the prototype contains all meta info + _MODULE_PROTOTYPES[actual_prototype_key] = prototype + # track module path for display purposes + _MODULE_PROTOTYPE_MODULES[actual_prototype_key.lower()] = mod # Db-based prototypes @@ -266,11 +339,12 @@ def save_prototype(prototype): # we can't edit a prototype defined in a module if prototype_key in _MODULE_PROTOTYPES: - mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key, "N/A") - raise PermissionError( - _("{protkey} is a read-only prototype " "(defined as code in {module}).").format( - protkey=prototype_key, module=mod) - ) + mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key) + if mod: + err = _("{protkey} is a read-only prototype (defined as code in {module}).") + else: + err = _("{protkey} is a read-only prototype (passed directly as a dict).") + raise PermissionError(err.format(protkey=prototype_key, module=mod)) # make sure meta properties are included with defaults in_prototype["prototype_desc"] = in_prototype.get( @@ -334,11 +408,12 @@ def delete_prototype(prototype_key, caller=None): """ if prototype_key in _MODULE_PROTOTYPES: - mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key.lower(), "N/A") - raise PermissionError( - _("{protkey} is a read-only prototype " "(defined as code in {module}).").format( - protkey=prototype_key, module=mod) - ) + mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key) + if mod: + err = _("{protkey} is a read-only prototype (defined as code in {module}).") + else: + err = _("{protkey} is a read-only prototype (passed directly as a dict).") + raise PermissionError(err.format(protkey=prototype_key, module=mod)) stored_prototype = DbPrototype.objects.filter(db_key__iexact=prototype_key) @@ -452,7 +527,7 @@ def search_prototype(key=None, tags=None, require_single=False, return_iterators ndbprots = db_matches.count() if nmodules + ndbprots != 1: raise KeyError(_( - "Found {num} matching prototypes {module_prototypes}.").format( + "Found {num} matching prototypes among {module_prototypes}.").format( num=nmodules + ndbprots, module_prototypes=module_prototypes) ) @@ -906,10 +981,12 @@ def check_permission(prototype_key, action, default=True): """ if action == "edit": if prototype_key in _MODULE_PROTOTYPES: - mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key, "N/A") - logger.log_err( - "{} is a read-only prototype " "(defined as code in {}).".format(prototype_key, mod) - ) + mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key) + if mod: + err = _("{protkey} is a read-only prototype (defined as code in {module}).") + else: + err = _("{protkey} is a read-only prototype (passed directly as a dict).") + logger.log_err(err.format(protkey=prototype_key, module=mod)) return False prototype = search_prototype(key=prototype_key) diff --git a/evennia/typeclasses/managers.py b/evennia/typeclasses/managers.py index e1fd58ed32..8d461d7dc9 100644 --- a/evennia/typeclasses/managers.py +++ b/evennia/typeclasses/managers.py @@ -797,7 +797,7 @@ class TypeclassManager(TypedObjectManager): all_subclasses.extend(self._get_subclasses(subclass)) return all_subclasses - def get_family(self, **kwargs): + def get_family(self, *args, **kwargs): """ Variation of get that not only returns the current typeclass but also all subclasses of that typeclass. @@ -817,7 +817,7 @@ class TypeclassManager(TypedObjectManager): "%s.%s" % (cls.__module__, cls.__name__) for cls in self._get_subclasses(self.model) ] kwargs.update({"db_typeclass_path__in": paths}) - return super().get(**kwargs) + return super().get(*args, **kwargs) def filter_family(self, *args, **kwargs): """ diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 529ddc43e5..ba9b755c94 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -244,7 +244,7 @@ callable must be a module-global function on the form >: start - # node abort + ## node abort This exits the menu since there is no `## options` section.