Skip to content

Commit 3b92b64

Browse files
el-uFlySniper
authored andcommitted
core: utility method for visualizing worlds as PlantUML (ArchipelagoMW#1935)
* core: typing for MultiWorld.get_regions * core: utility method for visualizing worlds as PlantUML * core: utility method for visualizing worlds as PlantUML: update docs
1 parent c7d10f5 commit 3b92b64

File tree

4 files changed

+120
-1
lines changed

4 files changed

+120
-1
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
*.archipelago
2828
*.apsave
2929
*.BIN
30+
*.puml
3031

3132
setups
3233
build

BaseClasses.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import typing # this can go away when Python 3.8 support is dropped
99
from argparse import Namespace
1010
from collections import ChainMap, Counter, deque
11+
from collections.abc import Collection
1112
from enum import IntEnum, IntFlag
1213
from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union, \
1314
Type, ClassVar
@@ -357,7 +358,7 @@ def _recache(self):
357358
for r_location in region.locations:
358359
self._location_cache[r_location.name, player] = r_location
359360

360-
def get_regions(self, player=None):
361+
def get_regions(self, player: Optional[int] = None) -> Collection[Region]:
361362
return self.regions if player is None else self._region_cache[player].values()
362363

363364
def get_region(self, regionname: str, player: int) -> Region:

Utils.py

+111
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
if typing.TYPE_CHECKING:
3030
import tkinter
3131
import pathlib
32+
from BaseClasses import Region
3233

3334

3435
def tuplize_version(version: str) -> Version:
@@ -766,3 +767,113 @@ def freeze_support() -> None:
766767
import multiprocessing
767768
_extend_freeze_support()
768769
multiprocessing.freeze_support()
770+
771+
772+
def visualize_regions(root_region: Region, file_name: str, *,
773+
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
774+
linetype_ortho: bool = True) -> None:
775+
"""Visualize the layout of a world as a PlantUML diagram.
776+
777+
:param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
778+
:param file_name: The name of the destination .puml file.
779+
:param show_entrance_names: (default False) If enabled, the name of the entrance will be shown near each connection.
780+
:param show_locations: (default True) If enabled, the locations will be listed inside each region.
781+
Priority locations will be shown in bold.
782+
Excluded locations will be stricken out.
783+
Locations without ID will be shown in italics.
784+
Locked locations will be shown with a padlock icon.
785+
For filled locations, the item name will be shown after the location name.
786+
Progression items will be shown in bold.
787+
Items without ID will be shown in italics.
788+
:param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
789+
:param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
790+
791+
Example usage in World code:
792+
from Utils import visualize_regions
793+
visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml")
794+
795+
Example usage in Main code:
796+
from Utils import visualize_regions
797+
for player in world.player_ids:
798+
visualize_regions(world.get_region("Menu", player), f"{world.get_out_file_name_base(player)}.puml")
799+
"""
800+
assert root_region.multiworld, "The multiworld attribute of root_region has to be filled"
801+
from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region
802+
from collections import deque
803+
import re
804+
805+
uml: typing.List[str] = list()
806+
seen: typing.Set[Region] = set()
807+
regions: typing.Deque[Region] = deque((root_region,))
808+
multiworld: MultiWorld = root_region.multiworld
809+
810+
def fmt(obj: Union[Entrance, Item, Location, Region]) -> str:
811+
name = obj.name
812+
if isinstance(obj, Item):
813+
name = multiworld.get_name_string_for_object(obj)
814+
if obj.advancement:
815+
name = f"**{name}**"
816+
if obj.code is None:
817+
name = f"//{name}//"
818+
if isinstance(obj, Location):
819+
if obj.progress_type == LocationProgressType.PRIORITY:
820+
name = f"**{name}**"
821+
elif obj.progress_type == LocationProgressType.EXCLUDED:
822+
name = f"--{name}--"
823+
if obj.address is None:
824+
name = f"//{name}//"
825+
return re.sub("[\".:]", "", name)
826+
827+
def visualize_exits(region: Region) -> None:
828+
for exit_ in region.exits:
829+
if exit_.connected_region:
830+
if show_entrance_names:
831+
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"")
832+
else:
833+
try:
834+
uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"")
835+
uml.append(f"\"{fmt(exit_.connected_region)}\" <--> \"{fmt(region)}\"")
836+
except ValueError:
837+
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"")
838+
else:
839+
uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\"")
840+
uml.append(f"\"{fmt(region)}\" --> \"unconnected exit:\\n{fmt(exit_)}\"")
841+
842+
def visualize_locations(region: Region) -> None:
843+
any_lock = any(location.locked for location in region.locations)
844+
for location in region.locations:
845+
lock = "<&lock-locked> " if location.locked else "<&lock-unlocked,color=transparent> " if any_lock else ""
846+
if location.item:
847+
uml.append(f"\"{fmt(region)}\" : {{method}} {lock}{fmt(location)}: {fmt(location.item)}")
848+
else:
849+
uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}")
850+
851+
def visualize_region(region: Region) -> None:
852+
uml.append(f"class \"{fmt(region)}\"")
853+
if show_locations:
854+
visualize_locations(region)
855+
visualize_exits(region)
856+
857+
def visualize_other_regions() -> None:
858+
if other_regions := [region for region in multiworld.get_regions(root_region.player) if region not in seen]:
859+
uml.append("package \"other regions\" <<Cloud>> {")
860+
for region in other_regions:
861+
uml.append(f"class \"{fmt(region)}\"")
862+
uml.append("}")
863+
864+
uml.append("@startuml")
865+
uml.append("hide circle")
866+
uml.append("hide empty members")
867+
if linetype_ortho:
868+
uml.append("skinparam linetype ortho")
869+
while regions:
870+
if (current_region := regions.popleft()) not in seen:
871+
seen.add(current_region)
872+
visualize_region(current_region)
873+
regions.extend(exit_.connected_region for exit_ in current_region.exits if exit_.connected_region)
874+
if show_other_regions:
875+
visualize_other_regions()
876+
uml.append("@enduml")
877+
878+
with open(file_name, "wt", encoding="utf-8") as f:
879+
f.write("\n".join(uml))

docs/world api.md

+6
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,12 @@ def generate_basic(self) -> None:
559559
# in most cases it's better to do this at the same time the itempool is
560560
# filled to avoid accidental duplicates:
561561
# manually placed and still in the itempool
562+
563+
# for debugging purposes, you may want to visualize the layout of your world. Uncomment the following code to
564+
# write a PlantUML diagram to the file "my_world.puml" that can help you see whether your regions and locations
565+
# are connected and placed as desired
566+
# from Utils import visualize_regions
567+
# visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml")
562568
```
563569

564570
### Setting Rules

0 commit comments

Comments
 (0)