Files
dockipelago/worlds/tloz_ph/__init__.py
Jonathan Tinney 7971961166
Some checks failed
Analyze modified files / flake8 (push) Failing after 2m28s
Build / build-win (push) Has been cancelled
Build / build-ubuntu2204 (push) Has been cancelled
ctest / Test C++ ubuntu-latest (push) Has been cancelled
ctest / Test C++ windows-latest (push) Has been cancelled
Analyze modified files / mypy (push) Has been cancelled
Build and Publish Docker Images / Push Docker image to Docker Hub (push) Successful in 5m4s
Native Code Static Analysis / scan-build (push) Failing after 5m2s
type check / pyright (push) Successful in 1m7s
unittests / Test Python 3.11.2 ubuntu-latest (push) Failing after 16m23s
unittests / Test Python 3.12 ubuntu-latest (push) Failing after 28m19s
unittests / Test Python 3.13 ubuntu-latest (push) Failing after 14m49s
unittests / Test hosting with 3.13 on ubuntu-latest (push) Successful in 5m0s
unittests / Test Python 3.13 macos-latest (push) Has been cancelled
unittests / Test Python 3.11 windows-latest (push) Has been cancelled
unittests / Test Python 3.13 windows-latest (push) Has been cancelled
add schedule I, sonic 1/frontiers/heroes, spirit island
2026-04-02 23:46:36 -07:00

1561 lines
79 KiB
Python

