mirror of
https://github.com/MarioSpore/Grinch-AP.git
synced 2025-10-21 12:11:33 -06:00

Collecting an item into a CollectionState without sweeping, finding all reachable locations, removing that item from the state, and then finding all reachable locations again could result in more locations being reachable than before the item was initially collected into the CollectionState. This issue was present because OoT was not invalidating its reachable region caches for the different ages when items were removed from the CollectionState. To fix the issue, this PR has updated `OOTWorld.remove()` to invalid its caches, like how `CollectionState.remove()` invalidates the core Archipelago caches.
1466 lines
72 KiB
Python
1466 lines
72 KiB
Python
import logging
|
|
import threading
|
|
import copy
|
|
import functools
|
|
import settings
|
|
import typing
|
|
from typing import Optional, List, AbstractSet, Union # remove when 3.8 support is dropped
|
|
from collections import Counter, deque
|
|
from string import printable
|
|
|
|
logger = logging.getLogger("Ocarina of Time")
|
|
|
|
from .Location import OOTLocation, LocationFactory, location_name_to_id, build_location_name_groups
|
|
from .Entrance import OOTEntrance
|
|
from .EntranceShuffle import shuffle_random_entrances, entrance_shuffle_table, EntranceShuffleError
|
|
from .HintList import getRequiredHints
|
|
from .Hints import HintArea, HintAreaNotFound, hint_dist_keys, get_hint_area, buildWorldGossipHints
|
|
from .Items import OOTItem, item_table, oot_data_to_ap_id, oot_is_item_of_type
|
|
from .ItemPool import generate_itempool, get_junk_item, get_junk_pool
|
|
from .Regions import OOTRegion, TimeOfDay
|
|
from .Rules import set_rules, set_shop_rules, set_entrances_based_rules
|
|
from .RuleParser import Rule_AST_Transformer
|
|
from .Options import OoTOptions, oot_option_groups
|
|
from .Utils import data_path, read_json
|
|
from .LocationList import business_scrubs, set_drop_location_names, dungeon_song_locations
|
|
from .DungeonList import dungeon_table, create_dungeons
|
|
from .LogicTricks import normalized_name_tricks
|
|
from .Rom import Rom
|
|
from .Patches import OoTContainer, patch_rom
|
|
from .N64Patch import create_patch_file
|
|
from .Cosmetics import patch_cosmetics
|
|
|
|
from BaseClasses import MultiWorld, CollectionState, Tutorial, LocationProgressType
|
|
from Options import Range, Toggle, VerifyKeys, Accessibility, PlandoConnections, PlandoItems
|
|
from Fill import fill_restrictive, fast_fill, FillError
|
|
from worlds.generic.Rules import exclusion_rules, add_item_rule
|
|
from worlds.AutoWorld import World, AutoLogicRegister, WebWorld
|
|
|
|
# OoT's generate_output doesn't benefit from more than 2 threads, instead it uses a lot of memory.
|
|
i_o_limiter = threading.Semaphore(2)
|
|
|
|
|
|
class OOTCollectionState(metaclass=AutoLogicRegister):
|
|
def init_mixin(self, parent: MultiWorld):
|
|
oot_ids = parent.get_game_players(OOTWorld.game) + parent.get_game_groups(OOTWorld.game)
|
|
self.child_reachable_regions = {player: set() for player in oot_ids}
|
|
self.adult_reachable_regions = {player: set() for player in oot_ids}
|
|
self.child_blocked_connections = {player: set() for player in oot_ids}
|
|
self.adult_blocked_connections = {player: set() for player in oot_ids}
|
|
self.day_reachable_regions = {player: set() for player in oot_ids}
|
|
self.dampe_reachable_regions = {player: set() for player in oot_ids}
|
|
self.age = {player: None for player in oot_ids}
|
|
|
|
def copy_mixin(self, ret) -> CollectionState:
|
|
ret.child_reachable_regions = {player: copy.copy(self.child_reachable_regions[player]) for player in
|
|
self.child_reachable_regions}
|
|
ret.adult_reachable_regions = {player: copy.copy(self.adult_reachable_regions[player]) for player in
|
|
self.adult_reachable_regions}
|
|
ret.child_blocked_connections = {player: copy.copy(self.child_blocked_connections[player]) for player in
|
|
self.child_blocked_connections}
|
|
ret.adult_blocked_connections = {player: copy.copy(self.adult_blocked_connections[player]) for player in
|
|
self.adult_blocked_connections}
|
|
ret.day_reachable_regions = {player: copy.copy(self.adult_reachable_regions[player]) for player in
|
|
self.day_reachable_regions}
|
|
ret.dampe_reachable_regions = {player: copy.copy(self.adult_reachable_regions[player]) for player in
|
|
self.dampe_reachable_regions}
|
|
return ret
|
|
|
|
|
|
class OOTSettings(settings.Group):
|
|
class RomFile(settings.UserFilePath):
|
|
"""File name of the OoT v1.0 ROM"""
|
|
description = "Ocarina of Time ROM File"
|
|
copy_to = "The Legend of Zelda - Ocarina of Time.z64"
|
|
md5s = [
|
|
"5bd1fe107bf8106b2ab6650abecd54d6", # normal
|
|
"6697768a7a7df2dd27a692a2638ea90b", # byte-swapped
|
|
"05f0f3ebacbc8df9243b6148ffe4792f", # decompressed
|
|
]
|
|
|
|
class RomStart(str):
|
|
"""
|
|
Set this to false to never autostart a rom (such as after patching),
|
|
true for operating system default program
|
|
Alternatively, a path to a program to open the .z64 file with
|
|
"""
|
|
|
|
rom_file: RomFile = RomFile(RomFile.copy_to)
|
|
rom_start: typing.Union[RomStart, bool] = True
|
|
|
|
|
|
class OOTWeb(WebWorld):
|
|
setup = Tutorial(
|
|
"Multiworld Setup Guide",
|
|
"A guide to setting up the Archipelago Ocarina of Time software on your computer.",
|
|
"English",
|
|
"setup_en.md",
|
|
"setup/en",
|
|
["Edos"]
|
|
)
|
|
|
|
# Very out of date, requires updating to match current
|
|
# setup_es = Tutorial(
|
|
# setup.tutorial_name,
|
|
# setup.description,
|
|
# "Español",
|
|
# "setup_es.md",
|
|
# "setup/es",
|
|
# setup.authors
|
|
# )
|
|
|
|
setup_fr = Tutorial(
|
|
setup.tutorial_name,
|
|
setup.description,
|
|
"Français",
|
|
"setup_fr.md",
|
|
"setup/fr",
|
|
["TheLynk"]
|
|
)
|
|
|
|
setup_de = Tutorial(
|
|
setup.tutorial_name,
|
|
setup.description,
|
|
"Deutsch",
|
|
"setup_de.md",
|
|
"setup/de",
|
|
["Held_der_Zeit"]
|
|
)
|
|
|
|
tutorials = [setup, setup_fr, setup_de]
|
|
option_groups = oot_option_groups
|
|
game_info_languages = ["en", "de"]
|
|
|
|
|
|
class OOTWorld(World):
|
|
"""
|
|
The Legend of Zelda: Ocarina of Time is a 3D action/adventure game. Travel through Hyrule in two time periods,
|
|
learn magical ocarina songs, and explore twelve dungeons on your quest. Use Link's many items and abilities
|
|
to rescue the Seven Sages, and then confront Ganondorf to save Hyrule!
|
|
"""
|
|
game: str = "Ocarina of Time"
|
|
options_dataclass = OoTOptions
|
|
options: OoTOptions
|
|
settings: typing.ClassVar[OOTSettings]
|
|
topology_present: bool = True
|
|
item_name_to_id = {item_name: oot_data_to_ap_id(data, False) for item_name, data in item_table.items() if
|
|
data[2] is not None and item_name not in {
|
|
'Keaton Mask', 'Skull Mask', 'Spooky Mask', 'Bunny Hood',
|
|
'Mask of Truth', 'Goron Mask', 'Zora Mask', 'Gerudo Mask',
|
|
'Buy Magic Bean', 'Milk',
|
|
'Small Key', 'Map', 'Compass', 'Boss Key',
|
|
}} # These are items which aren't used, but have get-item values
|
|
location_name_to_id = location_name_to_id
|
|
web = OOTWeb()
|
|
|
|
required_client_version = (0, 4, 0)
|
|
|
|
item_name_groups = {
|
|
# internal groups
|
|
"medallions": {"Light Medallion", "Forest Medallion", "Fire Medallion",
|
|
"Water Medallion", "Shadow Medallion", "Spirit Medallion"},
|
|
"stones": {"Kokiri Emerald", "Goron Ruby", "Zora Sapphire"},
|
|
"rewards": {"Light Medallion", "Forest Medallion", "Fire Medallion",
|
|
"Water Medallion", "Shadow Medallion", "Spirit Medallion",
|
|
"Kokiri Emerald", "Goron Ruby", "Zora Sapphire"},
|
|
"logic_bottles": {"Bottle", "Bottle with Milk", "Deliver Letter",
|
|
"Sell Big Poe", "Bottle with Red Potion", "Bottle with Green Potion",
|
|
"Bottle with Blue Potion", "Bottle with Fairy", "Bottle with Fish",
|
|
"Bottle with Blue Fire", "Bottle with Bugs", "Bottle with Poe"},
|
|
|
|
# hint groups
|
|
"Bottles": {"Bottle", "Bottle with Milk", "Rutos Letter",
|
|
"Bottle with Big Poe", "Bottle with Red Potion", "Bottle with Green Potion",
|
|
"Bottle with Blue Potion", "Bottle with Fairy", "Bottle with Fish",
|
|
"Bottle with Blue Fire", "Bottle with Bugs", "Bottle with Poe"},
|
|
"Adult Trade Item": {"Pocket Egg", "Pocket Cucco", "Cojiro", "Odd Mushroom",
|
|
"Odd Potion", "Poachers Saw", "Broken Sword", "Prescription",
|
|
"Eyeball Frog", "Eyedrops", "Claim Check"},
|
|
"Keys": {"Small Key (Bottom of the Well)", "Small Key (Fire Temple)", "Small Key (Forest Temple)",
|
|
"Small Key (Ganons Castle)", "Small Key (Gerudo Training Ground)", "Small Key (Shadow Temple)",
|
|
"Small Key (Spirit Temple)", "Small Key (Thieves Hideout)", "Small Key (Water Temple)",
|
|
"Small Key Ring (Bottom of the Well)", "Small Key Ring (Fire Temple)",
|
|
"Small Key Ring (Forest Temple)", "Small Key Ring (Ganons Castle)",
|
|
"Small Key Ring (Gerudo Training Ground)", "Small Key Ring (Shadow Temple)",
|
|
"Small Key Ring (Spirit Temple)", "Small Key Ring (Thieves Hideout)", "Small Key Ring (Water Temple)",
|
|
"Boss Key (Fire Temple)", "Boss Key (Forest Temple)", "Boss Key (Ganons Castle)",
|
|
"Boss Key (Shadow Temple)", "Boss Key (Spirit Temple)", "Boss Key (Water Temple)"},
|
|
|
|
# aliases
|
|
"Longshot": {"Progressive Hookshot"}, # fuzzy hinting thought Longshot was Slingshot
|
|
"Hookshot": {"Progressive Hookshot"}, # for consistency, mostly
|
|
}
|
|
|
|
location_name_groups = build_location_name_groups()
|
|
|
|
|
|
def __init__(self, world, player):
|
|
self.hint_data_available = threading.Event()
|
|
self.collectible_flags_available = threading.Event()
|
|
super(OOTWorld, self).__init__(world, player)
|
|
|
|
|
|
@classmethod
|
|
def stage_assert_generate(cls, multiworld: MultiWorld):
|
|
oot_settings = OOTWorld.settings
|
|
rom = Rom(file=oot_settings.rom_file)
|
|
|
|
|
|
# Option parsing, handling incompatible options, building useful-item table
|
|
def generate_early(self):
|
|
self.parser = Rule_AST_Transformer(self, self.player)
|
|
|
|
for option_name in self.options_dataclass.type_hints:
|
|
result = getattr(self.options, option_name)
|
|
if isinstance(result, Range):
|
|
option_value = int(result)
|
|
elif isinstance(result, Toggle):
|
|
option_value = bool(result)
|
|
elif isinstance(result, VerifyKeys):
|
|
option_value = result.value
|
|
elif isinstance(result, PlandoConnections):
|
|
option_value = result.value
|
|
elif isinstance(result, PlandoItems):
|
|
option_value = result.value
|
|
else:
|
|
option_value = result.current_key
|
|
setattr(self, option_name, option_value)
|
|
|
|
self.regions = [] # internal caches of regions for this world, used later
|
|
self._regions_cache = {}
|
|
|
|
self.shop_prices = {}
|
|
self.remove_from_start_inventory = [] # some items will be precollected but not in the inventory
|
|
self.starting_items = Counter()
|
|
self.songs_as_items = False
|
|
self.file_hash = [self.random.randint(0, 31) for i in range(5)]
|
|
self.connect_name = ''.join(self.random.choices(printable, k=16))
|
|
self.collectible_flag_addresses = {}
|
|
|
|
# Incompatible option handling
|
|
# ER and glitched logic are not compatible; glitched takes priority
|
|
if self.logic_rules == 'glitched':
|
|
self.shuffle_interior_entrances = 'off'
|
|
self.shuffle_dungeon_entrances = 'off'
|
|
self.spawn_positions = 'off'
|
|
self.shuffle_bosses = 'off'
|
|
self.shuffle_grotto_entrances = False
|
|
self.shuffle_overworld_entrances = False
|
|
self.owl_drops = False
|
|
self.warp_songs = False
|
|
|
|
# Fix spawn positions option
|
|
new_sp = []
|
|
if self.spawn_positions in {'child', 'both'}:
|
|
new_sp.append('child')
|
|
if self.spawn_positions in {'adult', 'both'}:
|
|
new_sp.append('adult')
|
|
self.spawn_positions = new_sp
|
|
|
|
# Closed forest and adult start are not compatible; closed forest takes priority
|
|
if self.open_forest == 'closed':
|
|
self.starting_age = 'child'
|
|
# These ER options force closed forest to become closed deku
|
|
if (self.shuffle_interior_entrances == 'all' or self.shuffle_overworld_entrances or self.warp_songs or self.spawn_positions):
|
|
self.open_forest = 'closed_deku'
|
|
|
|
# Ganon boss key should not be in itempool in triforce hunt
|
|
if self.triforce_hunt:
|
|
self.shuffle_ganon_bosskey = 'triforce'
|
|
|
|
# Force itempool to higher settings if it doesn't have enough hearts
|
|
max_required_hearts = 3
|
|
if self.bridge == 'hearts':
|
|
max_required_hearts = max(max_required_hearts, self.bridge_hearts)
|
|
if self.shuffle_ganon_bosskey == 'hearts':
|
|
max_required_hearts = max(max_required_hearts, self.ganon_bosskey_hearts)
|
|
if max_required_hearts > 3 and self.item_pool_value == 'minimal':
|
|
self.item_pool_value = 'scarce'
|
|
if max_required_hearts > 12 and self.item_pool_value == 'scarce':
|
|
self.item_pool_value = 'balanced'
|
|
|
|
# If songs/keys locked to own world by settings, add them to local_items
|
|
local_types = []
|
|
if self.shuffle_song_items != 'any':
|
|
local_types.append('Song')
|
|
if self.shuffle_mapcompass != 'keysanity':
|
|
local_types += ['Map', 'Compass']
|
|
if self.shuffle_smallkeys != 'keysanity':
|
|
local_types.append('SmallKey')
|
|
if self.shuffle_hideoutkeys != 'keysanity':
|
|
local_types.append('HideoutSmallKey')
|
|
if self.shuffle_bosskeys != 'keysanity':
|
|
local_types.append('BossKey')
|
|
if self.shuffle_ganon_bosskey != 'keysanity':
|
|
local_types.append('GanonBossKey')
|
|
self.options.local_items.value |= set(name for name, data in item_table.items() if data[0] in local_types)
|
|
|
|
# If any songs are itemlinked, set songs_as_items
|
|
for group in self.multiworld.groups.values():
|
|
if self.songs_as_items or group['game'] != self.game or self.player not in group['players']:
|
|
continue
|
|
for item_name in group['item_pool']:
|
|
if oot_is_item_of_type(item_name, 'Song'):
|
|
self.songs_as_items = True
|
|
break
|
|
|
|
# Determine skipped trials in GT
|
|
# This needs to be done before the logic rules in GT are parsed
|
|
trial_list = ['Forest', 'Fire', 'Water', 'Spirit', 'Shadow', 'Light']
|
|
chosen_trials = self.random.sample(trial_list, self.trials) # chooses a list of trials to NOT skip
|
|
self.skipped_trials = {trial: (trial not in chosen_trials) for trial in trial_list}
|
|
|
|
# Determine tricks in logic
|
|
if self.logic_rules == 'glitchless':
|
|
for trick in self.logic_tricks:
|
|
normalized_name = trick.casefold()
|
|
if normalized_name in normalized_name_tricks:
|
|
setattr(self, normalized_name_tricks[normalized_name]['name'], True)
|
|
else:
|
|
raise Exception(f'Unknown OOT logic trick for player {self.player}: {trick}')
|
|
|
|
# No Logic forces all tricks on, prog balancing off and beatable-only
|
|
elif self.logic_rules == 'no_logic':
|
|
self.options.progression_balancing.value = False
|
|
self.options.accessibility.value = Accessibility.option_minimal
|
|
for trick in normalized_name_tricks.values():
|
|
setattr(self, trick['name'], True)
|
|
|
|
# Not implemented for now, but needed to placate the generator. Remove as they are implemented
|
|
self.ocarina_songs = False # just need to pull in the OcarinaSongs module
|
|
self.mix_entrance_pools = False
|
|
self.decouple_entrances = False
|
|
self.available_tokens = 100
|
|
# Deprecated LACS options
|
|
self.lacs_condition = 'vanilla'
|
|
self.lacs_stones = 3
|
|
self.lacs_medallions = 6
|
|
self.lacs_rewards = 9
|
|
self.lacs_tokens = 100
|
|
self.lacs_hearts = 20
|
|
# RuleParser hack
|
|
self.triforce_goal_per_world = self.triforce_goal
|
|
|
|
# Set internal names used by the OoT generator
|
|
self.keysanity = self.shuffle_smallkeys in ['keysanity', 'remove', 'any_dungeon', 'overworld']
|
|
self.trials_random = self.options.trials.randomized
|
|
self.mq_dungeons_random = self.options.mq_dungeons_count.randomized
|
|
self.easier_fire_arrow_entry = self.fae_torch_count < 24
|
|
|
|
if self.misc_hints:
|
|
self.misc_hints = ['ganondorf', 'altar', 'warp_songs', 'dampe_diary',
|
|
'10_skulltulas', '20_skulltulas', '30_skulltulas', '40_skulltulas', '50_skulltulas']
|
|
else:
|
|
self.misc_hints = []
|
|
|
|
# Hint stuff
|
|
self.clearer_hints = True # this is being enforced since non-oot items do not have non-clear hint text
|
|
self.gossip_hints = {}
|
|
self.required_locations = []
|
|
self.empty_areas = {}
|
|
self.major_item_locations = []
|
|
self.hinted_dungeon_reward_locations = {}
|
|
|
|
# ER names
|
|
self.shuffle_special_dungeon_entrances = self.shuffle_dungeon_entrances == 'all'
|
|
self.shuffle_dungeon_entrances = self.shuffle_dungeon_entrances != 'off'
|
|
self.ensure_tod_access = (self.shuffle_interior_entrances != 'off') or self.shuffle_overworld_entrances or self.spawn_positions
|
|
self.entrance_shuffle = (
|
|
self.shuffle_interior_entrances != 'off'
|
|
or self.shuffle_bosses != 'off'
|
|
or self.shuffle_dungeon_entrances
|
|
or self.shuffle_special_dungeon_entrances
|
|
or self.spawn_positions
|
|
or self.shuffle_grotto_entrances
|
|
or self.shuffle_overworld_entrances
|
|
or self.owl_drops
|
|
or self.warp_songs
|
|
)
|
|
self.disable_trade_revert = (self.shuffle_interior_entrances != 'off') or self.shuffle_overworld_entrances
|
|
self.shuffle_special_interior_entrances = self.shuffle_interior_entrances == 'all'
|
|
|
|
# Convert the double option used by shopsanity into a single option
|
|
if self.shopsanity == 'random_number':
|
|
self.shopsanity = 'random'
|
|
elif self.shopsanity == 'fixed_number':
|
|
self.shopsanity = str(self.shop_slots)
|
|
|
|
# Rename options
|
|
self.dungeon_shortcuts_choice = self.dungeon_shortcuts
|
|
if self.dungeon_shortcuts_choice == 'random_dungeons':
|
|
self.dungeon_shortcuts_choice = 'random'
|
|
self.key_rings_list = {s.replace("'", "") for s in self.key_rings_list}
|
|
self.dungeon_shortcuts = {s.replace("'", "") for s in self.dungeon_shortcuts_list}
|
|
self.mq_dungeons_specific = {s.replace("'", "") for s in self.mq_dungeons_list}
|
|
# self.empty_dungeons_specific = {s.replace("'", "") for s in self.empty_dungeons_list}
|
|
|
|
# Determine which dungeons have key rings.
|
|
keyring_dungeons = [d['name'] for d in dungeon_table if d['small_key']] + ['Thieves Hideout']
|
|
if self.key_rings == 'off':
|
|
self.key_rings = []
|
|
elif self.key_rings == 'all':
|
|
self.key_rings = keyring_dungeons
|
|
elif self.key_rings == 'choose':
|
|
self.key_rings = self.key_rings_list
|
|
elif self.key_rings == 'random_dungeons':
|
|
self.key_rings = self.random.sample(keyring_dungeons,
|
|
self.random.randint(0, len(keyring_dungeons)))
|
|
|
|
# Determine which dungeons are MQ. Not compatible with glitched logic.
|
|
mq_dungeons = set()
|
|
all_dungeons = [d['name'] for d in dungeon_table]
|
|
if self.logic_rules != 'glitched':
|
|
if self.mq_dungeons_mode == 'mq':
|
|
mq_dungeons = all_dungeons
|
|
elif self.mq_dungeons_mode == 'specific':
|
|
mq_dungeons = self.mq_dungeons_specific
|
|
elif self.mq_dungeons_mode == 'count':
|
|
mq_dungeons = self.random.sample(all_dungeons, self.mq_dungeons_count)
|
|
else:
|
|
self.mq_dungeons_mode = 'count'
|
|
self.mq_dungeons_count = 0
|
|
self.dungeon_mq = {item['name']: (item['name'] in mq_dungeons) for item in dungeon_table}
|
|
self.dungeon_mq['Thieves Hideout'] = False # fix for bug in SaveContext:287
|
|
|
|
# Empty dungeon placeholder for the moment
|
|
self.empty_dungeons = {name: False for name in self.dungeon_mq}
|
|
|
|
# Determine which dungeons have shortcuts. Not compatible with glitched logic.
|
|
shortcut_dungeons = ['Deku Tree', 'Dodongos Cavern', \
|
|
'Jabu Jabus Belly', 'Forest Temple', 'Fire Temple', \
|
|
'Water Temple', 'Shadow Temple', 'Spirit Temple']
|
|
if self.logic_rules != 'glitched':
|
|
if self.dungeon_shortcuts_choice == 'off':
|
|
self.dungeon_shortcuts = set()
|
|
elif self.dungeon_shortcuts_choice == 'all':
|
|
self.dungeon_shortcuts = set(shortcut_dungeons)
|
|
elif self.dungeon_shortcuts_choice == 'random':
|
|
self.dungeon_shortcuts = self.random.sample(shortcut_dungeons,
|
|
self.random.randint(0, len(shortcut_dungeons)))
|
|
# == 'choice', leave as previous
|
|
else:
|
|
self.dungeon_shortcuts = set()
|
|
|
|
# fixing some options
|
|
# Fixes starting time spelling: "witching_hour" -> "witching-hour"
|
|
self.starting_tod = self.starting_tod.replace('_', '-')
|
|
self.shuffle_scrubs = self.shuffle_scrubs.replace('_prices', '')
|
|
|
|
# Convert adult trade option to expected Set
|
|
self.adult_trade_start = {self.adult_trade_start.title().replace('_', ' ')}
|
|
|
|
# Get hint distribution
|
|
self.hint_dist_user = read_json(data_path('Hints', f'{self.hint_dist}.json'))
|
|
|
|
self.added_hint_types = {}
|
|
self.item_added_hint_types = {}
|
|
self.hint_exclusions = set()
|
|
if self.shuffle_child_trade == 'skip_child_zelda':
|
|
self.hint_exclusions.add('Song from Impa')
|
|
self.hint_type_overrides = {}
|
|
self.item_hint_type_overrides = {}
|
|
|
|
# unused hint stuff
|
|
self.named_item_pool = {}
|
|
self.hint_text_overrides = {}
|
|
|
|
for dist in hint_dist_keys:
|
|
self.added_hint_types[dist] = []
|
|
for loc in self.hint_dist_user['add_locations']:
|
|
if 'types' in loc:
|
|
if dist in loc['types']:
|
|
self.added_hint_types[dist].append(loc['location'])
|
|
self.item_added_hint_types[dist] = []
|
|
for i in self.hint_dist_user['add_items']:
|
|
if dist in i['types']:
|
|
self.item_added_hint_types[dist].append(i['item'])
|
|
self.hint_type_overrides[dist] = []
|
|
for loc in self.hint_dist_user['remove_locations']:
|
|
if dist in loc['types']:
|
|
self.hint_type_overrides[dist].append(loc['location'])
|
|
self.item_hint_type_overrides[dist] = []
|
|
for i in self.hint_dist_user['remove_items']:
|
|
if dist in i['types']:
|
|
self.item_hint_type_overrides[dist].append(i['item'])
|
|
|
|
self.always_hints = [hint.name for hint in getRequiredHints(self)]
|
|
|
|
# Determine items which are not considered advancement based on settings. They will never be excluded.
|
|
self.nonadvancement_items = {'Double Defense', 'Deku Stick Capacity', 'Deku Nut Capacity'}
|
|
if (self.damage_multiplier != 'ohko' and self.damage_multiplier != 'quadruple' and
|
|
self.shuffle_scrubs == 'off' and not self.shuffle_grotto_entrances):
|
|
# nayru's love may be required to prevent forced damage
|
|
self.nonadvancement_items.add('Nayrus Love')
|
|
if getattr(self, 'logic_grottos_without_agony', False) and self.hints != 'agony':
|
|
# Stone of Agony skippable if not used for hints or grottos
|
|
self.nonadvancement_items.add('Stone of Agony')
|
|
if (not self.shuffle_special_interior_entrances and not self.shuffle_overworld_entrances and
|
|
not self.warp_songs and not self.spawn_positions):
|
|
# Serenade and Prelude are never required unless one of those settings is enabled
|
|
self.nonadvancement_items.add('Serenade of Water')
|
|
self.nonadvancement_items.add('Prelude of Light')
|
|
if not self.blue_fire_arrows:
|
|
# Ice Arrows serve no purpose if they're not hacked to have one
|
|
self.nonadvancement_items.add('Ice Arrows')
|
|
if not self.bombchus_in_logic:
|
|
# Nonrenewable bombchus are not a default logical explosive
|
|
self.nonadvancement_items.update({
|
|
'Bombchus (5)',
|
|
'Bombchus (10)',
|
|
'Bombchus (20)',
|
|
})
|
|
if not (self.bridge == 'hearts' or self.shuffle_ganon_bosskey == 'hearts'):
|
|
self.nonadvancement_items.update({
|
|
'Heart Container',
|
|
'Piece of Heart',
|
|
'Piece of Heart (Treasure Chest Game)'
|
|
})
|
|
if self.logic_rules == 'glitchless':
|
|
# Both two-handed swords can be required in glitch logic, so only consider them nonprogression in glitchless
|
|
self.nonadvancement_items.add('Biggoron Sword')
|
|
self.nonadvancement_items.add('Giants Knife')
|
|
if not getattr(self, 'logic_water_central_gs_fw', False):
|
|
# Farore's Wind skippable if not used for this logic trick in Water Temple
|
|
self.nonadvancement_items.add('Farores Wind')
|
|
|
|
|
|
# Reads a group of regions from the given JSON file.
|
|
def load_regions_from_json(self, file_path):
|
|
region_json = read_json(file_path)
|
|
|
|
for region in region_json:
|
|
new_region = OOTRegion(region['region_name'], self.player, self.multiworld)
|
|
if 'pretty_name' in region:
|
|
new_region.pretty_name = region['pretty_name']
|
|
if 'font_color' in region:
|
|
new_region.font_color = region['font_color']
|
|
if 'scene' in region:
|
|
new_region.scene = region['scene']
|
|
if 'dungeon' in region:
|
|
new_region.dungeon = region['dungeon']
|
|
new_region.set_hint_data(region['dungeon'])
|
|
if 'is_boss_room' in region:
|
|
new_region.is_boss_room = region['is_boss_room']
|
|
if 'hint' in region:
|
|
new_region.set_hint_data(region['hint'])
|
|
if 'alt_hint' in region:
|
|
new_region.alt_hint = HintArea[region['alt_hint']]
|
|
if 'time_passes' in region:
|
|
new_region.time_passes = region['time_passes']
|
|
new_region.provides_time = TimeOfDay.ALL
|
|
if new_region.name == 'Ganons Castle Grounds':
|
|
new_region.provides_time = TimeOfDay.DAMPE
|
|
if 'locations' in region:
|
|
for location, rule in region['locations'].items():
|
|
new_location = LocationFactory(location, self.player)
|
|
if new_location.type in ['HintStone', 'Hint']:
|
|
continue
|
|
new_location.parent_region = new_region
|
|
new_location.rule_string = rule
|
|
self.parser.parse_spot_rule(new_location)
|
|
if new_location.never:
|
|
# We still need to fill the location even if ALR is off.
|
|
logger.debug('Unreachable location: %s', new_location.name)
|
|
new_location.player = self.player
|
|
# Change some attributes of Drop locations
|
|
if new_location.type == 'Drop':
|
|
new_location.name = new_region.name + ' ' + new_location.name
|
|
new_location.show_in_spoiler = False
|
|
new_region.locations.append(new_location)
|
|
if 'events' in region:
|
|
for event, rule in region['events'].items():
|
|
# Allow duplicate placement of events
|
|
lname = '%s from %s' % (event, new_region.name)
|
|
new_location = OOTLocation(self.player, lname, type='Event', parent=new_region)
|
|
new_location.rule_string = rule
|
|
self.parser.parse_spot_rule(new_location)
|
|
if new_location.never:
|
|
logger.debug('Dropping unreachable event: %s', new_location.name)
|
|
else:
|
|
new_location.player = self.player
|
|
new_region.locations.append(new_location)
|
|
self.make_event_item(event, new_location)
|
|
new_location.show_in_spoiler = False
|
|
if 'exits' in region:
|
|
for exit, rule in region['exits'].items():
|
|
new_exit = OOTEntrance(self.player, self.multiworld, '%s -> %s' % (new_region.name, exit), new_region)
|
|
new_exit.vanilla_connected_region = exit
|
|
new_exit.rule_string = rule
|
|
self.parser.parse_spot_rule(new_exit)
|
|
if new_exit.never:
|
|
logger.debug('Dropping unreachable exit: %s', new_exit.name)
|
|
else:
|
|
new_region.exits.append(new_exit)
|
|
|
|
self.multiworld.regions.append(new_region)
|
|
self.regions.append(new_region)
|
|
self._regions_cache[new_region.name] = new_region
|
|
|
|
|
|
# Sets deku scrub prices
|
|
def set_scrub_prices(self):
|
|
# Get Deku Scrub Locations
|
|
scrub_locations = [location for location in self.get_locations() if location.type in {'Scrub', 'GrottoScrub'}]
|
|
scrub_dictionary = {}
|
|
self.scrub_prices = {}
|
|
for location in scrub_locations:
|
|
if location.default not in scrub_dictionary:
|
|
scrub_dictionary[location.default] = []
|
|
scrub_dictionary[location.default].append(location)
|
|
|
|
# Loop through each type of scrub.
|
|
for (scrub_item, default_price, text_id, text_replacement) in business_scrubs:
|
|
price = default_price
|
|
if self.shuffle_scrubs == 'low':
|
|
price = 10
|
|
elif self.shuffle_scrubs == 'random':
|
|
# this is a random value between 0-99
|
|
# average value is ~33 rupees
|
|
price = int(self.random.betavariate(1, 2) * 99)
|
|
|
|
# Set price in the dictionary as well as the location.
|
|
self.scrub_prices[scrub_item] = price
|
|
if scrub_item in scrub_dictionary:
|
|
for location in scrub_dictionary[scrub_item]:
|
|
location.price = price
|
|
if location.item is not None:
|
|
location.item.price = price
|
|
|
|
|
|
# Sets prices for shuffled shop locations
|
|
def random_shop_prices(self):
|
|
shop_item_indexes = ['7', '5', '8', '6']
|
|
self.shop_prices = {}
|
|
for region in self.regions:
|
|
if self.shopsanity == 'random':
|
|
shop_item_count = self.random.randint(0, 4)
|
|
else:
|
|
shop_item_count = int(self.shopsanity)
|
|
|
|
for location in region.locations:
|
|
if location.type == 'Shop':
|
|
if location.name[-1:] in shop_item_indexes[:shop_item_count]:
|
|
if self.shopsanity_prices == 'normal':
|
|
self.shop_prices[location.name] = int(self.random.betavariate(1.5, 2) * 60) * 5
|
|
elif self.shopsanity_prices == 'affordable':
|
|
self.shop_prices[location.name] = 10
|
|
elif self.shopsanity_prices == 'starting_wallet':
|
|
self.shop_prices[location.name] = self.random.randrange(0,100,5)
|
|
elif self.shopsanity_prices == 'adults_wallet':
|
|
self.shop_prices[location.name] = self.random.randrange(0,201,5)
|
|
elif self.shopsanity_prices == 'giants_wallet':
|
|
self.shop_prices[location.name] = self.random.randrange(0,501,5)
|
|
elif self.shopsanity_prices == 'tycoons_wallet':
|
|
self.shop_prices[location.name] = self.random.randrange(0,1000,5)
|
|
|
|
|
|
# Fill boss prizes
|
|
def fill_bosses(self, bossCount=9):
|
|
boss_location_names = (
|
|
'Queen Gohma',
|
|
'King Dodongo',
|
|
'Barinade',
|
|
'Phantom Ganon',
|
|
'Volvagia',
|
|
'Morpha',
|
|
'Bongo Bongo',
|
|
'Twinrova',
|
|
'Links Pocket'
|
|
)
|
|
boss_rewards = sorted(map(self.create_item, self.item_name_groups['rewards']))
|
|
boss_locations = [self.multiworld.get_location(loc, self.player) for loc in boss_location_names]
|
|
|
|
placed_prizes = [loc.item.name for loc in boss_locations if loc.item is not None]
|
|
prizepool = [item for item in boss_rewards if item.name not in placed_prizes]
|
|
prize_locs = [loc for loc in boss_locations if loc.item is None]
|
|
|
|
while bossCount:
|
|
bossCount -= 1
|
|
self.random.shuffle(prizepool)
|
|
self.random.shuffle(prize_locs)
|
|
item = prizepool.pop()
|
|
loc = prize_locs.pop()
|
|
loc.place_locked_item(item)
|
|
self.hinted_dungeon_reward_locations[item.name] = loc
|
|
|
|
|
|
# Separate the result from generate_itempool into main and prefill pools
|
|
def divide_itempools(self):
|
|
prefill_item_types = set()
|
|
if self.shopsanity != 'off':
|
|
prefill_item_types.add('Shop')
|
|
if self.shuffle_song_items != 'any':
|
|
prefill_item_types.add('Song')
|
|
if self.shuffle_smallkeys != 'keysanity':
|
|
prefill_item_types.add('SmallKey')
|
|
if self.shuffle_bosskeys != 'keysanity':
|
|
prefill_item_types.add('BossKey')
|
|
if self.shuffle_hideoutkeys != 'keysanity':
|
|
prefill_item_types.add('HideoutSmallKey')
|
|
if self.shuffle_ganon_bosskey != 'keysanity':
|
|
prefill_item_types.add('GanonBossKey')
|
|
if self.shuffle_mapcompass != 'keysanity':
|
|
prefill_item_types.update({'Map', 'Compass'})
|
|
|
|
main_items = []
|
|
prefill_items = []
|
|
for item in self.itempool:
|
|
if item.type in prefill_item_types:
|
|
prefill_items.append(item)
|
|
else:
|
|
main_items.append(item)
|
|
return main_items, prefill_items
|
|
|
|
|
|
# only returns proper result after create_items and divide_itempools are run
|
|
def get_pre_fill_items(self):
|
|
return self.pre_fill_items
|
|
|
|
|
|
# Note on allow_arbitrary_name:
|
|
# OoT defines many helper items and event names that are treated indistinguishably from regular items,
|
|
# but are only defined in the logic files. This means we need to create items for any name.
|
|
# Allowing any item name to be created is dangerous in case of plando, so this is a middle ground.
|
|
def create_item(self, name: str, allow_arbitrary_name: bool = False):
|
|
if name in item_table:
|
|
return OOTItem(name, self.player, item_table[name], False,
|
|
(name in self.nonadvancement_items if getattr(self, 'nonadvancement_items',
|
|
None) else False))
|
|
if allow_arbitrary_name:
|
|
return OOTItem(name, self.player, ('Event', True, None, None), True, False)
|
|
raise Exception(f"Invalid item name: {name}")
|
|
|
|
def make_event_item(self, name, location, item=None):
|
|
if item is None:
|
|
item = self.create_item(name, allow_arbitrary_name=True)
|
|
self.multiworld.push_item(location, item, collect=False)
|
|
location.locked = True
|
|
if name not in item_table:
|
|
location.internal = True
|
|
return item
|
|
|
|
|
|
# Create regions, locations, and entrances
|
|
def create_regions(self):
|
|
if self.logic_rules == 'glitchless' or self.logic_rules == 'no_logic': # enables ER + NL
|
|
world_type = 'World'
|
|
else:
|
|
world_type = 'Glitched World'
|
|
overworld_data_path = data_path(world_type, 'Overworld.json')
|
|
bosses_data_path = data_path(world_type, 'Bosses.json')
|
|
menu = OOTRegion('Menu', self.player, self.multiworld)
|
|
start = OOTEntrance(self.player, self.multiworld, 'New Game', menu)
|
|
menu.exits.append(start)
|
|
self.multiworld.regions.append(menu)
|
|
self.load_regions_from_json(overworld_data_path)
|
|
self.load_regions_from_json(bosses_data_path)
|
|
start.connect(self.get_region('Root'))
|
|
create_dungeons(self)
|
|
self.parser.create_delayed_rules()
|
|
|
|
if self.shopsanity != 'off':
|
|
self.random_shop_prices()
|
|
self.set_scrub_prices()
|
|
|
|
# Bind entrances to vanilla
|
|
for region in self.regions:
|
|
for exit in region.exits:
|
|
exit.connect(self.get_region(exit.vanilla_connected_region))
|
|
|
|
|
|
# Create items, starting item handling, boss prize fill (before entrance randomizer)
|
|
def create_items(self):
|
|
# Generate itempool
|
|
generate_itempool(self)
|
|
|
|
junk_pool = get_junk_pool(self)
|
|
removed_items = []
|
|
# Determine starting items
|
|
for item in self.multiworld.precollected_items[self.player]:
|
|
if item.name in self.remove_from_start_inventory:
|
|
self.remove_from_start_inventory.remove(item.name)
|
|
removed_items.append(item.name)
|
|
else:
|
|
self.starting_items[item.name] += 1
|
|
if item.type == 'Song':
|
|
self.songs_as_items = True
|
|
# Call the junk fill and get a replacement
|
|
if item in self.itempool:
|
|
self.itempool.remove(item)
|
|
self.itempool.append(self.create_item(*get_junk_item(self.random, pool=junk_pool)))
|
|
if self.start_with_consumables:
|
|
self.starting_items['Deku Sticks'] = 30
|
|
self.starting_items['Deku Nuts'] = 40
|
|
if self.start_with_rupees:
|
|
self.starting_items['Rupees'] = 999
|
|
|
|
# Divide itempool into prefill and main pools
|
|
self.itempool, self.pre_fill_items = self.divide_itempools()
|
|
|
|
self.multiworld.itempool += self.itempool
|
|
self.remove_from_start_inventory.extend(removed_items)
|
|
|
|
# Fill boss prizes. needs to happen before entrance shuffle
|
|
self.fill_bosses()
|
|
|
|
|
|
def set_rules(self):
|
|
# This has to run AFTER creating items but BEFORE set_entrances_based_rules
|
|
if self.entrance_shuffle:
|
|
# 10 attempts at shuffling entrances
|
|
tries = 10
|
|
while tries:
|
|
try:
|
|
shuffle_random_entrances(self)
|
|
except EntranceShuffleError as e:
|
|
tries -= 1
|
|
logger.debug(
|
|
f"Failed shuffling entrances for world {self.player}, retrying {tries} more times")
|
|
if tries == 0:
|
|
raise e
|
|
# Restore original state and delete assumed entrances
|
|
for entrance in self.get_shuffled_entrances():
|
|
if entrance.connected_region is not None:
|
|
entrance.disconnect()
|
|
entrance.connect(self.multiworld.get_region(entrance.vanilla_connected_region, self.player))
|
|
if entrance.assumed:
|
|
assumed_entrance = entrance.assumed
|
|
if assumed_entrance.connected_region is not None:
|
|
assumed_entrance.disconnect()
|
|
del assumed_entrance
|
|
entrance.reverse = None
|
|
entrance.replaces = None
|
|
entrance.assumed = None
|
|
entrance.shuffled = False
|
|
# Clean up root entrances
|
|
root = self.get_region("Root Exits")
|
|
root.exits = root.exits[:8]
|
|
else:
|
|
break
|
|
|
|
set_rules(self)
|
|
set_entrances_based_rules(self)
|
|
|
|
|
|
def generate_basic(self): # mostly killing locations that shouldn't exist by settings
|
|
|
|
# Gather items for ice trap appearances
|
|
self.fake_items = []
|
|
if self.ice_trap_appearance in ['major_only', 'anything']:
|
|
self.fake_items.extend(item for item in self.itempool if item.index and self.is_major_item(item))
|
|
if self.ice_trap_appearance in ['junk_only', 'anything']:
|
|
self.fake_items.extend(item for item in self.itempool if
|
|
item.index and not item.type == 'Shop' and not self.is_major_item(item) and item.name != 'Ice Trap')
|
|
|
|
# Kill unreachable events that can't be gotten even with all items
|
|
# Make sure to only kill actual internal events, not in-game "events"
|
|
all_state = self.get_state_with_complete_itempool()
|
|
all_locations = self.get_locations()
|
|
all_state.sweep_for_advancements(locations=all_locations)
|
|
reachable = self.multiworld.get_reachable_locations(all_state, self.player)
|
|
unreachable = [loc for loc in all_locations if
|
|
(loc.internal or loc.type == 'Drop') and loc.address is None and loc.locked and loc not in reachable]
|
|
for loc in unreachable:
|
|
loc.parent_region.locations.remove(loc)
|
|
# Exception: Sell Big Poe is an event which is only reachable if Bottle with Big Poe is in the item pool.
|
|
# We allow it to be removed only if Bottle with Big Poe is not in the itempool.
|
|
bigpoe = self.multiworld.get_location('Sell Big Poe from Market Guard House', self.player)
|
|
if not all_state.has('Bottle with Big Poe', self.player) and bigpoe not in reachable:
|
|
bigpoe.parent_region.locations.remove(bigpoe)
|
|
|
|
# If fast scarecrow then we need to kill the Pierre location as it will be unreachable
|
|
if self.free_scarecrow:
|
|
loc = self.multiworld.get_location("Pierre", self.player)
|
|
loc.parent_region.locations.remove(loc)
|
|
# If open zora's domain then we need to kill Deliver Rutos Letter
|
|
if self.zora_fountain == 'open':
|
|
loc = self.multiworld.get_location("Deliver Rutos Letter", self.player)
|
|
loc.parent_region.locations.remove(loc)
|
|
|
|
|
|
def pre_fill(self):
|
|
|
|
def prefill_state(base_state):
|
|
state = base_state.copy()
|
|
for item in self.get_pre_fill_items():
|
|
self.collect(state, item)
|
|
state.sweep_for_advancements(locations=self.get_locations())
|
|
return state
|
|
|
|
# Prefill shops, songs, and dungeon items
|
|
items = self.get_pre_fill_items()
|
|
locations = list(self.multiworld.get_unfilled_locations(self.player))
|
|
self.random.shuffle(locations)
|
|
|
|
# Set up initial state
|
|
state = CollectionState(self.multiworld)
|
|
for item in self.itempool:
|
|
self.collect(state, item)
|
|
state.sweep_for_advancements(locations=self.get_locations())
|
|
|
|
# Place dungeon items
|
|
special_fill_types = ['GanonBossKey', 'BossKey', 'SmallKey', 'HideoutSmallKey', 'Map', 'Compass']
|
|
type_to_setting = {
|
|
'Map': 'shuffle_mapcompass',
|
|
'Compass': 'shuffle_mapcompass',
|
|
'SmallKey': 'shuffle_smallkeys',
|
|
'BossKey': 'shuffle_bosskeys',
|
|
'HideoutSmallKey': 'shuffle_hideoutkeys',
|
|
'GanonBossKey': 'shuffle_ganon_bosskey',
|
|
}
|
|
special_fill_types.sort(key=lambda x: 0 if getattr(self, type_to_setting[x]) == 'dungeon' else 1)
|
|
|
|
for fill_stage in special_fill_types:
|
|
stage_items = list(filter(lambda item: oot_is_item_of_type(item, fill_stage), self.pre_fill_items))
|
|
if not stage_items:
|
|
continue
|
|
if fill_stage in ['GanonBossKey', 'HideoutSmallKey']:
|
|
locations = gather_locations(self.multiworld, fill_stage, self.player)
|
|
if isinstance(locations, list):
|
|
for item in stage_items:
|
|
self.pre_fill_items.remove(item)
|
|
self.random.shuffle(locations)
|
|
fill_restrictive(self.multiworld, prefill_state(state), locations, stage_items,
|
|
single_player_placement=True, lock=True, allow_excluded=True)
|
|
else:
|
|
for dungeon_info in dungeon_table:
|
|
dungeon_name = dungeon_info['name']
|
|
dungeon_items = list(filter(lambda item: dungeon_name in item.name, stage_items))
|
|
if not dungeon_items:
|
|
continue
|
|
locations = gather_locations(self.multiworld, fill_stage, self.player, dungeon=dungeon_name)
|
|
if isinstance(locations, list):
|
|
for item in dungeon_items:
|
|
self.pre_fill_items.remove(item)
|
|
self.random.shuffle(locations)
|
|
fill_restrictive(self.multiworld, prefill_state(state), locations, dungeon_items,
|
|
single_player_placement=True, lock=True, allow_excluded=True)
|
|
|
|
# Place songs
|
|
# 5 built-in retries because this section can fail sometimes
|
|
if self.shuffle_song_items != 'any':
|
|
tries = 10
|
|
if self.shuffle_song_items == 'song':
|
|
song_locations = list(filter(lambda location: location.type == 'Song',
|
|
self.multiworld.get_unfilled_locations(player=self.player)))
|
|
elif self.shuffle_song_items == 'dungeon':
|
|
song_locations = list(filter(lambda location: location.name in dungeon_song_locations,
|
|
self.multiworld.get_unfilled_locations(player=self.player)))
|
|
else:
|
|
raise Exception(f"Unknown song shuffle type: {self.shuffle_song_items}")
|
|
|
|
songs = list(filter(lambda item: item.type == 'Song', self.pre_fill_items))
|
|
for song in songs:
|
|
self.pre_fill_items.remove(song)
|
|
|
|
important_warps = (self.shuffle_special_interior_entrances or self.shuffle_overworld_entrances or
|
|
self.warp_songs or self.spawn_positions)
|
|
song_order = {
|
|
'Zeldas Lullaby': 1,
|
|
'Eponas Song': 1,
|
|
'Sarias Song': 3 if important_warps else 0,
|
|
'Suns Song': 0,
|
|
'Song of Time': 0,
|
|
'Song of Storms': 3,
|
|
'Minuet of Forest': 2 if important_warps else 0,
|
|
'Bolero of Fire': 2 if important_warps else 0,
|
|
'Serenade of Water': 2 if important_warps else 0,
|
|
'Requiem of Spirit': 2,
|
|
'Nocturne of Shadow': 2,
|
|
'Prelude of Light': 2 if important_warps else 0,
|
|
}
|
|
songs.sort(key=lambda song: song_order.get(song.name, 0))
|
|
|
|
while tries:
|
|
try:
|
|
self.random.shuffle(song_locations)
|
|
fill_restrictive(self.multiworld, prefill_state(state), song_locations[:], songs[:],
|
|
single_player_placement=True, lock=True, allow_excluded=True)
|
|
logger.debug(f"Successfully placed songs for player {self.player} after {6 - tries} attempt(s)")
|
|
except FillError as e:
|
|
tries -= 1
|
|
if tries == 0:
|
|
raise Exception(f"Failed placing songs for player {self.player}. Error cause: {e}")
|
|
logger.debug(f"Failed placing songs for player {self.player}. Retries left: {tries}")
|
|
# undo what was done
|
|
for song in songs:
|
|
song.location = None
|
|
song.world = None
|
|
for location in song_locations:
|
|
location.item = None
|
|
location.locked = False
|
|
else:
|
|
break
|
|
|
|
# Place shop items
|
|
# fast fill will fail because there is some logic on the shop items. we'll gather them up and place the shop items
|
|
if self.shopsanity != 'off':
|
|
shop_prog = list(filter(lambda item: item.type == 'Shop' and item.advancement, self.pre_fill_items))
|
|
shop_junk = list(filter(lambda item: item.type == 'Shop' and not item.advancement, self.pre_fill_items))
|
|
shop_locations = list(
|
|
filter(lambda location: location.type == 'Shop' and location.name not in self.shop_prices,
|
|
self.multiworld.get_unfilled_locations(player=self.player)))
|
|
shop_prog.sort(key=lambda item: {
|
|
'Buy Deku Shield': 2 * int(self.open_forest == 'closed'),
|
|
'Buy Goron Tunic': 1,
|
|
'Buy Zora Tunic': 1,
|
|
}.get(item.name, 0)) # place Deku Shields if needed, then tunics, then other advancement
|
|
self.random.shuffle(shop_locations)
|
|
self.pre_fill_items = [] # all prefill should be done
|
|
fill_restrictive(self.multiworld, prefill_state(state), shop_locations, shop_prog,
|
|
single_player_placement=True, lock=True, allow_excluded=True)
|
|
fast_fill(self.multiworld, shop_junk, shop_locations)
|
|
for loc in shop_locations:
|
|
loc.locked = True
|
|
set_shop_rules(self) # sets wallet requirements on shop items, must be done after they are filled
|
|
|
|
# Exclude locations in Ganon's Castle proportional to the number of items required to make the bridge
|
|
# Check for dungeon ER later
|
|
if self.logic_rules == 'glitchless':
|
|
if self.bridge == 'medallions':
|
|
ganon_junk_fill = self.bridge_medallions / 9
|
|
elif self.bridge == 'stones':
|
|
ganon_junk_fill = self.bridge_stones / 9
|
|
elif self.bridge == 'dungeons':
|
|
ganon_junk_fill = self.bridge_rewards / 9
|
|
elif self.bridge == 'vanilla':
|
|
ganon_junk_fill = 2 / 9
|
|
elif self.bridge == 'tokens':
|
|
ganon_junk_fill = self.bridge_tokens / 100
|
|
elif self.bridge == 'hearts':
|
|
ganon_junk_fill = self.bridge_hearts / 20
|
|
elif self.bridge == 'open':
|
|
ganon_junk_fill = 0
|
|
else:
|
|
raise Exception("Unexpected bridge setting")
|
|
|
|
ganon_junk_fill = min(1, ganon_junk_fill)
|
|
gc = next(filter(lambda dungeon: dungeon.name == 'Ganons Castle', self.dungeons))
|
|
locations = [loc.name for region in gc.regions for loc in region.locations if loc.item is None]
|
|
junk_fill_locations = self.random.sample(locations, round(len(locations) * ganon_junk_fill))
|
|
exclusion_rules(self.multiworld, self.player, junk_fill_locations)
|
|
|
|
# Locations which are not sendable must be converted to events
|
|
# This includes all locations for which show_in_spoiler is false, and shuffled shop items.
|
|
for loc in self.get_locations():
|
|
if loc.address is not None and (
|
|
not loc.show_in_spoiler or oot_is_item_of_type(loc.item, 'Shop')
|
|
or (self.shuffle_child_trade == 'skip_child_zelda' and loc.name in ['HC Zeldas Letter', 'Song from Impa'])):
|
|
loc.address = None
|
|
|
|
|
|
def generate_output(self, output_directory: str):
|
|
|
|
# Write entrances to spoiler log
|
|
all_entrances = self.get_shuffled_entrances()
|
|
all_entrances.sort(reverse=True, key=lambda x: (x.type, x.name))
|
|
if not self.decouple_entrances:
|
|
while all_entrances:
|
|
loadzone = all_entrances.pop()
|
|
if loadzone.type != 'Overworld':
|
|
if loadzone.primary:
|
|
entrance = loadzone
|
|
else:
|
|
entrance = loadzone.reverse
|
|
if entrance.reverse is not None:
|
|
self.multiworld.spoiler.set_entrance(entrance, entrance.replaces.reverse, 'both', self.player)
|
|
else:
|
|
self.multiworld.spoiler.set_entrance(entrance, entrance.replaces, 'entrance', self.player)
|
|
else:
|
|
reverse = loadzone.replaces.reverse
|
|
if reverse in all_entrances:
|
|
all_entrances.remove(reverse)
|
|
self.multiworld.spoiler.set_entrance(loadzone, reverse, 'both', self.player)
|
|
else:
|
|
for entrance in all_entrances:
|
|
self.multiworld.spoiler.set_entrance(entrance, entrance.replaces, 'entrance', self.player)
|
|
|
|
if self.hints != 'none':
|
|
self.hint_data_available.wait()
|
|
|
|
with i_o_limiter:
|
|
# Make traps appear as other random items
|
|
trap_location_ids = [loc.address for loc in self.get_locations() if loc.item.trap]
|
|
self.trap_appearances = {}
|
|
for loc_id in trap_location_ids:
|
|
self.trap_appearances[loc_id] = self.create_item(self.random.choice(self.fake_items).name)
|
|
|
|
# Seed hint RNG, used for ganon text lines also
|
|
self.hint_rng = self.random
|
|
|
|
outfile_name = self.multiworld.get_out_file_name_base(self.player)
|
|
oot_settings = OOTWorld.settings
|
|
rom = Rom(file=oot_settings.rom_file)
|
|
try:
|
|
if self.hints != 'none':
|
|
buildWorldGossipHints(self)
|
|
patch_rom(self, rom)
|
|
patch_cosmetics(self, rom)
|
|
except Exception as e:
|
|
logger.error(e)
|
|
raise e
|
|
finally:
|
|
self.collectible_flags_available.set()
|
|
rom.update_header()
|
|
patch_data = create_patch_file(rom, self.random)
|
|
rom.restore()
|
|
|
|
apz5 = OoTContainer(patch_data, outfile_name, output_directory,
|
|
player=self.player,
|
|
player_name=self.multiworld.get_player_name(self.player))
|
|
apz5.write()
|
|
|
|
|
|
# Gathers hint data for OoT. Loops over all world locations for woth, barren, and major item locations.
|
|
@classmethod
|
|
def stage_generate_output(cls, multiworld: MultiWorld, output_directory: str):
|
|
def hint_type_players(hint_type: str) -> set:
|
|
return {autoworld.player for autoworld in multiworld.get_game_worlds("Ocarina of Time")
|
|
if autoworld.hints != 'none'
|
|
and autoworld.hint_dist_user['distribution'][hint_type]['copies'] > 0
|
|
and (autoworld.hint_dist_user['distribution'][hint_type]['fixed'] > 0
|
|
or autoworld.hint_dist_user['distribution'][hint_type]['weight'] > 0)}
|
|
|
|
try:
|
|
item_hint_players = hint_type_players('item')
|
|
barren_hint_players = hint_type_players('barren')
|
|
woth_hint_players = hint_type_players('woth')
|
|
|
|
items_by_region = {}
|
|
for player in barren_hint_players:
|
|
items_by_region[player] = {}
|
|
for r in multiworld.worlds[player].regions:
|
|
items_by_region[player][r._hint_text] = {'dungeon': False, 'weight': 0, 'is_barren': True}
|
|
for d in multiworld.worlds[player].dungeons:
|
|
items_by_region[player][d.hint_text] = {'dungeon': True, 'weight': 0, 'is_barren': True}
|
|
del (items_by_region[player]["Link's pocket"])
|
|
del (items_by_region[player][None])
|
|
|
|
if item_hint_players: # loop once over all locations to gather major items. Check oot locations for barren/woth if needed
|
|
for loc in multiworld.get_locations():
|
|
player = loc.item.player
|
|
autoworld = multiworld.worlds[player]
|
|
if ((player in item_hint_players and (autoworld.is_major_item(loc.item) or loc.item.name in autoworld.item_added_hint_types['item']))
|
|
or (loc.player in item_hint_players and loc.name in multiworld.worlds[loc.player].added_hint_types['item'])):
|
|
autoworld.major_item_locations.append(loc)
|
|
|
|
if loc.game == "Ocarina of Time" and loc.item.code and (not loc.locked or
|
|
(oot_is_item_of_type(loc.item, 'Song') or
|
|
(oot_is_item_of_type(loc.item, 'SmallKey') and multiworld.worlds[loc.player].shuffle_smallkeys in ('overworld', 'any_dungeon', 'regional')) or
|
|
(oot_is_item_of_type(loc.item, 'HideoutSmallKey') and multiworld.worlds[loc.player].shuffle_hideoutkeys in ('overworld', 'any_dungeon', 'regional')) or
|
|
(oot_is_item_of_type(loc.item, 'BossKey') and multiworld.worlds[loc.player].shuffle_bosskeys in ('overworld', 'any_dungeon', 'regional')) or
|
|
(oot_is_item_of_type(loc.item, 'GanonBossKey') and multiworld.worlds[loc.player].shuffle_ganon_bosskey in ('overworld', 'any_dungeon', 'regional')))):
|
|
if loc.player in barren_hint_players:
|
|
hint_area = get_hint_area(loc)
|
|
items_by_region[loc.player][hint_area]['weight'] += 1
|
|
if loc.item.advancement or loc.item.useful:
|
|
items_by_region[loc.player][hint_area]['is_barren'] = False
|
|
if loc.player in woth_hint_players and loc.item.advancement:
|
|
# Skip item at location and see if game is still beatable
|
|
state = CollectionState(multiworld)
|
|
state.locations_checked.add(loc)
|
|
if not multiworld.can_beat_game(state):
|
|
multiworld.worlds[loc.player].required_locations.append(loc)
|
|
elif barren_hint_players or woth_hint_players: # Check only relevant oot locations for barren/woth
|
|
for player in (barren_hint_players | woth_hint_players):
|
|
for loc in multiworld.worlds[player].get_locations():
|
|
if loc.item.code and (not loc.locked or
|
|
(oot_is_item_of_type(loc.item, 'Song') or
|
|
(oot_is_item_of_type(loc.item, 'SmallKey') and multiworld.worlds[loc.player].shuffle_smallkeys in ('overworld', 'any_dungeon', 'regional')) or
|
|
(oot_is_item_of_type(loc.item, 'HideoutSmallKey') and multiworld.worlds[loc.player].shuffle_hideoutkeys in ('overworld', 'any_dungeon', 'regional')) or
|
|
(oot_is_item_of_type(loc.item, 'BossKey') and multiworld.worlds[loc.player].shuffle_bosskeys in ('overworld', 'any_dungeon', 'regional')) or
|
|
(oot_is_item_of_type(loc.item, 'GanonBossKey') and multiworld.worlds[loc.player].shuffle_ganon_bosskey in ('overworld', 'any_dungeon', 'regional')))):
|
|
if player in barren_hint_players:
|
|
hint_area = get_hint_area(loc)
|
|
items_by_region[player][hint_area]['weight'] += 1
|
|
if loc.item.advancement or loc.item.useful:
|
|
items_by_region[player][hint_area]['is_barren'] = False
|
|
if player in woth_hint_players and loc.item.advancement:
|
|
state = CollectionState(multiworld)
|
|
state.locations_checked.add(loc)
|
|
if not multiworld.can_beat_game(state):
|
|
multiworld.worlds[player].required_locations.append(loc)
|
|
for player in barren_hint_players:
|
|
multiworld.worlds[player].empty_areas = {region: info for (region, info) in items_by_region[player].items()
|
|
if info['is_barren']}
|
|
except Exception as e:
|
|
raise e
|
|
finally:
|
|
for autoworld in multiworld.get_game_worlds("Ocarina of Time"):
|
|
autoworld.hint_data_available.set()
|
|
|
|
|
|
def fill_slot_data(self):
|
|
self.collectible_flags_available.wait()
|
|
|
|
slot_data = {
|
|
'collectible_override_flags': self.collectible_override_flags,
|
|
'collectible_flag_offsets': self.collectible_flag_offsets
|
|
}
|
|
slot_data.update(self.options.as_dict(
|
|
"open_forest", "open_kakariko", "open_door_of_time", "zora_fountain", "gerudo_fortress",
|
|
"bridge", "bridge_stones", "bridge_medallions", "bridge_rewards", "bridge_tokens", "bridge_hearts",
|
|
"shuffle_ganon_bosskey", "ganon_bosskey_medallions", "ganon_bosskey_stones", "ganon_bosskey_rewards",
|
|
"ganon_bosskey_tokens", "ganon_bosskey_hearts", "trials",
|
|
"triforce_hunt", "triforce_goal", "extra_triforce_percentage",
|
|
"shopsanity", "shop_slots", "shopsanity_prices", "tokensanity",
|
|
"dungeon_shortcuts", "dungeon_shortcuts_list",
|
|
"mq_dungeons_mode", "mq_dungeons_list", "mq_dungeons_count",
|
|
"shuffle_interior_entrances", "shuffle_grotto_entrances", "shuffle_dungeon_entrances",
|
|
"shuffle_overworld_entrances", "shuffle_bosses", "key_rings", "key_rings_list", "enhance_map_compass",
|
|
"shuffle_mapcompass", "shuffle_smallkeys", "shuffle_hideoutkeys", "shuffle_bosskeys",
|
|
"logic_rules", "logic_no_night_tokens_without_suns_song", "logic_tricks",
|
|
"warp_songs", "shuffle_song_items","shuffle_medigoron_carpet_salesman", "shuffle_frog_song_rupees",
|
|
"shuffle_scrubs", "shuffle_child_trade", "shuffle_freestanding_items", "shuffle_pots", "shuffle_crates",
|
|
"shuffle_cows", "shuffle_beehives", "shuffle_kokiri_sword", "shuffle_ocarinas", "shuffle_gerudo_card",
|
|
"shuffle_beans", "starting_age", "bombchus_in_logic", "spawn_positions", "owl_drops",
|
|
"no_epona_race", "skip_some_minigame_phases", "complete_mask_quest", "free_scarecrow", "plant_beans",
|
|
"chicken_count", "big_poe_count", "fae_torch_count", "blue_fire_arrows",
|
|
"damage_multiplier", "deadly_bonks", "starting_tod", "junk_ice_traps",
|
|
"start_with_consumables", "adult_trade_start", "plando_connections"
|
|
)
|
|
)
|
|
return slot_data
|
|
|
|
|
|
def modify_multidata(self, multidata: dict):
|
|
|
|
# Replace connect name
|
|
multidata['connect_names'][self.connect_name] = multidata['connect_names'][self.multiworld.player_name[self.player]]
|
|
|
|
# Remove undesired items from start_inventory
|
|
# This is because we don't want them to show up in the autotracker,
|
|
# they just don't exist in-game.
|
|
for item_name in self.remove_from_start_inventory:
|
|
item_id = self.item_name_to_id.get(item_name, None)
|
|
if item_id is None:
|
|
continue
|
|
multidata["precollected_items"][self.player].remove(item_id)
|
|
|
|
# If skip child zelda, push item onto autotracker
|
|
if self.shuffle_child_trade == 'skip_child_zelda':
|
|
impa_item_id = self.item_name_to_id.get(self.get_location('Song from Impa').item.name, None)
|
|
zelda_item_id = self.item_name_to_id.get(self.get_location('HC Zeldas Letter').item.name, None)
|
|
if impa_item_id:
|
|
multidata["precollected_items"][self.player].append(impa_item_id)
|
|
if zelda_item_id:
|
|
multidata["precollected_items"][self.player].append(zelda_item_id)
|
|
|
|
|
|
def extend_hint_information(self, er_hint_data: dict):
|
|
|
|
er_hint_data[self.player] = {}
|
|
|
|
hint_entrances = set()
|
|
for entrance in entrance_shuffle_table:
|
|
if entrance[0] in {'Dungeon', 'DungeonSpecial', 'Interior', 'SpecialInterior', 'Grotto', 'Grave'}:
|
|
hint_entrances.add(entrance[1][0])
|
|
|
|
# Get main hint entrance to region.
|
|
# If the region is directly adjacent to a hint-entrance, we return that one.
|
|
# If it's in a dungeon, scan all the entrances for all the regions in the dungeon.
|
|
# This should terminate on the first region anyway, but we scan everything to be safe.
|
|
# If it's one of the special cases, go one level deeper.
|
|
# If it's a boss room, go one level deeper to the boss door region, which is in a dungeon.
|
|
# Otherwise return None.
|
|
def get_entrance_to_region(region):
|
|
special_case_regions = {
|
|
"Beyond Door of Time",
|
|
"Kak Impas House Near Cow",
|
|
}
|
|
|
|
for entrance in region.entrances:
|
|
if entrance.name in hint_entrances:
|
|
return entrance
|
|
if region.dungeon is not None:
|
|
for r in region.dungeon.regions:
|
|
for e in r.entrances:
|
|
if e.name in hint_entrances:
|
|
return e
|
|
if region.is_boss_room or region.name in special_case_regions:
|
|
return get_entrance_to_region(region.entrances[0].parent_region)
|
|
return None
|
|
|
|
if (self.shuffle_interior_entrances != 'off' or self.shuffle_dungeon_entrances
|
|
or self.shuffle_grotto_entrances or self.shuffle_bosses != 'off'):
|
|
for region in self.regions:
|
|
if not any(bool(loc.address) for loc in region.locations): # check if region has any non-event locations
|
|
continue
|
|
main_entrance = get_entrance_to_region(region)
|
|
if main_entrance is not None and (main_entrance.shuffled or (region.is_boss_room and self.shuffle_bosses != 'off')):
|
|
for location in region.locations:
|
|
if type(location.address) == int:
|
|
er_hint_data[self.player][location.address] = main_entrance.name
|
|
logger.debug(f"Set {location.name} hint data to {main_entrance.name}")
|
|
|
|
|
|
def write_spoiler(self, spoiler_handle: typing.TextIO) -> None:
|
|
required_trials_str = ", ".join(t for t in self.skipped_trials if not self.skipped_trials[t])
|
|
if required_trials_str == "":
|
|
required_trials_str = "None"
|
|
spoiler_handle.write(f"\n\nTrials ({self.multiworld.get_player_name(self.player)}): {required_trials_str}\n")
|
|
|
|
if self.shopsanity != 'off':
|
|
spoiler_handle.write(f"\nShop Prices ({self.multiworld.get_player_name(self.player)}):\n")
|
|
for k, v in self.shop_prices.items():
|
|
spoiler_handle.write(f"{k}: {v} Rupees\n")
|
|
|
|
|
|
# Key ring handling:
|
|
# Key rings are multiple items glued together into one, so we need to give
|
|
# the appropriate number of keys in the collection state when they are
|
|
# picked up.
|
|
def collect(self, state: CollectionState, item: OOTItem) -> bool:
|
|
state._oot_stale[self.player] = True
|
|
if item.advancement and item.special and item.special.get('alias', False):
|
|
alt_item_name, count = item.special.get('alias')
|
|
state.prog_items[self.player][alt_item_name] += count
|
|
return True
|
|
return super().collect(state, item)
|
|
|
|
def remove(self, state: CollectionState, item: OOTItem) -> bool:
|
|
if item.advancement and item.special and item.special.get('alias', False):
|
|
alt_item_name, count = item.special.get('alias')
|
|
state.prog_items[self.player][alt_item_name] -= count
|
|
if state.prog_items[self.player][alt_item_name] < 1:
|
|
del (state.prog_items[self.player][alt_item_name])
|
|
# invalidate caches, nothing can be trusted anymore now
|
|
state.child_reachable_regions[self.player] = set()
|
|
state.child_blocked_connections[self.player] = set()
|
|
state.adult_reachable_regions[self.player] = set()
|
|
state.adult_blocked_connections[self.player] = set()
|
|
state._oot_stale[self.player] = True
|
|
return True
|
|
changed = super().remove(state, item)
|
|
if changed:
|
|
# invalidate caches, nothing can be trusted anymore now
|
|
state.child_reachable_regions[self.player] = set()
|
|
state.child_blocked_connections[self.player] = set()
|
|
state.adult_reachable_regions[self.player] = set()
|
|
state.adult_blocked_connections[self.player] = set()
|
|
state._oot_stale[self.player] = True
|
|
return changed
|
|
|
|
|
|
# Helper functions
|
|
def region_has_shortcuts(self, regionname):
|
|
region = self.get_region(regionname)
|
|
try:
|
|
dungeon_name = HintArea.at(region).dungeon_name
|
|
return dungeon_name in self.dungeon_shortcuts
|
|
except HintAreaNotFound:
|
|
return False
|
|
|
|
def get_shufflable_entrances(self, type=None, only_primary=False):
|
|
return [entrance for entrance in self.get_entrances() if ((type == None or entrance.type == type)
|
|
and (not only_primary or entrance.primary))]
|
|
|
|
def get_shuffled_entrances(self, type=None, only_primary=False):
|
|
return [entrance for entrance in self.get_shufflable_entrances(type=type, only_primary=only_primary) if
|
|
entrance.shuffled]
|
|
|
|
def get_locations(self):
|
|
return self.multiworld.get_locations(self.player)
|
|
|
|
def get_entrances(self):
|
|
return self.multiworld.get_entrances(self.player)
|
|
|
|
def is_major_item(self, item: OOTItem):
|
|
if item.type == 'Token':
|
|
return self.bridge == 'tokens' or self.lacs_condition == 'tokens'
|
|
|
|
if item.name in self.nonadvancement_items:
|
|
return True
|
|
|
|
if item.type in ('Drop', 'Event', 'Shop', 'DungeonReward') or not item.advancement:
|
|
return False
|
|
|
|
if item.name.startswith('Bombchus') and not self.bombchus_in_logic:
|
|
return False
|
|
|
|
if item.type in ['Map', 'Compass']:
|
|
return False
|
|
if item.type == 'SmallKey' and self.shuffle_smallkeys in ['dungeon', 'vanilla']:
|
|
return False
|
|
if item.type == 'HideoutSmallKey' and self.shuffle_hideoutkeys == 'vanilla':
|
|
return False
|
|
if item.type == 'BossKey' and self.shuffle_bosskeys in ['dungeon', 'vanilla']:
|
|
return False
|
|
if item.type == 'GanonBossKey' and self.shuffle_ganon_bosskey in ['dungeon', 'vanilla']:
|
|
return False
|
|
|
|
return True
|
|
|
|
# Specifically ensures that only real items are gotten, not any events.
|
|
# In particular, ensures that Time Travel needs to be found.
|
|
def get_state_with_complete_itempool(self):
|
|
all_state = CollectionState(self.multiworld)
|
|
for item in self.itempool + self.pre_fill_items:
|
|
self.multiworld.worlds[item.player].collect(all_state, item)
|
|
# If free_scarecrow give Scarecrow Song
|
|
if self.free_scarecrow:
|
|
all_state.collect(self.create_item("Scarecrow Song"), prevent_sweep=True)
|
|
all_state._oot_stale[self.player] = True
|
|
|
|
return all_state
|
|
|
|
def get_filler_item_name(self) -> str:
|
|
return get_junk_item(self.random, count=1, pool=get_junk_pool(self))[0]
|
|
|
|
|
|
def valid_dungeon_item_location(world: OOTWorld, option: str, dungeon: str, loc: OOTLocation) -> bool:
|
|
if option == 'dungeon':
|
|
return (getattr(loc.parent_region.dungeon, 'name', None) == dungeon
|
|
and (world.shuffle_song_items != 'dungeon' or loc.name not in dungeon_song_locations))
|
|
elif option == 'any_dungeon':
|
|
return (loc.parent_region.dungeon is not None
|
|
and (world.shuffle_song_items != 'dungeon' or loc.name not in dungeon_song_locations))
|
|
elif option == 'overworld':
|
|
return (loc.parent_region.dungeon is None
|
|
and (loc.type != 'Shop' or loc.name in world.shop_prices)
|
|
and (world.shuffle_song_items != 'song' or loc.type != 'Song')
|
|
and (world.shuffle_song_items != 'dungeon' or loc.name not in dungeon_song_locations))
|
|
elif option == 'regional':
|
|
color = HintArea.for_dungeon(dungeon).color
|
|
return (HintArea.at(loc).color == color
|
|
and (loc.type != 'Shop' or loc.name in world.shop_prices)
|
|
and (world.shuffle_song_items != 'song' or loc.type != 'Song')
|
|
and (world.shuffle_song_items != 'dungeon' or loc.name not in dungeon_song_locations))
|
|
return False
|
|
# raise ValueError(f'Unexpected argument to valid_dungeon_item_location: {option}')
|
|
|
|
|
|
def gather_locations(multiworld: MultiWorld,
|
|
item_type: str,
|
|
players: Union[int, AbstractSet[int]],
|
|
dungeon: str = ''
|
|
) -> Optional[List[OOTLocation]]:
|
|
type_to_setting = {
|
|
'Map': 'shuffle_mapcompass',
|
|
'Compass': 'shuffle_mapcompass',
|
|
'SmallKey': 'shuffle_smallkeys',
|
|
'BossKey': 'shuffle_bosskeys',
|
|
'HideoutSmallKey': 'shuffle_hideoutkeys',
|
|
'GanonBossKey': 'shuffle_ganon_bosskey',
|
|
}
|
|
|
|
# Special handling for atypical item types
|
|
if item_type == 'HideoutSmallKey':
|
|
dungeon = 'Thieves Hideout'
|
|
elif item_type == 'GanonBossKey':
|
|
dungeon = 'Ganons Castle'
|
|
|
|
if isinstance(players, int):
|
|
players = {players}
|
|
fill_opts = {p: getattr(multiworld.worlds[p], type_to_setting[item_type]) for p in players}
|
|
locations = []
|
|
if any(map(lambda v: v == 'keysanity', fill_opts.values())):
|
|
return None
|
|
for player, option in fill_opts.items():
|
|
condition = functools.partial(valid_dungeon_item_location,
|
|
multiworld.worlds[player], option, dungeon)
|
|
locations += filter(condition, multiworld.get_unfilled_locations(player=player))
|
|
|
|
return locations
|
|
|