diff --git a/evennia/contrib/map_and_pathfind/mapsystem.py b/evennia/contrib/map_and_pathfind/mapsystem.py index 988d9bac91..d7293c536b 100644 --- a/evennia/contrib/map_and_pathfind/mapsystem.py +++ b/evennia/contrib/map_and_pathfind/mapsystem.py @@ -74,14 +74,18 @@ See `./example_maps.py` for some empty grid areas to start from. ---- """ from collections import defaultdict -from scipy.sparse.csgraph import dijkstra -from scipy.sparse import csr_matrix -from scipy import zeros + +try: + from scipy.sparse.csgraph import dijkstra + from scipy.sparse import csr_matrix + from scipy import zeros +except ImportError as err: + raise ImportError( + f"{err}\nThe MapSystem contrib requires " + "the SciPy package. Install with `pip install scipy'.") from evennia.utils.utils import variable_from_module, mod_import -_BIG = 999999999999 - _REVERSE_DIRECTIONS = { "n": "s", "ne": "sw", @@ -104,6 +108,8 @@ _MAPSCAN = { "nw": (1, -1) } +_BIG = 999999999999 + class MapError(RuntimeError): pass @@ -114,11 +120,9 @@ class MapParserError(MapError): class MapNode: """ - This represents a 'room' node on the map. - - A node is always located at an (int, int) location - on the map, even if it actually represents a throughput - to another node. + This represents a 'room' node on the map. MapNodes are always located + on even x,y coordinates on the on-map xygrid and represents specific coordinates + on the in-game XYgrid. """ # symbol used in map definition @@ -162,10 +166,13 @@ class MapNode: # lowest direction to a given neighbor self.cheapest_to_node = {} + def __str__(self): + return f"" + def build_links(self, xygrid): """ - Start tracking links in all cardinal directions to - tie this to another node. + Start tracking links in all cardinal directions to tie this to another node. All + links are placed on the xygrid since they never have an in-game representation. Args: xygrid (dict): A 2d dict-of-dicts with x,y coordinates as keys and nodes as values. @@ -284,6 +291,9 @@ class MapLink: if not self.display_symbol: self.display_symbol = self.symbol + def __str__(self): + return f"" + def get_visually_connected(self, xygrid, directions=None): """ A helper to get all directions to which there appears to be a @@ -596,7 +606,6 @@ class Map: # Dijkstra algorithm variables self.node_index_map = None - self.pathfinding_matrix = None self.dist_matrix = None self.pathfinding_routes = None @@ -604,7 +613,16 @@ class Map: self.reload() def __str__(self): - return "\n".join("".join(line) for line in self.display_map) + """ + Print the string representation of the map. + Since the y-axes origo is at the bottom, we must flip the + y-axis before printing (since printing is always top-to-bottom). + + """ + return "\n".join("".join(line) for line in self.display_map[::-1]) + + def __repr__(self): + return f"" def _get_node_from_coord(self, X, Y): """ @@ -626,7 +644,7 @@ class Map: return self.XYgrid[X][Y] except IndexError: raise MapError("_get_node_from_coord got coordinate ({x},{y}) which is " - "outside the grid size of (0,0) - ({self.width}, {self.height}).") + "outside the grid size of (0,0) - ({self.max_X}, {self.max_Y}).") def _calculate_path_matrix(self): """ @@ -827,11 +845,12 @@ class Map: we want to find the shortest route to. Returns: - tuple: Two lists, first one containing the shortest sequence of map nodes to - traverse and the second a list of directions (n, se etc) describing the path. + tuple: Two lists, first containing the list of directions as strings (n, ne etc) and + the second is a mixed list of MapNodes and string-directions in a sequence describing + the full path including the start- and end-node. """ - istartnode = self._get_node_from_coord(*startcoord).node_index + startnode = self._get_node_from_coord(*startcoord) endnode = self._get_node_from_coord(*endcoord) if not self.pathfinding_routes: @@ -840,34 +859,45 @@ class Map: pathfinding_routes = self.pathfinding_routes node_index_map = self.node_index_map - nodepath = [endnode] - linkpath = [] + path = [endnode] + directions = [] + istartnode = startnode.node_index inextnode = endnode.node_index while pathfinding_routes[istartnode, inextnode] != -9999: - # the -9999 is set by algorithm for unreachable nodes or end-node + # the -9999 is set by algorithm for unreachable nodes or if trying + # to go a node we are already at (the start node in this case since + # we are working backwards). inextnode = pathfinding_routes[istartnode, inextnode] - nodepath.append(node_index_map[inextnode]) - linkpath.append(nodepath[-1].get_cheapest_link_to(nodepath[-2])) + nextnode = node_index_map[inextnode] + directions.append(nextnode.get_cheapest_link_to(path[-1])) + path.extend((directions[-1], nextnode)) # we have the path - reverse to get the correct order - nodepath = nodepath[::-1] - linkpath = linkpath[::-1] + path = path[::-1] + directions = directions[::-1] - return nodepath, linkpath + return directions, path - def get_map_display(self, coord, dist=2, character='@', return_str=True): + def get_map_display(self, coord, dist=2, only_nodes=False, + character='@', max_size=None, return_str=True): """ Display the map centered on a point and everything around it within a certain distance. Args: coord (tuple): (X,Y) in-world coordinate location. - dist (int, optional): Number of gridpoints distance to show. - A value of 2 will show adjacent nodes, a value - of 1 will only show links from current node. If this is None, - show entire map centered on iX,iY. + dist (int, optional): Number of gridpoints distance to show. Which + grid to use depends on the setting of `only_nodes`. + only_nodes (boolean): This determins if `dist` only counts the number of + full nodes or counts the number of actual visual map-grid-points + (including links). If set, it's recommended to set `max_size` to avoid + too-large map displays. character (str, optional): Place this symbol at the `coord` position of the displayed map. Ignored if falsy. + max_size (tuple, optional): A max `(width, height)` of the resulting + string or list. This can be useful together with `only_nodes` + to avoid a map display growing unexpectedly. If unset, size + can grow up to the full size of the map. return_str (bool, optional): Return result as an already formatted string. @@ -878,25 +908,86 @@ class Map: extract a character at (ix,iy) coordinate from it, use indexing `outlist[iy][ix]` in that order. + Notes: + If outputting an output list, the y-axis must first be + reversed since printing happens top-bottom and the y coordinate + system goes bottom-up. This can be done simply with + + reversed = outlist[::-1] + + before starting the printout loop. + + If `only_nodes` is True, a dist of 2 will give the following + result in a row of nodes: + + #-#-@----------#-# + + This display may grow much bigger than expected (both horizontally + and vertically). consider setting `max_size` if wanting to restrict the display size. + also note that link 'weights' are *included* in this estimate, so + if links have weights > 1, fewer nodes will be found for a given `dist`. + + If `only_nodes` is False, dist of 2 would give + + #-@-- + + This is more of a 'moving' overview type of map that just displays a part of the grid + you are on. It does not consider links or weights and may also show nodes not + actually reachable at the moment: + + | | + # @-# + """ iX, iY = coord # convert inputs to xygrid width, height = self.max_x + 1, self.max_y + 1 ix, iy = max(0, min(iX * 2, width)), max(0, min(iY * 2, height)) - if dist is None: - gridmap = self.display_map - ixc, iyc = ix, iy + if only_nodes: + # dist measures only full, reachable nodes + + # we will build a list of coordinates (from the full + # map display) to actually include in the final + points = [(ix, iy)] + xmax = 0 + ymax = 0 + + node_index_map = self.node_index_map + + center_node = self._get_node_from_coord(iX, iY) + # find all reachable nodes within a (weighted) distance of `dist` + for inode, node_dist in enumerate(self.dist_matrix[center_node.node_index]): + if node_dist > dist: + continue + # we have a node within 'dist' from us, get, the route to it + node = node_index_map[inode] + _, path = self.get_shortest_path(node.iX, node.iY) + + # follow directions to figure out which map coords to display + ix0, iy0 = ix, iy + # for path_element in path: + # dx, dy = _MAPSCAN[direction] + # ix0, iy0 = ix0 + dx, iy0 + dy + # xmax, ymax = max(xmax, ix0), max(ymax, iy0) + # points.append((ix0, iy0)) + else: - left, right = max(0, ix - dist), min(width, ix + dist + 1) - bottom, top = max(0, iy - dist), min(height, iy + dist + 1) - ixc, iyc = ix - left, iy - bottom - gridmap = [line[left:right] for line in self.display_map[bottom:top]] + # dist measures individual grid points + if dist is None: + gridmap = self.display_map + ixc, iyc = ix, iy + else: + left, right = max(0, ix - dist), min(width, ix + dist + 1) + bottom, top = max(0, iy - dist), min(height, iy + dist + 1) + ixc, iyc = ix - left, iy - bottom + gridmap = [line[left:right] for line in self.display_map[bottom:top]] - if character: - gridmap[iyc][ixc] = character # correct indexing; it's a list of lines + if character: + gridmap[iyc][ixc] = character # correct indexing; it's a list of lines + # we must flip the y-axis before returning if return_str: - return "\n".join("".join(line) for line in gridmap) + return "\n".join("".join(line) for line in gridmap[::-1]) else: return gridmap diff --git a/evennia/contrib/map_and_pathfind/tests.py b/evennia/contrib/map_and_pathfind/tests.py index fd373d0f89..eb1463530c 100644 --- a/evennia/contrib/map_and_pathfind/tests.py +++ b/evennia/contrib/map_and_pathfind/tests.py @@ -84,35 +84,51 @@ class TestMap1(TestCase): self.assertEqual(node.y, 2) def test_get_shortest_path(self): - nodepath, linkpath = self.map.get_shortest_path((0, 0), (1, 1)) - self.assertEqual([node.node_index for node in nodepath], [0, 1, 3]) - self.assertEqual(linkpath, ['e', 'n']) + directions, path = self.map.get_shortest_path((0, 0), (1, 1)) + self.assertEqual(directions, ['e', 'n']) + self.assertEqual( + [str(node) for node in path], + [str(self.map.node_index_map[0]), + 'e', + str(self.map.node_index_map[1]), + 'n', + str(self.map.node_index_map[3])] + ) @parameterized.expand([ - ((0, 0), "#-\n| ", [["#", "-"], ["|", " "]]), - ((1, 0), "-#\n |", [["-", "#"], [" ", "|"]]), - ((0, 1), "| \n#-", [["|", " "], ["#", "-"]]), - ((1, 1), " |\n-#", [[" ", "|"], ["-", "#"]]), + ((0, 0), "| \n#-", [["|", " "], ["#", "-"]]), + ((1, 0), " |\n-#", [[" ", "|"], ["-", "#"]]), + ((0, 1), "#-\n| ", [["#", "-"], ["|", " "]]), + ((1, 1), "-#\n |", [["-", "#"], [" ", "|"]]), ]) def test_get_map_display(self, coord, expectstr, expectlst): - string = self.map.get_map_display(coord, dist=1, character=None) - lst = self.map.get_map_display(coord, dist=1, return_str=False, character=None) - self.assertEqual(string, expectstr) - self.assertEqual(lst, expectlst) + """ + Test displaying a part of the map around a central point. + + """ + mapstr = self.map.get_map_display(coord, dist=1, character=None) + maplst = self.map.get_map_display(coord, dist=1, return_str=False, character=None) + self.assertEqual(expectstr, mapstr) + self.assertEqual(expectlst, maplst[::-1]) @parameterized.expand([ - ((0, 0), "@-\n| ", [["@", "-"], ["|", " "]]), - ((1, 0), "-@\n |", [["-", "@"], [" ", "|"]]), - ((0, 1), "| \n@-", [["|", " "], ["@", "-"]]), - ((1, 1), " |\n-@", [[" ", "|"], ["-", "@"]]), + ((0, 0), "| \n@-", [["|", " "], ["@", "-"]]), + ((1, 0), " |\n-@", [[" ", "|"], ["-", "@"]]), + ((0, 1), "@-\n| ", [["@", "-"], ["|", " "]]), + ((1, 1), "-@\n |", [["-", "@"], [" ", "|"]]), ]) def test_get_map_display__character(self, coord, expectstr, expectlst): - string = self.map.get_map_display(coord, dist=1, character='@') - lst = self.map.get_map_display(coord, dist=1, return_str=False, character='@') - self.assertEqual(string, expectstr) - self.assertEqual(lst, expectlst) + """ + Test displaying a part of the map around a central point, showing the + character @-symbol in that spot. + + """ + mapstr = self.map.get_map_display(coord, dist=1, character='@') + maplst = self.map.get_map_display(coord, dist=1, return_str=False, character='@') + self.assertEqual(expectstr, mapstr) + self.assertEqual(expectlst, maplst[::-1]) # flip y-axis to match print direction class TestMap2(TestCase): @@ -125,4 +141,46 @@ class TestMap2(TestCase): def test_str_output(self): """Check the display_map""" - self.assertEqual(str(self.map).strip(), MAP2_DISPLAY) + # strip the leftover spaces on the right to better + # work with text editor stripping this automatically ... + stripped_map = "\n".join(line.rstrip() for line in str(self.map).split('\n')) + self.assertEqual(stripped_map, MAP2_DISPLAY) + + def test_node_from_coord(self): + for mapnode in self.map.node_index_map.values(): + node = self.map._get_node_from_coord(mapnode.X, mapnode.Y) + self.assertEqual(node, mapnode) + self.assertEqual(node.x // 2, node.X) + self.assertEqual(node.y // 2, node.Y) + + @parameterized.expand([ + ((1, 0), (4, 0), ('e', 'e', 'e')), # straight path + ((1, 0), (5, 1), ('n', 'e', 'e', 'e')), # shortcut over long link + ((2, 2), (2, 5), ('n', 'n')), # shortcut over long link (vertical) + ((4, 4), (0, 5), ('w', 'n', 'w', 'w')), # shortcut over long link (vertical) + ((4, 0), (0, 5), ('n', 'w', 'n', 'n', 'n', 'w', 'w')), # across entire grid + ((4, 0), (0, 5), ('n', 'w', 'n', 'n', 'n', 'w', 'w')), # across entire grid + ((5, 3), (0, 3), ('s', 'w', 'w', 'w', 'w', 'n')), # down and back + ]) + def test_shortest_path(self, startcoord, endcoord, expected_directions): + """ + Test shortest-path calculations throughout the grid. + + """ + directions, _ = self.map.get_shortest_path(startcoord, endcoord) + self.assertEqual(expected_directions, tuple(directions)) + + @parameterized.expand([ + ((1, 0), '#-#-#-#\n| | \n#-#-#--\n | \n @-#-#'), + ((2, 2), ' #---#\n | |\n# | #\n| | \n#-#-@-#--\n| ' + '| \n#-#-#---#\n | |\n #-#-#-#'), + ((4, 5), '#-#-@ \n| | \n#---# \n| | \n| #-#'), + ((5, 2), '--# \n | \n #-#\n |\n#---@\n \n--#-#\n | \n#-# '), + ]) + def test_get_map_display__character(self, coord, expected): + """ + Test showing smaller part of grid, showing @-character in the middle. + + """ + mapstr = self.map.get_map_display(coord, dist=4, character='@') + self.assertEqual(expected, mapstr)