import os
import logging
import random
from math import ceil
from typing import List, Union, ClassVar, Any, Optional, Tuple, TYPE_CHECKING
import settings
from BaseClasses import Tutorial, Region, Location, LocationProgressType, Item, ItemClassification, Entrance
from Fill import fill_restrictive, FillError
from Options import Accessibility, OptionError
from entrance_rando import randomize_entrances, bake_target_group_lookup, EntranceRandomizationError, disconnect_entrance_for_randomization
from worlds.AutoWorld import WebWorld, World
from .Util import *
from .Options import *
from .Logic import create_connections
from .data import LOCATIONS_DATA
from .data.Constants import *
from .data.Items import ITEMS
from .data.Regions import REGIONS
from .data.LogicPredicates import *
from .data.Entrances import ENTRANCES, entrance_id_to_region, EVENTS, entrance_id_to_entrance
from .Subclasses import PHRegion, decode_entrance_groups, update_switch_logic, EntranceGroups, OPPOSITE_ENTRANCE_GROUPS
from .Client import PhantomHourglassClient # Unused, but required to register with BizHawkClient
from .tracker.TrackerUtil import TRACKER_WORLD
logger = logging.getLogger("Client")
dev_prints = False
if TYPE_CHECKING:
from .Subclasses import ERPlacementState, PHEntrance, PHRegion, PHTransition
class PhantomHourglassWeb(WebWorld):
setup_en = Tutorial(
"Phantom Hourglass Setup Guide",
"A guide to setting up Phantom Hourglass Archipelago Randomizer on your computer.",
"English",
"setup.md",
"setup/en",
["Carrotinator"]
)
faq = Tutorial(
"Phantom Hourglass FAQ",
"Questions you might have about the implementation, and credits",
"English",
"faq_and_credits.md",
"faq/en",
["Carrotinator"]
)
tricks = Tutorial(
"Phantom Hourglass Tricks and Skips",
"Tricks and skips that might be required in harder logic settings, with videos when available",
"English",
"tricks_and_skips.md",
"tricks_and_skips/en",
["Carrotinator"]
)
tutorials = [setup_en, faq, tricks]
game = "The Legend of Zelda - Phantom Hourglass"
theme = "ocean"
option_groups = ph_option_groups
class PhantomHourglassSettings(settings.Group):
class PHGetLogicalPathShortcuts(str):
"""
For use with universal tracker.
Toggles if universal tracker can use unlocked shortcuts and map warps to find shorter paths for /get_logical_path.
"""
class BoatSpeed(int):
"""Your boat's max speed. Default is 266."""
class BoatFastAccel(str):
"""Makes your boat accelerate instantly after charting a route or changing gear."""
ut_get_logical_path_shortcuts: Union[PHGetLogicalPathShortcuts, bool] = True
boat_speed: BoatSpeed = 0x10A
boat_snap_speed: Union[BoatFastAccel, bool] = True
# Adds a consistent count of items to pool, independent of how many are from locations
def add_items_from_filler(item_pool_dict: dict, filler_item_count: int, item: str, count: int):
if filler_item_count >= count:
filler_item_count -= count
item_pool_dict[item] = item_pool_dict.get(item, 0) + count
else:
item_pool_dict[item] = filler_item_count
filler_item_count = 0
print(f"Ran out of filler items! on item {item}")
return [item_pool_dict, filler_item_count]
def add_spirit_gems(pack_option, add_option):
if pack_option == 1:
return {"Power Gem": 20, "Wisdom Gem": 20, "Courage Gem": 20}
else:
count = ceil(20 / pack_option.value) + add_option
return {"Power Gem Pack": count, "Wisdom Gem Pack": count, "Courage Gem Pack": count}
def add_sand(starting_time, time_incr, time_logic):
max_sand_count = ceil((5999 - starting_time) / time_incr)
max_time = 1
if time_logic <= 2:
max_time = 310 // [1, 2, 4, 0.5][time_logic]
min_sand_count = ceil(max(max_time - starting_time, 1) / time_incr)
if min_sand_count > 20:
print(f"Too many sand items? Adding {min_sand_count} Sands or Hours to pool")
# Balance to limits
sand_count = min_sand_count + 2
if sand_count < 5:
sand_count = 5
if sand_count > max_sand_count:
sand_count = max_sand_count
# print(f"Sand count: {sand_count} total {starting_time.value + min_sand_count * time_incr.value}")
return {"Sand of Hours": sand_count}
def add_beedle_point_items():
return {"Beedle Points (50)": 2, "Beedle Points (20)": 3, "Beedle Points (10)": 4}
def add_pedestal_items(place, option, excluded_dungeons):
res = dict()
def add_from_group(g, count=1):
return {n: count for n in ITEM_GROUPS[g]}
# Create items
if option == "open_globally":
res |= add_from_group("Global Pedestal Items")
elif option == "open_per_dungeon":
res |= add_from_group("Regular Crystal Items")
res |= add_from_group("Unique Force Gems", 3)
elif option == "unique_pedestals":
res |= add_from_group("Unique Crystal Items")
res |= add_from_group("Unique Force Gems", 3)
return res
class PhantomHourglassWorld(World):
"""
The Legend of Zelda: Phantom Hourglass is the sea bound handheld sequel to the Wind Waker.
"""
game = "The Legend of Zelda - Phantom Hourglass"
options_dataclass = PhantomHourglassOptions
options: PhantomHourglassOptions
required_client_version = (0, 6, 0)
web = PhantomHourglassWeb()
topology_present = True
settings: ClassVar[PhantomHourglassSettings]
settings_key = "tloz_ph_options"
location_name_to_id = build_location_name_to_id_dict()
item_name_to_id = build_item_name_to_id_dict()
item_name_groups = ITEM_GROUPS
location_name_groups = LOCATION_GROUPS
origin_region_name = "Menu"
glitches_item_name = "_UT_Glitched_Logic"
ut_can_gen_without_yaml = True
location_id_to_alias: Dict[int, str]
tracker_world = TRACKER_WORLD
found_entrances_datastorage_key = ["ph_checked_entrances_{player}_{team}",
"ph_keylocking_{player}_{team}",
"ph_ut_events_{player}_{team}",
"ph_disconnect_entrances_{player}_{team}",
"ph_traversed_entrances_{player}_{team}"]
def __init__(self, multiworld, player):
super().__init__(multiworld, player)
self.pre_fill_items: List[Item] = []
self.required_dungeons = []
self.boss_reward_items_pool = []
self.boss_reward_location_names = []
self.dungeon_name_groups = {}
self.post_dungeon_name_groups = {}
self.boss_room_name_groups = {}
self.locations_to_exclude = set()
self.extra_filler_items = []
self.excluded_dungeons = []
self.ut_pairings = {}
self.manual_er_pairings = []
self.plando_er_pairings = []
self.required_bosses = []
self.entrances: dict[str, "Entrance"] = {}
self.er_placement_state = None
self.ut_connected_entrances = set()
self.ut_redisconnected_entrances = set()
self.ut_traversed_entrances = set()
self.ut_reconnected_entrances = set()
self.disconnected_exits_map = {}
self.ut_excluded = []
self.ut_created_events = []
self.treasure_price_index = 0
self.ut_map_page_hidden_locations = {}
self.ut_map_page_hidden_entrances = {}
self.ut_map_page_hidden_events = {}
self.is_ut = getattr(self.multiworld, "generation_is_fake", False)
def generate_early(self):
re_gen_passthrough = getattr(self.multiworld, "re_gen_passthrough", {})
if re_gen_passthrough and self.game in re_gen_passthrough:
# Get the passed through slot data from the real generation
slot_data: dict[str, Any] = re_gen_passthrough[self.game]
# slot_options: dict[str, Any] = slot_data.get("options", {})
# Set all your options here instead of getting them from the yaml
for key, value in slot_data.items():
opt = getattr(self.options, key, None)
if opt is not None:
# You can also set .value directly but that won't work if you have OptionSets
setattr(self.options, key, opt.from_any(value))
# Set randomized data that effects exclusions etc
self.required_dungeons = list(slot_data["required_dungeons"])
self.boss_reward_items_pool = slot_data["boss_reward_items_pool"]
self.ut_pairings = slot_data.get("er_pairings", {})
self.treasure_price_index = slot_data.get("treasure_price_index", 0)
# Figure out what events are active, and add to ut_pairings
print(F"Generating early")
print(f"UT Pairings: {self.ut_pairings}")
if (self.options.ut_events and getattr(self.multiworld, "enforce_deferred_connections", "default") != "off"):
for event in EVENTS.values():
if self.options.ut_events == "unique_events" and event.extra_data.get("shared_event", False):
continue
if "GOAL" in event.name:
if self.options.goal_requirements != "triforce_door" and event.name in ["GOAL: Triforce Door"]:
continue
if self.options.bellum_access != "win" and event.name in ["GOAL"]:
continue
if ((self.options.goal_requirements == "triforce_door" or self.options.bellum_access == "win")
and event.name in ["GOAL: Bellumbeck"]):
continue
if not self.options.shuffle_houses and event.name == "EVENT: Open Eddo's Door":
continue
if not self.options.shuffle_overworld_transitions and event.name == "EVENT: Gust Windmills":
continue
if "Unnamed Entrance" in event.name:
continue
print(f"Adding Event: {event.name} {event.id} => {event.vanilla_reciprocal.id}")
self.ut_pairings[str(event.id)] = event.vanilla_reciprocal.id
# Hide stuff in UT map page based on what entrances are randomized
if not self.ut_map_page_hidden_locations or not self.ut_map_page_hidden_entrances:
from .tracker.TrackerUtil import get_hidden_entrances
self.ut_map_page_hidden_locations, self.ut_map_page_hidden_entrances = get_hidden_entrances(self)
else:
self.pick_required_dungeons()
if self.options.shuffle_dungeon_entrances:
self.options.dungeon_shortcuts.value = 0
if self.options.randomize_boss_keys:
self.options.boss_key_behaviour.value = 1
# Dungeon hint restrictions
if self.options.shuffle_bosses.value == 2 and self.options.dungeon_hint_type == "hint_dungeon":
self.options.dungeon_hint_type.value = 1
if not self.options.exclude_non_required_dungeons:
self.options.excluded_dungeon_hints.value = 0
# Treasure Prices
self.treasure_price_index = self.random.randint(0, 9)
self.restrict_non_local_items()
def restrict_non_local_items(self):
# Restrict non_local_items option in cases where it's incompatible with other options that enforce items
# to be placed locally (e.g. dungeon items with keysanity off)
if not self.options.keysanity == "anywhere":
self.options.non_local_items.value -= set(ITEM_GROUPS["Small Keys"])
self.options.non_local_items.value -= set(ITEM_GROUPS["Throwable Keys"])
self.options.non_local_items.value -= set(self.boss_reward_items_pool)
def create_location(self, region_name: str, location_name: str, local: bool):
region = self.multiworld.get_region(region_name, self.player)
location = Location(self.player, location_name, self.location_name_to_id[location_name], region)
region.locations.append(location)
def add_to_name_group(group_name, group_var):
if group_name in LOCATIONS_DATA[location_name]:
group_var.setdefault(LOCATIONS_DATA[location_name][group_name], set())
group_var[LOCATIONS_DATA[location_name][group_name]].add(location_name)
# Used for excluding room sets
add_to_name_group("dungeon", self.dungeon_name_groups)
add_to_name_group("post_dungeon", self.post_dungeon_name_groups)
add_to_name_group("boss_room", self.boss_room_name_groups)
if local:
location.item_rule = lambda item: item.player == self.player
def create_regions(self):
# Add region aliases if UT
all_regions = set(REGIONS)
if self.is_ut:
all_regions.update(set(ENTRANCES.keys()))
for aliases in region_aliases.values():
all_regions.update(aliases)
# Create regions
for region_name in all_regions:
region = PHRegion(region_name, self.player, self.multiworld)
self.multiworld.regions.append(region)
# Create locations
for location_name, location_data in LOCATIONS_DATA.items():
if not self.location_is_active(location_name, location_data):
continue
is_local = "local" in location_data and location_data["local"] is True
self.create_location(location_data['region_id'], location_name, is_local)
self.create_events()
self.exclude_locations_automatically()
def create_event(self, region_name, event_item_name):
region = self.multiworld.get_region(region_name, self.player)
location = Location(self.player, region_name + ".event", None, region)
region.locations.append(location)
location.place_locked_item(Item(event_item_name, ItemClassification.progression, None, self.player))
def location_is_active(self, location_name, location_data):
if not location_data.get("conditional", False):
return True
else:
if location_name in LOCATION_GROUPS["Golden Frogs"]:
return self.options.randomize_frogs != PhantomHourglassFrogRandomization.option_start_with
if location_name in LOCATION_GROUPS["Rupee Dig Spots"]:
return self.options.randomize_digs
if "Archery Minigame 2000" == location_name:
return self.options.logic in ["hard", "glitched"] and self.options.randomize_minigames
if location_name in LOCATION_GROUPS["Minigames"]:
return self.options.randomize_minigames
if location_name in LOCATION_GROUPS["Fishing Locations"]:
return self.options.randomize_fishing
if location_name in LOCATION_GROUPS["Salvage Locations"]:
return self.options.randomize_salvage
if "Beedle Membership" in location_name:
return self.options.randomize_beedle_membership.value > 1
if "Harrow Island" in location_name:
return self.options.randomize_harrow
if "Zauz's House Triforce Crest" == location_name:
return self.options.randomize_triforce_crest
if "Masked Beedle" in location_name:
return self.options.randomize_masked_beedle
# if "GOAL" in location_name:
# if location_name == "GOAL: Beat Bellumbeck" and self.options.bellum_access != "win":
# return True
# elif location_name == "GOAL: Triforce Door" and self.options.goal_requirements == "triforce_door":
# return True
if location_name == "Man of Smiles' Prize Postcard": # This it pretty random but whatever...
return self.options.randomize_beedle_membership.value > 0
if "EVENT" in location_name:
print(f"Found event {location_name} {self.is_ut}")
return self.is_ut
return False
def pick_required_dungeons(self):
implemented_dungeons = ["Temple of Fire",
"Temple of Wind",
"Temple of Courage",
"Goron Temple",
"Temple of Ice",
"Mutoh's Temple",
"Ghost Ship",
"Temple of the Ocean King"]
# Remove optional dungeons from pool
if self.options.ghost_ship_in_dungeon_pool.value == 2:
implemented_dungeons.remove("Ghost Ship")
if not self.options.totok_in_dungeon_pool:
implemented_dungeons.remove("Temple of the Ocean King")
self.random.shuffle(implemented_dungeons)
# Cap dungeons required if over the number of eligible dungeons
dungeons_required = len(implemented_dungeons) if self.options.dungeons_required > len(implemented_dungeons) \
else self.options.dungeons_required.value
self.options.dungeons_required.value = dungeons_required
self.required_dungeons = implemented_dungeons[:dungeons_required]
# Cap zauz metals at number of metals
if self.options.goal_requirements == "defeat_bosses":
if self.options.zauz_required_metals > dungeons_required:
self.options.zauz_required_metals.value = dungeons_required
elif self.options.goal_requirements == "metal_hunt":
if self.options.zauz_required_metals > self.options.metal_hunt_total:
self.options.zauz_required_metals.value = self.options.metal_hunt_total.value
else:
self.options.zauz_required_metals.value = 0
# Cap metal hunt items
if self.options.metal_hunt_total < self.options.metal_hunt_required:
self.options.metal_hunt_total.value = self.options.metal_hunt_required.value
# Extend mcguffin list
if self.options.goal_requirements == "defeat_bosses":
if self.options.require_specific_bosses:
reward_count = self.options.dungeons_required
else:
reward_count = 6
if self.options.totok_in_dungeon_pool:
reward_count += 1
if self.options.ghost_ship_in_dungeon_pool.value != 2:
reward_count += 1
self.boss_reward_items_pool = self.pick_metals(reward_count)
def pick_metals(self, count):
metal_items: list = list(ITEM_GROUPS["Vanilla Metals"])
extended_pool: list = []
if self.options.additional_metal_names == "vanilla_only":
extended_pool = list(ITEM_GROUPS["Vanilla Metals"])
elif self.options.additional_metal_names == "additional_rare_metal":
extended_pool = ["Additional Rare Metal"]
elif self.options.additional_metal_names == "custom":
metal_items += ITEM_GROUPS["Custom Metals"]
extended_pool = list(ITEM_GROUPS["Metals"])
elif self.options.additional_metal_names == "custom_prefer_vanilla":
metal_items = list(ITEM_GROUPS["Custom Metals"])
extended_pool = list(ITEM_GROUPS["Metals"])
while len(metal_items) < count:
metal_items += self.random.choice([extended_pool])
self.random.shuffle(metal_items)
if self.options.additional_metal_names == "custom_prefer_vanilla":
vanillas = list(ITEM_GROUPS["Vanilla Metals"])
self.random.shuffle(vanillas)
metal_items = vanillas + metal_items
return metal_items[:count]
def create_events(self):
# Create events for required dungeons
if self.options.goal_requirements == "defeat_bosses":
if "Blaaz Boss Reward" in self.required_bosses:
self.create_event("Post Blaaz", "_required_dungeon")
if "Cyclok Boss Reward" in self.required_bosses:
self.create_event("Post Cyclok", "_required_dungeon")
if "Crayk Boss Reward" in self.required_bosses:
self.create_event("Post Crayk", "_required_dungeon")
if "_gs" in self.required_bosses:
if self.options.ghost_ship_in_dungeon_pool == "rescue_tetra":
self.create_event("Ghost Ship Tetra", "_required_dungeon")
elif self.options.ghost_ship_in_dungeon_pool == "cubus_sisters":
self.create_event("Post Cubus Sisters", "_required_dungeon")
if "Cubus Sisters Ghost Key" in self.required_bosses:
self.create_event("Post Cubus Sisters", "_required_dungeon")
if "Dongo Boss Reward" in self.required_bosses:
self.create_event("Post Dongorongo", "_required_dungeon")
if "Gleeok Boss Reward" in self.required_bosses:
self.create_event("Post Gleeok", "_required_dungeon")
if "Eox Boss Reward" in self.required_bosses:
self.create_event("Post Eox", "_required_dungeon")
# Post Dungeon Events
self.create_event("Post ToF", "_beat_tof")
self.create_event("Post ToC", "_beat_toc")
self.create_event("Post ToW", "_beat_tow")
self.create_event("Post GT", "_beat_gt")
self.create_event("Post ToI", "_beat_toi")
self.create_event("Post MT", "_beat_mt")
self.create_event("Spawn Pirate Ambush", "_beat_ghost_ship")
# Farmable minigame events
self.create_event("Bannan Cannon Game", "_can_play_cannon_game")
self.create_event("Archery Game", "_can_play_archery")
self.create_event("Harrow Minigame", "_can_play_harrow")
self.create_event("Dee Ess Goron Race", "_can_play_goron_race")
self.create_event("TotOK B1 Phantom", "_can_farm_totok")
# Wayfarer Trade Quest
self.create_event("Wayfarer Event", "_wayfarer_gift")
self.create_event("SS Wayfarer Event", "_wayfarer_trade")
# Shop stuff
self.create_event("Treasure Teller", "_has_treasure_teller")
# Switch states etc
self.create_event("Bremeur's Temple Event", "_ruins_lower_water")
self.create_event("Gust North Event", "_windmills")
self.create_event("Goron Chus Event", "_goron_chus")
self.create_event("Goron SE Bridge Event", "_goron_bridge")
self.create_event("Goron NE Event", "_goron_maze_switch")
self.create_event("Eddo Event", "_eddo_door")
self.create_event("ToI B1 Switch", "_toi_b1_switch")
self.create_event("Ghost Ship B3", "_rescue_4th_sister")
# Blue warps
self.create_event("ToI Blue Warp", "_toi_blue_warp")
# Mountain passage
self.create_event("Mountain Passage 1", "_mp1")
self.create_event("Mountain Passage Rat", "_mp3")
# Goal
self.create_event("Goal", "_beaten_game")
def exclude_locations_automatically(self):
locations_to_exclude = set()
# If non required dungeons need to be excluded, and UT can now participate too!
if self.options.exclude_non_required_dungeons:
always_include = ["Temple of the Ocean King", "Mountain Passage"]
excluded_dungeons = [d for d in DUNGEON_NAMES
if d not in self.required_dungeons + always_include]
self.excluded_dungeons = excluded_dungeons
# print(f"Excluded dungeons: {self.excluded_dungeons}")
for dungeon in excluded_dungeons:
locations_to_exclude.update(self.dungeon_name_groups[dungeon])
# hold off on excluding boss rooms/post boss locations if bosses are shuffled. mixed pool bosses don't inherit dungeon status
if self.options.shuffle_bosses != 1 or self.options.decouple_entrances or dungeon == "Ghost Ship":
locations_to_exclude.update(self.boss_room_name_groups.get(dungeon, []))
locations_to_exclude.update(self.post_dungeon_name_groups.get(dungeon, []))
if not self.options.shuffle_houses and dungeon == "Temple of Fire":
locations_to_exclude.add("Shipyard Chest")
self.locations_to_exclude = locations_to_exclude
for name in locations_to_exclude:
self.multiworld.get_location(name, self.player).progress_type = LocationProgressType.EXCLUDED
def create_er_target_groups(self, type_option_lookup):
simple_mixed_pool = []
for a, option in type_option_lookup.items():
if option == "simple_mixed_pool":
simple_mixed_pool.append(a)
unique_groups = {entrance.randomization_group for entrance in self.multiworld.get_entrances(self.player)
if entrance.parent_region and not entrance.connected_region}
def get_target_groups(g: int) -> list[int]:
direction = g & EntranceGroups.DIRECTION_MASK
area = (g & EntranceGroups.AREA_MASK) >> 3
island = (g & EntranceGroups.ISLAND_MASK) >> 7
target_directions, target_areas, target_islands = [], [], set()
in_simple_mixed_pool = area in simple_mixed_pool
# print(f"{decode_entrance_groups(g)} in simple pool? {in_simple_mixed_pool}")
# Create target direction list
if ((in_simple_mixed_pool and self.options.entrance_directionality.value in [1, 2]) or
(not in_simple_mixed_pool and self.options.entrance_directionality.value in [1, 3])):
#if area == 1 and (not in_simple_mixed_pool or len(simple_mixed_pool) == 1):
# 90% if houses are dead ends, and GER can't handle that with disregarded directionality
# target_directions = [OPPOSITE_ENTRANCE_GROUPS[direction]]
# else:
target_directions = range(7)
else:
target_directions = [OPPOSITE_ENTRANCE_GROUPS[direction]]
# Create target type list
if in_simple_mixed_pool:
target_areas += simple_mixed_pool
else:
target_areas.append(area)
# Create target island list
if ((in_simple_mixed_pool and self.options.shuffle_between_islands.value in [0, 3])
or (not in_simple_mixed_pool
and self.options.shuffle_between_islands.value in [0, 2]
and type_option_lookup[area].value != 3)):
target_islands.update(range(15))
else:
target_islands.add(island)
# ports still need to be able to connect to the sea
if area == 3:
target_islands.update(range(15))
if in_simple_mixed_pool and 3 in simple_mixed_pool:
target_islands.add(0)
if island == 0:
target_islands.update(range(15))
def island_iter(loop, t):
ret = []
for i in loop:
new_group = d | (t << 3) | (i << 7)
if new_group in unique_groups:
ret.append(new_group)
return ret
def area_iter(loop):
ret = []
for t in loop:
if in_simple_mixed_pool and 3 in simple_mixed_pool and t == 3:
ret += island_iter(range(15), t)
else:
ret += island_iter(target_islands, t)
return ret
# Put it all together
res = []
for d in target_directions:
if in_simple_mixed_pool and 3 in simple_mixed_pool and area == 3:
res += area_iter(simple_mixed_pool)
else:
res += area_iter(target_areas)
if dev_prints and False:
print(f"res: {decode_entrance_groups(g)}")
print(f"\t{sorted([decode_entrance_groups(i) for i in res])}")
return res
return bake_target_group_lookup(self, get_target_groups)
def connect_entrances(self) -> None:
# UT only needs to disconnect entrances, use slot data pairings to figure out which
if self.is_ut:
disconnect_ids = {int(i) for i in self.ut_pairings.keys()}
for e in self.entrances.values():
if ENTRANCES[e.name].id in disconnect_ids:
target_name = ENTRANCES[e.name].vanilla_reciprocal.name
disconnect_entrance_for_randomization(e, one_way_target_name=target_name)
if getattr(self.multiworld, "enforce_deferred_connections", "default") == "off":
for i, pairing in self.ut_pairings.items():
_exit: "Entrance" = self.get_entrance(entrance_id_to_entrance[int(i)].name)
entrance_region: "Region" = self.get_region(entrance_id_to_region[pairing])
_exit.connect(entrance_region)
else:
# What option corresponds with what type
type_option_lookup = {
1: self.options.shuffle_houses,
2: self.options.shuffle_caves,
3: self.options.shuffle_ports,
4: self.options.shuffle_overworld_transitions,
5: self.options.shuffle_dungeon_entrances,
6: self.options.shuffle_bosses,
7: self.options.shuffle_dungeons_internally,
8: self.options.shuffle_dungeons_internally,
9: self.options.shuffle_caves,
10: self.options.shuffle_caves,
11: False # Events, UT only
}
# Filter entrances to disconnect by yaml settings
randomized_entrances: list["Entrance"] = []
plando_disconnects = set()
for i in self.options.plando_transitions.value:
plando_disconnects.add(i.entrance)
plando_disconnects.add(ENTRANCES[i.entrance].vanilla_reciprocal.name)
plando_disconnects.add(i.exit)
plando_disconnects.add(ENTRANCES[i.exit].vanilla_reciprocal.name)
if dev_prints:
print(f"Plando disconnects {plando_disconnects}")
for e in self.entrances.values():
# print(f"ER: {e.name} {bin(e.randomization_group)} {bin(EntranceGroups.AREA_MASK)} {(e.randomization_group & EntranceGroups.AREA_MASK) >> 3}")
if type_option_lookup[(e.randomization_group & EntranceGroups.AREA_MASK) >> 3]:
if not (ENTRANCES[e.name].extra_data.get("glitched", False) and self.options.logic != "glitched"):
randomized_entrances.append(e)
elif e.name in plando_disconnects:
randomized_entrances.append(e)
# if self.options.shuffle_bosses and self.options.ghost_ship_in_dungeon_pool.value == 2 and self.options.exclude_non_required_dungeons:
# randomized_entrances.remove(self.entrances["Ghost Ship Cubus Sisters Reunion"])
# randomized_entrances.remove(self.entrances["Cubus Sisters Blue Warp"])
# Disconnect entrances to shuffle
for entrance in randomized_entrances:
target_name = ENTRANCES[entrance.name].vanilla_reciprocal.name
disconnect_entrance_for_randomization(entrance, one_way_target_name=target_name)
if dev_prints:
print(f"disconnected {entrance.name}, parent {entrance.parent_region}, child {entrance.connected_region}, group {entrance.randomization_group}")
# Get valid connection groups
groups = self.create_er_target_groups(type_option_lookup)
if dev_prints:
print(f"groups:")
for a, g in sorted(groups.items()):
print(f"\t{a}\t{decode_entrance_groups(a)}: {sorted([decode_entrance_groups(i) for i in g])}")
# Decide if coupled
coupled = not self.options.decouple_entrances
def on_connect(er_state: "ERPlacementState", placed_exits: list["PHEntrance"],
paired_entrances: list["PHEntrance"]):
# Super cursed way of passing switch state options
# if not hasattr(er_state, "switch_state_option"):
# er_state.switch_state_option = self.options.color_switch_behaviour
# Figure out what exits are new and need to inherit switch state stuff
new_exits = set()
if hasattr(er_state, "old_available_exits"):
new_exits = set(er_state.find_placeable_exits(True, er_state.entrance_lookup._usable_exits)) - er_state.old_available_exits
if dev_prints:
# print(f"\ton connecting {placed_exits}, revealed new exits {new_exits}")
pass
else:
er_state.old_available_exits = set()
# Pass on valid switch states to new available exits. Switch logic is backlogged for now
# for ex, entr in zip(placed_exits, paired_entrances):
# update_switch_logic(ex, entr, er_state, self.options.logic.value, self.options.color_switch_behaviour.value, new_exits)
# Update old exits now that you've used new exits
er_state.old_available_exits.update(new_exits)
# Super cursed way of passing in target group lookup to er_state
if not hasattr(er_state, "target_group_lookup"):
er_state.target_group_lookup = groups
return False
# Remove dead ends
for entr in placed_exits:
# print(f"\tConnected {entr.name} group {decode_entrance_groups(entr.randomization_group)}")
for i in er_state.dead_end_counter.values():
if entr.name in i.dead_ends:
i.dead_ends.remove(entr.name)
# print(f"\t\tremoved from {decode_entrance_groups(i.group)} dead ends")
if entr.name in i.others:
i.others.remove(entr.name)
# print(f"\t\tremoved from {decode_entrance_groups(i.group)} dead ends")
return False
# Connect plando first, cause they will not be redone if failed
self.connect_plando(self.options.plando_transitions)
# Do ER
ph_max_er_attempts = 10
for i in range(ph_max_er_attempts):
# Workaround cause ER likes to link dead ends to each other when ignoring directions.
# Concept borrowed from CodeGorilla's Crystalis implementation
try:
if not self.options.decouple_entrances: self.manual_er()
self.er_placement_state = randomize_entrances(self, coupled, groups, on_connect=on_connect)
break
except EntranceRandomizationError as error:
print(f"Phantom Hourglass ER failed {i+1} time(s)")
if i >= ph_max_er_attempts-1:
raise EntranceRandomizationError(
f"Phantom Hourglass: failed GER after {ph_max_er_attempts} attempts.")
# disconnect entrances again, but only if they got connected before
for region in self.get_regions():
# print(f"\tRegion: {region} | exits {[e for e in region.get_exits()]}")
for _exit in region.get_exits():
if (_exit.parent_region
and _exit.connected_region
and _exit in randomized_entrances):
# print(f"Disconnecting entrance {_exit} {_exit.randomization_group}")
target_name = ENTRANCES[_exit.name].vanilla_reciprocal.name
disconnect_entrance_for_randomization(_exit, one_way_target_name=target_name)
def generate_basic(self) -> None:
if not self.is_ut:
self.link_dungeon_to_boss()
def link_dungeon_to_boss(self):
# Required dungeon determines which bosses are required, so read the pairings to figure out what boss
# to put the reward on when bosses are shuffled
# also need to figure out exclusion for bosses and post boss locs
if not self.options.require_specific_bosses:
self.required_bosses = list(DUNGEON_TO_BOSS_ITEM_LOCATION.values())
if self.options.ghost_ship_in_dungeon_pool.value == 2:
self.required_bosses.remove("_gs")
if not self.options.totok_in_dungeon_pool:
self.required_bosses.remove("TotOK B13 Sea Chart Chest")
elif self.options.shuffle_bosses.value == 1 and not self.options.decouple_entrances:
self.required_bosses = []
for e1, e2 in self.er_placement_state.pairings:
if e1 in BOSS_STAIRCASES and BOSS_STAIRCASES[e1] in self.required_dungeons:
if (BOSS_STAIRCASES[e1] == "Ghost Ship"
and self.options.ghost_ship_in_dungeon_pool == "rescue_tetra"):
self.required_bosses.append("Ghost Ship Rescue Tetra")
elif e2 in BOSS_ENTRANCE_LOOKUP:
self.required_bosses.append(BOSS_ENTRANCE_LOOKUP[e2])
else:
raise KeyError(f"Weird boss entrance attempted, {e1} <=> {e2}")
if "Temple of the Ocean King" in self.required_dungeons:
self.required_bosses.append("TotOK B13 Sea Chart Chest")
# Exclude post boss locations if needed
if self.options.exclude_non_required_dungeons:
excluded_boss_keys = {BOSS_LOCATION_TO_DUNGEON[boss] for boss in BOSS_LOCATION_TO_DUNGEON if
boss not in self.required_bosses}
for dung in excluded_boss_keys:
for loc in self.boss_room_name_groups.get(dung, set()) | self.post_dungeon_name_groups.get(dung, set()):
if dung != "Ghost Ship":
self.multiworld.get_location(loc, self.player).progress_type = LocationProgressType.EXCLUDED
self.locations_to_exclude.add(loc)
if not self.options.shuffle_houses and dung == "Temple of Fire":
self.multiworld.get_location("Shipyard Chest", self.player).progress_type = LocationProgressType.EXCLUDED
self.locations_to_exclude.add("Shipyard Chest")
else:
self.required_bosses = [DUNGEON_TO_BOSS_ITEM_LOCATION[dung] for dung in
self.required_dungeons]
if "_gs" in self.required_bosses:
self.required_bosses.remove("_gs")
self.required_bosses.append(
GHOST_SHIP_BOSS_ITEM_LOCATION[self.options.ghost_ship_in_dungeon_pool.value])
# Add dungeon hints to start
if self.options.dungeon_hint_location.value == 0 and self.options.dungeon_hint_type == "hint_boss":
self.options.start_location_hints.value.update(self.required_bosses)
# Based on the messenger's plando connection by Aaron Wagner
def connect_plando(self, plando_connections: "PhantomHourglassEntrancePlando") -> None:
def remove_dangling_exit(region: Region) -> None:
# find the disconnected exit and remove references to it
for _exit in region.exits:
if not _exit.connected_region:
break
else:
raise ValueError(f"Unable to find randomized transition for {plando_connection}")
region.exits.remove(_exit)
def remove_dangling_entrance(region: Region) -> None:
# find the disconnected entrance and remove references to it
for _entrance in region.entrances:
if not _entrance.parent_region:
break
else:
raise ValueError(f"Invalid target region for {plando_connection}")
region.entrances.remove(_entrance)
for plando_connection in plando_connections:
# get the connecting regions
r1 = ENTRANCES[plando_connection.entrance]
reg1 = self.get_region(r1.entrance_region)
remove_dangling_exit(reg1)
r2 = ENTRANCES[plando_connection.exit]
reg2 = self.get_region(r2.entrance_region)
remove_dangling_entrance(reg2)
# connect the regions
reg1.connect(reg2)
self.plando_er_pairings.append((r1.name, r2.name))
if dev_prints:
print(f"Plando Connecting {r1} => {r2} with regions {reg1} => {reg2}")
print(f"ER pairings: {self.plando_er_pairings}")
# pretend the user set the plando direction as "both" regardless of what they actually put on coupled
if (self.options.decouple_entrances == "couple_all"
or plando_connection.direction == "both"):
remove_dangling_exit(reg2)
remove_dangling_entrance(reg1)
reg2.connect(reg1)
self.plando_er_pairings.append((r2.name, r1.name))
if dev_prints:
print(f"Connecting backwards {r2} => {r1}")
def manual_er(self):
def get_disconnected_entrances():
return {entrance.name: entrance for region in self.multiworld.get_regions(self.player)
for entrance in region.entrances if not entrance.parent_region}
def get_disconnected_exits():
return {ex.name: ex for region in self.multiworld.get_regions(self.player)
for ex in region.exits if not ex.connected_region}
def manual_connect(ex, entr):
# Connect!
if dev_prints:
print(f"Connecting {ex} => {entr}")
target_region = entr.connected_region
target_region.entrances.remove(entr)
ex.connect(target_region)
self.manual_er_pairings.append((ex.name, entr.name))
# If coupled do reverse entrance
if not self.options.decouple_entrances:
ex2 = exit_map[entr.name]
entr2 = entrance_map[ex.name]
if dev_prints:
print(f"Connecting {ex2} => {entr2}")
entr2.connected_region.entrances.remove(entr2)
ex2.connect(entr2.connected_region)
self.manual_er_pairings.append((ex2.name, entr2.name))
def get_random_entrance(entr):
entr_list = [entrance_map[i] for i in entr]
self.random.shuffle(entr_list)
return entr_list[0]
def get_random_exit(ex):
ex_list = [exit_map[i] for i in ex]
self.random.shuffle(ex_list)
return ex_list[0]
self.manual_er_pairings = []
bremeur_location = "Ruins NW Pyramid"
# Connect ruins stuff early given certain risky conditions, because GER can't handle the water level
if (self.options.shuffle_houses == "shuffle"
and self.options.shuffle_between_islands.value in [1, 3]):
# Find entrance objects
entrance_map = get_disconnected_entrances()
exit_map = get_disconnected_exits()
bremeur_entrance = entrance_map["Bremeur's Exit"]
house_exit = get_random_exit(["Ruins NW Pyramid", "Ruins NE Small Pyramid"])
bremeur_location = house_exit.name
# Connect!
manual_connect(house_exit, bremeur_entrance)
if (self.options.shuffle_overworld_transitions == "shuffle"
and self.options.shuffle_between_islands.value in [1, 3]
and self.options.shuffle_houses.value in [0, 1]):
entrance_map = get_disconnected_entrances()
exit_map = get_disconnected_exits()
# Create entrance pool
entrance_list = ["Ruins NW One-Way Ledge South",
"Ruins NW One-Way Ledge SW",]
if self.options.entrance_directionality.value in [1, 3]:
entrance_list += ["Ruins NW Across Bridge East",
"Ruins NW Upper One-Way East",
"Ruins SW Port Cliff North",
"Ruins SW East",
"Ruins NE Doylan Bridge One-Way West"]
if bremeur_location == "Ruins NE Small Pyramid":
entrance_list += ["Ruins NE Doylan's Bridge NW"]
# Find entrance objects
maze_exit = exit_map["Ruins SW Upper Maze North"]
new_entrance = get_random_entrance(entrance_list)
# Connect!
manual_connect(maze_exit, new_entrance)
# If house ends up in the wrong screen, do another manual placement
old_entrance = new_entrance.name
if "Ruins NW" in old_entrance:
if bremeur_location != "Ruins NW Pyramid":
new_entrance = get_random_entrance(["Ruins NE Doylan's Bridge NW",
"Ruins NE Doylan Bridge One-Way West"])
if old_entrance != "Ruins NW Across Bridge East":
new_exit = exit_map["Ruins NW Across Bridge East"]
else:
new_exit = get_random_exit(["Ruins NW One-Way Ledge South", "Ruins NW One-Way Ledge SW"])
manual_connect(new_exit, new_entrance)
elif "Ruins NE" in old_entrance:
if bremeur_location != "Ruins NE Small Pyramid":
new_exit = exit_map["Ruins NE Doylan's Bridge NW"]
new_entrance = get_random_entrance(["Ruins NW One-Way Ledge South",
"Ruins NW One-Way Ledge SW",
"Ruins NW Across Bridge East",
"Ruins NW Upper One-Way East"])
manual_connect(new_exit, new_entrance)
elif "Ruins SW" in old_entrance:
new_exit_name = ["Ruins SW Port Cliff North", "Ruins SW East"]
new_exit_name.remove(old_entrance)
new_exit = exit_map[new_exit_name[0]]
if bremeur_location == "Ruins NE Small Pyramid":
new_entrance = get_random_entrance(["Ruins NE Doylan's Bridge NW",
"Ruins NE Doylan Bridge One-Way West"])
else:
new_entrance = get_random_entrance(["Ruins NW One-Way Ledge South",
"Ruins NW One-Way Ledge SW",
"Ruins NW Across Bridge East",
"Ruins NW Upper One-Way East"])
manual_connect(new_exit, new_entrance)
def set_rules(self):
create_connections(self.multiworld, self.player, self.origin_region_name, self.options)
self.multiworld.completion_condition[self.player] = lambda state: state.has("_beaten_game", self.player)
def create_item(self, name: str) -> Item:
classification = ITEMS[name].classification
if name in self.extra_filler_items:
self.extra_filler_items.remove(name)
classification = ItemClassification.filler
if name == "Swordsman's Scroll" and self.options.logic == "glitched":
classification = ItemClassification.progression
if self.options.ph_time_logic.value > 2:
if name in ["Sand of Hours", "Heart Container"]:
classification = ItemClassification.useful
if name == "Heart Container" and self.options.ph_heart_time == 0:
classification = ItemClassification.useful
ap_code = self.item_name_to_id[name]
return Item(name, classification, ap_code, self.player)
def build_item_pool_dict(self):
def force_vanilla():
item_obj = self.create_item(item_name)
loc_obj = self.multiworld.get_location(loc_name, self.player)
loc_obj.place_locked_item(item_obj)
loc_obj.progress_type = LocationProgressType.DEFAULT
removed_item_quantities = self.options.remove_items_from_pool.value.copy()
item_pool_dict = {}
filler_item_count = 0
boss_reward_item_count = len(self.boss_reward_items_pool)
for loc_name, loc_data in LOCATIONS_DATA.items():
if not self.location_is_active(loc_name, loc_data):
# print(f"{loc_name} is not active")
continue
# If no defined vanilla item, fill with filler
if "vanilla_item" not in loc_data:
# print(f"{loc_name} has no defined vanilla item")
filler_item_count += 1
continue
item_name = loc_data.get("item_override", loc_data["vanilla_item"])
if item_name == "Filler Item":
filler_item_count += 1
continue
if item_name in removed_item_quantities and removed_item_quantities[item_name] > 0:
removed_item_quantities[item_name] -= 1
filler_item_count += 1
continue
if self.options.keysanity == "vanilla":
# Place small key in vanilla location
if "Small Key" in item_name:
force_vanilla()
continue
if self.options.randomize_boss_keys == "vanilla" and "Boss Key" in item_name:
force_vanilla()
continue
if "force_vanilla" in loc_data and loc_data["force_vanilla"]:
force_vanilla()
continue
if hasattr(ITEMS[item_name], 'dungeon'):
# dung = item_name.rsplit('(', 1)[1][:-1]
# If pedestal item location is vanilla, lock them there
if (self.options.randomize_pedestal_items.value in [0, 1]
and item_name in ITEM_GROUPS["Regular Pedestal Items"]):
force_vanilla()
continue
if (loc_name in ["Mountain Passage 1F Entrance Chest", "Mountain Passage 2F Rat Key"]
and self.options.accessibility.value in [0, 1] # full accessibility
and self.options.keysanity == "in_own_dungeon"):
forced_item = self.create_item(item_name)
self.multiworld.get_location(loc_name, self.player).place_locked_item(forced_item)
continue
if item_name in ITEM_GROUPS["Golden Frog Glyphs"]:
if self.options.randomize_frogs == "vanilla":
forced_item = self.create_item(item_name)
self.multiworld.get_location(loc_name, self.player).place_locked_item(forced_item)
continue
if item_name == "Rare Metal": # Change rare metals to filler items for unrequired dungeons
if boss_reward_item_count <= 0 or self.options.goal_requirements != "defeat_bosses":
filler_item_count += 1
continue
item_name = self.boss_reward_items_pool[boss_reward_item_count - 1]
boss_reward_item_count -= 1
if item_name == "Triforce Crest" and not self.options.randomize_triforce_crest:
filler_item_count += 1
continue
# Goal locations are for UT, and should not have actual items
if "GOAL" in item_name:
forced_item = self.create_item(item_name)
self.multiworld.get_location(loc_name, self.player).place_locked_item(forced_item)
continue
if "Treasure Map" in item_name:
filler_item_count += 1
continue
if (item_name in ITEM_GROUPS["Items With Ammo"] |
ITEM_GROUPS["Technical Items"] |
ITEM_GROUPS["Potions"] |
ITEM_GROUPS["Single Spirit Gems"] |
ITEM_GROUPS["Regular Pedestal Items"] | # These get locked in the dungeon category if vanilla
{"Heart Container"}):
filler_item_count += 1
continue
item_pool_dict[item_name] = item_pool_dict.get(item_name, 0) + 1
# Fill filler count with consistent amounts of items, when filler count is empty it won't add any more items
# so add progression items first
add_items = {"Bombs (Progressive)": 3, "Bow (Progressive)": 3, "Bombchus (Progressive)": 3}
add_items |= {"Phantom Hourglass": 1}
# If metal hunt create and add metals
if self.options.goal_requirements == "metal_hunt":
metal_pool = {}
for i in self.pick_metals(self.options.metal_hunt_total):
metal_pool.setdefault(i, 0)
metal_pool[i] += 1
add_items |= metal_pool.items()
add_items |= add_spirit_gems(self.options.spirit_gem_packs, self.options.additional_spirit_gems)
add_items |= {"Heart Container": 13}
# Add pedestal items
if self.options.randomize_pedestal_items.value > 1:
add_items |= add_pedestal_items(self.options.randomize_pedestal_items, self.options.pedestal_item_options, self.excluded_dungeons)
# Add treasure maps
if self.options.randomize_salvage.value:
add_items |= {i: 1 for i in ITEM_GROUPS["Treasure Maps"]}
if self.options.map_warp_options.value in [1]:
add_items |= {i: 1 for i in ITEM_GROUPS["Map Warp Unlocks"]}
# Add beedle point items
if self.options.randomize_beedle_membership.value > 0:
add_items |= {"Freebie Card": 1, "Complimentary Card": 1}
if self.options.randomize_beedle_membership.value > 1:
add_items |= add_beedle_point_items()
# Add items from options
for item, count in self.options.add_items_to_pool.items():
add_items.setdefault(item, 0)
add_items[item] += count
# Add sand items to pool
add_items |= add_sand(self.options.ph_starting_time, self.options.ph_time_increment,
self.options.ph_time_logic)
# Add ships last cause they can be overwritten
for i in ITEM_GROUPS["Ships"]:
add_items.setdefault(i, 0)
add_items[i] += 1
# add items to item pool
for i, count in add_items.items():
item_pool_dict, filler_item_count = add_items_from_filler(item_pool_dict, filler_item_count, i, count)
# Add as many filler items as required
for _ in range(filler_item_count):
random_filler_item = self.get_filler_item_name()
item_pool_dict[random_filler_item] = item_pool_dict.get(random_filler_item, 0) + 1
# Remove items from options, replace with filler
for item, count in self.options.remove_items_from_pool.items():
if item in item_pool_dict:
new_count = item_pool_dict[item] - count
if new_count < 0:
count = count + new_count
item_pool_dict[item] -= count
for i in range(count):
random_filler_item = self.get_filler_item_name()
item_pool_dict[random_filler_item] = item_pool_dict.get(random_filler_item, 0) + 1
return item_pool_dict
def create_items(self):
item_pool_dict = self.build_item_pool_dict()
self.get_extra_filler_items(item_pool_dict)
items = []
for item_name, quantity in item_pool_dict.items():
for _ in range(quantity):
items.append(self.create_item(item_name))
self.filter_confined_dungeon_items_from_pool(items)
self.multiworld.itempool.extend(items)
def get_extra_filler_items(self, item_pool_dict):
# Create a random list of useful or currency items to turn into filler to satisfy all removed locations
filler_count = 0
extra_items_list = []
for item, count in item_pool_dict.items():
if 'backup_filler' in ITEMS[item].tags:
extra_items_list.extend([item] * count)
if ITEMS[item].classification in [ItemClassification.filler, ItemClassification.trap]:
filler_count += count
# Add sand of hours to extra filler list only if not progression
if self.options.ph_time_logic > 2:
if item in ["Sand of Hours", "Heart Container"]:
extra_items_list.extend([item] * count)
# Add hearts if their time is zero
if item == "Heart Container" and self.options.ph_heart_time == 0:
extra_items_list.extend([item] * count)
extra_item_count = len(self.locations_to_exclude) - filler_count + 20
# print(f"Filler items basic: {len(self.locations_to_exclude)} | have: {filler_count} | "
# f"available: {len(extra_items_list)} | total: {extra_item_count}")
# since item pool is created before items are filtered to dungeon pool,
# remove the worst case scenario for excluded key items to lighten the pool
ed = len(self.excluded_dungeons)
extra_item_count -= ([0] + list(range(8)))[ed] if self.options.randomize_boss_keys.value in [0, 1] else 0 # boss keys iod
extra_item_count -= [0, 0, 0, 1, 3, 6, 9, 12][ed] if self.options.keysanity.value in [0, 1] else 0 # keys iod
extra_item_count -= [0, 0, 0, 0, 0, 0, 1, 3][ed] if (self.options.randomize_pedestal_items.value in [0, 1, 2]
and self.options.pedestal_item_options in [0, 1]) else 0
extra_item_count -= ed if not self.options.require_specific_bosses else 0 # boss rewards on rsb
if self.options.shuffle_bosses == 1 and not self.options.decouple_entrances: # boss exclusion happens later
extra_item_count += [0, 5, 10, 14, 18, 21, 24, 27][ed] # worst case boss room + post dungeon locs
# print(f"Filler items advanced: {extra_item_count}")
if extra_item_count > 0:
self.random.shuffle(extra_items_list)
self.extra_filler_items = extra_items_list[:extra_item_count]
def get_pre_fill_items(self):
return self.pre_fill_items
def pre_fill(self) -> None:
self.pre_fill_boss_rewards()
self.pre_fill_dungeon_items()
def filter_confined_dungeon_items_from_pool(self, items: List[Item]):
confined_dungeon_items = []
# Confine small keys to own dungeon if option is enabled
if self.options.keysanity == "in_own_dungeon":
confined_dungeon_items.extend([item for item in items if item.name.startswith("Small Key")])
# Confine small keys to own dungeon if option is enabled
if self.options.randomize_boss_keys == "in_own_dungeon":
confined_dungeon_items.extend([item for item in items if item.name.startswith("Boss Key")])
if self.options.randomize_pedestal_items == "in_own_dungeon":
confined_dungeon_items.extend([item for item in items if item.name in ITEM_GROUPS["Pedestal Items"]])
# Remove boss reward items from pool for pre filling
confined_dungeon_items.extend([item for item in items if item.name in self.boss_reward_items_pool])
for item in confined_dungeon_items:
items.remove(item)
self.pre_fill_items.extend(confined_dungeon_items)
def pre_fill_boss_rewards(self):
if self.is_ut:
print(f"UT is creating boss rewards! stop it!")
# Pre-fill dungeon rewards
if self.options.goal_requirements == "defeat_bosses":
boss_reward_locations = [loc for loc in self.multiworld.get_locations(self.player)
if loc.name in self.required_bosses]
boss_reward_items = [item for item in self.pre_fill_items if item.name in self.boss_reward_items_pool]
# Remove from the all_state the items we're about to place
for item in boss_reward_items:
self.pre_fill_items.remove(item)
collection_state = self.multiworld.get_all_state()
# Perform a prefill to place confined items inside locations of this dungeon
self.random.shuffle(boss_reward_locations)
# print(f"Pre-Filling boss rewards: {boss_reward_locations} \n {boss_reward_items}")
fill_restrictive(self.multiworld, collection_state, boss_reward_locations, boss_reward_items,
single_player_placement=True, lock=True, allow_excluded=True)
def pre_fill_dungeon_items(self):
if self.is_ut:
print(f"UT is creating dungeon items! stop it!")
global_crystal_dungeons = {}
def global_pedestal_helper(crystal, dungeon):
global_crystal_dungeons.setdefault(dungeon, [])
item = crystal + " Crystals"
if dungeon in self.excluded_dungeons:
global_crystal_dungeons["Temple of the Ocean King"].append(item)
else:
global_crystal_dungeons[self.random.choice(["Temple of the Ocean King", dungeon])].append(item)
# Since crystals can be in multiple dungeons with global crystals,
# and them ending up in excluded dungeons causes errors,
# pre-choose what dungeon they belong to
if (self.options.randomize_pedestal_items == "in_own_dungeon"
and self.options.pedestal_item_options == "open_globally"):
global_crystal_dungeons.setdefault("Temple of the Ocean King", [])
global_pedestal_helper("Square", "Temple of Courage")
global_pedestal_helper("Round", "Ghost Ship")
global_pedestal_helper("Triangle", "Ghost Ship")
# If keysanity is off, dungeon items can only be put inside local dungeon locations, and there are not so many
# of those which makes them pretty crowded.
# This usually ends up with generator not having anywhere to place a few small keys, making the seed unbeatable.
# To circumvent this, we perform a restricted pre-fill here, placing only those dungeon items
# before anything else.
for dung_name in DUNGEON_NAMES:
# print(f"pre-filling {dung_name}")
# Build a list of locations in this dungeon
dungeon_location_names = [name for name, loc in LOCATIONS_DATA.items()
if "dungeon" in loc and loc["dungeon"] == dung_name
and name not in self.required_bosses]
if self.options.shuffle_bosses: # Exclude boss room if boss shuffling
dungeon_location_names = [i for i in dungeon_location_names if i not in LOCATION_GROUPS.get(BOSS_LOOKUP.get(dung_name, None), [])]
dungeon_locations = [loc for loc in self.multiworld.get_locations(self.player)
if loc.name in dungeon_location_names and not loc.locked]
# From the list of all dungeon items that needs to be placed restrictively, only filter the ones for the
# dungeon we are currently processing.
confined_dungeon_items = [item for item in self.pre_fill_items
if item.name.endswith(f"({dung_name})")]
# Add global crystals/force gems
if dung_name in global_crystal_dungeons:
confined_dungeon_items.extend([item for item in self.pre_fill_items if item.name in global_crystal_dungeons[dung_name]])
# Add force gems
if self.options.randomize_pedestal_items == "in_own_dungeon" and dung_name == "Temple of the Ocean King":
confined_dungeon_items.extend([item for item in self.pre_fill_items
if "Force Gem" in item.name])
if len(confined_dungeon_items) == 0:
continue # This list might be empty with some keysanity options
# Remove from the all_state the items we're about to place
for item in confined_dungeon_items:
self.pre_fill_items.remove(item)
collection_state = self.multiworld.get_all_state()
# Perform a prefill to place confined items inside locations of this dungeon
self.random.shuffle(dungeon_locations)
fill_restrictive(self.multiworld, collection_state, dungeon_locations, confined_dungeon_items,
single_player_placement=True, lock=True, allow_excluded=True)
def get_filler_item_name(self) -> str:
filler_item_names = [
"Blue Rupee (5)",
"Red Rupee (20)",
"Rupoor (-10)"
]
filler_item_names += ITEM_GROUPS["Treasure Items"]
filler_item_names += ITEM_GROUPS["Ammo Refills"]
filler_item_names += ITEM_GROUPS["Potions"]
if self.options.randomize_fishing: # If fishing is enable add useless fish to filler pool cause funny :3
filler_item_names += ["Fish: Skippyjack", "Fish: Toona"]
if self.options.randomize_salvage:
filler_item_names += ["Salvage Repair Kit"]
if self.options.randomize_beedle_membership:
filler_item_names += ["Compliment Card"]
item_name = self.random.choice(filler_item_names)
return item_name
def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]):
player_hint_data = dict()
pairings = dict()
if self.er_placement_state:
for e1, e2 in self.er_placement_state.pairings + self.manual_er_pairings + self.plando_er_pairings:
pairings[ENTRANCES[e1].id] = ENTRANCES[e2].id
if not pairings: # If not er, don't bother trying anything else
return
def create_hint_entrances(key):
hint_entrances = loc_data[key]
hint_entrances = [hint_entrances] if isinstance(hint_entrances, str) else hint_entrances
hint_entrances_ids = [e.id for name, e in ENTRANCES.items() if name in hint_entrances]
for entrance_id in hint_entrances_ids:
reverse_id = reverse_pairings.get(entrance_id, None)
if reverse_id is not None and (reverse_id not in dead_end_ids or self.options.decouple_entrances):
entrance_list.add(entrance_id_to_entrance[reverse_id].name)
reverse_pairings = {e2: int(e1) for e1, e2 in pairings.items()}
dead_end_ids = [e.id for name, e in ENTRANCES.items() if name in DEAD_END_ENTRANCES]
for loc, loc_data in LOCATIONS_DATA.items():
if "hint_entrance" in loc_data:
entrance_list = set()
create_hint_entrances("hint_entrance")
if not entrance_list and "hint_entrance_secondary" in loc_data:
create_hint_entrances("hint_entrance_secondary")
if entrance_list:
player_hint_data[loc_data["id"]] = ", ".join(entrance_list)
hint_data[self.player] = player_hint_data
def fill_slot_data(self) -> dict:
options = [
# Goal
"goal_requirements", "bellum_access",
# Dungeons
"dungeons_required", "require_specific_bosses", "exclude_non_required_dungeons",
"ghost_ship_in_dungeon_pool", "totok_in_dungeon_pool",
# Metal Hunt
"metal_hunt_total", "metal_hunt_required", "zauz_required_metals",
# Logic
"logic", "phantom_combat_difficulty", "boat_requires_sea_chart",
# Item Randomization
"randomize_minigames", "randomize_digs", "randomize_fishing",
"keysanity", "randomize_boss_keys", "randomize_pedestal_items",
"randomize_frogs", "randomize_salvage",
"randomize_triforce_crest", "randomize_harrow",
# Beedle randomization
"randomize_masked_beedle", "randomize_beedle_membership",
# World Settings
"map_warp_options",
"fog_settings", "skip_ocean_fights",
"dungeon_shortcuts", "totok_checkpoints",
"boss_key_behaviour", "color_switch_behaviour", "pedestal_item_options",
# Spirit Packs
"spirit_gem_packs", "additional_spirit_gems",
# Hint settings
"dungeon_hint_type", "dungeon_hint_location", "excluded_dungeon_hints",
"shop_hints", "spirit_island_hints",
# PH settings
"ph_time_logic", "ph_starting_time", "ph_time_increment", "ph_heart_time", "ph_required",
# Cosmetic
"additional_metal_names",
# ER
"shuffle_dungeon_entrances", "shuffle_ports", "shuffle_caves", "shuffle_houses",
"shuffle_overworld_transitions", "shuffle_bosses",
"entrance_directionality", "decouple_entrances",
# UT
"ut_events", "ut_blocked_entrances_behaviour", "ut_smart_keys",
# Deathlink
"death_link"
]
slot_data = self.options.as_dict(*options)
slot_data["player_id"] = self.player
# Used to make excluded dungeons consistent for UT
slot_data["required_dungeons"] = self.required_dungeons
# Used to determine if reached goal in client
slot_data["required_metals"] = self.options.metal_hunt_required.value \
if self.options.goal_requirements == "metal_hunt" \
else len(self.required_dungeons)
# Used for dungeon hints in client
slot_data["required_dungeon_locations"] = self.required_bosses # for dungeon hints
slot_data["boss_reward_items_pool"] = self.boss_reward_items_pool
slot_data["treasure_price_index"] = self.treasure_price_index
# Create ER Pairings, as ids to save space
pairings = {}
if self.er_placement_state:
for e1, e2 in self.er_placement_state.pairings + self.manual_er_pairings + self.plando_er_pairings:
pairings[ENTRANCES[e1].id] = ENTRANCES[e2].id
slot_data["er_pairings"] = pairings
return slot_data
def write_spoiler(self, spoiler_handle):
spoiler_handle.write(f"\n")
if self.options.goal_requirements == "defeat_bosses":
spoiler_handle.write(f"\nRequired Dungeons ({self.multiworld.player_name[self.player]}):\n")
for dung in self.required_dungeons:
spoiler_handle.write(f"\t- {dung}\n")
if self.excluded_dungeons:
spoiler_handle.write(f"\nExcluded Dungeons ({self.multiworld.player_name[self.player]}):\n")
for dung in self.excluded_dungeons:
spoiler_handle.write(f"\t- {dung}\n")
if self.options.goal_requirements == "defeat_bosses" and self.options.shuffle_bosses:
spoiler_handle.write(f"\nRequired Bosses ({self.multiworld.player_name[self.player]}):\n")
for boss in self.required_bosses:
spoiler_handle.write(f"\t- {boss}\n")
if self.er_placement_state.pairings:
spoiler_handle.write(f"\n\nEntrance Rando ({self.multiworld.player_name[self.player]}):\n")
prev = None
arrow = "=>" if self.options.decouple_entrances else "<=>"
for i in self.er_placement_state.pairings + self.manual_er_pairings + self.plando_er_pairings:
if (i[1], i[0]) != prev or self.options.decouple_entrances:
text = i[0] + f" {arrow} " + i[1]
spoiler_handle.write(f"\t{text}\n")
prev = i
# UT stuff
@staticmethod
def interpret_slot_data(slot_data: dict[str, Any]):
return slot_data
# UT reconnect entrances
def reconnect_found_entrances(self, key, stored_data):
print(f"UT Tried to defer entrances! key {key}"
f" {stored_data}"
)
if getattr(self.multiworld, "enforce_deferred_connections", "default") == "off":
print(f"Don't defer entrances when off")
elif "ph_checked_entrances" in key or "ph_traversed_entrances" in key:
if stored_data:
if "ph_traversed_entrances" in key:
self.ut_traversed_entrances.update(stored_data)
# always_connect_checked = set(stored_data) if "ph_checked_entrances" in key else set()
disconnects = self.ut_redisconnected_entrances - self.ut_traversed_entrances
reconnects = {i for i in self.ut_redisconnected_entrances & self.ut_traversed_entrances if i not in self.ut_reconnected_entrances}
new_entrances = (set(stored_data) - self.ut_connected_entrances - disconnects) | reconnects
if reconnects:
self.ut_reconnected_entrances.update(reconnects)
print(f"new checked entrances: {new_entrances}")
for i in new_entrances:
pairing = self.ut_pairings.get(str(i), None)
# print(f"Pairing {pairing} {entrance_id_to_entrance[i].name}")
# print(f"UT pairings {self.ut_pairings}")
if pairing is not None:
_exit: "Entrance" = self.get_entrance(entrance_id_to_entrance[i].name)
entrance_region: "Region" = self.get_region(entrance_id_to_region[pairing])
print(f"Connecting: {_exit} => {entrance_region} | {i}: {pairing}")
_exit.connect(entrance_region)
self.ut_connected_entrances |= new_entrances
elif "ph_disconnect_entrances" in key and stored_data:
self.ut_redisconnected_entrances.update(stored_data)
for e in self.entrances.values():
entr_id = ENTRANCES[e.name].id
if (entr_id in stored_data and e.parent_region and e.connected_region
and entr_id in self.ut_connected_entrances
and entr_id not in self.ut_traversed_entrances):
print(f"Disconnecting {e.name}")
child_region = e.connected_region
parent_region = e.parent_region
# disconnect the edge
child_region.entrances.remove(e)
e.connected_region = None
# Create target
parent_region.create_er_target(e.name)
if "ph_keylocking" in key and stored_data:
print(f"Attempting to keylock stuff!")
for i in stored_data:
print(f"Excluding {self.location_id_to_name[i]}")
self.multiworld.get_location(self.location_id_to_name[i], self.player).progress_type = LocationProgressType.EXCLUDED
if "ph_ut_events" in key and stored_data and self.settings['ut_get_logical_path_shortcuts']:
# Used to create an event item for specific tracker logic
def manage_ut_event(stored_name, region_name, event_item_name):
if stored_name in stored_data and not stored_name in self.ut_created_events:
print(f"UT is Creating {event_item_name} event")
self.create_event(region_name, event_item_name)
self.ut_created_events.append(stored_name)
# Used when event is only used for get_logical_path. inspired by codegorilla's crystalis implementation
def connect_existing_regions(stored_name, reg1, reg2, name=None):
if stored_name in stored_data and not stored_name in self.ut_created_events:
try:
entr = self.get_entrance(f"{reg1} -> {reg2}")
print(f"Entrance exists, removing rule")
entr.access_rule = lambda state: True
except KeyError:
print(f"Entrance does not exist, creating it anew")
self.get_region(reg1).connect(self.get_region(reg2), name)
self.ut_created_events.append(stored_name)
manage_ut_event("1f", "TotOK 1F Chart", "_UT_got_chart")
for event_tag in stored_data:
if event_tag in hidden_event_connect:
connect_existing_regions(event_tag, *hidden_event_connect[event_tag])
# # Sent on getting location. Does not show event in UT
# manage_ut_event("1f", "TotOK 1F Chart", "_UT_got_chart")
# # Connected on flag read
# connect_existing_regions("gsb", "Goron NW Shortcut", "Goron NW Outside Temple")
# connect_existing_regions("fi", "Mercay NE", "Mercay NW Freedle Island")
# connect_existing_regions("gms", "Goron NE Middle", "Goron NE")
# connect_existing_regions("gss", "Goron NE South", "Goron NE")
# connect_existing_regions("gls", "Goron NW Outside Temple", "Goron NW Like Like")
#
# connect_existing_regions("rb", "Ruins SE Return Bridge West", "Ruins SE Return Bridge East")
# connect_existing_regions("fif", "Frost SE Exit", "Frost SE")
# connect_existing_regions("ub", "Uncharted Outside Cave", "Uncharted Island")
# connect_existing_regions("md", "Molida Outside Temple", "Molida North")
# connect_existing_regions("cb", "Cannon Outside Eddo", "Cannon Bomb Garden")
# connect_existing_regions("mcb", "Sun Lake Cave", "Sun Lake Cave Back")
#
# connect_existing_regions("tfw", "ToF 1F", "ToF 4F")
# connect_existing_regions("tww", "ToW 1F", "ToW 2F")
# connect_existing_regions("tcw", "ToC 1F", "ToC 3F")
# connect_existing_regions("gtw", "GT 1F", "GT B4")
# connect_existing_regions("tiw", "ToI 1F", "ToI Blue Warp")
# connect_existing_regions("mtw", "MT 1F", "MT B3")
#
# # map warp connections
# connect_existing_regions("wsw", "Menu", "SW Ocean East", "Warp to SW Ocean")
# connect_existing_regions("wse", "Menu", "SE Ocean", "Warp to SE Ocean")
# connect_existing_regions("wnw", "Menu", "NW Ocean", "Warp to NW Ocean")
# connect_existing_regions("wne", "Menu", "NE Ocean", "Warp to NE Ocean")
#
# connect_existing_regions("wmc", "Menu", "Mercay SE", "Warp to Mercay Island")
# connect_existing_regions("wc", "Menu", "Cannon Island", "Warp to Cannon Island")
# connect_existing_regions("we", "Menu", "Ember Port", "Warp to Isle of Ember")
# connect_existing_regions("wml", "Menu", "Molida South", "Warp to Molida Island")
# connect_existing_regions("ws", "Menu", "Spirit Island", "Warp to Spirit Island")
#
# connect_existing_regions("wgu", "Menu", "Gust South", "Warp to Isle of Gust")
# connect_existing_regions("wb", "Menu", "Bannan Island", "Warp to Bannan Island")
# connect_existing_regions("wz", "Menu", "Zauz's Island", "Warp to Zauz' Island")
# connect_existing_regions("wu", "Menu", "Uncharted Island", "Warp to Uncharted Island")
#
# connect_existing_regions("wgo", "Menu", "Goron SW", "Warp to Goron Island")
# connect_existing_regions("wf", "Menu", "Frost SW", "Warp to Isle of Frost")
# connect_existing_regions("wh", "Menu", "Harrow Island", "Warp to Harrow Island")
# connect_existing_regions("wds", "Menu", "Dee Ess Island", "Warp to Dee Ess Island")
#
# connect_existing_regions("wd", "Menu", "IotD Port", "Warp to Isle of the Dead")
# connect_existing_regions("wr", "Menu", "Ruins SW Port", "Warp to Isle of Ruins")
# connect_existing_regions("wmz", "Menu", "Maze Island", "Warp to Maze Island")