From 14571508ae8fabc0318080e77067261988552e5b Mon Sep 17 00:00:00 2001 From: aerinon Date: Tue, 24 May 2022 11:29:41 -0600 Subject: [PATCH] Init work on decoupling doors --- BaseClasses.py | 10 +- CLI.py | 3 +- DoorShuffle.py | 75 +- Main.py | 1 + mystery_example.yml | 1 + resources/app/cli/args.json | 4 + resources/app/cli/lang/en.json | 1 + resources/app/gui/lang/en.json | 1 + .../app/gui/randomize/dungeon/widgets.json | 1 + source/classes/CustomSettings.py | 2 + source/classes/constants.py | 1 + source/dungeon/DungeonStitcher.py | 848 ++++++++++++++++++ source/tools/MysteryUtils.py | 1 + 13 files changed, 916 insertions(+), 33 deletions(-) create mode 100644 source/dungeon/DungeonStitcher.py diff --git a/BaseClasses.py b/BaseClasses.py index 2b56d39f..7c040640 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -142,6 +142,7 @@ class World(object): set_player_attr('collection_rate', False) set_player_attr('colorizepots', False) set_player_attr('pot_pool', {}) + set_player_attr('decoupledoors', False) set_player_attr('shopsanity', False) set_player_attr('mixed_travel', 'prevent') @@ -2462,6 +2463,7 @@ class Spoiler(object): 'shufflelinks': self.world.shufflelinks, 'door_shuffle': self.world.doorShuffle, 'intensity': self.world.intensity, + 'decoupledoors': self.world.decoupledoors, 'item_pool': self.world.difficulty, 'item_functionality': self.world.difficulty_adjustments, 'gt_crystals': self.world.crystals_needed_for_gt, @@ -2544,6 +2546,7 @@ class Spoiler(object): outfile.write(f"Link's House Shuffled: {yn(self.metadata['shufflelinks'])}\n") outfile.write('Door Shuffle: %s\n' % self.metadata['door_shuffle'][player]) outfile.write('Intensity: %s\n' % self.metadata['intensity'][player]) + outfile.write(f"Decouple Doors: {yn(self.metadata['decoupledoors'][player])}\n") outfile.write(f"Drop Shuffle: {yn(self.metadata['dropshuffle'][player])}\n") outfile.write(f"Pottery Mode: {self.metadata['pottery'][player]}\n") outfile.write(f"Pot Shuffle (Legacy): {yn(self.metadata['potshuffle'][player])}\n") @@ -2759,7 +2762,7 @@ goal_mode = {'ganon': 0, 'pedestal': 1, 'dungeons': 2, 'triforcehunt': 3, 'cryst diff_mode = {"normal": 0, "hard": 1, "expert": 2} func_mode = {"normal": 0, "hard": 1, "expert": 2} -# byte 3: S?MM PIII (shop, unused, mixed, palettes, intensity) +# byte 3: SDMM PIII (shop, decouple doors, mixed, palettes, intensity) # keydrop now has it's own byte mixed_travel_mode = {"prevent": 0, "allow": 1, "force": 2} # intensity is 3 bits (reserves 4-7 levels) @@ -2813,7 +2816,8 @@ class Settings(object): (goal_mode[w.goal[p]] << 5) | (diff_mode[w.difficulty[p]] << 3) | (func_mode[w.difficulty_adjustments[p]] << 1) | (1 if w.hints[p] else 0), - (0x80 if w.shopsanity[p] else 0) | (mixed_travel_mode[w.mixed_travel[p]] << 4) + (0x80 if w.shopsanity[p] else 0) | (0x40 if w.decoupledoors[p] else 0) + | (mixed_travel_mode[w.mixed_travel[p]] << 4) | (0x8 if w.standardize_palettes[p] == "original" else 0) | (0 if w.intensity[p] == "random" else w.intensity[p]), @@ -2861,7 +2865,7 @@ class Settings(object): args.retro[p] = True if settings[1] & 0x01 else False args.hints[p] = True if settings[2] & 0x01 else False args.shopsanity[p] = True if settings[3] & 0x80 else False - # args.keydropshuffle[p] = True if settings[3] & 0x40 else False + args.decoupledoors[p] = True if settings[3] & 0x40 else False args.mixed_travel[p] = r(mixed_travel_mode)[(settings[3] & 0x30) >> 4] args.standardize_palettes[p] = "original" if settings[3] & 0x8 else "standardize" intensity = settings[3] & 0x7 diff --git a/CLI.py b/CLI.py index c659d3b5..77b020b4 100644 --- a/CLI.py +++ b/CLI.py @@ -125,7 +125,7 @@ def parse_cli(argv, no_defaults=False): 'ow_palettes', 'uw_palettes', 'sprite', 'disablemusic', 'quickswap', 'fastmenu', 'heartcolor', 'heartbeep', 'remote_items', 'shopsanity', 'dropshuffle', 'pottery', 'keydropshuffle', 'mixed_travel', 'standardize_palettes', 'code', 'reduce_flashing', 'shuffle_sfx', - 'msu_resume', 'collection_rate', 'colorizepots']: + 'msu_resume', 'collection_rate', 'colorizepots', 'decoupledoors']: value = getattr(defaults, name) if getattr(playerargs, name) is None else getattr(playerargs, name) if player == 1: setattr(ret, name, {1: value}) @@ -192,6 +192,7 @@ def parse_settings(): "keysanity": False, "door_shuffle": "basic", "intensity": 2, + 'decoupledoors': False, "experimental": False, "dungeon_counters": "default", "mixed_travel": "prevent", diff --git a/DoorShuffle.py b/DoorShuffle.py index 7b47c1ee..56a4594b 100644 --- a/DoorShuffle.py +++ b/DoorShuffle.py @@ -12,9 +12,11 @@ from Dungeons import dungeon_regions, region_starts, standard_starts, split_regi from Dungeons import dungeon_bigs, dungeon_hints from Items import ItemFactory from RoomData import DoorKind, PairedDoor, reset_rooms -from DungeonGenerator import ExplorationState, convert_regions, generate_dungeon, pre_validate, determine_required_paths, drop_entrances +from source.dungeon.DungeonStitcher import GenerationException, generate_dungeon +# from DungeonGenerator import generate_dungeon +from DungeonGenerator import ExplorationState, convert_regions, pre_validate, determine_required_paths, drop_entrances from DungeonGenerator import create_dungeon_builders, split_dungeon_builder, simple_dungeon_builder, default_dungeon_entrances -from DungeonGenerator import dungeon_portals, dungeon_drops, GenerationException +from DungeonGenerator import dungeon_portals, dungeon_drops from DungeonGenerator import valid_region_to_explore as valid_region_to_explore_lim from KeyDoorShuffle import analyze_dungeon, build_key_layout, validate_key_layout, determine_prize_lock from Utils import ncr, kth_combination @@ -780,7 +782,8 @@ def within_dungeon(world, player): for builder in world.dungeon_layouts[player].values(): shuffle_key_doors(builder, world, player) logging.getLogger('').info('%s: %s', world.fish.translate("cli", "cli", "keydoor.shuffle.time"), time.process_time()-start) - smooth_door_pairs(world, player) + if not world.decoupledoors[player]: + smooth_door_pairs(world, player) if world.intensity[player] >= 3: portal = world.get_portal('Sanctuary', player) @@ -1059,7 +1062,8 @@ def cross_dungeon(world, player): target_items += 19 # 19 pot keys d_items = target_items - all_dungeon_items_cnt world.pool_adjustment[player] = d_items - smooth_door_pairs(world, player) + if not world.decoupledoors[player]: + smooth_door_pairs(world, player) # Re-assign dungeon bosses gt = world.get_dungeon('Ganons Tower', player) @@ -1567,8 +1571,9 @@ def find_small_key_door_candidates(builder, start_regions, world, player): checked_doors.update(checked) flat_candidates = [] for candidate in candidates: - # not valid if: Normal and Pair in is Checked and Pair is not in Candidates - if candidate.type != DoorType.Normal or candidate.dest not in checked_doors or candidate.dest in candidates: + # not valid if: Normal Coupled and Pair in is Checked and Pair is not in Candidates + if (world.decoupledoors[player] or candidate.type != DoorType.Normal + or candidate.dest not in checked_doors or candidate.dest in candidates): flat_candidates.append(candidate) paired_candidates = build_pair_list(flat_candidates) @@ -1609,8 +1614,7 @@ def find_valid_combination(builder, start_regions, world, player, drop_keys=True combinations = ncr(len(key_door_pool), key_doors_needed) itr = 0 start = time.process_time() - sample_list = list(range(0, int(combinations))) - random.shuffle(sample_list) + sample_list = build_sample_list(combinations) proposal = kth_combination(sample_list[itr], key_door_pool, key_doors_needed) proposal.extend(custom_key_doors) # eliminate start region if portal marked as destination @@ -1625,12 +1629,7 @@ def find_valid_combination(builder, start_regions, world, player, drop_keys=True determine_prize_lock(key_layout, world, player) while not validate_key_layout(key_layout, world, player): itr += 1 - stop_early = False - if itr % 1000 == 0: - mark = time.process_time()-start - if (mark > 10 and itr*100/combinations > 50) or (mark > 20 and itr*100/combinations > 25) or mark > 30: - stop_early = True - if itr >= combinations or stop_early: + if itr >= len(sample_list): if not drop_keys: logger.info('No valid layouts for %s with %s doors', builder.name, builder.key_doors_num) return False @@ -1640,8 +1639,7 @@ def find_valid_combination(builder, start_regions, world, player, drop_keys=True if builder.key_doors_num < 0: raise Exception('Bad dungeon %s - less than 0 key doors not valid' % builder.name) combinations = ncr(len(key_door_pool), max(0, key_doors_needed)) - sample_list = list(range(0, int(combinations))) - random.shuffle(sample_list) + sample_list = build_sample_list(combinations) itr = 0 start = time.process_time() # reset time since itr reset proposal = kth_combination(sample_list[itr], key_door_pool, key_doors_needed) @@ -1660,6 +1658,20 @@ def find_valid_combination(builder, start_regions, world, player, drop_keys=True return True +def build_sample_list(combinations): + if combinations <= 1000000: + sample_list = list(range(0, int(combinations))) + + else: + num_set = set() + while len(num_set) < 1000000: + num_set.add(random.randint(0, combinations)) + sample_list = list(num_set) + sample_list.sort() + random.shuffle(sample_list) + return sample_list + + def log_key_logic(d_name, key_logic): logger = logging.getLogger('') if logger.isEnabledFor(logging.DEBUG): @@ -1693,7 +1705,8 @@ def build_pair_list(flat_list): queue = deque(flat_list) while len(queue) > 0: d = queue.pop() - if d.dest in queue and d.type != DoorType.SpiralStairs: + paired = d.dest.dest == d + if d.dest in queue and d.type != DoorType.SpiralStairs and paired: paired_list.append((d, d.dest)) queue.remove(d.dest) else: @@ -1716,6 +1729,7 @@ okay_normals = [DoorKind.Normal, DoorKind.SmallKey, DoorKind.Bombable, DoorKind. def find_key_door_candidates(region, checked, world, player): + decoupled = world.decoupledoors[player] dungeon_name = region.dungeon.name candidates = [] checked_doors = list(checked) @@ -1730,7 +1744,7 @@ def find_key_door_candidates(region, checked, world, player): if d and not d.blocked and d.dest is not last_door and d.dest is not last_region and d not in checked_doors: valid = False if (0 <= d.doorListPos < 4 and d.type in [DoorType.Interior, DoorType.Normal, DoorType.SpiralStairs] - and not d.entranceFlag): + and not d.entranceFlag): room = world.get_room(d.roomIndex, player) position, kind = room.doorList[d.doorListPos] if d.type == DoorType.Interior: @@ -1740,18 +1754,21 @@ def find_key_door_candidates(region, checked, world, player): elif d.type == DoorType.SpiralStairs: valid = kind in [DoorKind.StairKey, DoorKind.StairKey2, DoorKind.StairKeyLow] elif d.type == DoorType.Normal: - d2 = d.dest - if d2 not in candidates: - if d2.type == DoorType.Normal: - room_b = world.get_room(d2.roomIndex, player) - pos_b, kind_b = room_b.doorList[d2.doorListPos] - valid = kind in okay_normals and kind_b in okay_normals and valid_key_door_pair(d, d2) - else: - valid = kind in okay_normals - if valid and 0 <= d2.doorListPos < 4: - candidates.append(d2) + if decoupled: + valid = kind in okay_normals else: - valid = True + d2 = d.dest + if d2 not in candidates: + if d2.type == DoorType.Normal: + room_b = world.get_room(d2.roomIndex, player) + pos_b, kind_b = room_b.doorList[d2.doorListPos] + valid = kind in okay_normals and kind_b in okay_normals and valid_key_door_pair(d, d2) + else: + valid = kind in okay_normals + if valid and 0 <= d2.doorListPos < 4: + candidates.append(d2) + else: + valid = True if valid and d not in candidates: candidates.append(d) connected = ext.connected_region diff --git a/Main.py b/Main.py index 1c2357bb..87289631 100644 --- a/Main.py +++ b/Main.py @@ -443,6 +443,7 @@ def copy_world(world): ret.enemy_damage = world.enemy_damage.copy() ret.beemizer = world.beemizer.copy() ret.intensity = world.intensity.copy() + ret.decoupledoors = world.decoupledoors.copy() ret.experimental = world.experimental.copy() ret.shopsanity = world.shopsanity.copy() ret.dropshuffle = world.dropshuffle.copy() diff --git a/mystery_example.yml b/mystery_example.yml index b51c58c5..8363d05d 100644 --- a/mystery_example.yml +++ b/mystery_example.yml @@ -17,6 +17,7 @@ 1: 2 2: 2 3: 4 + decoupledoors: off dropshuffle: on: 1 off: 1 diff --git a/resources/app/cli/args.json b/resources/app/cli/args.json index 7c438cbf..8b971856 100644 --- a/resources/app/cli/args.json +++ b/resources/app/cli/args.json @@ -164,6 +164,10 @@ "3", "2", "1", "random" ] }, + "deoupledoors": { + "action": "store_true", + "type": "bool" + }, "experimental": { "action": "store_true", "type": "bool" diff --git a/resources/app/cli/lang/en.json b/resources/app/cli/lang/en.json index 4e805b12..599915b4 100644 --- a/resources/app/cli/lang/en.json +++ b/resources/app/cli/lang/en.json @@ -215,6 +215,7 @@ "3: And shuffles dungeon lobbies", "random: Picks one of those at random" ], + "decoupledoors" : [ "Door entrances and exits are decoupled" ], "experimental": [ "Enable experimental features. (default: %(default)s)" ], "dungeon_counters": [ "Enable dungeon chest counters. (default: %(default)s)" ], "crystals_ganon": [ diff --git a/resources/app/gui/lang/en.json b/resources/app/gui/lang/en.json index c0ab9367..8e3f0f8d 100644 --- a/resources/app/gui/lang/en.json +++ b/resources/app/gui/lang/en.json @@ -55,6 +55,7 @@ "randomizer.dungeon.smallkeyshuffle": "Small Keys", "randomizer.dungeon.bigkeyshuffle": "Big Keys", "randomizer.dungeon.keydropshuffle": "Key Drop Shuffle (Legacy)", + "randomizer.dungeon.decoupledoors": "Decouple Doors", "randomizer.dungeon.dropshuffle": "Shuffle Enemy Key Drops", "randomizer.dungeon.potshuffle": "Pot Shuffle (Legacy)", "randomizer.dungeon.pottery": "Pottery", diff --git a/resources/app/gui/randomize/dungeon/widgets.json b/resources/app/gui/randomize/dungeon/widgets.json index 2a99ea1a..b45df12f 100644 --- a/resources/app/gui/randomize/dungeon/widgets.json +++ b/resources/app/gui/randomize/dungeon/widgets.json @@ -22,6 +22,7 @@ "width": 45 } }, + "decoupledoors": { "type": "checkbox" }, "keydropshuffle": { "type": "checkbox" }, "pottery": { "type": "selectbox", diff --git a/source/classes/CustomSettings.py b/source/classes/CustomSettings.py index 016b3b64..57f9cf7e 100644 --- a/source/classes/CustomSettings.py +++ b/source/classes/CustomSettings.py @@ -87,6 +87,7 @@ class CustomSettings(object): args.standardize_palettes[p] = get_setting(settings['standardize_palettes'], args.standardize_palettes[p]) args.intensity[p] = get_setting(settings['intensity'], args.intensity[p]) + args.decoupledoors[p] = get_setting(settings['decoupledoors'], args.decoupledoors[p]) args.dungeon_counters[p] = get_setting(settings['dungeon_counters'], args.dungeon_counters[p]) args.crystals_gt[p] = get_setting(settings['crystals_gt'], args.crystals_gt[p]) args.crystals_ganon[p] = get_setting(settings['crystals_ganon'], args.crystals_ganon[p]) @@ -181,6 +182,7 @@ class CustomSettings(object): settings_dict[p]['shuffle'] = world.shuffle[p] settings_dict[p]['door_shuffle'] = world.doorShuffle[p] settings_dict[p]['intensity'] = world.intensity[p] + settings_dict[p]['decoupledoors'] = world.decoupledoors[p] settings_dict[p]['logic'] = world.logic[p] settings_dict[p]['mode'] = world.mode[p] settings_dict[p]['swords'] = world.swords[p] diff --git a/source/classes/constants.py b/source/classes/constants.py index 4de4ac13..677e163b 100644 --- a/source/classes/constants.py +++ b/source/classes/constants.py @@ -95,6 +95,7 @@ SETTINGSTOPROCESS = { "bigkeyshuffle": "bigkeyshuffle", "dungeondoorshuffle": "door_shuffle", "dungeonintensity": "intensity", + "decoupledoors": "decoupledoors", "keydropshuffle": "keydropshuffle", "dropshuffle": "dropshuffle", "pottery": "pottery", diff --git a/source/dungeon/DungeonStitcher.py b/source/dungeon/DungeonStitcher.py new file mode 100644 index 00000000..e16b449d --- /dev/null +++ b/source/dungeon/DungeonStitcher.py @@ -0,0 +1,848 @@ +import RaceRandom as random +import collections +import logging +import time + +from BaseClasses import CrystalBarrier, DoorType, Hook, RegionType, Sector +from BaseClasses import hook_from_door, flooded_keys +from Regions import dungeon_events, flooded_keys_reverse + + +def pre_validate(builder, entrance_region_names, split_dungeon, world, player): + pass + # todo: determine the part of check_valid that are necessary here + + +def generate_dungeon(builder, entrance_region_names, split_dungeon, world, player): + if builder.valid_proposal: # we made this earlier in gen, just use it + proposed_map = builder.valid_proposal + else: + proposed_map = generate_dungeon_find_proposal(builder, entrance_region_names, split_dungeon, world, player) + builder.valid_proposal = proposed_map + queue = collections.deque(proposed_map.items()) + while len(queue) > 0: + a, b = queue.popleft() + if world.decoupledoors[player]: + connect_doors_one_way(a, b) + else: + connect_doors(a, b) + queue.remove((b, a)) + if len(builder.sectors) == 0: + return Sector() + available_sectors = list(builder.sectors) + master_sector = available_sectors.pop() + for sub_sector in available_sectors: + master_sector.regions.extend(sub_sector.regions) + master_sector.outstanding_doors.clear() + master_sector.r_name_set = None + return master_sector + + +def generate_dungeon_find_proposal(builder, entrance_region_names, split_dungeon, world, player): + logger = logging.getLogger('') + name = builder.name + logger.debug(f'Generating Dungeon: {name}') + entrance_regions = convert_regions(entrance_region_names, world, player) + excluded = {} + for region in entrance_regions: + portal = next((x for x in world.dungeon_portals[player] if x.door.entrance.parent_region == region), None) + if portal: + if portal.destination: + excluded[region] = None + elif len(entrance_regions) > 1: + p_region = portal.door.entrance.connected_region + access_region = next(x.parent_region for x in p_region.entrances + if x.parent_region.type in [RegionType.LightWorld, RegionType.DarkWorld]) + if (access_region.name in world.inaccessible_regions[player] and + region.name not in world.enabled_entrances[player]): + excluded[region] = None + elif len(region.entrances) == 1: # for holes + access_region = next(x.parent_region for x in region.entrances + if x.parent_region.type in [RegionType.LightWorld, RegionType.DarkWorld] + or x.parent_region.name == 'Sewer Drop') + if access_region.name == 'Sewer Drop': + access_region = next(x.parent_region for x in access_region.entrances) + if (access_region.name in world.inaccessible_regions[player] and + region.name not in world.enabled_entrances[player]): + excluded[region] = None + entrance_regions = [x for x in entrance_regions if x not in excluded.keys()] + doors_to_connect, idx = {}, 0 + all_regions = set() + bk_special = False + for sector in builder.sectors: + for door in sector.outstanding_doors: + doors_to_connect[door.name] = door, idx + idx += 1 + all_regions.update(sector.regions) + bk_special |= check_for_special(sector.regions) + bk_needed = False + for sector in builder.sectors: + bk_needed |= determine_if_bk_needed(sector, split_dungeon, bk_special, world, player) + finished = False + # flag if standard and this is hyrule castle + paths = determine_paths_for_dungeon(world, player, all_regions, name) + proposed_map = create_random_proposal(doors_to_connect, world, player) + itr = 0 + hash_code = proposal_hash(doors_to_connect, proposed_map) + hash_code_set = set() + start = time.time() + while not finished: + if itr > 1000: + elasped = time.time() - start + raise GenerationException(f'Generation taking too long. {elasped}. Ref {name}') + if hash_code in hash_code_set: + proposed_map = create_random_proposal(doors_to_connect, world, player) + hash_code = proposal_hash(doors_to_connect, proposed_map) + if hash_code not in hash_code_set: + hash_code_set.add(hash_code) + explored_state = explore_proposal(name, entrance_regions, all_regions, proposed_map, doors_to_connect, + bk_needed, bk_special, world, player) + if check_valid(name, explored_state, proposed_map, doors_to_connect, all_regions, + bk_needed, bk_special, paths, entrance_regions, world, player): + finished = True + else: + proposed_map, hash_code = modify_proposal(proposed_map, explored_state, doors_to_connect, + hash_code_set, world, player) + itr += 1 + return proposed_map + + +def create_random_proposal(doors_to_connect, world, player): + logger = logging.getLogger('') + hooks = [Hook.North, Hook.South, Hook.East, Hook.West, Hook.Stairs] + primary_bucket = collections.defaultdict(list) + secondary_bucket = collections.defaultdict(list) + for name, door in doors_to_connect.items(): + door, idx = door + primary_bucket[hook_from_door(door)].append(door) + secondary_bucket[hook_from_door(door)].append(door) + proposal = {} + while True: + hooks_left, left = [], 0 + for hook in hooks: + hook_len = len(primary_bucket[hook]) + if hook_len > 0: + hooks_left.append(hook) + left += hook_len + if left == 0: + return proposal + next_hook = random.choice(hooks_left) + primary_door = random.choice(primary_bucket[next_hook]) + opp_hook, secondary_door = type_map[next_hook], None + while (secondary_door is None or secondary_door == primary_door + or decouple_check(primary_bucket[next_hook], secondary_bucket[opp_hook], + primary_door, secondary_door, world, player)): + secondary_door = random.choice(secondary_bucket[opp_hook]) + proposal[primary_door] = secondary_door + primary_bucket[next_hook].remove(primary_door) + secondary_bucket[opp_hook].remove(secondary_door) + if not world.decoupledoors[player]: + proposal[secondary_door] = primary_door + primary_bucket[opp_hook].remove(secondary_door) + secondary_bucket[next_hook].remove(primary_door) + logger.debug(f'Linking {primary_door.name} <-> {secondary_door.name}') + else: + logger.debug(f'Linking {primary_door.name} -> {secondary_door.name}') + + +def decouple_check(primary_list, secondary_list, primary_door, secondary_door, world, player): + if world.decoupledoors[player] and len(primary_list) == 2 and len(secondary_list) == 2: + primary_alone = next(d for d in primary_list if d != primary_door) + secondary_alone = next(d for d in secondary_list if d != secondary_door) + return primary_alone == secondary_alone + return False + + +def proposal_hash(doors_to_connect, proposed_map): + hash_code = '' + for name, door_pair in doors_to_connect.items(): + door, idx = door_pair + hash_code += str(idx) + str(doors_to_connect[proposed_map[door].name][1]) + return hash_code + + +def modify_proposal(proposed_map, explored_state, doors_to_connect, hash_code_set, world, player): + logger = logging.getLogger('') + hash_code, itr = None, 0 + while hash_code is None or hash_code in hash_code_set: + if itr > 10: + proposed_map = create_random_proposal(doors_to_connect, world, player) + hash_code = proposal_hash(doors_to_connect, proposed_map) + return proposed_map, hash_code + visited_bucket = collections.defaultdict(list) + unvisted_bucket = collections.defaultdict(list) + visited_choices = [] + unvisted_count = 0 + for door_one, door_two in proposed_map.items(): + if door_one in explored_state.visited_doors: + visited_bucket[hook_from_door(door_one)].append(door_one) + visited_choices.append(door_one) + else: + unvisted_bucket[hook_from_door(door_one)].append(door_one) + unvisted_count += 1 + if unvisted_count == 0: + # something is wrong beyond connectedness, crystal switch puzzle or bk layout - reshuffle + proposed_map = create_random_proposal(doors_to_connect, world, player) + hash_code = proposal_hash(doors_to_connect, proposed_map) + return proposed_map, hash_code + + attempt, opp_hook = None, None + opp_hook_len = 0 + while opp_hook_len == 0: + attempt = random.choice(visited_choices) + opp_hook = type_map[hook_from_door(attempt)] + opp_hook_len = len(unvisted_bucket[opp_hook]) + unvisted_bucket[opp_hook].sort(key=lambda d: d.name) + new_door = random.choice(unvisted_bucket[opp_hook]) + old_target = proposed_map[attempt] + proposed_map[attempt] = new_door + if not world.decoupledoors[player]: + old_attempt = proposed_map[new_door] + else: + old_attempt = next(x for x in proposed_map if proposed_map[x] == new_door) + proposed_map[old_attempt] = old_target + if not world.decoupledoors[player]: + proposed_map[old_target] = old_attempt + proposed_map[new_door] = attempt + hash_code = proposal_hash(doors_to_connect, proposed_map) + itr += 1 + + if not world.decoupledoors[player]: + logger.debug(f'Re-linking {attempt.name} <-> {new_door.name}') + logger.debug(f'Re-linking {old_attempt.name} <-> {old_target.name}') + else: + logger.debug(f'Re-Linking {attempt.name} -> {new_door.name}') + logger.debug(f'Re-Linking {old_attempt.name} -> {old_target.name}') + hash_code_set.add(hash_code) + return proposed_map, hash_code + + +def explore_proposal(name, entrance_regions, all_regions, proposed_map, valid_doors, + bk_needed, bk_special, world, player): + start = ExplorationState(dungeon=name) + start.big_key_special = bk_special + + bk_flag = False if world.bigkeyshuffle[player] and not bk_special else bk_needed + + def exception(d): + return name == 'Skull Woods 2' and d.name == 'Skull Pinball WS' + original_state = extend_reachable_state_improved(entrance_regions, start, proposed_map, all_regions, + valid_doors, bk_flag, world, player, exception) + return original_state + + +def check_valid(name, exploration_state, proposed_map, doors_to_connect, all_regions, + bk_needed, bk_special, paths, entrance_regions, world, player): + all_visited = set() + all_visited.update(exploration_state.visited_blue) + all_visited.update(exploration_state.visited_orange) + if len(all_regions.difference(all_visited)) > 0: + return False + if not valid_paths(name, paths, entrance_regions, doors_to_connect, all_regions, proposed_map, + bk_needed, bk_special, world, player): + return False + return True + + +def determine_if_bk_needed(sector, split_dungeon, bk_special, world, player): + if not split_dungeon or bk_special: + for region in sector.regions: + for ext in region.exits: + door = world.check_for_door(ext.name, player) + if door is not None and door.bigKey: + return True + return False + + +def check_for_special(regions): + for region in regions: + for loc in region.locations: + if loc.forced_big_key(): + return True + return False + + +def valid_paths(name, paths, entrance_regions, valid_doors, all_regions, proposed_map, + bk_needed, bk_special, world, player): + for path in paths: + if type(path) is tuple: + target = path[1] + start_regions = [] + for region in all_regions: + if path[0] == region.name: + start_regions.append(region) + break + else: + target = path + start_regions = entrance_regions + if not valid_path(name, start_regions, target, valid_doors, proposed_map, all_regions, + bk_needed, bk_special, world, player): + return False + return True + + +def valid_path(name, starting_regions, target, valid_doors, proposed_map, all_regions, + bk_needed, bk_special, world, player): + target_regions = set() + if type(target) is not list: + for region in all_regions: + if target == region.name: + target_regions.add(region) + break + else: + for region in all_regions: + if region.name in target: + target_regions.add(region) + + start = ExplorationState(dungeon=name) + start.big_key_special = bk_special + bk_flag = False if world.bigkeyshuffle[player] and not bk_special else bk_needed + + def exception(d): + return name == 'Skull Woods 2' and d.name == 'Skull Pinball WS' + original_state = extend_reachable_state_improved(starting_regions, start, proposed_map, all_regions, + valid_doors, bk_flag, world, player, exception) + + for exp_door in original_state.unattached_doors: + if not exp_door.door.blocked: + return True # outstanding connection possible + for target in target_regions: + if original_state.visited_at_all(target): + return True + return False # couldn't find an outstanding door or the target + + +boss_path_checks = ['Eastern Boss', 'Desert Boss', 'Hera Boss', 'Tower Agahnim 1', 'PoD Boss', 'Swamp Boss', + 'Skull Boss', 'Ice Boss', 'Mire Boss', 'TR Boss', 'GT Agahnim 2'] + +# pinball is allowed to orphan you +drop_path_checks = ['Skull Pot Circle', 'Skull Left Drop', 'Skull Back Drop', 'Sewers Rat Path'] + + +def determine_paths_for_dungeon(world, player, all_regions, name): + all_r_names = set(x.name for x in all_regions) + paths = [] + non_hole_portals = [] + for portal in world.dungeon_portals[player]: + if portal.door.entrance.parent_region in all_regions: + non_hole_portals.append(portal.door.entrance.parent_region.name) + if portal.destination: + paths.append(portal.door.entrance.parent_region.name) + if world.mode[player] == 'standard' and name == 'Hyrule Castle': + paths.append('Hyrule Dungeon Cellblock') + paths.append(('Hyrule Dungeon Cellblock', 'Sanctuary')) + if world.doorShuffle[player] in ['basic'] and name == 'Thieves Town': + paths.append('Thieves Attic Window') + elif 'Thieves Attic Window' in all_r_names: + paths.append('Thieves Attic Window') + for boss in boss_path_checks: + if boss in all_r_names: + paths.append(boss) + if 'Thieves Boss' in all_r_names: + paths.append('Thieves Boss') + if world.get_dungeon("Thieves Town", player).boss.enemizer_name == 'Blind': + paths.append(('Thieves Blind\'s Cell', 'Thieves Boss')) + for drop_check in drop_path_checks: + if drop_check in all_r_names: + paths.append((drop_check, non_hole_portals)) + return paths + + +def convert_regions(region_names, world, player): + region_list = [] + for name in region_names: + region_list.append(world.get_region(name, player)) + return region_list + + +type_map = { + Hook.Stairs: Hook.Stairs, + Hook.North: Hook.South, + Hook.South: Hook.North, + Hook.West: Hook.East, + Hook.East: Hook.West +} + + +def connect_doors(a, b): + # Return on unsupported types. + if a.type in [DoorType.Hole, DoorType.Warp, DoorType.Interior, DoorType.Logical]: + return + # Connect supported types + if a.type in [DoorType.Normal, DoorType.SpiralStairs, DoorType.Open, DoorType.StraightStairs, DoorType.Ladder]: + if a.blocked: + connect_one_way(b.entrance, a.entrance) + elif b.blocked: + connect_one_way(a.entrance, b.entrance) + else: + connect_two_way(a.entrance, b.entrance) + dep_doors, target = [], None + if len(a.dependents) > 0: + dep_doors, target = a.dependents, b + elif len(b.dependents) > 0: + dep_doors, target = b.dependents, a + if target is not None: + target_region = target.entrance.parent_region + for dep in dep_doors: + connect_simple_door(dep, target_region) + return + # If we failed to account for a type, panic + raise RuntimeError('Unknown door type ' + a.type.name) + + +def connect_doors_one_way(a, b): + # Return on unsupported types. + if a.type in [DoorType.Hole, DoorType.Warp, DoorType.Interior, DoorType.Logical]: + return + # Connect supported types + if a.type in [DoorType.Normal, DoorType.SpiralStairs, DoorType.Open, DoorType.StraightStairs, DoorType.Ladder]: + if not a.blocked: + connect_one_way(a.entrance, b.entrance) + dep_doors, target = [], None + if len(a.dependents) > 0: + dep_doors, target = a.dependents, b + if target is not None: + target_region = target.entrance.parent_region + for dep in dep_doors: + connect_simple_door(dep, target_region) + return + # If we failed to account for a type, panic + raise RuntimeError('Unknown door type ' + a.type.name) + + +def connect_two_way(entrance, ext): + + # if these were already connected somewhere, remove the backreference + if entrance.connected_region is not None: + entrance.connected_region.entrances.remove(entrance) + if ext.connected_region is not None: + ext.connected_region.entrances.remove(ext) + + entrance.connect(ext.parent_region) + ext.connect(entrance.parent_region) + if entrance.parent_region.dungeon: + ext.parent_region.dungeon = entrance.parent_region.dungeon + x = entrance.door + y = ext.door + if x is not None: + x.dest = y + if y is not None: + y.dest = x + + +def connect_one_way(entrance, ext): + + # if these were already connected somewhere, remove the backreference + if entrance.connected_region is not None: + entrance.connected_region.entrances.remove(entrance) + + entrance.connect(ext.parent_region) + if entrance.parent_region.dungeon: + ext.parent_region.dungeon = entrance.parent_region.dungeon + x = entrance.door + if x is not None: + x.dest = ext.door + + +def connect_simple_door(exit_door, region): + exit_door.entrance.connect(region) + exit_door.dest = region + + +special_big_key_doors = ['Hyrule Dungeon Cellblock Door', "Thieves Blind's Cell Door"] + + +class ExplorationState(object): + + def __init__(self, init_crystal=CrystalBarrier.Orange, dungeon=None): + + self.unattached_doors = [] + self.avail_doors = [] + self.event_doors = [] + + self.visited_orange = [] + self.visited_blue = [] + self.visited_doors = set() + self.events = set() + self.crystal = init_crystal + + # key region stuff + self.door_krs = {} + + # key validation stuff + self.small_doors = [] + self.big_doors = [] + self.opened_doors = [] + self.big_key_opened = False + self.big_key_special = False + + self.found_locations = [] + self.ttl_locations = 0 + self.used_locations = 0 + self.key_locations = 0 + self.used_smalls = 0 + self.bk_found = set() + + self.non_door_entrances = [] + self.dungeon = dungeon + self.pinball_used = False + + self.prize_door_set = {} + self.prize_doors = [] + self.prize_doors_opened = False + + def copy(self): + ret = ExplorationState(dungeon=self.dungeon) + ret.unattached_doors = list(self.unattached_doors) + ret.avail_doors = list(self.avail_doors) + ret.event_doors = list(self.event_doors) + ret.visited_orange = list(self.visited_orange) + ret.visited_blue = list(self.visited_blue) + ret.events = set(self.events) + ret.crystal = self.crystal + ret.door_krs = self.door_krs.copy() + + ret.small_doors = list(self.small_doors) + ret.big_doors = list(self.big_doors) + ret.opened_doors = list(self.opened_doors) + ret.big_key_opened = self.big_key_opened + ret.big_key_special = self.big_key_special + ret.ttl_locations = self.ttl_locations + ret.key_locations = self.key_locations + ret.used_locations = self.used_locations + ret.used_smalls = self.used_smalls + ret.found_locations = list(self.found_locations) + ret.bk_found = set(self.bk_found) + + ret.non_door_entrances = list(self.non_door_entrances) + ret.dungeon = self.dungeon + ret.pinball_used = self.pinball_used + + ret.prize_door_set = dict(self.prize_door_set) + ret.prize_doors = list(self.prize_doors) + ret.prize_doors_opened = self.prize_doors_opened + return ret + + def next_avail_door(self): + self.avail_doors.sort(key=lambda x: 0 if x.flag else 1 if x.door.bigKey else 2) + exp_door = self.avail_doors.pop() + self.crystal = exp_door.crystal + return exp_door + + def visit_region(self, region, key_region=None, key_checks=False, bk_flag=False): + if region.type != RegionType.Dungeon: + self.crystal = CrystalBarrier.Orange + if self.crystal == CrystalBarrier.Either: + if region not in self.visited_blue: + self.visited_blue.append(region) + if region not in self.visited_orange: + self.visited_orange.append(region) + elif self.crystal == CrystalBarrier.Orange: + self.visited_orange.append(region) + elif self.crystal == CrystalBarrier.Blue: + self.visited_blue.append(region) + if region.type == RegionType.Dungeon: + for location in region.locations: + if key_checks and location not in self.found_locations: + if location.forced_item and 'Small Key' in location.item.name: + self.key_locations += 1 + if location.name not in dungeon_events and '- Prize' not in location.name and location.name not in ['Agahnim 1', 'Agahnim 2']: + self.ttl_locations += 1 + if location not in self.found_locations: # todo: special logic for TT Boss? + self.found_locations.append(location) + if not bk_flag: + self.bk_found.add(location) + if location.name in dungeon_events and location.name not in self.events: + if self.flooded_key_check(location): + self.perform_event(location.name, key_region) + if location.name in flooded_keys_reverse.keys() and self.location_found( + flooded_keys_reverse[location.name]): + self.perform_event(flooded_keys_reverse[location.name], key_region) + if '- Prize' in location.name: + self.prize_received = True + + def flooded_key_check(self, location): + if location.name not in flooded_keys.keys(): + return True + return flooded_keys[location.name] in [x.name for x in self.found_locations] + + def location_found(self, location_name): + for l in self.found_locations: + if l.name == location_name: + return True + return False + + def perform_event(self, location_name, key_region): + self.events.add(location_name) + queue = collections.deque(self.event_doors) + while len(queue) > 0: + exp_door = queue.popleft() + if exp_door.door.req_event == location_name: + self.avail_doors.append(exp_door) + self.event_doors.remove(exp_door) + if key_region is not None: + d_name = exp_door.door.name + if d_name not in self.door_krs.keys(): + self.door_krs[d_name] = key_region + + def add_all_entrance_doors_check_unattached(self, region, world, player): + door_list = [x for x in get_doors(world, region, player) if x.type in [DoorType.Normal, DoorType.SpiralStairs]] + door_list.extend(get_entrance_doors(world, region, player)) + for door in door_list: + if self.can_traverse(door): + if door.dest is None and not self.in_door_list_ic(door, self.unattached_doors): + self.append_door_to_list(door, self.unattached_doors) + elif door.req_event is not None and door.req_event not in self.events and not self.in_door_list(door, + self.event_doors): + self.append_door_to_list(door, self.event_doors) + elif not self.in_door_list(door, self.avail_doors): + self.append_door_to_list(door, self.avail_doors) + for entrance in region.entrances: + door = world.check_for_door(entrance.name, player) + if door is None: + self.non_door_entrances.append(entrance) + + def add_all_doors_check_unattached(self, region, world, player): + for door in get_doors(world, region, player): + if self.can_traverse(door): + if door.controller is not None: + door = door.controller + if door.dest is None and not self.in_door_list_ic(door, self.unattached_doors): + self.append_door_to_list(door, self.unattached_doors) + elif door.req_event is not None and door.req_event not in self.events and not self.in_door_list(door, + self.event_doors): + self.append_door_to_list(door, self.event_doors) + elif not self.in_door_list(door, self.avail_doors): + self.append_door_to_list(door, self.avail_doors) + + def add_all_doors_check_proposed(self, region, proposed_map, valid_doors, flag, world, player, exception): + for door in get_doors(world, region, player): + if door in proposed_map and door.name in valid_doors: + self.visited_doors.add(door) + if door.blocked and exception(door): + self.pinball_used = True + if self.can_traverse(door, exception): + if door.controller is not None: + door = door.controller + if door.dest is None and door not in proposed_map.keys() and door.name in valid_doors: + if not self.in_door_list_ic(door, self.unattached_doors): + self.append_door_to_list(door, self.unattached_doors, flag) + else: + other = self.find_door_in_list(door, self.unattached_doors) + if self.crystal != other.crystal: + other.crystal = CrystalBarrier.Either + elif door.req_event is not None and door.req_event not in self.events and not self.in_door_list(door, + self.event_doors): + self.append_door_to_list(door, self.event_doors, flag) + elif not self.in_door_list(door, self.avail_doors): + self.append_door_to_list(door, self.avail_doors, flag) + + def add_all_doors_check_key_region(self, region, key_region, world, player): + for door in get_doors(world, region, player): + if self.can_traverse(door): + if door.req_event is not None and door.req_event not in self.events and not self.in_door_list(door, + self.event_doors): + self.append_door_to_list(door, self.event_doors) + elif not self.in_door_list(door, self.avail_doors): + self.append_door_to_list(door, self.avail_doors) + if door.name not in self.door_krs.keys(): + self.door_krs[door.name] = key_region + else: + if door.name not in self.door_krs.keys(): + self.door_krs[door.name] = key_region + + def add_all_doors_check_keys(self, region, key_door_proposal, world, player): + for door in get_doors(world, region, player): + if self.can_traverse(door): + if door.controller: + door = door.controller + if door in key_door_proposal and door not in self.opened_doors: + if not self.in_door_list(door, self.small_doors): + self.append_door_to_list(door, self.small_doors) + elif (door.bigKey or door.name in special_big_key_doors) and not self.big_key_opened: + if not self.in_door_list(door, self.big_doors): + self.append_door_to_list(door, self.big_doors) + elif door.req_event is not None and door.req_event not in self.events: + if not self.in_door_list(door, self.event_doors): + self.append_door_to_list(door, self.event_doors) + elif not self.in_door_list(door, self.avail_doors): + self.append_door_to_list(door, self.avail_doors) + + def visited(self, region): + if self.crystal == CrystalBarrier.Either: + return region in self.visited_blue and region in self.visited_orange + elif self.crystal == CrystalBarrier.Orange: + return region in self.visited_orange + elif self.crystal == CrystalBarrier.Blue: + return region in self.visited_blue + return False + + def visited_at_all(self, region): + return region in self.visited_blue or region in self.visited_orange + + def found_forced_bk(self): + for location in self.found_locations: + if location.forced_big_key(): + return True + return False + + def can_traverse(self, door, exception=None): + if door.blocked: + return exception(door) if exception else False + if door.crystal not in [CrystalBarrier.Null, CrystalBarrier.Either]: + return self.crystal == CrystalBarrier.Either or door.crystal == self.crystal + return True + + def count_locations_exclude_specials(self, world, player): + return count_locations_exclude_big_chest(self.found_locations, world, player) + + def validate(self, door, region, world, player): + return self.can_traverse(door) and not self.visited(region) and valid_region_to_explore(region, self.dungeon, + world, player) + + def in_door_list(self, door, door_list): + for d in door_list: + if d.door == door and d.crystal == self.crystal: + return True + return False + + @staticmethod + def in_door_list_ic(door, door_list): + for d in door_list: + if d.door == door: + return True + return False + + @staticmethod + def find_door_in_list(door, door_list): + for d in door_list: + if d.door == door: + return d + return None + + def append_door_to_list(self, door, door_list, flag=False): + if door.crystal == CrystalBarrier.Null: + door_list.append(ExplorableDoor(door, self.crystal, flag)) + else: + door_list.append(ExplorableDoor(door, door.crystal, flag)) + + def key_door_sort(self, d): + if d.door.smallKey: + if d.door in self.opened_doors: + return 1 + else: + return 0 + return 2 + + +def count_locations_exclude_big_chest(locations, world, player): + cnt = 0 + for loc in locations: + if ('- Big Chest' not in loc.name and not loc.forced_item and not reserved_location(loc, world, player) + and not prize_or_event(loc) and not blind_boss_unavail(loc, locations, world, player)): + cnt += 1 + return cnt + + +def prize_or_event(loc): + return loc.name in dungeon_events or '- Prize' in loc.name or loc.name in ['Agahnim 1', 'Agahnim 2'] + + +def reserved_location(loc, world, player): + return hasattr(world, 'item_pool_config') and loc.name in world.item_pool_config.reserved_locations[player] + + +def blind_boss_unavail(loc, locations, world, player): + if loc.name == "Thieves' Town - Boss": + return (loc.parent_region.dungeon.boss.name == 'Blind' and + (not any(x for x in locations if x.name == 'Suspicious Maiden') or + (world.get_region('Thieves Attic Window', player).dungeon.name == 'Thieves Town' and + not any(x for x in locations if x.name == 'Attic Cracked Floor')))) + return False + + +class ExplorableDoor(object): + + def __init__(self, door, crystal, flag): + self.door = door + self.crystal = crystal + self.flag = flag + + def __str__(self): + return str(self.__unicode__()) + + def __unicode__(self): + return '%s (%s)' % (self.door.name, self.crystal.name) + + +def extend_reachable_state_improved(search_regions, state, proposed_map, all_regions, valid_doors, bk_flag, world, player, exception): + local_state = state.copy() + for region in search_regions: + local_state.visit_region(region) + local_state.add_all_doors_check_proposed(region, proposed_map, valid_doors, False, world, player, exception) + while len(local_state.avail_doors) > 0: + explorable_door = local_state.next_avail_door() + if explorable_door.door.bigKey: + if bk_flag: + big_not_found = (not special_big_key_found(local_state) if local_state.big_key_special + else local_state.count_locations_exclude_specials(world, player) == 0) + if big_not_found: + continue # we can't open this door + if explorable_door.door in proposed_map: + connect_region = world.get_entrance(proposed_map[explorable_door.door].name, player).parent_region + else: + connect_region = world.get_entrance(explorable_door.door.name, player).connected_region + if connect_region is not None: + if valid_region_to_explore_in_regions(connect_region, all_regions, world, player) and not local_state.visited( + connect_region): + flag = explorable_door.flag or explorable_door.door.bigKey + local_state.visit_region(connect_region, bk_flag=flag) + local_state.add_all_doors_check_proposed(connect_region, proposed_map, valid_doors, flag, world, player, exception) + return local_state + + +def special_big_key_found(state): + for location in state.found_locations: + if location.forced_item and location.forced_item.bigkey: + return True + return False + + +def valid_region_to_explore_in_regions(region, all_regions, world, player): + if region is None: + return False + return ((region.type == RegionType.Dungeon and region in all_regions) + or region.name in world.inaccessible_regions[player] + or (region.name == 'Hyrule Castle Ledge' and world.mode[player] == 'standard')) + + +def valid_region_to_explore(region, name, world, player): + if region is None: + return False + return ((region.type == RegionType.Dungeon and region.dungeon and region.dungeon.name in name) + or region.name in world.inaccessible_regions[player] + or (region.name == 'Hyrule Castle Ledge' and world.mode[player] == 'standard')) + + +def get_doors(world, region, player): + res = [] + for ext in region.exits: + door = world.check_for_door(ext.name, player) + if door is not None: + res.append(door) + return res + + +def get_entrance_doors(world, region, player): + res = [] + for ext in region.entrances: + door = world.check_for_door(ext.name, player) + if door is not None: + res.append(door) + return res + + +class GenerationException(Exception): + pass + + diff --git a/source/tools/MysteryUtils.py b/source/tools/MysteryUtils.py index 415433c0..8325719b 100644 --- a/source/tools/MysteryUtils.py +++ b/source/tools/MysteryUtils.py @@ -77,6 +77,7 @@ def roll_settings(weights): door_shuffle = get_choice('door_shuffle') ret.door_shuffle = door_shuffle if door_shuffle != 'none' else 'vanilla' ret.intensity = get_choice('intensity') + ret.decoupledoors = get_choice('decoupledoors') == 'on' ret.experimental = get_choice('experimental') == 'on' ret.collection_rate = get_choice('collection_rate') == 'on'