From a3aac3d7370383d0044fa4e21897a585a9f106a2 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Tue, 6 May 2025 12:33:21 -0400 Subject: [PATCH] TUNIC: Entrance rando Direction Pairs + Decoupled (#3761) * Fix merge conflict * Fix formatting, fix rule for heir access after merge * Writing combat logic helpers * More helpers! * More logic! * Rename has_stick to has_melee, some fixes per Medic's review * Clamp max power from sword upgrades * Wrote the rest of the helpers * Remove unused import * Apply item classifications * Create the combat logic option * Item classification varies based on option * Add the shop sword logic stuff in * Add the rules for the boss-only option * Fix tiny issues * Some early Overworld combat logic * Fill out swamp combat logic * Add note * Bump up Boss Scav and Heir * More revisions to combat logic * Some changes, currently broken * New system for power, kinda jank probably * Revisions to new system, needs more balancing * Cap attack upgrades * Uncap mp power since it's directly related to damage output * Voidlings * Put together a table showing the vanilla-expected stats for each area * Added some info on potion counts * Made new helper functions * Make has_required_stats * Make has_combat_reqs * Update er_rules for new combat reqs * Fix all the broken things ever * Remove outdated todo * Make temp option for testing logic * More flexible choices for combat items * Hard require sword for bosses * Temporarily default combat logic to on * Finish writing overworld combat logic * East Forest combat logic done * Remove a few easy ones * Finish beneath the well * Dark Tomb combat logic * West Garden combat logic * make unit tests checkmark again * Weird west garden dagger house edge case * Try block for that weird west garden edge case * Add quarry combat logic * Update to filter out unreachable regions outside of ER * Fortress Grave Path logic, and a couple fixes to the west garden logic * Fortress east shortcut logic, and rewriting the try except blocks to use finally * Refactor to use a new function cause wow there was a lot of repeated code * Add combat logic to the other two sets of fortress fuses * Add combat rules to beneath the vault * Fix missing cathedral -> elevator connection * Combat logic for cathedral to elevator * Add cathedral main region, rename cathedral -> cathedral entry * Setup cathedral combat logic * Adjust locations' regions for ER * Add laurels zip logic to the chest in the spike room in cathedral * Add combat logic to frog's domain * Move frog's domain locations to regions for combat logic * Add new frog's domain regions for combat logic * Update region name for frog's domain * Fix typo * Add more regions for lower zig * Move around lower zig regions for combat logic * Lower Zig combat logic * Upper zig combat logic * Fix typo * Fix typos * Fix missing world. * Update combat logic description * Add todo * Add todo * Don't make zig skip if er or fixed shop is off * Make it so zig skip is only made with fewer shops and er * Temporarily default combat logic on * Update test to explicitly disable combat logic * Update test_access.py * Slight wording changes * Fix bugs, refactor quarry regions so you can access chests in lower quarry with ice grapples * Run through checks you can do with magic dagger * Run through checks you can do with magic dagger * Add rule for entering town portal of having equipment to deal with enemies * Add rule for atoll near the 6 crabs surrounding a poor defenseless baby slorm * Update the rule for the chest near the 6 crabs surrounding a slorm to also possibly require laurels * Revamp combat logic function to work properly without melee * Add laurels rules to combat logic chests * Modify beneath the vault bridge rule to need a lantern if combat logic is on * Put in money logic * Dagger or combat for swamp big skeleton chest * Remove the 100 moneys from logic * Modify lower zig ls drop region destinations * Remove completed todo * Reword combat logic option description, remove test option * Add combat logic to slot data * Merge Silent's missing slot data bugfix PR #3628 * Remove test combat option * Update combat logic description * Fix secret gathering place issue * Fix secret gathering place issue * Fix lower zig ls rule * Fix accidentally removed librarian rule * Remove redundant rule * Update gauntlet rule to hard-require a sword * Add test for a problematic connection * Adjust combat logic to deal with weird edge cases so it doesn't take stuff out of logic that was previously in logic * Fix create_item classification * Update some comments * Update per exempt's suggestion * Add combat logic to the well boss fight, reorder the combat logic stuff a little to better section them off * Add EntranceLayout option * Add back LogicRules as an invisible option, to not break old yamls * Fix a bug with seed group, continue changing fixed shop to entrance layout * Fix missed fixed shop -> entrance layout spot * Fix bug in seed groups with fixed shop on and off * Add entrance layout to the UT regen stuff * Put direction. in, will add them later * Remove unused elevation from portal class * Got like half of them in * Finish adding all of the directions * Add combat rule for zig front to back * Update per Medic's suggestion * Update ladder storage without items option description * Mess with state with collect and remove to save like 2 seconds (never again) * Save even more time, still never going to do this again on anything else * Add option check for collect and remove * Add directions to shop portals * Update direction in Portal with default * Move Direction above Portal * Add decoupled option, mess with plando connection stuff * Merge, implement verify plando directions * Condense the stuff in change and remove to less lines (thanks medic) * Remove unused thing * Swap to using logicmixin instead of prog_items (thanks Vi) * Fix consistency in stat counters * Add back something that was needed * Fix mistake when adding back * Making the fix better (thanks medic) * Make it actually return false if it gets to the backup lists and fails them * Fix stuff after merge * Add outlet regions, create new regions as needed for them * Put together part of decoupled and direction pairs * make direction pairs work * Make decoupled work * Make fixed shop work again * Fix a few minor bugs * Fix a few minor bugs * Fix plando * god i love programming * Reorder portal list * Update portal sorter for variable shops * Add missing parameter * Some cleanup of prints and functions * Fix typo * it's aliiiiiive * Make seed groups not sync decoupled * Add test with full-shop plando * Fix bug with vanilla portals * Handle plando connections and direction pair errors * Update plando checking for decoupled * Fix typo * Fix exception text to be shorter * Add some more comments * Add todo note * Remove unused safety thing * Remove extra plando connections definition in options * Make seed groups in decoupled with overlapping but not fully overlapped plando connections interact nicely without messing with what the entrances look like in the spoiler log * Fix weird edge case that is technically user error * Add note to fixed shop * Fix parsing shop names in UT * Remove debug print * Actually make UT work * multiworld. to world. * Fix typo from merge * Make it so the shops show up in the entrance hints * Fix bug in ladder storage rules * Remove blank line * # Conflicts: # worlds/tunic/__init__.py # worlds/tunic/er_data.py # worlds/tunic/er_rules.py # worlds/tunic/er_scripts.py # worlds/tunic/rules.py # worlds/tunic/test/test_access.py * Fix issues after merge * Update plando connections stuff in docs * Fix library mistake * has_stick -> has_melee * has_stick -> has_melee * Add a failsafe for direction pairing * Fix playthrough crash bug * Remove init from logicmixin * Updates per code review (thanks hesto) * has_stick to has_melee in newer update * has_stick to has_melee in newer update * # Conflicts: # worlds/tunic/__init__.py # worlds/tunic/combat_logic.py # worlds/tunic/er_data.py # worlds/tunic/er_rules.py # worlds/tunic/er_scripts.py * Cleanup more stuff after merge * Revert "Cleanup more stuff after merge" This reverts commit a6ee9a93da8f2fcc4413de6df6927b246017889d. * Revert "# Conflicts:" This reverts commit c74ccd74a45b6ad6b9abe6e339d115a0c98baf30. * Cleanup more stuff after merge * Swap to .get for decoupled so it works with older games probably maybe * Fix after merge * Fix typo * Fix UT support with fixed shop option * Backport plando connections fix * Fix issue with fixed shop + decoupled * Make the error not duplicate the while loop condition * Fix rule for quarry back to monastery * Fix more stuff after merge * Make it not output anything if you set plando connections but not ER * Add obvious note to plando connections description * Fix after merge * add comment to commented out connection --------- Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --- worlds/tunic/__init__.py | 135 ++++--- worlds/tunic/combat_logic.py | 1 + worlds/tunic/docs/en_TUNIC.md | 2 +- worlds/tunic/er_data.py | 533 +++++++++++++------------- worlds/tunic/er_rules.py | 21 +- worlds/tunic/er_scripts.py | 634 +++++++++++++++++++++++-------- worlds/tunic/options.py | 73 +++- worlds/tunic/rules.py | 5 +- worlds/tunic/test/test_access.py | 262 ++++++++++++- 9 files changed, 1150 insertions(+), 516 deletions(-) diff --git a/worlds/tunic/__init__.py b/worlds/tunic/__init__.py index aa6c8cdf..7027ab1a 100644 --- a/worlds/tunic/__init__.py +++ b/worlds/tunic/__init__.py @@ -7,12 +7,12 @@ from .locations import location_table, location_name_groups, standard_location_n from .rules import set_location_rules, set_region_rules, randomize_ability_unlocks, gold_hexagon from .er_rules import set_er_location_rules from .regions import tunic_regions -from .er_scripts import create_er_regions +from .er_scripts import create_er_regions, verify_plando_directions from .grass import grass_location_table, grass_location_name_to_id, grass_location_name_groups, excluded_grass_locations from .er_data import portal_mapping, RegionInfo, tunic_er_regions from .options import (TunicOptions, EntranceRando, tunic_option_groups, tunic_option_presets, TunicPlandoConnections, LaurelsLocation, LogicRules, LaurelsZips, IceGrappling, LadderStorage, check_options, - get_hexagons_in_pool, HexagonQuestAbilityUnlockType) + get_hexagons_in_pool, HexagonQuestAbilityUnlockType, EntranceLayout) from .breakables import breakable_location_name_to_id, breakable_location_groups, breakable_location_table from .combat_logic import area_data, CombatState from worlds.AutoWorld import WebWorld, World @@ -61,8 +61,9 @@ class SeedGroup(TypedDict): ice_grappling: int # ice_grappling value ladder_storage: int # ls value laurels_at_10_fairies: bool # laurels location value - fixed_shop: bool # fixed shop value - plando: TunicPlandoConnections # consolidated plando connections for the seed group + entrance_layout: int # entrance layout value + has_decoupled_enabled: bool # for checking that players don't have conflicting options + plando: List[PlandoConnection] # consolidated plando connections for the seed group class TunicWorld(World): @@ -95,7 +96,7 @@ class TunicWorld(World): tunic_portal_pairs: Dict[str, str] er_portal_hints: Dict[int, str] seed_groups: Dict[str, SeedGroup] = {} - shop_num: int = 1 # need to make it so that you can walk out of shops, but also that they aren't all connected + used_shop_numbers: Set[int] er_regions: Dict[str, RegionInfo] # absolutely needed so outlet regions work # for the local_fill option @@ -122,24 +123,35 @@ class TunicWorld(World): check_options(self) - if self.options.logic_rules >= LogicRules.option_no_major_glitches: - self.options.laurels_zips.value = LaurelsZips.option_true - self.options.ice_grappling.value = IceGrappling.option_medium - if self.options.logic_rules.value == LogicRules.option_unrestricted: - self.options.ladder_storage.value = LadderStorage.option_medium - self.er_regions = tunic_er_regions.copy() + if self.options.plando_connections and not self.options.entrance_rando: + self.options.plando_connections.value = () if self.options.plando_connections: + def replace_connection(old_cxn: PlandoConnection, new_cxn: PlandoConnection, index: int) -> None: + self.options.plando_connections.value.remove(old_cxn) + self.options.plando_connections.value.insert(index, new_cxn) + for index, cxn in enumerate(self.options.plando_connections): - # making shops second to simplify other things later - if cxn.entrance.startswith("Shop"): - replacement = PlandoConnection(cxn.exit, "Shop Portal", "both") - self.options.plando_connections.value.remove(cxn) - self.options.plando_connections.value.insert(index, replacement) - elif cxn.exit.startswith("Shop"): - replacement = PlandoConnection(cxn.entrance, "Shop Portal", "both") - self.options.plando_connections.value.remove(cxn) - self.options.plando_connections.value.insert(index, replacement) + replacement = None + if self.options.decoupled: + # flip any that are pointing to exit to point to entrance so that I don't have to deal with it + if cxn.direction == "exit": + replacement = PlandoConnection(cxn.exit, cxn.entrance, "entrance", cxn.percentage) + # if decoupled is on and you plando'd an entrance to itself but left the direction as both + if cxn.direction == "both" and cxn.entrance == cxn.exit: + replacement = PlandoConnection(cxn.entrance, cxn.exit, "entrance") + # if decoupled is off, just convert these to both + elif cxn.direction != "both": + replacement = PlandoConnection(cxn.entrance, cxn.exit, "both", cxn.percentage) + + if replacement: + replace_connection(cxn, replacement, index) + + if (self.options.entrance_layout == EntranceLayout.option_direction_pairs + and not verify_plando_directions(cxn)): + raise OptionError(f"TUNIC: Player {self.player_name} has invalid plando connections. " + f"They have Direction Pairs enabled and the connection " + f"{cxn.entrance} --> {cxn.exit} does not abide by this option.") # Universal tracker stuff, shouldn't do anything in standard gen if hasattr(self.multiworld, "re_gen_passthrough"): @@ -160,16 +172,16 @@ class TunicWorld(World): self.options.hexagon_quest_ability_type.value = self.passthrough.get("hexagon_quest_ability_type", 0) self.options.entrance_rando.value = self.passthrough["entrance_rando"] self.options.shuffle_ladders.value = self.passthrough["shuffle_ladders"] + self.options.entrance_layout.value = EntranceLayout.option_standard + if ("ziggurat2020_3, ziggurat2020_1_zig2_skip" in self.passthrough["Entrance Rando"].keys() + or "ziggurat2020_3, ziggurat2020_1_zig2_skip" in self.passthrough["Entrance Rando"].values()): + self.options.entrance_layout.value = EntranceLayout.option_fixed_shop + self.options.decoupled = self.passthrough.get("decoupled", 0) + self.options.laurels_location.value = LaurelsLocation.option_anywhere self.options.grass_randomizer.value = self.passthrough.get("grass_randomizer", 0) self.options.breakable_shuffle.value = self.passthrough.get("breakable_shuffle", 0) self.options.laurels_location.value = self.options.laurels_location.option_anywhere - self.options.combat_logic.value = self.passthrough["combat_logic"] - - self.options.fixed_shop.value = self.options.fixed_shop.option_false - if ("ziggurat2020_3, ziggurat2020_1_zig2_skip" in self.passthrough["Entrance Rando"].keys() - or "ziggurat2020_3, ziggurat2020_1_zig2_skip" in self.passthrough["Entrance Rando"].values()): - self.options.fixed_shop.value = self.options.fixed_shop.option_true - + self.options.combat_logic.value = self.passthrough.get("combat_logic", 0) else: self.using_ut = False else: @@ -227,10 +239,14 @@ class TunicWorld(World): ice_grappling=tunic.options.ice_grappling.value, ladder_storage=tunic.options.ladder_storage.value, laurels_at_10_fairies=tunic.options.laurels_location == LaurelsLocation.option_10_fairies, - fixed_shop=bool(tunic.options.fixed_shop), - plando=tunic.options.plando_connections) + entrance_layout=tunic.options.entrance_layout.value, + has_decoupled_enabled=bool(tunic.options.decoupled), + plando=tunic.options.plando_connections.value.copy()) continue - + # I feel that syncing this one is worse than erroring out + if bool(tunic.options.decoupled) != cls.seed_groups[group]["has_decoupled_enabled"]: + raise OptionError(f"TUNIC: All players in the seed group {group} must " + f"have Decoupled either enabled or disabled.") # off is more restrictive if not tunic.options.laurels_zips: cls.seed_groups[group]["laurels_zips"] = False @@ -243,34 +259,52 @@ class TunicWorld(World): # laurels at 10 fairies changes logic for secret gathering place placement if tunic.options.laurels_location == 3: cls.seed_groups[group]["laurels_at_10_fairies"] = True - # more restrictive, overrides the option for others in the same group, which is better than failing imo - if tunic.options.fixed_shop: - cls.seed_groups[group]["fixed_shop"] = True - + # fixed shop and direction pairs override standard, but conflict with each other + if tunic.options.entrance_layout: + if cls.seed_groups[group]["entrance_layout"] == EntranceLayout.option_standard: + cls.seed_groups[group]["entrance_layout"] = tunic.options.entrance_layout.value + elif cls.seed_groups[group]["entrance_layout"] != tunic.options.entrance_layout.value: + raise OptionError(f"TUNIC: Conflict between seed group {group}'s Entrance Layout options. " + f"Seed group cannot have both Fixed Shop and Direction Pairs enabled.") if tunic.options.plando_connections: # loop through the connections in the player's yaml - for cxn in tunic.options.plando_connections: + for index, player_cxn in enumerate(tunic.options.plando_connections): new_cxn = True for group_cxn in cls.seed_groups[group]["plando"]: - # if neither entrance nor exit match anything in the group, add to group - if ((cxn.entrance == group_cxn.entrance and cxn.exit == group_cxn.exit) - or (cxn.exit == group_cxn.entrance and cxn.entrance == group_cxn.exit)): - new_cxn = False - break - + # verify that it abides by direction pairs if enabled + if (cls.seed_groups[group]["entrance_layout"] == EntranceLayout.option_direction_pairs + and not verify_plando_directions(player_cxn)): + player_dir = "<->" if player_cxn.direction == "both" else "-->" + raise Exception(f"TUNIC: Conflict between Entrance Layout option and Plando Connection: " + f"{player_cxn.entrance} {player_dir} {player_cxn.exit}") # check if this pair is the same as a pair in the group already + if ((player_cxn.entrance == group_cxn.entrance and player_cxn.exit == group_cxn.exit) + or (player_cxn.entrance == group_cxn.exit and player_cxn.exit == group_cxn.entrance + and "both" in [player_cxn.direction, group_cxn.direction])): + new_cxn = False + # if the group's was one-way and the player's was two-way, we replace the group's now + if player_cxn.direction == "both" and group_cxn.direction == "entrance": + cls.seed_groups[group]["plando"].remove(group_cxn) + cls.seed_groups[group]["plando"].insert(index, player_cxn) + break is_mismatched = ( - cxn.entrance == group_cxn.entrance and cxn.exit != group_cxn.exit - or cxn.entrance == group_cxn.exit and cxn.exit != group_cxn.entrance - or cxn.exit == group_cxn.entrance and cxn.entrance != group_cxn.exit - or cxn.exit == group_cxn.exit and cxn.entrance != group_cxn.entrance + player_cxn.entrance == group_cxn.entrance and player_cxn.exit != group_cxn.exit + or player_cxn.exit == group_cxn.exit and player_cxn.entrance != group_cxn.entrance ) + if not tunic.options.decoupled: + is_mismatched = is_mismatched or ( + player_cxn.entrance == group_cxn.exit and player_cxn.exit != group_cxn.entrance + or player_cxn.exit == group_cxn.entrance and player_cxn.entrance != group_cxn.exit + ) if is_mismatched: - raise Exception(f"TUNIC: Conflict between seed group {group}'s plando " - f"connection {group_cxn.entrance} <-> {group_cxn.exit} and " - f"{tunic.player_name}'s plando connection {cxn.entrance} <-> {cxn.exit}") + group_dir = "<->" if group_cxn.direction == "both" else "-->" + player_dir = "<->" if player_cxn.direction == "both" else "-->" + raise OptionError(f"TUNIC: Conflict between seed group {group}'s plando " + f"connection {group_cxn.entrance} {group_dir} {group_cxn.exit} and " + f"{tunic.player_name}'s plando connection " + f"{player_cxn.entrance} {player_dir} {player_cxn.exit}") if new_cxn: - cls.seed_groups[group]["plando"].value.append(cxn) + cls.seed_groups[group]["plando"].append(player_cxn) def create_item(self, name: str, classification: ItemClassification = None) -> TunicItem: item_data = item_table[name] @@ -571,7 +605,7 @@ class TunicWorld(World): all_state = self.multiworld.get_all_state(True) all_state.update_reachable_regions(self.player) paths = all_state.path - portal_names = [portal.name for portal in portal_mapping] + portal_names = {portal.name for portal in portal_mapping}.union({f"Shop Portal {i + 1}" for i in range(500)}) for location in self.multiworld.get_locations(self.player): # skipping event locations if not location.address: @@ -630,6 +664,7 @@ class TunicWorld(World): "lanternless": self.options.lanternless.value, "maskless": self.options.maskless.value, "entrance_rando": int(bool(self.options.entrance_rando.value)), + "decoupled": self.options.decoupled.value if self.options.entrance_rando else 0, "shuffle_ladders": self.options.shuffle_ladders.value, "grass_randomizer": self.options.grass_randomizer.value, "combat_logic": self.options.combat_logic.value, diff --git a/worlds/tunic/combat_logic.py b/worlds/tunic/combat_logic.py index 2e9f19db..dbf1e864 100644 --- a/worlds/tunic/combat_logic.py +++ b/worlds/tunic/combat_logic.py @@ -22,6 +22,7 @@ class AreaStats(NamedTuple): # the vanilla upgrades/equipment you would have area_data: Dict[str, AreaStats] = { + # The upgrade page is right by the Well entrance. Upper Overworld by the chest in the top right might need something "Overworld": AreaStats(1, 1, 1, 1, 1, 1, 0, ["Stick"]), "East Forest": AreaStats(1, 1, 1, 1, 1, 1, 0, ["Sword"]), "Before Well": AreaStats(1, 1, 1, 1, 1, 1, 3, ["Sword", "Shield"]), diff --git a/worlds/tunic/docs/en_TUNIC.md b/worlds/tunic/docs/en_TUNIC.md index 610c7edf..0b857726 100644 --- a/worlds/tunic/docs/en_TUNIC.md +++ b/worlds/tunic/docs/en_TUNIC.md @@ -83,7 +83,7 @@ Notes: - The Entrance Randomizer option must be enabled for it to work. - The `direction` field is not supported. Connections are always coupled. - For a list of entrance names, check `er_data.py` in the TUNIC world folder or generate a game with the Entrance Randomizer option enabled and check the spoiler log. -- There is no limit to the number of Shops you can plando. +- You can plando up to 500 additional shops in Decoupled. You should not do this. See the [Archipelago Plando Guide](../../../tutorial/Archipelago/plando/en) for more information on Plando and Connection Plando. diff --git a/worlds/tunic/er_data.py b/worlds/tunic/er_data.py index 0b3a1616..744326aa 100644 --- a/worlds/tunic/er_data.py +++ b/worlds/tunic/er_data.py @@ -1,15 +1,28 @@ -from typing import Dict, NamedTuple, List, TYPE_CHECKING, Optional +from typing import Dict, NamedTuple, List, Optional, TYPE_CHECKING from enum import IntEnum if TYPE_CHECKING: from . import TunicWorld +# the direction you go to enter a portal +class Direction(IntEnum): + none = 0 # for when the direction isn't relevant + north = 1 + south = 2 + east = 3 + west = 4 + floor = 5 + ladder_up = 6 + ladder_down = 7 + + class Portal(NamedTuple): name: str # human-readable name region: str # AP region destination: str # vanilla destination scene tag: str # vanilla tag + direction: int # the direction you go to enter a portal def scene(self) -> str: # the actual scene name in Tunic if self.region.startswith("Shop"): @@ -25,497 +38,497 @@ class Portal(NamedTuple): portal_mapping: List[Portal] = [ Portal(name="Stick House Entrance", region="Overworld", - destination="Sword Cave", tag="_"), + destination="Sword Cave", tag="_", direction=Direction.north), Portal(name="Windmill Entrance", region="Overworld", - destination="Windmill", tag="_"), + destination="Windmill", tag="_", direction=Direction.north), Portal(name="Well Ladder Entrance", region="Overworld Well Ladder", - destination="Sewer", tag="_entrance"), + destination="Sewer", tag="_entrance", direction=Direction.ladder_down), Portal(name="Entrance to Well from Well Rail", region="Overworld Well to Furnace Rail", - destination="Sewer", tag="_west_aqueduct"), + destination="Sewer", tag="_west_aqueduct", direction=Direction.north), Portal(name="Old House Door Entrance", region="Overworld Old House Door", - destination="Overworld Interiors", tag="_house"), + destination="Overworld Interiors", tag="_house", direction=Direction.east), Portal(name="Old House Waterfall Entrance", region="Overworld", - destination="Overworld Interiors", tag="_under_checkpoint"), + destination="Overworld Interiors", tag="_under_checkpoint", direction=Direction.east), Portal(name="Entrance to Furnace from Well Rail", region="Overworld Well to Furnace Rail", - destination="Furnace", tag="_gyro_upper_north"), + destination="Furnace", tag="_gyro_upper_north", direction=Direction.south), Portal(name="Entrance to Furnace under Windmill", region="Overworld", - destination="Furnace", tag="_gyro_upper_east"), + destination="Furnace", tag="_gyro_upper_east", direction=Direction.west), Portal(name="Entrance to Furnace near West Garden", region="Overworld to West Garden from Furnace", - destination="Furnace", tag="_gyro_west"), + destination="Furnace", tag="_gyro_west", direction=Direction.east), Portal(name="Entrance to Furnace from Beach", region="Overworld Tunnel Turret", - destination="Furnace", tag="_gyro_lower"), + destination="Furnace", tag="_gyro_lower", direction=Direction.north), Portal(name="Caustic Light Cave Entrance", region="Overworld Swamp Lower Entry", - destination="Overworld Cave", tag="_"), + destination="Overworld Cave", tag="_", direction=Direction.north), Portal(name="Swamp Upper Entrance", region="Overworld Swamp Upper Entry", - destination="Swamp Redux 2", tag="_wall"), + destination="Swamp Redux 2", tag="_wall", direction=Direction.south), Portal(name="Swamp Lower Entrance", region="Overworld Swamp Lower Entry", - destination="Swamp Redux 2", tag="_conduit"), + destination="Swamp Redux 2", tag="_conduit", direction=Direction.south), Portal(name="Ruined Passage Not-Door Entrance", region="After Ruined Passage", - destination="Ruins Passage", tag="_east"), + destination="Ruins Passage", tag="_east", direction=Direction.north), Portal(name="Ruined Passage Door Entrance", region="Overworld Ruined Passage Door", - destination="Ruins Passage", tag="_west"), + destination="Ruins Passage", tag="_west", direction=Direction.east), Portal(name="Atoll Upper Entrance", region="Overworld to Atoll Upper", - destination="Atoll Redux", tag="_upper"), + destination="Atoll Redux", tag="_upper", direction=Direction.south), Portal(name="Atoll Lower Entrance", region="Overworld Beach", - destination="Atoll Redux", tag="_lower"), + destination="Atoll Redux", tag="_lower", direction=Direction.south), Portal(name="Special Shop Entrance", region="Overworld Special Shop Entry", - destination="ShopSpecial", tag="_"), + destination="ShopSpecial", tag="_", direction=Direction.east), Portal(name="Maze Cave Entrance", region="Overworld Beach", - destination="Maze Room", tag="_"), + destination="Maze Room", tag="_", direction=Direction.north), Portal(name="West Garden Entrance near Belltower", region="Overworld to West Garden Upper", - destination="Archipelagos Redux", tag="_upper"), + destination="Archipelagos Redux", tag="_upper", direction=Direction.west), Portal(name="West Garden Entrance from Furnace", region="Overworld to West Garden from Furnace", - destination="Archipelagos Redux", tag="_lower"), + destination="Archipelagos Redux", tag="_lower", direction=Direction.west), Portal(name="West Garden Laurels Entrance", region="Overworld West Garden Laurels Entry", - destination="Archipelagos Redux", tag="_lowest"), + destination="Archipelagos Redux", tag="_lowest", direction=Direction.west), Portal(name="Temple Door Entrance", region="Overworld Temple Door", - destination="Temple", tag="_main"), + destination="Temple", tag="_main", direction=Direction.north), Portal(name="Temple Rafters Entrance", region="Overworld after Temple Rafters", - destination="Temple", tag="_rafters"), + destination="Temple", tag="_rafters", direction=Direction.east), Portal(name="Ruined Shop Entrance", region="Overworld", - destination="Ruined Shop", tag="_"), + destination="Ruined Shop", tag="_", direction=Direction.east), Portal(name="Patrol Cave Entrance", region="Overworld at Patrol Cave", - destination="PatrolCave", tag="_"), + destination="PatrolCave", tag="_", direction=Direction.north), Portal(name="Hourglass Cave Entrance", region="Overworld Beach", - destination="Town Basement", tag="_beach"), + destination="Town Basement", tag="_beach", direction=Direction.north), Portal(name="Changing Room Entrance", region="Overworld", - destination="Changing Room", tag="_"), + destination="Changing Room", tag="_", direction=Direction.south), Portal(name="Cube Cave Entrance", region="Cube Cave Entrance Region", - destination="CubeRoom", tag="_"), + destination="CubeRoom", tag="_", direction=Direction.north), Portal(name="Stairs from Overworld to Mountain", region="Upper Overworld", - destination="Mountain", tag="_"), + destination="Mountain", tag="_", direction=Direction.north), Portal(name="Overworld to Fortress", region="East Overworld", - destination="Fortress Courtyard", tag="_"), + destination="Fortress Courtyard", tag="_", direction=Direction.east), Portal(name="Fountain HC Door Entrance", region="Overworld Fountain Cross Door", - destination="Town_FiligreeRoom", tag="_"), + destination="Town_FiligreeRoom", tag="_", direction=Direction.north), Portal(name="Southeast HC Door Entrance", region="Overworld Southeast Cross Door", - destination="EastFiligreeCache", tag="_"), + destination="EastFiligreeCache", tag="_", direction=Direction.north), Portal(name="Overworld to Quarry Connector", region="Overworld Quarry Entry", - destination="Darkwoods Tunnel", tag="_"), + destination="Darkwoods Tunnel", tag="_", direction=Direction.north), Portal(name="Dark Tomb Main Entrance", region="Overworld", - destination="Crypt Redux", tag="_"), + destination="Crypt Redux", tag="_", direction=Direction.north), Portal(name="Overworld to Forest Belltower", region="East Overworld", - destination="Forest Belltower", tag="_"), + destination="Forest Belltower", tag="_", direction=Direction.east), Portal(name="Town to Far Shore", region="Overworld Town Portal", - destination="Transit", tag="_teleporter_town"), + destination="Transit", tag="_teleporter_town", direction=Direction.floor), Portal(name="Spawn to Far Shore", region="Overworld Spawn Portal", - destination="Transit", tag="_teleporter_starting island"), + destination="Transit", tag="_teleporter_starting island", direction=Direction.floor), Portal(name="Secret Gathering Place Entrance", region="Overworld", - destination="Waterfall", tag="_"), - + destination="Waterfall", tag="_", direction=Direction.north), + Portal(name="Secret Gathering Place Exit", region="Secret Gathering Place", - destination="Overworld Redux", tag="_"), - + destination="Overworld Redux", tag="_", direction=Direction.south), + Portal(name="Windmill Exit", region="Windmill", - destination="Overworld Redux", tag="_"), + destination="Overworld Redux", tag="_", direction=Direction.south), Portal(name="Windmill Shop", region="Windmill", - destination="Shop", tag="_"), - + destination="Shop", tag="_", direction=Direction.north), + Portal(name="Old House Door Exit", region="Old House Front", - destination="Overworld Redux", tag="_house"), + destination="Overworld Redux", tag="_house", direction=Direction.west), Portal(name="Old House to Glyph Tower", region="Old House Front", - destination="g_elements", tag="_"), + destination="g_elements", tag="_", direction=Direction.south), # portal drops you on north side Portal(name="Old House Waterfall Exit", region="Old House Back", - destination="Overworld Redux", tag="_under_checkpoint"), - + destination="Overworld Redux", tag="_under_checkpoint", direction=Direction.west), + Portal(name="Glyph Tower Exit", region="Relic Tower", - destination="Overworld Interiors", tag="_"), - + destination="Overworld Interiors", tag="_", direction=Direction.north), + Portal(name="Changing Room Exit", region="Changing Room", - destination="Overworld Redux", tag="_"), - + destination="Overworld Redux", tag="_", direction=Direction.north), + Portal(name="Fountain HC Room Exit", region="Fountain Cross Room", - destination="Overworld Redux", tag="_"), - + destination="Overworld Redux", tag="_", direction=Direction.south), + Portal(name="Cube Cave Exit", region="Cube Cave", - destination="Overworld Redux", tag="_"), - + destination="Overworld Redux", tag="_", direction=Direction.south), + Portal(name="Guard Patrol Cave Exit", region="Patrol Cave", - destination="Overworld Redux", tag="_"), - + destination="Overworld Redux", tag="_", direction=Direction.south), + Portal(name="Ruined Shop Exit", region="Ruined Shop", - destination="Overworld Redux", tag="_"), - + destination="Overworld Redux", tag="_", direction=Direction.west), + Portal(name="Furnace Exit towards Well", region="Furnace Fuse", - destination="Overworld Redux", tag="_gyro_upper_north"), + destination="Overworld Redux", tag="_gyro_upper_north", direction=Direction.north), Portal(name="Furnace Exit to Dark Tomb", region="Furnace Walking Path", - destination="Crypt Redux", tag="_"), + destination="Crypt Redux", tag="_", direction=Direction.east), Portal(name="Furnace Exit towards West Garden", region="Furnace Walking Path", - destination="Overworld Redux", tag="_gyro_west"), + destination="Overworld Redux", tag="_gyro_west", direction=Direction.west), Portal(name="Furnace Exit to Beach", region="Furnace Ladder Area", - destination="Overworld Redux", tag="_gyro_lower"), + destination="Overworld Redux", tag="_gyro_lower", direction=Direction.south), Portal(name="Furnace Exit under Windmill", region="Furnace Ladder Area", - destination="Overworld Redux", tag="_gyro_upper_east"), - + destination="Overworld Redux", tag="_gyro_upper_east", direction=Direction.east), + Portal(name="Stick House Exit", region="Stick House", - destination="Overworld Redux", tag="_"), - + destination="Overworld Redux", tag="_", direction=Direction.south), + Portal(name="Ruined Passage Not-Door Exit", region="Ruined Passage", - destination="Overworld Redux", tag="_east"), + destination="Overworld Redux", tag="_east", direction=Direction.south), Portal(name="Ruined Passage Door Exit", region="Ruined Passage", - destination="Overworld Redux", tag="_west"), - + destination="Overworld Redux", tag="_west", direction=Direction.west), + Portal(name="Southeast HC Room Exit", region="Southeast Cross Room", - destination="Overworld Redux", tag="_"), - + destination="Overworld Redux", tag="_", direction=Direction.south), + Portal(name="Caustic Light Cave Exit", region="Caustic Light Cave", - destination="Overworld Redux", tag="_"), - + destination="Overworld Redux", tag="_", direction=Direction.south), + Portal(name="Maze Cave Exit", region="Maze Cave", - destination="Overworld Redux", tag="_"), - + destination="Overworld Redux", tag="_", direction=Direction.south), + Portal(name="Hourglass Cave Exit", region="Hourglass Cave", - destination="Overworld Redux", tag="_beach"), - + destination="Overworld Redux", tag="_beach", direction=Direction.south), + Portal(name="Special Shop Exit", region="Special Shop", - destination="Overworld Redux", tag="_"), - + destination="Overworld Redux", tag="_", direction=Direction.west), + Portal(name="Temple Rafters Exit", region="Sealed Temple Rafters", - destination="Overworld Redux", tag="_rafters"), + destination="Overworld Redux", tag="_rafters", direction=Direction.west), Portal(name="Temple Door Exit", region="Sealed Temple", - destination="Overworld Redux", tag="_main"), + destination="Overworld Redux", tag="_main", direction=Direction.south), Portal(name="Forest Belltower to Fortress", region="Forest Belltower Main behind bushes", - destination="Fortress Courtyard", tag="_"), + destination="Fortress Courtyard", tag="_", direction=Direction.north), Portal(name="Forest Belltower to Forest", region="Forest Belltower Lower", - destination="East Forest Redux", tag="_"), + destination="East Forest Redux", tag="_", direction=Direction.south), Portal(name="Forest Belltower to Overworld", region="Forest Belltower Main", - destination="Overworld Redux", tag="_"), + destination="Overworld Redux", tag="_", direction=Direction.west), Portal(name="Forest Belltower to Guard Captain Room", region="Forest Belltower Upper", - destination="Forest Boss Room", tag="_"), + destination="Forest Boss Room", tag="_", direction=Direction.south), Portal(name="Forest to Belltower", region="East Forest", - destination="Forest Belltower", tag="_"), + destination="Forest Belltower", tag="_", direction=Direction.north), Portal(name="Forest Guard House 1 Lower Entrance", region="East Forest", - destination="East Forest Redux Laddercave", tag="_lower"), + destination="East Forest Redux Laddercave", tag="_lower", direction=Direction.north), Portal(name="Forest Guard House 1 Gate Entrance", region="East Forest", - destination="East Forest Redux Laddercave", tag="_gate"), + destination="East Forest Redux Laddercave", tag="_gate", direction=Direction.north), Portal(name="Forest Dance Fox Outside Doorway", region="East Forest Dance Fox Spot", - destination="East Forest Redux Laddercave", tag="_upper"), + destination="East Forest Redux Laddercave", tag="_upper", direction=Direction.east), Portal(name="Forest to Far Shore", region="East Forest Portal", - destination="Transit", tag="_teleporter_forest teleporter"), + destination="Transit", tag="_teleporter_forest teleporter", direction=Direction.floor), Portal(name="Forest Guard House 2 Lower Entrance", region="Lower Forest", - destination="East Forest Redux Interior", tag="_lower"), + destination="East Forest Redux Interior", tag="_lower", direction=Direction.north), Portal(name="Forest Guard House 2 Upper Entrance", region="East Forest", - destination="East Forest Redux Interior", tag="_upper"), + destination="East Forest Redux Interior", tag="_upper", direction=Direction.east), Portal(name="Forest Grave Path Lower Entrance", region="East Forest", - destination="Sword Access", tag="_lower"), + destination="Sword Access", tag="_lower", direction=Direction.east), Portal(name="Forest Grave Path Upper Entrance", region="East Forest", - destination="Sword Access", tag="_upper"), + destination="Sword Access", tag="_upper", direction=Direction.east), Portal(name="Forest Grave Path Upper Exit", region="Forest Grave Path Upper", - destination="East Forest Redux", tag="_upper"), + destination="East Forest Redux", tag="_upper", direction=Direction.west), Portal(name="Forest Grave Path Lower Exit", region="Forest Grave Path Main", - destination="East Forest Redux", tag="_lower"), + destination="East Forest Redux", tag="_lower", direction=Direction.west), Portal(name="East Forest Hero's Grave", region="Forest Hero's Grave", - destination="RelicVoid", tag="_teleporter_relic plinth"), + destination="RelicVoid", tag="_teleporter_relic plinth", direction=Direction.floor), Portal(name="Guard House 1 Dance Fox Exit", region="Guard House 1 West", - destination="East Forest Redux", tag="_upper"), + destination="East Forest Redux", tag="_upper", direction=Direction.west), Portal(name="Guard House 1 Lower Exit", region="Guard House 1 West", - destination="East Forest Redux", tag="_lower"), + destination="East Forest Redux", tag="_lower", direction=Direction.south), Portal(name="Guard House 1 Upper Forest Exit", region="Guard House 1 East", - destination="East Forest Redux", tag="_gate"), + destination="East Forest Redux", tag="_gate", direction=Direction.south), Portal(name="Guard House 1 to Guard Captain Room", region="Guard House 1 East", - destination="Forest Boss Room", tag="_"), + destination="Forest Boss Room", tag="_", direction=Direction.north), Portal(name="Guard House 2 Lower Exit", region="Guard House 2 Lower", - destination="East Forest Redux", tag="_lower"), + destination="East Forest Redux", tag="_lower", direction=Direction.south), Portal(name="Guard House 2 Upper Exit", region="Guard House 2 Upper before bushes", - destination="East Forest Redux", tag="_upper"), + destination="East Forest Redux", tag="_upper", direction=Direction.west), Portal(name="Guard Captain Room Non-Gate Exit", region="Forest Boss Room", - destination="East Forest Redux Laddercave", tag="_"), + destination="East Forest Redux Laddercave", tag="_", direction=Direction.south), Portal(name="Guard Captain Room Gate Exit", region="Forest Boss Room", - destination="Forest Belltower", tag="_"), + destination="Forest Belltower", tag="_", direction=Direction.north), Portal(name="Well Ladder Exit", region="Beneath the Well Ladder Exit", - destination="Overworld Redux", tag="_entrance"), + destination="Overworld Redux", tag="_entrance", direction=Direction.ladder_up), Portal(name="Well to Well Boss", region="Beneath the Well Back", - destination="Sewer_Boss", tag="_"), + destination="Sewer_Boss", tag="_", direction=Direction.east), Portal(name="Well Exit towards Furnace", region="Beneath the Well Back", - destination="Overworld Redux", tag="_west_aqueduct"), + destination="Overworld Redux", tag="_west_aqueduct", direction=Direction.south), Portal(name="Well Boss to Well", region="Well Boss", - destination="Sewer", tag="_"), + destination="Sewer", tag="_", direction=Direction.west), Portal(name="Checkpoint to Dark Tomb", region="Dark Tomb Checkpoint", - destination="Crypt Redux", tag="_"), + destination="Crypt Redux", tag="_", direction=Direction.ladder_up), Portal(name="Dark Tomb to Overworld", region="Dark Tomb Entry Point", - destination="Overworld Redux", tag="_"), + destination="Overworld Redux", tag="_", direction=Direction.south), Portal(name="Dark Tomb to Furnace", region="Dark Tomb Dark Exit", - destination="Furnace", tag="_"), + destination="Furnace", tag="_", direction=Direction.west), Portal(name="Dark Tomb to Checkpoint", region="Dark Tomb Entry Point", - destination="Sewer_Boss", tag="_"), - + destination="Sewer_Boss", tag="_", direction=Direction.ladder_down), + Portal(name="West Garden Exit near Hero's Grave", region="West Garden before Terry", - destination="Overworld Redux", tag="_lower"), + destination="Overworld Redux", tag="_lower", direction=Direction.east), Portal(name="West Garden to Magic Dagger House", region="West Garden at Dagger House", - destination="archipelagos_house", tag="_"), + destination="archipelagos_house", tag="_", direction=Direction.east), Portal(name="West Garden Exit after Boss", region="West Garden after Boss", - destination="Overworld Redux", tag="_upper"), + destination="Overworld Redux", tag="_upper", direction=Direction.east), Portal(name="West Garden Shop", region="West Garden before Terry", - destination="Shop", tag="_"), + destination="Shop", tag="_", direction=Direction.east), Portal(name="West Garden Laurels Exit", region="West Garden Laurels Exit Region", - destination="Overworld Redux", tag="_lowest"), + destination="Overworld Redux", tag="_lowest", direction=Direction.east), Portal(name="West Garden Hero's Grave", region="West Garden Hero's Grave Region", - destination="RelicVoid", tag="_teleporter_relic plinth"), + destination="RelicVoid", tag="_teleporter_relic plinth", direction=Direction.floor), Portal(name="West Garden to Far Shore", region="West Garden Portal", - destination="Transit", tag="_teleporter_archipelagos_teleporter"), + destination="Transit", tag="_teleporter_archipelagos_teleporter", direction=Direction.floor), Portal(name="Magic Dagger House Exit", region="Magic Dagger House", - destination="Archipelagos Redux", tag="_"), + destination="Archipelagos Redux", tag="_", direction=Direction.west), Portal(name="Fortress Courtyard to Fortress Grave Path Lower", region="Fortress Courtyard", - destination="Fortress Reliquary", tag="_Lower"), + destination="Fortress Reliquary", tag="_Lower", direction=Direction.east), Portal(name="Fortress Courtyard to Fortress Grave Path Upper", region="Fortress Courtyard Upper", - destination="Fortress Reliquary", tag="_Upper"), + destination="Fortress Reliquary", tag="_Upper", direction=Direction.east), Portal(name="Fortress Courtyard to Fortress Interior", region="Fortress Courtyard", - destination="Fortress Main", tag="_Big Door"), + destination="Fortress Main", tag="_Big Door", direction=Direction.north), Portal(name="Fortress Courtyard to East Fortress", region="Fortress Courtyard Upper", - destination="Fortress East", tag="_"), + destination="Fortress East", tag="_", direction=Direction.north), Portal(name="Fortress Courtyard to Beneath the Vault", region="Beneath the Vault Entry", - destination="Fortress Basement", tag="_"), + destination="Fortress Basement", tag="_", direction=Direction.ladder_down), Portal(name="Fortress Courtyard to Forest Belltower", region="Fortress Exterior from East Forest", - destination="Forest Belltower", tag="_"), + destination="Forest Belltower", tag="_", direction=Direction.south), Portal(name="Fortress Courtyard to Overworld", region="Fortress Exterior from Overworld", - destination="Overworld Redux", tag="_"), + destination="Overworld Redux", tag="_", direction=Direction.west), Portal(name="Fortress Courtyard Shop", region="Fortress Exterior near cave", - destination="Shop", tag="_"), + destination="Shop", tag="_", direction=Direction.north), Portal(name="Beneath the Vault to Fortress Interior", region="Beneath the Vault Back", - destination="Fortress Main", tag="_"), + destination="Fortress Main", tag="_", direction=Direction.east), Portal(name="Beneath the Vault to Fortress Courtyard", region="Beneath the Vault Ladder Exit", - destination="Fortress Courtyard", tag="_"), + destination="Fortress Courtyard", tag="_", direction=Direction.ladder_up), Portal(name="Fortress Interior Main Exit", region="Eastern Vault Fortress", - destination="Fortress Courtyard", tag="_Big Door"), + destination="Fortress Courtyard", tag="_Big Door", direction=Direction.south), Portal(name="Fortress Interior to Beneath the Earth", region="Eastern Vault Fortress", - destination="Fortress Basement", tag="_"), + destination="Fortress Basement", tag="_", direction=Direction.west), Portal(name="Fortress Interior to Siege Engine Arena", region="Eastern Vault Fortress Gold Door", - destination="Fortress Arena", tag="_"), + destination="Fortress Arena", tag="_", direction=Direction.north), Portal(name="Fortress Interior Shop", region="Eastern Vault Fortress", - destination="Shop", tag="_"), + destination="Shop", tag="_", direction=Direction.north), Portal(name="Fortress Interior to East Fortress Upper", region="Eastern Vault Fortress", - destination="Fortress East", tag="_upper"), + destination="Fortress East", tag="_upper", direction=Direction.east), Portal(name="Fortress Interior to East Fortress Lower", region="Eastern Vault Fortress", - destination="Fortress East", tag="_lower"), + destination="Fortress East", tag="_lower", direction=Direction.east), Portal(name="East Fortress to Interior Lower", region="Fortress East Shortcut Lower", - destination="Fortress Main", tag="_lower"), + destination="Fortress Main", tag="_lower", direction=Direction.west), Portal(name="East Fortress to Courtyard", region="Fortress East Shortcut Upper", - destination="Fortress Courtyard", tag="_"), + destination="Fortress Courtyard", tag="_", direction=Direction.south), Portal(name="East Fortress to Interior Upper", region="Fortress East Shortcut Upper", - destination="Fortress Main", tag="_upper"), + destination="Fortress Main", tag="_upper", direction=Direction.west), Portal(name="Fortress Grave Path Lower Exit", region="Fortress Grave Path Entry", - destination="Fortress Courtyard", tag="_Lower"), + destination="Fortress Courtyard", tag="_Lower", direction=Direction.west), Portal(name="Fortress Hero's Grave", region="Fortress Hero's Grave Region", - destination="RelicVoid", tag="_teleporter_relic plinth"), + destination="RelicVoid", tag="_teleporter_relic plinth", direction=Direction.floor), Portal(name="Fortress Grave Path Upper Exit", region="Fortress Grave Path Upper", - destination="Fortress Courtyard", tag="_Upper"), + destination="Fortress Courtyard", tag="_Upper", direction=Direction.west), Portal(name="Fortress Grave Path Dusty Entrance", region="Fortress Grave Path Dusty Entrance Region", - destination="Dusty", tag="_"), + destination="Dusty", tag="_", direction=Direction.north), Portal(name="Dusty Exit", region="Fortress Leaf Piles", - destination="Fortress Reliquary", tag="_"), + destination="Fortress Reliquary", tag="_", direction=Direction.south), Portal(name="Siege Engine Arena to Fortress", region="Fortress Arena", - destination="Fortress Main", tag="_"), + destination="Fortress Main", tag="_", direction=Direction.south), Portal(name="Fortress to Far Shore", region="Fortress Arena Portal", - destination="Transit", tag="_teleporter_spidertank"), + destination="Transit", tag="_teleporter_spidertank", direction=Direction.floor), Portal(name="Atoll Upper Exit", region="Ruined Atoll", - destination="Overworld Redux", tag="_upper"), + destination="Overworld Redux", tag="_upper", direction=Direction.north), Portal(name="Atoll Lower Exit", region="Ruined Atoll Lower Entry Area", - destination="Overworld Redux", tag="_lower"), + destination="Overworld Redux", tag="_lower", direction=Direction.north), Portal(name="Atoll Shop", region="Ruined Atoll", - destination="Shop", tag="_"), + destination="Shop", tag="_", direction=Direction.north), Portal(name="Atoll to Far Shore", region="Ruined Atoll Portal", - destination="Transit", tag="_teleporter_atoll"), + destination="Transit", tag="_teleporter_atoll", direction=Direction.floor), Portal(name="Atoll Statue Teleporter", region="Ruined Atoll Statue", - destination="Library Exterior", tag="_"), + destination="Library Exterior", tag="_", direction=Direction.floor), Portal(name="Frog Stairs Eye Entrance", region="Ruined Atoll Frog Eye", - destination="Frog Stairs", tag="_eye"), + destination="Frog Stairs", tag="_eye", direction=Direction.south), # camera rotates, it's fine Portal(name="Frog Stairs Mouth Entrance", region="Ruined Atoll Frog Mouth", - destination="Frog Stairs", tag="_mouth"), + destination="Frog Stairs", tag="_mouth", direction=Direction.east), Portal(name="Frog Stairs Eye Exit", region="Frog Stairs Eye Exit", - destination="Atoll Redux", tag="_eye"), + destination="Atoll Redux", tag="_eye", direction=Direction.north), Portal(name="Frog Stairs Mouth Exit", region="Frog Stairs Upper", - destination="Atoll Redux", tag="_mouth"), + destination="Atoll Redux", tag="_mouth", direction=Direction.west), Portal(name="Frog Stairs to Frog's Domain's Entrance", region="Frog Stairs to Frog's Domain", - destination="frog cave main", tag="_Entrance"), + destination="frog cave main", tag="_Entrance", direction=Direction.ladder_down), Portal(name="Frog Stairs to Frog's Domain's Exit", region="Frog Stairs Lower", - destination="frog cave main", tag="_Exit"), + destination="frog cave main", tag="_Exit", direction=Direction.east), Portal(name="Frog's Domain Ladder Exit", region="Frog's Domain Entry", - destination="Frog Stairs", tag="_Entrance"), + destination="Frog Stairs", tag="_Entrance", direction=Direction.ladder_up), Portal(name="Frog's Domain Orb Exit", region="Frog's Domain Back", - destination="Frog Stairs", tag="_Exit"), + destination="Frog Stairs", tag="_Exit", direction=Direction.west), Portal(name="Library Exterior Tree", region="Library Exterior Tree Region", - destination="Atoll Redux", tag="_"), + destination="Atoll Redux", tag="_", direction=Direction.floor), Portal(name="Library Exterior Ladder", region="Library Exterior Ladder Region", - destination="Library Hall", tag="_"), + destination="Library Hall", tag="_", direction=Direction.west), # camera rotates Portal(name="Library Hall Bookshelf Exit", region="Library Hall Bookshelf", - destination="Library Exterior", tag="_"), + destination="Library Exterior", tag="_", direction=Direction.east), Portal(name="Library Hero's Grave", region="Library Hero's Grave Region", - destination="RelicVoid", tag="_teleporter_relic plinth"), + destination="RelicVoid", tag="_teleporter_relic plinth", direction=Direction.floor), Portal(name="Library Hall to Rotunda", region="Library Hall to Rotunda", - destination="Library Rotunda", tag="_"), + destination="Library Rotunda", tag="_", direction=Direction.ladder_up), Portal(name="Library Rotunda Lower Exit", region="Library Rotunda to Hall", - destination="Library Hall", tag="_"), + destination="Library Hall", tag="_", direction=Direction.ladder_down), Portal(name="Library Rotunda Upper Exit", region="Library Rotunda to Lab", - destination="Library Lab", tag="_"), + destination="Library Lab", tag="_", direction=Direction.ladder_up), Portal(name="Library Lab to Rotunda", region="Library Lab Lower", - destination="Library Rotunda", tag="_"), + destination="Library Rotunda", tag="_", direction=Direction.ladder_down), Portal(name="Library to Far Shore", region="Library Portal", - destination="Transit", tag="_teleporter_library teleporter"), + destination="Transit", tag="_teleporter_library teleporter", direction=Direction.floor), Portal(name="Library Lab to Librarian Arena", region="Library Lab to Librarian", - destination="Library Arena", tag="_"), + destination="Library Arena", tag="_", direction=Direction.ladder_up), Portal(name="Librarian Arena Exit", region="Library Arena", - destination="Library Lab", tag="_"), + destination="Library Lab", tag="_", direction=Direction.ladder_down), Portal(name="Stairs to Top of the Mountain", region="Lower Mountain Stairs", - destination="Mountaintop", tag="_"), + destination="Mountaintop", tag="_", direction=Direction.north), Portal(name="Mountain to Quarry", region="Lower Mountain", - destination="Quarry Redux", tag="_"), + destination="Quarry Redux", tag="_", direction=Direction.south), # connecting is north Portal(name="Mountain to Overworld", region="Lower Mountain", - destination="Overworld Redux", tag="_"), - + destination="Overworld Redux", tag="_", direction=Direction.south), + Portal(name="Top of the Mountain Exit", region="Top of the Mountain", - destination="Mountain", tag="_"), - + destination="Mountain", tag="_", direction=Direction.south), + Portal(name="Quarry Connector to Overworld", region="Quarry Connector", - destination="Overworld Redux", tag="_"), + destination="Overworld Redux", tag="_", direction=Direction.south), Portal(name="Quarry Connector to Quarry", region="Quarry Connector", - destination="Quarry Redux", tag="_"), - + destination="Quarry Redux", tag="_", direction=Direction.north), # rotates, it's fine + Portal(name="Quarry to Overworld Exit", region="Quarry Entry", - destination="Darkwoods Tunnel", tag="_"), + destination="Darkwoods Tunnel", tag="_", direction=Direction.south), # rotates, it's fine Portal(name="Quarry Shop", region="Quarry Entry", - destination="Shop", tag="_"), + destination="Shop", tag="_", direction=Direction.north), Portal(name="Quarry to Monastery Front", region="Quarry Monastery Entry", - destination="Monastery", tag="_front"), + destination="Monastery", tag="_front", direction=Direction.north), Portal(name="Quarry to Monastery Back", region="Monastery Rope", - destination="Monastery", tag="_back"), + destination="Monastery", tag="_back", direction=Direction.east), Portal(name="Quarry to Mountain", region="Quarry Back", - destination="Mountain", tag="_"), + destination="Mountain", tag="_", direction=Direction.north), Portal(name="Quarry to Ziggurat", region="Lower Quarry Zig Door", - destination="ziggurat2020_0", tag="_"), + destination="ziggurat2020_0", tag="_", direction=Direction.north), Portal(name="Quarry to Far Shore", region="Quarry Portal", - destination="Transit", tag="_teleporter_quarry teleporter"), - + destination="Transit", tag="_teleporter_quarry teleporter", direction=Direction.floor), + Portal(name="Monastery Rear Exit", region="Monastery Back", - destination="Quarry Redux", tag="_back"), + destination="Quarry Redux", tag="_back", direction=Direction.west), Portal(name="Monastery Front Exit", region="Monastery Front", - destination="Quarry Redux", tag="_front"), + destination="Quarry Redux", tag="_front", direction=Direction.south), Portal(name="Monastery Hero's Grave", region="Monastery Hero's Grave Region", - destination="RelicVoid", tag="_teleporter_relic plinth"), - + destination="RelicVoid", tag="_teleporter_relic plinth", direction=Direction.floor), + Portal(name="Ziggurat Entry Hallway to Ziggurat Upper", region="Rooted Ziggurat Entry", - destination="ziggurat2020_1", tag="_"), + destination="ziggurat2020_1", tag="_", direction=Direction.north), Portal(name="Ziggurat Entry Hallway to Quarry", region="Rooted Ziggurat Entry", - destination="Quarry Redux", tag="_"), - + destination="Quarry Redux", tag="_", direction=Direction.south), + Portal(name="Ziggurat Upper to Ziggurat Entry Hallway", region="Rooted Ziggurat Upper Entry", - destination="ziggurat2020_0", tag="_"), + destination="ziggurat2020_0", tag="_", direction=Direction.south), Portal(name="Ziggurat Upper to Ziggurat Tower", region="Rooted Ziggurat Upper Back", - destination="ziggurat2020_2", tag="_"), - + destination="ziggurat2020_2", tag="_", direction=Direction.north), # connecting is south + Portal(name="Ziggurat Tower to Ziggurat Upper", region="Rooted Ziggurat Middle Top", - destination="ziggurat2020_1", tag="_"), + destination="ziggurat2020_1", tag="_", direction=Direction.south), Portal(name="Ziggurat Tower to Ziggurat Lower", region="Rooted Ziggurat Middle Bottom", - destination="ziggurat2020_3", tag="_"), - + destination="ziggurat2020_3", tag="_", direction=Direction.south), + Portal(name="Ziggurat Lower to Ziggurat Tower", region="Rooted Ziggurat Lower Entry", - destination="ziggurat2020_2", tag="_"), + destination="ziggurat2020_2", tag="_", direction=Direction.north), Portal(name="Ziggurat Portal Room Entrance", region="Rooted Ziggurat Portal Room Entrance", - destination="ziggurat2020_FTRoom", tag="_"), + destination="ziggurat2020_FTRoom", tag="_", direction=Direction.north), # only if fixed shop is on, removed otherwise - Portal(name="Ziggurat Lower Falling Entrance", region="Zig Skip Exit", - destination="ziggurat2020_1", tag="_zig2_skip"), - + Portal(name="Ziggurat Lower Falling Entrance", region="Zig Skip Exit", # not a real region + destination="ziggurat2020_1", tag="_zig2_skip", direction=Direction.none), + Portal(name="Ziggurat Portal Room Exit", region="Rooted Ziggurat Portal Room Exit", - destination="ziggurat2020_3", tag="_"), + destination="ziggurat2020_3", tag="_", direction=Direction.south), Portal(name="Ziggurat to Far Shore", region="Rooted Ziggurat Portal", - destination="Transit", tag="_teleporter_ziggurat teleporter"), - + destination="Transit", tag="_teleporter_ziggurat teleporter", direction=Direction.floor), + Portal(name="Swamp Lower Exit", region="Swamp Front", - destination="Overworld Redux", tag="_conduit"), + destination="Overworld Redux", tag="_conduit", direction=Direction.north), Portal(name="Swamp to Cathedral Main Entrance", region="Swamp to Cathedral Main Entrance Region", - destination="Cathedral Redux", tag="_main"), + destination="Cathedral Redux", tag="_main", direction=Direction.north), Portal(name="Swamp to Cathedral Secret Legend Room Entrance", region="Swamp to Cathedral Treasure Room", - destination="Cathedral Redux", tag="_secret"), + destination="Cathedral Redux", tag="_secret", direction=Direction.south), # feels a little weird Portal(name="Swamp to Gauntlet", region="Back of Swamp", - destination="Cathedral Arena", tag="_"), + destination="Cathedral Arena", tag="_", direction=Direction.north), Portal(name="Swamp Shop", region="Swamp Front", - destination="Shop", tag="_"), + destination="Shop", tag="_", direction=Direction.north), Portal(name="Swamp Upper Exit", region="Back of Swamp Laurels Area", - destination="Overworld Redux", tag="_wall"), + destination="Overworld Redux", tag="_wall", direction=Direction.north), Portal(name="Swamp Hero's Grave", region="Swamp Hero's Grave Region", - destination="RelicVoid", tag="_teleporter_relic plinth"), - + destination="RelicVoid", tag="_teleporter_relic plinth", direction=Direction.floor), + Portal(name="Cathedral Main Exit", region="Cathedral Entry", - destination="Swamp Redux 2", tag="_main"), + destination="Swamp Redux 2", tag="_main", direction=Direction.south), Portal(name="Cathedral Elevator", region="Cathedral to Gauntlet", - destination="Cathedral Arena", tag="_"), + destination="Cathedral Arena", tag="_", direction=Direction.ladder_down), # elevators are ladders, right? Portal(name="Cathedral Secret Legend Room Exit", region="Cathedral Secret Legend Room", - destination="Swamp Redux 2", tag="_secret"), - + destination="Swamp Redux 2", tag="_secret", direction=Direction.north), + Portal(name="Gauntlet to Swamp", region="Cathedral Gauntlet Exit", - destination="Swamp Redux 2", tag="_"), + destination="Swamp Redux 2", tag="_", direction=Direction.south), Portal(name="Gauntlet Elevator", region="Cathedral Gauntlet Checkpoint", - destination="Cathedral Redux", tag="_"), + destination="Cathedral Redux", tag="_", direction=Direction.ladder_up), Portal(name="Gauntlet Shop", region="Cathedral Gauntlet Checkpoint", - destination="Shop", tag="_"), - + destination="Shop", tag="_", direction=Direction.east), + Portal(name="Hero's Grave to Fortress", region="Hero Relic - Fortress", - destination="Fortress Reliquary", tag="_teleporter_relic plinth"), + destination="Fortress Reliquary", tag="_teleporter_relic plinth", direction=Direction.floor), Portal(name="Hero's Grave to Monastery", region="Hero Relic - Quarry", - destination="Monastery", tag="_teleporter_relic plinth"), + destination="Monastery", tag="_teleporter_relic plinth", direction=Direction.floor), Portal(name="Hero's Grave to West Garden", region="Hero Relic - West Garden", - destination="Archipelagos Redux", tag="_teleporter_relic plinth"), + destination="Archipelagos Redux", tag="_teleporter_relic plinth", direction=Direction.floor), Portal(name="Hero's Grave to East Forest", region="Hero Relic - East Forest", - destination="Sword Access", tag="_teleporter_relic plinth"), + destination="Sword Access", tag="_teleporter_relic plinth", direction=Direction.floor), Portal(name="Hero's Grave to Library", region="Hero Relic - Library", - destination="Library Hall", tag="_teleporter_relic plinth"), + destination="Library Hall", tag="_teleporter_relic plinth", direction=Direction.floor), Portal(name="Hero's Grave to Swamp", region="Hero Relic - Swamp", - destination="Swamp Redux 2", tag="_teleporter_relic plinth"), - + destination="Swamp Redux 2", tag="_teleporter_relic plinth", direction=Direction.floor), + Portal(name="Far Shore to West Garden", region="Far Shore to West Garden Region", - destination="Archipelagos Redux", tag="_teleporter_archipelagos_teleporter"), + destination="Archipelagos Redux", tag="_teleporter_archipelagos_teleporter", direction=Direction.floor), Portal(name="Far Shore to Library", region="Far Shore to Library Region", - destination="Library Lab", tag="_teleporter_library teleporter"), + destination="Library Lab", tag="_teleporter_library teleporter", direction=Direction.floor), Portal(name="Far Shore to Quarry", region="Far Shore to Quarry Region", - destination="Quarry Redux", tag="_teleporter_quarry teleporter"), + destination="Quarry Redux", tag="_teleporter_quarry teleporter", direction=Direction.floor), Portal(name="Far Shore to East Forest", region="Far Shore to East Forest Region", - destination="East Forest Redux", tag="_teleporter_forest teleporter"), + destination="East Forest Redux", tag="_teleporter_forest teleporter", direction=Direction.floor), Portal(name="Far Shore to Fortress", region="Far Shore to Fortress Region", - destination="Fortress Arena", tag="_teleporter_spidertank"), + destination="Fortress Arena", tag="_teleporter_spidertank", direction=Direction.floor), Portal(name="Far Shore to Atoll", region="Far Shore", - destination="Atoll Redux", tag="_teleporter_atoll"), + destination="Atoll Redux", tag="_teleporter_atoll", direction=Direction.floor), Portal(name="Far Shore to Ziggurat", region="Far Shore", - destination="ziggurat2020_FTRoom", tag="_teleporter_ziggurat teleporter"), + destination="ziggurat2020_FTRoom", tag="_teleporter_ziggurat teleporter", direction=Direction.floor), Portal(name="Far Shore to Heir", region="Far Shore", - destination="Spirit Arena", tag="_teleporter_spirit arena"), + destination="Spirit Arena", tag="_teleporter_spirit arena", direction=Direction.floor), Portal(name="Far Shore to Town", region="Far Shore", - destination="Overworld Redux", tag="_teleporter_town"), + destination="Overworld Redux", tag="_teleporter_town", direction=Direction.floor), Portal(name="Far Shore to Spawn", region="Far Shore to Spawn Region", - destination="Overworld Redux", tag="_teleporter_starting island"), - + destination="Overworld Redux", tag="_teleporter_starting island", direction=Direction.floor), + Portal(name="Heir Arena Exit", region="Spirit Arena", - destination="Transit", tag="_teleporter_spirit arena"), - + destination="Transit", tag="_teleporter_spirit arena", direction=Direction.floor), + Portal(name="Purgatory Bottom Exit", region="Purgatory", - destination="Purgatory", tag="_bottom"), + destination="Purgatory", tag="_bottom", direction=Direction.south), Portal(name="Purgatory Top Exit", region="Purgatory", - destination="Purgatory", tag="_top"), + destination="Purgatory", tag="_top", direction=Direction.north), ] @@ -523,6 +536,7 @@ class RegionInfo(NamedTuple): game_scene: str # the name of the scene in the actual game dead_end: int = 0 # if a region has only one exit outlet_region: Optional[str] = None + is_fake_region: bool = False # gets the outlet region name if it exists, the region if it doesn't @@ -540,9 +554,9 @@ class DeadEnd(IntEnum): # key is the AP region name. "Fake" in region info just means the mod won't receive that info at all tunic_er_regions: Dict[str, RegionInfo] = { - "Menu": RegionInfo("Fake", dead_end=DeadEnd.all_cats), + "Menu": RegionInfo("Fake", dead_end=DeadEnd.all_cats, is_fake_region=True), "Overworld": RegionInfo("Overworld Redux"), # main overworld, the central area - "Overworld Holy Cross": RegionInfo("Fake", dead_end=DeadEnd.all_cats), # main overworld holy cross checks + "Overworld Holy Cross": RegionInfo("Fake", dead_end=DeadEnd.all_cats, is_fake_region=True), # main overworld holy cross checks "Overworld Belltower": RegionInfo("Overworld Redux"), # the area with the belltower and chest "Overworld Belltower at Bell": RegionInfo("Overworld Redux"), # being able to ring the belltower, basically "Overworld Swamp Upper Entry": RegionInfo("Overworld Redux"), # upper swamp entry spot @@ -722,7 +736,7 @@ tunic_er_regions: Dict[str, RegionInfo] = { "Rooted Ziggurat Lower Front": RegionInfo("ziggurat2020_3"), # the front for combat logic "Rooted Ziggurat Lower Mid Checkpoint": RegionInfo("ziggurat2020_3"), # the mid-checkpoint before double admin "Rooted Ziggurat Lower Back": RegionInfo("ziggurat2020_3"), # the boss side - "Zig Skip Exit": RegionInfo("ziggurat2020_3", dead_end=DeadEnd.special, outlet_region="Rooted Ziggurat Lower Entry"), # for use with fixed shop on + "Zig Skip Exit": RegionInfo("ziggurat2020_3", dead_end=DeadEnd.special, outlet_region="Rooted Ziggurat Lower Entry", is_fake_region=True), # for use with fixed shop on "Rooted Ziggurat Portal Room Entrance": RegionInfo("ziggurat2020_3", outlet_region="Rooted Ziggurat Lower Back"), # the door itself on the zig 3 side "Rooted Ziggurat Portal": RegionInfo("ziggurat2020_FTRoom", outlet_region="Rooted Ziggurat Portal Room"), "Rooted Ziggurat Portal Room": RegionInfo("ziggurat2020_FTRoom"), @@ -758,7 +772,7 @@ tunic_er_regions: Dict[str, RegionInfo] = { "Purgatory": RegionInfo("Purgatory"), "Shop": RegionInfo("Shop", dead_end=DeadEnd.all_cats), "Spirit Arena": RegionInfo("Spirit Arena", dead_end=DeadEnd.all_cats), - "Spirit Arena Victory": RegionInfo("Spirit Arena", dead_end=DeadEnd.all_cats), + "Spirit Arena Victory": RegionInfo("Spirit Arena", dead_end=DeadEnd.all_cats, is_fake_region=True), } @@ -813,7 +827,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = { "Overworld Southeast Cross Door": [], "Overworld Fountain Cross Door": - [], + [], "Overworld Town Portal": [], "Overworld Spawn Portal": @@ -1301,7 +1315,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = { [], }, - # cannot get from frogs back to front "Library Exterior Ladder Region": { "Library Exterior by Tree": [], @@ -1634,10 +1647,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = { "Rooted Ziggurat Portal Room Entrance": [], }, - "Zig Skip Exit": { - "Rooted Ziggurat Lower Front": - [], - }, "Rooted Ziggurat Portal Room Entrance": { "Rooted Ziggurat Lower Back": [], diff --git a/worlds/tunic/er_rules.py b/worlds/tunic/er_rules.py index 84ebb483..8c0979e3 100644 --- a/worlds/tunic/er_rules.py +++ b/worlds/tunic/er_rules.py @@ -381,9 +381,11 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ regions["Overworld"].connect( connecting_region=regions["Overworld Tunnel Turret"], rule=lambda state: state.has(laurels, player)) - regions["Overworld Tunnel Turret"].connect( - connecting_region=regions["Overworld"], - rule=lambda state: state.has_any({grapple, laurels}, player)) + + # always have access to Overworld, so connecting back isn't needed + # regions["Overworld Tunnel Turret"].connect( + # connecting_region=regions["Overworld"], + # rule=lambda state: state.has_any({grapple, laurels}, player)) cube_entrance = regions["Overworld"].connect( connecting_region=regions["Cube Cave Entrance Region"], @@ -1053,11 +1055,6 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ regions["Rooted Ziggurat Portal Room Entrance"].connect( connecting_region=regions["Rooted Ziggurat Lower Back"]) - # zig skip region only gets made if entrance rando and fewer shops are on - if options.entrance_rando and options.fixed_shop: - regions["Zig Skip Exit"].connect( - connecting_region=regions["Rooted Ziggurat Lower Front"]) - regions["Rooted Ziggurat Portal"].connect( connecting_region=regions["Rooted Ziggurat Portal Room"]) regions["Rooted Ziggurat Portal Room"].connect( @@ -1226,14 +1223,6 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ and has_sword(state, player)))) if options.ladder_storage: - def get_portal_info(portal_sd: str) -> Tuple[str, str]: - for portal1, portal2 in portal_pairs.items(): - if portal1.scene_destination() == portal_sd: - return portal1.name, get_portal_outlet_region(portal2, world) - if portal2.scene_destination() == portal_sd: - return portal2.name, get_portal_outlet_region(portal1, world) - raise Exception("no matches found in get_paired_region") - # connect ls elevation regions to their destinations def ls_connect(origin_name: str, portal_sdt: str) -> None: p_name, paired_region_name = get_portal_info(portal_sdt) diff --git a/worlds/tunic/er_scripts.py b/worlds/tunic/er_scripts.py index 597c65b9..ae1b5fcb 100644 --- a/worlds/tunic/er_scripts.py +++ b/worlds/tunic/er_scripts.py @@ -1,11 +1,12 @@ from typing import Dict, List, Set, Tuple, TYPE_CHECKING from BaseClasses import Region, ItemClassification, Item, Location from .locations import all_locations -from .er_data import Portal, portal_mapping, traversal_requirements, DeadEnd, RegionInfo +from .er_data import (Portal, portal_mapping, traversal_requirements, DeadEnd, Direction, RegionInfo, + get_portal_outlet_region) from .er_rules import set_er_region_rules from .breakables import create_breakable_exclusive_regions, set_breakable_location_rules from Options import PlandoConnection -from .options import EntranceRando +from .options import EntranceRando, EntranceLayout from random import Random from copy import deepcopy @@ -23,17 +24,18 @@ class TunicERLocation(Location): def create_er_regions(world: "TunicWorld") -> Dict[Portal, Portal]: regions: Dict[str, Region] = {} + world.used_shop_numbers = set() + for region_name, region_data in world.er_regions.items(): if world.options.entrance_rando and region_name == "Zig Skip Exit": # need to check if there's a seed group for this first if world.options.entrance_rando.value not in EntranceRando.options.values(): - if not world.seed_groups[world.options.entrance_rando.value]["fixed_shop"]: + if world.seed_groups[world.options.entrance_rando.value]["entrance_layout"] != EntranceLayout.option_fixed_shop: continue - elif not world.options.fixed_shop: + elif world.options.entrance_layout != EntranceLayout.option_fixed_shop: continue if not world.options.entrance_rando and region_name in ("Zig Skip Exit", "Purgatory"): continue - region = Region(region_name, world.player, world.multiworld) regions[region_name] = region world.multiworld.regions.append(region) @@ -46,13 +48,18 @@ def create_er_regions(world: "TunicWorld") -> Dict[Portal, Portal]: portal_pairs = pair_portals(world, regions) # output the entrances to the spoiler log here for convenience - sorted_portal_pairs = sort_portals(portal_pairs) - for portal1, portal2 in sorted_portal_pairs.items(): - world.multiworld.spoiler.set_entrance(portal1, portal2, "both", world.player) + sorted_portal_pairs = sort_portals(portal_pairs, world) + if not world.options.decoupled: + for portal1, portal2 in sorted_portal_pairs.items(): + world.multiworld.spoiler.set_entrance(portal1, portal2, "both", world.player) + else: + for portal1, portal2 in sorted_portal_pairs.items(): + world.multiworld.spoiler.set_entrance(portal1, portal2, "entrance", world.player) + else: portal_pairs = vanilla_portals(world, regions) - create_randomized_entrances(portal_pairs, regions) + create_randomized_entrances(world, portal_pairs, regions) set_er_region_rules(world, regions, portal_pairs) @@ -75,6 +82,7 @@ def create_er_regions(world: "TunicWorld") -> Dict[Portal, Portal]: return portal_pairs +# keys are event names, values are event regions tunic_events: Dict[str, str] = { "Eastern Bell": "Forest Belltower Upper", "Western Bell": "Overworld Belltower at Bell", @@ -111,17 +119,31 @@ def place_event_items(world: "TunicWorld", regions: Dict[str, Region]) -> None: region.locations.append(location) +# keeping track of which shop numbers have been used already to avoid duplicates +# due to plando, shops can be added out of order, so a set is the best way to make this work smoothly +def get_shop_num(world: "TunicWorld") -> int: + portal_num = -1 + for i in range(500): + if i + 1 not in world.used_shop_numbers: + portal_num = i + 1 + world.used_shop_numbers.add(portal_num) + break + if portal_num == -1: + raise Exception(f"TUNIC: {world.player_name} has plando'd too many shops.") + return portal_num + + # all shops are the same shop. however, you cannot get to all shops from the same shop entrance. # so, we need a bunch of shop regions that connect to the actual shop, but the actual shop cannot connect back -def create_shop_region(world: "TunicWorld", regions: Dict[str, Region]) -> None: - new_shop_name = f"Shop {world.shop_num}" +def create_shop_region(world: "TunicWorld", regions: Dict[str, Region], portal_num) -> None: + new_shop_name = f"Shop {portal_num}" world.er_regions[new_shop_name] = RegionInfo("Shop", dead_end=DeadEnd.all_cats) new_shop_region = Region(new_shop_name, world.player, world.multiworld) new_shop_region.connect(regions["Shop"]) regions[new_shop_name] = new_shop_region - world.shop_num += 1 +# for non-ER that uses the ER rules, we create a vanilla set of portal pairs def vanilla_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal, Portal]: portal_pairs: Dict[Portal, Portal] = {} # we don't want the zig skip exit for vanilla portals, since it shouldn't be considered for logic here @@ -135,9 +157,10 @@ def vanilla_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Por portal2_sdt = portal1.destination_scene() if portal2_sdt.startswith("Shop,"): - portal2 = Portal(name=f"Shop Portal {world.shop_num}", region=f"Shop {world.shop_num}", - destination="Previous Region", tag="_") - create_shop_region(world, regions) + portal_num = get_shop_num(world) + portal2 = Portal(name=f"Shop Portal {portal_num}", region=f"Shop {portal_num}", + destination=str(portal_num), tag="_", direction=Direction.none) + create_shop_region(world, regions, portal_num) for portal in portal_map: if portal.scene_destination() == portal2_sdt: @@ -152,7 +175,13 @@ def vanilla_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Por return portal_pairs -# pairing off portals, starting with dead ends +# the really long function that gives us our portal pairs +# before we start pairing, we separate the portals into dead ends and non-dead ends (two_plus) +# then, we do a few other important tasks to accommodate options and seed gropus +# first phase: pick a two_plus in a reachable region and non-reachable region and pair them +# repeat this phase until all regions are reachable +# second phase: randomly pair dead ends to random two_plus +# third phase: randomly pair the remaining two_plus to each other def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal, Portal]: portal_pairs: Dict[Portal, Portal] = {} dead_ends: List[Portal] = [] @@ -162,8 +191,9 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal laurels_zips = world.options.laurels_zips.value ice_grappling = world.options.ice_grappling.value ladder_storage = world.options.ladder_storage.value - fixed_shop = world.options.fixed_shop + entrance_layout = world.options.entrance_layout laurels_location = world.options.laurels_location + decoupled = world.options.decoupled traversal_reqs = deepcopy(traversal_requirements) has_laurels = True waterfall_plando = False @@ -174,7 +204,7 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal laurels_zips = seed_group["laurels_zips"] ice_grappling = seed_group["ice_grappling"] ladder_storage = seed_group["ladder_storage"] - fixed_shop = seed_group["fixed_shop"] + entrance_layout = seed_group["entrance_layout"] laurels_location = "10_fairies" if seed_group["laurels_at_10_fairies"] is True else False logic_tricks: Tuple[bool, int, int] = (laurels_zips, ice_grappling, ladder_storage) @@ -183,15 +213,18 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal if laurels_location == "10_fairies" and not world.using_ut: has_laurels = False - shop_count = 6 - if fixed_shop: - shop_count = 0 - else: - # if fixed shop is off, remove this portal - for portal in portal_map: - if portal.region == "Zig Skip Exit": - portal_map.remove(portal) - break + # for the direction pairs option with decoupled off + # tracks how many portals are in each direction in each list + two_plus_direction_tracker: Dict[int, int] = {direction: 0 for direction in range(8)} + dead_end_direction_tracker: Dict[int, int] = {direction: 0 for direction in range(8)} + + # for ensuring we have enough entrances in directions left that we don't leave dead ends without any + def too_few_portals_for_direction_pairs(direction: int, offset: int) -> bool: + if two_plus_direction_tracker[direction] <= (dead_end_direction_tracker[direction_pairs[direction]] + offset): + return False + if two_plus_direction_tracker[direction_pairs[direction]] <= dead_end_direction_tracker[direction] + offset: + return False + return True # If using Universal Tracker, restore portal_map. Could be cleaner, but it does not matter for UT even a little bit if world.using_ut: @@ -202,25 +235,59 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal dead_end_status = world.er_regions[portal.region].dead_end if dead_end_status == DeadEnd.free: two_plus.append(portal) + two_plus_direction_tracker[portal.direction] += 1 elif dead_end_status == DeadEnd.all_cats: dead_ends.append(portal) + dead_end_direction_tracker[portal.direction] += 1 elif dead_end_status == DeadEnd.restricted: if ice_grappling: two_plus.append(portal) + two_plus_direction_tracker[portal.direction] += 1 else: dead_ends.append(portal) + dead_end_direction_tracker[portal.direction] += 1 # these two get special handling elif dead_end_status == DeadEnd.special: if portal.region == "Secret Gathering Place": if laurels_location == "10_fairies": two_plus.append(portal) + two_plus_direction_tracker[portal.direction] += 1 else: dead_ends.append(portal) + dead_end_direction_tracker[portal.direction] += 1 + if portal.region == "Zig Skip Exit" and entrance_layout == EntranceLayout.option_fixed_shop: + # direction isn't meaningful here since zig skip cannot be in direction pairs mode + two_plus.append(portal) + + # now we generate the shops and add them to the dead ends list + shop_count = 6 + if entrance_layout == EntranceLayout.option_fixed_shop: + shop_count = 0 + else: + # if fixed shop is off, remove this portal + for portal in portal_map: if portal.region == "Zig Skip Exit": - if fixed_shop: - two_plus.append(portal) - else: - dead_ends.append(portal) + portal_map.remove(portal) + break + # need 8 shops with direction pairs or there won't be a valid set of pairs + if entrance_layout == EntranceLayout.option_direction_pairs: + shop_count = 8 + + # for universal tracker, we want to skip shop gen since it's essentially full plando + if world.using_ut: + shop_count = 0 + + for _ in range(shop_count): + # 6 of the shops have south exits, 2 of them have west exits + portal_num = get_shop_num(world) + shop_dir = Direction.south + if portal_num > 6: + shop_dir = Direction.west + shop_portal = Portal(name=f"Shop Portal {portal_num}", region=f"Shop {portal_num}", + destination=str(portal_num), tag="_", direction=shop_dir) + create_shop_region(world, regions, portal_num) + dead_ends.append(shop_portal) + dead_end_direction_tracker[shop_portal.direction] += 1 connected_regions: Set[str] = set() # make better start region stuff when/if implementing random start @@ -249,29 +316,68 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal portal_name2 = portal.name # connected_regions.update(add_dependent_regions(portal.region, logic_rules)) # shops have special handling - if not portal_name2 and portal2 == "Shop, Previous Region_": - portal_name2 = "Shop Portal" - plando_connections.append(PlandoConnection(portal_name1, portal_name2, "both")) + if not portal_name1 and portal1.startswith("Shop"): + # it should show up as "Shop, 1_" for shop 1 + portal_name1 = "Shop Portal " + str(portal1).split(", ")[1].split("_")[0] + if not portal_name2 and portal2.startswith("Shop"): + portal_name2 = "Shop Portal " + str(portal2).split(", ")[1].split("_")[0] + if world.options.decoupled: + plando_connections.append(PlandoConnection(portal_name1, portal_name2, "entrance")) + else: + plando_connections.append(PlandoConnection(portal_name1, portal_name2, "both")) + # put together the list of non-deadend regions non_dead_end_regions = set() for region_name, region_info in world.er_regions.items(): - if not region_info.dead_end: + # these are not real regions, they are just here to be descriptive + if region_info.is_fake_region or region_name == "Shop": + continue + # dead ends aren't real in decoupled + if decoupled: + non_dead_end_regions.add(region_name) + elif not region_info.dead_end: non_dead_end_regions.add(region_name) # if ice grappling to places is in logic, both places stop being dead ends elif region_info.dead_end == DeadEnd.restricted and ice_grappling: non_dead_end_regions.add(region_name) - # secret gathering place and zig skip get weird, special handling + # secret gathering place is treated as a non-dead end if 10 fairies is on to assure non-laurels access to it elif region_info.dead_end == DeadEnd.special: - if (region_name == "Secret Gathering Place" and laurels_location == "10_fairies") \ - or (region_name == "Zig Skip Exit" and fixed_shop): + if region_name == "Secret Gathering Place" and laurels_location == "10_fairies": non_dead_end_regions.add(region_name) + if decoupled: + # add the dead ends to the two plus list, since dead ends aren't real in decoupled + two_plus.extend(dead_ends) + dead_ends.clear() + # if decoupled is on, we make a second two_plus list, where the first is entrances and the second is exits + two_plus2 = two_plus.copy() + else: + # if decoupled is off, the two lists are the same list, since entrances and exits are intertwined + two_plus2 = two_plus + if plando_connections: - for connection in plando_connections: + if decoupled: + modified_plando_connections = plando_connections.copy() + for index, cxn in enumerate(modified_plando_connections): + # it's much easier if we split both-direction portals into two one-ways in decoupled + if cxn.direction == "both": + replacement1 = PlandoConnection(cxn.entrance, cxn.exit, "entrance") + replacement2 = PlandoConnection(cxn.exit, cxn.entrance, "entrance") + modified_plando_connections.remove(cxn) + modified_plando_connections.insert(index, replacement1) + modified_plando_connections.append(replacement2) + else: + modified_plando_connections = plando_connections + + connected_shop_portal1s: Set[int] = set() + connected_shop_portal2s: Set[int] = set() + for connection in modified_plando_connections: p_entrance = connection.entrance p_exit = connection.exit # if you plando secret gathering place, need to know that during portal pairing - if "Secret Gathering Place Exit" in [p_entrance, p_exit]: + if p_exit == "Secret Gathering Place Exit": + waterfall_plando = True + if p_entrance == "Secret Gathering Place Exit" and not decoupled: waterfall_plando = True portal1_dead_end = True portal2_dead_end = True @@ -279,118 +385,186 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal portal1 = None portal2 = None - # search two_plus for both at once + # search the two_plus lists (or list) for the portals for portal in two_plus: if p_entrance == portal.name: portal1 = portal portal1_dead_end = False + break + for portal in two_plus2: if p_exit == portal.name: portal2 = portal portal2_dead_end = False + break # search dead_ends individually since we can't really remove items from two_plus during the loop if portal1: two_plus.remove(portal1) else: # if not both, they're both dead ends - if not portal2: + if not portal2 and not decoupled: if world.options.entrance_rando.value not in EntranceRando.options.values(): raise Exception(f"Tunic ER seed group {world.options.entrance_rando.value} paired a dead " "end to a dead end in their plando connections.") else: raise Exception(f"{player_name} paired a dead end to a dead end in their " - "plando connections.") + f"plando connections -- {connection.entrance} to {connection.exit}") for portal in dead_ends: if p_entrance == portal.name: portal1 = portal + dead_ends.remove(portal1) break - if not portal1: - raise Exception(f"Could not find entrance named {p_entrance} for " - f"plando connections in {player_name}'s YAML.") - dead_ends.remove(portal1) + else: + if p_entrance.startswith("Shop Portal "): + portal_num = int(p_entrance.split("Shop Portal ")[-1]) + # shops 1-6 are south, 7 and 8 are east, and after that it just breaks direction pairs + if portal_num <= 6: + pdir = Direction.south + elif portal_num in [7, 8]: + pdir = Direction.east + else: + pdir = Direction.none + portal1 = Portal(name=f"Shop Portal {portal_num}", region=f"Shop {portal_num}", + destination=str(portal_num), tag="_", direction=pdir) + connected_shop_portal1s.add(portal_num) + if portal_num not in world.used_shop_numbers: + create_shop_region(world, regions, portal_num) + world.used_shop_numbers.add(portal_num) + if decoupled and portal_num not in connected_shop_portal2s: + two_plus2.append(portal1) + non_dead_end_regions.add(portal1.region) + else: + raise Exception(f"Could not find entrance named {p_entrance} for " + f"plando connections in {player_name}'s YAML.") if portal2: - two_plus.remove(portal2) + two_plus2.remove(portal2) else: for portal in dead_ends: if p_exit == portal.name: portal2 = portal + dead_ends.remove(portal2) break - # if it's not a dead end, it might be a shop - if p_exit == "Shop Portal": - portal2 = Portal(name=f"Shop Portal {world.shop_num}", region=f"Shop {world.shop_num}", - destination="Previous Region", tag="_") - create_shop_region(world, regions) - shop_count -= 1 - # need to maintain an even number of portals total - if shop_count < 0: - shop_count += 2 - # and if it's neither shop nor dead end, it just isn't correct + # if it's not a dead end, maybe it's a plando'd shop portal that doesn't normally exist else: if not portal2: - raise Exception(f"Could not find entrance named {p_exit} for " - f"plando connections in {player_name}'s YAML.\n" - f"If you are using Universal Tracker, the most likely reason for this error " - f"is that the host generated with a newer version of the APWorld.\n" - f"Please check the TUNIC Randomizer Github and place the newest APWorld in your " - f"custom_worlds folder, and remove the one in lib/worlds if there is one there.") - dead_ends.remove(portal2) + if p_exit.startswith("Shop Portal "): + portal_num = int(p_exit.split("Shop Portal ")[-1]) + if portal_num <= 6: + pdir = Direction.south + elif portal_num in [7, 8]: + pdir = Direction.east + else: + pdir = Direction.none + portal2 = Portal(name=f"Shop Portal {portal_num}", region=f"Shop {portal_num}", + destination=str(portal_num), tag="_", direction=pdir) + connected_shop_portal2s.add(portal_num) + if portal_num not in world.used_shop_numbers: + create_shop_region(world, regions, portal_num) + world.used_shop_numbers.add(portal_num) + if decoupled and portal_num not in connected_shop_portal1s: + two_plus.append(portal2) + non_dead_end_regions.add(portal2.region) + else: + raise Exception(f"Could not find entrance named {p_exit} for " + f"plando connections in {player_name}'s YAML.") - # update the traversal chart to say you can get from portal1's region to portal2's and vice versa - if not portal1_dead_end and not portal2_dead_end: - traversal_reqs.setdefault(portal1.region, dict())[portal2.region] = [] - traversal_reqs.setdefault(portal2.region, dict())[portal1.region] = [] + # if we're doing decoupled, we don't need to do complex checks + if decoupled: + # we turn any plando that uses "exit" to use "entrance" instead + traversal_reqs.setdefault(portal1.region, dict())[get_portal_outlet_region(portal2, world)] = [] + # outside decoupled, we want to use what we were doing before decoupled got added + else: + # update the traversal chart to say you can get from portal1's region to portal2's and vice versa + if not portal1_dead_end and not portal2_dead_end: + traversal_reqs.setdefault(portal1.region, dict())[get_portal_outlet_region(portal2, world)] = [] + traversal_reqs.setdefault(portal2.region, dict())[get_portal_outlet_region(portal1, world)] = [] - if (portal1.region == "Zig Skip Exit" and (portal2_dead_end or portal2.region == "Secret Gathering Place") - or portal2.region == "Zig Skip Exit" and (portal1_dead_end or portal1.region == "Secret Gathering Place")): - if world.options.entrance_rando.value not in EntranceRando.options.values(): - raise Exception(f"Tunic ER seed group {world.options.entrance_rando.value} paired a dead " - "end to a dead end in their plando connections.") - else: - raise Exception(f"{player_name} paired a dead end to a dead end in their " - "plando connections.") - - if (portal1.region == "Secret Gathering Place" and (portal2_dead_end or portal2.region == "Zig Skip Exit") - or portal2.region == "Secret Gathering Place" and (portal1_dead_end or portal1.region == "Zig Skip Exit")): - # need to make sure you didn't pair this to a dead end or zig skip - if portal1_dead_end or portal2_dead_end or \ - portal1.region == "Zig Skip Exit" or portal2.region == "Zig Skip Exit": + if (portal1.region == "Zig Skip Exit" and (portal2_dead_end or portal2.region == "Secret Gathering Place") + or portal2.region == "Zig Skip Exit" and (portal1_dead_end or portal1.region == "Secret Gathering Place")): if world.options.entrance_rando.value not in EntranceRando.options.values(): raise Exception(f"Tunic ER seed group {world.options.entrance_rando.value} paired a dead " "end to a dead end in their plando connections.") else: raise Exception(f"{player_name} paired a dead end to a dead end in their " "plando connections.") + + if (portal1.region == "Secret Gathering Place" and (portal2_dead_end or portal2.region == "Zig Skip Exit") + or portal2.region == "Secret Gathering Place" and (portal1_dead_end or portal1.region == "Zig Skip Exit")): + # need to make sure you didn't pair this to a dead end or zig skip + if portal1_dead_end or portal2_dead_end or \ + portal1.region == "Zig Skip Exit" or portal2.region == "Zig Skip Exit": + if world.options.entrance_rando.value not in EntranceRando.options.values(): + raise Exception(f"Tunic ER seed group {world.options.entrance_rando.value} paired a dead " + "end to a dead end in their plando connections.") + else: + raise Exception(f"{player_name} paired a dead end to a dead end in their " + "plando connections.") + # okay now that we're done with all of that nonsense, we can finally make the portal pair portal_pairs[portal1] = portal2 + if portal1_dead_end: + dead_end_direction_tracker[portal1.direction] -= 1 + else: + two_plus_direction_tracker[portal1.direction] -= 1 + if portal2_dead_end: + dead_end_direction_tracker[portal2.direction] -= 1 + else: + two_plus_direction_tracker[portal2.direction] -= 1 + # if we have plando connections, our connected regions may change somewhat connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic_tricks) - if fixed_shop and not world.using_ut: - portal1 = None + # if there are an odd number of shops after plando, add another one, except in decoupled where it doesn't matter + if not decoupled and len(world.used_shop_numbers) % 2 == 1: + if entrance_layout == EntranceLayout.option_direction_pairs: + raise Exception(f"TUNIC: {world.player_name} plando'd too many shops for the Direction Pairs option.") + portal_num = get_shop_num(world) + shop_portal = Portal(name=f"Shop Portal {portal_num}", region=f"Shop {portal_num}", + destination=str(portal_num), tag="_", direction=Direction.none) + create_shop_region(world, regions, portal_num) + dead_ends.append(shop_portal) + + if entrance_layout == EntranceLayout.option_fixed_shop and not world.using_ut: + windmill = None for portal in two_plus: if portal.scene_destination() == "Overworld Redux, Windmill_": - portal1 = portal + windmill = portal break - if not portal1: - raise Exception(f"Failed to do Fixed Shop option. " - f"Did {player_name} plando connection the Windmill Shop entrance?") + if not windmill: + raise Exception(f"Failed to do Fixed Shop option for Entrance Layout. " + f"Did {player_name} plando the Windmill Shop entrance?") - portal2 = Portal(name=f"Shop Portal {world.shop_num}", region=f"Shop {world.shop_num}", - destination="Previous Region", tag="_") - create_shop_region(world, regions) + portal_num = get_shop_num(world) + shop = Portal(name=f"Shop Portal {portal_num}", region=f"Shop {portal_num}", + destination=str(portal_num), tag="_", direction=Direction.south) + create_shop_region(world, regions, portal_num) - portal_pairs[portal1] = portal2 - two_plus.remove(portal1) + portal_pairs[windmill] = shop + two_plus.remove(windmill) + if decoupled: + two_plus.append(shop) + non_dead_end_regions.add(shop.region) + connected_regions.add(shop.region) - random_object: Random = world.random # use the seed given in the options to shuffle the portals if isinstance(world.options.entrance_rando.value, str): random_object = Random(world.options.entrance_rando.value) + else: + random_object: Random = world.random + # we want to start by making sure every region is accessible random_object.shuffle(two_plus) - check_success = 0 + + # this is a backup in case we run into that rare direction pairing failure + # so that we don't have to redo the plando bit basically + backup_connected_regions = connected_regions.copy() + backup_portal_pairs = portal_pairs.copy() + backup_two_plus = two_plus.copy() + backup_two_plus_direction_tracker = two_plus_direction_tracker.copy() + rare_failure_count = 0 + portal1 = None portal2 = None previous_conn_num = 0 @@ -403,96 +577,182 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal # should, hopefully, only ever occur if someone plandos connections poorly if previous_conn_num == len(connected_regions): fail_count += 1 - if fail_count >= 500: + if fail_count > 500: raise Exception(f"Failed to pair regions. Check plando connections for {player_name} for errors. " - "Unconnected regions:", non_dead_end_regions - connected_regions) + f"Unconnected regions: {non_dead_end_regions - connected_regions}.\n" + f"Unconnected portals: {[portal.name for portal in two_plus]}") + if (fail_count > 100 and not decoupled + and (world.options.entrance_layout == EntranceLayout.option_direction_pairs or waterfall_plando)): + # in direction pairs, we may run into a case where we run out of pairable directions + # since we need to ensure the dead ends will have something to connect to + # or if fairy cave is plando'd, it may run into an issue where it is trying to get access to 2 separate + # areas at once to give access to laurels + # so, this is basically just resetting entrance pairing + # this should be very rare, so this fail-safe shouldn't be covering up for an actual solution + # this should never happen in decoupled, since it's entirely too flexible for that + portal_pairs = backup_portal_pairs.copy() + two_plus = two_plus2 = backup_two_plus.copy() + two_plus_direction_tracker = backup_two_plus_direction_tracker.copy() + random_object.shuffle(two_plus) + connected_regions = backup_connected_regions.copy() + rare_failure_count += 1 + fail_count = 0 + + if rare_failure_count > 100: + raise Exception(f"Failed to pair regions due to rare pairing issues for {player_name}. " + f"Unconnected regions: {non_dead_end_regions - connected_regions}.\n" + f"Unconnected portals: {[portal.name for portal in two_plus]}") else: fail_count = 0 previous_conn_num = len(connected_regions) # find a portal in a connected region - if check_success == 0: - for portal in two_plus: - if portal.region in connected_regions: - portal1 = portal - two_plus.remove(portal) - check_success = 1 - break + for portal in two_plus: + if portal.region in connected_regions: + # if there's more dead ends of a direction than two plus of the opposite direction, + # then we'll run out of viable connections for those dead ends later + # decoupled does not have this issue since dead ends aren't real in decoupled + if not decoupled and entrance_layout == EntranceLayout.option_direction_pairs: + if not too_few_portals_for_direction_pairs(portal.direction, 0): + continue - # then we find a portal in an inaccessible region - if check_success == 1: - for portal in two_plus: - if portal.region not in connected_regions: - # if secret gathering place happens to get paired really late, you can end up running out - if not has_laurels and len(two_plus) < 80: - # if you plando'd secret gathering place with laurels at 10 fairies, you're the reason for this - if waterfall_plando: - cr = connected_regions.copy() - cr.add(portal.region) - if "Secret Gathering Place" not in update_reachable_regions(cr, traversal_reqs, has_laurels, logic_tricks): - continue - # if not waterfall_plando, then we just want to pair secret gathering place now - elif portal.region != "Secret Gathering Place": + portal1 = portal + two_plus.remove(portal) + break + if not portal1: + raise Exception("TUNIC: Failed to pair portals at first part of first phase.") + + # then we find a portal in an unconnected region + for portal in two_plus2: + if portal.region not in connected_regions: + # if secret gathering place happens to get paired really late, you can end up running out + if not has_laurels and len(two_plus2) < 80: + # if you plando'd secret gathering place with laurels at 10 fairies, you're the reason for this + if waterfall_plando: + cr = connected_regions.copy() + cr.add(portal.region) + if "Secret Gathering Place" not in update_reachable_regions(cr, traversal_reqs, has_laurels, logic_tricks): continue - portal2 = portal - connected_regions.add(portal.region) - two_plus.remove(portal) - check_success = 2 - break + # if not waterfall_plando, then we just want to pair secret gathering place now + elif portal.region != "Secret Gathering Place": + continue + + # if they're not facing opposite directions, just continue + if entrance_layout == EntranceLayout.option_direction_pairs and not verify_direction_pair(portal, portal1): + continue + + # if you have direction pairs, we need to make sure we don't run out of spots for problem portals + # this cuts down on using the failsafe significantly + if not decoupled and entrance_layout == EntranceLayout.option_direction_pairs: + should_continue = False + # these portals are weird since they're one-ways essentially + # we need to make sure they are connected in this first phase + south_problems = ["Ziggurat Upper to Ziggurat Entry Hallway", + "Ziggurat Tower to Ziggurat Upper", "Forest Belltower to Guard Captain Room"] + if (portal.direction == Direction.south and portal.name not in south_problems + and not too_few_portals_for_direction_pairs(portal.direction, 3)): + for test_portal in two_plus: + if test_portal.name in south_problems: + should_continue = True + # at risk of connecting frog's domain entry ladder to librarian exit + if (portal.direction == Direction.ladder_down + or portal.direction == Direction.ladder_up and portal.name != "Frog's Domain Ladder Exit" + and not too_few_portals_for_direction_pairs(portal.direction, 1)): + for test_portal in two_plus: + if test_portal.name == "Frog's Domain Ladder Exit": + should_continue = True + if should_continue: + continue + + portal2 = portal + connected_regions.add(get_portal_outlet_region(portal, world)) + two_plus2.remove(portal) + break + + if not portal2: + if entrance_layout == EntranceLayout.option_direction_pairs or waterfall_plando: + # portal1 doesn't have a valid direction pair yet, throw it back and start over + two_plus.append(portal1) + continue + else: + raise Exception(f"TUNIC: Failed to pair portals at second part of first phase for {world.player_name}.") # once we have both portals, connect them and add the new region(s) to connected_regions - if check_success == 2: - if "Secret Gathering Place" in connected_regions: - has_laurels = True - connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic_tricks) - portal_pairs[portal1] = portal2 - check_success = 0 - random_object.shuffle(two_plus) + if not has_laurels and "Secret Gathering Place" in connected_regions: + has_laurels = True + connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic_tricks) - # for universal tracker, we want to skip shop gen - if world.using_ut: - shop_count = 0 - - for i in range(shop_count): - portal1 = two_plus.pop() - if portal1 is None: - raise Exception("TUNIC: Too many shops in the pool, or something else went wrong.") - portal2 = Portal(name=f"Shop Portal {world.shop_num}", region=f"Shop {world.shop_num}", - destination="Previous Region", tag="_") - create_shop_region(world, regions) - portal_pairs[portal1] = portal2 + two_plus_direction_tracker[portal1.direction] -= 1 + two_plus_direction_tracker[portal2.direction] -= 1 + portal1 = None + portal2 = None + random_object.shuffle(two_plus) + if two_plus != two_plus2: + random_object.shuffle(two_plus2) # connect dead ends to random non-dead ends - # none of the key events are in dead ends, so we don't need to do gate_before_switch + # there are no dead ends in decoupled while len(dead_ends) > 0: if world.using_ut: break - portal1 = two_plus.pop() - portal2 = dead_ends.pop() - portal_pairs[portal1] = portal2 + portal2 = dead_ends[0] + for portal in two_plus: + if entrance_layout == EntranceLayout.option_direction_pairs and not verify_direction_pair(portal, portal2): + continue + if entrance_layout == EntranceLayout.option_fixed_shop and portal.region == "Zig Skip Exit": + continue + portal1 = portal + portal_pairs[portal1] = portal2 + two_plus.remove(portal1) + dead_ends.remove(portal2) + break + else: + raise Exception(f"Failed to pair {portal2.name} with anything in two_plus for player {world.player_name}.") + # then randomly connect the remaining portals to each other - # every region is accessible, so gate_before_switch is not necessary - while len(two_plus) > 1: + final_pair_number = 0 + while len(two_plus) > 0: if world.using_ut: break - portal1 = two_plus.pop() - portal2 = two_plus.pop() + final_pair_number += 1 + if final_pair_number > 10000: + raise Exception(f"Failed to pair portals while pairing the final entrances off to each other. " + f"Remaining portals in two_plus: {[portal.name for portal in two_plus]}. " + f"Remaining portals in two_plus2: {[portal.name for portal in two_plus2]}.") + portal1 = two_plus[0] + two_plus.remove(portal1) + portal2 = None + if entrance_layout != EntranceLayout.option_direction_pairs: + portal2 = two_plus2.pop() + else: + for portal in two_plus2: + if verify_direction_pair(portal1, portal): + portal2 = portal + two_plus2.remove(portal2) + break + if portal2 is None: + raise Exception("Something went wrong with the remaining two plus portals. Contact the TUNIC rando devs.") portal_pairs[portal1] = portal2 - if len(two_plus) == 1: - raise Exception("two plus had an odd number of portals, investigate this. last portal is " + two_plus[0].name) + if len(two_plus2) > 0: + raise Exception(f"TUNIC: Something went horribly wrong in ER for {world.player_name}. " + f"Please contact the TUNIC rando devs.") return portal_pairs # loop through our list of paired portals and make two-way connections -def create_randomized_entrances(portal_pairs: Dict[Portal, Portal], regions: Dict[str, Region]) -> None: +def create_randomized_entrances(world: "TunicWorld", portal_pairs: Dict[Portal, Portal], regions: Dict[str, Region]) -> None: for portal1, portal2 in portal_pairs.items(): - region1 = regions[portal1.region] - region2 = regions[portal2.region] - region1.connect(connecting_region=region2, name=portal1.name) - region2.connect(connecting_region=region1, name=portal2.name) + # connect to the outlet region if there is one, if not connect to the actual region + regions[portal1.region].connect( + connecting_region=regions[get_portal_outlet_region(portal2, world)], + name=portal1.name) + if not world.options.decoupled or not world.options.entrance_rando: + regions[portal2.region].connect( + connecting_region=regions[get_portal_outlet_region(portal1, world)], + name=portal2.name) def update_reachable_regions(connected_regions: Set[str], traversal_reqs: Dict[str, Dict[str, List[List[str]]]], @@ -541,22 +801,58 @@ def update_reachable_regions(connected_regions: Set[str], traversal_reqs: Dict[s return connected_regions +# which directions are opposites +direction_pairs: Dict[int, int] = { + Direction.north: Direction.south, + Direction.south: Direction.north, + Direction.east: Direction.west, + Direction.west: Direction.east, + Direction.ladder_up: Direction.ladder_down, + Direction.ladder_down: Direction.ladder_up, + Direction.floor: Direction.floor, +} + + +# verify that two portals are in compatible directions +def verify_direction_pair(portal1: Portal, portal2: Portal) -> bool: + return portal1.direction == direction_pairs[portal2.direction] + + +# verify that two plando'd portals are in compatible directions +def verify_plando_directions(connection: PlandoConnection) -> bool: + entrance_portal = None + exit_portal = None + for portal in portal_mapping: + if connection.entrance == portal.name: + entrance_portal = portal + if connection.exit == portal.name: + exit_portal = portal + if entrance_portal and exit_portal: + break + # neither of these are shops, so verify the pair + if entrance_portal and exit_portal: + return verify_direction_pair(entrance_portal, exit_portal) + # this is two shop portals, they can never pair directions + elif not entrance_portal and not exit_portal: + return False + # if one of them is none, it's a shop, which has two possible directions + elif not entrance_portal: + return exit_portal.direction in [Direction.north, Direction.east] + elif not exit_portal: + return entrance_portal.direction in [Direction.north, Direction.east] + else: + # shouldn't be reachable, more of a just in case + raise Exception("Something went very wrong with verify_plando_directions") + + # sort the portal dict by the name of the first portal, referring to the portal order in the master portal list -def sort_portals(portal_pairs: Dict[Portal, Portal]) -> Dict[str, str]: +def sort_portals(portal_pairs: Dict[Portal, Portal], world: "TunicWorld") -> Dict[str, str]: sorted_pairs: Dict[str, str] = {} reference_list: List[str] = [portal.name for portal in portal_mapping] - reference_list.append("Shop Portal") - # note: this is not necessary yet since the shop portals aren't numbered yet -- they will be when decoupled happens # due to plando, there can be a variable number of shops - # I could either do it like this, or just go up to like 200, this seemed better - # shop_count = 0 - # for portal1, portal2 in portal_pairs.items(): - # if portal1.name.startswith("Shop"): - # shop_count += 1 - # if portal2.name.startswith("Shop"): - # shop_count += 1 - # reference_list.extend([f"Shop Portal {i + 1}" for i in range(shop_count)]) + largest_shop_number = max(world.used_shop_numbers) + reference_list.extend([f"Shop Portal {i + 1}" for i in range(largest_shop_number)]) for name in reference_list: for portal1, portal2 in portal_pairs.items(): diff --git a/worlds/tunic/options.py b/worlds/tunic/options.py index c17b085b..e3fed5b5 100644 --- a/worlds/tunic/options.py +++ b/worlds/tunic/options.py @@ -5,7 +5,7 @@ from typing import Dict, Any, TYPE_CHECKING from decimal import Decimal, ROUND_HALF_UP from Options import (DefaultOnToggle, Toggle, StartInventoryPool, Choice, Range, TextChoice, PlandoConnections, - PerGameCommonOptions, OptionGroup, Visibility, NamedRange) + PerGameCommonOptions, OptionGroup, Removed, Visibility, NamedRange) from .er_data import portal_mapping if TYPE_CHECKING: from . import TunicWorld @@ -147,14 +147,42 @@ class EntranceRando(TextChoice): class FixedShop(Toggle): """ - Forces the Windmill entrance to lead to a shop, and removes the remaining shops from the pool. - Adds another entrance in Rooted Ziggurat Lower to keep an even number of entrances. - Has no effect if Entrance Rando is not enabled. + This option has been superseded by the Entrance Layout option. + If enabled, it will override the Entrance Layout option. + This is kept to keep older yamls working, and will be removed at a later date. """ + visibility = Visibility.none internal_name = "fixed_shop" display_name = "Fewer Shops in Entrance Rando" +class EntranceLayout(Choice): + """ + Decide how the Entrance Randomizer chooses how to pair the entrances. + Standard: Entrances are randomly connected. There are 6 shops in the pool with this option. + Fixed Shop: Forces the Windmill entrance to lead to a shop, and removes the other shops from the pool. + Adds another entrance in Rooted Ziggurat Lower to keep an even number of entrances. + Direction Pairs: Entrances facing opposite directions are paired together. There are 8 shops in the pool with this option. + Note: For seed groups, if one player in a group chooses Fixed Shop and another chooses Direction Pairs, it will error out. + Either of these options will override Standard within a seed group. + """ + internal_name = "entrance_layout" + display_name = "Entrance Layout" + option_standard = 0 + option_fixed_shop = 1 + option_direction_pairs = 2 + default = 0 + + +class Decoupled(Toggle): + """ + Decouple the entrances, so that when you go from one entrance to another, the return trip won't necessarily bring you back to the same place. + Note: For seed groups, all players in the group must have this option enabled or disabled. + """ + internal_name = "decoupled" + display_name = "Decoupled Entrances" + + class LaurelsLocation(Choice): """ Force the Hero's Laurels to be placed at a location in your world. @@ -210,13 +238,22 @@ class LocalFill(NamedRange): class TunicPlandoConnections(PlandoConnections): """ Generic connection plando. Format is: - - entrance: "Entrance Name" - exit: "Exit Name" + - entrance: Entrance Name + exit: Exit Name + direction: Direction percentage: 100 + Direction must be one of entrance, exit, or both, and defaults to both if omitted. + Direction entrance means the entrance leads to the exit. Direction exit means the exit leads to the entrance. + If you do not have Decoupled enabled, you do not need the direction line, as it will only use both. Percentage is an integer from 0 to 100 which determines whether that connection will be made. Defaults to 100 if omitted. + If the Entrance Layout option is set to Standard or Fixed Shop, you can plando multiple shops. + If the Entrance Layout option is set to Direction Pairs, your plando connections must be facing opposite directions. + Shop Portal 1-6 are South portals, and Shop Portal 7-8 are West portals. + This option does nothing if Entrance Rando is disabled. """ - entrances = {*(portal.name for portal in portal_mapping), "Shop", "Shop Portal"} - exits = {*(portal.name for portal in portal_mapping), "Shop", "Shop Portal"} + shops = {f"Shop Portal {i + 1}" for i in range(500)} + entrances = {portal.name for portal in portal_mapping}.union(shops) + exits = {portal.name for portal in portal_mapping}.union(shops) duplicate_exits = True @@ -329,6 +366,7 @@ class TunicOptions(PerGameCommonOptions): start_with_sword: StartWithSword keys_behind_bosses: KeysBehindBosses ability_shuffling: AbilityShuffling + fool_traps: FoolTraps laurels_location: LaurelsLocation @@ -343,7 +381,9 @@ class TunicOptions(PerGameCommonOptions): local_fill: LocalFill entrance_rando: EntranceRando - fixed_shop: FixedShop + entrance_layout: EntranceLayout + decoupled: Decoupled + plando_connections: TunicPlandoConnections combat_logic: CombatLogic lanternless: Lanternless @@ -353,9 +393,8 @@ class TunicOptions(PerGameCommonOptions): ladder_storage: LadderStorage ladder_storage_without_items: LadderStorageWithoutItems - plando_connections: TunicPlandoConnections - - logic_rules: LogicRules + fixed_shop: FixedShop # will be removed at a later date + logic_rules: Removed # fully removed in the direction pairs update tunic_option_groups = [ @@ -372,8 +411,14 @@ tunic_option_groups = [ LaurelsZips, IceGrappling, LadderStorage, - LadderStorageWithoutItems - ]) + LadderStorageWithoutItems, + ]), + OptionGroup("Entrance Randomizer", [ + EntranceRando, + EntranceLayout, + Decoupled, + TunicPlandoConnections, + ]), ] tunic_option_presets: Dict[str, Dict[str, Any]] = { diff --git a/worlds/tunic/rules.py b/worlds/tunic/rules.py index 2c5abb42..52d5c42e 100644 --- a/worlds/tunic/rules.py +++ b/worlds/tunic/rules.py @@ -1,5 +1,4 @@ from typing import Dict, TYPE_CHECKING -from decimal import Decimal, ROUND_HALF_UP from worlds.generic.Rules import set_rule, forbid_item, add_rule from BaseClasses import CollectionState @@ -157,8 +156,8 @@ def set_region_rules(world: "TunicWorld") -> None: if options.ladder_storage >= LadderStorage.option_medium: # ls at any ladder in a safe spot in quarry to get to the monastery rope entrance - world.get_region("Quarry Back").connect(world.get_region("Monastery"), - rule=lambda state: can_ladder_storage(state, world)) + add_rule(world.get_entrance(entrance_name="Quarry Back -> Monastery"), + rule=lambda state: can_ladder_storage(state, world)) def set_location_rules(world: "TunicWorld") -> None: diff --git a/worlds/tunic/test/test_access.py b/worlds/tunic/test/test_access.py index 24551a13..6a26180c 100644 --- a/worlds/tunic/test/test_access.py +++ b/worlds/tunic/test/test_access.py @@ -78,7 +78,8 @@ class TestERSpecial(TunicTestBase): options = {options.EntranceRando.internal_name: options.EntranceRando.option_yes, options.AbilityShuffling.internal_name: options.AbilityShuffling.option_true, options.HexagonQuest.internal_name: options.HexagonQuest.option_false, - options.FixedShop.internal_name: options.FixedShop.option_false, + options.CombatLogic.internal_name: options.CombatLogic.option_off, + options.EntranceLayout.internal_name: options.EntranceLayout.option_fixed_shop, options.IceGrappling.internal_name: options.IceGrappling.option_easy, "plando_connections": [ { @@ -126,3 +127,262 @@ class TestLadderStorage(TunicTestBase): self.assertFalse(self.can_reach_location("Fortress Courtyard - Page Near Cave")) self.collect_by_name(["Pages 24-25 (Prayer)"]) self.assertTrue(self.can_reach_location("Fortress Courtyard - Page Near Cave")) + + +# check that it still functions if in decoupled and every single normal entrance leads to a shop +class TestERDecoupledPlando(TunicTestBase): + options = {options.EntranceRando.internal_name: options.EntranceRando.option_yes, + options.Decoupled.internal_name: options.Decoupled.option_true, + "plando_connections": [ + {"entrance": "Stick House Entrance", "exit": "Shop Portal 1", "direction": "entrance"}, + {"entrance": "Windmill Entrance", "exit": "Shop Portal 2", "direction": "entrance"}, + {"entrance": "Well Ladder Entrance", "exit": "Shop Portal 3", "direction": "entrance"}, + {"entrance": "Entrance to Well from Well Rail", "exit": "Shop Portal 4", "direction": "entrance"}, + {"entrance": "Old House Door Entrance", "exit": "Shop Portal 5", "direction": "entrance"}, + {"entrance": "Old House Waterfall Entrance", "exit": "Shop Portal 6", "direction": "entrance"}, + {"entrance": "Entrance to Furnace from Well Rail", "exit": "Shop Portal 7", "direction": "entrance"}, + {"entrance": "Entrance to Furnace under Windmill", "exit": "Shop Portal 8", "direction": "entrance"}, + {"entrance": "Entrance to Furnace near West Garden", "exit": "Shop Portal 9", + "direction": "entrance"}, + {"entrance": "Entrance to Furnace from Beach", "exit": "Shop Portal 10", "direction": "entrance"}, + {"entrance": "Caustic Light Cave Entrance", "exit": "Shop Portal 11", "direction": "entrance"}, + {"entrance": "Swamp Upper Entrance", "exit": "Shop Portal 12", "direction": "entrance"}, + {"entrance": "Swamp Lower Entrance", "exit": "Shop Portal 13", "direction": "entrance"}, + {"entrance": "Ruined Passage Not-Door Entrance", "exit": "Shop Portal 14", "direction": "entrance"}, + {"entrance": "Ruined Passage Door Entrance", "exit": "Shop Portal 15", "direction": "entrance"}, + {"entrance": "Atoll Upper Entrance", "exit": "Shop Portal 16", "direction": "entrance"}, + {"entrance": "Atoll Lower Entrance", "exit": "Shop Portal 17", "direction": "entrance"}, + {"entrance": "Special Shop Entrance", "exit": "Shop Portal 18", "direction": "entrance"}, + {"entrance": "Maze Cave Entrance", "exit": "Shop Portal 19", "direction": "entrance"}, + {"entrance": "West Garden Entrance near Belltower", "exit": "Shop Portal 20", + "direction": "entrance"}, + {"entrance": "West Garden Entrance from Furnace", "exit": "Shop Portal 21", "direction": "entrance"}, + {"entrance": "West Garden Laurels Entrance", "exit": "Shop Portal 22", "direction": "entrance"}, + {"entrance": "Temple Door Entrance", "exit": "Shop Portal 23", "direction": "entrance"}, + {"entrance": "Temple Rafters Entrance", "exit": "Shop Portal 24", "direction": "entrance"}, + {"entrance": "Ruined Shop Entrance", "exit": "Shop Portal 25", "direction": "entrance"}, + {"entrance": "Patrol Cave Entrance", "exit": "Shop Portal 26", "direction": "entrance"}, + {"entrance": "Hourglass Cave Entrance", "exit": "Shop Portal 27", "direction": "entrance"}, + {"entrance": "Changing Room Entrance", "exit": "Shop Portal 28", "direction": "entrance"}, + {"entrance": "Cube Cave Entrance", "exit": "Shop Portal 29", "direction": "entrance"}, + {"entrance": "Stairs from Overworld to Mountain", "exit": "Shop Portal 30", "direction": "entrance"}, + {"entrance": "Overworld to Fortress", "exit": "Shop Portal 31", "direction": "entrance"}, + {"entrance": "Fountain HC Door Entrance", "exit": "Shop Portal 32", "direction": "entrance"}, + {"entrance": "Southeast HC Door Entrance", "exit": "Shop Portal 33", "direction": "entrance"}, + {"entrance": "Overworld to Quarry Connector", "exit": "Shop Portal 34", "direction": "entrance"}, + {"entrance": "Dark Tomb Main Entrance", "exit": "Shop Portal 35", "direction": "entrance"}, + {"entrance": "Overworld to Forest Belltower", "exit": "Shop Portal 36", "direction": "entrance"}, + {"entrance": "Town to Far Shore", "exit": "Shop Portal 37", "direction": "entrance"}, + {"entrance": "Spawn to Far Shore", "exit": "Shop Portal 38", "direction": "entrance"}, + {"entrance": "Secret Gathering Place Entrance", "exit": "Shop Portal 39", "direction": "entrance"}, + {"entrance": "Secret Gathering Place Exit", "exit": "Shop Portal 40", "direction": "entrance"}, + {"entrance": "Windmill Exit", "exit": "Shop Portal 41", "direction": "entrance"}, + {"entrance": "Windmill Shop", "exit": "Shop Portal 42", "direction": "entrance"}, + {"entrance": "Old House Door Exit", "exit": "Shop Portal 43", "direction": "entrance"}, + {"entrance": "Old House to Glyph Tower", "exit": "Shop Portal 44", "direction": "entrance"}, + {"entrance": "Old House Waterfall Exit", "exit": "Shop Portal 45", "direction": "entrance"}, + {"entrance": "Glyph Tower Exit", "exit": "Shop Portal 46", "direction": "entrance"}, + {"entrance": "Changing Room Exit", "exit": "Shop Portal 47", "direction": "entrance"}, + {"entrance": "Fountain HC Room Exit", "exit": "Shop Portal 48", "direction": "entrance"}, + {"entrance": "Cube Cave Exit", "exit": "Shop Portal 49", "direction": "entrance"}, + {"entrance": "Guard Patrol Cave Exit", "exit": "Shop Portal 50", "direction": "entrance"}, + {"entrance": "Ruined Shop Exit", "exit": "Shop Portal 51", "direction": "entrance"}, + {"entrance": "Furnace Exit towards Well", "exit": "Shop Portal 52", "direction": "entrance"}, + {"entrance": "Furnace Exit to Dark Tomb", "exit": "Shop Portal 53", "direction": "entrance"}, + {"entrance": "Furnace Exit towards West Garden", "exit": "Shop Portal 54", "direction": "entrance"}, + {"entrance": "Furnace Exit to Beach", "exit": "Shop Portal 55", "direction": "entrance"}, + {"entrance": "Furnace Exit under Windmill", "exit": "Shop Portal 56", "direction": "entrance"}, + {"entrance": "Stick House Exit", "exit": "Shop Portal 57", "direction": "entrance"}, + {"entrance": "Ruined Passage Not-Door Exit", "exit": "Shop Portal 58", "direction": "entrance"}, + {"entrance": "Ruined Passage Door Exit", "exit": "Shop Portal 59", "direction": "entrance"}, + {"entrance": "Southeast HC Room Exit", "exit": "Shop Portal 60", "direction": "entrance"}, + {"entrance": "Caustic Light Cave Exit", "exit": "Shop Portal 61", "direction": "entrance"}, + {"entrance": "Maze Cave Exit", "exit": "Shop Portal 62", "direction": "entrance"}, + {"entrance": "Hourglass Cave Exit", "exit": "Shop Portal 63", "direction": "entrance"}, + {"entrance": "Special Shop Exit", "exit": "Shop Portal 64", "direction": "entrance"}, + {"entrance": "Temple Rafters Exit", "exit": "Shop Portal 65", "direction": "entrance"}, + {"entrance": "Temple Door Exit", "exit": "Shop Portal 66", "direction": "entrance"}, + {"entrance": "Forest Belltower to Fortress", "exit": "Shop Portal 67", "direction": "entrance"}, + {"entrance": "Forest Belltower to Forest", "exit": "Shop Portal 68", "direction": "entrance"}, + {"entrance": "Forest Belltower to Overworld", "exit": "Shop Portal 69", "direction": "entrance"}, + {"entrance": "Forest Belltower to Guard Captain Room", "exit": "Shop Portal 70", + "direction": "entrance"}, + {"entrance": "Forest to Belltower", "exit": "Shop Portal 71", "direction": "entrance"}, + {"entrance": "Forest Guard House 1 Lower Entrance", "exit": "Shop Portal 72", + "direction": "entrance"}, + {"entrance": "Forest Guard House 1 Gate Entrance", "exit": "Shop Portal 73", + "direction": "entrance"}, + {"entrance": "Forest Dance Fox Outside Doorway", "exit": "Shop Portal 74", "direction": "entrance"}, + {"entrance": "Forest to Far Shore", "exit": "Shop Portal 75", "direction": "entrance"}, + {"entrance": "Forest Guard House 2 Lower Entrance", "exit": "Shop Portal 76", + "direction": "entrance"}, + {"entrance": "Forest Guard House 2 Upper Entrance", "exit": "Shop Portal 77", + "direction": "entrance"}, + {"entrance": "Forest Grave Path Lower Entrance", "exit": "Shop Portal 78", "direction": "entrance"}, + {"entrance": "Forest Grave Path Upper Entrance", "exit": "Shop Portal 79", "direction": "entrance"}, + {"entrance": "Forest Grave Path Upper Exit", "exit": "Shop Portal 80", "direction": "entrance"}, + {"entrance": "Forest Grave Path Lower Exit", "exit": "Shop Portal 81", "direction": "entrance"}, + {"entrance": "East Forest Hero's Grave", "exit": "Shop Portal 82", "direction": "entrance"}, + {"entrance": "Guard House 1 Dance Fox Exit", "exit": "Shop Portal 83", "direction": "entrance"}, + {"entrance": "Guard House 1 Lower Exit", "exit": "Shop Portal 84", "direction": "entrance"}, + {"entrance": "Guard House 1 Upper Forest Exit", "exit": "Shop Portal 85", "direction": "entrance"}, + {"entrance": "Guard House 1 to Guard Captain Room", "exit": "Shop Portal 86", + "direction": "entrance"}, + {"entrance": "Guard House 2 Lower Exit", "exit": "Shop Portal 87", "direction": "entrance"}, + {"entrance": "Guard House 2 Upper Exit", "exit": "Shop Portal 88", "direction": "entrance"}, + {"entrance": "Guard Captain Room Non-Gate Exit", "exit": "Shop Portal 89", "direction": "entrance"}, + {"entrance": "Guard Captain Room Gate Exit", "exit": "Shop Portal 90", "direction": "entrance"}, + {"entrance": "Well Ladder Exit", "exit": "Shop Portal 91", "direction": "entrance"}, + {"entrance": "Well to Well Boss", "exit": "Shop Portal 92", "direction": "entrance"}, + {"entrance": "Well Exit towards Furnace", "exit": "Shop Portal 93", "direction": "entrance"}, + {"entrance": "Well Boss to Well", "exit": "Shop Portal 94", "direction": "entrance"}, + {"entrance": "Checkpoint to Dark Tomb", "exit": "Shop Portal 95", "direction": "entrance"}, + {"entrance": "Dark Tomb to Overworld", "exit": "Shop Portal 96", "direction": "entrance"}, + {"entrance": "Dark Tomb to Furnace", "exit": "Shop Portal 97", "direction": "entrance"}, + {"entrance": "Dark Tomb to Checkpoint", "exit": "Shop Portal 98", "direction": "entrance"}, + {"entrance": "West Garden Exit near Hero's Grave", "exit": "Shop Portal 99", + "direction": "entrance"}, + {"entrance": "West Garden to Magic Dagger House", "exit": "Shop Portal 100", + "direction": "entrance"}, + {"entrance": "West Garden Exit after Boss", "exit": "Shop Portal 101", "direction": "entrance"}, + {"entrance": "West Garden Shop", "exit": "Shop Portal 102", "direction": "entrance"}, + {"entrance": "West Garden Laurels Exit", "exit": "Shop Portal 103", "direction": "entrance"}, + {"entrance": "West Garden Hero's Grave", "exit": "Shop Portal 104", "direction": "entrance"}, + {"entrance": "West Garden to Far Shore", "exit": "Shop Portal 105", "direction": "entrance"}, + {"entrance": "Magic Dagger House Exit", "exit": "Shop Portal 106", "direction": "entrance"}, + {"entrance": "Fortress Courtyard to Fortress Grave Path Lower", "exit": "Shop Portal 107", + "direction": "entrance"}, + {"entrance": "Fortress Courtyard to Fortress Grave Path Upper", "exit": "Shop Portal 108", + "direction": "entrance"}, + {"entrance": "Fortress Courtyard to Fortress Interior", "exit": "Shop Portal 109", + "direction": "entrance"}, + {"entrance": "Fortress Courtyard to East Fortress", "exit": "Shop Portal 110", + "direction": "entrance"}, + {"entrance": "Fortress Courtyard to Beneath the Vault", "exit": "Shop Portal 111", + "direction": "entrance"}, + {"entrance": "Fortress Courtyard to Forest Belltower", "exit": "Shop Portal 112", + "direction": "entrance"}, + {"entrance": "Fortress Courtyard to Overworld", "exit": "Shop Portal 113", "direction": "entrance"}, + {"entrance": "Fortress Courtyard Shop", "exit": "Shop Portal 114", "direction": "entrance"}, + {"entrance": "Beneath the Vault to Fortress Interior", "exit": "Shop Portal 115", + "direction": "entrance"}, + {"entrance": "Beneath the Vault to Fortress Courtyard", "exit": "Shop Portal 116", + "direction": "entrance"}, + {"entrance": "Fortress Interior Main Exit", "exit": "Shop Portal 117", "direction": "entrance"}, + {"entrance": "Fortress Interior to Beneath the Earth", "exit": "Shop Portal 118", + "direction": "entrance"}, + {"entrance": "Fortress Interior to Siege Engine Arena", "exit": "Shop Portal 119", + "direction": "entrance"}, + {"entrance": "Fortress Interior Shop", "exit": "Shop Portal 120", "direction": "entrance"}, + {"entrance": "Fortress Interior to East Fortress Upper", "exit": "Shop Portal 121", + "direction": "entrance"}, + {"entrance": "Fortress Interior to East Fortress Lower", "exit": "Shop Portal 122", + "direction": "entrance"}, + {"entrance": "East Fortress to Interior Lower", "exit": "Shop Portal 123", "direction": "entrance"}, + {"entrance": "East Fortress to Courtyard", "exit": "Shop Portal 124", "direction": "entrance"}, + {"entrance": "East Fortress to Interior Upper", "exit": "Shop Portal 125", "direction": "entrance"}, + {"entrance": "Fortress Grave Path Lower Exit", "exit": "Shop Portal 126", "direction": "entrance"}, + {"entrance": "Fortress Hero's Grave", "exit": "Shop Portal 127", "direction": "entrance"}, + {"entrance": "Fortress Grave Path Upper Exit", "exit": "Shop Portal 128", "direction": "entrance"}, + {"entrance": "Fortress Grave Path Dusty Entrance", "exit": "Shop Portal 129", + "direction": "entrance"}, + {"entrance": "Dusty Exit", "exit": "Shop Portal 130", "direction": "entrance"}, + {"entrance": "Siege Engine Arena to Fortress", "exit": "Shop Portal 131", "direction": "entrance"}, + {"entrance": "Fortress to Far Shore", "exit": "Shop Portal 132", "direction": "entrance"}, + {"entrance": "Atoll Upper Exit", "exit": "Shop Portal 133", "direction": "entrance"}, + {"entrance": "Atoll Lower Exit", "exit": "Shop Portal 134", "direction": "entrance"}, + {"entrance": "Atoll Shop", "exit": "Shop Portal 135", "direction": "entrance"}, + {"entrance": "Atoll to Far Shore", "exit": "Shop Portal 136", "direction": "entrance"}, + {"entrance": "Atoll Statue Teleporter", "exit": "Shop Portal 137", "direction": "entrance"}, + {"entrance": "Frog Stairs Eye Entrance", "exit": "Shop Portal 138", "direction": "entrance"}, + {"entrance": "Frog Stairs Mouth Entrance", "exit": "Shop Portal 139", "direction": "entrance"}, + {"entrance": "Frog Stairs Eye Exit", "exit": "Shop Portal 140", "direction": "entrance"}, + {"entrance": "Frog Stairs Mouth Exit", "exit": "Shop Portal 141", "direction": "entrance"}, + {"entrance": "Frog Stairs to Frog's Domain's Entrance", "exit": "Shop Portal 142", + "direction": "entrance"}, + {"entrance": "Frog Stairs to Frog's Domain's Exit", "exit": "Shop Portal 143", + "direction": "entrance"}, + {"entrance": "Frog's Domain Ladder Exit", "exit": "Shop Portal 144", "direction": "entrance"}, + {"entrance": "Frog's Domain Orb Exit", "exit": "Shop Portal 145", "direction": "entrance"}, + {"entrance": "Library Exterior Tree", "exit": "Shop Portal 146", "direction": "entrance"}, + {"entrance": "Library Exterior Ladder", "exit": "Shop Portal 147", "direction": "entrance"}, + {"entrance": "Library Hall Bookshelf Exit", "exit": "Shop Portal 148", "direction": "entrance"}, + {"entrance": "Library Hero's Grave", "exit": "Shop Portal 149", "direction": "entrance"}, + {"entrance": "Library Hall to Rotunda", "exit": "Shop Portal 150", "direction": "entrance"}, + {"entrance": "Library Rotunda Lower Exit", "exit": "Shop Portal 151", "direction": "entrance"}, + {"entrance": "Library Rotunda Upper Exit", "exit": "Shop Portal 152", "direction": "entrance"}, + {"entrance": "Library Lab to Rotunda", "exit": "Shop Portal 153", "direction": "entrance"}, + {"entrance": "Library to Far Shore", "exit": "Shop Portal 154", "direction": "entrance"}, + {"entrance": "Library Lab to Librarian Arena", "exit": "Shop Portal 155", "direction": "entrance"}, + {"entrance": "Librarian Arena Exit", "exit": "Shop Portal 156", "direction": "entrance"}, + {"entrance": "Stairs to Top of the Mountain", "exit": "Shop Portal 157", "direction": "entrance"}, + {"entrance": "Mountain to Quarry", "exit": "Shop Portal 158", "direction": "entrance"}, + {"entrance": "Mountain to Overworld", "exit": "Shop Portal 159", "direction": "entrance"}, + {"entrance": "Top of the Mountain Exit", "exit": "Shop Portal 160", "direction": "entrance"}, + {"entrance": "Quarry Connector to Overworld", "exit": "Shop Portal 161", "direction": "entrance"}, + {"entrance": "Quarry Connector to Quarry", "exit": "Shop Portal 162", "direction": "entrance"}, + {"entrance": "Quarry to Overworld Exit", "exit": "Shop Portal 163", "direction": "entrance"}, + {"entrance": "Quarry Shop", "exit": "Shop Portal 164", "direction": "entrance"}, + {"entrance": "Quarry to Monastery Front", "exit": "Shop Portal 165", "direction": "entrance"}, + {"entrance": "Quarry to Monastery Back", "exit": "Shop Portal 166", "direction": "entrance"}, + {"entrance": "Quarry to Mountain", "exit": "Shop Portal 167", "direction": "entrance"}, + {"entrance": "Quarry to Ziggurat", "exit": "Shop Portal 168", "direction": "entrance"}, + {"entrance": "Quarry to Far Shore", "exit": "Shop Portal 169", "direction": "entrance"}, + {"entrance": "Monastery Rear Exit", "exit": "Shop Portal 170", "direction": "entrance"}, + {"entrance": "Monastery Front Exit", "exit": "Shop Portal 171", "direction": "entrance"}, + {"entrance": "Monastery Hero's Grave", "exit": "Shop Portal 172", "direction": "entrance"}, + {"entrance": "Ziggurat Entry Hallway to Ziggurat Upper", "exit": "Shop Portal 173", + "direction": "entrance"}, + {"entrance": "Ziggurat Entry Hallway to Quarry", "exit": "Shop Portal 174", "direction": "entrance"}, + {"entrance": "Ziggurat Upper to Ziggurat Entry Hallway", "exit": "Shop Portal 175", + "direction": "entrance"}, + {"entrance": "Ziggurat Upper to Ziggurat Tower", "exit": "Shop Portal 176", "direction": "entrance"}, + {"entrance": "Ziggurat Tower to Ziggurat Upper", "exit": "Shop Portal 177", "direction": "entrance"}, + {"entrance": "Ziggurat Tower to Ziggurat Lower", "exit": "Shop Portal 178", "direction": "entrance"}, + {"entrance": "Ziggurat Lower to Ziggurat Tower", "exit": "Shop Portal 179", "direction": "entrance"}, + {"entrance": "Ziggurat Portal Room Entrance", "exit": "Shop Portal 180", "direction": "entrance"}, + {"entrance": "Ziggurat Portal Room Exit", "exit": "Shop Portal 181", "direction": "entrance"}, + {"entrance": "Ziggurat to Far Shore", "exit": "Shop Portal 182", "direction": "entrance"}, + {"entrance": "Swamp Lower Exit", "exit": "Shop Portal 183", "direction": "entrance"}, + {"entrance": "Swamp to Cathedral Main Entrance", "exit": "Shop Portal 184", "direction": "entrance"}, + {"entrance": "Swamp to Cathedral Secret Legend Room Entrance", "exit": "Shop Portal 185", + "direction": "entrance"}, + {"entrance": "Swamp to Gauntlet", "exit": "Shop Portal 186", "direction": "entrance"}, + {"entrance": "Swamp Shop", "exit": "Shop Portal 187", "direction": "entrance"}, + {"entrance": "Swamp Upper Exit", "exit": "Shop Portal 188", "direction": "entrance"}, + {"entrance": "Swamp Hero's Grave", "exit": "Shop Portal 189", "direction": "entrance"}, + {"entrance": "Cathedral Main Exit", "exit": "Shop Portal 190", "direction": "entrance"}, + {"entrance": "Cathedral Elevator", "exit": "Shop Portal 191", "direction": "entrance"}, + {"entrance": "Cathedral Secret Legend Room Exit", "exit": "Shop Portal 192", + "direction": "entrance"}, + {"entrance": "Gauntlet to Swamp", "exit": "Shop Portal 193", "direction": "entrance"}, + {"entrance": "Gauntlet Elevator", "exit": "Shop Portal 194", "direction": "entrance"}, + {"entrance": "Gauntlet Shop", "exit": "Shop Portal 195", "direction": "entrance"}, + {"entrance": "Hero's Grave to Fortress", "exit": "Shop Portal 196", "direction": "entrance"}, + {"entrance": "Hero's Grave to Monastery", "exit": "Shop Portal 197", "direction": "entrance"}, + {"entrance": "Hero's Grave to West Garden", "exit": "Shop Portal 198", "direction": "entrance"}, + {"entrance": "Hero's Grave to East Forest", "exit": "Shop Portal 199", "direction": "entrance"}, + {"entrance": "Hero's Grave to Library", "exit": "Shop Portal 200", "direction": "entrance"}, + {"entrance": "Hero's Grave to Swamp", "exit": "Shop Portal 201", "direction": "entrance"}, + {"entrance": "Far Shore to West Garden", "exit": "Shop Portal 202", "direction": "entrance"}, + {"entrance": "Far Shore to Library", "exit": "Shop Portal 203", "direction": "entrance"}, + {"entrance": "Far Shore to Quarry", "exit": "Shop Portal 204", "direction": "entrance"}, + {"entrance": "Far Shore to East Forest", "exit": "Shop Portal 205", "direction": "entrance"}, + {"entrance": "Far Shore to Fortress", "exit": "Shop Portal 206", "direction": "entrance"}, + {"entrance": "Far Shore to Atoll", "exit": "Shop Portal 207", "direction": "entrance"}, + {"entrance": "Far Shore to Ziggurat", "exit": "Shop Portal 208", "direction": "entrance"}, + {"entrance": "Far Shore to Heir", "exit": "Shop Portal 209", "direction": "entrance"}, + {"entrance": "Far Shore to Town", "exit": "Shop Portal 210", "direction": "entrance"}, + {"entrance": "Far Shore to Spawn", "exit": "Shop Portal 211", "direction": "entrance"}, + {"entrance": "Heir Arena Exit", "exit": "Shop Portal 212", "direction": "entrance"}, + {"entrance": "Purgatory Bottom Exit", "exit": "Shop Portal 213", "direction": "entrance"}, + {"entrance": "Purgatory Top Exit", "exit": "Shop Portal 214", "direction": "entrance"}, + {"entrance": "Shop Portal 215", "exit": "Shop Portal 216", "direction": "entrance"}, + {"entrance": "Shop Portal 217", "exit": "Shop Portal 218", "direction": "entrance"}, + {"entrance": "Shop Portal 219", "exit": "Shop Portal 220", "direction": "entrance"}, + {"entrance": "Shop Portal 221", "exit": "Shop Portal 222", "direction": "entrance"}, + {"entrance": "Shop Portal 223", "exit": "Shop Portal 224", "direction": "entrance"}, + {"entrance": "Shop Portal 225", "exit": "Shop Portal 226", "direction": "entrance"}, + {"entrance": "Shop Portal 227", "exit": "Shop Portal 228", "direction": "entrance"}, + {"entrance": "Shop Portal 229", "exit": "Shop Portal 230", "direction": "entrance"}, + ]}