diff --git a/evennia/contrib/map_and_pathfind/mapsystem.py b/evennia/contrib/map_and_pathfind/mapsystem.py index ed636634c..48c597d87 100644 --- a/evennia/contrib/map_and_pathfind/mapsystem.py +++ b/evennia/contrib/map_and_pathfind/mapsystem.py @@ -668,7 +668,7 @@ class TeleporterMapLink(MapLink): """ The teleport link works by connecting to nowhere - and will then continue on another teleport link with the same symbol elsewhere on the map. The teleport - must connect in only one direction, and only to another Link. + symbol must connect to only one other link (not to a node). For this to work, there must be exactly one other teleport with the same `.symbol` on the map. The two teleports will always operate as two-way connections, but by making the 'out-link' on @@ -683,14 +683,15 @@ class TeleporterMapLink(MapLink): -#-t t># - one-way teleport from left to right. - #t - invalid, may only connect to another link + -#t - invalid, may only connect to another link - #-t-# - invalid, only one connected link is allowed. + -#-t-# - invalid, only one connected link is allowed. """ symbol = 't' # usually invisible display_symbol = ' ' + direction_name = 'teleport' def __init__(self, *args): super().__init__(*args) @@ -756,19 +757,78 @@ class TeleporterMapLink(MapLink): # the string 'teleport' will not be understood by the traverser, leading to # this being interpreted as an empty target and the `at_empty_target` # hook firing when trying to traverse this link. - if start_direction == 'teleport': + direction_name = self.direction_name + if start_direction == direction_name: # called while traversing another teleport # - we must make sure we can always access/leave the teleport. - self.directions = {"teleport": direction, - direction: "teleport"} + self.directions = {direction_name: direction, + direction: direction_name} else: # called while traversing a normal link - self.directions = {start_direction: "teleport", - "teleport": direction} + self.directions = {start_direction: direction_name, + direction_name: direction} return self.directions.get(start_direction) +class MapTransitionLink(TeleporterMapLink): + """ + This link teleports the user to another map and lets them continue moving + from there. Like the TeleporterMapLink, the map-transition symbol must connect to only one other + link (not directly to a node). + + The other map will be scanned for a matching `.symbol` that must also be a MapTransitionLink. + The link is always two-way, but the link connecting to the transition can be one-way to create + a one-way transition. Make new links with different symbols (like A, B, C, ...) to link + multiple maps together. + + Note that unlike for teleports, pathfinding will *not* work across the map-transition. + + Examples: + :: + + map1 map2 + + T + / T-# - movement to the transition-link will continue on the other map. + -# + + T + / + -# T># - one-way link from map1 to map2 + + -#t - invalid, may only connect to another link + + -#-t-# - invalid, only one connected link is allowed. + + """ + symbol = 'T' + display_symbol = ' ' + direction_name = 'transition' + interrupt_path = True + + map1_name = 'map' + map2_name = 'map' + + def __init__(self, *args): + super().__init__(*args) + self.map1 = None + self.map2 = None + + def at_empty_target(self, start_direction, end_direction, xygrid): + """ + This is called by .traverse when it finds this link pointing to nowhere. + + Args: + 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. + + """ + # TODO - this needs some higher-level handler to work. + + class SmartMapLink(MapLink): """ A 'smart' link withot visible direction, but which uses its topological surroundings @@ -1008,7 +1068,6 @@ class InterruptMapLink(InvisibleSmartMapLink): symbol = "i" interrupt_path = True - class BlockedMapLink(InvisibleSmartMapLink): """ A high-weight (but still passable) link that causes the shortest-path algorithm to consider this @@ -1047,6 +1106,7 @@ DEFAULT_LEGEND = { "b": BlockedMapLink, "i": InterruptMapLink, 't': TeleporterMapLink, + 'T': MapTransitionLink, } # -------------------------------------------- @@ -1097,7 +1157,7 @@ class Map: # we normally only accept one single character for the legend key legend_key_exceptions = ("\\") - def __init__(self, map_module_or_dict): + def __init__(self, map_module_or_dict, name="map"): """ Initialize the map parser by feeding it the map. @@ -1105,6 +1165,9 @@ class Map: 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 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. Notes: The map deals with two sets of coorinate systems: @@ -1321,7 +1384,6 @@ class Map: # we have a link at this xygrid position (this is ok everywhere) xygrid[ix][iy] = mapnode_or_link_class(ix, iy) - # from evennia import set_trace;set_trace() # second pass: Here we loop over all nodes and have them connect to each other # via the detected linkages. for node in node_index_map.values(): @@ -1435,8 +1497,6 @@ class Map: the full path including the start- and end-node. """ - # from evennia import set_trace;set_trace() - startnode = self.get_node_from_coord(startcoord) endnode = self.get_node_from_coord(endcoord) diff --git a/evennia/contrib/map_and_pathfind/tests.py b/evennia/contrib/map_and_pathfind/tests.py index a3ce85173..a42c59e32 100644 --- a/evennia/contrib/map_and_pathfind/tests.py +++ b/evennia/contrib/map_and_pathfind/tests.py @@ -4,11 +4,12 @@ Tests for the Mapsystem """ +from time import time +from random import randint from unittest import TestCase from parameterized import parameterized from . import mapsystem - MAP1 = """ + 0 1 2 @@ -873,3 +874,112 @@ class TestMap11(TestCase): character='@', max_size=max_size) self.assertEqual(expected, mapstr) + + +class TestMapStressTest(TestCase): + """ + Performance test of map patfinder and visualizer. + + #-#-#-#-#.... + |x|x|x|x| + #-#-#-#-# + |x|x|x|x| + #-#-#-#-# + |x|x|x|x| + #-#-#-#-# + ... + + This should be a good stress-testing scenario because most each internal node has a maxiumum + number of connections and options to consider. + + """ + + def _get_grid(self, Xsize, Ysize): + edge = f"+ {' ' * Xsize * 2}" + l1 = f"\n {'#-' * Xsize}#" + l2 = f"\n {'|x' * Xsize}|" + + return f"{edge}\n{(l1 + l2) * Ysize}{l1}\n\n{edge}" + + @parameterized.expand([ + ((10, 10), 0.01), + ((100, 100), 1), + ]) + def test_grid_creation(self, gridsize, max_time): + """ + Test of grid-creataion performance for Nx, Ny grid. + + """ + Xmax, Ymax = gridsize + grid = self._get_grid(Xmax, Ymax) + # print(f"\n\n{grid}\n") + t0 = time() + mapsystem.Map({'map': grid}) + t1 = time() + self.assertLess(t1 - t0, max_time, f"Map creation of ({Xmax}x{Ymax}) grid slower " + f"than expected {max_time}s.") + + @parameterized.expand([ + ((10, 10), 10**-4), + ((20, 20), 10**-4), + ]) + def test_grid_pathfind(self, gridsize, max_time): + """ + Test pathfinding performance for Nx, Ny grid. + + """ + Xmax, Ymax = gridsize + grid = self._get_grid(Xmax, Ymax) + mapobj = mapsystem.Map({'map': grid}) + + t0 = time() + mapobj._calculate_path_matrix() + t1 = time() + # print(f"pathfinder matrix for grid {Xmax}x{Ymax}: {t1 - t0}s") + + # get the maximum distance and 9 other random points in the grid + start_end_points = [((0, 0), (Xmax-1, Ymax-1))] + for _ in range(9): + start_end_points.append(((randint(0, Xmax), randint(0, Ymax)), + (randint(0, Xmax), randint(0, Ymax)))) + + t0 = time() + for startcoord, endcoord in start_end_points: + mapobj.get_shortest_path(startcoord, endcoord) + t1 = time() + self.assertLess((t1 - t0) / 10, max_time, f"Pathfinding for ({Xmax}x{Ymax}) grid slower " + f"than expected {max_time}s.") + + @parameterized.expand([ + ((10, 10), 4, 0.01), + ((20, 20), 4, 0.01), + ]) + def test_grid_visibility(self, gridsize, dist, max_time): + """ + Test grid visualization performance for Nx, Ny grid for + different visibility distances. + + """ + Xmax, Ymax = gridsize + grid = self._get_grid(Xmax, Ymax) + mapobj = mapsystem.Map({'map': grid}) + + t0 = time() + mapobj._calculate_path_matrix() + t1 = time() + # print(f"pathfinder matrix for grid {Xmax}x{Ymax}: {t1 - t0}s") + + # get random center points in grid and a range of targets to visualize the + # path to + start_end_points = [((0, 0), (Xmax-1, Ymax-1))] # include max distance + for _ in range(9): + start_end_points.append(((randint(0, Xmax), randint(0, Ymax)), + (randint(0, Xmax), randint(0, Ymax)))) + + t0 = time() + for coord, target in start_end_points: + mapobj.get_visual_range(coord, dist=dist, mode='nodes', character='@', target=target) + t1 = time() + self.assertLess((t1 - t0) / 10, max_time, + f"Visual Range calculation for ({Xmax}x{Ymax}) grid " + f"slower than expected {max_time}s.")