OoT Time Optimization (#2401)

- Entrance randomizer no longer grows with multiworld
- Improved ER success rate again by prioritizing Temple of Time even more
- Prefill is faster, has slightly reduced failure rate when map/compass are in dungeon but previous items in any_dungeon (which consumed all available locations), no longer removes items from the main itempool; itemlinked prefill items removed to accomodate improvements
- Now triggers only one recache after `generate_basic` instead of one per oot world
- Avoids recaches during `create_regions`
- All ER temp entrances have unique names (so the entrance cache does not break)
This commit is contained in:
espeon65536
2023-10-29 21:05:49 -06:00
committed by GitHub
parent f81e72686a
commit db978aa48a
4 changed files with 172 additions and 146 deletions

View File

@@ -170,15 +170,19 @@ class OOTWorld(World):
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):
rom = Rom(file=get_options()['oot_options']['rom_file'])
# Option parsing, handling incompatible options, building useful-item table
def generate_early(self):
self.parser = Rule_AST_Transformer(self, self.player)
@@ -194,9 +198,10 @@ class OOTWorld(World):
option_value = result.current_key
setattr(self, option_name, option_value)
self.shop_prices = {}
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
@@ -490,6 +495,8 @@ class OOTWorld(World):
# 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)
@@ -561,8 +568,9 @@ class OOTWorld(World):
self.multiworld.regions.append(new_region)
self.regions.append(new_region)
self._regions_cache[new_region.name] = new_region
# self.multiworld._recache()
# 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'}]
@@ -591,6 +599,8 @@ class OOTWorld(World):
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 = {}
@@ -616,6 +626,8 @@ class OOTWorld(World):
elif self.shopsanity_prices == 'tycoons_wallet':
self.shop_prices[location.name] = self.multiworld.random.randrange(0,1000,5)
# Fill boss prizes
def fill_bosses(self, bossCount=9):
boss_location_names = (
'Queen Gohma',
@@ -644,6 +656,44 @@ class OOTWorld(World):
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,
@@ -663,7 +713,9 @@ class OOTWorld(World):
location.internal = True
return item
def create_regions(self): # create and link regions
# 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:
@@ -689,6 +741,8 @@ class OOTWorld(World):
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)
@@ -714,12 +768,16 @@ class OOTWorld(World):
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:
@@ -757,6 +815,7 @@ class OOTWorld(World):
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
@@ -769,7 +828,8 @@ class OOTWorld(World):
# 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.multiworld.get_all_state(use_cache=True)
all_state = self.get_state_with_complete_itempool()
all_state.sweep_for_events()
all_locations = self.get_locations()
reachable = self.multiworld.get_reachable_locations(all_state, self.player)
unreachable = [loc for loc in all_locations if
@@ -791,35 +851,63 @@ class OOTWorld(World):
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_events(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.multiworld.random.shuffle(locations)
# Set up initial state
state = CollectionState(self.multiworld)
for item in self.itempool:
self.collect(state, item)
state.sweep_for_events(self.get_locations())
# Place dungeon items
special_fill_types = ['GanonBossKey', 'BossKey', 'SmallKey', 'HideoutSmallKey', 'Map', 'Compass']
world_items = [item for item in self.multiworld.itempool if item.player == self.player]
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), world_items))
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.multiworld.itempool.remove(item)
self.pre_fill_items.remove(item)
self.multiworld.random.shuffle(locations)
fill_restrictive(self.multiworld, self.multiworld.get_all_state(False), locations, stage_items,
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):
dungeon_items = list(filter(lambda item: dungeon_name in item.name, stage_items))
if not dungeon_items:
continue
for item in dungeon_items:
self.multiworld.itempool.remove(item)
self.pre_fill_items.remove(item)
self.multiworld.random.shuffle(locations)
fill_restrictive(self.multiworld, self.multiworld.get_all_state(False), locations, dungeon_items,
fill_restrictive(self.multiworld, prefill_state(state), locations, dungeon_items,
single_player_placement=True, lock=True, allow_excluded=True)
# Place songs
@@ -835,9 +923,9 @@ class OOTWorld(World):
else:
raise Exception(f"Unknown song shuffle type: {self.shuffle_song_items}")
songs = list(filter(lambda item: item.player == self.player and item.type == 'Song', self.multiworld.itempool))
songs = list(filter(lambda item: item.type == 'Song', self.pre_fill_items))
for song in songs:
self.multiworld.itempool.remove(song)
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)
@@ -860,7 +948,7 @@ class OOTWorld(World):
while tries:
try:
self.multiworld.random.shuffle(song_locations)
fill_restrictive(self.multiworld, self.multiworld.get_all_state(False), song_locations[:], songs[:],
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:
@@ -882,10 +970,8 @@ class OOTWorld(World):
# 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.player == self.player and item.type == 'Shop'
and item.advancement, self.multiworld.itempool))
shop_junk = list(filter(lambda item: item.player == self.player and item.type == 'Shop'
and not item.advancement, self.multiworld.itempool))
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)))
@@ -895,30 +981,14 @@ class OOTWorld(World):
'Buy Zora Tunic': 1,
}.get(item.name, 0)) # place Deku Shields if needed, then tunics, then other advancement
self.multiworld.random.shuffle(shop_locations)
for item in shop_prog + shop_junk:
self.multiworld.itempool.remove(item)
fill_restrictive(self.multiworld, self.multiworld.get_all_state(False), shop_locations, shop_prog,
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
# If skip child zelda is active and Song from Impa is unfilled, put a local giveable item into it.
impa = self.multiworld.get_location("Song from Impa", self.player)
if self.shuffle_child_trade == 'skip_child_zelda':
if impa.item is None:
candidate_items = list(item for item in self.multiworld.itempool if item.player == self.player)
if candidate_items:
item_to_place = self.multiworld.random.choice(candidate_items)
self.multiworld.itempool.remove(item_to_place)
else:
item_to_place = self.create_item("Recovery Heart")
impa.place_locked_item(item_to_place)
# Give items to startinventory
self.multiworld.push_precollected(impa.item)
self.multiworld.push_precollected(self.create_item("Zeldas Letter"))
# 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':
@@ -953,49 +1023,6 @@ class OOTWorld(World):
or (self.shuffle_child_trade == 'skip_child_zelda' and loc.name in ['HC Zeldas Letter', 'Song from Impa'])):
loc.address = None
# Handle item-linked dungeon items and songs
@classmethod
def stage_pre_fill(cls, multiworld: MultiWorld):
special_fill_types = ['Song', 'GanonBossKey', 'BossKey', 'SmallKey', 'HideoutSmallKey', 'Map', 'Compass']
for group_id, group in multiworld.groups.items():
if group['game'] != cls.game:
continue
group_items = [item for item in multiworld.itempool if item.player == group_id]
for fill_stage in special_fill_types:
group_stage_items = list(filter(lambda item: oot_is_item_of_type(item, fill_stage), group_items))
if not group_stage_items:
continue
if fill_stage in ['Song', 'GanonBossKey', 'HideoutSmallKey']:
# No need to subdivide by dungeon name
locations = gather_locations(multiworld, fill_stage, group['players'])
if isinstance(locations, list):
for item in group_stage_items:
multiworld.itempool.remove(item)
multiworld.random.shuffle(locations)
fill_restrictive(multiworld, multiworld.get_all_state(False), locations, group_stage_items,
single_player_placement=False, lock=True, allow_excluded=True)
if fill_stage == 'Song':
# We don't want song locations to contain progression unless it's a song
# or it was marked as priority.
# We do this manually because we'd otherwise have to either
# iterate twice or do many function calls.
for loc in locations:
if loc.progress_type == LocationProgressType.DEFAULT:
loc.progress_type = LocationProgressType.EXCLUDED
add_item_rule(loc, lambda i: not (i.advancement or i.useful))
else:
# Perform the fill task once per dungeon
for dungeon_info in dungeon_table:
dungeon_name = dungeon_info['name']
locations = gather_locations(multiworld, fill_stage, group['players'], dungeon=dungeon_name)
if isinstance(locations, list):
group_dungeon_items = list(filter(lambda item: dungeon_name in item.name, group_stage_items))
for item in group_dungeon_items:
multiworld.itempool.remove(item)
multiworld.random.shuffle(locations)
fill_restrictive(multiworld, multiworld.get_all_state(False), locations, group_dungeon_items,
single_player_placement=False, lock=True, allow_excluded=True)
def generate_output(self, output_directory: str):
if self.hints != 'none':
@@ -1134,6 +1161,15 @@ class OOTWorld(World):
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):
@@ -1248,17 +1284,15 @@ class OOTWorld(World):
return False
def get_shufflable_entrances(self, type=None, only_primary=False):
return [entrance for entrance in self.multiworld.get_entrances(self.player) if (
(type == None or entrance.type == type) and (not only_primary or entrance.primary))]
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):
for region in self.regions:
for loc in region.locations:
yield loc
return self.multiworld.get_locations(self.player)
def get_location(self, location):
return self.multiworld.get_location(location, self.player)
@@ -1271,6 +1305,9 @@ class OOTWorld(World):
self._regions_cache[region_name] = ret
return ret
def get_entrances(self):
return self.multiworld.get_entrances(self.player)
def get_entrance(self, entrance):
return self.multiworld.get_entrance(entrance, self.player)
@@ -1304,9 +1341,8 @@ class OOTWorld(World):
# 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.multiworld.itempool:
if item.player == self.player:
self.multiworld.worlds[item.player].collect(all_state, item)
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"), event=True)
@@ -1346,7 +1382,6 @@ def gather_locations(multiworld: MultiWorld,
dungeon: str = ''
) -> Optional[List[OOTLocation]]:
type_to_setting = {
'Song': 'shuffle_song_items',
'Map': 'shuffle_mapcompass',
'Compass': 'shuffle_mapcompass',
'SmallKey': 'shuffle_smallkeys',
@@ -1365,21 +1400,12 @@ def gather_locations(multiworld: MultiWorld,
players = {players}
fill_opts = {p: getattr(multiworld.worlds[p], type_to_setting[item_type]) for p in players}
locations = []
if item_type == 'Song':
if any(map(lambda v: v == 'any', fill_opts.values())):
return None
for player, option in fill_opts.items():
if option == 'song':
condition = lambda location: location.type == 'Song'
elif option == 'dungeon':
condition = lambda location: location.name in dungeon_song_locations
locations += filter(condition, multiworld.get_unfilled_locations(player=player))
else:
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))
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