Fix tests, looking at expanding map-area display

This commit is contained in:
Griatch 2021-06-06 20:53:49 +02:00
parent 0893b7b80b
commit 0458c9e308
2 changed files with 210 additions and 61 deletions

View file

@ -74,14 +74,18 @@ See `./example_maps.py` for some empty grid areas to start from.
---- ----
""" """
from collections import defaultdict from collections import defaultdict
from scipy.sparse.csgraph import dijkstra
from scipy.sparse import csr_matrix try:
from scipy import zeros 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 from evennia.utils.utils import variable_from_module, mod_import
_BIG = 999999999999
_REVERSE_DIRECTIONS = { _REVERSE_DIRECTIONS = {
"n": "s", "n": "s",
"ne": "sw", "ne": "sw",
@ -104,6 +108,8 @@ _MAPSCAN = {
"nw": (1, -1) "nw": (1, -1)
} }
_BIG = 999999999999
class MapError(RuntimeError): class MapError(RuntimeError):
pass pass
@ -114,11 +120,9 @@ class MapParserError(MapError):
class MapNode: class MapNode:
""" """
This represents a 'room' node on the map. 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
A node is always located at an (int, int) location on the in-game XYgrid.
on the map, even if it actually represents a throughput
to another node.
""" """
# symbol used in map definition # symbol used in map definition
@ -162,10 +166,13 @@ class MapNode:
# lowest direction to a given neighbor # lowest direction to a given neighbor
self.cheapest_to_node = {} self.cheapest_to_node = {}
def __str__(self):
return f"<MapNode {self.node_index} XY=({self.X},{self.Y}) ({self.symbol})>"
def build_links(self, xygrid): def build_links(self, xygrid):
""" """
Start tracking links in all cardinal directions to Start tracking links in all cardinal directions to tie this to another node. All
tie this to another node. links are placed on the xygrid since they never have an in-game representation.
Args: Args:
xygrid (dict): A 2d dict-of-dicts with x,y coordinates as keys and nodes as values. 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: if not self.display_symbol:
self.display_symbol = self.symbol self.display_symbol = self.symbol
def __str__(self):
return f"<LinkNode xy=({self.x},{self.y}) ({self.symbol})>"
def get_visually_connected(self, xygrid, directions=None): def get_visually_connected(self, xygrid, directions=None):
""" """
A helper to get all directions to which there appears to be a A helper to get all directions to which there appears to be a
@ -596,7 +606,6 @@ class Map:
# Dijkstra algorithm variables # Dijkstra algorithm variables
self.node_index_map = None self.node_index_map = None
self.pathfinding_matrix = None
self.dist_matrix = None self.dist_matrix = None
self.pathfinding_routes = None self.pathfinding_routes = None
@ -604,7 +613,16 @@ class Map:
self.reload() self.reload()
def __str__(self): 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"<Map {self.max_X}x{self.max_Y}, {len(self.node_index_map)} nodes>"
def _get_node_from_coord(self, X, Y): def _get_node_from_coord(self, X, Y):
""" """
@ -626,7 +644,7 @@ class Map:
return self.XYgrid[X][Y] return self.XYgrid[X][Y]
except IndexError: except IndexError:
raise MapError("_get_node_from_coord got coordinate ({x},{y}) which is " 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): def _calculate_path_matrix(self):
""" """
@ -827,11 +845,12 @@ class Map:
we want to find the shortest route to. we want to find the shortest route to.
Returns: Returns:
tuple: Two lists, first one containing the shortest sequence of map nodes to tuple: Two lists, first containing the list of directions as strings (n, ne etc) and
traverse and the second a list of directions (n, se etc) describing the path. 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) endnode = self._get_node_from_coord(*endcoord)
if not self.pathfinding_routes: if not self.pathfinding_routes:
@ -840,34 +859,45 @@ class Map:
pathfinding_routes = self.pathfinding_routes pathfinding_routes = self.pathfinding_routes
node_index_map = self.node_index_map node_index_map = self.node_index_map
nodepath = [endnode] path = [endnode]
linkpath = [] directions = []
istartnode = startnode.node_index
inextnode = endnode.node_index inextnode = endnode.node_index
while pathfinding_routes[istartnode, inextnode] != -9999: 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] inextnode = pathfinding_routes[istartnode, inextnode]
nodepath.append(node_index_map[inextnode]) nextnode = node_index_map[inextnode]
linkpath.append(nodepath[-1].get_cheapest_link_to(nodepath[-2])) directions.append(nextnode.get_cheapest_link_to(path[-1]))
path.extend((directions[-1], nextnode))
# we have the path - reverse to get the correct order # we have the path - reverse to get the correct order
nodepath = nodepath[::-1] path = path[::-1]
linkpath = linkpath[::-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. Display the map centered on a point and everything around it within a certain distance.
Args: Args:
coord (tuple): (X,Y) in-world coordinate location. coord (tuple): (X,Y) in-world coordinate location.
dist (int, optional): Number of gridpoints distance to show. dist (int, optional): Number of gridpoints distance to show. Which
A value of 2 will show adjacent nodes, a value grid to use depends on the setting of `only_nodes`.
of 1 will only show links from current node. If this is None, only_nodes (boolean): This determins if `dist` only counts the number of
show entire map centered on iX,iY. 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 character (str, optional): Place this symbol at the `coord` position
of the displayed map. Ignored if falsy. 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 return_str (bool, optional): Return result as an
already formatted string. already formatted string.
@ -878,25 +908,86 @@ class Map:
extract a character at (ix,iy) coordinate from it, use extract a character at (ix,iy) coordinate from it, use
indexing `outlist[iy][ix]` in that order. 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 iX, iY = coord
# convert inputs to xygrid # convert inputs to xygrid
width, height = self.max_x + 1, self.max_y + 1 width, height = self.max_x + 1, self.max_y + 1
ix, iy = max(0, min(iX * 2, width)), max(0, min(iY * 2, height)) ix, iy = max(0, min(iX * 2, width)), max(0, min(iY * 2, height))
if dist is None: if only_nodes:
gridmap = self.display_map # dist measures only full, reachable nodes
ixc, iyc = ix, iy
# 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: else:
left, right = max(0, ix - dist), min(width, ix + dist + 1) # dist measures individual grid points
bottom, top = max(0, iy - dist), min(height, iy + dist + 1) if dist is None:
ixc, iyc = ix - left, iy - bottom gridmap = self.display_map
gridmap = [line[left:right] for line in self.display_map[bottom:top]] 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: if character:
gridmap[iyc][ixc] = character # correct indexing; it's a list of lines gridmap[iyc][ixc] = character # correct indexing; it's a list of lines
# we must flip the y-axis before returning
if return_str: if return_str:
return "\n".join("".join(line) for line in gridmap) return "\n".join("".join(line) for line in gridmap[::-1])
else: else:
return gridmap return gridmap

View file

@ -84,35 +84,51 @@ class TestMap1(TestCase):
self.assertEqual(node.y, 2) self.assertEqual(node.y, 2)
def test_get_shortest_path(self): def test_get_shortest_path(self):
nodepath, linkpath = self.map.get_shortest_path((0, 0), (1, 1)) directions, path = self.map.get_shortest_path((0, 0), (1, 1))
self.assertEqual([node.node_index for node in nodepath], [0, 1, 3]) self.assertEqual(directions, ['e', 'n'])
self.assertEqual(linkpath, ['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([ @parameterized.expand([
((0, 0), "#-\n| ", [["#", "-"], ["|", " "]]), ((0, 0), "| \n#-", [["|", " "], ["#", "-"]]),
((1, 0), "-#\n |", [["-", "#"], [" ", "|"]]), ((1, 0), " |\n-#", [[" ", "|"], ["-", "#"]]),
((0, 1), "| \n#-", [["|", " "], ["#", "-"]]), ((0, 1), "#-\n| ", [["#", "-"], ["|", " "]]),
((1, 1), " |\n-#", [[" ", "|"], ["-", "#"]]), ((1, 1), "-#\n |", [["-", "#"], [" ", "|"]]),
]) ])
def test_get_map_display(self, coord, expectstr, expectlst): 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) Test displaying a part of the map around a central point.
self.assertEqual(string, expectstr)
self.assertEqual(lst, expectlst) """
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([ @parameterized.expand([
((0, 0), "@-\n| ", [["@", "-"], ["|", " "]]), ((0, 0), "| \n@-", [["|", " "], ["@", "-"]]),
((1, 0), "-@\n |", [["-", "@"], [" ", "|"]]), ((1, 0), " |\n-@", [[" ", "|"], ["-", "@"]]),
((0, 1), "| \n@-", [["|", " "], ["@", "-"]]), ((0, 1), "@-\n| ", [["@", "-"], ["|", " "]]),
((1, 1), " |\n-@", [[" ", "|"], ["-", "@"]]), ((1, 1), "-@\n |", [["-", "@"], [" ", "|"]]),
]) ])
def test_get_map_display__character(self, coord, expectstr, expectlst): 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='@') Test displaying a part of the map around a central point, showing the
self.assertEqual(string, expectstr) character @-symbol in that spot.
self.assertEqual(lst, expectlst)
"""
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): class TestMap2(TestCase):
@ -125,4 +141,46 @@ class TestMap2(TestCase):
def test_str_output(self): def test_str_output(self):
"""Check the display_map""" """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)