mirror of
https://github.com/evennia/evennia.git
synced 2026-03-23 08:16:30 +01:00
Finished launcher, testing example
This commit is contained in:
parent
2af82b19e1
commit
1c6fffeff2
10 changed files with 504 additions and 170 deletions
|
|
@ -16,6 +16,8 @@ Use `evennia xyzgrid help` for usage help.
|
|||
|
||||
"""
|
||||
|
||||
from os.path import join as pathjoin
|
||||
from django.conf import settings
|
||||
from evennia.contrib.xyzgrid.xyzgrid import get_xyzgrid
|
||||
|
||||
_HELP_SHORT = """
|
||||
|
|
@ -37,7 +39,7 @@ evennia xyzgrid init
|
|||
First start of the grid. This will create the XYZGrid global script. No maps are loaded yet!
|
||||
It's safe to run this command multiple times; the grid will only be initialized once.
|
||||
|
||||
evennia xyzgrid add path.to.xymap.module
|
||||
evennia xyzgrid add path.to.xymap.module [,path, path,...]
|
||||
|
||||
Add one or more XYmaps (each a string-map representing one Z position along with prototypes
|
||||
etc). The module will be parsed for
|
||||
|
|
@ -45,11 +47,12 @@ evennia xyzgrid add path.to.xymap.module
|
|||
- a XYMAP_DATA a dict
|
||||
{"map": mapstring, "zcoord": mapname/zcoord, "legend": dict, "prototypes": dict}
|
||||
describing one single XYmap, or
|
||||
- a XYMAP_LIST - a list of multiple dicts on the XYMAP_DATA form. This allows to load
|
||||
- a XYMAP_DATA_LIST - a list of multiple dicts on the XYMAP_DATA form. This allows to load
|
||||
multiple maps from the same module.
|
||||
|
||||
Note that adding a map does *not* build it. If maps are linked to one another, you should add
|
||||
all linked maps before building, or you'll get errors when spawning the linking exits.
|
||||
all linked maps before running 'build', or you'll get errors when creating transitional exits
|
||||
between maps.
|
||||
|
||||
evennia xyzgrid build
|
||||
|
||||
|
|
@ -106,38 +109,77 @@ def _option_list(**suboptions):
|
|||
print(str(xymap))
|
||||
|
||||
|
||||
def _option_init(**suboptions):
|
||||
def _option_init(*suboptions):
|
||||
"""
|
||||
Initialize a new grid. Will fail if a Grid already exists.
|
||||
|
||||
"""
|
||||
grid = get_xyzgrid()
|
||||
print(f"The grid is initalized as the Script 'XYZGrid'({grid.dbref})")
|
||||
print(f"The grid is initalized as the Script '{grid.key}'({grid.dbref})")
|
||||
|
||||
def _option_add(**suboptions):
|
||||
|
||||
def _option_add(*suboptions):
|
||||
"""
|
||||
Add a new map to the grid.
|
||||
Add one or more map to the grid. Supports `add path,path,path,...`
|
||||
|
||||
"""
|
||||
grid = get_xyzgrid()
|
||||
xymap_data_list = []
|
||||
for path in suboptions:
|
||||
xymap_data_list.expand(grid.maps_from_module(path))
|
||||
grid.add_maps(*xymap_data_list)
|
||||
|
||||
def _option_build(**suboptions):
|
||||
|
||||
def _option_build(*suboptions):
|
||||
"""
|
||||
Build the grid or part of it.
|
||||
|
||||
"""
|
||||
grid = get_xyzgrid()
|
||||
|
||||
def _option_initpath(**suboptions):
|
||||
# override grid's logger to echo directly to console
|
||||
def _log(self, msg):
|
||||
print(msg)
|
||||
grid.log = _log
|
||||
|
||||
if suboptions:
|
||||
opts = ''.join(suboptions).strip('()')
|
||||
# coordinate tuple
|
||||
try:
|
||||
x, y, z = (part.strip() for part in opts.split(","))
|
||||
except ValueError:
|
||||
print("Build coordinate must be given as (X, Y, Z) tuple, where '*' act "
|
||||
"wild cards and Z is the mapname/z-coord of the map to load.")
|
||||
return
|
||||
else:
|
||||
x, y, z = '*', '*', '*'
|
||||
|
||||
grid.spawn(xyz=(x, y, z))
|
||||
|
||||
|
||||
def _option_initpath(*suboptions):
|
||||
"""
|
||||
Initialize the pathfinding matrices for grid or part of it.
|
||||
|
||||
"""
|
||||
|
||||
def _option_delete(**suboptions):
|
||||
"""
|
||||
Delete the grid or parts of it.
|
||||
(Re)Initialize the pathfinding matrices for grid or part of it.
|
||||
|
||||
"""
|
||||
grid = get_xyzgrid()
|
||||
xymaps = grid.all_rooms()
|
||||
nmaps = len(xymaps)
|
||||
for inum, xymap in enumerate(grid.all_rooms()):
|
||||
print(f"Rebuilding pathfinding matrix for xymap Z={xymap.Z} ({inum+1}/{nmaps}) ...")
|
||||
xymap.calculate_path_matrix(force=True)
|
||||
|
||||
cachepath = pathjoin(settings.GAMEDIR, "server", ".cache")
|
||||
print(f"... done. Data cached to {cachepath}.")
|
||||
|
||||
|
||||
def _option_delete(*suboptions):
|
||||
"""
|
||||
Delete the grid or parts of it. Allows mapname,mapname, ...
|
||||
|
||||
"""
|
||||
|
||||
grid = get_xyzgrid()
|
||||
if not suboptions:
|
||||
repl = input("WARNING: This will delete the ENTIRE Grid and wipe all rooms/exits!"
|
||||
"\nObjects/Chars inside deleted rooms will be moved to their home locations."
|
||||
|
|
@ -146,16 +188,35 @@ def _option_delete(**suboptions):
|
|||
print("Aborted.")
|
||||
else:
|
||||
print("Deleting grid ...")
|
||||
grid = get_xyzgrid()
|
||||
grid.delete()
|
||||
|
||||
else:
|
||||
pass
|
||||
zcoords = (part.strip() for part in suboptions)
|
||||
err = False
|
||||
for zcoord in zcoords:
|
||||
if not grid.get_map(zcoord):
|
||||
print(f"Mapname/zcoord {zcoord} is not a part of the grid.")
|
||||
err = True
|
||||
if err:
|
||||
print("Valid mapnames/zcoords are\n:", "\n ".join(
|
||||
xymap.Z for xymap in grid.all_rooms()))
|
||||
return
|
||||
repl = input("This will delete map(s) {', '.join(zcoords)} and wipe all corresponding "
|
||||
"rooms/exits!"
|
||||
"\nObjects/Chars inside deleted rooms will be moved to their home locations."
|
||||
"\nThis can't be undone. Are you sure you want to continue? Y/[N]?")
|
||||
if repl.lower() not in ('yes', 'y'):
|
||||
print("Aborted.")
|
||||
else:
|
||||
print("Deleting selected xymaps ...")
|
||||
|
||||
grid.remove_map(*zcoords, remove_objects=True)
|
||||
|
||||
|
||||
def xyzcommand(*args):
|
||||
"""
|
||||
Evennia launcher command. This is made available as `evennia xyzgrid` on the command line,
|
||||
once `settings.EXTRA_LAUNCHER_COMMANDS` is updated.
|
||||
once added to `settings.EXTRA_LAUNCHER_COMMANDS`.
|
||||
|
||||
"""
|
||||
if not args:
|
||||
|
|
|
|||
|
|
@ -1,68 +1,263 @@
|
|||
MAP = r"""
|
||||
"""
|
||||
Example xymaps to use with the XYZgrid contrib. Build outside of the game using
|
||||
the `evennia xyzgrid` launcher command.
|
||||
|
||||
First add the launcher extension in your mygame/server/conf/settings.py:
|
||||
|
||||
EXTRA_LAUNCHER_COMMANDS['xyzgrid'] = 'evennia.contrib.xyzgrid.launchcmd.xyzcommand'
|
||||
|
||||
Then
|
||||
|
||||
evennia xyzgrid init
|
||||
evennia xyzgrid add evennia.contrib.xyzgrid.map_example
|
||||
evennia xyzgrid build
|
||||
|
||||
|
||||
|
||||
|
||||
"""
|
||||
|
||||
from evennia.contrib.xyzgrid import map_legend
|
||||
|
||||
# default prototype parent. It's important that
|
||||
# the typeclass inherits from the XYZRoom (or XYZExit)
|
||||
# the map_legend.XYZROOM_PARENT and XYZEXIT_PARENTS can also
|
||||
# be used as a shortcut.
|
||||
|
||||
PARENT = {
|
||||
"key": "An empty room",
|
||||
"prototype_key": "xyzmap_room_map1",
|
||||
"typeclass": "evennia.contrib.xyzgrid.xyzroom.XYZRoom",
|
||||
"desc": "An empty room."
|
||||
}
|
||||
|
||||
# -------------------- map 1 - the large tree
|
||||
# this exemplifies the various map symbols
|
||||
# but is not heavily prototyped
|
||||
|
||||
MAP1 = r"""
|
||||
1
|
||||
+ 0 1 2 3 4 5 6 7 8 9 0
|
||||
|
||||
10 #-#-#-#-#
|
||||
| | \
|
||||
9 #---+---#-#-----I
|
||||
\ | /
|
||||
8 #-#-#-#-# #
|
||||
9 #-------#-#-------I
|
||||
\ /
|
||||
8 #-#---# #-t
|
||||
|\ |
|
||||
7 #i#-#-#+#-----#-t
|
||||
7 #i#-#b--#-t
|
||||
| |
|
||||
6 #i#-#---#-#-#-#-#
|
||||
| |x|x|
|
||||
5 o-#-#-# #-#-#
|
||||
\ / |x|x|
|
||||
4 o-o-#-# #-#-#
|
||||
/ /
|
||||
3 #-# / #
|
||||
\ / d
|
||||
2 o-o-#-# |
|
||||
| | u
|
||||
1 #-#-#># #
|
||||
^ |
|
||||
0 T-----#-# #-t
|
||||
5 o-#---#
|
||||
\ /
|
||||
4 o-o-#-#
|
||||
/ d
|
||||
3 #-----+-------#
|
||||
| d
|
||||
2 | |
|
||||
v u
|
||||
1 #---#>#-#
|
||||
/
|
||||
0 T-#
|
||||
|
||||
+ 0 1 2 3 4 5 6 7 8 9 0
|
||||
1
|
||||
"""
|
||||
|
||||
# use default legend
|
||||
LEGEND = {
|
||||
|
||||
class TransitionToCave(map_legend.MapTransitionMapNode):
|
||||
"""
|
||||
A transition from map2 to map1
|
||||
|
||||
"""
|
||||
symbol = 'T'
|
||||
target_map_xyz = (2, 3, 'small cave')
|
||||
|
||||
|
||||
# extends the default legend
|
||||
LEGEND_MAP1 = {
|
||||
'T': TransitionToCave
|
||||
}
|
||||
|
||||
PARENT = {
|
||||
"key": "An empty dungeon room",
|
||||
"prototype_key": "dungeon_doom_prot",
|
||||
"typeclass": "evennia.contrib.xyzgrid.xyzrooms.XYZRoom",
|
||||
"desc": "Air is cold and stale in this barren room."
|
||||
}
|
||||
|
||||
# link coordinates to rooms
|
||||
ROOMS = {
|
||||
(1, 0): {
|
||||
PROTOTYPES_MAP1 = {
|
||||
# node/room prototypes
|
||||
(3, 0): {
|
||||
"key": "Dungeon Entrance",
|
||||
"prototype_parent": PARENT,
|
||||
"desc": "A dark entrance."
|
||||
"desc": "To the west, a narrow opening leads into darkness."
|
||||
},
|
||||
(4, 0): {
|
||||
"key": "Antechamber",
|
||||
"prototype_parent": PARENT,
|
||||
"desc": "A small antechamber",
|
||||
(4, 1): {
|
||||
"key": "Under the foilage of a giant tree",
|
||||
"desc": "High above the branches of a giant tree blocs out the sunlight. A slide "
|
||||
"leading down from the upper branches ends here."
|
||||
},
|
||||
(4, 4): {
|
||||
"key": "The slide",
|
||||
"desc": "A slide leads down to the ground from here. It looks like a one-way trip."
|
||||
},
|
||||
(6, 1): {
|
||||
"key": "Thorny path",
|
||||
"desc": "To the east is a pathway of thorns. If you get through, you don't think you'll be "
|
||||
"able to get back here the same way."
|
||||
},
|
||||
(8, 1): {
|
||||
"key": "By a large tree",
|
||||
"desc": "You are standing at the root of a great tree."
|
||||
},
|
||||
(8, 3): {
|
||||
"key": "At the top of the tree",
|
||||
"desc": "You are at the top of the tree."
|
||||
},
|
||||
(3, 7): {
|
||||
"key": "Dense foilage",
|
||||
"desc": "The foilage to the east is extra dense. It will take forever to get through it."
|
||||
},
|
||||
(5, 7): {
|
||||
"key": "On a huge branch",
|
||||
"desc": "To the east is a glowing light, may be a teleporter."
|
||||
},
|
||||
(9, 8): {
|
||||
"key": "On an enormous branch",
|
||||
"desc": "To the east is a glowing light, may be a teleporter."
|
||||
},
|
||||
(10, 9): {
|
||||
"key": "A gorgeous view",
|
||||
"desc": "The view from here is breathtaking, showing the forest stretching far and wide."
|
||||
},
|
||||
# default rooms
|
||||
('*', '*'): {
|
||||
"key": "Among the branches of a giant tree",
|
||||
"desc": "These branches are wide enough to easily walk on. There's green all around."
|
||||
},
|
||||
# directional prototypes
|
||||
(3, 0, 'w'): {
|
||||
"desc": "A dark passage into the underworld."
|
||||
},
|
||||
}
|
||||
|
||||
for prot in PROTOTYPES_MAP1.values():
|
||||
prot['prototype_parent'] = PARENT
|
||||
|
||||
|
||||
XYMAP_DATA_MAP1 = {
|
||||
"zcoord": "the large tree",
|
||||
"map": MAP1,
|
||||
"legend": LEGEND_MAP1,
|
||||
"prototypes": PROTOTYPES_MAP1
|
||||
}
|
||||
|
||||
# ------------- map2 definitions - small cave
|
||||
# this gives prototypes for every room
|
||||
|
||||
MAP2 = r"""
|
||||
+ 0 1 2 3
|
||||
|
||||
3 #-#-#
|
||||
|x|
|
||||
2 #-#-#
|
||||
| \
|
||||
1 #---#
|
||||
| /
|
||||
0 T-#-#
|
||||
|
||||
+ 0 1 2 3
|
||||
|
||||
"""
|
||||
|
||||
# custom map node
|
||||
class TransitionToLargeTree(map_legend.MapTransitionMapNode):
|
||||
"""
|
||||
A transition from map1 to map2
|
||||
|
||||
"""
|
||||
symbol = 'T'
|
||||
target_map_xyz = (3, 0, 'the large tree')
|
||||
|
||||
|
||||
# this extends the default legend (that defines #,-+ etc)
|
||||
LEGEND_MAP2 = {
|
||||
"T": TransitionToLargeTree
|
||||
}
|
||||
|
||||
# prototypes for specific locations
|
||||
PROTOTYPES_MAP2 = {
|
||||
# node/rooms prototype overrides
|
||||
(1, 0): {
|
||||
"key": "The entrance",
|
||||
"desc": "This is the entrance to a small cave leading into the ground. "
|
||||
"Light sifts in from the outside, while cavernous passages disappear "
|
||||
"into darkness."
|
||||
},
|
||||
(2, 0): {
|
||||
"key": "A gruesome sight.",
|
||||
"desc": "Something was killed here recently. The smell is unbearable."
|
||||
},
|
||||
(1, 1): {
|
||||
"key": "A dark pathway",
|
||||
"desc": "The path splits three ways here. To the north a faint light can be seen."
|
||||
},
|
||||
(3, 2): {
|
||||
"key": "Stagnant water",
|
||||
"desc": "A pool of stagnant, black water dominates this small chamber. To the nortwest "
|
||||
"a faint light can be seen."
|
||||
},
|
||||
(0, 2): {
|
||||
"key": "A dark alcove",
|
||||
"desc": "This alcove is empty."
|
||||
},
|
||||
(1, 2): {
|
||||
"key": "South-west corner of the atrium",
|
||||
"desc": "Sunlight sifts down into a large underground chamber. Weeds and grass sprout "
|
||||
"between the stones."
|
||||
},
|
||||
(2, 2): {
|
||||
"key": "South-east corner of the atrium",
|
||||
"desc": "Sunlight sifts down into a large underground chamber. Weeds and grass sprout "
|
||||
"between the stones."
|
||||
},
|
||||
(1, 3): {
|
||||
"key": "North-west corner of the atrium",
|
||||
"desc": "Sunlight sifts down into a large underground chamber. Weeds and grass sprout "
|
||||
"between the stones."
|
||||
},
|
||||
(2, 3): {
|
||||
"key": "North-east corner of the atrium",
|
||||
"desc": "Sunlight sifts down into a large underground chamber. Weeds and grass sprout "
|
||||
"between the stones. To the east is a dark passage."
|
||||
},
|
||||
(3, 3): {
|
||||
"key": "Craggy crevice",
|
||||
"desc": "This is the deepest part of the dungeon. The path shrinks away and there "
|
||||
"is no way to continue deeper."
|
||||
},
|
||||
# default fallback for undefined nodes
|
||||
('*', '*'): {
|
||||
"key": "A dark room",
|
||||
"desc": "A dark, but empty, room."
|
||||
},
|
||||
# directional prototypes
|
||||
(1, 0, 'w'): {
|
||||
"desc": "A narrow path to the fresh air of the outside world."
|
||||
},
|
||||
# directional fallbacks for unset directions
|
||||
('*', '*', '*'): {
|
||||
"desc": "A dark passage"
|
||||
}
|
||||
}
|
||||
|
||||
# this is required by the prototypes, but we add it all at once so we don't
|
||||
# need to add it to every line above
|
||||
for prot in PROTOTYPES_MAP2.values():
|
||||
prot['prototype_parent'] = PARENT
|
||||
|
||||
MAP_DATA = {
|
||||
"name": "Dungeon of Doom",
|
||||
"map": MAP,
|
||||
"legend": LEGEND,
|
||||
"rooms": ROOMS,
|
||||
|
||||
XYMAP_DATA_MAP2 = {
|
||||
"map": MAP2,
|
||||
"zcoord": "the small cave",
|
||||
"legend": LEGEND_MAP2,
|
||||
"prototypes": PROTOTYPES_MAP2
|
||||
}
|
||||
|
||||
XYMAP_LIST = [
|
||||
MAP_DATA
|
||||
# This is read by the parser
|
||||
XYMAP_DATA_LIST = [
|
||||
XYMAP_DATA_MAP1,
|
||||
XYMAP_DATA_MAP2
|
||||
]
|
||||
|
|
|
|||
|
|
@ -79,8 +79,8 @@ class MapNode:
|
|||
'sw': ('southwest', 'sw', 'south-west'),
|
||||
'w': ('west', 'w'),
|
||||
'nw': ('northwest', 'nw', 'north-west'),
|
||||
'd' : ('down', 'd', 'do'),
|
||||
'u' : ('up', 'u'),
|
||||
'd': ('down', 'd', 'do'),
|
||||
'u': ('up', 'u'),
|
||||
}
|
||||
|
||||
def __init__(self, x, y, Z, node_index=0, xymap=None):
|
||||
|
|
@ -261,6 +261,7 @@ class MapNode:
|
|||
return
|
||||
|
||||
xyz = self.get_spawn_xyz()
|
||||
print("xyz:", xyz, self.node_index)
|
||||
|
||||
try:
|
||||
nodeobj = NodeTypeclass.objects.get_xyz(xyz=xyz)
|
||||
|
|
@ -944,9 +945,10 @@ class SmartMapLink(MapLink):
|
|||
directions[direction] = REVERSE_DIRECTIONS[direction]
|
||||
else:
|
||||
raise MapParserError(
|
||||
f"must have exactly two connections - either "
|
||||
f"two nodes or unambiguous link directions. Found neighbor(s) in directions "
|
||||
f"{list(neighbors.keys())}.", self)
|
||||
"must have exactly two connections - either directly to "
|
||||
"two nodes or connecting directly to one node and with exactly one other "
|
||||
f"link direction. The neighbor(s) in directions {list(neighbors.keys())} do "
|
||||
"not fulfill these criteria.", self)
|
||||
|
||||
self.directions = directions
|
||||
return self.directions.get(start_direction)
|
||||
|
|
@ -1027,7 +1029,7 @@ class InvisibleSmartMapLink(SmartMapLink):
|
|||
class BasicMapNode(MapNode):
|
||||
"""Basic map Node"""
|
||||
symbol = "#"
|
||||
prototype = "xyz_room_prototype"
|
||||
prototype = "xyz_room"
|
||||
|
||||
|
||||
class MapTransitionMapNode(TransitionMapNode):
|
||||
|
|
@ -1043,35 +1045,35 @@ class InterruptMapNode(MapNode):
|
|||
symbol = "I"
|
||||
display_symbol = "#"
|
||||
interrupt_path = True
|
||||
prototype = "xyz_room_prototype"
|
||||
prototype = "xyz_room"
|
||||
|
||||
|
||||
class NSMapLink(MapLink):
|
||||
"""Two-way, North-South link"""
|
||||
symbol = "|"
|
||||
directions = {"n": "s", "s": "n"}
|
||||
prototype = "xyz_exit_prototype"
|
||||
prototype = "xyz_exit"
|
||||
|
||||
|
||||
class EWMapLink(MapLink):
|
||||
"""Two-way, East-West link"""
|
||||
symbol = "-"
|
||||
directions = {"e": "w", "w": "e"}
|
||||
prototype = "xyz_exit_prototype"
|
||||
prototype = "xyz_exit"
|
||||
|
||||
|
||||
class NESWMapLink(MapLink):
|
||||
"""Two-way, NorthWest-SouthWest link"""
|
||||
symbol = "/"
|
||||
directions = {"ne": "sw", "sw": "ne"}
|
||||
prototype = "xyz_exit_prototype"
|
||||
prototype = "xyz_exit"
|
||||
|
||||
|
||||
class SENWMapLink(MapLink):
|
||||
"""Two-way, SouthEast-NorthWest link"""
|
||||
symbol = "\\"
|
||||
directions = {"se": "nw", "nw": "se"}
|
||||
prototype = "xyz_exit_prototype"
|
||||
prototype = "xyz_exit"
|
||||
|
||||
|
||||
class PlusMapLink(MapLink):
|
||||
|
|
@ -1079,7 +1081,7 @@ class PlusMapLink(MapLink):
|
|||
symbol = "+"
|
||||
directions = {"s": "n", "n": "s",
|
||||
"e": "w", "w": "e"}
|
||||
prototype = "xyz_exit_prototype"
|
||||
prototype = "xyz_exit"
|
||||
|
||||
|
||||
class CrossMapLink(MapLink):
|
||||
|
|
@ -1087,35 +1089,35 @@ class CrossMapLink(MapLink):
|
|||
symbol = "x"
|
||||
directions = {"ne": "sw", "sw": "ne",
|
||||
"se": "nw", "nw": "se"}
|
||||
prototype = "xyz_exit_prototype"
|
||||
prototype = "xyz_exit"
|
||||
|
||||
|
||||
class NSOneWayMapLink(MapLink):
|
||||
"""One-way North-South link"""
|
||||
symbol = "v"
|
||||
directions = {"n": "s"}
|
||||
prototype = "xyz_exit_prototype"
|
||||
prototype = "xyz_exit"
|
||||
|
||||
|
||||
class SNOneWayMapLink(MapLink):
|
||||
"""One-way South-North link"""
|
||||
symbol = "^"
|
||||
directions = {"s": "n"}
|
||||
prototype = "xyz_exit_prototype"
|
||||
prototype = "xyz_exit"
|
||||
|
||||
|
||||
class EWOneWayMapLink(MapLink):
|
||||
"""One-way East-West link"""
|
||||
symbol = "<"
|
||||
directions = {"e": "w"}
|
||||
prototype = "xyz_exit_prototype"
|
||||
prototype = "xyz_exit"
|
||||
|
||||
|
||||
class WEOneWayMapLink(MapLink):
|
||||
"""One-way West-East link"""
|
||||
symbol = ">"
|
||||
directions = {"w": "e"}
|
||||
prototype = "xyz_exit_prototype"
|
||||
prototype = "xyz_exit"
|
||||
|
||||
|
||||
class UpMapLink(SmartMapLink):
|
||||
|
|
@ -1125,7 +1127,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"
|
||||
prototype = "xyz_exit"
|
||||
|
||||
|
||||
class DownMapLink(UpMapLink):
|
||||
|
|
@ -1134,14 +1136,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"
|
||||
prototype = "xyz_exit"
|
||||
|
||||
|
||||
class InterruptMapLink(InvisibleSmartMapLink):
|
||||
"""A (still passable) link that causes the pathfinder to stop before crossing."""
|
||||
symbol = "i"
|
||||
interrupt_path = True
|
||||
prototype = "xyz_exit_prototype"
|
||||
prototype = "xyz_exit"
|
||||
|
||||
|
||||
class BlockedMapLink(InvisibleSmartMapLink):
|
||||
|
|
@ -1154,7 +1156,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"
|
||||
prototype = "xyz_exit"
|
||||
|
||||
|
||||
class RouterMapLink(SmartRerouterMapLink):
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
"""
|
||||
Maprunner
|
||||
|
||||
This is a stand-alone program for baking and preparing grid-maps.
|
||||
|
||||
## Baking
|
||||
|
||||
The Dijkstra algorithm is very powerful for pathfinding, but for very large grids it can be slow
|
||||
to build the initial distance-matrix. As an example, for an extreme case of 10 000 nodes, all
|
||||
connected along all 8 cardinal directions, there are so many possible combinations that it
|
||||
takes about 25 seconds on medium hardware to build the matrix. 40 000 nodes takes about 9 minutes.
|
||||
|
||||
Once the matrix is built, pathfinding across the entire grid is a <0.1s operation however. So as
|
||||
long as the grid doesn't change, it's a good idea to pre-build it. Pre-building like this is
|
||||
often referred to as 'baking' the asset.
|
||||
|
||||
This program will build and run the Dijkstra on a given map and store the result as a
|
||||
serialized binary file in the `mygame/server/.cache/ directory. If it exists, the Map
|
||||
will load this file. If the map changed since it was saved, the file will be automatically
|
||||
be rebuilt.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,35 +1,32 @@
|
|||
"""
|
||||
Prototypes for building the XYZ-grid into actual game-rooms.
|
||||
Default prototypes for building the XYZ-grid into actual game-rooms.
|
||||
|
||||
Add this to mygame/conf/settings/settings.py:
|
||||
|
||||
PROTOTYPE_MODULES += ['evennia.contrib.xyzgrid.prototypes']
|
||||
|
||||
The prototypes can then be used in mapping prototypes as
|
||||
|
||||
{'prototype_parent': 'xyz_room', ...}
|
||||
|
||||
and/or
|
||||
|
||||
{'prototype_parent': 'xyz_exit', ...}
|
||||
|
||||
"""
|
||||
|
||||
# Note - the XYZRoom/exit parents track the XYZ coordinates automatically
|
||||
# so we don't need to add custom tags to them here.
|
||||
_ROOM_PARENT = {
|
||||
'prototype_tags': ("xyzroom", ),
|
||||
'typeclass': 'evennia.contrib.xyzgrid.xyzroom.XYZRoom'
|
||||
}
|
||||
|
||||
_EXIT_PARENT = {
|
||||
'prototype_tags': ("xyzexit", ),
|
||||
'typeclass': 'evennia.contrib.xyzgrid.xyzroom.XYZExit'
|
||||
}
|
||||
|
||||
# required by the prototype importer
|
||||
PROTOTYPE_LIST = [
|
||||
{
|
||||
'prototype_key': 'xyz_room_prototype',
|
||||
'prototype_parent': _ROOM_PARENT,
|
||||
'key': "A non-descript room",
|
||||
},{
|
||||
'prototype_key': 'xyz_transition_room_prototype',
|
||||
'prototype_parent': _ROOM_PARENT,
|
||||
'typeclass': 'evennia.contrib.xyzgrid.xyzroom.XYZMapTransitionRoom',
|
||||
},{
|
||||
'prototype_key': 'xyz_exit_prototype',
|
||||
'prototype_parent': _EXIT_PARENT,
|
||||
'prototype_key': 'xyz_room',
|
||||
'typeclass': 'evennia.contrib.xyzgrid.xyzroom.XYZRoom',
|
||||
'prototype_tags': ("xyzroom", ),
|
||||
'key': "A room",
|
||||
'desc': "An empty room."
|
||||
}, {
|
||||
'prototype_key': 'xyz_exit',
|
||||
'prototype_tags': ("xyzexit", ),
|
||||
'typeclass': 'evennia.contrib.xyzgrid.xyzroom.XYZExit',
|
||||
'desc': "An exit."
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -349,11 +349,11 @@ class _MapTest(TestCase):
|
|||
map_data = {'map': MAP1, 'zcoord': "map1"}
|
||||
map_display = MAP1_DISPLAY
|
||||
|
||||
def setUp(self):
|
||||
def setUp(self):
|
||||
"""Set up grid and map"""
|
||||
self.grid, err = xyzgrid.XYZGrid.create("testgrid")
|
||||
self.grid.add_maps(self.map_data)
|
||||
self.map = self.grid.get(self.map_data['zcoord'])
|
||||
self.map = self.grid.get_map(self.map_data['zcoord'])
|
||||
|
||||
def tearDown(self):
|
||||
self.grid.delete()
|
||||
|
|
@ -1155,7 +1155,7 @@ class TestXYZGrid(TestCase):
|
|||
|
||||
def test_str_output(self):
|
||||
"""Check the display_map"""
|
||||
xymap = self.grid.get(self.zcoord)
|
||||
xymap = self.grid.get_map(self.zcoord)
|
||||
stripped_map = "\n".join(line.rstrip() for line in str(xymap).split('\n'))
|
||||
self.assertEqual(MAP1_DISPLAY, stripped_map)
|
||||
|
||||
|
|
@ -1215,7 +1215,7 @@ class TestXYZGridTransition(TestCase):
|
|||
test shortest-path calculations throughout the grid.
|
||||
|
||||
"""
|
||||
directions, _ = self.grid.get('map12a').get_shortest_path(startcoord, endcoord)
|
||||
directions, _ = self.grid.get_map('map12a').get_shortest_path(startcoord, endcoord)
|
||||
self.assertEqual(expected_directions, tuple(directions))
|
||||
|
||||
def test_spawn(self):
|
||||
|
|
@ -1236,3 +1236,41 @@ class TestXYZGridTransition(TestCase):
|
|||
# make sure exits traverse the maps
|
||||
self.assertEqual(east_exit.db_destination, room2)
|
||||
self.assertEqual(west_exit.db_destination, room1)
|
||||
|
||||
class TestBuildExampleGrid(TestCase):
|
||||
"""
|
||||
Test building the map_example
|
||||
|
||||
"""
|
||||
def setUp(self):
|
||||
# build and populate grid
|
||||
self.grid, err = xyzgrid.XYZGrid.create("testgrid")
|
||||
|
||||
def tearDown(self):
|
||||
self.grid.delete()
|
||||
|
||||
def test_build(self):
|
||||
"""
|
||||
Build the map example.
|
||||
|
||||
"""
|
||||
mapdatas = self.grid.maps_from_module("evennia.contrib.xyzgrid.map_example")
|
||||
self.assertEqual(len(mapdatas), 2)
|
||||
|
||||
self.grid.add_maps(*mapdatas)
|
||||
self.grid.spawn()
|
||||
|
||||
# testing
|
||||
room1a = xyzroom.XYZRoom.objects.get_xyz(xyz=(3, 0, 'the large tree'))
|
||||
room1b = xyzroom.XYZRoom.objects.get_xyz(xyz=(10, 9, 'the large tree'))
|
||||
room2a = xyzroom.XYZRoom.objects.get_xyz(xyz=(1, 0, 'small cave'))
|
||||
room2b = xyzroom.XYZRoom.objects.get_xyz(xyz=(1, 3, 'small cave'))
|
||||
|
||||
self.assertEqual(room1a.key, "Dungeon Entrance")
|
||||
self.assertTrue(room1a.desc.startswith("To the west"))
|
||||
self.assertEqual(room1b.key, "A gorgeous view")
|
||||
self.assertTrue(room1b.desc.startswith("The view from here is breathtaking."))
|
||||
self.assertEqual(room2a.key, "The entrance")
|
||||
self.assertTrue(room2a.desc.startswith("This is the entrance to"))
|
||||
self.assertEqual(room2b.key, "North-west corner of the atrium")
|
||||
self.assertTrue(room2b.desc.startswith("Sunlight sifts down"))
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ class MapError(RuntimeError):
|
|||
prefix = ""
|
||||
if node_or_link:
|
||||
prefix = (f"{node_or_link.__class__.__name__} '{node_or_link.symbol}' "
|
||||
f"at XY=({node_or_link.X:g},{node_or_link.Y:g}) ")
|
||||
f"at XYZ=({node_or_link.X:g},{node_or_link.Y:g},{node_or_link.Z}) ")
|
||||
self.node_or_link = node_or_link
|
||||
self.message = f"{prefix}{error}"
|
||||
super().__init__(self.message)
|
||||
|
|
|
|||
|
|
@ -12,9 +12,7 @@ as up and down. These are indicated in code as 'n', 'ne', 'e', 'se', 's', 'sw',
|
|||
|
||||
|
||||
```python
|
||||
# in module passed to 'Map' class. It will either a dict
|
||||
# MAP_DATA with keys 'map' and (optionally) 'legend', or
|
||||
# the MAP/LEGEND variables directly.
|
||||
# in module passed to 'Map' class
|
||||
|
||||
MAP = r'''
|
||||
1
|
||||
|
|
@ -47,10 +45,11 @@ as up and down. These are indicated in code as 'n', 'ne', 'e', 'se', 's', 'sw',
|
|||
|
||||
'''
|
||||
|
||||
|
||||
LEGEND = {'#': xyzgrid.MapNode, '|': xyzgrid.NSMapLink,...}
|
||||
|
||||
# optional, for more control
|
||||
MAP_DATA = {
|
||||
# read by parser if XYMAP_DATA_LIST doesn't exist
|
||||
XYMAP_DATA = {
|
||||
"map": MAP,
|
||||
"legend": LEGEND,
|
||||
"zcoord": "City of Foo",
|
||||
|
|
@ -62,6 +61,11 @@ as up and down. These are indicated in code as 'n', 'ne', 'e', 'se', 's', 'sw',
|
|||
|
||||
}
|
||||
|
||||
# will be parsed first, allows for multiple map-data dicts from one module
|
||||
XYMAP_DATA_LIST = [
|
||||
XYMAP_DATA
|
||||
]
|
||||
|
||||
```
|
||||
|
||||
The two `+` signs in the upper/lower left corners are required and marks the edge of the map area.
|
||||
|
|
@ -102,7 +106,7 @@ except ImportError as err:
|
|||
f"{err}\nThe XYZgrid contrib requires "
|
||||
"the SciPy package. Install with `pip install scipy'.")
|
||||
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, is_iter
|
||||
from evennia.utils import logger
|
||||
from evennia.prototypes import prototypes as protlib
|
||||
|
||||
|
|
@ -140,7 +144,6 @@ DEFAULT_LEGEND = {
|
|||
}
|
||||
|
||||
|
||||
|
||||
# --------------------------------------------
|
||||
# Map parser implementation
|
||||
|
||||
|
|
@ -309,13 +312,18 @@ class XYMap:
|
|||
else:
|
||||
# read from contents of module
|
||||
mod = mod_import(map_module_or_dict)
|
||||
mapdata = variable_from_module(mod, "MAP_DATA")
|
||||
mapdata_list = variable_from_module(mod, "XYMAP_DATA_LIST")
|
||||
if mapdata_list and self.Z:
|
||||
# use the stored Z value to figure out which map data we want
|
||||
mapping = {mapdata.get("zcoord") for mapdata in mapdata_list}
|
||||
mapdata = mapping.get(self.Z, {})
|
||||
|
||||
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={})
|
||||
mapdata = variable_from_module(mod, "XYMAP_DATA")
|
||||
|
||||
if not mapdata:
|
||||
raise MapError("No valid XYMAP_DATA or XYMAP_DATA_LIST could be found from "
|
||||
f"{map_module_or_dict}.")
|
||||
|
||||
# validate
|
||||
if any(key for key in mapdata if key not in MAP_DATA_KEYS):
|
||||
|
|
@ -330,10 +338,9 @@ class XYMap:
|
|||
"`.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.")
|
||||
raise MapError("No map found. Add 'map' key to map-data dict.")
|
||||
for key, prototype in mapdata.get('prototypes', {}).items():
|
||||
if not is_iter(key) and (2 <= len(key) <= 3):
|
||||
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).")
|
||||
|
|
@ -491,10 +498,13 @@ class XYMap:
|
|||
for node in node_index_map.values():
|
||||
node_coord = (node.X, node.Y)
|
||||
# load prototype from override, or use default
|
||||
node.prototype = self.prototypes.get(node_coord, node.prototype)
|
||||
node.prototype = self.prototypes.get(
|
||||
node_coord, self.prototypes.get(('*', '*'), node.prototype))
|
||||
# do the same for links (x, y, direction) coords
|
||||
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,),
|
||||
self.prototypes.get(('*', '*', '*'), maplink.prototype))
|
||||
|
||||
# store
|
||||
self.display_map = display_map
|
||||
|
|
@ -547,13 +557,16 @@ class XYMap:
|
|||
points, xmin, xmax, ymin, ymax = _scan_neighbors(center_node, [], dist=dist)
|
||||
return list(set(points)), xmin, xmax, ymin, ymax
|
||||
|
||||
def calculate_path_matrix(self):
|
||||
def calculate_path_matrix(self, force=False):
|
||||
"""
|
||||
Solve the pathfinding problem using Dijkstra's algorithm. This will try to
|
||||
load the solution from disk if possible.
|
||||
|
||||
Args:
|
||||
force (bool, optional): If the cache should always be rebuilt.
|
||||
|
||||
"""
|
||||
if self.pathfinder_baked_filename and isfile(self.pathfinder_baked_filename):
|
||||
if not force and self.pathfinder_baked_filename and isfile(self.pathfinder_baked_filename):
|
||||
# check if the solution for this grid was already solved previously.
|
||||
|
||||
mapstr, dist_matrix, pathfinding_routes = "", None, None
|
||||
|
|
@ -569,7 +582,6 @@ class XYMap:
|
|||
# we can re-use the stored data!
|
||||
self.dist_matrix = dist_matrix
|
||||
self.pathfinding_routes = pathfinding_routes
|
||||
return
|
||||
|
||||
# build a matrix representing the map graph, with 0s as impassable areas
|
||||
|
||||
|
|
@ -615,7 +627,7 @@ class XYMap:
|
|||
wildcard = '*'
|
||||
spawned = []
|
||||
|
||||
for node in self.node_index_map.values():
|
||||
for node in sorted(self.node_index_map.values(), key=lambda n: (n.Y, n.X)):
|
||||
if (x in (wildcard, node.X)) and (y in (wildcard, node.Y)):
|
||||
node.spawn()
|
||||
spawned.append(node)
|
||||
|
|
@ -643,7 +655,7 @@ class XYMap:
|
|||
wildcard = '*'
|
||||
|
||||
if not nodes:
|
||||
nodes = self.node_index_map.values()
|
||||
nodes = sorted(self.node_index_map.values(), key=lambda n: (n.Z, n.Y, n.X))
|
||||
|
||||
for node in nodes:
|
||||
if (x in (wildcard, node.X)) and (y in (wildcard, node.Y)):
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@ The grid has three main functions:
|
|||
"""
|
||||
from evennia.scripts.scripts import DefaultScript
|
||||
from evennia.utils import logger
|
||||
from evennia.utils.utils import variable_from_module, make_iter
|
||||
from .xymap import XYMap
|
||||
from .xyzroom import XYZRoom
|
||||
from .xyzroom import XYZRoom, XYZExit
|
||||
|
||||
|
||||
class XYZGrid(DefaultScript):
|
||||
|
|
@ -40,7 +41,7 @@ class XYZGrid(DefaultScript):
|
|||
self.reload()
|
||||
return self.ndb.grid
|
||||
|
||||
def get(self, zcoord):
|
||||
def get_map(self, zcoord):
|
||||
"""
|
||||
Get a specific xymap.
|
||||
|
||||
|
|
@ -53,7 +54,7 @@ class XYZGrid(DefaultScript):
|
|||
"""
|
||||
return self.grid.get(zcoord)
|
||||
|
||||
def all(self):
|
||||
def all_maps(self):
|
||||
"""
|
||||
Get all xymaps stored in the grid.
|
||||
|
||||
|
|
@ -63,20 +64,55 @@ class XYZGrid(DefaultScript):
|
|||
"""
|
||||
return self.grid
|
||||
|
||||
def log(self, msg):
|
||||
logger.log_info(f"|grid| {msg}")
|
||||
|
||||
def get_room(xyz, **kwargs):
|
||||
"""
|
||||
Get room object from XYZ coordinate.
|
||||
|
||||
Args:
|
||||
xyz (tuple): X,Y,Z coordinate of room to fetch.
|
||||
|
||||
Returns:
|
||||
XYZRoom: The found room.
|
||||
|
||||
Raises:
|
||||
XYZRoom.DoesNotExist: If room is not found.
|
||||
|
||||
Notes:
|
||||
This assumes the room was previously built.
|
||||
|
||||
"""
|
||||
return XYZRoom.objects.get_xyz(xyz=xyz, **kwargs)
|
||||
|
||||
def get_exit(xyz, name='north', **kwargs):
|
||||
"""
|
||||
Get exit object at coordinate.
|
||||
|
||||
Args:
|
||||
xyz (tuple): X,Y,Z coordinate of the room the
|
||||
exit leads out of.
|
||||
name (str): The full name of the exit, e.g. 'north' or 'northwest'.
|
||||
|
||||
"""
|
||||
kwargs['db_key'] = name
|
||||
return XYZExit.objects.get_xyz_exit(xyz=xyz, **kwargs)
|
||||
|
||||
def reload(self):
|
||||
"""
|
||||
Reload and rebuild the grid. This is done on a server reload and is also necessary if adding
|
||||
a new map since this may introduce new between-map traversals.
|
||||
|
||||
"""
|
||||
logger.log_info("[grid] (Re)loading grid ...")
|
||||
self.log("(Re)loading grid ...")
|
||||
self.ndb.grid = {}
|
||||
nmaps = 0
|
||||
# generate all Maps - this will also initialize their components
|
||||
# and bake any pathfinding paths (or load from disk-cache)
|
||||
for zcoord, mapdata in self.db.map_data.items():
|
||||
|
||||
logger.log_info(f"[grid] Loading map '{zcoord}'...")
|
||||
self.log(f"Loading map '{zcoord}'...")
|
||||
xymap = XYMap(dict(mapdata), Z=zcoord, xyzgrid=self)
|
||||
xymap.parse()
|
||||
xymap.calculate_path_matrix()
|
||||
|
|
@ -84,7 +120,7 @@ class XYZGrid(DefaultScript):
|
|||
nmaps += 1
|
||||
|
||||
# store
|
||||
logger.log_info(f"[grid] Loaded and linked {nmaps} map(s).")
|
||||
self.log(f"Loaded and linked {nmaps} map(s).")
|
||||
|
||||
def at_init(self):
|
||||
"""
|
||||
|
|
@ -94,6 +130,27 @@ class XYZGrid(DefaultScript):
|
|||
"""
|
||||
self.reload()
|
||||
|
||||
def maps_from_module(self, module):
|
||||
"""
|
||||
Load map data from module. The loader will look for a dict XYMAP_DATA or a list of
|
||||
XYMAP_DATA_LIST (a list of XYMAP_DATA dicts). Each XYMAP_DATA dict should contain
|
||||
`{"xymap": mapstring, "zcoord": mapname/zcoord, "legend": dict, "prototypes": dict}`.
|
||||
|
||||
Args:
|
||||
module (module or str): A module or python-path to a module containing
|
||||
map data as either `XYMAP_DATA` or `XYMAP_DATA_LIST` variables.
|
||||
|
||||
Returns:
|
||||
list: List of zero, one or more xy-map data dicts loaded from the module.
|
||||
|
||||
"""
|
||||
map_data_list = variable_from_module(module, "XYMAP_DATA_LIST")
|
||||
if not map_data_list:
|
||||
map_data_list = variable_from_module(module, "XYMAP_DATA")
|
||||
if map_data_list:
|
||||
map_data_list = make_iter(map_data_list)
|
||||
return map_data_list
|
||||
|
||||
def add_maps(self, *mapdatas):
|
||||
"""
|
||||
Add map or maps to the grid.
|
||||
|
|
@ -176,12 +233,12 @@ class XYZGrid(DefaultScript):
|
|||
|
||||
# first build all nodes/rooms
|
||||
for zcoord, xymap in xymaps.items():
|
||||
logger.log_info(f"[grid] spawning/updating nodes for {zcoord} ...")
|
||||
self.log(f"spawning/updating nodes for {zcoord} ...")
|
||||
xymap.spawn_nodes(xy=(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} ...")
|
||||
self.log(f"spawning/updating links for {zcoord} ...")
|
||||
xymap.spawn_links(xy=(x, y), directions=directions)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ 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
|
||||
|
|
@ -50,8 +49,6 @@ class XYZManager(ObjectManager):
|
|||
x, y, z = xyz
|
||||
wildcard = '*'
|
||||
|
||||
|
||||
|
||||
return (
|
||||
self
|
||||
.filter_family(**kwargs)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue