diff --git a/evennia/contrib/map_and_pathfind/mapsystem.py b/evennia/contrib/map_and_pathfind/mapsystem.py index 8e3d285ef7..2ba4322386 100644 --- a/evennia/contrib/map_and_pathfind/mapsystem.py +++ b/evennia/contrib/map_and_pathfind/mapsystem.py @@ -146,9 +146,14 @@ class MapNode: tags around it. For further customization, the `.get_display_symbol` method receives the full grid and can return a dynamically determined display symbol. If set to `None`, the `symbol` is used. + - `interrupt_path` (bool): If this is set, the shortest-path algorithm will include this + node as normally, but stop when reaching it, even if not having reached its target yet. This + is useful for marking 'points of interest' along a route, or places where you are not + 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). """ - # symbol used in map definition + # symbol used to identify this link on the map symbol = '#' # if printing this node should show another symbol. If set # to the empty string, use `symbol`. @@ -159,8 +164,11 @@ class MapNode: # 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 - def __init__(self, x, y, node_index): + def __init__(self, x, y, node_index=0): """ Initialize the mapnode. @@ -195,6 +203,8 @@ class MapNode: # {startdirection: [direction, ...], ...} # where the directional path-lists also include the start-direction self.xy_steps_to_node = {} + # direction-names of the closest neighbors to the node + self.closest_neighbor_names = {} def __str__(self): return f"', + (('w', 'e'),): '<', + (('nw', 'se'), ('sw', 'ne')): '\\', + (('ne', 'sw'), ('sw', 'ne')): '/', + } + + def get_display_symbol(self, xygrid, **kwargs): + """ + 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' + + Depending on how we are connected, we figure out how the 'normal' link + should look and use that instead. + + """ + if not hasattr(self, "_cached_display_symbol"): + mapinstance = kwargs['mapinstance'] + + legend = mapinstance.legend + default_symbol = ( + self.symbol if self.display_symbol is None else self.display_symbol) + self._cached_display_symbol = default_symbol + + dirtuple = tuple((key, self.directions[key]) + for key in sorted(self.directions.keys())) + + replacement_symbol = self.display_symbol_aliases.get(dirtuple, default_symbol) + + if replacement_symbol != self.symbol: + node_or_link_class = legend.get(replacement_symbol) + 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, **kwargs) + return self._cached_display_symbol class SmartRerouterMapLink(MapLink): @@ -645,8 +749,7 @@ class SmartRerouterMapLink(MapLink): """ # get all visually connected links - if not hasattr(self, '_cached_directions'): - # try to get from cache where possible + if not self.directions: directions = {} unhandled_links = list(self.get_linked_neighbors(xygrid).keys()) @@ -674,14 +777,24 @@ class SmartRerouterMapLink(MapLink): directions[unhandled_links[0]] = unhandled_links[1] directions[unhandled_links[1]] = unhandled_links[0] - self._cached_directions = directions + self.directions = directions - return self._cached_directions.get(start_direction) + return self.directions.get(start_direction) # ---------------------------------- # Default nodes and link classes +class BasicMapNode(MapNode): + """Basic map Node""" + symbol = "#" + +class InterruptMapNode(MapNode): + """A point of interest, where pathfinder will stop""" + symbol = "i" + display_symbol = "#" + interrupt_path = True + class NSMapLink(MapLink): """Two-way, North-South link""" symbol = "|" @@ -744,14 +857,35 @@ class WEOneWayMapLink(MapLink): class UpMapLink(SmartMapLink): """Up direction. Note that this still uses the xygrid!""" symbol = 'u' + # 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} - class DownMapLink(UpMapLink): """Works exactly like `UpMapLink` but for the 'down' direction.""" symbol = 'd' + # 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} + + +class InterruptMapLink(InvisibleSmartMapLink): + """A (still passable) link that causes the pathfinder to stop before crossing.""" + symbol = "i" + interrupt_path = True + + +class BlockedMapLink(InvisibleSmartMapLink): + """ + A high-weight (but still passable) link that causes the shortest-path algorithm to consider this + a blocked path. The block will not show up in the map display, paths will just never use this + link. + + """ + symbol = 'b' + weights = {'n': _BIG, 'ne': _BIG, 'e': _BIG, 'se': _BIG, + 's': _BIG, 'sw': _BIG, 'w': _BIG, 'nw': _BIG} class RouterMapLink(SmartRerouterMapLink): @@ -762,7 +896,8 @@ class RouterMapLink(SmartRerouterMapLink): # these are all symbols used for x,y coordinate spots # at (0,1) etc. DEFAULT_LEGEND = { - "#": MapNode, + "#": BasicMapNode, + "I": InterruptMapNode, "|": NSMapLink, "-": EWMapLink, "/": NESWMapLink, @@ -776,6 +911,8 @@ DEFAULT_LEGEND = { "o": RouterMapLink, "u": UpMapLink, "d": DownMapLink, + "b": BlockedMapLink, + "i": InterruptMapLink, } # -------------------------------------------- @@ -973,7 +1110,6 @@ class Map: "symbols marking the upper- and bottom-left corners of the " "grid area.") - # from evennia import set_trace;set_trace() # find the the position (in the string as a whole) of the top-left corner-marker maplines = mapstring.split("\n") topleft_marker_x, topleft_marker_y = -1, -1 @@ -1059,7 +1195,7 @@ class 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) + display_map[iy][ix] = node_or_link.get_display_symbol(xygrid, mapinstance=self) # store self.max_x, self.max_y = max_x, max_y @@ -1183,7 +1319,6 @@ class Map: pathfinding_routes = self.pathfinding_routes node_index_map = self.node_index_map - # from evennia import set_trace;set_trace() path = [endnode] directions = [] @@ -1193,15 +1328,23 @@ class Map: # we are working backwards). inextnode = pathfinding_routes[istartnode, inextnode] nextnode = node_index_map[inextnode] - shortest_route_to = nextnode.shortest_route_to_node[path[-1].node_index] directions.append(shortest_route_to[0]) - path.extend(shortest_route_to[1] + [nextnode]) + path.extend(shortest_route_to[1][::-1] + [nextnode]) + + if any(1 for step in shortest_route_to[1] if step.interrupt_path): + # detected an interrupt in linkage - discard what we have so far + directions = [] + path = [nextnode] + + if nextnode.interrupt_path and nextnode is not startnode: + directions = [] + path = [nextnode] # we have the path - reverse to get the correct order - path = path[::-1] directions = directions[::-1] + path = path[::-1] return directions, path diff --git a/evennia/contrib/map_and_pathfind/tests.py b/evennia/contrib/map_and_pathfind/tests.py index b2f2043b8c..23b403b78b 100644 --- a/evennia/contrib/map_and_pathfind/tests.py +++ b/evennia/contrib/map_and_pathfind/tests.py @@ -234,26 +234,58 @@ o-o-#-# o MAP9 = r""" + 0 1 2 3 -3 #-# #-# - d d | +3 #-#-#-# + d d d 2 | | | - u | u -1 #-#-#-# + u u u +1 #-# #-# u d -0 #u# #d# +0 #d# #u# + 0 1 2 3 """ MAP9_DISPLAY = r""" -#-# #-# - d d | - | | | - u | u #-#-#-# + d d d + | | | + u u u +#-# #-# u d -#u# #d# +#d# #u# +""".strip() + + +MAP10 = r""" + + + 0 1 2 3 + + 4 #---#-# + b | + 3 #i#---# + |/| + 2 # #-I-# + | + 1 #-#b#-# + | | b + 0 #b#-#-# + + + 0 1 2 3 + +""" + +# note that I,i,b are invisible +MAP10_DISPLAY = r""" +#---#-# + / | +#-#---# +|/| +# #-#-# + | +#-#-#-# +| | | +#-#-#-# """.strip() @@ -686,7 +718,7 @@ class TestMap8(TestCase): class TestMap9(TestCase): """ - Test the Map class with a simple 4-node map + Test Map9 - a map with up/down links. """ def setUp(self): @@ -699,7 +731,9 @@ class TestMap9(TestCase): @parameterized.expand([ ((0, 0), (0, 1), ('u',)), - ((0, 0), (1, 0), ('u',)), + ((0, 0), (1, 0), ('d',)), + ((1, 0), (2, 1), ('d', 'u', 'e', 'u', 'e', 'd')), + ((2, 1), (0, 1), ('u', 'w', 'd', 'w')), ]) def test_shortest_path(self, startcoord, endcoord, expected_directions): """ @@ -708,3 +742,54 @@ class TestMap9(TestCase): """ directions, _ = self.map.get_shortest_path(startcoord, endcoord) self.assertEqual(expected_directions, tuple(directions)) + + +class TestMap10(TestCase): + """ + Test Map10 - a map with blocked- and interrupt links/nodes. These are + 'invisible' nodes and won't show up in the map display. + + """ + def setUp(self): + self.map = mapsystem.Map({"map": MAP10}) + + def test_str_output(self): + """Check the display_map""" + stripped_map = "\n".join(line.rstrip() for line in str(self.map).split('\n')) + self.assertEqual(MAP10_DISPLAY, stripped_map) + + @parameterized.expand([ + ((0, 0), (1, 0), ('n', 'e', 's')), + ((3, 0), (3, 1), ()), # the blockage hinders this + ((1, 3), (0, 4), ('e', 'n', 'w', 'w')), + ((0, 1), (3, 2), ('e', 'n', 'e')), # path interrupted by I node + ((0, 1), (0, 3), ('e', 'n', 'n')), # path interrupted by i link + ((1, 3), (0, 3), ()), + ((3, 2), (2, 2), ('w',)), + ((3, 2), (1, 2), ('w',)), + ((3, 3), (0, 3), ('w',)), + ((2, 2), (3, 2), ('e',)), + ]) + 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([ + ((2, 2), (3, 2), ('e', ), ((2, 2), (2.5, 2), (3, 2))), + ((3, 3), (0, 3), ('w', ), ((3, 3), (2.5, 3.0), (2.0, 3.0), (1.5, 3.0), (1, 3))), + ]) + def test_paths(self, startcoord, endcoord, expected_directions, expected_path): + """ + Test path locations. + + """ + directions, path = self.map.get_shortest_path(startcoord, endcoord) + self.assertEqual(expected_directions, tuple(directions)) + strpositions = [(step.X, step.Y) for step in path] + self.assertEqual(expected_path, tuple(strpositions)) + +