From 485aa23afd45fe50f4de62309c961ec465d3e2ce Mon Sep 17 00:00:00 2001 From: el-u <109771707+el-u@users.noreply.github.com> Date: Mon, 2 Oct 2023 01:56:55 +0200 Subject: [PATCH] core: utility method for visualizing worlds as PlantUML (#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 --- .gitignore | 1 + BaseClasses.py | 3 +- Utils.py | 111 ++++++++++++++++++++++++++++++++++++++++++++++ docs/world api.md | 6 +++ 4 files changed, 120 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8e4cc86657a5..e374a12954aa 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ *.archipelago *.apsave *.BIN +*.puml setups build diff --git a/BaseClasses.py b/BaseClasses.py index 535338b4ec75..45190ac7b983 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -8,6 +8,7 @@ import typing # this can go away when Python 3.8 support is dropped from argparse import Namespace from collections import ChainMap, Counter, deque +from collections.abc import Collection from enum import IntEnum, IntFlag from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union, \ Type, ClassVar @@ -357,7 +358,7 @@ def _recache(self): for r_location in region.locations: self._location_cache[r_location.name, player] = r_location - def get_regions(self, player=None): + def get_regions(self, player: Optional[int] = None) -> Collection[Region]: return self.regions if player is None else self._region_cache[player].values() def get_region(self, regionname: str, player: int) -> Region: diff --git a/Utils.py b/Utils.py index 9ceba48299ca..60b1cdadb7fe 100644 --- a/Utils.py +++ b/Utils.py @@ -29,6 +29,7 @@ if typing.TYPE_CHECKING: import tkinter import pathlib + from BaseClasses import Region def tuplize_version(version: str) -> Version: @@ -766,3 +767,113 @@ def freeze_support() -> None: import multiprocessing _extend_freeze_support() multiprocessing.freeze_support() + + +def visualize_regions(root_region: Region, file_name: str, *, + show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True, + linetype_ortho: bool = True) -> None: + """Visualize the layout of a world as a PlantUML diagram. + + :param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.) + :param file_name: The name of the destination .puml file. + :param show_entrance_names: (default False) If enabled, the name of the entrance will be shown near each connection. + :param show_locations: (default True) If enabled, the locations will be listed inside each region. + Priority locations will be shown in bold. + Excluded locations will be stricken out. + Locations without ID will be shown in italics. + Locked locations will be shown with a padlock icon. + For filled locations, the item name will be shown after the location name. + Progression items will be shown in bold. + Items without ID will be shown in italics. + :param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown. + :param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines. + + Example usage in World code: + from Utils import visualize_regions + visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml") + + Example usage in Main code: + from Utils import visualize_regions + for player in world.player_ids: + visualize_regions(world.get_region("Menu", player), f"{world.get_out_file_name_base(player)}.puml") + """ + assert root_region.multiworld, "The multiworld attribute of root_region has to be filled" + from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region + from collections import deque + import re + + uml: typing.List[str] = list() + seen: typing.Set[Region] = set() + regions: typing.Deque[Region] = deque((root_region,)) + multiworld: MultiWorld = root_region.multiworld + + def fmt(obj: Union[Entrance, Item, Location, Region]) -> str: + name = obj.name + if isinstance(obj, Item): + name = multiworld.get_name_string_for_object(obj) + if obj.advancement: + name = f"**{name}**" + if obj.code is None: + name = f"//{name}//" + if isinstance(obj, Location): + if obj.progress_type == LocationProgressType.PRIORITY: + name = f"**{name}**" + elif obj.progress_type == LocationProgressType.EXCLUDED: + name = f"--{name}--" + if obj.address is None: + name = f"//{name}//" + return re.sub("[\".:]", "", name) + + def visualize_exits(region: Region) -> None: + for exit_ in region.exits: + if exit_.connected_region: + if show_entrance_names: + uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"") + else: + try: + uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"") + uml.append(f"\"{fmt(exit_.connected_region)}\" <--> \"{fmt(region)}\"") + except ValueError: + uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"") + else: + uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\"") + uml.append(f"\"{fmt(region)}\" --> \"unconnected exit:\\n{fmt(exit_)}\"") + + def visualize_locations(region: Region) -> None: + any_lock = any(location.locked for location in region.locations) + for location in region.locations: + lock = "<&lock-locked> " if location.locked else "<&lock-unlocked,color=transparent> " if any_lock else "" + if location.item: + uml.append(f"\"{fmt(region)}\" : {{method}} {lock}{fmt(location)}: {fmt(location.item)}") + else: + uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}") + + def visualize_region(region: Region) -> None: + uml.append(f"class \"{fmt(region)}\"") + if show_locations: + visualize_locations(region) + visualize_exits(region) + + def visualize_other_regions() -> None: + if other_regions := [region for region in multiworld.get_regions(root_region.player) if region not in seen]: + uml.append("package \"other regions\" <> {") + for region in other_regions: + uml.append(f"class \"{fmt(region)}\"") + uml.append("}") + + uml.append("@startuml") + uml.append("hide circle") + uml.append("hide empty members") + if linetype_ortho: + uml.append("skinparam linetype ortho") + while regions: + if (current_region := regions.popleft()) not in seen: + seen.add(current_region) + visualize_region(current_region) + regions.extend(exit_.connected_region for exit_ in current_region.exits if exit_.connected_region) + if show_other_regions: + visualize_other_regions() + uml.append("@enduml") + + with open(file_name, "wt", encoding="utf-8") as f: + f.write("\n".join(uml)) diff --git a/docs/world api.md b/docs/world api.md index 7a7f37b17ce4..05b9e0939941 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -559,6 +559,12 @@ def generate_basic(self) -> None: # in most cases it's better to do this at the same time the itempool is # filled to avoid accidental duplicates: # manually placed and still in the itempool + + # for debugging purposes, you may want to visualize the layout of your world. Uncomment the following code to + # write a PlantUML diagram to the file "my_world.puml" that can help you see whether your regions and locations + # are connected and placed as desired + # from Utils import visualize_regions + # visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml") ``` ### Setting Rules