Refactoring of map spawner mechanism. Still not working correctly

This commit is contained in:
Griatch 2021-07-03 18:05:49 +02:00
parent 90ad6c112c
commit 61ab313ee3
9 changed files with 647 additions and 313 deletions

View file

@ -15,6 +15,7 @@ except ImportError as err:
"the SciPy package. Install with `pip install scipy'.") "the SciPy package. Install with `pip install scipy'.")
from evennia.prototypes import spawner from evennia.prototypes import spawner
from evennia.utils.utils import make_iter
from .utils import MAPSCAN, REVERSE_DIRECTIONS, MapParserError, BIGVAL from .utils import MAPSCAN, REVERSE_DIRECTIONS, MapParserError, BIGVAL
NodeTypeclass = None 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 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). (such as a guard or locked gate etc).
- `prototype` (dict) - The default `prototype` dict to use for reproducing this map component - `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. on the game grid. This is used if not overridden specifically for this coordinate. If this
- `deferred` (bool): A deferred node is used to indicate a link (currently) pointing to nowhere is not given, nothing will be spawned for this coordinate (a 'virtual' node can be useful
because the end node is not yet available - usually because that node is on another map for various reasons, mostly map-transitions).
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.
""" """
# symbol used to identify this link on the map # 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 # if printing this node should show another symbol. If set
# to the empty string, use `symbol`. # to the empty string, use `symbol`.
display_symbol = None 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 # this will interrupt a shortest-path step (useful for 'points' of interest, stop before
# a door etc). # a door etc).
interrupt_path = False interrupt_path = False
# the prototype to use for mapping this to the grid. # the prototype to use for mapping this to the grid.
prototype = None 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. Initialize the mapnode.
Args: Args:
x (int): Coordinate on xygrid. x (int): Coordinate on xygrid.
y (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 node_index (int): This identifies this node with a running
index number required for pathfinding. This is used index number required for pathfinding. This is used
internally and should not be set manually. internally and should not be set manually.
@ -97,6 +107,7 @@ class MapNode:
# XYgrid coordinate # XYgrid coordinate
self.X = x // 2 self.X = x // 2
self.Y = y // 2 self.Y = y // 2
self.Z = Z
self.node_index = node_index self.node_index = node_index
@ -124,22 +135,21 @@ class MapNode:
def __repr__(self): def __repr__(self):
return str(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 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 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 could be multiple steps to reach another node, the system will iterate down each
path and store it once and for all. 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: Notes:
This sets up all data needed for later use of this node in pathfinding and 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 other operations. The method can't run immediately when the node is created
since a complete parsed xygrid is required. since a complete parsed xygrid is required.
""" """
xygrid = self.xymap.xygrid
# we must use the xygrid coordinates # we must use the xygrid coordinates
x, y = self.x, self.y 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 # 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. # 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: if end_node:
# the link could be followed to an end node! # the link could be followed to an end node!
@ -207,14 +217,10 @@ class MapNode:
link_graph[node_index] = weight link_graph[node_index] = weight
return link_graph 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. 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: Returns:
str: The display-symbol to use. This must visually be a single character str: The display-symbol to use. This must visually be a single character
but could have color markers, use a unicode font etc. 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 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 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 that the exits (next step) requires all nodes to exist before they can link up
to their destinations. to their destinations.
@ -234,77 +253,120 @@ class MapNode:
""" """
global NodeTypeclass global NodeTypeclass
if not 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: try:
nodeobj = NodeTypeclass.objects.get_xyz(coord=coord) nodeobj = NodeTypeclass.objects.get_xyz(coord=coord)
except NodeTypeclass.DoesNotExist: except NodeTypeclass.DoesNotExist:
# create a new entity with proper coordinates etc # create a new entity with proper coordinates etc
nodeobj = NodeTypeclass.create( nodeobj, err = NodeTypeclass.create(
self.prototype.get('key', 'An Empty room'), self.prototype.get('key', 'An Empty room'),
coord=coord coord=coord
) )
if err:
raise RuntimeError(err)
# apply prototype to node. This will not override the XYZ tags since # apply prototype to node. This will not override the XYZ tags since
# these are not in the prototype and exact=False # these are not in the prototype and exact=False
spawner.batch_update_objects_with_prototype( spawner.batch_update_objects_with_prototype(
self.prototype, objects=[nodeobj], exact=False) 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 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. 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 global ExitTypeclass
if not 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 # we need to search for exits in all directions since some
# may have been removed since last sync # 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 # figure out if the topology changed between grid and map (will always
# build all exits first run) # build all exits first run)
differing_directions = set(maplinks.keys()).symmetric_difference(set(linkobjs.keys())) differing_keys = set(maplinks.keys()).symmetric_difference(set(linkobjs.keys()))
for direction in differing_directions: for differing_key in differing_keys:
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
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] exitnode = self.links[direction]
linkobjs[direction] = ExitTypeclass.create( linkobjs[direction] = ExitTypeclass.create(
link.prototype.get('key', direction), # either get name from the prototype or use our custom set
key,
coord=coord, 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 # apply prototypes to catch any changes
for direction, linkobj in linkobjs: for direction, linkobj in linkobjs:
spawner.batch_update_objects_with_prototype( spawner.batch_update_objects_with_prototype(
maplinks[direction].prototype, objects=[linkobj], exact=False) 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): class TransitionMapNode(MapNode):
""" """
Entering this node teleports the user to another Map (this is completely handled by the This node acts as an end-node for a link that actually leads to a specific node on another
prototyped Room class). This teleportation is not understood by the pathfinder, so why it will map. It is not actually represented by a separate room in-game.
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 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: Properties:
- `linked_map_name` (str) - the map you will move to when entering this node. - `target_map_coord` (tuple) - the (X, Y, Z) coordinate of a node on the other map to teleport
- `linked_coords` (tuple) - the XY coordinates *on the linked* map this node to when moving to this node. This should not be another TransitionMapNode (see below for
will teleport to. This must be another node that is not a TransitionMapNode. how to make a two-way link).
Note that for the trip to be two-way, a similar set up must be created from the
other map.
Examples: Examples:
:: ::
@ -312,18 +374,26 @@ class TransitionMapNode(MapNode):
map1 map2 map1 map2
#-T #- - one-way transition from map1 -> map2. #-T #- - one-way transition from map1 -> map2.
#-T T-# - two-way. Both ExternalMapNodes links to the coords of the #-T T-# - two-way. Both TransitionMapNodes links to the coords of the
`#` (NOT the `T`) on the other map! actual rooms (`#`) on the other map (NOT to the `T`s)!
""" """
symbol = 'T' symbol = 'T'
display_symbol = ' ' display_symbol = ' '
linked_map_name = "" # X,Y,Z coordinates of target node (not a transitionalmapnode)
linked_map_coords = None 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""" """Check so we don't have too many links"""
super().build_links(xygrid) super().build_links()
if len(self.links) > 1: if len(self.links) > 1:
raise MapParserError("may have at most one link connecting to it.", self) 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 - `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 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 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 tags around it. For further customization, the `.get_display_symbol` can be used.
the full grid and can return a dynamically determined display symbol. If `None`, the
`symbol` is used.
- `default_weight` (int) - Each link direction covered by this link can have its seprate weight, - `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, 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. 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 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 of the link is only used to determine its destination). This can be overridden on a
per-direction basis. per-direction basis.
- `requires_grid` (bool): If set, it indicates this component requires the full grid (multiple - `spawn_aliases` (list): A list of [key, alias, alias, ...] for the node to use when spawning
maps to be available before it can be processed. This is usually only needed for exits from this link. If not given, a sane set of defaults (n=north etc) will be used. This
inter-map traversal links where the other map must already be ready. Note that this is is required if you use any custom directions outside of the cardinal directions + up/down.
*only* relevant for the *first* link out of a node.
""" """
# symbol for identifying this link on the map # symbol for identifying this link on the map
@ -421,15 +488,19 @@ class MapLink:
interrupt_path = False interrupt_path = False
# prototype for the first link out of a node. # prototype for the first link out of a node.
prototype = None 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, Z, xymap=None):
def __init__(self, x, y, xymap=None):
""" """
Initialize the link. Initialize the link.
Args: Args:
x (int): The xygrid x coordinate x (int): The xygrid x coordinate
y (int): The xygrid y 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. xymap (XYMap, optional): The map object this sits on.
""" """
@ -440,6 +511,7 @@ class MapLink:
self.X = x / 2 self.X = x / 2
self.Y = y / 2 self.Y = y / 2
self.Z = Z
def __str__(self): def __str__(self):
return f"<LinkNode '{self.symbol}' XY=({self.X:g},{self.Y:g})>" return f"<LinkNode '{self.symbol}' XY=({self.X:g},{self.Y:g})>"
@ -447,14 +519,13 @@ class MapLink:
def __repr__(self): def __repr__(self):
return str(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. Recursively traverse the links out of this LinkNode.
Args: Args:
start_direction (str): The direction (n, ne etc) from which start_direction (str): The direction (n, ne etc) from which
this traversal originates for this link. this traversal originates for this link.
xygrid (dict): 2D dict with x,y coordinates as keys.
Kwargs: Kwargs:
_weight (int): Internal use. _weight (int): Internal use.
_linklen (int): Internal use. _linklen (int): Internal use.
@ -469,7 +540,9 @@ class MapLink:
MapParserError: If a link lead to nowhere. 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 not end_direction:
if _steps is None: if _steps is None:
# is perfectly okay to not be linking back on the first step (to a node) # 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] next_target = xygrid[end_x][end_y]
except KeyError: except KeyError:
# check if we have some special action up our sleeve # 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: if not next_target:
raise MapParserError( raise MapParserError(
f"points to empty space in the direction {end_direction}!", self) f"points to empty space in the direction {end_direction}!", self)
if ((hasattr(next_target, "deferred") and next_target.deferred) _weight += self.get_weight(start_direction, _weight)
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)
if _steps is None: if _steps is None:
_steps = [] _steps = []
_steps.append(self) _steps.append(self)
@ -515,15 +582,14 @@ class MapLink:
# we hit another link. Progress recursively. # we hit another link. Progress recursively.
return next_target.traverse( return next_target.traverse(
REVERSE_DIRECTIONS.get(end_direction, end_direction), 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 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. visual link/node. This does not trace the length of the link and check weights etc.
Args: Args:
xygrid (dict): 2D dict with x,y coordinates as keys.
directions (list, optional): Only scan in these directions. directions (list, optional): Only scan in these directions.
Returns: Returns:
@ -533,6 +599,7 @@ class MapLink:
if not directions: if not directions:
directions = REVERSE_DIRECTIONS.keys() directions = REVERSE_DIRECTIONS.keys()
xygrid = self.xymap.xygrid
links = {} links = {}
for direction in directions: for direction in directions:
dx, dy = MAPSCAN[direction] dx, dy = MAPSCAN[direction]
@ -542,11 +609,11 @@ class MapLink:
# a map node or a link connecting in our direction # a map node or a link connecting in our direction
node_or_link = xygrid[end_x][end_y] node_or_link = xygrid[end_x][end_y]
if (node_or_link.multilink 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 links[direction] = node_or_link
return links 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. 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 start_direction (str): The direction (n, ne etc) from which
this traversal originates for this link. this traversal originates for this link.
end_direction (str): The direction found from `get_direction` earlier. end_direction (str): The direction found from `get_direction` earlier.
xygrid (dict): 2D dict with x,y coordinates as keys.
Returns: Returns:
MapNode, MapLink or None: The next target to go to from here. `None` if this MapNode, MapLink or None: The next target to go to from here. `None` if this
@ -567,14 +633,13 @@ class MapLink:
""" """
return None 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 Hook to override for customizing how the directions are
determined. determined.
Args: Args:
start_direction (str): The starting direction (n, ne etc). start_direction (str): The starting direction (n, ne etc).
xygrid (dict): 2D dict with x,y coordinates as keys.
Returns: Returns:
str: The 'out' direction side of the link - where the link str: The 'out' direction side of the link - where the link
@ -588,13 +653,12 @@ class MapLink:
""" """
return self.directions.get(start_direction) 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. Hook to override for customizing how the weights are determined.
Args: Args:
start_direction (str): The starting direction (n, ne etc). 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 current_weight (int): This can have an existing value if
we are progressing down a multi-step path. we are progressing down a multi-step path.
@ -604,15 +668,11 @@ class MapLink:
""" """
return self.weights.get(start_direction, self.default_weight) 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. Hook to override for customizing how the display_symbol is determined.
This is called after all other hooks, at map visualization. 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: Returns:
str: The display-symbol to use. This must visually be a single character str: The display-symbol to use. This must visually be a single character
but could have color markers, use a unicode font etc. but could have color markers, use a unicode font etc.
@ -657,7 +717,7 @@ class SmartRerouterMapLink(MapLink):
""" """
multilink = True 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. Dynamically determine the direction based on a source direction and grid topology.
@ -665,7 +725,7 @@ class SmartRerouterMapLink(MapLink):
# get all visually connected links # get all visually connected links
if not self.directions: if not self.directions:
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 # get all straight lines (n-s, sw-ne etc) we can trace through
# the dynamic link and remove them from the unhandled_links list # the dynamic link and remove them from the unhandled_links list
@ -726,7 +786,7 @@ class TeleporterMapLink(MapLink):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.paired_teleporter = None 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 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 targeting a link at an empty spot on the grid). This will also search for
@ -735,7 +795,6 @@ class TeleporterMapLink(MapLink):
Args: Args:
start_direction (str): The direction (n, ne etc) from which this traversal originates start_direction (str): The direction (n, ne etc) from which this traversal originates
for this link. for this link.
xygrid (dict): 2D dict with x,y coordinates as keys.
Returns: Returns:
TeleporterMapLink: The paired teleporter. TeleporterMapLink: The paired teleporter.
@ -746,6 +805,7 @@ class TeleporterMapLink(MapLink):
'pointing to an empty space' error we'd get if returning `None`. 'pointing to an empty space' error we'd get if returning `None`.
""" """
xygrid = self.xymap.xygrid
if not self.paired_teleporter: if not self.paired_teleporter:
# scan for another teleporter # scan for another teleporter
symbol = self.symbol symbol = self.symbol
@ -769,13 +829,13 @@ class TeleporterMapLink(MapLink):
return self.paired_teleporter 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. Figure out the connected link and paired teleport.
""" """
if not self.directions: if not self.directions:
neighbors = self.get_linked_neighbors(xygrid) neighbors = self.get_linked_neighbors()
if len(neighbors) != 1: if len(neighbors) != 1:
raise MapParserError("must have exactly one link connected to it.", self) raise MapParserError("must have exactly one link connected to it.", self)
@ -846,7 +906,7 @@ class SmartMapLink(MapLink):
""" """
multilink = True 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. 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 # get all visually connected links
if not self.directions: if not self.directions:
directions = {} directions = {}
neighbors = self.get_linked_neighbors(xygrid) neighbors = self.get_linked_neighbors()
nodes = [direction for direction, neighbor in neighbors.items() nodes = [direction for direction, neighbor in neighbors.items()
if hasattr(neighbor, 'node_index')] if hasattr(neighbor, 'node_index')]
@ -914,7 +974,7 @@ class InvisibleSmartMapLink(SmartMapLink):
(('ne', 'sw'), ('sw', 'ne')): '/', (('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 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' 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"): if not hasattr(self, "_cached_display_symbol"):
legend = xymap.legend legend = self.xymap.legend
default_symbol = ( default_symbol = (
self.symbol if self.display_symbol is None else self.display_symbol) self.symbol if self.display_symbol is None else self.display_symbol)
self._cached_display_symbol = default_symbol self._cached_display_symbol = default_symbol
@ -939,8 +999,8 @@ class InvisibleSmartMapLink(SmartMapLink):
if node_or_link_class: if node_or_link_class:
# initiate class in the current location and run get_display_symbol # initiate class in the current location and run get_display_symbol
# to get what it would show. # to get what it would show.
self._cached_display_symbol = node_or_link_class( self._cached_display_symbol = (
self.x, self.y).get_display_symbol(xygrid, xymap=xymap, **kwargs) node_or_link_class(self.x, self.y, self.Z).get_display_symbol())
return self._cached_display_symbol return self._cached_display_symbol
@ -950,15 +1010,15 @@ class InvisibleSmartMapLink(SmartMapLink):
class BasicMapNode(MapNode): class BasicMapNode(MapNode):
"""Basic map Node""" """Basic map Node"""
symbol = "#" symbol = "#"
prototype = "xyz_room_prototype"
class MapTransitionMapNode(TransitionMapNode): class MapTransitionMapNode(TransitionMapNode):
"""Teleports entering players to other map""" """Transition-target to other map"""
symbol = "T" symbol = "T"
display_symbol = " " display_symbol = " "
interrupt_path = True target_map_coords = (0, 0, 'unset') # must be changed
linked_map_name = "" prototype = None # important!
linked_map_coords = None
class InterruptMapNode(MapNode): class InterruptMapNode(MapNode):
@ -966,30 +1026,35 @@ class InterruptMapNode(MapNode):
symbol = "I" symbol = "I"
display_symbol = "#" display_symbol = "#"
interrupt_path = True interrupt_path = True
prototype = "xyz_room_prototype"
class NSMapLink(MapLink): class NSMapLink(MapLink):
"""Two-way, North-South link""" """Two-way, North-South link"""
symbol = "|" symbol = "|"
directions = {"n": "s", "s": "n"} directions = {"n": "s", "s": "n"}
prototype = "xyz_exit_prototype"
class EWMapLink(MapLink): class EWMapLink(MapLink):
"""Two-way, East-West link""" """Two-way, East-West link"""
symbol = "-" symbol = "-"
directions = {"e": "w", "w": "e"} directions = {"e": "w", "w": "e"}
prototype = "xyz_exit_prototype"
class NESWMapLink(MapLink): class NESWMapLink(MapLink):
"""Two-way, NorthWest-SouthWest link""" """Two-way, NorthWest-SouthWest link"""
symbol = "/" symbol = "/"
directions = {"ne": "sw", "sw": "ne"} directions = {"ne": "sw", "sw": "ne"}
prototype = "xyz_exit_prototype"
class SENWMapLink(MapLink): class SENWMapLink(MapLink):
"""Two-way, SouthEast-NorthWest link""" """Two-way, SouthEast-NorthWest link"""
symbol = "\\" symbol = "\\"
directions = {"se": "nw", "nw": "se"} directions = {"se": "nw", "nw": "se"}
prototype = "xyz_exit_prototype"
class PlusMapLink(MapLink): class PlusMapLink(MapLink):
@ -997,6 +1062,7 @@ class PlusMapLink(MapLink):
symbol = "+" symbol = "+"
directions = {"s": "n", "n": "s", directions = {"s": "n", "n": "s",
"e": "w", "w": "e"} "e": "w", "w": "e"}
prototype = "xyz_exit_prototype"
class CrossMapLink(MapLink): class CrossMapLink(MapLink):
@ -1004,30 +1070,35 @@ class CrossMapLink(MapLink):
symbol = "x" symbol = "x"
directions = {"ne": "sw", "sw": "ne", directions = {"ne": "sw", "sw": "ne",
"se": "nw", "nw": "se"} "se": "nw", "nw": "se"}
prototype = "xyz_exit_prototype"
class NSOneWayMapLink(MapLink): class NSOneWayMapLink(MapLink):
"""One-way North-South link""" """One-way North-South link"""
symbol = "v" symbol = "v"
directions = {"n": "s"} directions = {"n": "s"}
prototype = "xyz_exit_prototype"
class SNOneWayMapLink(MapLink): class SNOneWayMapLink(MapLink):
"""One-way South-North link""" """One-way South-North link"""
symbol = "^" symbol = "^"
directions = {"s": "n"} directions = {"s": "n"}
prototype = "xyz_exit_prototype"
class EWOneWayMapLink(MapLink): class EWOneWayMapLink(MapLink):
"""One-way East-West link""" """One-way East-West link"""
symbol = "<" symbol = "<"
directions = {"e": "w"} directions = {"e": "w"}
prototype = "xyz_exit_prototype"
class WEOneWayMapLink(MapLink): class WEOneWayMapLink(MapLink):
"""One-way West-East link""" """One-way West-East link"""
symbol = ">" symbol = ">"
directions = {"w": "e"} directions = {"w": "e"}
prototype = "xyz_exit_prototype"
class UpMapLink(SmartMapLink): 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. # 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, direction_aliases = {'n': symbol, 'ne': symbol, 'e': symbol, 'se': symbol,
's': symbol, 'sw': symbol, 'w': symbol, 'nw': symbol} 's': symbol, 'sw': symbol, 'w': symbol, 'nw': symbol}
prototype = "xyz_exit_prototype"
class DownMapLink(UpMapLink): 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. # 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, direction_aliases = {'n': symbol, 'ne': symbol, 'e': symbol, 'se': symbol,
's': symbol, 'sw': symbol, 'w': symbol, 'nw': symbol} 's': symbol, 'sw': symbol, 'w': symbol, 'nw': symbol}
prototype = "xyz_exit_prototype"
class InterruptMapLink(InvisibleSmartMapLink): class InterruptMapLink(InvisibleSmartMapLink):
"""A (still passable) link that causes the pathfinder to stop before crossing.""" """A (still passable) link that causes the pathfinder to stop before crossing."""
symbol = "i" symbol = "i"
interrupt_path = True interrupt_path = True
prototype = "xyz_exit_prototype"
class BlockedMapLink(InvisibleSmartMapLink): class BlockedMapLink(InvisibleSmartMapLink):
@ -1063,6 +1137,7 @@ class BlockedMapLink(InvisibleSmartMapLink):
symbol = 'b' symbol = 'b'
weights = {'n': BIGVAL, 'ne': BIGVAL, 'e': BIGVAL, 'se': BIGVAL, weights = {'n': BIGVAL, 'ne': BIGVAL, 'e': BIGVAL, 'se': BIGVAL,
's': BIGVAL, 'sw': BIGVAL, 'w': BIGVAL, 'nw': BIGVAL} 's': BIGVAL, 'sw': BIGVAL, 'w': BIGVAL, 'nw': BIGVAL}
prototype = "xyz_exit_prototype"
class RouterMapLink(SmartRerouterMapLink): class RouterMapLink(SmartRerouterMapLink):

View file

@ -8,6 +8,7 @@ from time import time
from random import randint from random import randint
from unittest import TestCase from unittest import TestCase
from parameterized import parameterized from parameterized import parameterized
from django.test import override_settings
from . import xymap, xyzgrid, map_legend from . import xymap, xyzgrid, map_legend
@ -346,7 +347,7 @@ class TestMap1(TestCase):
""" """
def setUp(self): def setUp(self):
self.map = xymap.XYMap({"map": MAP1}, name="testmap") self.map = xymap.XYMap({"map": MAP1}, Z="testmap")
self.map.parse() self.map.parse()
def test_str_output(self): def test_str_output(self):
@ -425,13 +426,14 @@ class TestMap1(TestCase):
mapstr = self.map.get_visual_range(coord, dist=dist, mode='nodes', character='@') mapstr = self.map.get_visual_range(coord, dist=dist, mode='nodes', character='@')
self.assertEqual(expected, mapstr) self.assertEqual(expected, mapstr)
class TestMap2(TestCase): class TestMap2(TestCase):
""" """
Test with Map2 - a bigger map with multi-step links Test with Map2 - a bigger map with multi-step links
""" """
def setUp(self): def setUp(self):
self.map = xymap.XYMap({"map": MAP2}, name="testmap") self.map = xymap.XYMap({"map": MAP2}, Z="testmap")
self.map.parse() self.map.parse()
def test_str_output(self): def test_str_output(self):
@ -542,7 +544,7 @@ class TestMap3(TestCase):
""" """
def setUp(self): def setUp(self):
self.map = xymap.XYMap({"map": MAP3}, name="testmap") self.map = xymap.XYMap({"map": MAP3}, Z="testmap")
self.map.parse() self.map.parse()
def test_str_output(self): def test_str_output(self):
@ -592,7 +594,7 @@ class TestMap4(TestCase):
""" """
def setUp(self): def setUp(self):
self.map = xymap.XYMap({"map": MAP4}, name="testmap") self.map = xymap.XYMap({"map": MAP4}, Z="testmap")
self.map.parse() self.map.parse()
def test_str_output(self): def test_str_output(self):
@ -623,7 +625,7 @@ class TestMap5(TestCase):
""" """
def setUp(self): def setUp(self):
self.map = xymap.XYMap({"map": MAP5}, name="testmap") self.map = xymap.XYMap({"map": MAP5}, Z="testmap")
self.map.parse() self.map.parse()
def test_str_output(self): def test_str_output(self):
@ -652,7 +654,7 @@ class TestMap6(TestCase):
""" """
def setUp(self): def setUp(self):
self.map = xymap.XYMap({"map": MAP6}, name="testmap") self.map = xymap.XYMap({"map": MAP6}, Z="testmap")
self.map.parse() self.map.parse()
def test_str_output(self): def test_str_output(self):
@ -685,7 +687,7 @@ class TestMap7(TestCase):
""" """
def setUp(self): def setUp(self):
self.map = xymap.XYMap({"map": MAP7}, name="testmap") self.map = xymap.XYMap({"map": MAP7}, Z="testmap")
self.map.parse() self.map.parse()
def test_str_output(self): def test_str_output(self):
@ -714,7 +716,7 @@ class TestMap8(TestCase):
""" """
def setUp(self): def setUp(self):
self.map = xymap.XYMap({"map": MAP8}, name="testmap") self.map = xymap.XYMap({"map": MAP8}, Z="testmap")
self.map.parse() self.map.parse()
def test_str_output(self): def test_str_output(self):
@ -781,7 +783,7 @@ class TestMap9(TestCase):
""" """
def setUp(self): def setUp(self):
self.map = xymap.XYMap({"map": MAP9}, name="testmap") self.map = xymap.XYMap({"map": MAP9}, Z="testmap")
self.map.parse() self.map.parse()
def test_str_output(self): def test_str_output(self):
@ -811,7 +813,7 @@ class TestMap10(TestCase):
""" """
def setUp(self): def setUp(self):
self.map = xymap.XYMap({"map": MAP10}, name="testmap") self.map = xymap.XYMap({"map": MAP10}, Z="testmap")
self.map.parse() self.map.parse()
def test_str_output(self): def test_str_output(self):
@ -860,7 +862,7 @@ class TestMap11(TestCase):
""" """
def setUp(self): def setUp(self):
self.map = xymap.XYMap({"map": MAP11}, name="testmap") self.map = xymap.XYMap({"map": MAP11}, Z="testmap")
self.map.parse() self.map.parse()
def test_str_output(self): def test_str_output(self):
@ -951,7 +953,7 @@ class TestMapStressTest(TestCase):
grid = self._get_grid(Xmax, Ymax) grid = self._get_grid(Xmax, Ymax)
# print(f"\n\n{grid}\n") # print(f"\n\n{grid}\n")
t0 = time() t0 = time()
mapobj = xymap.XYMap({'map': grid}, name="testmap") mapobj = xymap.XYMap({'map': grid}, Z="testmap")
mapobj.parse() mapobj.parse()
t1 = time() t1 = time()
self.assertLess(t1 - t0, max_time, f"Map creation of ({Xmax}x{Ymax}) grid slower " 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 Xmax, Ymax = gridsize
grid = self._get_grid(Xmax, Ymax) grid = self._get_grid(Xmax, Ymax)
mapobj = xymap.XYMap({'map': grid}, name="testmap") mapobj = xymap.XYMap({'map': grid}, Z="testmap")
mapobj.parse() mapobj.parse()
t0 = time() t0 = time()
@ -1001,7 +1003,7 @@ class TestMapStressTest(TestCase):
""" """
Xmax, Ymax = gridsize Xmax, Ymax = gridsize
grid = self._get_grid(Xmax, Ymax) grid = self._get_grid(Xmax, Ymax)
mapobj = xymap.XYMap({'map': grid}, name="testmap") mapobj = xymap.XYMap({'map': grid}, Z="testmap")
mapobj.parse() mapobj.parse()
t0 = time() t0 = time()
@ -1026,20 +1028,49 @@ class TestMapStressTest(TestCase):
f"slower than expected {max_time}s.") 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 # map transitions
class Map12aTransition(map_legend.MapTransitionMapNode): class Map12aTransition(map_legend.MapTransitionMapNode):
symbol = "T" symbol = "T"
linked_map_name = "map12b" target_map_coords = (1, 0, "map12b")
linked_map_coords = (1, 0)
class Map12bTransition(map_legend.MapTransitionMapNode): class Map12bTransition(map_legend.MapTransitionMapNode):
symbol = "T" symbol = "T"
linked_map_name= "map12a" target_map_coords = (0, 1, "map12a")
linked_map_coords = (0, 1)
class TestXYZGrid(TestCase): class TestXYZGridTransition(TestCase):
""" """
Test the XYZGrid class and transitions between maps. Test the XYZGrid class and transitions between maps.
@ -1049,12 +1080,12 @@ class TestXYZGrid(TestCase):
self.map_data12a = { self.map_data12a = {
"map": MAP12a, "map": MAP12a,
"name": "map12a", "zcoord": "map12a",
"legend": {"T": Map12aTransition} "legend": {"T": Map12aTransition}
} }
self.map_data12b = { self.map_data12b = {
"map": MAP12b, "map": MAP12b,
"name": "map12b", "zcoord": "map12b",
"legend": {"T": Map12bTransition} "legend": {"T": Map12bTransition}
} }
@ -1076,9 +1107,9 @@ class TestXYZGrid(TestCase):
directions, _ = self.grid.get('map12a').get_shortest_path(startcoord, endcoord) directions, _ = self.grid.get('map12a').get_shortest_path(startcoord, endcoord)
self.assertEqual(expected_directions, tuple(directions)) 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()

View file

@ -53,7 +53,7 @@ as up and down. These are indicated in code as 'n', 'ne', 'e', 'se', 's', 'sw',
MAP_DATA = { MAP_DATA = {
"map": MAP, "map": MAP,
"legend": LEGEND, "legend": LEGEND,
"name": "City of Foo", "zcoord": "City of Foo",
"prototypes": { "prototypes": {
(0,1): { ... }, (0,1): { ... },
(1,3): { ... }, (1,3): { ... },
@ -104,12 +104,17 @@ except ImportError as err:
from django.conf import settings from django.conf import settings
from evennia.utils.utils import variable_from_module, mod_import from evennia.utils.utils import variable_from_module, mod_import
from evennia.utils import logger from evennia.utils import logger
from evennia.prototypes import prototypes as protlib
from .utils import MapError, MapParserError, BIGVAL from .utils import MapError, MapParserError, BIGVAL
from . import map_legend from . import map_legend
_CACHE_DIR = settings.CACHE_DIR _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 # these are all symbols used for x,y coordinate spots
DEFAULT_LEGEND = { DEFAULT_LEGEND = {
@ -134,6 +139,8 @@ DEFAULT_LEGEND = {
't': map_legend.TeleporterMapLink, 't': map_legend.TeleporterMapLink,
} }
# -------------------------------------------- # --------------------------------------------
# Map parser implementation # Map parser implementation
@ -184,7 +191,7 @@ class XYMap:
# we normally only accept one single character for the legend key # we normally only accept one single character for the legend key
legend_key_exceptions = ("\\") 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. 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, 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' this should be a dict with a MAP_DATA key 'map' and optionally a 'legend'
dicts to specify the map structure. dicts to specify the map structure.
name (str, optional): Unique identifier for this map. Needed if the game uses Z (int or str, optional): Name or Z-coord for for this map. Needed if the game uses
more than one map. Used when referencing this map during map transitions, more than one map. If not given, it can also be embedded in the
baking of pathfinding matrices etc. This will be overridden by any 'name' given `map_module_or_dict`. Used when referencing this map during map transitions,
in the MAP_DATA itself. baking of pathfinding matrices etc.
grid (.xyzgrid.XYZgrid): A top-level grid this map is a part of. xyzgrid (.xyzgrid.XYZgrid): A top-level grid this map is a part of.
Notes: 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. - grid-coordinates x,y are the character positions in the map string.
- world-coordinates X,Y are the in-world coordinates of nodes/rooms. - world-coordinates X,Y are the in-world coordinates of nodes/rooms.
There are fewer of these since they ignore the 'link' spaces between 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 X = x // 2
Y = y // 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 = "" self.mapstring = ""
# store so we can reload # store so we can reload
self.map_module_or_dict = map_module_or_dict self.map_module_or_dict = map_module_or_dict
self.grid = grid
self.prototypes = None self.prototypes = None
# transitional mapping # transitional mapping
@ -237,10 +254,10 @@ class XYMap:
self.pathfinding_routes = None self.pathfinding_routes = None
self.pathfinder_baked_filename = None self.pathfinder_baked_filename = None
if name: if Z:
if not isdir(_CACHE_DIR): if not isdir(_CACHE_DIR):
mkdir(_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 # load data and parse it
self.reload() self.reload()
@ -257,6 +274,88 @@ class XYMap:
def __repr__(self): def __repr__(self):
return f"<Map {self.max_X + 1}x{self.max_Y + 1}, {len(self.node_index_map)} nodes>" return f"<Map {self.max_X + 1}x{self.max_Y + 1}, {len(self.node_index_map)} nodes>"
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": <str>,
"zcoord": <int or str>, # optional
"legend": <dict>, # optional
"prototypes": <dict> # 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): def parse(self):
""" """
Parses the numerical grid from the string. The first pass means parsing out Parses the numerical grid from the string. The first pass means parsing out
@ -359,26 +458,33 @@ class XYMap:
node_index += 1 node_index += 1
xygrid[ix][iy] = XYgrid[iX][iY] = node_index_map[node_index] = \ 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: else:
# we have a link at this xygrid position (this is ok everywhere) # 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 # store the symbol mapping for transition lookups
symbol_map[char].append(xygrid[ix][iy]) 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(): for node in node_index_map.values():
node.build_links(xygrid) node.build_links()
# build display map # build display map
display_map = [[" "] * (max_x + 1) for _ in range(max_y + 1)] display_map = [[" "] * (max_x + 1) for _ in range(max_y + 1)]
for ix, ydct in xygrid.items(): for ix, ydct in xygrid.items():
for iy, node_or_link in ydct.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 # validate and make sure all nodes/links have prototypes
for node in node_index_map.values(): for node in node_index_map.values():
@ -389,16 +495,7 @@ class XYMap:
for direction, maplink in node.first_links.items(): for direction, maplink in node.first_links.items():
maplink.prototype = self.prototypes.get(node_coord + (direction,), maplink.prototype) maplink.prototype = self.prototypes.get(node_coord + (direction,), maplink.prototype)
# store results # store
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
self.display_map = display_map self.display_map = display_map
def _get_topology_around_coord(self, coord, dist=2): 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), pickle.dump((self.mapstring, self.dist_matrix, self.pathfinding_routes),
fil, protocol=4) 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: Args:
map_module_or_dict (str, module or dict, optional): See description for the variable coord (tuple, optional): An (X,Y) coordinate of node(s). `None` acts as a wildcard.
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: Examples:
This will both (re)load the data and parse it into a new map structure, replacing any - `coord=(1, 3) - spawn (1,3) coordinate only.
existing one. - `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: x, y = coord
map_module_or_dict = self.map_module_or_dict
mapdata = {} spawned = []
if isinstance(map_module_or_dict, dict): for node in self.node_index_map.values():
mapdata = map_module_or_dict if (x is None or x == node.X) and (y is None or y == node.Y):
else: node.spawn()
mod = mod_import(map_module_or_dict) spawned.append(node)
mapdata = variable_from_module(mod, "MAP_DATA") return spawned
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={})
# validate def spawn_links(self, coord=(None, None), nodes=None, only_directions=None):
for key in mapdata.get('legend', DEFAULT_LEGEND): """
if not key or len(key) > 1: Convert links of this XYMap into actual in-game exits by spawning their related
if key not in self.legend_key_exceptions: prototypes. It's possible to only spawn a specic exit by specifying the node and
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.")
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) x, y = coord
self.mapstring = mapdata['map'] if not nodes:
self.prototypes = mapdata.get('prototypes', {}) nodes = self.node_index_map.values()
# merge the custom legend onto the default legend to allow easily
# overriding only parts of it for node in nodes:
self.legend = {**DEFAULT_LEGEND, **map_module_or_dict.get("legend", DEFAULT_LEGEND)} 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): def get_node_from_coord(self, coords):
""" """
@ -775,7 +871,7 @@ class XYMap:
def _default_callable(node): def _default_callable(node):
return target_path_style.format( return target_path_style.format(
display_symbol=node.get_display_symbol(self.xygrid)) display_symbol=node.get_display_symbol())
if callable(target_path_style): if callable(target_path_style):
_target_path_style = target_path_style _target_path_style = target_path_style

View file

@ -20,6 +20,7 @@ import itertools
from evennia.scripts.scripts import DefaultScript from evennia.scripts.scripts import DefaultScript
from evennia.utils import logger from evennia.utils import logger
from .xymap import XYMap from .xymap import XYMap
from .xyzroom import XYZRoom, XYZExit
class XYZGrid(DefaultScript): class XYZGrid(DefaultScript):
@ -54,12 +55,13 @@ class XYZGrid(DefaultScript):
nmaps = 0 nmaps = 0
# generate all Maps - this will also initialize their components # generate all Maps - this will also initialize their components
# and bake any pathfinding paths (or load from disk-cache) # and bake any pathfinding paths (or load from disk-cache)
for mapname, mapdata in self.db.map_data.items(): for zcoord, mapdata in self.db.map_data.items():
logger.log_info(f"[grid] Loading map '{mapname}'...")
xymap = XYMap(dict(mapdata), name=mapname, grid=self) logger.log_info(f"[grid] Loading map '{zcoord}'...")
xymap = XYMap(dict(mapdata), Z=zcoord, xyzgrid=self)
xymap.parse() xymap.parse()
xymap.calculate_path_matrix() xymap.calculate_path_matrix()
self.ndb.grid[mapname] = xymap self.ndb.grid[zcoord] = xymap
nmaps += 1 nmaps += 1
# store # store
@ -82,43 +84,44 @@ class XYZGrid(DefaultScript):
`{"map": <mapstr>, "legend": <legenddict>, "name": <name>, `{"map": <mapstr>, "legend": <legenddict>, "name": <name>,
"prototypes": <dict-of-dicts>}`. The `prototypes are "prototypes": <dict-of-dicts>}`. The `prototypes are
coordinate-specific overrides for nodes/links on the map, keyed with their coordinate-specific overrides for nodes/links on the map, keyed with their
(X,Y) coordinate. (X,Y) coordinate within that map.
Raises: Raises:
RuntimeError: If mapdata is malformed. 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: for mapdata in mapdatas:
name = mapdata.get('name') zcoord = mapdata.get('zcoord')
if not name: if not zcoord:
raise RuntimeError("XYZGrid.add_map data must contain 'name'.") 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: 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 remove_objects (bool, optional): If the synced database objects (rooms/exits) should
be removed alongside this map. be removed alongside this map.
""" """
if mapname in self.db.map_data: for zcoord in zcoords:
self.db.map_data.pop(zcoord) if zcoord in self.db.map_data:
self.reload() 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: def delete(self):
pass """
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 Create/recreate/update the in-game grid based on the stored Maps or for a specific Map
or coordinate. or coordinate.
@ -126,8 +129,8 @@ class XYZGrid(DefaultScript):
Args: Args:
coord (tuple, optional): An (X,Y,Z) coordinate, where Z is the name of the map. `None` coord (tuple, optional): An (X,Y,Z) coordinate, where Z is the name of the map. `None`
acts as a wildcard. acts as a wildcard.
direction (str, optional): A cardinal direction ('n', 'ne' etc). If given, sync only the only_directions (list, optional): A list of cardinal directions ('n', 'ne' etc).
exit in the given direction. `None` acts as a wildcard. If given, spawn exits only the given direction. `None` acts as a wildcard.
Examples: Examples:
- `coord=(1, 3, 'foo')` - sync a specific element of map 'foo' only. - `coord=(1, 3, 'foo')` - sync a specific element of map 'foo' only.
@ -147,14 +150,12 @@ class XYZGrid(DefaultScript):
else: else:
raise RuntimeError(f"The 'z' coordinate/name '{z}' is not found on the grid.") raise RuntimeError(f"The 'z' coordinate/name '{z}' is not found on the grid.")
# first syncing pass (only nodes/rooms) # first build all nodes/rooms
synced = [] for zcoord, xymap in xymaps.items():
for xymap in xymaps: logger.log_info(f"[grid] spawning/updating nodes for {zcoord} ...")
for node in xymap.node_index_map.values(): xymap.spawn_nodes(coord=(x, y))
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()
# 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)

View file

@ -10,6 +10,7 @@ used as stand-alone XYZ-coordinate-aware rooms.
from django.db.models import Q from django.db.models import Q
from evennia.objects.objects import DefaultRoom, DefaultExit from evennia.objects.objects import DefaultRoom, DefaultExit
from evennia.objects.manager import ObjectManager from evennia.objects.manager import ObjectManager
from evennia.utils.utils import inherits_from
# name of all tag categories. Note that the Z-coordinate is # name of all tag categories. Note that the Z-coordinate is
# the `map_name` of the XYZgrid # the `map_name` of the XYZgrid
@ -185,6 +186,22 @@ class XYZRoom(DefaultRoom):
# makes the `room.objects.filter_xymap` available # makes the `room.objects.filter_xymap` available
objects = XYZManager() objects = XYZManager()
def __str__(self):
return repr(self)
def __repr__(self):
x, y, z = self.xyzcoords
return f"<XYZRoom '{self.db_key}', XYZ=({x},{y},{z})>"
@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 @classmethod
def create(cls, key, account=None, coord=(0, 0, 'map'), **kwargs): 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) " return None, [f"XYRroom.create got `coord={coord}` - needs a valid (X,Y,Z) "
"coordinate of ints/strings."] "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(): if existing_query.exists():
existing_room = existing_query.first() existing_room = existing_query.first()
return None, [f"XYRoom XYZ={coord} already exists " return None, [f"XYRoom XYZ={coord} already exists "
@ -227,7 +244,7 @@ class XYZRoom(DefaultRoom):
(str(z), MAP_Z_TAG_CATEGORY), (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): class XYZExit(DefaultExit):
@ -238,6 +255,32 @@ class XYZExit(DefaultExit):
objects = XYZExitManager() objects = XYZExitManager()
def __str__(self):
return repr(self)
def __repr__(self):
x, y, z = self.xyzcoords
xd, yd, zd = self.xyzdestcoords
return f"<XYZExit '{self.db_key}', XYZ=({x},{y},{z})->({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 @classmethod
def create(cls, key, account=None, coord=(0, 0, 'map'), destination_coord=(0, 0, 'map'), def create(cls, key, account=None, coord=(0, 0, 'map'), destination_coord=(0, 0, 'map'),
location=None, destination=None, **kwargs): location=None, destination=None, **kwargs):
@ -246,8 +289,7 @@ class XYZExit(DefaultExit):
Args: Args:
key (str): New name of object to create. key (str): New name of object to create.
account (Account, optional): Any Account to tie to this entity (usually not used for account (Account, optional): Any Account to tie to this entity (unused for exits).
rooms).
coords (tuple or None, optional): A 3D coordinate (X, Y, Z) for this room's location 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 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 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 `location`. destination_coord (tuple or None, optional): Works as `coords`, but for
destination of destination of
the exit. Set to `None` if using the `destination` kwarg to point to room directly. 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 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. 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. be any room (including non-XYRooms) and is not checked for XY coordinates.
**kwargs: Will be passed into the normal `DefaultRoom.create` method. **kwargs: Will be passed into the normal `DefaultRoom.create` method.
Returns: Returns:
room (Object): A newly created Room of the given typeclass. tuple: A tuple `(exit, errors)`, where the errors is a list containing all found
errors (list): A list of errors in string form, if any. errors (in which case the returned exit will be `None`).
""" """
tags = [] tags = []
@ -274,22 +318,28 @@ class XYZExit(DefaultExit):
return None, ["XYExit.create need either a `coord` or a `location`."] return None, ["XYExit.create need either a `coord` or a `location`."]
source = location source = location
else: 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), tags.extend(((str(x), MAP_X_TAG_CATEGORY),
(str(y), MAP_Y_TAG_CATEGORY), (str(y), MAP_Y_TAG_CATEGORY),
(str(z), MAP_Z_TAG_CATEGORY))) (str(z), MAP_Z_TAG_CATEGORY)))
try: if destination:
xdest, ydest, zdest = destination_coord
except ValueError:
if not destination:
return None, ["XYExit.create need either a `destination_coord` or a `destination`."]
dest = destination dest = destination
else: else:
dest = cls.objects.get_xyz(x=xdest, y=ydest, z=zdest) try:
tags.extend(((str(xdest), MAP_XDEST_TAG_CATEGORY), xdest, ydest, zdest = destination_coord
(str(ydest), MAP_YDEST_TAG_CATEGORY), except ValueError:
(str(zdest), MAP_ZDEST_TAG_CATEGORY))) 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( return DefaultExit.create(
key, source, dest, account=account, key, source, dest, account=account,
location=location, destination=destination, tags=tags, **kwargs) location=location, tags=tags, typeclass=cls, **kwargs)

View file

@ -816,6 +816,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
use_destination=True, use_destination=True,
to_none=False, to_none=False,
move_hooks=True, move_hooks=True,
alternative_source=None,
**kwargs, **kwargs,
): ):
""" """
@ -837,6 +838,10 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
move_hooks (bool): If False, turn off the calling of move-related hooks 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 (at_before/after_move etc) with quiet=True, this is as quiet a move
as can be done. 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: Keyword Args:
Passed on to announce_move_to and announce_move_from hooks. 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)` 7. `self.at_after_move(source_location)`
""" """
def logerr(string="", err=None): def logerr(string="", err=None):
"""Simple log helper method""" """Simple log helper method"""
logger.log_trace() logger.log_trace()
@ -872,6 +876,8 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
if not emit_to_obj: if not emit_to_obj:
emit_to_obj = self emit_to_obj = self
source_location = alternative_source or self.location
if not destination: if not destination:
if to_none: if to_none:
# immediately move to None. There can be no hooks called since # 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. # Before the move, call eventual pre-commands.
if move_hooks: if move_hooks:
try: try:
if not self.at_before_move(destination, **kwargs): if not source_location.at_before_move(destination, **kwargs):
return False return False
except Exception as err: except Exception as err:
logerr(errtxt.format(err="at_before_move()"), err) logerr(errtxt.format(err="at_before_move()"), err)
return False return False
# Save the old location
source_location = self.location
# Call hook on source location # Call hook on source location
if move_hooks and source_location: if move_hooks and source_location:
try: try:
@ -2446,7 +2449,7 @@ class DefaultRoom(DefaultObject):
if not locks and account: if not locks and account:
locks = cls.lockstring.format(**{"id": account.id}) locks = cls.lockstring.format(**{"id": account.id})
elif not locks and not account: elif not locks and not account:
locks = cls.lockstring(**{"id": obj.id}) locks = cls.lockstring.format(**{"id": obj.id})
obj.locks.add(locks) obj.locks.add(locks)
@ -2461,6 +2464,7 @@ class DefaultRoom(DefaultObject):
obj.db.desc = description if description else _("This is a room.") obj.db.desc = description if description else _("This is a room.")
except Exception as e: except Exception as e:
raise
errors.append("An error occurred while creating this '%s' object." % key) errors.append("An error occurred while creating this '%s' object." % key)
logger.log_err(e) logger.log_err(e)
@ -2667,7 +2671,7 @@ class DefaultExit(DefaultObject):
obj.db.desc = description if description else _("This is an exit.") obj.db.desc = description if description else _("This is an exit.")
except Exception as e: 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) logger.log_err(e)
return obj, errors return obj, errors

View file

@ -144,19 +144,45 @@ def homogenize_prototype(prototype, custom_keys=None):
return homogenized 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 Load module prototypes. Also prototype-dicts passed directly to this function are considered
to do this late so as to not interfere with evennia initialization. '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: global _MODULE_PROTOTYPE_MODULES, _MODULE_PROTOTYPES
# to remove a default prototype, override it with an empty dict.
# internally we store as (key, desc, locks, tags, prototype_dict) 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 = [] prots = []
prototype_list = variable_from_module(mod, "PROTOTYPE_LIST") prototype_list = variable_from_module(mod, "PROTOTYPE_LIST")
if prototype_list: if prototype_list:
# found mod.PROTOTYPE_LIST - this should be a list of valid # 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: if "prototype_key" not in prot:
prot["prototype_key"] = variable_name.lower() prot["prototype_key"] = variable_name.lower()
prots.append((prot["prototype_key"], homogenize_prototype(prot))) prots.append((prot["prototype_key"], homogenize_prototype(prot)))
return prots
# assign module path to each prototype_key for easy reference def _cleanup_prototype(prototype_key, prototype, mod=None):
_MODULE_PROTOTYPE_MODULES.update({prototype_key.lower(): mod for prototype_key, _ in prots}) """
# make sure the prototype contains all meta info 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: for prototype_key, prot in prots:
actual_prot_key = prot.get("prototype_key", prototype_key).lower() prototype = _cleanup_prototype(prototype_key, prot, mod=mod)
prot.update( # the key can change since in-proto key is given prio over variable-name-based keys
{ actual_prototype_key = prototype['prototype_key']
"prototype_key": actual_prot_key,
"prototype_desc": prot["prototype_desc"] if "prototype_desc" in prot else mod, if actual_prototype_key in _MODULE_PROTOTYPES and not override:
"prototype_locks": ( # don't override - useful to still let settings replace dynamic inserts
prot["prototype_locks"] continue
if "prototype_locks" in prot
else "use:all();edit:false()" # make sure the prototype contains all meta info
), _MODULE_PROTOTYPES[actual_prototype_key] = prototype
"prototype_tags": list( # track module path for display purposes
set(list(make_iter(prot.get("prototype_tags", []))) + ["module"]) _MODULE_PROTOTYPE_MODULES[actual_prototype_key.lower()] = mod
),
}
)
_MODULE_PROTOTYPES[actual_prot_key] = prot
# Db-based prototypes # Db-based prototypes
@ -266,11 +339,12 @@ def save_prototype(prototype):
# we can't edit a prototype defined in a module # we can't edit a prototype defined in a module
if prototype_key in _MODULE_PROTOTYPES: if prototype_key in _MODULE_PROTOTYPES:
mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key, "N/A") mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key)
raise PermissionError( if mod:
_("{protkey} is a read-only prototype " "(defined as code in {module}).").format( err = _("{protkey} is a read-only prototype (defined as code in {module}).")
protkey=prototype_key, module=mod) 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 # make sure meta properties are included with defaults
in_prototype["prototype_desc"] = in_prototype.get( in_prototype["prototype_desc"] = in_prototype.get(
@ -334,11 +408,12 @@ def delete_prototype(prototype_key, caller=None):
""" """
if prototype_key in _MODULE_PROTOTYPES: if prototype_key in _MODULE_PROTOTYPES:
mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key.lower(), "N/A") mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key)
raise PermissionError( if mod:
_("{protkey} is a read-only prototype " "(defined as code in {module}).").format( err = _("{protkey} is a read-only prototype (defined as code in {module}).")
protkey=prototype_key, module=mod) 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) 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() ndbprots = db_matches.count()
if nmodules + ndbprots != 1: if nmodules + ndbprots != 1:
raise KeyError(_( raise KeyError(_(
"Found {num} matching prototypes {module_prototypes}.").format( "Found {num} matching prototypes among {module_prototypes}.").format(
num=nmodules + ndbprots, num=nmodules + ndbprots,
module_prototypes=module_prototypes) module_prototypes=module_prototypes)
) )
@ -906,10 +981,12 @@ def check_permission(prototype_key, action, default=True):
""" """
if action == "edit": if action == "edit":
if prototype_key in _MODULE_PROTOTYPES: if prototype_key in _MODULE_PROTOTYPES:
mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key, "N/A") mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key)
logger.log_err( if mod:
"{} is a read-only prototype " "(defined as code in {}).".format(prototype_key, 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 return False
prototype = search_prototype(key=prototype_key) prototype = search_prototype(key=prototype_key)

View file

@ -797,7 +797,7 @@ class TypeclassManager(TypedObjectManager):
all_subclasses.extend(self._get_subclasses(subclass)) all_subclasses.extend(self._get_subclasses(subclass))
return all_subclasses return all_subclasses
def get_family(self, **kwargs): def get_family(self, *args, **kwargs):
""" """
Variation of get that not only returns the current typeclass Variation of get that not only returns the current typeclass
but also all subclasses of that 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) "%s.%s" % (cls.__module__, cls.__name__) for cls in self._get_subclasses(self.model)
] ]
kwargs.update({"db_typeclass_path__in": paths}) kwargs.update({"db_typeclass_path__in": paths})
return super().get(**kwargs) return super().get(*args, **kwargs)
def filter_family(self, *args, **kwargs): def filter_family(self, *args, **kwargs):
""" """

View file

@ -244,7 +244,7 @@ callable must be a module-global function on the form
>: start >: start
# node abort ## node abort
This exits the menu since there is no `## options` section. This exits the menu since there is no `## options` section.