From 51f65f4b9e68e9653d06e99044fb7a50e8b5a604 Mon Sep 17 00:00:00 2001 From: espeon65536 <81029175+espeon65536@users.noreply.github.com> Date: Sat, 15 Oct 2022 03:39:04 -0700 Subject: [PATCH] OoT: ER algorithm improvements (#1103) * OoT: ER improvements Include dungeon rewards in itempool to allow for ER improvement Better validate_world function by checking for multi-entrance incompatibility more efficiently Fix some generation failures by ensuring all entrances placed with logic Introduce bias to some interior entrance placement to improve generation rate * OoT: fix overworld ER spoiler information * OoT: rewrite dungeon item placement algorithm in particular, no longer assumes that exactly the number of vanilla keys is present, which lets it place more or fewer items. --- worlds/oot/EntranceShuffle.py | 68 ++++++++++++++++------ worlds/oot/__init__.py | 106 ++++++++++++++++++---------------- 2 files changed, 107 insertions(+), 67 deletions(-) diff --git a/worlds/oot/EntranceShuffle.py b/worlds/oot/EntranceShuffle.py index 08d1e3ff..bd06a3d8 100644 --- a/worlds/oot/EntranceShuffle.py +++ b/worlds/oot/EntranceShuffle.py @@ -343,6 +343,27 @@ priority_entrance_table = { } +# These hint texts have more than one entrance, so they are OK for impa's house and potion shop +multi_interior_regions = { + 'Kokiri Forest', + 'Lake Hylia', + 'the Market', + 'Kakariko Village', + 'Lon Lon Ranch', +} + +interior_entrance_bias = { + 'Kakariko Village -> Kak Potion Shop Front': 4, + 'Kak Backyard -> Kak Potion Shop Back': 4, + 'Kakariko Village -> Kak Impas House': 3, + 'Kak Impas Ledge -> Kak Impas House Back': 3, + 'Goron City -> GC Shop': 2, + 'Zoras Domain -> ZD Shop': 2, + 'Market Entrance -> Market Guard House': 2, + 'ToT Entrance -> Temple of Time': 1, +} + + class EntranceShuffleError(Exception): pass @@ -500,7 +521,7 @@ def shuffle_random_entrances(ootworld): delete_target_entrance(remaining_target) for pool_type, entrance_pool in one_way_entrance_pools.items(): - shuffle_entrance_pool(ootworld, entrance_pool, one_way_target_entrance_pools[pool_type], locations_to_ensure_reachable, all_state, none_state, check_all=True, retry_count=5) + shuffle_entrance_pool(ootworld, pool_type, entrance_pool, one_way_target_entrance_pools[pool_type], locations_to_ensure_reachable, all_state, none_state, check_all=True, retry_count=5) replaced_entrances = [entrance.replaces for entrance in entrance_pool] for remaining_target in chain.from_iterable(one_way_target_entrance_pools.values()): if remaining_target.replaces in replaced_entrances: @@ -510,7 +531,7 @@ def shuffle_random_entrances(ootworld): # Shuffle all entrance pools, in order for pool_type, entrance_pool in entrance_pools.items(): - shuffle_entrance_pool(ootworld, entrance_pool, target_entrance_pools[pool_type], locations_to_ensure_reachable, all_state, none_state) + shuffle_entrance_pool(ootworld, pool_type, entrance_pool, target_entrance_pools[pool_type], locations_to_ensure_reachable, all_state, none_state, check_all=True) # Multiple checks after shuffling to ensure everything is OK # Check that all entrances hook up correctly @@ -596,7 +617,7 @@ def place_one_way_priority_entrance(ootworld, priority_name, allowed_regions, al raise EntranceShuffleError(f'Unable to place priority one-way entrance for {priority_name} in world {ootworld.player}') -def shuffle_entrance_pool(ootworld, entrance_pool, target_entrances, locations_to_ensure_reachable, all_state, none_state, check_all=False, retry_count=20): +def shuffle_entrance_pool(ootworld, pool_type, entrance_pool, target_entrances, locations_to_ensure_reachable, all_state, none_state, check_all=False, retry_count=20): restrictive_entrances, soft_entrances = split_entrances_by_requirements(ootworld, entrance_pool, target_entrances) @@ -604,11 +625,11 @@ def shuffle_entrance_pool(ootworld, entrance_pool, target_entrances, locations_t retry_count -= 1 rollbacks = [] try: - shuffle_entrances(ootworld, restrictive_entrances, target_entrances, rollbacks, locations_to_ensure_reachable, all_state, none_state) + shuffle_entrances(ootworld, pool_type+'Rest', restrictive_entrances, target_entrances, rollbacks, locations_to_ensure_reachable, all_state, none_state) if check_all: - shuffle_entrances(ootworld, soft_entrances, target_entrances, rollbacks, locations_to_ensure_reachable, all_state, none_state) + shuffle_entrances(ootworld, pool_type+'Soft', soft_entrances, target_entrances, rollbacks, locations_to_ensure_reachable, all_state, none_state) else: - shuffle_entrances(ootworld, soft_entrances, target_entrances, rollbacks, set(), all_state, none_state) + shuffle_entrances(ootworld, pool_type+'Soft', soft_entrances, target_entrances, rollbacks, set(), all_state, none_state) validate_world(ootworld, None, locations_to_ensure_reachable, all_state, none_state) for entrance, target in rollbacks: @@ -621,12 +642,16 @@ def shuffle_entrance_pool(ootworld, entrance_pool, target_entrances, locations_t raise EntranceShuffleError(f'Entrance placement attempt count exceeded for world {ootworld.player}') -def shuffle_entrances(ootworld, entrances, target_entrances, rollbacks, locations_to_ensure_reachable, all_state, none_state): +def shuffle_entrances(ootworld, pool_type, entrances, target_entrances, rollbacks, locations_to_ensure_reachable, all_state, none_state): ootworld.world.random.shuffle(entrances) for entrance in entrances: if entrance.connected_region != None: continue ootworld.world.random.shuffle(target_entrances) + # Here we deliberately introduce bias by prioritizing certain interiors, i.e. the ones most likely to cause problems. + # success rate over randomization + if pool_type in {'InteriorSoft', 'MixedSoft'}: + target_entrances.sort(reverse=True, key=lambda entrance: interior_entrance_bias.get(entrance.replaces.name, 0)) for target in target_entrances: if target.connected_region == None: continue @@ -715,25 +740,33 @@ def validate_world(ootworld, entrance_placed, locations_to_ensure_reachable, all # Check if all locations are reachable if not beatable-only or game is not yet complete if locations_to_ensure_reachable: - if world.accessibility[player].current_key != 'minimal' or not world.can_beat_game(all_state): - for loc in locations_to_ensure_reachable: - if not all_state.can_reach(loc, 'Location', player): - raise EntranceShuffleError(f'{loc} is unreachable') + for loc in locations_to_ensure_reachable: + if not all_state.can_reach(loc, 'Location', player): + raise EntranceShuffleError(f'{loc} is unreachable') if ootworld.shuffle_interior_entrances and (ootworld.misc_hints or ootworld.hints != 'none') and \ (entrance_placed == None or entrance_placed.type in ['Interior', 'SpecialInterior']): # Ensure Kak Potion Shop entrances are in the same hint area so there is no ambiguity as to which entrance is used for hints - potion_front_entrance = get_entrance_replacing(world.get_region('Kak Potion Shop Front', player), 'Kakariko Village -> Kak Potion Shop Front', player) - potion_back_entrance = get_entrance_replacing(world.get_region('Kak Potion Shop Back', player), 'Kak Backyard -> Kak Potion Shop Back', player) - if potion_front_entrance is not None and potion_back_entrance is not None and not same_hint_area(potion_front_entrance, potion_back_entrance): + potion_front = get_entrance_replacing(world.get_region('Kak Potion Shop Front', player), 'Kakariko Village -> Kak Potion Shop Front', player) + potion_back = get_entrance_replacing(world.get_region('Kak Potion Shop Back', player), 'Kak Backyard -> Kak Potion Shop Back', player) + if potion_front is not None and potion_back is not None and not same_hint_area(potion_front, potion_back): raise EntranceShuffleError('Kak Potion Shop entrances are not in the same hint area') + elif (potion_front and not potion_back) or (not potion_front and potion_back): + # Check the hint area and ensure it's one of the ones with more than one entrance + potion_placed_entrance = potion_front if potion_front else potion_back + if get_hint_area(potion_placed_entrance) not in multi_interior_regions: + raise EntranceShuffleError('Kak Potion Shop entrances can never be in the same hint area') # When cows are shuffled, ensure the same thing for Impa's House, since the cow is reachable from both sides if ootworld.shuffle_cows: - impas_front_entrance = get_entrance_replacing(world.get_region('Kak Impas House', player), 'Kakariko Village -> Kak Impas House', player) - impas_back_entrance = get_entrance_replacing(world.get_region('Kak Impas House Back', player), 'Kak Impas Ledge -> Kak Impas House Back', player) - if impas_front_entrance is not None and impas_back_entrance is not None and not same_hint_area(impas_front_entrance, impas_back_entrance): + impas_front = get_entrance_replacing(world.get_region('Kak Impas House', player), 'Kakariko Village -> Kak Impas House', player) + impas_back = get_entrance_replacing(world.get_region('Kak Impas House Back', player), 'Kak Impas Ledge -> Kak Impas House Back', player) + if impas_front is not None and impas_back is not None and not same_hint_area(impas_front, impas_back): raise EntranceShuffleError('Kak Impas House entrances are not in the same hint area') + elif (impas_front and not impas_back) or (not impas_front and impas_back): + impas_placed_entrance = impas_front if impas_front else impas_back + if get_hint_area(impas_placed_entrance) not in multi_interior_regions: + raise EntranceShuffleError('Kak Impas House entrances can never be in the same hint area') # Check basic refills, time passing, return to ToT if (ootworld.shuffle_special_interior_entrances or ootworld.shuffle_overworld_entrances or ootworld.spawn_positions) and \ @@ -845,3 +878,4 @@ def delete_target_entrance(target): if target.parent_region != None: target.parent_region.exits.remove(target) target.parent_region = None + del target diff --git a/worlds/oot/__init__.py b/worlds/oot/__init__.py index 2536c3d4..c985ea13 100644 --- a/worlds/oot/__init__.py +++ b/worlds/oot/__init__.py @@ -1,7 +1,7 @@ import logging import threading import copy -from collections import Counter +from collections import Counter, deque logger = logging.getLogger("Ocarina of Time") @@ -412,17 +412,6 @@ class OOTWorld(World): self.shop_prices[location.name] = int(self.world.random.betavariate(1.5, 2) * 60) * 5 def fill_bosses(self, bossCount=9): - rewardlist = ( - 'Kokiri Emerald', - 'Goron Ruby', - 'Zora Sapphire', - 'Forest Medallion', - 'Fire Medallion', - 'Water Medallion', - 'Spirit Medallion', - 'Shadow Medallion', - 'Light Medallion' - ) boss_location_names = ( 'Queen Gohma', 'King Dodongo', @@ -434,7 +423,7 @@ class OOTWorld(World): 'Twinrova', 'Links Pocket' ) - boss_rewards = [self.create_item(reward) for reward in rewardlist] + boss_rewards = [item for item in self.itempool if item.type == 'DungeonReward'] boss_locations = [self.world.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] @@ -447,9 +436,8 @@ class OOTWorld(World): self.world.random.shuffle(prize_locs) item = prizepool.pop() loc = prize_locs.pop() - self.world.push_item(loc, item, collect=False) - loc.locked = True - loc.event = True + loc.place_locked_item(item) + self.world.itempool.remove(item) def create_item(self, name: str): if name in item_table: @@ -496,6 +484,10 @@ class OOTWorld(World): # Generate itempool generate_itempool(self) add_dungeon_items(self) + # Add dungeon rewards + rewardlist = sorted(list(self.item_name_groups['rewards'])) + self.itempool += map(self.create_item, rewardlist) + junk_pool = get_junk_pool(self) removed_items = [] # Determine starting items @@ -621,61 +613,64 @@ class OOTWorld(World): "Gerudo Training Ground Maze Path Final Chest", "Gerudo Training Ground MQ Ice Arrows Chest", ] + def get_names(items): + for item in items: + yield item.name + # Place/set rules for dungeon items itempools = { - 'dungeon': [], - 'overworld': [], - 'any_dungeon': [], + 'dungeon': set(), + 'overworld': set(), + 'any_dungeon': set(), } any_dungeon_locations = [] for dungeon in self.dungeons: - itempools['dungeon'] = [] + itempools['dungeon'] = set() # Put the dungeon items into their appropriate pools. # Build in reverse order since we need to fill boss key first and pop() returns the last element if self.shuffle_mapcompass in itempools: - itempools[self.shuffle_mapcompass].extend(dungeon.dungeon_items) + itempools[self.shuffle_mapcompass].update(get_names(dungeon.dungeon_items)) if self.shuffle_smallkeys in itempools: - itempools[self.shuffle_smallkeys].extend(dungeon.small_keys) + itempools[self.shuffle_smallkeys].update(get_names(dungeon.small_keys)) shufflebk = self.shuffle_bosskeys if dungeon.name != 'Ganons Castle' else self.shuffle_ganon_bosskey if shufflebk in itempools: - itempools[shufflebk].extend(dungeon.boss_key) + itempools[shufflebk].update(get_names(dungeon.boss_key)) # We can't put a dungeon item on the end of a dungeon if a song is supposed to go there. Make sure not to include it. dungeon_locations = [loc for region in dungeon.regions for loc in region.locations if loc.item is None and ( self.shuffle_song_items != 'dungeon' or loc.name not in dungeon_song_locations)] if itempools['dungeon']: # only do this if there's anything to shuffle - for item in itempools['dungeon']: + dungeon_itempool = [item for item in self.world.itempool if item.player == self.player and item.name in itempools['dungeon']] + for item in dungeon_itempool: self.world.itempool.remove(item) self.world.random.shuffle(dungeon_locations) fill_restrictive(self.world, self.world.get_all_state(False), dungeon_locations, - itempools['dungeon'], True, True) + dungeon_itempool, True, True) any_dungeon_locations.extend(dungeon_locations) # adds only the unfilled locations # Now fill items that can go into any dungeon. Retrieve the Gerudo Fortress keys from the pool if necessary if self.shuffle_fortresskeys == 'any_dungeon': - fortresskeys = filter(lambda item: item.player == self.player and item.type == 'HideoutSmallKey', - self.world.itempool) - itempools['any_dungeon'].extend(fortresskeys) + itempools['any_dungeon'].add('Small Key (Thieves Hideout)') if itempools['any_dungeon']: - for item in itempools['any_dungeon']: + any_dungeon_itempool = [item for item in self.world.itempool if item.player == self.player and item.name in itempools['any_dungeon']] + for item in any_dungeon_itempool: self.world.itempool.remove(item) - itempools['any_dungeon'].sort(key=lambda item: - {'GanonBossKey': 4, 'BossKey': 3, 'SmallKey': 2, 'HideoutSmallKey': 1}.get(item.type, 0)) + any_dungeon_itempool.sort(key=lambda item: + {'GanonBossKey': 4, 'BossKey': 3, 'SmallKey': 2, 'HideoutSmallKey': 1}.get(item.type, 0)) self.world.random.shuffle(any_dungeon_locations) fill_restrictive(self.world, self.world.get_all_state(False), any_dungeon_locations, - itempools['any_dungeon'], True, True) + any_dungeon_itempool, True, True) # If anything is overworld-only, fill into local non-dungeon locations if self.shuffle_fortresskeys == 'overworld': - fortresskeys = filter(lambda item: item.player == self.player and item.type == 'HideoutSmallKey', - self.world.itempool) - itempools['overworld'].extend(fortresskeys) + itempools['overworld'].add('Small Key (Thieves Hideout)') if itempools['overworld']: - for item in itempools['overworld']: + overworld_itempool = [item for item in self.world.itempool if item.player == self.player and item.name in itempools['overworld']] + for item in overworld_itempool: self.world.itempool.remove(item) - itempools['overworld'].sort(key=lambda item: - {'GanonBossKey': 4, 'BossKey': 3, 'SmallKey': 2, 'HideoutSmallKey': 1}.get(item.type, 0)) + overworld_itempool.sort(key=lambda item: + {'GanonBossKey': 4, 'BossKey': 3, 'SmallKey': 2, 'HideoutSmallKey': 1}.get(item.type, 0)) non_dungeon_locations = [loc for loc in self.get_locations() if not loc.item and loc not in any_dungeon_locations and (loc.type != 'Shop' or loc.name in self.shop_prices) and @@ -683,7 +678,7 @@ class OOTWorld(World): (loc.name not in dungeon_song_locations or self.shuffle_song_items != 'dungeon')] self.world.random.shuffle(non_dungeon_locations) fill_restrictive(self.world, self.world.get_all_state(False), non_dungeon_locations, - itempools['overworld'], True, True) + overworld_itempool, True, True) # Place songs # 5 built-in retries because this section can fail sometimes @@ -805,6 +800,10 @@ class OOTWorld(World): or (self.skip_child_zelda and loc.name in ['HC Zeldas Letter', 'Song from Impa'])): loc.address = None + # Handle item-linked dungeon items and songs + def stage_pre_fill(cls): + pass + def generate_output(self, output_directory: str): if self.hints != 'none': self.hint_data_available.wait() @@ -831,18 +830,25 @@ class OOTWorld(World): # Write entrances to spoiler log all_entrances = self.get_shuffled_entrances() - all_entrances.sort(key=lambda x: x.name) - all_entrances.sort(key=lambda x: x.type) + all_entrances.sort(reverse=True, key=lambda x: x.name) + all_entrances.sort(reverse=True, key=lambda x: x.type) if not self.decouple_entrances: - for loadzone in all_entrances: - if loadzone.primary: - entrance = loadzone + 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.world.spoiler.set_entrance(entrance, entrance.replaces.reverse, 'both', self.player) + else: + self.world.spoiler.set_entrance(entrance, entrance.replaces, 'entrance', self.player) else: - entrance = loadzone.reverse - if entrance.reverse is not None: - self.world.spoiler.set_entrance(entrance, entrance.replaces.reverse, 'both', self.player) - else: - self.world.spoiler.set_entrance(entrance, entrance.replaces, 'entrance', self.player) + reverse = loadzone.replaces.reverse + if reverse in all_entrances: + all_entrances.remove(reverse) + self.world.spoiler.set_entrance(loadzone, reverse, 'both', self.player) else: for entrance in all_entrances: self.world.spoiler.set_entrance(entrance, entrance.replaces, 'entrance', self.player) @@ -1027,7 +1033,7 @@ class OOTWorld(World): all_state = self.world.get_all_state(use_cache=False) # Remove event progression items for item, player in all_state.prog_items: - if (item not in item_table or item_table[item][2] is None) and player == self.player: + if player == self.player and (item not in item_table or (item_table[item][2] is None and item_table[item][0] != 'DungeonReward')): all_state.prog_items[(item, player)] = 0 # Remove all events and checked locations all_state.locations_checked = {loc for loc in all_state.locations_checked if loc.player != self.player}