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

View file

@ -8,6 +8,7 @@ from time import time
from random import randint
from unittest import TestCase
from parameterized import parameterized
from django.test import override_settings
from . import xymap, xyzgrid, map_legend
@ -346,7 +347,7 @@ class TestMap1(TestCase):
"""
def setUp(self):
self.map = xymap.XYMap({"map": MAP1}, name="testmap")
self.map = xymap.XYMap({"map": MAP1}, Z="testmap")
self.map.parse()
def test_str_output(self):
@ -425,13 +426,14 @@ class TestMap1(TestCase):
mapstr = self.map.get_visual_range(coord, dist=dist, mode='nodes', character='@')
self.assertEqual(expected, mapstr)
class TestMap2(TestCase):
"""
Test with Map2 - a bigger map with multi-step links
"""
def setUp(self):
self.map = xymap.XYMap({"map": MAP2}, name="testmap")
self.map = xymap.XYMap({"map": MAP2}, Z="testmap")
self.map.parse()
def test_str_output(self):
@ -542,7 +544,7 @@ class TestMap3(TestCase):
"""
def setUp(self):
self.map = xymap.XYMap({"map": MAP3}, name="testmap")
self.map = xymap.XYMap({"map": MAP3}, Z="testmap")
self.map.parse()
def test_str_output(self):
@ -592,7 +594,7 @@ class TestMap4(TestCase):
"""
def setUp(self):
self.map = xymap.XYMap({"map": MAP4}, name="testmap")
self.map = xymap.XYMap({"map": MAP4}, Z="testmap")
self.map.parse()
def test_str_output(self):
@ -623,7 +625,7 @@ class TestMap5(TestCase):
"""
def setUp(self):
self.map = xymap.XYMap({"map": MAP5}, name="testmap")
self.map = xymap.XYMap({"map": MAP5}, Z="testmap")
self.map.parse()
def test_str_output(self):
@ -652,7 +654,7 @@ class TestMap6(TestCase):
"""
def setUp(self):
self.map = xymap.XYMap({"map": MAP6}, name="testmap")
self.map = xymap.XYMap({"map": MAP6}, Z="testmap")
self.map.parse()
def test_str_output(self):
@ -685,7 +687,7 @@ class TestMap7(TestCase):
"""
def setUp(self):
self.map = xymap.XYMap({"map": MAP7}, name="testmap")
self.map = xymap.XYMap({"map": MAP7}, Z="testmap")
self.map.parse()
def test_str_output(self):
@ -714,7 +716,7 @@ class TestMap8(TestCase):
"""
def setUp(self):
self.map = xymap.XYMap({"map": MAP8}, name="testmap")
self.map = xymap.XYMap({"map": MAP8}, Z="testmap")
self.map.parse()
def test_str_output(self):
@ -781,7 +783,7 @@ class TestMap9(TestCase):
"""
def setUp(self):
self.map = xymap.XYMap({"map": MAP9}, name="testmap")
self.map = xymap.XYMap({"map": MAP9}, Z="testmap")
self.map.parse()
def test_str_output(self):
@ -811,7 +813,7 @@ class TestMap10(TestCase):
"""
def setUp(self):
self.map = xymap.XYMap({"map": MAP10}, name="testmap")
self.map = xymap.XYMap({"map": MAP10}, Z="testmap")
self.map.parse()
def test_str_output(self):
@ -860,7 +862,7 @@ class TestMap11(TestCase):
"""
def setUp(self):
self.map = xymap.XYMap({"map": MAP11}, name="testmap")
self.map = xymap.XYMap({"map": MAP11}, Z="testmap")
self.map.parse()
def test_str_output(self):
@ -951,7 +953,7 @@ class TestMapStressTest(TestCase):
grid = self._get_grid(Xmax, Ymax)
# print(f"\n\n{grid}\n")
t0 = time()
mapobj = xymap.XYMap({'map': grid}, name="testmap")
mapobj = xymap.XYMap({'map': grid}, Z="testmap")
mapobj.parse()
t1 = time()
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
grid = self._get_grid(Xmax, Ymax)
mapobj = xymap.XYMap({'map': grid}, name="testmap")
mapobj = xymap.XYMap({'map': grid}, Z="testmap")
mapobj.parse()
t0 = time()
@ -1001,7 +1003,7 @@ class TestMapStressTest(TestCase):
"""
Xmax, Ymax = gridsize
grid = self._get_grid(Xmax, Ymax)
mapobj = xymap.XYMap({'map': grid}, name="testmap")
mapobj = xymap.XYMap({'map': grid}, Z="testmap")
mapobj.parse()
t0 = time()
@ -1026,20 +1028,49 @@ class TestMapStressTest(TestCase):
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
class Map12aTransition(map_legend.MapTransitionMapNode):
symbol = "T"
linked_map_name = "map12b"
linked_map_coords = (1, 0)
target_map_coords = (1, 0, "map12b")
class Map12bTransition(map_legend.MapTransitionMapNode):
symbol = "T"
linked_map_name= "map12a"
linked_map_coords = (0, 1)
target_map_coords = (0, 1, "map12a")
class TestXYZGrid(TestCase):
class TestXYZGridTransition(TestCase):
"""
Test the XYZGrid class and transitions between maps.
@ -1049,12 +1080,12 @@ class TestXYZGrid(TestCase):
self.map_data12a = {
"map": MAP12a,
"name": "map12a",
"zcoord": "map12a",
"legend": {"T": Map12aTransition}
}
self.map_data12b = {
"map": MAP12b,
"name": "map12b",
"zcoord": "map12b",
"legend": {"T": Map12bTransition}
}
@ -1076,9 +1107,9 @@ class TestXYZGrid(TestCase):
directions, _ = self.grid.get('map12a').get_shortest_path(startcoord, endcoord)
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": MAP,
"legend": LEGEND,
"name": "City of Foo",
"zcoord": "City of Foo",
"prototypes": {
(0,1): { ... },
(1,3): { ... },
@ -104,12 +104,17 @@ except ImportError as err:
from django.conf import settings
from evennia.utils.utils import variable_from_module, mod_import
from evennia.utils import logger
from evennia.prototypes import prototypes as protlib
from .utils import MapError, MapParserError, BIGVAL
from . import map_legend
_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
DEFAULT_LEGEND = {
@ -134,6 +139,8 @@ DEFAULT_LEGEND = {
't': map_legend.TeleporterMapLink,
}
# --------------------------------------------
# Map parser implementation
@ -184,7 +191,7 @@ class XYMap:
# we normally only accept one single character for the legend key
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.
@ -192,31 +199,41 @@ class XYMap:
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'
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. This will be overridden by any 'name' given
in the MAP_DATA itself.
grid (.xyzgrid.XYZgrid): A top-level grid this map is a part of.
Z (int or str, optional): Name or Z-coord for for this map. Needed if the game uses
more than one map. If not given, it can also be embedded in the
`map_module_or_dict`. Used when referencing this map during map transitions,
baking of pathfinding matrices etc.
xyzgrid (.xyzgrid.XYZgrid): A top-level grid this map is a part of.
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.
- world-coordinates X,Y are the in-world coordinates of nodes/rooms.
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
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 = ""
# store so we can reload
self.map_module_or_dict = map_module_or_dict
self.grid = grid
self.prototypes = None
# transitional mapping
@ -237,10 +254,10 @@ class XYMap:
self.pathfinding_routes = None
self.pathfinder_baked_filename = None
if name:
if Z:
if not isdir(_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
self.reload()
@ -257,6 +274,88 @@ class XYMap:
def __repr__(self):
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):
"""
Parses the numerical grid from the string. The first pass means parsing out
@ -359,26 +458,33 @@ class XYMap:
node_index += 1
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:
# 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
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():
node.build_links(xygrid)
node.build_links()
# build display map
display_map = [[" "] * (max_x + 1) for _ in range(max_y + 1)]
for ix, ydct in xygrid.items():
for iy, node_or_link in ydct.items():
display_map[iy][ix] = node_or_link.get_display_symbol(xygrid, xymap=self)
display_map[iy][ix] = node_or_link.get_display_symbol()
# validate and make sure all nodes/links have prototypes
for node in node_index_map.values():
@ -389,16 +495,7 @@ class XYMap:
for direction, maplink in node.first_links.items():
maplink.prototype = self.prototypes.get(node_coord + (direction,), maplink.prototype)
# store results
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
# store
self.display_map = display_map
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),
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:
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.
coord (tuple, optional): An (X,Y) coordinate of node(s). `None` acts as a wildcard.
Notes:
This will both (re)load the data and parse it into a new map structure, replacing any
existing one.
Examples:
- `coord=(1, 3) - spawn (1,3) coordinate only.
- `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:
map_module_or_dict = self.map_module_or_dict
x, y = coord
mapdata = {}
if isinstance(map_module_or_dict, dict):
mapdata = map_module_or_dict
else:
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['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={})
spawned = []
for node in self.node_index_map.values():
if (x is None or x == node.X) and (y is None or y == node.Y):
node.spawn()
spawned.append(node)
return spawned
# validate
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.")
def spawn_links(self, coord=(None, None), nodes=None, only_directions=None):
"""
Convert links of this XYMap into actual in-game exits by spawning their related
prototypes. It's possible to only spawn a specic exit by specifying the node and
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)
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)}
"""
x, y = coord
if not nodes:
nodes = self.node_index_map.values()
for node in nodes:
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):
"""
@ -775,7 +871,7 @@ class XYMap:
def _default_callable(node):
return target_path_style.format(
display_symbol=node.get_display_symbol(self.xygrid))
display_symbol=node.get_display_symbol())
if callable(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.utils import logger
from .xymap import XYMap
from .xyzroom import XYZRoom, XYZExit
class XYZGrid(DefaultScript):
@ -54,12 +55,13 @@ class XYZGrid(DefaultScript):
nmaps = 0
# generate all Maps - this will also initialize their components
# and bake any pathfinding paths (or load from disk-cache)
for mapname, mapdata in self.db.map_data.items():
logger.log_info(f"[grid] Loading map '{mapname}'...")
xymap = XYMap(dict(mapdata), name=mapname, grid=self)
for zcoord, mapdata in self.db.map_data.items():
logger.log_info(f"[grid] Loading map '{zcoord}'...")
xymap = XYMap(dict(mapdata), Z=zcoord, xyzgrid=self)
xymap.parse()
xymap.calculate_path_matrix()
self.ndb.grid[mapname] = xymap
self.ndb.grid[zcoord] = xymap
nmaps += 1
# store
@ -82,43 +84,44 @@ class XYZGrid(DefaultScript):
`{"map": <mapstr>, "legend": <legenddict>, "name": <name>,
"prototypes": <dict-of-dicts>}`. The `prototypes are
coordinate-specific overrides for nodes/links on the map, keyed with their
(X,Y) coordinate.
(X,Y) coordinate within that map.
Raises:
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:
name = mapdata.get('name')
if not name:
raise RuntimeError("XYZGrid.add_map data must contain 'name'.")
zcoord = mapdata.get('zcoord')
if not zcoord:
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:
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
be removed alongside this map.
"""
if mapname in self.db.map_data:
self.db.map_data.pop(zcoord)
self.reload()
for zcoord in zcoords:
if zcoord in self.db.map_data:
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:
pass
def delete(self):
"""
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
or coordinate.
@ -126,8 +129,8 @@ class XYZGrid(DefaultScript):
Args:
coord (tuple, optional): An (X,Y,Z) coordinate, where Z is the name of the map. `None`
acts as a wildcard.
direction (str, optional): A cardinal direction ('n', 'ne' etc). If given, sync only the
exit in the given direction. `None` acts as a wildcard.
only_directions (list, optional): A list of cardinal directions ('n', 'ne' etc).
If given, spawn exits only the given direction. `None` acts as a wildcard.
Examples:
- `coord=(1, 3, 'foo')` - sync a specific element of map 'foo' only.
@ -147,14 +150,12 @@ class XYZGrid(DefaultScript):
else:
raise RuntimeError(f"The 'z' coordinate/name '{z}' is not found on the grid.")
# first syncing pass (only nodes/rooms)
synced = []
for xymap in xymaps:
for node in xymap.node_index_map.values():
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()
# first build all nodes/rooms
for zcoord, xymap in xymaps.items():
logger.log_info(f"[grid] spawning/updating nodes for {zcoord} ...")
xymap.spawn_nodes(coord=(x, y))
# 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 evennia.objects.objects import DefaultRoom, DefaultExit
from evennia.objects.manager import ObjectManager
from evennia.utils.utils import inherits_from
# name of all tag categories. Note that the Z-coordinate is
# the `map_name` of the XYZgrid
@ -185,6 +186,22 @@ class XYZRoom(DefaultRoom):
# makes the `room.objects.filter_xymap` available
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
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) "
"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():
existing_room = existing_query.first()
return None, [f"XYRoom XYZ={coord} already exists "
@ -227,7 +244,7 @@ class XYZRoom(DefaultRoom):
(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):
@ -238,6 +255,32 @@ class XYZExit(DefaultExit):
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
def create(cls, key, account=None, coord=(0, 0, 'map'), destination_coord=(0, 0, 'map'),
location=None, destination=None, **kwargs):
@ -246,8 +289,7 @@ class XYZExit(DefaultExit):
Args:
key (str): New name of object to create.
account (Account, optional): Any Account to tie to this entity (usually not used for
rooms).
account (Account, optional): Any Account to tie to this entity (unused for exits).
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
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
destination of
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
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.
**kwargs: Will be passed into the normal `DefaultRoom.create` method.
Returns:
room (Object): A newly created Room of the given typeclass.
errors (list): A list of errors in string form, if any.
tuple: A tuple `(exit, errors)`, where the errors is a list containing all found
errors (in which case the returned exit will be `None`).
"""
tags = []
@ -274,22 +318,28 @@ class XYZExit(DefaultExit):
return None, ["XYExit.create need either a `coord` or a `location`."]
source = location
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),
(str(y), MAP_Y_TAG_CATEGORY),
(str(z), MAP_Z_TAG_CATEGORY)))
try:
xdest, ydest, zdest = destination_coord
except ValueError:
if not destination:
return None, ["XYExit.create need either a `destination_coord` or a `destination`."]
if destination:
dest = destination
else:
dest = cls.objects.get_xyz(x=xdest, y=ydest, z=zdest)
tags.extend(((str(xdest), MAP_XDEST_TAG_CATEGORY),
(str(ydest), MAP_YDEST_TAG_CATEGORY),
(str(zdest), MAP_ZDEST_TAG_CATEGORY)))
try:
xdest, ydest, zdest = destination_coord
except ValueError:
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(
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,
to_none=False,
move_hooks=True,
alternative_source=None,
**kwargs,
):
"""
@ -837,6 +838,10 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
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
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:
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)`
"""
def logerr(string="", err=None):
"""Simple log helper method"""
logger.log_trace()
@ -872,6 +876,8 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
if not emit_to_obj:
emit_to_obj = self
source_location = alternative_source or self.location
if not destination:
if to_none:
# 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.
if move_hooks:
try:
if not self.at_before_move(destination, **kwargs):
if not source_location.at_before_move(destination, **kwargs):
return False
except Exception as err:
logerr(errtxt.format(err="at_before_move()"), err)
return False
# Save the old location
source_location = self.location
# Call hook on source location
if move_hooks and source_location:
try:
@ -2446,7 +2449,7 @@ class DefaultRoom(DefaultObject):
if not locks and account:
locks = cls.lockstring.format(**{"id": account.id})
elif not locks and not account:
locks = cls.lockstring(**{"id": obj.id})
locks = cls.lockstring.format(**{"id": obj.id})
obj.locks.add(locks)
@ -2461,6 +2464,7 @@ class DefaultRoom(DefaultObject):
obj.db.desc = description if description else _("This is a room.")
except Exception as e:
raise
errors.append("An error occurred while creating this '%s' object." % key)
logger.log_err(e)
@ -2667,7 +2671,7 @@ class DefaultExit(DefaultObject):
obj.db.desc = description if description else _("This is an exit.")
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)
return obj, errors

View file

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

View file

@ -797,7 +797,7 @@ class TypeclassManager(TypedObjectManager):
all_subclasses.extend(self._get_subclasses(subclass))
return all_subclasses
def get_family(self, **kwargs):
def get_family(self, *args, **kwargs):
"""
Variation of get that not only returns the current 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)
]
kwargs.update({"db_typeclass_path__in": paths})
return super().get(**kwargs)
return super().get(*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
# node abort
## node abort
This exits the menu since there is no `## options` section.