Files
Grinch-AP/worlds/tww/randomizers/Entrances.py
Jonathan Tan cf0ae5e31b The Wind Waker: Implement New Game (#4458)
Adds The Legend of Zelda: The Wind Waker as a supported game in Archipelago. The game uses [LagoLunatic's randomizer](https://github.com/LagoLunatic/wwrando) as its base (regarding logic, options, etc.) and builds from there.
2025-03-23 00:42:17 +01:00

879 lines
44 KiB
Python

from collections import defaultdict
from collections.abc import Generator
from dataclasses import dataclass
from typing import TYPE_CHECKING, ClassVar, Optional
from Fill import FillError
from Options import OptionError
from .. import Macros
from ..Locations import LOCATION_TABLE, TWWFlag, split_location_name_by_zone
if TYPE_CHECKING:
from .. import TWWWorld
@dataclass(frozen=True)
class ZoneEntrance:
"""
A data class that encapsulates information about a zone entrance.
"""
entrance_name: str
island_name: Optional[str] = None
nested_in: Optional["ZoneExit"] = None
@property
def is_nested(self) -> bool:
"""
Determine if this entrance is nested within another entrance.
:return: `True` if the entrance is nested, `False` otherwise.
"""
return self.nested_in is not None
def __repr__(self) -> str:
"""
Provide a string representation of the zone exit.
:return: A string representing the zone exit.
"""
return f"ZoneEntrance('{self.entrance_name}')"
all: ClassVar[dict[str, "ZoneEntrance"]] = {}
def __post_init__(self) -> None:
ZoneEntrance.all[self.entrance_name] = self
# Must be an island entrance XOR must be a nested entrance.
assert (self.island_name is None) ^ (self.nested_in is None)
@dataclass(frozen=True)
class ZoneExit:
"""
A data class that encapsulates information about a zone exit.
"""
unique_name: str
zone_name: Optional[str] = None
def __repr__(self) -> str:
"""
Provide a string representation of the zone exit.
:return: A string representing the zone exit.
"""
return f"ZoneExit('{self.unique_name}')"
all: ClassVar[dict[str, "ZoneExit"]] = {}
def __post_init__(self) -> None:
ZoneExit.all[self.unique_name] = self
DUNGEON_ENTRANCES: list[ZoneEntrance] = [
ZoneEntrance("Dungeon Entrance on Dragon Roost Island", "Dragon Roost Island"),
ZoneEntrance("Dungeon Entrance in Forest Haven Sector", "Forest Haven"),
ZoneEntrance("Dungeon Entrance in Tower of the Gods Sector", "Tower of the Gods Sector"),
ZoneEntrance("Dungeon Entrance on Headstone Island", "Headstone Island"),
ZoneEntrance("Dungeon Entrance on Gale Isle", "Gale Isle"),
]
DUNGEON_EXITS: list[ZoneExit] = [
ZoneExit("Dragon Roost Cavern", "Dragon Roost Cavern"),
ZoneExit("Forbidden Woods", "Forbidden Woods"),
ZoneExit("Tower of the Gods", "Tower of the Gods"),
ZoneExit("Earth Temple", "Earth Temple"),
ZoneExit("Wind Temple", "Wind Temple"),
]
MINIBOSS_ENTRANCES: list[ZoneEntrance] = [
ZoneEntrance("Miniboss Entrance in Forbidden Woods", nested_in=ZoneExit.all["Forbidden Woods"]),
ZoneEntrance("Miniboss Entrance in Tower of the Gods", nested_in=ZoneExit.all["Tower of the Gods"]),
ZoneEntrance("Miniboss Entrance in Earth Temple", nested_in=ZoneExit.all["Earth Temple"]),
ZoneEntrance("Miniboss Entrance in Wind Temple", nested_in=ZoneExit.all["Wind Temple"]),
ZoneEntrance("Miniboss Entrance in Hyrule Castle", "Tower of the Gods Sector"),
]
MINIBOSS_EXITS: list[ZoneExit] = [
ZoneExit("Forbidden Woods Miniboss Arena"),
ZoneExit("Tower of the Gods Miniboss Arena"),
ZoneExit("Earth Temple Miniboss Arena"),
ZoneExit("Wind Temple Miniboss Arena"),
ZoneExit("Master Sword Chamber"),
]
BOSS_ENTRANCES: list[ZoneEntrance] = [
ZoneEntrance("Boss Entrance in Dragon Roost Cavern", nested_in=ZoneExit.all["Dragon Roost Cavern"]),
ZoneEntrance("Boss Entrance in Forbidden Woods", nested_in=ZoneExit.all["Forbidden Woods"]),
ZoneEntrance("Boss Entrance in Tower of the Gods", nested_in=ZoneExit.all["Tower of the Gods"]),
ZoneEntrance("Boss Entrance in Forsaken Fortress", "Forsaken Fortress Sector"),
ZoneEntrance("Boss Entrance in Earth Temple", nested_in=ZoneExit.all["Earth Temple"]),
ZoneEntrance("Boss Entrance in Wind Temple", nested_in=ZoneExit.all["Wind Temple"]),
]
BOSS_EXITS: list[ZoneExit] = [
ZoneExit("Gohma Boss Arena"),
ZoneExit("Kalle Demos Boss Arena"),
ZoneExit("Gohdan Boss Arena"),
ZoneExit("Helmaroc King Boss Arena"),
ZoneExit("Jalhalla Boss Arena"),
ZoneExit("Molgera Boss Arena"),
]
SECRET_CAVE_ENTRANCES: list[ZoneEntrance] = [
ZoneEntrance("Secret Cave Entrance on Outset Island", "Outset Island"),
ZoneEntrance("Secret Cave Entrance on Dragon Roost Island", "Dragon Roost Island"),
ZoneEntrance("Secret Cave Entrance on Fire Mountain", "Fire Mountain"),
ZoneEntrance("Secret Cave Entrance on Ice Ring Isle", "Ice Ring Isle"),
ZoneEntrance("Secret Cave Entrance on Private Oasis", "Private Oasis"),
ZoneEntrance("Secret Cave Entrance on Needle Rock Isle", "Needle Rock Isle"),
ZoneEntrance("Secret Cave Entrance on Angular Isles", "Angular Isles"),
ZoneEntrance("Secret Cave Entrance on Boating Course", "Boating Course"),
ZoneEntrance("Secret Cave Entrance on Stone Watcher Island", "Stone Watcher Island"),
ZoneEntrance("Secret Cave Entrance on Overlook Island", "Overlook Island"),
ZoneEntrance("Secret Cave Entrance on Bird's Peak Rock", "Bird's Peak Rock"),
ZoneEntrance("Secret Cave Entrance on Pawprint Isle", "Pawprint Isle"),
ZoneEntrance("Secret Cave Entrance on Pawprint Isle Side Isle", "Pawprint Isle"),
ZoneEntrance("Secret Cave Entrance on Diamond Steppe Island", "Diamond Steppe Island"),
ZoneEntrance("Secret Cave Entrance on Bomb Island", "Bomb Island"),
ZoneEntrance("Secret Cave Entrance on Rock Spire Isle", "Rock Spire Isle"),
ZoneEntrance("Secret Cave Entrance on Shark Island", "Shark Island"),
ZoneEntrance("Secret Cave Entrance on Cliff Plateau Isles", "Cliff Plateau Isles"),
ZoneEntrance("Secret Cave Entrance on Horseshoe Island", "Horseshoe Island"),
ZoneEntrance("Secret Cave Entrance on Star Island", "Star Island"),
]
SECRET_CAVE_EXITS: list[ZoneExit] = [
ZoneExit("Savage Labyrinth", zone_name="Outset Island"),
ZoneExit("Dragon Roost Island Secret Cave", zone_name="Dragon Roost Island"),
ZoneExit("Fire Mountain Secret Cave", zone_name="Fire Mountain"),
ZoneExit("Ice Ring Isle Secret Cave", zone_name="Ice Ring Isle"),
ZoneExit("Cabana Labyrinth", zone_name="Private Oasis"),
ZoneExit("Needle Rock Isle Secret Cave", zone_name="Needle Rock Isle"),
ZoneExit("Angular Isles Secret Cave", zone_name="Angular Isles"),
ZoneExit("Boating Course Secret Cave", zone_name="Boating Course"),
ZoneExit("Stone Watcher Island Secret Cave", zone_name="Stone Watcher Island"),
ZoneExit("Overlook Island Secret Cave", zone_name="Overlook Island"),
ZoneExit("Bird's Peak Rock Secret Cave", zone_name="Bird's Peak Rock"),
ZoneExit("Pawprint Isle Chuchu Cave", zone_name="Pawprint Isle"),
ZoneExit("Pawprint Isle Wizzrobe Cave"),
ZoneExit("Diamond Steppe Island Warp Maze Cave", zone_name="Diamond Steppe Island"),
ZoneExit("Bomb Island Secret Cave", zone_name="Bomb Island"),
ZoneExit("Rock Spire Isle Secret Cave", zone_name="Rock Spire Isle"),
ZoneExit("Shark Island Secret Cave", zone_name="Shark Island"),
ZoneExit("Cliff Plateau Isles Secret Cave", zone_name="Cliff Plateau Isles"),
ZoneExit("Horseshoe Island Secret Cave", zone_name="Horseshoe Island"),
ZoneExit("Star Island Secret Cave", zone_name="Star Island"),
]
SECRET_CAVE_INNER_ENTRANCES: list[ZoneEntrance] = [
ZoneEntrance("Inner Entrance in Ice Ring Isle Secret Cave", nested_in=ZoneExit.all["Ice Ring Isle Secret Cave"]),
ZoneEntrance(
"Inner Entrance in Cliff Plateau Isles Secret Cave", nested_in=ZoneExit.all["Cliff Plateau Isles Secret Cave"]
),
]
SECRET_CAVE_INNER_EXITS: list[ZoneExit] = [
ZoneExit("Ice Ring Isle Inner Cave"),
ZoneExit("Cliff Plateau Isles Inner Cave"),
]
FAIRY_FOUNTAIN_ENTRANCES: list[ZoneEntrance] = [
ZoneEntrance("Fairy Fountain Entrance on Outset Island", "Outset Island"),
ZoneEntrance("Fairy Fountain Entrance on Thorned Fairy Island", "Thorned Fairy Island"),
ZoneEntrance("Fairy Fountain Entrance on Eastern Fairy Island", "Eastern Fairy Island"),
ZoneEntrance("Fairy Fountain Entrance on Western Fairy Island", "Western Fairy Island"),
ZoneEntrance("Fairy Fountain Entrance on Southern Fairy Island", "Southern Fairy Island"),
ZoneEntrance("Fairy Fountain Entrance on Northern Fairy Island", "Northern Fairy Island"),
]
FAIRY_FOUNTAIN_EXITS: list[ZoneExit] = [
ZoneExit("Outset Fairy Fountain"),
ZoneExit("Thorned Fairy Fountain", zone_name="Thorned Fairy Island"),
ZoneExit("Eastern Fairy Fountain", zone_name="Eastern Fairy Island"),
ZoneExit("Western Fairy Fountain", zone_name="Western Fairy Island"),
ZoneExit("Southern Fairy Fountain", zone_name="Southern Fairy Island"),
ZoneExit("Northern Fairy Fountain", zone_name="Northern Fairy Island"),
]
DUNGEON_INNER_EXITS: list[ZoneExit] = (
MINIBOSS_EXITS
+ BOSS_EXITS
)
ALL_ENTRANCES: list[ZoneEntrance] = (
DUNGEON_ENTRANCES
+ MINIBOSS_ENTRANCES
+ BOSS_ENTRANCES
+ SECRET_CAVE_ENTRANCES
+ SECRET_CAVE_INNER_ENTRANCES
+ FAIRY_FOUNTAIN_ENTRANCES
)
ALL_EXITS: list[ZoneExit] = (
DUNGEON_EXITS
+ MINIBOSS_EXITS
+ BOSS_EXITS
+ SECRET_CAVE_EXITS
+ SECRET_CAVE_INNER_EXITS
+ FAIRY_FOUNTAIN_EXITS
)
ENTRANCE_RANDOMIZABLE_ITEM_LOCATION_TYPES: list[TWWFlag] = [
TWWFlag.DUNGEON,
TWWFlag.PZL_CVE,
TWWFlag.CBT_CVE,
TWWFlag.SAVAGE,
TWWFlag.GRT_FRY,
]
ITEM_LOCATION_NAME_TO_EXIT_OVERRIDES: dict[str, ZoneExit] = {
"Forbidden Woods - Mothula Miniboss Room": ZoneExit.all["Forbidden Woods Miniboss Arena"],
"Tower of the Gods - Darknut Miniboss Room": ZoneExit.all["Tower of the Gods Miniboss Arena"],
"Earth Temple - Stalfos Miniboss Room": ZoneExit.all["Earth Temple Miniboss Arena"],
"Wind Temple - Wizzrobe Miniboss Room": ZoneExit.all["Wind Temple Miniboss Arena"],
"Hyrule - Master Sword Chamber": ZoneExit.all["Master Sword Chamber"],
"Dragon Roost Cavern - Gohma Heart Container": ZoneExit.all["Gohma Boss Arena"],
"Forbidden Woods - Kalle Demos Heart Container": ZoneExit.all["Kalle Demos Boss Arena"],
"Tower of the Gods - Gohdan Heart Container": ZoneExit.all["Gohdan Boss Arena"],
"Forsaken Fortress - Helmaroc King Heart Container": ZoneExit.all["Helmaroc King Boss Arena"],
"Earth Temple - Jalhalla Heart Container": ZoneExit.all["Jalhalla Boss Arena"],
"Wind Temple - Molgera Heart Container": ZoneExit.all["Molgera Boss Arena"],
"Pawprint Isle - Wizzrobe Cave": ZoneExit.all["Pawprint Isle Wizzrobe Cave"],
"Ice Ring Isle - Inner Cave - Chest": ZoneExit.all["Ice Ring Isle Inner Cave"],
"Cliff Plateau Isles - Highest Isle": ZoneExit.all["Cliff Plateau Isles Inner Cave"],
"Outset Island - Great Fairy": ZoneExit.all["Outset Fairy Fountain"],
}
MINIBOSS_EXIT_TO_DUNGEON: dict[str, str] = {
"Forbidden Woods Miniboss Arena": "Forbidden Woods",
"Tower of the Gods Miniboss Arena": "Tower of the Gods",
"Earth Temple Miniboss Arena": "Earth Temple",
"Wind Temple Miniboss Arena": "Wind Temple",
}
BOSS_EXIT_TO_DUNGEON: dict[str, str] = {
"Gohma Boss Arena": "Dragon Roost Cavern",
"Kalle Demos Boss Arena": "Forbidden Woods",
"Gohdan Boss Arena": "Tower of the Gods",
"Helmaroc King Boss Arena": "Forsaken Fortress",
"Jalhalla Boss Arena": "Earth Temple",
"Molgera Boss Arena": "Wind Temple",
}
VANILLA_ENTRANCES_TO_EXITS: dict[str, str] = {
"Dungeon Entrance on Dragon Roost Island": "Dragon Roost Cavern",
"Dungeon Entrance in Forest Haven Sector": "Forbidden Woods",
"Dungeon Entrance in Tower of the Gods Sector": "Tower of the Gods",
"Dungeon Entrance on Headstone Island": "Earth Temple",
"Dungeon Entrance on Gale Isle": "Wind Temple",
"Miniboss Entrance in Forbidden Woods": "Forbidden Woods Miniboss Arena",
"Miniboss Entrance in Tower of the Gods": "Tower of the Gods Miniboss Arena",
"Miniboss Entrance in Earth Temple": "Earth Temple Miniboss Arena",
"Miniboss Entrance in Wind Temple": "Wind Temple Miniboss Arena",
"Miniboss Entrance in Hyrule Castle": "Master Sword Chamber",
"Boss Entrance in Dragon Roost Cavern": "Gohma Boss Arena",
"Boss Entrance in Forbidden Woods": "Kalle Demos Boss Arena",
"Boss Entrance in Tower of the Gods": "Gohdan Boss Arena",
"Boss Entrance in Forsaken Fortress": "Helmaroc King Boss Arena",
"Boss Entrance in Earth Temple": "Jalhalla Boss Arena",
"Boss Entrance in Wind Temple": "Molgera Boss Arena",
"Secret Cave Entrance on Outset Island": "Savage Labyrinth",
"Secret Cave Entrance on Dragon Roost Island": "Dragon Roost Island Secret Cave",
"Secret Cave Entrance on Fire Mountain": "Fire Mountain Secret Cave",
"Secret Cave Entrance on Ice Ring Isle": "Ice Ring Isle Secret Cave",
"Secret Cave Entrance on Private Oasis": "Cabana Labyrinth",
"Secret Cave Entrance on Needle Rock Isle": "Needle Rock Isle Secret Cave",
"Secret Cave Entrance on Angular Isles": "Angular Isles Secret Cave",
"Secret Cave Entrance on Boating Course": "Boating Course Secret Cave",
"Secret Cave Entrance on Stone Watcher Island": "Stone Watcher Island Secret Cave",
"Secret Cave Entrance on Overlook Island": "Overlook Island Secret Cave",
"Secret Cave Entrance on Bird's Peak Rock": "Bird's Peak Rock Secret Cave",
"Secret Cave Entrance on Pawprint Isle": "Pawprint Isle Chuchu Cave",
"Secret Cave Entrance on Pawprint Isle Side Isle": "Pawprint Isle Wizzrobe Cave",
"Secret Cave Entrance on Diamond Steppe Island": "Diamond Steppe Island Warp Maze Cave",
"Secret Cave Entrance on Bomb Island": "Bomb Island Secret Cave",
"Secret Cave Entrance on Rock Spire Isle": "Rock Spire Isle Secret Cave",
"Secret Cave Entrance on Shark Island": "Shark Island Secret Cave",
"Secret Cave Entrance on Cliff Plateau Isles": "Cliff Plateau Isles Secret Cave",
"Secret Cave Entrance on Horseshoe Island": "Horseshoe Island Secret Cave",
"Secret Cave Entrance on Star Island": "Star Island Secret Cave",
"Inner Entrance in Ice Ring Isle Secret Cave": "Ice Ring Isle Inner Cave",
"Inner Entrance in Cliff Plateau Isles Secret Cave": "Cliff Plateau Isles Inner Cave",
"Fairy Fountain Entrance on Outset Island": "Outset Fairy Fountain",
"Fairy Fountain Entrance on Thorned Fairy Island": "Thorned Fairy Fountain",
"Fairy Fountain Entrance on Eastern Fairy Island": "Eastern Fairy Fountain",
"Fairy Fountain Entrance on Western Fairy Island": "Western Fairy Fountain",
"Fairy Fountain Entrance on Southern Fairy Island": "Southern Fairy Fountain",
"Fairy Fountain Entrance on Northern Fairy Island": "Northern Fairy Fountain",
}
class EntranceRandomizer:
"""
This class handles the logic for The Wind Waker entrance randomizer.
We reference the logic from the base randomizer with some modifications to suit it for Archipelago.
Reference: https://github.com/LagoLunatic/wwrando/blob/master/randomizers/entrances.py
:param world: The Wind Waker game world.
"""
def __init__(self, world: "TWWWorld"):
self.world = world
self.multiworld = world.multiworld
self.player = world.player
self.item_location_to_containing_zone_exit: dict[str, ZoneExit] = {}
self.zone_exit_to_logically_dependent_item_locations: dict[ZoneExit, list[str]] = defaultdict(list)
self.register_mappings_between_item_locations_and_zone_exits()
self.done_entrances_to_exits: dict[ZoneEntrance, ZoneExit] = {}
self.done_exits_to_entrances: dict[ZoneExit, ZoneEntrance] = {}
for entrance_name, exit_name in VANILLA_ENTRANCES_TO_EXITS.items():
zone_entrance = ZoneEntrance.all[entrance_name]
zone_exit = ZoneExit.all[exit_name]
self.done_entrances_to_exits[zone_entrance] = zone_exit
self.done_exits_to_entrances[zone_exit] = zone_entrance
self.banned_exits: list[ZoneExit] = []
self.islands_with_a_banned_dungeon: set[str] = set()
def randomize_entrances(self) -> None:
"""
Randomize entrances for The Wind Waker.
"""
self.init_banned_exits()
for relevant_entrances, relevant_exits in self.get_all_entrance_sets_to_be_randomized():
self.randomize_one_set_of_entrances(relevant_entrances, relevant_exits)
self.finalize_all_randomized_sets_of_entrances()
def init_banned_exits(self) -> None:
"""
Initialize the list of banned exits for the randomizer.
Dungeon exits in banned dungeons should be prohibited from being randomized.
Additionally, if dungeon entrances are not randomized, we can now note which island holds these banned dungeons.
"""
options = self.world.options
if options.required_bosses:
for zone_exit in BOSS_EXITS:
assert zone_exit.unique_name.endswith(" Boss Arena")
boss_name = zone_exit.unique_name.removesuffix(" Boss Arena")
if boss_name in self.world.boss_reqs.banned_bosses:
self.banned_exits.append(zone_exit)
for zone_exit in DUNGEON_EXITS:
dungeon_name = zone_exit.unique_name
if dungeon_name in self.world.boss_reqs.banned_dungeons:
self.banned_exits.append(zone_exit)
for zone_exit in MINIBOSS_EXITS:
if zone_exit == ZoneExit.all["Master Sword Chamber"]:
# Hyrule cannot be chosen as a banned dungeon.
continue
assert zone_exit.unique_name.endswith(" Miniboss Arena")
dungeon_name = zone_exit.unique_name.removesuffix(" Miniboss Arena")
if dungeon_name in self.world.boss_reqs.banned_dungeons:
self.banned_exits.append(zone_exit)
if not options.randomize_dungeon_entrances:
# If dungeon entrances are not randomized, `islands_with_a_banned_dungeon` can be initialized early since
# it's preset and won't be updated later since we won't randomize the dungeon entrances.
for en in DUNGEON_ENTRANCES:
if self.done_entrances_to_exits[en].unique_name in self.world.boss_reqs.banned_dungeons:
assert en.island_name is not None
self.islands_with_a_banned_dungeon.add(en.island_name)
def randomize_one_set_of_entrances(
self, relevant_entrances: list[ZoneEntrance], relevant_exits: list[ZoneExit]
) -> None:
"""
Randomize a single set of entrances and their corresponding exits.
:param relevant_entrances: A list of entrances to be randomized.
:param relevant_exits: A list of exits corresponding to the entrances.
"""
# Keep miniboss and boss entrances vanilla in non-required bosses' dungeons.
for zone_entrance in relevant_entrances.copy():
zone_exit = self.done_entrances_to_exits[zone_entrance]
if zone_exit in self.banned_exits and zone_exit in DUNGEON_INNER_EXITS:
relevant_entrances.remove(zone_entrance)
else:
del self.done_entrances_to_exits[zone_entrance]
for zone_exit in relevant_exits.copy():
if zone_exit in self.banned_exits and zone_exit in DUNGEON_INNER_EXITS:
relevant_exits.remove(zone_exit)
else:
del self.done_exits_to_entrances[zone_exit]
self.multiworld.random.shuffle(relevant_entrances)
# We calculate which exits are terminal (the end of a nested chain) per set instead of for all entrances.
# This is so that, for example, Ice Ring Isle counts as terminal when its inner cave is not being randomized.
non_terminal_exits = []
for en in relevant_entrances:
if en.nested_in is not None and en.nested_in not in non_terminal_exits:
non_terminal_exits.append(en.nested_in)
terminal_exits = {ex for ex in relevant_exits if ex not in non_terminal_exits}
remaining_entrances = relevant_entrances.copy()
remaining_exits = relevant_exits.copy()
nonprogress_entrances, nonprogress_exits = self.split_nonprogress_entrances_and_exits(
remaining_entrances, remaining_exits
)
if nonprogress_entrances:
for en in nonprogress_entrances:
remaining_entrances.remove(en)
for ex in nonprogress_exits:
remaining_exits.remove(ex)
self.randomize_one_set_of_exits(nonprogress_entrances, nonprogress_exits, terminal_exits)
self.randomize_one_set_of_exits(remaining_entrances, remaining_exits, terminal_exits)
def check_if_one_exit_is_progress(self, zone_exit: ZoneExit) -> bool:
"""
Determine if the zone exit leads to progress locations in the world.
:param zone_exit: The zone exit to check.
:return: Whether the zone exit leads to progress locations.
"""
locs_for_exit = self.zone_exit_to_logically_dependent_item_locations[zone_exit]
assert locs_for_exit, f"Could not find any item locations corresponding to zone exit: {zone_exit.unique_name}"
# Banned required bosses mode dungeons still technically count as progress locations, so filter them out
# separately first.
nonbanned_locs = [loc for loc in locs_for_exit if loc not in self.world.boss_reqs.banned_locations]
progress_locs = [loc for loc in nonbanned_locs if loc not in self.world.nonprogress_locations]
return bool(progress_locs)
def split_nonprogress_entrances_and_exits(
self, relevant_entrances: list[ZoneEntrance], relevant_exits: list[ZoneExit]
) -> tuple[list[ZoneEntrance], list[ZoneExit]]:
"""
Splits the entrance and exit lists into two pairs: ones that should be considered nonprogress on this seed (will
never lead to any progress items) and ones that should be regarded as potentially required.
This is so we can effectively randomize these two pairs separately without convoluted logic to ensure they don't
connect.
:param relevant_entrances: A list of entrances.
:param relevant_exits: A list of exits corresponding to the entrances.
:raises FillError: If the number of randomizable entrances does not equal the number of randomizable exits.
"""
nonprogress_exits = [ex for ex in relevant_exits if not self.check_if_one_exit_is_progress(ex)]
nonprogress_entrances = [
en
for en in relevant_entrances
if en.nested_in is not None
and (
(en.nested_in in nonprogress_exits)
# The area this entrance is nested in is not randomized, but we still need to determine whether it's
# progression.
or (en.nested_in not in relevant_exits and not self.check_if_one_exit_is_progress(en.nested_in))
)
]
# At this point, `nonprogress_entrances` includes only the inner entrances nested inside the main exits, not any
# island entrances on the sea. So, we need to select `N` random island entrances to allow all of the nonprogress
# exits to be accessible, where `N` is the difference between the number of entrances and exits we currently
# have.
possible_island_entrances = [en for en in relevant_entrances if en.island_name is not None]
# We need special logic to handle Forsaken Fortress, as it is the only island entrance inside a dungeon.
ff_boss_entrance = ZoneEntrance.all["Boss Entrance in Forsaken Fortress"]
if ff_boss_entrance in possible_island_entrances:
if self.world.options.progression_dungeons:
if "Forsaken Fortress" in self.world.boss_reqs.banned_dungeons:
ff_progress = False
else:
ff_progress = True
else:
ff_progress = False
if ff_progress:
# If it's progress, don't allow it to be randomly chosen to lead to nonprogress exits.
possible_island_entrances.remove(ff_boss_entrance)
else:
# If it's not progress, manually mark it as such, and don't allow it to be chosen randomly.
nonprogress_entrances.append(ff_boss_entrance)
possible_island_entrances.remove(ff_boss_entrance)
num_island_entrances_needed = len(nonprogress_exits) - len(nonprogress_entrances)
if num_island_entrances_needed > len(possible_island_entrances):
raise FillError("Not enough island entrances left to split entrances.")
for _ in range(num_island_entrances_needed):
# Note: `relevant_entrances` is already shuffled, so we can just take the first result from
# `possible_island_entrances`—it's the same as picking one randomly.
nonprogress_island_entrance = possible_island_entrances.pop(0)
nonprogress_entrances.append(nonprogress_island_entrance)
assert len(nonprogress_entrances) == len(nonprogress_exits)
return nonprogress_entrances, nonprogress_exits
def randomize_one_set_of_exits(
self, relevant_entrances: list[ZoneEntrance], relevant_exits: list[ZoneExit], terminal_exits: set[ZoneExit]
) -> None:
"""
Randomize a single set of entrances and their corresponding exits.
:param relevant_entrances: A list of entrances to be randomized.
:param relevant_exits: A list of exits corresponding to the entrances.
:param terminal_exits: A set of exits which do not contain any entrances.
:raises FillError: If there are no valid exits to assign to an entrance.
"""
options = self.world.options
remaining_entrances = relevant_entrances.copy()
remaining_exits = relevant_exits.copy()
doing_banned = False
if any(ex in self.banned_exits for ex in relevant_exits):
doing_banned = True
if options.required_bosses and not doing_banned:
# Prioritize entrances that share an island with an entrance randomized to lead into a
# required-bosses-mode-banned dungeon. (e.g., DRI, Pawprint, Outset, TotG sector.)
# This is because we need to prevent these islands from having a required boss or anything that could lead
# to a required boss. If we don't do this first, we can get backed into a corner where there is no other
# option left.
entrances_not_on_unique_islands = []
for zone_entrance in relevant_entrances:
if zone_entrance.is_nested:
continue
if zone_entrance.island_name in self.islands_with_a_banned_dungeon:
# This island was already used on a previous call to `randomize_one_set_of_exits`.
entrances_not_on_unique_islands.append(zone_entrance)
continue
for zone_entrance in entrances_not_on_unique_islands:
remaining_entrances.remove(zone_entrance)
remaining_entrances = entrances_not_on_unique_islands + remaining_entrances
while remaining_entrances:
# Filter out boss entrances that aren't yet accessible from the sea.
# We don't want to connect these to anything yet or we risk creating an infinite loop.
possible_remaining_entrances = [
en for en in remaining_entrances if self.get_outermost_entrance_for_entrance(en) is not None
]
zone_entrance = possible_remaining_entrances.pop(0)
remaining_entrances.remove(zone_entrance)
possible_remaining_exits = remaining_exits.copy()
if len(possible_remaining_entrances) == 0 and len(remaining_entrances) > 0:
# If this is the last entrance we have left to attach exits to, we can't place a terminal exit here.
# Terminal exits do not create another entrance, so one would leave us with no possible way to continue
# placing the remaining exits on future loops.
possible_remaining_exits = [ex for ex in possible_remaining_exits if ex not in terminal_exits]
if options.required_bosses and zone_entrance.island_name is not None and not doing_banned:
# Prevent required bosses (and non-terminal exits, which could lead to required bosses) from appearing
# on islands where we already placed a banned boss or dungeon.
# This can happen with DRI and Pawprint, as these islands have two entrances. This would be bad because
# the required bosses mode's dungeon markers only tell you what island the required dungeons are on, not
# which of the two entrances to enter.
# So, if a banned dungeon is placed on DRI's main entrance, we will have to fill DRI's pit entrance with
# either a miniboss or one of the caves that does not have a nested entrance inside. We allow multiple
# banned and required dungeons on a single island.
if zone_entrance.island_name in self.islands_with_a_banned_dungeon:
possible_remaining_exits = [
ex
for ex in possible_remaining_exits
if ex in terminal_exits and ex not in (DUNGEON_EXITS + BOSS_EXITS)
]
if not possible_remaining_exits:
raise FillError(f"No valid exits to place for entrance: {zone_entrance.entrance_name}")
zone_exit = self.multiworld.random.choice(possible_remaining_exits)
remaining_exits.remove(zone_exit)
self.done_entrances_to_exits[zone_entrance] = zone_exit
self.done_exits_to_entrances[zone_exit] = zone_entrance
if zone_exit in self.banned_exits:
# Keep track of which islands have a required bosses mode banned dungeon to avoid marker overlap.
if zone_exit in DUNGEON_EXITS + BOSS_EXITS:
# We only keep track of dungeon exits and boss exits, not miniboss exits.
# Banned miniboss exits can share an island with required dungeons/bosses.
outer_entrance = self.get_outermost_entrance_for_entrance(zone_entrance)
# Because we filter above so that we always assign entrances from the sea inwards, we can assume
# that when we assign an entrance, it has a path back to the sea.
# If we're assigning a non-terminal entrance, any nested entrances will get assigned after this one,
# and we'll run through this code again (so we can reason based on `zone_exit` only instead of
# having to recurse through the nested exits to find banned dungeons/bosses).
assert outer_entrance and outer_entrance.island_name is not None
self.islands_with_a_banned_dungeon.add(outer_entrance.island_name)
def finalize_all_randomized_sets_of_entrances(self) -> None:
"""
Finalize all randomized entrance sets.
For all entrance-exit pairs, this function adds a connection with the appropriate access rule to the world.
"""
def get_access_rule(entrance: ZoneEntrance) -> str:
snake_case_region = entrance.entrance_name.lower().replace("'", "").replace(" ", "_")
return getattr(Macros, f"can_access_{snake_case_region}")
# Connect each entrance-exit pair in the multiworld with the access rule for the entrance.
# The Great Sea is the parent_region for many entrances, so get it in advance.
great_sea_region = self.world.get_region("The Great Sea")
for zone_entrance, zone_exit in self.done_entrances_to_exits.items():
# Get the parent region of the entrance.
if zone_entrance.island_name is not None:
# Entrances with an `island_name` are found in The Great Sea.
parent_region = great_sea_region
else:
# All other entrances must be nested within some other region.
parent_region = self.world.get_region(zone_entrance.nested_in.unique_name)
exit_region_name = zone_exit.unique_name
exit_region = self.world.get_region(exit_region_name)
parent_region.connect(
exit_region,
# The default name uses the "parent_region -> connecting_region", but the parent_region would not be
# useful for spoiler paths or debugging, so use the entrance name at the start.
f"{zone_entrance.entrance_name} -> {exit_region_name}",
rule=lambda state, rule=get_access_rule(zone_entrance): rule(state, self.player),
)
if __debug__ and self.world.options.required_bosses:
# Ensure we didn't accidentally place a banned boss and a required boss on the same island.
banned_island_names = set(
self.get_entrance_zone_for_boss(boss_name) for boss_name in self.world.boss_reqs.banned_bosses
)
required_island_names = set(
self.get_entrance_zone_for_boss(boss_name) for boss_name in self.world.boss_reqs.required_bosses
)
assert not banned_island_names & required_island_names
def register_mappings_between_item_locations_and_zone_exits(self) -> None:
"""
Map item locations to their corresponding zone exits.
"""
for loc_name in list(LOCATION_TABLE.keys()):
zone_exit = self.get_zone_exit_for_item_location(loc_name)
if zone_exit is not None:
self.item_location_to_containing_zone_exit[loc_name] = zone_exit
self.zone_exit_to_logically_dependent_item_locations[zone_exit].append(loc_name)
if loc_name == "The Great Sea - Withered Trees":
# This location isn't inside a zone exit, but it does logically require the player to be able to reach
# a different item location inside one.
sub_zone_exit = self.get_zone_exit_for_item_location("Cliff Plateau Isles - Highest Isle")
if sub_zone_exit is not None:
self.zone_exit_to_logically_dependent_item_locations[sub_zone_exit].append(loc_name)
def get_all_entrance_sets_to_be_randomized(
self,
) -> Generator[tuple[list[ZoneEntrance], list[ZoneExit]], None, None]:
"""
Retrieve all entrance-exit pairs that need to be randomized.
:raises OptionError: If an invalid randomization option is set in the world's options.
:return: A generator that yields sets of entrances and exits to be randomized.
"""
options = self.world.options
dungeons = bool(options.randomize_dungeon_entrances)
minibosses = bool(options.randomize_miniboss_entrances)
bosses = bool(options.randomize_boss_entrances)
secret_caves = bool(options.randomize_secret_cave_entrances)
inner_caves = bool(options.randomize_secret_cave_inner_entrances)
fountains = bool(options.randomize_fairy_fountain_entrances)
mix_entrances = options.mix_entrances
if mix_entrances == "separate_pools":
if dungeons:
yield self.get_one_entrance_set(dungeons=dungeons)
if minibosses:
yield self.get_one_entrance_set(minibosses=minibosses)
if bosses:
yield self.get_one_entrance_set(bosses=bosses)
if secret_caves:
yield self.get_one_entrance_set(caves=secret_caves)
if inner_caves:
yield self.get_one_entrance_set(inner_caves=inner_caves)
if fountains:
yield self.get_one_entrance_set(fountains=fountains)
elif mix_entrances == "mix_pools":
yield self.get_one_entrance_set(
dungeons=dungeons,
minibosses=minibosses,
bosses=bosses,
caves=secret_caves,
inner_caves=inner_caves,
fountains=fountains,
)
else:
raise OptionError(f"Invalid entrance randomization option: {mix_entrances}")
def get_one_entrance_set(
self,
*,
dungeons: bool = False,
caves: bool = False,
minibosses: bool = False,
bosses: bool = False,
inner_caves: bool = False,
fountains: bool = False,
) -> tuple[list[ZoneEntrance], list[ZoneExit]]:
"""
Retrieve a single set of entrance-exit pairs that need to be randomized.
:param dungeons: Whether to include dungeon entrances and exits. Defaults to `False`.
:param caves: Whether to include secret cave entrances and exits. Defaults to `False`.
:param minibosses: Whether to include miniboss entrances and exits. Defaults to `False`.
:param bosses: Whether to include boss entrances and exits. Defaults to `False`.
:param inner_caves: Whether to include inner cave entrances and exits. Defaults to `False`.
:param fountains: Whether to include fairy fountain entrances and exits. Defaults to `False`.
:return: A tuple of lists of entrances and exits that should be randomized together.
"""
relevant_entrances: list[ZoneEntrance] = []
relevant_exits: list[ZoneExit] = []
if dungeons:
relevant_entrances += DUNGEON_ENTRANCES
relevant_exits += DUNGEON_EXITS
if minibosses:
relevant_entrances += MINIBOSS_ENTRANCES
relevant_exits += MINIBOSS_EXITS
if bosses:
relevant_entrances += BOSS_ENTRANCES
relevant_exits += BOSS_EXITS
if caves:
relevant_entrances += SECRET_CAVE_ENTRANCES
relevant_exits += SECRET_CAVE_EXITS
if inner_caves:
relevant_entrances += SECRET_CAVE_INNER_ENTRANCES
relevant_exits += SECRET_CAVE_INNER_EXITS
if fountains:
relevant_entrances += FAIRY_FOUNTAIN_ENTRANCES
relevant_exits += FAIRY_FOUNTAIN_EXITS
return relevant_entrances, relevant_exits
def get_outermost_entrance_for_exit(self, zone_exit: ZoneExit) -> Optional[ZoneEntrance]:
"""
Unrecurses nested dungeons to determine a given exit's outermost (island) entrance.
:param zone_exit: The given exit.
:return: The outermost (island) entrance for the exit, or `None` if entrances have yet to be randomized.
"""
zone_entrance = self.done_exits_to_entrances[zone_exit]
return self.get_outermost_entrance_for_entrance(zone_entrance)
def get_outermost_entrance_for_entrance(self, zone_entrance: ZoneEntrance) -> Optional[ZoneEntrance]:
"""
Unrecurses nested dungeons to determine a given entrance's outermost (island) entrance.
:param zone_exit: The given entrance.
:return: The outermost (island) entrance for the entrance, or `None` if entrances have yet to be randomized.
"""
seen_entrances = self.get_all_entrances_on_path_to_entrance(zone_entrance)
if seen_entrances is None:
# Undecided.
return None
outermost_entrance = seen_entrances[-1]
return outermost_entrance
def get_all_entrances_on_path_to_entrance(self, zone_entrance: ZoneEntrance) -> Optional[list[ZoneEntrance]]:
"""
Unrecurses nested dungeons to build a list of all entrances leading to a given entrance.
:param zone_exit: The given entrance.
:return: A list of entrances leading to the given entrance, or `None` if entrances have yet to be randomized.
"""
seen_entrances: list[ZoneEntrance] = []
while zone_entrance.is_nested:
if zone_entrance in seen_entrances:
path_str = ", ".join([e.entrance_name for e in seen_entrances])
raise FillError(f"Entrances are in an infinite loop: {path_str}")
seen_entrances.append(zone_entrance)
if zone_entrance.nested_in not in self.done_exits_to_entrances:
# Undecided.
return None
zone_entrance = self.done_exits_to_entrances[zone_entrance.nested_in]
seen_entrances.append(zone_entrance)
return seen_entrances
def is_item_location_behind_randomizable_entrance(self, location_name: str) -> bool:
"""
Determine if the location is behind a randomizable entrance.
:param location_name: The location to check.
:return: `True` if the location is behind a randomizable entrance, `False` otherwise.
"""
loc_zone_name, _ = split_location_name_by_zone(location_name)
if loc_zone_name in ["Ganon's Tower", "Mailbox"]:
# Ganon's Tower and the handful of Mailbox locations that depend on beating dungeon bosses are considered
# "Dungeon" location types by the logic, but the entrance randomizer does not need to consider them.
# Although the mail locations are technically locked behind dungeons, we can still ignore them here because
# if all of the locations in the dungeon itself are nonprogress, then any mail depending on that dungeon
# should also be enforced as nonprogress by other parts of the code.
return False
types = LOCATION_TABLE[location_name].flags
is_boss = TWWFlag.BOSS in types
if loc_zone_name == "Forsaken Fortress" and not is_boss:
# Special case. FF is a dungeon that is not randomized, except for the boss arena.
return False
is_big_octo = TWWFlag.BG_OCTO in types
if is_big_octo:
# The Big Octo Great Fairy is the only Great Fairy location that is not also a Fairy Fountain.
return False
# In the general case, we check if the location has a type corresponding to exits that can be randomized.
if any(t in types for t in ENTRANCE_RANDOMIZABLE_ITEM_LOCATION_TYPES):
return True
return False
def get_zone_exit_for_item_location(self, location_name: str) -> Optional[ZoneExit]:
"""
Retrieve the zone exit for a given location.
:param location_name: The name of the location.
:raises Exception: If a location exit override should be used instead.
:return: The zone exit for the location or `None` if the location is not behind a randomizable entrance.
"""
if not self.is_item_location_behind_randomizable_entrance(location_name):
return None
zone_exit = ITEM_LOCATION_NAME_TO_EXIT_OVERRIDES.get(location_name, None)
if zone_exit is not None:
return zone_exit
loc_zone_name, _ = split_location_name_by_zone(location_name)
possible_exits = [ex for ex in ZoneExit.all.values() if ex.zone_name == loc_zone_name]
if len(possible_exits) == 0:
return None
elif len(possible_exits) == 1:
return possible_exits[0]
else:
raise Exception(
f"Multiple zone exits share the same zone name: {loc_zone_name!r}. "
"Use a location exit override instead."
)
def get_entrance_zone_for_boss(self, boss_name: str) -> str:
"""
Retrieve the entrance zone for a given boss.
:param boss_name: The name of the boss.
:return: The name of the island on which the boss is located.
"""
boss_arena_name = f"{boss_name} Boss Arena"
zone_exit = ZoneExit.all[boss_arena_name]
outermost_entrance = self.get_outermost_entrance_for_exit(zone_exit)
assert outermost_entrance is not None and outermost_entrance.island_name is not None
return outermost_entrance.island_name