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.
This commit is contained in:
espeon65536 2022-10-15 03:39:04 -07:00 committed by GitHub
parent 1f01404ca4
commit 51f65f4b9e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 107 additions and 67 deletions

View File

@ -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,7 +740,6 @@ 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')
@ -723,17 +747,26 @@ def validate_world(ootworld, entrance_placed, locations_to_ensure_reachable, all
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

View File

@ -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,60 +613,63 @@ 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:
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:
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
@ -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,10 +830,12 @@ 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:
while all_entrances:
loadzone = all_entrances.pop()
if loadzone.type != 'Overworld':
if loadzone.primary:
entrance = loadzone
else:
@ -843,6 +844,11 @@ class OOTWorld(World):
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:
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}