From 90c3368f9de28d88ec1b8c7f4fedcd614121cb96 Mon Sep 17 00:00:00 2001 From: aerinon Date: Thu, 31 Oct 2019 11:09:58 -0600 Subject: [PATCH] Revamped dungeon generation Revamped key logic generation Prevent key floods in playthrough/can_beat_game checks --- BaseClasses.py | 31 +- DoorShuffle.py | 549 +++++++++++++++++------------------ Doors.py | 8 +- DungeonGenerator.py | 689 ++++++++++++++++++++++++++++++++++++++++++++ Dungeons.py | 23 ++ Main.py | 3 +- Regions.py | 5 - Rules.py | 32 +- 8 files changed, 1029 insertions(+), 311 deletions(-) create mode 100644 DungeonGenerator.py diff --git a/BaseClasses.py b/BaseClasses.py index a7a761db..87aae1ae 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -90,6 +90,7 @@ class World(object): self._room_cache = {} self.dungeon_layouts = {} self.inaccessible_regions = [] + self.key_logic = {} def intialize_regions(self): for region in self.regions: @@ -328,7 +329,7 @@ class World(object): sphere = [] # build up spheres of collection radius. Everything in each sphere is independent from each other in dependencies and only depends on lower spheres for location in prog_locations: - if location.can_reach(state): + if location.can_reach(state) and state.not_flooding_a_key(state.world, location): sphere.append(location) if not sphere: @@ -414,14 +415,17 @@ class CollectionState(object): def _do_not_flood_the_keys(self, reachable_events): adjusted_checks = list(reachable_events) for event in reachable_events: - if event.name == 'Trench 2 Switch' and self.world.get_location('Swamp Palace - Trench 2 Pot Key', event.player) not in reachable_events: - adjusted_checks.remove(event) - if event.name == 'Trench 1 Switch' and self.world.get_location('Swamp Palace - Trench 1 Pot Key', event.player) not in reachable_events: + if event.name in flooded_keys.keys() and self.world.get_location(flooded_keys[event.name], event.player) not in reachable_events: adjusted_checks.remove(event) if len(adjusted_checks) < len(reachable_events): return adjusted_checks return reachable_events + def not_flooding_a_key(self, world, location): + if location.name in flooded_keys.keys(): + return world.get_location(flooded_keys[location.name], location.player) in self.locations_checked + return True + def has(self, item, player, count=1): if count == 1: return (item, player) in self.prog_items @@ -948,7 +952,8 @@ class Door(object): # logical properties # self.connected = False # combine with Dest? self.dest = None - self.blocked = False # Indicates if the door is normally blocked off. (Sanc door or always closed) + self.blocked = False # Indicates if the door is normally blocked off as an exit. (Sanc door or always closed) + self.stonewall = False # Indicate that the door cannot be enter until exited (Desert Torches, PoD Eye Statue) self.smallKey = False # There's a small key door on this side self.bigKey = False # There's a big key door on this side self.ugly = False # Indicates that it can't be seen from the front (e.g. back of a big key door) @@ -1004,6 +1009,10 @@ class Door(object): self.blocked = True return self + def no_entrance(self): + self.stonewall = True + return self + def trap(self, trapFlag): self.trapFlag = trapFlag return self @@ -1024,6 +1033,12 @@ class Door(object): self.crystal = CrystalBarrier.Either return self + def __eq__(self, other): + return isinstance(other, self.__class__) and self.name == other.name + + def __hash__(self): + return hash(self.name) + def __str__(self): return str(self.__unicode__()) @@ -1419,3 +1434,9 @@ class Spoiler(object): path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines))) outfile.write('\n'.join(path_listings)) + + +flooded_keys = { + 'Trench 1 Switch': 'Swamp Palace - Trench 1 Pot Key', + 'Trench 2 Switch': 'Swamp Palace - Trench 2 Pot Key' +} diff --git a/DoorShuffle.py b/DoorShuffle.py index 8b4a6ced..fdb2bcee 100644 --- a/DoorShuffle.py +++ b/DoorShuffle.py @@ -1,14 +1,15 @@ import random import collections +from collections import defaultdict import logging import operator as op from functools import reduce -from BaseClasses import RegionType, Door, DoorType, Direction, Sector, CrystalBarrier, Polarity, pol_idx, pol_inc +from BaseClasses import RegionType, Door, DoorType, Direction, Sector, Polarity, pol_idx, pol_inc from Dungeons import hyrule_castle_regions, eastern_regions, desert_regions, hera_regions, tower_regions, pod_regions -from Dungeons import dungeon_regions, region_starts, split_region_starts -from Regions import key_only_locations, dungeon_events, flooded_keys, flooded_keys_reverse +from Dungeons import dungeon_regions, region_starts, split_region_starts, dungeon_keys, dungeon_bigs from RoomData import DoorKind, PairedDoor +from DungeonGenerator import ExplorationState, extend_reachable_state, convert_regions, generate_dungeon def link_doors(world, player): @@ -129,6 +130,16 @@ paired_directions = { Direction.Down: [Direction.Down, Direction.Up], } +allied_directions = { + Direction.South: [Direction.North, Direction.South], + Direction.North: [Direction.North, Direction.South], + Direction.West: [Direction.East, Direction.West], + Direction.East: [Direction.East, Direction.West], + Direction.Up: [Direction.Down, Direction.Up], + Direction.Down: [Direction.Down, Direction.Up], + +} + def switch_dir(direction): return oppositemap[direction] @@ -455,12 +466,13 @@ def experiment(world, player): split_sectors = split_up_sectors(sector_list, split_region_starts[key]) for idx, sub_sector_list in enumerate(split_sectors): dungeon_sectors.append((key, sub_sector_list, split_region_starts[key][idx])) + # todo: shuffable entrances like pinball, left pit need to be added to entrance list else: dungeon_sectors.append((key, sector_list, region_starts[key])) dungeon_layouts = [] for key, sector_list, entrance_list in dungeon_sectors: - ds = shuffle_dungeon_no_repeats_new(world, player, sector_list, entrance_list) + ds = generate_dungeon(sector_list, entrance_list, world, player) ds.name = key dungeon_layouts.append((ds, entrance_list)) @@ -619,6 +631,14 @@ def shuffle_sectors(buckets, candidates): buckets[solution[i]].append(candidates[i]) +# def find_proposal_greedy_backtrack(bucket, candidates): +# choices = [] +# +# # todo: stick things on the queue in interesting order +# queue = collections.deque(candidates): +# + + # monte carlo proposal generation def find_proposal_monte_carlo(proposal, buckets, candidates): n = len(candidates) @@ -666,253 +686,6 @@ def find_proposal(proposal, buckets, candidates): return proposal -# code below is for an algorithm without restarts -class ExplorableDoor(object): - - def __init__(self, door, crystal): - self.door = door - self.crystal = crystal - - def __str__(self): - return str(self.__unicode__()) - - def __unicode__(self): - return '%s (%s)' % (self.door.name, self.crystal.name) - - -class ExplorationState(object): - - def __init__(self): - - self.unattached_doors = [] - self.avail_doors = [] - self.event_doors = [] - - self.visited_orange = [] - self.visited_blue = [] - self.events = set() - self.crystal = CrystalBarrier.Orange - - # 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.non_door_entrances = [] - - def copy(self): - ret = ExplorationState() - 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.non_door_entrances = list(self.non_door_entrances) - return ret - - def next_avail_door(self): - 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): - 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) - for location in region.locations: - if key_checks and location not in self.found_locations: - if location.name in key_only_locations: - self.key_locations += 1 - if location.name not in dungeon_events and '- Prize' not in location.name: - self.ttl_locations += 1 - if location not in self.found_locations: - self.found_locations.append(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 key_checks and region.name == 'Hyrule Dungeon Cellblock' and not self.big_key_opened: - self.big_key_opened = True - self.avail_doors.extend(self.big_doors) - self.big_doors.clear() - - def flooded_key_check(self, location): - if location.name not in flooded_keys.keys(): - return True - return flooded_keys[location.name] 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.pop() - 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.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_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 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 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 can_traverse(self, door): - if door.blocked: - return False - if door.crystal not in [CrystalBarrier.Null, CrystalBarrier.Either]: - return self.crystal == CrystalBarrier.Either or door.crystal == self.crystal - return True - - def validate(self, door, region, world): - return self.can_traverse(door) and not self.visited(region) and valid_region_to_explore(region, world) - - 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 - - def append_door_to_list(self, door, door_list): - if door.crystal == CrystalBarrier.Null: - door_list.append(ExplorableDoor(door, self.crystal)) - else: - door_list.append(ExplorableDoor(door, door.crystal)) - - 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 extend_reachable_state(search_regions, state, world, player): - local_state = state.copy() - for region in search_regions: - local_state.visit_region(region) - local_state.add_all_doors_check_unattached(region, world, player) - while len(local_state.avail_doors) > 0: - explorable_door = local_state.next_avail_door() - entrance = world.get_entrance(explorable_door.door.name, player) - connect_region = entrance.connected_region - if connect_region is not None: - if valid_region_to_explore(connect_region, world) and not local_state.visited(connect_region): - local_state.visit_region(connect_region) - local_state.add_all_doors_check_unattached(connect_region, world, player) - return local_state - - def extend_state_backward(search_regions, state, world, player): local_state = state.copy() for region in search_regions: @@ -929,6 +702,7 @@ def extend_state_backward(search_regions, state, world, player): return local_state +# code below is for an algorithm without restarts # todo: this sometimes generates two independent parts - that could be valid if the entrances are accessible # todo: prevent crystal barrier dead ends def shuffle_dungeon_no_repeats_new(world, player, available_sectors, entrance_region_names): @@ -937,10 +711,7 @@ def shuffle_dungeon_no_repeats_new(world, player, available_sectors, entrance_re for sector in available_sectors: random.shuffle(sector.outstanding_doors) - entrance_regions = [] - # current_sector = None - for region_name in entrance_region_names: - entrance_regions.append(world.get_region(region_name, player)) + entrance_regions = convert_regions(entrance_region_names, world, player) state = extend_reachable_state(entrance_regions, ExplorationState(), world, player) # Loop until all available doors are used @@ -1058,7 +829,7 @@ def is_valid(door_a, door_b, sector_a, sector_b, available_sectors, reachable_do return False elif early_loop_dies(door_a, sector_a, sector_b, available_sectors): return False - elif logical_dead_end(door_a, door_b, state, world, player, available_sectors, reachable_doors): + elif logical_dead_end_3(door_a, door_b, state, world, player, available_sectors, reachable_doors): return False elif door_a.blocked and door_b.blocked: # I can't see this going well unless we are in loop generation... return False @@ -1179,11 +950,13 @@ def logical_dead_end(door_a, door_b, state, world, player, available_sectors, re region_set = set(local_state.visited_orange+local_state.visited_blue) if needed and len(visited_regions.intersection(region_set)) == 0 and door.direction in directions: hooks_needed += 1 - elif door.direction in hook_directions: - outstanding_hooks += 1 + visited_regions.update(region_set) + elif door.direction in hook_directions and not door.blocked: if opposing_hooks > 0 and more_than_one_hook(local_state, hook_directions): needed = False - visited_regions.update(region_set) + if not needed: + outstanding_hooks += 1 + visited_regions.update(region_set) if not needed: only_dead_ends = False if outstanding_doors_of_type > 0 and ((number_of_hooks == 0 and only_dead_ends) or hooks_needed > number_of_hooks + outstanding_hooks): @@ -1191,6 +964,179 @@ def logical_dead_end(door_a, door_b, state, world, player, available_sectors, re return False +def logical_dead_end_3(door_a, door_b, state, world, player, available_sectors, reachable_doors): + region = world.get_entrance(door_b.name, player).parent_region + new_state = extend_reachable_state([region], state, world, player) + new_state.unattached_doors[:] = [x for x in new_state.unattached_doors if x.door not in [door_a, door_b]] + if len(new_state.unattached_doors) == 0: + return True + current_hooks = defaultdict(lambda: 0) + hooks_needed = defaultdict(set) + outstanding_hooks = defaultdict(lambda: 0) + outstanding_total = defaultdict(lambda: 0) + potential_hooks = [] + only_dead_ends_vector = [True, True, True] + avail_dirs = set() + for exp_d in new_state.unattached_doors: + hook_key = hook_id(exp_d.door) + current_hooks[hook_key] += 1 + avail_dirs.update(allied_directions[exp_d.door.direction]) + for sector in available_sectors: + for door in sector.outstanding_doors: + if door != door_b and not state.in_door_list_ic(door, new_state.unattached_doors): + opp_hook_key = opp_hook_id(door) + outstanding_total[opp_hook_key] += 1 + if door.blocked: + hooks_needed[opp_hook_key].add(door) + else: + dead_end, cross_interaction = True, False + region = world.get_entrance(door.name, player).parent_region + local_state = extend_state_backward([region], ExplorationState(), world, player) + if len(local_state.non_door_entrances) > 0: + dead_end = False # not a dead end + cross_interaction = True + elif len(local_state.unattached_doors) > 1: + dead_end = False + for exp_d in local_state.unattached_doors: + if cross_door_interaction(exp_d.door, door, reachable_doors, avail_dirs): + cross_interaction = True + break + if dead_end: + hooks_needed[opp_hook_key].add(door) + else: + door_set = set([x.door for x in local_state.unattached_doors if x.door != door]) + potential_hooks.append((door, door_set)) + if cross_interaction: + only_dead_ends_vector[pol_idx[door.direction][0]] = False + logically_valid = False + satisfying_hooks = [] + while not logically_valid: + check_good = True + for key in [Direction.North, Direction.South, Direction.East, Direction.West, DoorType.SpiralStairs]: + dir = key if isinstance(key, Direction) else Direction.Up + vector_idx = pol_idx[dir][0] + ttl_hooks = current_hooks[key] + current_hooks[switch_dir(dir) if isinstance(key, Direction) else DoorType.SpiralStairs] + if outstanding_total[key] > 0 and ttl_hooks == 0 and only_dead_ends_vector[vector_idx]: + return True # no way to get to part of the dungeon + hooks_wanted = hooks_needed[key] + if outstanding_total[key] > 0 and len(hooks_wanted) > current_hooks[key] + outstanding_hooks[key]: + check_good = False + fixer = find_fixer(potential_hooks, key, hooks_wanted, []) + if fixer is None: + return True # couldn't find a fix + else: + apply_fix(fixer, potential_hooks, outstanding_hooks, hooks_needed, satisfying_hooks, key) + if check_good and len(satisfying_hooks) > 0: + check_good = False + votes = defaultdict(lambda: 0) # I really don't like this as other votes could lead to a valid configuration + door_dict = {} + for hook_set in satisfying_hooks: + if len(hook_set) == 0: + return True # unsatisfiable condition + for hookable in hook_set: + votes[hookable.name] += 1 + door_dict[hookable.name] = hookable + winner = None + for door_name in votes.keys(): + if winner is None or votes[door_name] > votes[winner]: + winner = door_name + winning_hook = door_dict[winner] + key = opp_hook_id(winning_hook) + hooks_needed[key].add(winning_hook) + satisfying_hooks[:] = [x for x in satisfying_hooks if winning_hook not in x] + logically_valid = check_good + return False # no logical dead ends! + + # potential_fixers = [] + # skip = False + # for hookable in hook_set: + # key = opp_hook_id(hookable) + # if len(hooks_needed[key]) < current_hooks[key] + outstanding_hooks[key]: + # hooks_needed[key].add(hookable) + # hooks_to_remove.append(hook_set) + # hooks_to_remove.extend([x for x in satisfying_hooks if hookable in x]) + # skip = True + # break + # fixer = find_fixer(potential_hooks, key, hooks_needed[key], hook_set) + # if fixer is not None: + # potential_fixers.append(fixer) + # if skip: + # break + # if len(potential_fixers) == 0: + # return True # can't find fixers for this set + # elif len(potential_fixers) >= 1: + # check_good = False + # fixer = potential_fixers[0] # just pick the first for now + # apply_fix(fixer, potential_hooks, outstanding_hooks, hooks_needed, satisfying_hooks, hook_id(fixer[0])) + # hooks_to_remove.append(hook_set) + # # what's left - multiple set with multiple available fixes + # if len(hooks_to_remove) == 0: + # return True # can't seem to make progress yet + # satisfying_hooks[:] = [x for x in satisfying_hooks if x not in hooks_to_remove] + # logically_valid = check_good + # return False # no logical dead ends! + + +def hook_id(door): + if door.type == DoorType.Normal: + return door.direction + if door.type == DoorType.SpiralStairs: + return door.type + return 'Some new door type' + + +def opp_hook_id(door): + if door.type == DoorType.Normal: + return switch_dir(door.direction) + if door.type == DoorType.SpiralStairs: + return door.type + return 'Some new door type' + + +def find_fixer(potential_hooks, key, hooks_wanted, invalid_options): + fixer = None + for door, door_set in potential_hooks: + if match_hook_key(door, key) and (len(hooks_wanted) > 1 or len(hooks_wanted.union(door_set)) > 1) and door not in invalid_options: + if fixer is None or len(door_set) > len(fixer[1]): # choose the one with most options + fixer = (door, door_set) + return fixer + + +def apply_fix(fixer, potential_hooks, outstanding_hooks, hooks_needed, satisfying_hooks, key): + outstanding_hooks[key] += 1 + potential_hooks.remove(fixer) + winnow_potential_hooks(potential_hooks, fixer[0]) # + winnow_satisfying_hooks(satisfying_hooks, fixer[0]) + if len(fixer[1]) == 1: + new_need = fixer[1].pop() + hooks_needed[opp_hook_id(new_need)].add(new_need) + # removes any hooks that are now fulfilled + satisfying_hooks[:] = [x for x in satisfying_hooks if new_need not in x] + else: + satisfying_hooks.append(fixer[1]) + + +def match_hook_key(door, key): + if isinstance(key, DoorType): + return door.type == key + if isinstance(key, Direction): + return door.direction == key + return False + + +def winnow_potential_hooks(hooks, door_removal): + for door, door_set in hooks: + if door_removal in door_set: + door_set.remove(door_removal) + hooks[:] = [x for x in hooks if len(x[1]) > 0] + + +def winnow_satisfying_hooks(satisfying, door_removal): + for hook_set in satisfying: + if door_removal in hook_set: + hook_set.remove(door_removal) + + def door_of_interest(door, door_b, d_type, directions, hook_directions, state): if door == door_b or door.type != d_type: return False @@ -1205,6 +1151,14 @@ def different_direction(door, d_type, directions, hook_directions, reachable_doo return door.type != d_type or (door.direction not in directions and door.direction not in hook_directions) +def cross_door_interaction(door, original_door, reachable_doors, avail_dir): + if door in reachable_doors or door == original_door: + return False + if door.type == original_door.type and door.direction in allied_directions[original_door.direction]: # revisit if cross-type linking ever happens + return False + return door.direction in avail_dir + + def more_than_one_hook(state, hook_directions): cnt = 0 for exp_d in state.unattached_doors: @@ -1267,7 +1221,8 @@ def shuffle_key_doors(dungeon_sector, entrances, world, player): combinations = ncr(len(paired_candidates), num_key_doors) itr = 0 proposal = kth_combination(itr, paired_candidates, num_key_doors) - while not validate_key_layout(dungeon_sector, start_regions, proposal, world, player): + key_logic = KeyLogic(dungeon_sector.name) + while not validate_key_layout(dungeon_sector, start_regions, proposal, key_logic, world, player): itr += 1 if itr >= combinations: logging.getLogger('').info('Lowering key door count because no valid layouts: %s', dungeon_sector.name) @@ -1275,8 +1230,21 @@ def shuffle_key_doors(dungeon_sector, entrances, world, player): combinations = ncr(len(paired_candidates), num_key_doors) itr = 0 proposal = kth_combination(itr, paired_candidates, num_key_doors) + key_logic = KeyLogic(dungeon_sector.name) # make changes + if player not in world.key_logic.keys(): + world.key_logic[player] = {} + world.key_logic[player][dungeon_sector.name] = key_logic reassign_key_doors(current_doors, proposal, world, player) + + +class KeyLogic(object): + + def __init__(self, dungeon_name): + self.door_rules = {} + self.bk_restricted = [] + self.small_key_name = dungeon_keys[dungeon_name] + self.bk_name = dungeon_bigs[dungeon_name] def build_pair_list(flat_list): @@ -1361,7 +1329,7 @@ def ncr(n, r): return numerator / denominator -def validate_key_layout(sector, start_regions, key_door_proposal, world, player): +def validate_key_layout(sector, start_regions, key_door_proposal, key_logic, world, player): flat_proposal = flatten_pair_list(key_door_proposal) state = ExplorationState() state.key_locations = len(world.get_dungeon(sector.name, player).small_keys) @@ -1371,10 +1339,10 @@ def validate_key_layout(sector, start_regions, key_door_proposal, world, player) state.visit_region(region, key_checks=True) state.add_all_doors_check_keys(region, flat_proposal, world, player) checked_states = set() - return validate_key_layout_r(state, flat_proposal, checked_states, world, player) + return validate_key_layout_r(state, flat_proposal, checked_states, key_logic, world, player) -def validate_key_layout_r(state, flat_proposal, checked_states, world, player): +def validate_key_layout_r(state, flat_proposal, checked_states, key_logic, world, player): # improvements: remove recursion to make this iterative # store a cache of various states of opened door to increase speed of checks - many are repetitive @@ -1395,7 +1363,21 @@ def validate_key_layout_r(state, flat_proposal, checked_states, world, player): if (not smalls_avail or available_small_locations == 0) and (state.big_key_opened or num_bigs == 0 or available_big_locations == 0): return False else: - if smalls_avail and available_small_locations > 0: + if not state.big_key_opened and available_big_locations >= num_bigs > 0: # bk first for better key rules + state_copy = state.copy() + state_copy.big_key_opened = True + state_copy.used_locations += 1 + state_copy.avail_doors.extend(state.big_doors) + state_copy.big_doors.clear() + code = state_id(state_copy, flat_proposal) + if code not in checked_states: + valid = validate_key_layout_r(state_copy, flat_proposal, checked_states, key_logic, world, player) + if valid: + checked_states.add(code) + elif smalls_avail and available_small_locations > 0: + key_rule_num = min(state.key_locations, count_unique_doors(state.small_doors) + state.used_smalls) + if key_rule_num == len(state.found_locations): + key_logic.bk_restricted.extend([x for x in state.found_locations if x not in key_logic.bk_restricted]) for exp_door in state.small_doors: state_copy = state.copy() state_copy.opened_doors.append(exp_door.door) @@ -1408,34 +1390,43 @@ def validate_key_layout_r(state, flat_proposal, checked_states, world, player): now_available = [x for x in state_copy.small_doors if x.door == dest_door] state_copy.small_doors[:] = [x for x in state_copy.small_doors if x.door != dest_door] state_copy.avail_doors.extend(now_available) + set_key_rules(key_logic, dest_door, key_rule_num) + set_key_rules(key_logic, exp_door.door, key_rule_num) state_copy.used_locations += 1 state_copy.used_smalls += 1 code = state_id(state_copy, flat_proposal) if code not in checked_states: - valid = validate_key_layout_r(state_copy, flat_proposal, checked_states, world, player) + valid = validate_key_layout_r(state_copy, flat_proposal, checked_states, key_logic, world, player) if valid: checked_states.add(code) if not valid: return valid - if not state.big_key_opened and available_big_locations >= num_bigs > 0: - state_copy = state.copy() - state_copy.big_key_opened = True - state_copy.used_locations += 1 - state_copy.avail_doors.extend(state.big_doors) - state_copy.big_doors.clear() - code = state_id(state_copy, flat_proposal) - if code not in checked_states: - valid = validate_key_layout_r(state_copy, flat_proposal, checked_states, world, player) - if valid: - checked_states.add(code) return valid +def count_unique_doors(doors_to_count): + cnt = 0 + counted = set() + for d in doors_to_count: + if d.door not in counted: + cnt += 1 + counted.add(d.door) + counted.add(d.door.dest) + return cnt + + +def set_key_rules(key_logic, door, number): + if door.name not in key_logic.door_rules.keys(): + key_logic.door_rules[door.name] = number + else: + key_logic.door_rules[door.name] = min(number, key_logic.door_rules[door.name]) + + def state_id(state, flat_proposal): - state_id = '1' if state.big_key_opened else '0' + s_id = '1' if state.big_key_opened else '0' for d in flat_proposal: - state_id += '1' if d in state.opened_doors else '0' - return state_id + s_id += '1' if d in state.opened_doors else '0' + return s_id def reassign_key_doors(current_doors, proposal, world, player): diff --git a/Doors.py b/Doors.py index 11e5921a..73105312 100644 --- a/Doors.py +++ b/Doors.py @@ -184,9 +184,7 @@ def create_doors(world, player): create_door(player, 'Desert Tiles 2 SE', Nrml).dir(Direction.South, 0x43, Right, High).small_key().pos(2), create_door(player, 'Desert Tiles 2 NE', Intr).dir(Direction.North, 0x43, Right, High).small_key().pos(1), create_door(player, 'Desert Wall Slide SE', Intr).dir(Direction.South, 0x43, Right, High).small_key().pos(1), - # todo: we need a new flag for a door that has a wall on it - you have to traverse it one particular way first - # the above is not a problem until we get to crossed mode - create_door(player, 'Desert Wall Slide NW', Nrml).dir(Direction.North, 0x43, Left, High).big_key().pos(0), + create_door(player, 'Desert Wall Slide NW', Nrml).dir(Direction.North, 0x43, Left, High).big_key().pos(0).no_entrance(), create_door(player, 'Desert Boss SW', Nrml).dir(Direction.South, 0x33, Left, High).no_exit().trap(0x4).pos(0), # Hera @@ -323,9 +321,7 @@ def create_doors(world, player): create_door(player, 'PoD Mimics 2 SW', Nrml).dir(Direction.South, 0x1b, Left, High).pos(1), create_door(player, 'PoD Mimics 2 NW', Intr).dir(Direction.North, 0x1b, Left, High).pos(0), create_door(player, 'PoD Bow Statue SW', Intr).dir(Direction.South, 0x1b, Left, High).pos(0), - # todo: we need a new flag for a door that has a wall on it - you have to traverse it one particular way first - # the above is not a problem until we get to crossed mode - create_door(player, 'PoD Bow Statue Down Ladder', Lddr), + create_door(player, 'PoD Bow Statue Down Ladder', Lddr).no_entrance(), create_door(player, 'PoD Dark Pegs Up Ladder', Lddr), create_door(player, 'PoD Dark Pegs WN', Intr).dir(Direction.West, 0x0b, Mid, High).small_key().pos(2), create_door(player, 'PoD Lonely Turtle SW', Intr).dir(Direction.South, 0x0b, Mid, High).pos(0), diff --git a/DungeonGenerator.py b/DungeonGenerator.py new file mode 100644 index 00000000..740e0b53 --- /dev/null +++ b/DungeonGenerator.py @@ -0,0 +1,689 @@ +import random +import collections +from collections import defaultdict +from enum import Enum, unique +import logging + +from BaseClasses import DoorType, Direction, CrystalBarrier, RegionType, flooded_keys +from Regions import key_only_locations, dungeon_events, flooded_keys_reverse + + +@unique +class Hook(Enum): + North = 0 + West = 1 + South = 2 + East = 3 + Stairs = 4 + + +class GraphPiece: + + def __init__(self): + self.hanger_info = None + self.hooks = {} + self.visited_regions = set() + + +def generate_dungeon(available_sectors, entrance_region_names, world, player): + logger = logging.getLogger('') + entrance_regions = convert_regions(entrance_region_names, world, player) + doors_to_connect = set() + all_regions = set() + for sector in available_sectors: + for door in sector.outstanding_doors: + doors_to_connect.add(door) + all_regions.update(sector.regions) + proposed_map = {} + choices_master = [[]] + depth = 0 + dungeon_cache = {} + backtrack = False + # last_choice = None + while len(proposed_map) < len(doors_to_connect): + # what are my choices? + if depth not in dungeon_cache.keys(): + dungeon, hangers, hooks = gen_dungeon_info(available_sectors, entrance_regions, proposed_map, doors_to_connect, world, player) + dungeon_cache[depth] = dungeon, hangers, hooks + valid = check_valid(dungeon, hangers, hooks, proposed_map, doors_to_connect, all_regions) + else: + dungeon, hangers, hooks = dungeon_cache[depth] + valid = True + if valid: + prev_choices = choices_master[depth] + # make a choice + hanger, hook = make_a_choice(dungeon, hangers, hooks, prev_choices) + if hanger is None: + backtrack = True + else: + logger.debug(' '*depth+"%d: Linking %s to %s", depth, hanger.name, hook.name) + proposed_map[hanger] = hook + proposed_map[hook] = hanger + last_choice = (hanger, hook) + choices_master[depth].append(last_choice) + depth += 1 + choices_master.append([]) + else: + backtrack = True + if backtrack: + backtrack = False + choices_master.pop() + dungeon_cache.pop(depth, None) + depth -= 1 + a, b = choices_master[depth][-1] + logger.debug(' '*depth+"%d: Rescinding %s, %s", depth, a.name, b.name) + proposed_map.pop(a, None) + proposed_map.pop(b, None) + queue = collections.deque(proposed_map.items()) + while len(queue) > 0: + a, b = queue.pop() + connect_doors(a, b, world, player) + queue.remove((b, a)) + master_sector = available_sectors.pop() + for sub_sector in available_sectors: + master_sector.regions.extend(sub_sector.regions) + return master_sector + + +def gen_dungeon_info(available_sectors, entrance_regions, proposed_map, valid_doors, world, player): + # step 1 create dungeon: Dict + dungeon = {} + original_state = extend_reachable_state_improved(entrance_regions, ExplorationState(), proposed_map, valid_doors, world, player) + dungeon['Origin'] = create_graph_piece_from_state(None, original_state, original_state, proposed_map) + doors_to_connect = set() + blue_hooks = [] + o_state_cache = {} + for sector in available_sectors: + for door in sector.outstanding_doors: + doors_to_connect.add(door) + if not door.stonewall and door not in proposed_map.keys(): + parent = parent_region(door, world, player).parent_region + o_state = extend_reachable_state_improved([parent], ExplorationState(), proposed_map, valid_doors, world, player) + o_state_cache[door.name] = o_state + piece = create_graph_piece_from_state(door, o_state, o_state, proposed_map) + dungeon[door.name] = piece + for hook, crystal in piece.hooks.items(): + if crystal == CrystalBarrier.Blue or crystal == CrystalBarrier.Either: + h_type = hook_from_door(hook) + if h_type not in blue_hooks: + blue_hooks.append(h_type) # todo: specific hooks and valid path to c_switch + if len(blue_hooks) > 0: + for sector in available_sectors: + for door in sector.outstanding_doors: + h_type = hanger_from_door(door) + if not door.stonewall and door not in proposed_map.keys() and h_type in blue_hooks: + parent = parent_region(door, world, player).parent_region + blue_start = ExplorationState(CrystalBarrier.Blue) + b_state = extend_reachable_state_improved([parent], blue_start, proposed_map, valid_doors, world, player) + o_state = o_state_cache[door.name] + dungeon[door.name] = create_graph_piece_from_state(door, o_state, b_state, proposed_map) + + # catalog hooks: Dict + # and hangers: + avail_hooks = defaultdict(set) + hangers = defaultdict(set) + for key, piece in dungeon.items(): + door_hang = piece.hanger_info + if door_hang is not None: + hanger = hanger_from_door(door_hang) + hangers[hanger].add(door_hang) + for door, crystal in piece.hooks.items(): + hook = hook_from_door(door) + avail_hooks[hook].add((door, crystal, door_hang)) + + # thin out invalid hanger + winnow_hangers(hangers, avail_hooks) + return dungeon, hangers, avail_hooks + + +def make_a_choice(dungeon, hangers, avail_hooks, prev_choices): + # choose a hanger + all_hooks = set() + origin = dungeon['Origin'] + for key in avail_hooks.keys(): + for hstuff in avail_hooks[key]: + all_hooks.add(hstuff[0]) + candidate_hangers = [] + for key in hangers.keys(): + candidate_hangers.extend(hangers[key]) + candidate_hangers.sort(key=lambda x: x.name) # sorting to create predictable seeds + random.shuffle(candidate_hangers) # randomize if equal preference + stage_2_hangers = [] + hookable_hangers = collections.deque() + queue = collections.deque(candidate_hangers) + while len(queue) > 0: + c_hang = queue.pop() + if c_hang in all_hooks: + hookable_hangers.append(c_hang) + else: + stage_2_hangers.append(c_hang) # prefer hangers that are not hooks + # todo : prefer hangers with fewer hooks at some point? not sure about this + # this prefer hangers of the fewest type - to catch problems fast + hookable_hangers = sorted(hookable_hangers, key=lambda door: len(hangers[hanger_from_door(door)]), reverse=True) + origin_hangers = [] + while len(hookable_hangers) > 0: + c_hang = hookable_hangers.pop() + if c_hang in origin.hooks.keys(): + origin_hangers.append(c_hang) + else: + stage_2_hangers.append(c_hang) # prefer hangers that are not hooks on the 'origin' + stage_2_hangers.extend(origin_hangers) + + hook = None + next_hanger = None + while hook is None: + if len(stage_2_hangers) == 0: + return None, None + next_hanger = stage_2_hangers.pop(0) + next_hanger_type = hanger_from_door(next_hanger) + hook_candidates = [] + for door, crystal, orig_hang in avail_hooks[next_hanger_type]: + if filter_choices(next_hanger, door, orig_hang, prev_choices, hook_candidates): + hook_candidates.append(door) + if len(hook_candidates) > 0: + hook_candidates.sort(key=lambda x: x.name) # sort for deterministic seeds + hook = random.choice(tuple(hook_candidates)) + + return next_hanger, hook + + +def filter_choices(next_hanger, door, orig_hang, prev_choices, hook_candidates): + if (next_hanger, door) in prev_choices or (door, next_hanger) in prev_choices: + return False + return next_hanger != door and orig_hang != next_hanger and door not in hook_candidates + + +def check_valid(dungeon, hangers, hooks, proposed_map, doors_to_connect, all_regions): + # evaluate if everything is still plausible + + # only origin is left in the dungeon and not everything is connected + if len(dungeon.keys()) <= 1 and len(proposed_map.keys()) < len(doors_to_connect): + return False + # origin has no more hooks, but not all doors have been proposed + if len(dungeon['Origin'].hooks) == 0 and len(proposed_map.keys()) < len(doors_to_connect): + return False + for key in hangers.keys(): + if len(hooks[key]) > 0 and len(hangers[key]) == 0: + return False + # todo: stonewall - check that there's no hook-only that is without a matching hanger + all_visited = set() + for piece in dungeon.values(): + all_visited.update(piece.visited_regions) + if len(all_regions.difference(all_visited)) > 0: + return False + new_hangers_found = True + accessible_hook_types = [] + hanger_matching = set() + all_hangers = set() + origin_hooks = set(dungeon['Origin'].hooks.keys()) + for door_hook in origin_hooks: + h_type = hook_from_door(door_hook) + if h_type not in accessible_hook_types: + accessible_hook_types.append(h_type) + while new_hangers_found: + new_hangers_found = False + for hanger_set in hangers.values(): + for hanger in hanger_set: + all_hangers.add(hanger) + h_type = hanger_from_door(hanger) + if (h_type in accessible_hook_types or hanger in origin_hooks) and hanger not in hanger_matching: + new_hangers_found = True + hanger_matching.add(hanger) + matching_hooks = dungeon[hanger.name].hooks.keys() + origin_hooks.update(matching_hooks) + for door_hook in matching_hooks: + new_h_type = hook_from_door(door_hook) + if new_h_type not in accessible_hook_types: + accessible_hook_types.append(new_h_type) + return len(all_hangers.difference(hanger_matching)) == 0 + + +def winnow_hangers(hangers, hooks): + removal_info = [] + for hanger, door_set in hangers.items(): + for door in door_set: + hook_set = hooks[hanger] + if len(hook_set) == 0: + removal_info.append((hanger, door)) + else: + found_valid = False + for door_hook, crystal, orig_hanger in hook_set: + if orig_hanger != door: + found_valid = True + if not found_valid: + removal_info.append((hanger, door)) + for hanger, door in removal_info: + hangers[hanger].remove(door) + + +def create_graph_piece_from_state(door, o_state, b_state, proposed_map): + # todo: info about dungeon events - not sure about that + graph_piece = GraphPiece() + all_unattached = {} + for exp_d in o_state.unattached_doors: + all_unattached[exp_d.door] = exp_d.crystal + for exp_d in b_state.unattached_doors: + d = exp_d.door + if d in all_unattached.keys(): + if all_unattached[d] != exp_d.crystal: + if all_unattached[d] == CrystalBarrier.Orange and exp_d.crystal == CrystalBarrier.Blue: + all_unattached[d] = CrystalBarrier.Null + else: + logging.getLogger('').warning('Mismatched state @ %s (o:%s b:%s)', d.name, all_unattached[d], exp_d.crystal) + else: + all_unattached[exp_d.door] = exp_d.crystal + for d, crystal in all_unattached.items(): + if (door is None or d != door) and not d.blocked and d not in proposed_map.keys(): + graph_piece.hooks[d] = crystal + graph_piece.hanger_info = door + graph_piece.visited_regions.update(o_state.visited_blue) + graph_piece.visited_regions.update(o_state.visited_orange) + graph_piece.visited_regions.update(b_state.visited_blue) + graph_piece.visited_regions.update(b_state.visited_orange) + return graph_piece + + +def parent_region(door, world, player): + return world.get_entrance(door.name, player) + + +def hook_from_door(door): + if door.type == DoorType.SpiralStairs: + return Hook.Stairs + if door.type == DoorType.Normal: + dir = { + Direction.North: Hook.North, + Direction.South: Hook.South, + Direction.West: Hook.West, + Direction.East: Hook.East, + } + return dir[door.direction] + return None + + +def hanger_from_door(door): + if door.type == DoorType.SpiralStairs: + return Hook.Stairs + if door.type == DoorType.Normal: + dir = { + Direction.North: Hook.South, + Direction.South: Hook.North, + Direction.West: Hook.East, + Direction.East: Hook.West, + } + return dir[door.direction] + return None + + +def connect_doors(a, b, world, player): + # Return on unsupported types. + if a.type in [DoorType.Open, DoorType.StraightStairs, DoorType.Hole, DoorType.Warp, DoorType.Ladder, + DoorType.Interior, DoorType.Logical]: + return + # Connect supported types + if a.type == DoorType.Normal or a.type == DoorType.SpiralStairs: + if a.blocked: + connect_one_way(world, b.name, a.name, player) + elif b.blocked: + connect_one_way(world, a.name, b.name, player) + else: + connect_two_way(world, a.name, b.name, player) + return + # If we failed to account for a type, panic + raise RuntimeError('Unknown door type ' + a.type.name) + + +def connect_two_way(world, entrancename, exitname, player): + entrance = world.get_entrance(entrancename, player) + ext = world.get_entrance(exitname, player) + + # 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 = world.check_for_door(entrancename, player) + y = world.check_for_door(exitname, player) + if x is not None: + x.dest = y + if y is not None: + y.dest = x + + +def connect_one_way(world, entrancename, exitname, player): + entrance = world.get_entrance(entrancename, player) + ext = world.get_entrance(exitname, player) + + # 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) + if entrance.parent_region.dungeon: + ext.parent_region.dungeon = entrance.parent_region.dungeon + x = world.check_for_door(entrancename, player) + y = world.check_for_door(exitname, player) + if x is not None: + x.dest = y + if y is not None: + y.dest = x + + +class ExplorationState(object): + + def __init__(self, init_crystal=CrystalBarrier.Orange): + + self.unattached_doors = [] + self.avail_doors = [] + self.event_doors = [] + + self.visited_orange = [] + self.visited_blue = [] + 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.non_door_entrances = [] + + def copy(self): + ret = ExplorationState() + 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.non_door_entrances = list(self.non_door_entrances) + return ret + + def next_avail_door(self): + 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): + 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) + for location in region.locations: + if key_checks and location not in self.found_locations: + if location.name in key_only_locations: + self.key_locations += 1 + if location.name not in dungeon_events and '- Prize' not in location.name: + self.ttl_locations += 1 + if location not in self.found_locations: + self.found_locations.append(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 key_checks and region.name == 'Hyrule Dungeon Cellblock' and not self.big_key_opened: + self.big_key_opened = True + self.avail_doors.extend(self.big_doors) + self.big_doors.clear() + + 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.pop() + 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.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, world, player): + for door in get_dungeon_doors(region, world, player): + if self.can_traverse(door): + if door.dest is None and not self.in_door_list_ic(door, self.unattached_doors)\ + and door not in proposed_map.keys() and door in valid_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_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 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 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 can_traverse(self, door): + if door.blocked: + return False + if door.crystal not in [CrystalBarrier.Null, CrystalBarrier.Either]: + return self.crystal == CrystalBarrier.Either or door.crystal == self.crystal + return True + + def validate(self, door, region, world): + return self.can_traverse(door) and not self.visited(region) and valid_region_to_explore(region, world) + + 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 + + def append_door_to_list(self, door, door_list): + if door.crystal == CrystalBarrier.Null: + door_list.append(ExplorableDoor(door, self.crystal)) + else: + door_list.append(ExplorableDoor(door, door.crystal)) + + def key_door_sort(self, d): + if d.door.smallKey: + if d.door in self.opened_doors: + return 1 + else: + return 0 + return 2 + + +class ExplorableDoor(object): + + def __init__(self, door, crystal): + self.door = door + self.crystal = crystal + + def __str__(self): + return str(self.__unicode__()) + + def __unicode__(self): + return '%s (%s)' % (self.door.name, self.crystal.name) + + +def extend_reachable_state(search_regions, state, world, player): + local_state = state.copy() + for region in search_regions: + local_state.visit_region(region) + local_state.add_all_doors_check_unattached(region, world, player) + while len(local_state.avail_doors) > 0: + explorable_door = local_state.next_avail_door() + connect_region = world.get_entrance(explorable_door.door.name, player).connected_region + if connect_region is not None: + if valid_region_to_explore(connect_region, world) and not local_state.visited(connect_region): + local_state.visit_region(connect_region) + local_state.add_all_doors_check_unattached(connect_region, world, player) + return local_state + + +def extend_reachable_state_improved(search_regions, state, proposed_map, valid_doors, world, player): + 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, world, player) + while len(local_state.avail_doors) > 0: + explorable_door = local_state.next_avail_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(connect_region, world) and not local_state.visited(connect_region): + local_state.visit_region(connect_region) + local_state.add_all_doors_check_proposed(connect_region, proposed_map, valid_doors, world, player) + return local_state + + +# cross-utility methods +def valid_region_to_explore(region, world): + return region.type == RegionType.Dungeon or region.name in world.inaccessible_regions + + +def get_doors(world, region, player): + res = [] + for exit in region.exits: + door = world.check_for_door(exit.name, player) + if door is not None: + res.append(door) + return res + + +def get_dungeon_doors(region, world, player): + res = [] + for ext in region.exits: + door = world.check_for_door(ext.name, player) + if door is not None and ext.parent_region.type == RegionType.Dungeon: + res.append(door) + return res + + +def get_entrance_doors(world, region, player): + res = [] + for exit in region.entrances: + door = world.check_for_door(exit.name, player) + if door is not None: + res.append(door) + return res + + +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 diff --git a/Dungeons.py b/Dungeons.py index 826688da..8420e468 100644 --- a/Dungeons.py +++ b/Dungeons.py @@ -287,4 +287,27 @@ split_region_starts = { ] } +dungeon_keys = { + 'Hyrule Castle': 'Small Key (Escape)', + 'Eastern Palace': 'Small Key (Eastern Palace)', + 'Desert Palace': 'Small Key (Desert Palace)', + 'Tower of Hera': 'Small Key (Tower of Hera)', + 'Agahnims Tower': 'Small Key (Agahnims Tower)', + 'Palace of Darkness': 'Small Key (Palace of Darkness)', + 'Swamp Palace': 'Small Key (Swamp Palace)', + 'Skull Woods': 'Small Key (Skull Woods)', + 'Thieves Town': 'Small Key (Thieves Town)' +} + +dungeon_bigs = { + 'Hyrule Castle': 'Big Key (Escape)', + 'Eastern Palace': 'Big Key (Eastern Palace)', + 'Desert Palace': 'Big Key (Desert Palace)', + 'Tower of Hera': 'Big Key (Tower of Hera)', + 'Agahnims Tower': 'Big Key (Agahnims Tower)', + 'Palace of Darkness': 'Big Key (Palace of Darkness)', + 'Swamp Palace': 'Big Key (Swamp Palace)', + 'Skull Woods': 'Big Key (Skull Woods)', + 'Thieves Town': 'Big Key (Thieves Town)' +} diff --git a/Main.py b/Main.py index 0519eee1..e9e89ed7 100644 --- a/Main.py +++ b/Main.py @@ -282,6 +282,7 @@ def copy_world(world): ret.rooms = world.rooms ret.inaccessible_regions = world.inaccessible_regions ret.dungeon_layouts = world.dungeon_layouts + ret.key_logic = world.key_logic for player in range(1, world.players + 1): set_rules(ret, player) @@ -337,7 +338,7 @@ def create_playthrough(world): sphere = [] # build up spheres of collection radius. Everything in each sphere is independent from each other in dependencies and only depends on lower spheres for location in sphere_candidates: - if state.can_reach(location): + if state.can_reach(location) and state.not_flooding_a_key(world, location): sphere.append(location) for location in sphere: diff --git a/Regions.py b/Regions.py index 7fe6af09..188b4f20 100644 --- a/Regions.py +++ b/Regions.py @@ -647,11 +647,6 @@ dungeon_events = [ 'Revealing Light' ] -flooded_keys = { - 'Trench 1 Switch': 'Swamp Palace - Trench 1 Pot Key', - 'Trench 2 Switch': 'Swamp Palace - Trench 2 Pot Key' -} - flooded_keys_reverse = { 'Swamp Palace - Trench 1 Pot Key': 'Trench 1 Switch', 'Swamp Palace - Trench 2 Pot Key': 'Trench 2 Switch' diff --git a/Rules.py b/Rules.py index 144501f0..cfaded7b 100644 --- a/Rules.py +++ b/Rules.py @@ -2,7 +2,8 @@ import collections from collections import defaultdict import logging from BaseClasses import CollectionState, DoorType -from DoorShuffle import ExplorationState +from DungeonGenerator import ExplorationState +from Regions import key_only_locations def set_rules(world, player): @@ -257,11 +258,8 @@ def global_rules(world, player): set_rule(world.get_location('Mimic Cave', player), lambda state: state.has('Hammer', player)) # Start of door rando rules - # TODO: Do these need to flag off when door rando is off? - # If these generate fine rules with vanilla shuffle - then no. - - # Escape/ Hyrule Castle - generate_key_logic('Hyrule Castle', 'Small Key (Escape)', world, player) + # TODO: Do these need to flag off when door rando is off? - some of them, yes + add_key_logic_rules(world, player) # todo - vanilla shuffle rules # Eastern Palace # Eyegore room needs a bow @@ -272,7 +270,6 @@ def global_rules(world, player): forbid_item(world.get_location('Eastern Palace - Big Chest', player), 'Big Key (Eastern Palace)', player) set_rule(world.get_entrance('Eastern Big Key NE', player), lambda state: state.has('Big Key (Eastern Palace)', player)) set_rule(world.get_entrance('Eastern Courtyard N', player), lambda state: state.has('Big Key (Eastern Palace)', player)) - generate_key_logic('Eastern Palace', 'Small Key (Eastern Palace)', world, player) # Boss rules. Same as below but no BK or arrow requirement. set_defeat_dungeon_boss_rule(world.get_location('Eastern Palace - Prize', player)) @@ -286,7 +283,6 @@ def global_rules(world, player): set_rule(world.get_entrance('Desert Wall Slide NW', player), lambda state: state.has_fire_source(player)) set_defeat_dungeon_boss_rule(world.get_location('Desert Palace - Prize', player)) set_defeat_dungeon_boss_rule(world.get_location('Desert Palace - Boss', player)) - generate_key_logic('Desert Palace', 'Small Key (Desert Palace)', world, player) # Tower of Hera set_rule(world.get_location('Tower of Hera - Big Chest', player), lambda state: state.has('Big Key (Tower of Hera)', player)) @@ -296,10 +292,8 @@ def global_rules(world, player): set_rule(world.get_entrance('Hera Startile Corner NW', player), lambda state: state.has('Big Key (Tower of Hera)', player)) set_defeat_dungeon_boss_rule(world.get_location('Tower of Hera - Boss', player)) set_defeat_dungeon_boss_rule(world.get_location('Tower of Hera - Prize', player)) - generate_key_logic('Tower of Hera', 'Small Key (Tower of Hera)', world, player) set_rule(world.get_entrance('Tower Altar NW', player), lambda state: state.has_sword(player)) - generate_key_logic('Agahnims Tower', 'Small Key (Agahnims Tower)', world, player) set_rule(world.get_entrance('PoD Mimics 1 NW', player), lambda state: state.can_shoot_arrows(player)) set_rule(world.get_entrance('PoD Mimics 2 NW', player), lambda state: state.can_shoot_arrows(player)) @@ -313,7 +307,6 @@ def global_rules(world, player): set_rule(world.get_entrance('PoD Dark Pegs Up Ladder', player), lambda state: state.has('Hammer', player)) set_defeat_dungeon_boss_rule(world.get_location('Palace of Darkness - Boss', player)) set_defeat_dungeon_boss_rule(world.get_location('Palace of Darkness - Prize', player)) - generate_key_logic('Palace of Darkness', 'Small Key (Palace of Darkness)', world, player) set_rule(world.get_entrance('Swamp Lobby Moat', player), lambda state: state.has('Flippers', player) and state.has('Open Floodgate', player)) set_rule(world.get_entrance('Swamp Trench 1 Approach Dry', player), lambda state: not state.has('Trench 1 Filled', player)) @@ -348,7 +341,6 @@ def global_rules(world, player): forbid_item(world.get_location('Swamp Palace - Big Chest', player), 'Big Key (Swamp Palace)', player) set_defeat_dungeon_boss_rule(world.get_location('Swamp Palace - Boss', player)) set_defeat_dungeon_boss_rule(world.get_location('Swamp Palace - Prize', player)) - generate_key_logic('Swamp Palace', 'Small Key (Swamp Palace)', world, player) set_rule(world.get_entrance('Skull Big Chest Hookpath', player), lambda state: state.has('Hookshot', player)) set_rule(world.get_location('Skull Woods - Big Chest', player), lambda state: state.has('Big Key (Skull Woods)', player)) @@ -358,11 +350,10 @@ def global_rules(world, player): set_rule(world.get_entrance('Skull Vines NW', player), lambda state: state.has_sword(player)) set_defeat_dungeon_boss_rule(world.get_location('Skull Woods - Boss', player)) set_defeat_dungeon_boss_rule(world.get_location('Skull Woods - Prize', player)) - generate_key_logic('Skull Woods', 'Small Key (Skull Woods)', world, player) set_rule(world.get_entrance('Thieves BK Corner NE', player), lambda state: state.has('Big Key (Thieves Town)', player)) # blind can't have the small key? - not necessarily true anymore - but likely still - set_rule(world.get_location('Thieves\' Town - Big Chest', player), lambda state: (state.has('Big Key (Thieves Town)') and state.has('Hammer', player))) + set_rule(world.get_location('Thieves\' Town - Big Chest', player), lambda state: (state.has('Big Key (Thieves Town)', player) and state.has('Hammer', player))) if world.accessibility == 'locations': forbid_item(world.get_location('Thieves\' Town - Big Chest', player), 'Big Key (Thieves Town)', player) for entrance in ['Thieves Basement Block Path', 'Thieves Blocked Entry Path', 'Thieves Conveyor Block Path', 'Thieves Conveyor Bridge Block Path']: @@ -375,7 +366,6 @@ def global_rules(world, player): set_rule(world.get_location('Revealing Light', player), lambda state: state.has('Shining Light', player) and state.has('Maiden Rescued', player)) set_rule(world.get_location('Thieves\' Town - Boss', player), lambda state: state.has('Maiden Unmasked', player) and world.get_location('Thieves\' Town - Boss', player).parent_region.dungeon.boss.can_defeat(state)) set_rule(world.get_location('Thieves\' Town - Prize', player), lambda state: state.has('Maiden Unmasked', player) and world.get_location('Thieves\' Town - Prize', player).parent_region.dungeon.boss.can_defeat(state)) - generate_key_logic('Thieves Town', 'Small Key (Thieves Town)', world, player) # End of door rando rules. @@ -1691,6 +1681,18 @@ def set_inverted_bunny_rules(world, player): add_rule(location, get_rule_to_add(location.parent_region)) +def add_key_logic_rules(world, player): + logger = logging.getLogger('') + key_logic = world.key_logic[player] + for d_name, d_logic in key_logic.items(): + for door_name, keys in d_logic.door_rules.items(): + logger.debug(' %s needs %s keys', door_name, keys) + add_rule(world.get_entrance(door_name, player), create_key_rule(d_logic.small_key_name, player, keys)) + for location in d_logic.bk_restricted: + if location.name not in key_only_locations.keys(): + forbid_item(location, d_logic.bk_name, player) + + def generate_key_logic(dungeon_name, small_key_name, world, player): sector, start_region_names = world.dungeon_layouts[player][dungeon_name] logger = logging.getLogger('')