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:
parent
1f01404ca4
commit
51f65f4b9e
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
Loading…
Reference in New Issue