diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 261dc125..5845e58b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,10 +32,10 @@ jobs: steps: # checkout commit - name: Checkout commit - uses: actions/checkout@v2 + uses: actions/checkout@v3 # install python - name: Install python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} architecture: "x64" @@ -50,21 +50,21 @@ jobs: pip install pyinstaller # get parent directory - name: Get Repo Name - uses: mad9000/actions-find-and-replace-string@1 + uses: mad9000/actions-find-and-replace-string@3 id: repoName with: source: ${{ github.repository }} find: '${{ github.repository_owner }}/' replace: '' - name: Get Parent Directory Path (!Windows) - uses: mad9000/actions-find-and-replace-string@1 + uses: mad9000/actions-find-and-replace-string@3 id: parentDirNotWin with: source: ${{ github.workspace }} find: '${{ steps.repoName.outputs.value }}/${{ steps.repoName.outputs.value }}' replace: ${{ steps.repoName.outputs.value }} - name: Get Parent Directory Path (Windows) - uses: mad9000/actions-find-and-replace-string@1 + uses: mad9000/actions-find-and-replace-string@3 id: parentDir with: source: ${{ steps.parentDirNotWin.outputs.value }} @@ -92,7 +92,7 @@ jobs: python ./resources/ci/common/prepare_binary.py # upload binary artifacts for later step - name: Upload Binary Artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: binaries-${{ matrix.os-name }} path: ${{ steps.parentDir.outputs.value }}/artifact @@ -117,10 +117,10 @@ jobs: steps: # checkout commit - name: Checkout commit - uses: actions/checkout@v2 + uses: actions/checkout@v3 # install python - name: Install Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} architecture: "x64" @@ -134,21 +134,21 @@ jobs: python ./resources/ci/common/install.py # get parent directory - name: Get Repo Name - uses: mad9000/actions-find-and-replace-string@1 + uses: mad9000/actions-find-and-replace-string@3 id: repoName with: source: ${{ github.repository }} find: '${{ github.repository_owner }}/' replace: '' - name: Get Parent Directory Path (!Windows) - uses: mad9000/actions-find-and-replace-string@1 + uses: mad9000/actions-find-and-replace-string@3 id: parentDirNotWin with: source: ${{ github.workspace }} find: '${{ steps.repoName.outputs.value }}/${{ steps.repoName.outputs.value }}' replace: ${{ steps.repoName.outputs.value }} - name: Get Parent Directory Path (Windows) - uses: mad9000/actions-find-and-replace-string@1 + uses: mad9000/actions-find-and-replace-string@3 id: parentDir with: source: ${{ steps.parentDirNotWin.outputs.value }} @@ -156,7 +156,7 @@ jobs: replace: ${{ steps.repoName.outputs.value }} # download binary artifact - name: Download Binary Artifact - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: binaries-${{ matrix.os-name }} path: ./ @@ -170,13 +170,13 @@ jobs: python ./resources/ci/common/prepare_release.py # upload appversion artifact for later step - name: Upload AppVersion Artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: appversion-${{ matrix.os-name }} path: ./resources/app/meta/manifests/app_version.txt # upload archive artifact for later step - name: Upload Archive Artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: archive-${{ matrix.os-name }} path: ${{ steps.parentDir.outputs.value }}/deploy @@ -202,24 +202,24 @@ jobs: steps: # checkout commit - name: Checkout commit - uses: actions/checkout@v2 + uses: actions/checkout@v3 # get parent directory - name: Get Repo Name - uses: mad9000/actions-find-and-replace-string@1 + uses: mad9000/actions-find-and-replace-string@3 id: repoName with: source: ${{ github.repository }} find: '${{ github.repository_owner }}/' replace: '' - name: Get Parent Directory Path (!Windows) - uses: mad9000/actions-find-and-replace-string@1 + uses: mad9000/actions-find-and-replace-string@3 id: parentDirNotWin with: source: ${{ github.workspace }} find: '${{ steps.repoName.outputs.value }}/${{ steps.repoName.outputs.value }}' replace: ${{ steps.repoName.outputs.value }} - name: Get Parent Directory Path (Windows) - uses: mad9000/actions-find-and-replace-string@1 + uses: mad9000/actions-find-and-replace-string@3 id: parentDir with: source: ${{ steps.parentDirNotWin.outputs.value }} @@ -230,25 +230,25 @@ jobs: python -m pip install pytz requests # download appversion artifact - name: Download AppVersion Artifact - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: appversion-${{ matrix.os-name }} path: ${{ steps.parentDir.outputs.value }}/build # download ubuntu archive artifact - name: Download Ubuntu Archive Artifact - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: archive-ubuntu-latest path: ${{ steps.parentDir.outputs.value }}/deploy/linux # download macos archive artifact - name: Download MacOS Archive Artifact - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: archive-macOS-latest path: ${{ steps.parentDir.outputs.value }}/deploy/macos # download windows archive artifact - name: Download Windows Archive Artifact - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: archive-windows-latest path: ${{ steps.parentDir.outputs.value }}/deploy/windows diff --git a/.mailmap b/.mailmap new file mode 100644 index 00000000..1aecc0db --- /dev/null +++ b/.mailmap @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/BaseClasses.py b/BaseClasses.py index 6eb27720..1bc5ba16 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -29,6 +29,8 @@ class World(object): self.doorShuffle = doorShuffle.copy() self.intensity = {} self.door_type_mode = {} + self.trap_door_mode = {} + self.key_logic_algorithm = {} self.logic = logic.copy() self.mode = mode.copy() self.swords = swords.copy() @@ -110,7 +112,8 @@ class World(object): set_player_attr('can_access_trock_front', None) set_player_attr('can_access_trock_big_chest', None) set_player_attr('can_access_trock_middle', None) - set_player_attr('fix_fake_world', logic[player] not in ['owglitches', 'nologic'] or shuffle[player] in ['crossed', 'insanity']) + set_player_attr('fix_fake_world', logic[player] not in ['owglitches', 'nologic'] + or shuffle[player] in ['lean', 'crossed', 'insanity']) set_player_attr('mapshuffle', False) set_player_attr('compassshuffle', False) set_player_attr('keyshuffle', 'none') @@ -139,10 +142,12 @@ class World(object): set_player_attr('pot_contents', None) set_player_attr('pseudoboots', False) set_player_attr('collection_rate', False) - set_player_attr('colorizepots', False) + set_player_attr('colorizepots', True) set_player_attr('pot_pool', {}) set_player_attr('decoupledoors', False) set_player_attr('door_type_mode', 'original') + set_player_attr('trap_door_mode', 'optional') + set_player_attr('key_logic_algorithm', 'default') set_player_attr('shopsanity', False) set_player_attr('mixed_travel', 'prevent') @@ -156,7 +161,9 @@ class World(object): def finish_init(self): for player in range(1, self.players + 1): if self.mode[player] == 'retro': - self.mode[player] == 'open' + self.mode[player] = 'open' + if self.goal[player] == 'completionist': + self.accessibility[player] = 'locations' def get_name_string_for_object(self, obj): return obj.name if self.players == 1 else f'{obj.name} ({self.get_player_names(obj.player)})' @@ -472,7 +479,10 @@ class World(object): if self.has_beaten_game(state): return True - prog_locations = [location for location in self.get_locations() if location.item is not None and (location.item.advancement or location.event) and location not in state.locations_checked] + prog_locations = [location for location in self.get_locations() if location.item is not None + and (location.item.advancement or location.event + or self.goal[location.player] == 'completionist') + and location not in state.locations_checked] while prog_locations: sphere = [] @@ -504,6 +514,7 @@ class CollectionState(object): self.world = parent if not skip_init: self.prog_items = Counter() + self.forced_keys = Counter() self.reachable_regions = {player: dict() for player in range(1, parent.players + 1)} self.blocked_connections = {player: dict() for player in range(1, parent.players + 1)} self.events = [] @@ -518,6 +529,7 @@ class CollectionState(object): self.opened_doors = {player: set() for player in range(1, parent.players + 1)} self.dungeons_to_check = {player: defaultdict(dict) for player in range(1, parent.players + 1)} self.dungeon_limits = None + self.placing_item = None # self.trace = None def update_reachable_regions(self, player): @@ -535,12 +547,13 @@ class CollectionState(object): queue = deque(self.blocked_connections[player].items()) self.traverse_world(queue, rrp, bc, player) - unresolved_events = [x for y in self.reachable_regions[player] for x in y.locations - if x.event and x.item and (x.item.smallkey or x.item.bigkey or x.item.advancement) - and x not in self.locations_checked and x.can_reach(self)] - unresolved_events = self._do_not_flood_the_keys(unresolved_events) - if len(unresolved_events) == 0: - self.check_key_doors_in_dungeons(rrp, player) + if self.world.key_logic_algorithm[player] == 'default': + unresolved_events = [x for y in self.reachable_regions[player] for x in y.locations + if x.event and x.item and (x.item.smallkey or x.item.bigkey or x.item.advancement) + and x not in self.locations_checked and x.can_reach(self)] + unresolved_events = self._do_not_flood_the_keys(unresolved_events) + if len(unresolved_events) == 0: + self.check_key_doors_in_dungeons(rrp, player) def traverse_world(self, queue, rrp, bc, player): # run BFS on all connections, and keep track of those blocked by missing items @@ -632,6 +645,7 @@ class CollectionState(object): def check_key_doors_in_dungeons(self, rrp, player): for dungeon_name, checklist in self.dungeons_to_check[player].items(): + # todo: optimization idea - abort exploration if there are unresolved events now if self.apply_dungeon_exploration(rrp, player, dungeon_name, checklist): continue init_door_candidates = self.should_explore_child_state(self, dungeon_name, player) @@ -740,6 +754,15 @@ class CollectionState(object): rrp[k] = missing_regions[k] possible_path = terminal_states[0].path[k] self.path[k] = paths[k] = possible_path + for conn in k.exits: + if self.is_small_door(conn): + door = conn.door if conn.door.smallKey else conn.door.controller + key_logic = self.world.key_logic[player][dungeon_name] + if door.name not in self.reached_doors[player]: + self.door_counter[player][0][dungeon_name] += 1 + self.reached_doors[player].add(door.name) + if key_logic.sm_doors[door]: + self.reached_doors[player].add(key_logic.sm_doors[door].name) missing_bc = {} for blocked, crystal in common_bc.items(): if (blocked not in bc and blocked.parent_region in rrp @@ -790,7 +813,23 @@ class CollectionState(object): else: door_candidates.append(door.name) return door_candidates - return None + door_candidates, skip = [], set() + if (state.world.accessibility[player] != 'locations' and remaining_keys == 0 and dungeon_name != 'Universal' + and state.placing_item and state.placing_item.name == small_key_name): + key_logic = state.world.key_logic[player][dungeon_name] + for door, paired in key_logic.sm_doors.items(): + if door.name in key_logic.door_rules: + rule = key_logic.door_rules[door.name] + key = KeyRuleType.AllowSmall + if (key in rule.new_rules and key_total >= rule.new_rules[key] and door.name not in skip + and door.name in state.reached_doors[player] and door.name not in state.opened_doors[player] + and rule.small_location.item is None): + if paired: + door_candidates.append((door.name, paired.name)) + skip.add(paired.name) + else: + door_candidates.append(door.name) + return door_candidates if door_candidates else None @staticmethod def print_rrp(rrp): @@ -806,6 +845,7 @@ class CollectionState(object): def copy(self): ret = CollectionState(self.world, skip_init=True) ret.prog_items = self.prog_items.copy() + ret.forced_keys = self.forced_keys.copy() ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in range(1, self.world.players + 1)} ret.blocked_connections = {player: copy.copy(self.blocked_connections[player]) for player in range(1, self.world.players + 1)} ret.events = copy.copy(self.events) @@ -820,6 +860,7 @@ class CollectionState(object): player: defaultdict(dict, {name: copy.copy(checklist) for name, checklist in self.dungeons_to_check[player].items()}) for player in range(1, self.world.players + 1)} + ret.placing_item = self.placing_item return ret def apply_dungeon_exploration(self, rrp, player, dungeon_name, checklist): @@ -929,29 +970,30 @@ class CollectionState(object): checked_locations = set([l for l in locations if l in self.locations_checked]) reachable_events = [location for location in locations if location.event and location.can_reach(self)] reachable_events = self._do_not_flood_the_keys(reachable_events) + found_new = False for event in reachable_events: if event not in checked_locations: self.events.append((event.name, event.player)) self.collect(event.item, True, event) - return len(reachable_events) > len(checked_locations) + found_new = True + return found_new def sweep_for_events(self, key_only=False, locations=None): # this may need improvement if locations is None: locations = self.world.get_filled_locations() new_locations = True - checked_locations = 0 while new_locations: reachable_events = [location for location in locations if location.event and (not key_only or (self.world.keyshuffle[location.item.player] == 'none' and location.item.smallkey) or (not self.world.bigkeyshuffle[location.item.player] and location.item.bigkey)) and location.can_reach(self)] reachable_events = self._do_not_flood_the_keys(reachable_events) + new_locations = False for event in reachable_events: if (event.name, event.player) not in self.events: self.events.append((event.name, event.player)) self.collect(event.item, True, event) - new_locations = len(reachable_events) > checked_locations - checked_locations = len(reachable_events) + new_locations = True def can_reach_blue(self, region, player): @@ -1011,6 +1053,14 @@ class CollectionState(object): return (item, player) in self.prog_items return self.prog_items[item, player] >= count + def has_sm_key_strict(self, item, player, count=1): + if self.world.keyshuffle[player] == 'universal': + if self.world.mode[player] == 'standard' and self.world.doorShuffle[player] == 'vanilla' and item == 'Small Key (Escape)': + return True # Cannot access the shop until escape is finished. This is safe because the key is manually placed in make_custom_item_pool + return self.can_buy_unlimited('Small Key (Universal)', player) + obtained = self.prog_items[item, player] - self.forced_keys[item, player] + return obtained >= count + def can_buy_unlimited(self, item, player): for shop in self.world.shops[player]: if shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(self): @@ -1020,6 +1070,12 @@ class CollectionState(object): def item_count(self, item, player): return self.prog_items[item, player] + def everything(self, player): + all_locations = self.world.get_filled_locations(player) + all_locations.remove(self.world.get_location('Ganon', player)) + return (len([x for x in self.locations_checked if x.player == player]) + >= len(all_locations)) + def has_crystals(self, count, player): crystals = ['Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'] return len([crystal for crystal in crystals if self.has(crystal, player)]) >= count @@ -1137,6 +1193,8 @@ class CollectionState(object): return self.has('Fire Rod', player) or self.has('Lamp', player) def can_flute(self, player): + if self.world.mode[player] == 'standard' and not self.has('Zelda Delivered', player): + return False # can't flute in rain state if any(map(lambda i: i.name in ['Ocarina', 'Ocarina (Activated)'], self.world.precollected_items)): return True lw = self.world.get_region('Light World', player) @@ -1199,6 +1257,8 @@ class CollectionState(object): def collect(self, item, event=False, location=None): if location: self.locations_checked.add(location) + if item and item.smallkey and location.forced_item is not None: + self.forced_keys[item.name, item.player] += 1 if not item: return changed = False @@ -1385,10 +1445,10 @@ class Region(object): or (item.bigkey and not self.world.bigkeyshuffle[item.player]) or (item.map and not self.world.mapshuffle[item.player]) or (item.compass and not self.world.compassshuffle[item.player])) - sewer_hack = self.world.mode[item.player] == 'standard' and item.name == 'Small Key (Escape)' - if sewer_hack or inside_dungeon_item: + # not all small keys to escape must be in escape + # sewer_hack = self.world.mode[item.player] == 'standard' and item.name == 'Small Key (Escape)' + if inside_dungeon_item: return self.dungeon and self.dungeon.is_dungeon_item(item) and item.player == self.player - return True def __str__(self): @@ -1849,6 +1909,9 @@ class Door(object): return world.get_room(self.roomIndex, self.player).kind(self) return None + def dungeon_name(self): + return self.entrance.parent_region.dungeon.name if self.entrance.parent_region.dungeon else 'Cave' + def __eq__(self, other): return isinstance(other, self.__class__) and self.name == other.name @@ -2260,6 +2323,9 @@ class Item(object): def __unicode__(self): return self.world.get_name_string_for_object(self) if self.world else f'{self.name} (Player {self.player})' + def __eq__(self, other): + return self.name == other.name and self.player == other.player + # have 6 address that need to be filled class Crystal(Item): @@ -2395,6 +2461,8 @@ class Spoiler(object): 'door_shuffle': self.world.doorShuffle, 'intensity': self.world.intensity, 'door_type_mode': self.world.door_type_mode, + 'trap_door_mode': self.world.trap_door_mode, + 'key_logic': self.world.key_logic_algorithm, 'decoupledoors': self.world.decoupledoors, 'dungeon_counters': self.world.dungeon_counters, 'item_pool': self.world.difficulty, @@ -2574,7 +2642,7 @@ class Spoiler(object): outfile.write('Mode: %s\n' % self.metadata['mode'][player]) outfile.write('Swords: %s\n' % self.metadata['weapons'][player]) outfile.write('Goal: %s\n' % self.metadata['goal'][player]) - if self.metadata['goal'][player] in ['triforcehunt', 'trinity']: + if self.metadata['goal'][player] in ['triforcehunt', 'trinity', 'ganonhunt']: outfile.write('Triforce Pieces Required: %s\n' % self.metadata['triforcegoal'][player]) outfile.write('Triforce Pieces Total: %s\n' % self.metadata['triforcepool'][player]) outfile.write('Crystals required for GT: %s\n' % (str(self.world.crystals_gt_orig[player]))) @@ -2595,13 +2663,14 @@ class Spoiler(object): outfile.write(f"GT/Ganon Shuffled: {yn(self.metadata['shuffleganon'])}\n") outfile.write(f"Overworld Map: {self.metadata['overworld_map'][player]}\n") outfile.write(f"Take Any Caves: {self.metadata['take_any'][player]}\n") - outfile.write(f"Overworld Map: {self.metadata['overworld_map'][player]}\n") if self.metadata['goal'][player] != 'trinity': outfile.write('Pyramid hole pre-opened: %s\n' % ('Yes' if self.metadata['open_pyramid'][player] else 'No')) outfile.write('Door Shuffle: %s\n' % self.metadata['door_shuffle'][player]) if self.metadata['door_shuffle'][player] != 'vanilla': outfile.write(f"Intensity: {self.metadata['intensity'][player]}\n") outfile.write(f"Door Type Mode: {self.metadata['door_type_mode'][player]}\n") + outfile.write(f"Trap Door Mode: {self.metadata['trap_door_mode'][player]}\n") + outfile.write(f"Key Logic Algorithm: {self.metadata['key_logic'][player]}\n") outfile.write(f"Decouple Doors: {yn(self.metadata['decoupledoors'][player])}\n") outfile.write(f"Experimental: {yn(self.metadata['experimental'][player])}\n") outfile.write(f"Dungeon Counters: {self.metadata['dungeon_counters'][player]}\n") @@ -2617,6 +2686,7 @@ class Spoiler(object): outfile.write('Enemy health: %s\n' % self.metadata['enemy_health'][player]) outfile.write('Enemy damage: %s\n' % self.metadata['enemy_damage'][player]) outfile.write(f"Hints: {yn(self.metadata['hints'][player])}\n") + outfile.write('Race: %s\n' % ('Yes' if self.world.settings.world_rep['meta']['race'] else 'No')) if self.startinventory: outfile.write('Starting Inventory:'.ljust(line_width)) @@ -2854,7 +2924,8 @@ world_mode = {"open": 0, "standard": 1, "inverted": 2} sword_mode = {"random": 0, "assured": 1, "swordless": 2, "vanilla": 3} # byte 2: GGGD DFFH (goal, diff, item_func, hints) -goal_mode = {'ganon': 0, 'pedestal': 1, 'dungeons': 2, 'triforcehunt': 3, 'crystals': 4, 'trinity': 5} +goal_mode = {'ganon': 0, 'pedestal': 1, 'dungeons': 2, 'triforcehunt': 3, 'crystals': 4, 'trinity': 5, + 'ganonhunt': 6, 'completionist': 7} diff_mode = {"normal": 0, "hard": 1, "expert": 2} func_mode = {"normal": 0, "hard": 1, "expert": 2} @@ -2892,15 +2963,19 @@ boss_mode = {"none": 0, "simple": 1, "full": 2, "chaos": 3, 'random': 3, 'unique # byte 10: settings_version -# byte 11: FBBB, TTSS (flute_mode, bow_mode, take_any, small_key_mode) +# byte 11: FBBB TTSS (flute_mode, bow_mode, take_any, small_key_mode) flute_mode = {'normal': 0, 'active': 1} keyshuffle_mode = {'none': 0, 'wild': 1, 'universal': 2} # reserved 8 modes? take_any_mode = {'none': 0, 'random': 1, 'fixed': 2} -bow_mode = {'progressive': 0, 'silvers': 1, 'retro': 2, 'retro_silver': 3} +bow_mode = {'progressive': 0, 'silvers': 1, 'retro': 2, 'retro_silvers': 3} # additions -# psuedoboots does not effect code -# sfx_shuffle and other adjust items does not effect settings code +# byte 12: POOT TKKK (pseudoboots, overworld_map, trap_door_mode, key_logic_algo) +overworld_map_mode = {'default': 0, 'compass': 1, 'map': 2} +trap_door_mode = {'vanilla': 0, 'optional': 1, 'boss': 2, 'oneway': 3} +key_logic_algo = {'default': 0, 'partial': 1, 'strict': 2} + +# sfx_shuffle and other adjust items does not affect settings code # Bump this when making changes that are not backwards compatible (nearly all of them) settings_version = 1 @@ -2944,7 +3019,10 @@ class Settings(object): settings_version, (flute_mode[w.flute_mode[p]] << 7 | bow_mode[w.bow_mode[p]] << 4 - | take_any_mode[w.take_any[p]] << 2 | keyshuffle_mode[w.keyshuffle[p]]) + | take_any_mode[w.take_any[p]] << 2 | keyshuffle_mode[w.keyshuffle[p]]), + + ((0x80 if w.pseudoboots[p] else 0) | overworld_map_mode[w.overworld_map[p]] << 6 + | trap_door_mode[w.trap_door_mode[p]] << 4 | key_logic_algo[w.key_logic_algorithm[p]]), ]) return base64.b64encode(code, "+-".encode()).decode() @@ -3012,6 +3090,11 @@ class Settings(object): args.bow_mode[p] = r(bow_mode)[(settings[11] & 0x70) >> 4] args.take_any[p] = r(take_any_mode)[(settings[11] & 0xC) >> 2] args.keyshuffle[p] = r(keyshuffle_mode)[settings[11] & 0x3] + if len(settings) > 12: + args.pseudoboots[p] = True if settings[12] & 0x80 else False + args.overworld_map[p] = r(overworld_map_mode)[(settings[12] & 0x60) >> 6] + args.trap_door_mode[p] = r(trap_door_mode)[(settings[12] & 0x14) >> 4] + args.key_logic_algorithm[p] = r(key_logic_algo)[settings[12] & 0x07] class KeyRuleType(FastEnum): diff --git a/CLI.py b/CLI.py index 272d0bca..a07982a0 100644 --- a/CLI.py +++ b/CLI.py @@ -122,9 +122,14 @@ def parse_cli(argv, no_defaults=False): defaults = copy.deepcopy(ret) for player in range(1, player_num + 1): playerargs = parse_cli(shlex.split(getattr(ret, f"p{player}")), True) + + if playerargs.filename: + playersettings = apply_settings_file({}, playerargs.filename) + for k, v in playersettings.items(): + setattr(playerargs, k, v) for name in ['logic', 'mode', 'swords', 'goal', 'difficulty', 'item_functionality', - 'flute_mode', 'bow_mode', 'take_any', + 'flute_mode', 'bow_mode', 'take_any', 'boots_hint', 'shuffle', 'door_shuffle', 'intensity', 'crystals_ganon', 'crystals_gt', 'openpyramid', 'mapshuffle', 'compassshuffle', 'keyshuffle', 'bigkeyshuffle', 'startinventory', 'usestartinventory', 'bombbag', 'overworld_map', 'restrict_boss_items', @@ -135,7 +140,8 @@ 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', 'decoupledoors', 'door_type_mode']: + 'msu_resume', 'collection_rate', 'colorizepots', 'decoupledoors', 'door_type_mode', + 'trap_door_mode', 'key_logic_algorithm']: value = getattr(defaults, name) if getattr(playerargs, name) is None else getattr(playerargs, name) if player == 1: setattr(ret, name, {1: value}) @@ -161,6 +167,7 @@ def parse_settings(): "retro": False, "bombbag": False, "mode": "open", + "boots_hint": False, "logic": "noglitches", "goal": "ganon", "crystals_gt": "7", @@ -198,7 +205,7 @@ def parse_settings(): 'keydropshuffle': False, 'dropshuffle': 'none', 'pottery': 'none', - 'colorizepots': False, + 'colorizepots': True, 'shufflepots': False, 'mapshuffle': False, 'compassshuffle': False, @@ -208,6 +215,8 @@ def parse_settings(): 'door_shuffle': 'vanilla', 'intensity': 2, 'door_type_mode': 'original', + 'trap_door_mode': 'optional', + 'key_logic_algorithm': 'default', 'decoupledoors': False, 'experimental': False, 'dungeon_counters': 'default', diff --git a/DoorShuffle.py b/DoorShuffle.py index a025349b..5e3d4b8d 100644 --- a/DoorShuffle.py +++ b/DoorShuffle.py @@ -6,7 +6,7 @@ from enum import unique, Flag from typing import DefaultDict, Dict, List from itertools import chain -from BaseClasses import RegionType, Region, Door, DoorType, Direction, Sector, CrystalBarrier, DungeonInfo, dungeon_keys +from BaseClasses import RegionType, Region, Door, DoorType, Sector, CrystalBarrier, DungeonInfo, dungeon_keys from BaseClasses import PotFlags, LocationType, Direction from Doors import reset_portals from Dungeons import dungeon_regions, region_starts, standard_starts, split_region_starts @@ -15,13 +15,12 @@ from Items import ItemFactory from RoomData import DoorKind, PairedDoor, reset_rooms from source.dungeon.DungeonStitcher import GenerationException, generate_dungeon from source.dungeon.DungeonStitcher import ExplorationState as ExplorationState2 -# from DungeonGenerator import generate_dungeon -from DungeonGenerator import ExplorationState, convert_regions, pre_validate, determine_required_paths, drop_entrances +from DungeonGenerator import ExplorationState, convert_regions, 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, connect_doors, count_reserved_locations from DungeonGenerator import valid_region_to_explore from KeyDoorShuffle import analyze_dungeon, build_key_layout, validate_key_layout, determine_prize_lock -from KeyDoorShuffle import validate_bk_layout, check_bk_special +from KeyDoorShuffle import validate_bk_layout from Utils import ncr, kth_combination @@ -129,7 +128,7 @@ def link_doors_prep(world, player): vanilla_key_logic(world, player) -def link_doors_main(world, player): +def create_dungeon_pool(world, player): pool = None if world.doorShuffle[player] == 'basic': pool = [([name], regions) for name, regions in dungeon_regions.items()] @@ -143,6 +142,11 @@ def link_doors_main(world, player): elif world.doorShuffle[player] != 'vanilla': logging.getLogger('').error('Invalid door shuffle setting: %s' % world.doorShuffle[player]) raise Exception('Invalid door shuffle setting: %s' % world.doorShuffle[player]) + return pool + + +def link_doors_main(world, player): + pool = create_dungeon_pool(world, player) if pool: main_dungeon_pool(pool, world, player) if world.doorShuffle[player] != 'vanilla': @@ -226,21 +230,37 @@ def vanilla_key_logic(world, player): add_inaccessible_doors(world, player) entrances_map, potentials, connections = determine_entrance_list(world, player) - for builder in builders: + enabled_entrances = world.enabled_entrances[player] = {} + builder_queue = deque(builders) + last_key, loops = None, 0 + while len(builder_queue) > 0: + builder = builder_queue.popleft() origin_list = entrances_map[builder.name] - start_regions = convert_regions(origin_list, world, player) - doors = convert_key_doors(default_small_key_doors[builder.name], world, player) - key_layout = build_key_layout(builder, start_regions, doors, world, player) - valid = validate_key_layout(key_layout, world, player) - if not valid: - logging.getLogger('').info('Vanilla key layout not valid %s', builder.name) - builder.key_door_proposal = doors - if player not in world.key_logic.keys(): - world.key_logic[player] = {} - analyze_dungeon(key_layout, world, player) - world.key_logic[player][builder.name] = key_layout.key_logic - world.key_layout[player][builder.name] = key_layout - log_key_logic(builder.name, key_layout.key_logic) + find_enabled_origins(builder.sectors, enabled_entrances, origin_list, entrances_map, builder.name) + if len(origin_list) <= 0: + if last_key == builder.name or loops > 1000: + origin_name = (world.get_region(origin_list[0], player).entrances[0].parent_region.name + if len(origin_list) > 0 else 'no origin') + raise GenerationException(f'Infinite loop detected for "{builder.name}" located at {origin_name}') + builder_queue.append(builder) + last_key = builder.name + loops += 1 + else: + find_new_entrances(builder.master_sector, entrances_map, connections, potentials, + enabled_entrances, world, player) + start_regions = convert_regions(origin_list, world, player) + doors = convert_key_doors(default_small_key_doors[builder.name], world, player) + key_layout = build_key_layout(builder, start_regions, doors, {}, world, player) + valid = validate_key_layout(key_layout, world, player) + if not valid: + logging.getLogger('').info('Vanilla key layout not valid %s', builder.name) + builder.key_door_proposal = doors + if player not in world.key_logic.keys(): + world.key_logic[player] = {} + analyze_dungeon(key_layout, world, player) + world.key_logic[player][builder.name] = key_layout.key_logic + world.key_layout[player][builder.name] = key_layout + log_key_logic(builder.name, key_layout.key_logic) # if world.shuffle[player] == 'vanilla' and world.accessibility[player] == 'items' and not world.retro[player] and not world.keydropshuffle[player]: # validate_vanilla_key_logic(world, player) @@ -477,14 +497,14 @@ def choose_portals(world, player): info.required_passage = {x: y for x, y in info.required_passage.items() if len(y) > 0} for target_region, possible_portals in info.required_passage.items(): candidates = find_portal_candidates(master_door_list, dungeon, custom, allowed, need_passage=True, - bk_shuffle=bk_shuffle, rupee_bow=rupee_bow_flag) + bk_shuffle=bk_shuffle, standard=std_flag, rupee_bow=rupee_bow_flag) choice, portal = assign_portal(candidates, possible_portals, custom, world, player) portal.destination = True clean_up_portal_assignment(portal_assignment, dungeon, portal, master_door_list, outstanding_portals) dead_end_choices = info.total - 1 - len(portal_assignment[dungeon]) for i in range(0, dead_end_choices): candidates = find_portal_candidates(master_door_list, dungeon, custom, allowed, dead_end_allowed=True, - bk_shuffle=bk_shuffle, rupee_bow=rupee_bow_flag) + bk_shuffle=bk_shuffle, standard=std_flag, rupee_bow=rupee_bow_flag) possible_portals = outstanding_portals if not info.sole_entrance else [x for x in outstanding_portals if x != info.sole_entrance] choice, portal = assign_portal(candidates, possible_portals, custom, world, player) if choice.deadEnd: @@ -542,7 +562,9 @@ def customizer_portals(master_door_list, world, player): custom_doors = world.customizer.get_doors()[player] if custom_doors and 'lobbies' in custom_doors: for portal, assigned_door in custom_doors['lobbies'].items(): - door = next(x for x in master_door_list if x.name == assigned_door) + door = next((x for x in master_door_list if x.name == assigned_door), None) + if door is None: + raise Exception(f'{assigned_door} not found. Check for typos') custom_portals[portal] = door assigned_doors.add(door) if custom_doors and 'doors' in custom_doors: @@ -552,9 +574,27 @@ def customizer_portals(master_door_list, world, player): if isinstance(dest, str): door = world.get_door(dest, player) assigned_doors.add(door) - else: + elif 'dest' in dest: door = world.get_door(dest['dest'], player) assigned_doors.add(door) + # restricts connected doors to the customized portals + if assigned_doors: + pool = create_dungeon_pool(world, player) + if pool: + pool_map = {} + for pool, region_list in pool: + sector_pool = convert_to_sectors(region_list, world, player) + merge_sectors(sector_pool, world, player) + for p in pool: + pool_map[p] = sector_pool + for portal, assigned_door in custom_portals.items(): + portal_region = world.get_door(assigned_door, player).entrance.parent_region + portal_dungeon = world.get_region(f'{portal} Portal', player).dungeon.name + sector_pool = pool_map[portal_dungeon] + sector = next((s for s in sector_pool if portal_region in s.regions), None) + for door in sector.outstanding_doors: + if door.portalAble: + door.dungeonLink = portal_dungeon return custom_portals, assigned_doors @@ -798,15 +838,18 @@ def main_dungeon_pool(dungeon_pool, world, player): hc = world.get_dungeon('Hyrule Castle', player) hc_compass = ItemFactory('Compass (Escape)', player) hc_compass.advancement = world.restrict_boss_items[player] != 'none' - hc.dungeon_items.append(hc_compass) + if hc.dungeon_items.count(hc_compass) < 1: + hc.dungeon_items.append(hc_compass) if 'Agahnims Tower' in pool: at = world.get_dungeon('Agahnims Tower', player) at_compass = ItemFactory('Compass (Agahnims Tower)', player) at_compass.advancement = world.restrict_boss_items[player] != 'none' - at.dungeon_items.append(at_compass) + if at.dungeon_items.count(at_compass) < 1: + at.dungeon_items.append(at_compass) at_map = ItemFactory('Map (Agahnims Tower)', player) at_map.advancement = world.restrict_boss_items[player] != 'none' - at.dungeon_items.append(at_map) + if at.dungeon_items.count(at_map) < 1: + at.dungeon_items.append(at_map) sector_pool = convert_to_sectors(region_list, world, player) merge_sectors(sector_pool, world, player) # todo: which dungeon to create @@ -831,7 +874,8 @@ def main_dungeon_pool(dungeon_pool, world, player): for name in pool: builder = world.dungeon_layouts[player][name] region_set = builder.master_sector.region_set() - builder.bk_required = builder.bk_door_proposal or any(x in region_set for x in special_bk_regions) + builder.bk_required = (builder.bk_door_proposal or any(x in region_set for x in special_bk_regions) + or len(world.key_logic[player][name].bk_chests) > 0) dungeon = world.get_dungeon(name, player) if not builder.bk_required or builder.bk_provided: dungeon.big_key = None @@ -1018,7 +1062,8 @@ def main_dungeon_generation(dungeon_builders, recombinant_builders, connections_ find_standard_origins(builder, recombinant_builders, origin_list) find_enabled_origins(builder.sectors, enabled_entrances, origin_list, entrances_map, name) split_dungeon = treat_split_as_whole_dungeon(split_dungeon, name, origin_list, world, player) - if len(origin_list) <= 0 or not pre_validate(builder, origin_list, split_dungeon, world, player): + # todo: figure out pre-validate, ensure all needed origins are enabled? + if len(origin_list) <= 0: # or not pre_validate(builder, origin_list, split_dungeon, world, player): if last_key == builder.name or loops > 1000: origin_name = world.get_region(origin_list[0], player).entrances[0].parent_region.name if len(origin_list) > 0 else 'no origin' raise GenerationException(f'Infinite loop detected for "{builder.name}" located at {origin_name}') @@ -1068,14 +1113,14 @@ def determine_entrance_list(world, player): connections = {} for key, portal_list in dungeon_portals.items(): entrance_map[key] = [] - r_names = {} + r_names = [] if key in dungeon_drops.keys(): for drop in dungeon_drops[key]: - r_names[drop] = None + r_names.append((drop, None)) for portal_name in portal_list: portal = world.get_portal(portal_name, player) - r_names[portal.door.entrance.parent_region.name] = portal - for region_name, portal in r_names.items(): + r_names.append((portal.door.entrance.parent_region.name, portal)) + for region_name, portal in r_names: if portal: region = world.get_region(portal.name + ' Portal', player) else: @@ -1236,10 +1281,13 @@ def cross_dungeon(world, player): if world.restrict_boss_items[player] != 'none': hc_compass.advancement = at_compass.advancement = at_map.advancement = True hc = world.get_dungeon('Hyrule Castle', player) - hc.dungeon_items.append(hc_compass) + if hc.dungeon_items.count(hc_compass) < 1: + hc.dungeon_items.append(hc_compass) at = world.get_dungeon('Agahnims Tower', player) - at.dungeon_items.append(at_compass) - at.dungeon_items.append(at_map) + if at.dungeon_items.count(at_compass) < 1: + at.dungeon_items.append(at_compass) + if at.dungeon_items.count(at_map) < 1: + at.dungeon_items.append(at_map) setup_custom_door_types(world, player) assign_cross_keys(dungeon_builders, world, player) @@ -1695,7 +1743,6 @@ def setup_custom_door_types(world, player): custom_doors = custom_doors[player] if 'doors' not in custom_doors: return - # todo: dash/bomb door pool specific customizeable_types = ['Key Door', 'Dash Door', 'Bomb Door', 'Trap Door', 'Big Key Door'] world.custom_door_types[player] = type_map = {x: defaultdict(list) for x in customizeable_types} for door, dest in custom_doors['doors'].items(): @@ -1707,8 +1754,8 @@ def setup_custom_door_types(world, player): if d.type == DoorType.SpiralStairs: type_map[door_kind][dungeon.name].append(d) else: - # check if the - if d.dest.type in [DoorType.Interior, DoorType.Normal]: + # check if the dest is paired + if d.dest and d.dest.type in [DoorType.Interior, DoorType.Normal] and door_kind != 'Trap Door': type_map[door_kind][dungeon.name].append((d, d.dest)) else: type_map[door_kind][dungeon.name].append(d) @@ -1737,12 +1784,12 @@ class DoorTypePool: self.tricky += counts[6] def chaos_shuffle(self, counts): - weights = [1, 2, 4, 3, 2, 1] + weights = [1, 2, 4, 3, 2] return [random.choices(self.get_choices(counts[i]), weights=weights)[0] for i, c in enumerate(counts)] @staticmethod def get_choices(number): - return [max(number-i, 0) for i in range(-1, 5)] + return [max(number+i, 0) for i in range(-1, 4)] class BuilderDoorCandidates: @@ -1756,88 +1803,103 @@ class BuilderDoorCandidates: def shuffle_door_types(door_type_pools, paths, world, player): start_regions_map = {} for name, builder in world.dungeon_layouts[player].items(): - start_regions = convert_regions(builder.path_entrances, world, player) + start_regions = convert_regions(find_possible_entrances(world, player, builder), world, player) start_regions_map[name] = start_regions builder.candidates = BuilderDoorCandidates() + all_custom = defaultdict(list) + if player in world.custom_door_types: + for custom_dict in world.custom_door_types[player].values(): + for dungeon, doors in custom_dict.items(): + all_custom[dungeon].extend(doors) + world.paired_doors[player].clear() - used_doors = shuffle_trap_doors(door_type_pools, paths, start_regions_map, world, player) + used_doors = shuffle_trap_doors(door_type_pools, paths, start_regions_map, all_custom, world, player) # big keys - used_doors = shuffle_big_key_doors(door_type_pools, used_doors, start_regions_map, world, player) + used_doors = shuffle_big_key_doors(door_type_pools, used_doors, start_regions_map, all_custom, world, player) # small keys - used_doors = shuffle_small_key_doors(door_type_pools, used_doors, start_regions_map, world, player) + used_doors = shuffle_small_key_doors(door_type_pools, used_doors, start_regions_map, all_custom, world, player) # bombable / dashable - used_doors = shuffle_bomb_dash_doors(door_type_pools, used_doors, start_regions_map, world, player) + used_doors = shuffle_bomb_dash_doors(door_type_pools, used_doors, start_regions_map, all_custom, world, player) # handle paired list -def shuffle_trap_doors(door_type_pools, paths, start_regions_map, world, player): +def shuffle_trap_doors(door_type_pools, paths, start_regions_map, all_custom, world, player): used_doors = set() for pool, door_type_pool in door_type_pools: - ttl = 0 - suggestion_map, trap_map, flex_map = {}, {}, {} - remaining = door_type_pool.traps - if player in world.custom_door_types: - custom_trap_doors = world.custom_door_types[player]['Trap Door'] - else: - custom_trap_doors = defaultdict(list) - - for dungeon in pool: - builder = world.dungeon_layouts[player][dungeon] - find_trappable_candidates(builder, world, player) - if custom_trap_doors[dungeon]: - builder.candidates.trap = filter_key_door_pool(builder.candidates.trap, custom_trap_doors[dungeon]) - remaining -= len(custom_trap_doors[dungeon]) - ttl += len(builder.candidates.trap) - if ttl == 0: - continue - for dungeon in pool: - builder = world.dungeon_layouts[player][dungeon] - proportion = len(builder.candidates.trap) - calc = int(round(proportion * door_type_pool.traps/ttl)) - suggested = min(proportion, calc) - remaining -= suggested - suggestion_map[dungeon] = suggested - flex_map[dungeon] = (proportion - suggested) if suggested < proportion else 0 - for dungeon in pool: - builder = world.dungeon_layouts[player][dungeon] - valid_traps, trap_number = find_valid_trap_combination(builder, suggestion_map[dungeon], - start_regions_map[dungeon], paths, world, player, - drop=True) - trap_map[dungeon] = valid_traps - if trap_number < suggestion_map[dungeon]: - flex_map[dungeon] = 0 - remaining += suggestion_map[dungeon] - trap_number - suggestion_map[dungeon] = trap_number - builder_order = [x for x in pool if flex_map[x] > 0] - random.shuffle(builder_order) - queue = deque(builder_order) - while len(queue) > 0 and remaining > 0: - dungeon = queue.popleft() - builder = world.dungeon_layouts[player][dungeon] - increased = suggestion_map[dungeon] + 1 - valid_traps, trap_number = find_valid_trap_combination(builder, increased, start_regions_map[dungeon], - paths, world, player) - if valid_traps: + if world.trap_door_mode[player] != 'oneway': + ttl = 0 + suggestion_map, trap_map, flex_map = {}, {}, {} + remaining = door_type_pool.traps + if player in world.custom_door_types and 'Trap Door' in world.custom_door_types[player]: + custom_trap_doors = world.custom_door_types[player]['Trap Door'] + else: + custom_trap_doors = defaultdict(list) + for dungeon in pool: + builder = world.dungeon_layouts[player][dungeon] + if 'Mire Warping Pool' in builder.master_sector.region_set(): + custom_trap_doors[dungeon].append(world.get_door('Mire Warping Pool ES', player)) + world.custom_door_types[player]['Trap Door'] = custom_trap_doors + find_trappable_candidates(builder, world, player) + if all_custom[dungeon]: + builder.candidates.trap = filter_key_door_pool(builder.candidates.trap, all_custom[dungeon]) + remaining -= len(custom_trap_doors[dungeon]) + ttl += len(builder.candidates.trap) + if ttl == 0: + continue + for dungeon in pool: + builder = world.dungeon_layouts[player][dungeon] + proportion = len(builder.candidates.trap) + calc = int(round(proportion * door_type_pool.traps/ttl)) + suggested = min(proportion, calc) + remaining -= suggested + suggestion_map[dungeon] = suggested + flex_map[dungeon] = (proportion - suggested) if suggested < proportion else 0 + for dungeon in pool: + builder = world.dungeon_layouts[player][dungeon] + valid_traps, trap_number = find_valid_trap_combination(builder, suggestion_map[dungeon], + start_regions_map[dungeon], paths, world, player, + drop=True) trap_map[dungeon] = valid_traps - remaining -= 1 - suggestion_map[dungeon] = increased - flex_map[dungeon] -= 1 - if flex_map[dungeon] > 0: - queue.append(dungeon) - # time to re-assign + if trap_number < suggestion_map[dungeon]: + flex_map[dungeon] = 0 + remaining += suggestion_map[dungeon] - trap_number + suggestion_map[dungeon] = trap_number + builder_order = [x for x in pool if flex_map[x] > 0] + random.shuffle(builder_order) + queue = deque(builder_order) + while len(queue) > 0 and remaining > 0: + dungeon = queue.popleft() + builder = world.dungeon_layouts[player][dungeon] + increased = suggestion_map[dungeon] + 1 + valid_traps, trap_number = find_valid_trap_combination(builder, increased, start_regions_map[dungeon], + paths, world, player) + if valid_traps: + trap_map[dungeon] = valid_traps + remaining -= 1 + suggestion_map[dungeon] = increased + flex_map[dungeon] -= 1 + if flex_map[dungeon] > 0: + queue.append(dungeon) + # time to re-assign + else: + trap_map = {dungeon: [] for dungeon in pool} + for dungeon in pool: + builder = world.dungeon_layouts[player][dungeon] + if 'Mire Warping Pool' in builder.master_sector.region_set(): + trap_map[dungeon].append(world.get_door('Mire Warping Pool ES', player)) reassign_trap_doors(trap_map, world, player) for name, traps in trap_map.items(): used_doors.update(traps) return used_doors -def shuffle_big_key_doors(door_type_pools, used_doors, start_regions_map, world, player): +def shuffle_big_key_doors(door_type_pools, used_doors, start_regions_map, all_custom, world, player): for pool, door_type_pool in door_type_pools: ttl = 0 suggestion_map, bk_map, flex_map = {}, {}, {} remaining = door_type_pool.bigs - if player in world.custom_door_types: + if player in world.custom_door_types and 'Big Key Door' in world.custom_door_types[player]: custom_bk_doors = world.custom_door_types[player]['Big Key Door'] else: custom_bk_doors = defaultdict(list) @@ -1845,16 +1907,17 @@ def shuffle_big_key_doors(door_type_pools, used_doors, start_regions_map, world, for dungeon in pool: builder = world.dungeon_layouts[player][dungeon] find_big_key_candidates(builder, start_regions_map[dungeon], used_doors, world, player) - if custom_bk_doors[dungeon]: - builder.candidates.big = filter_key_door_pool(builder.candidates.big, custom_bk_doors[dungeon]) + if all_custom[dungeon]: + builder.candidates.big = filter_key_door_pool(builder.candidates.big, all_custom[dungeon]) remaining -= len(custom_bk_doors[dungeon]) ttl += len(builder.candidates.big) if ttl == 0: continue + remaining = max(0, remaining) for dungeon in pool: builder = world.dungeon_layouts[player][dungeon] proportion = len(builder.candidates.big) - calc = int(round(proportion * door_type_pool.bigs/ttl)) + calc = int(round(proportion * remaining/ttl)) suggested = min(proportion, calc) remaining -= suggested suggestion_map[dungeon] = suggested @@ -1891,48 +1954,56 @@ def shuffle_big_key_doors(door_type_pools, used_doors, start_regions_map, world, return used_doors -def shuffle_small_key_doors(door_type_pools, used_doors, start_regions_map, world, player): +def shuffle_small_key_doors(door_type_pools, used_doors, start_regions_map, all_custom, world, player): + max_computation = 11 # this is around 6 billion worse case factorial don't want to exceed this much for pool, door_type_pool in door_type_pools: ttl = 0 suggestion_map, small_map, flex_map = {}, {}, {} remaining = door_type_pool.smalls total_keys = remaining - if player in world.custom_door_types: + if player in world.custom_door_types and 'Key Door' in world.custom_door_types[player]: custom_key_doors = world.custom_door_types[player]['Key Door'] else: custom_key_doors = defaultdict(list) - + total_adjustable = len(pool) > 1 for dungeon in pool: builder = world.dungeon_layouts[player][dungeon] + if not total_adjustable: + builder.total_keys = total_keys find_small_key_door_candidates(builder, start_regions_map[dungeon], used_doors, world, player) - if custom_key_doors[dungeon]: - builder.candidates.small = filter_key_door_pool(builder.candidates.small, custom_key_doors[dungeon]) - remaining -= len(custom_key_doors[dungeon]) - builder.key_doors_num = max(0, len(builder.candidates.small) - builder.key_drop_cnt) + custom_doors = 0 + if all_custom[dungeon]: + builder.candidates.small = filter_key_door_pool(builder.candidates.small, all_custom[dungeon]) + custom_doors = len(custom_key_doors[dungeon]) + remaining -= custom_doors + builder.key_doors_num = max(0, len(builder.candidates.small) - builder.key_drop_cnt) + custom_doors total_keys -= builder.key_drop_cnt ttl += builder.key_doors_num + remaining = max(0, remaining) for dungeon in pool: builder = world.dungeon_layouts[player][dungeon] calculated = int(round(builder.key_doors_num*total_keys/ttl)) max_keys = max(0, builder.location_cnt - calc_used_dungeon_items(builder, world, player)) cand_len = max(0, len(builder.candidates.small) - builder.key_drop_cnt) - limit = min(max_keys, cand_len) + limit = min(max_keys, cand_len, max_computation) suggested = min(calculated, limit) - combo_size = ncr(len(builder.candidates.small), suggested + builder.key_drop_cnt) - while combo_size > 500000 and suggested > 0: - suggested -= 1 - combo_size = ncr(len(builder.candidates.small), suggested + builder.key_drop_cnt) - suggestion_map[dungeon] = builder.key_doors_num = suggested + builder.key_drop_cnt - remaining -= suggested + builder.key_drop_cnt + key_door_num = min(suggested + builder.key_drop_cnt, max_computation) + combo_size = ncr(len(builder.candidates.small), key_door_num) + suggestion_map[dungeon] = builder.key_doors_num = key_door_num + remaining -= key_door_num + builder.key_drop_cnt builder.combo_size = combo_size - flex_map[dungeon] = (limit - suggested) if suggested < limit else 0 + flex_map[dungeon] = (limit - key_door_num) if key_door_num < limit else 0 for dungeon in pool: builder = world.dungeon_layouts[player][dungeon] + if total_adjustable: + builder.total_keys = max(suggestion_map[dungeon], builder.key_drop_cnt) valid_doors, small_number = find_valid_combination(builder, suggestion_map[dungeon], start_regions_map[dungeon], world, player) small_map[dungeon] = valid_doors actual_chest_keys = small_number - builder.key_drop_cnt if actual_chest_keys < suggestion_map[dungeon]: + if total_adjustable: + builder.total_keys = actual_chest_keys + builder.key_drop_cnt flex_map[dungeon] = 0 remaining += suggestion_map[dungeon] - actual_chest_keys suggestion_map[dungeon] = small_number @@ -1943,6 +2014,8 @@ def shuffle_small_key_doors(door_type_pools, used_doors, start_regions_map, worl builder = queue.popleft() dungeon = builder.name increased = suggestion_map[dungeon] + 1 + if increased > max_computation: + continue builder.key_doors_num = increased valid_doors, small_number = find_valid_combination(builder, increased, start_regions_map[dungeon], world, player) @@ -1951,6 +2024,8 @@ def shuffle_small_key_doors(door_type_pools, used_doors, start_regions_map, worl remaining -= 1 suggestion_map[dungeon] = increased flex_map[dungeon] -= 1 + if total_adjustable: + builder.total_keys = max(increased, builder.key_drop_cnt) if flex_map[dungeon] > 0: builder.combo_size = ncr(len(builder.candidates.small), builder.key_doors_num) queue.append(builder) @@ -1976,14 +2051,14 @@ def shuffle_small_key_doors(door_type_pools, used_doors, start_regions_map, worl return used_doors -def shuffle_bomb_dash_doors(door_type_pools, used_doors, start_regions_map, world, player): +def shuffle_bomb_dash_doors(door_type_pools, used_doors, start_regions_map, all_custom, world, player): for pool, door_type_pool in door_type_pools: ttl = 0 suggestion_map, bd_map = {}, {} remaining_bomb = door_type_pool.bombable remaining_dash = door_type_pool.dashable - if player in world.custom_door_types: + if player in world.custom_door_types and 'Bomb Door' in world.custom_door_types[player]: custom_bomb_doors = world.custom_door_types[player]['Bomb Door'] custom_dash_doors = world.custom_door_types[player]['Dash Door'] else: @@ -1993,11 +2068,9 @@ def shuffle_bomb_dash_doors(door_type_pools, used_doors, start_regions_map, worl for dungeon in pool: builder = world.dungeon_layouts[player][dungeon] find_bd_candidates(builder, start_regions_map[dungeon], used_doors, world, player) - if custom_bomb_doors[dungeon]: - builder.candidates.bomb_dash = filter_key_door_pool(builder.candidates.bomb_dash, custom_bomb_doors[dungeon]) + if all_custom[dungeon]: + builder.candidates.bomb_dash = filter_key_door_pool(builder.candidates.bomb_dash, all_custom[dungeon]) remaining_bomb -= len(custom_bomb_doors[dungeon]) - if custom_dash_doors[dungeon]: - builder.candidates.bomb_dash = filter_key_door_pool(builder.candidates.bomb_dash, custom_dash_doors[dungeon]) remaining_dash -= len(custom_dash_doors[dungeon]) ttl += len(builder.candidates.bomb_dash) if ttl == 0: @@ -2094,7 +2167,7 @@ def find_trappable_candidates(builder, world, player): if ext.door and ext.door.type in [DoorType.Interior, DoorType.Normal]] for d in filtered_doors: # I only support the first 3 due to the trapFlag right now - if 0 <= d.doorListPos < 3 and not d.entranceFlag: + if 0 <= d.doorListPos < 3 and not d.entranceFlag and d.name != 'Skull Small Hall WS': room = world.get_room(d.roomIndex, player) kind = room.kind(d) if d.type == DoorType.Interior: @@ -2115,20 +2188,21 @@ def find_trappable_candidates(builder, world, player): for ext in world.get_region(r, player).exits: if ext.door: d = ext.door - if d.blocked and d.trapFlag != 0 and 'Boss' not in d.name and 'Agahnim' not in d.name: + if d.blocked and d.trapFlag != 0 and exclude_boss_traps(d): builder.candidates.trap.append(d) def find_valid_trap_combination(builder, suggested, start_regions, paths, world, player, drop=True): trap_door_pool = builder.candidates.trap trap_doors_needed = suggested - if player in world.custom_door_types: + if player in world.custom_door_types and 'Trap Door' in world.custom_door_types[player]: custom_trap_doors = world.custom_door_types[player]['Trap Door'][builder.name] else: custom_trap_doors = [] if custom_trap_doors: trap_door_pool = filter_key_door_pool(trap_door_pool, custom_trap_doors) trap_doors_needed -= len(custom_trap_doors) + trap_doors_needed = max(0, trap_doors_needed) if len(trap_door_pool) < trap_doors_needed: if not drop: return None, 0 @@ -2139,7 +2213,7 @@ def find_valid_trap_combination(builder, suggested, start_regions, paths, world, proposal = kth_combination(sample_list[itr], trap_door_pool, trap_doors_needed) proposal.extend(custom_trap_doors) - start_regions = filter_start_regions(builder, start_regions, world, player) + start_regions, event_starts = filter_start_regions(builder, start_regions, world, player) while not validate_trap_layout(proposal, builder, start_regions, paths, world, player): itr += 1 if itr >= len(sample_list): @@ -2160,14 +2234,32 @@ def find_valid_trap_combination(builder, suggested, start_regions, paths, world, # eliminate start region if portal marked as destination def filter_start_regions(builder, start_regions, world, player): std_flag = world.mode[player] == 'standard' and builder.name == 'Hyrule Castle' - excluded = {} + excluded = {} # todo: drop lobbies, might be better to white list instead (two entrances per region) + event_doors = {} for region in start_regions: portal = next((x for x in world.dungeon_portals[player] if x.door.entrance.parent_region == region), None) if portal and portal.destination: - excluded[region] = None + # make sure that a drop is not accessible for this "destination" + drop_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'), None) + if not drop_region: + excluded[region] = None + if portal and not portal.destination: + portal_entrance_region = portal.door.entrance.parent_region.name + if portal_entrance_region not in builder.path_entrances: + excluded[region] = None if std_flag and (not portal or portal.find_portal_entrance().parent_region.name != 'Hyrule Castle Courtyard'): excluded[region] = None - return [x for x in start_regions if x not in excluded.keys()] + if portal is None: + entrance = next((x for x in region.entrances + if x.parent_region.type in [RegionType.LightWorld, RegionType.DarkWorld] + or x.parent_region.name == 'Sewer Drop'), None) + event_doors[entrance] = None + else: + event_doors[portal.find_portal_entrance()] = None + + return [x for x in start_regions if x not in excluded.keys()], event_doors def validate_trap_layout(proposal, builder, start_regions, paths, world, player): @@ -2193,6 +2285,7 @@ def find_bk_special_location(builder, world, player): return loc return None + def check_required_paths_with_traps(paths, proposal, dungeon_name, start_regions, world, player): cached_initial_state = None if len(paths[dungeon_name]) > 0: @@ -2238,7 +2331,7 @@ def reassign_trap_doors(trap_map, world, player): logger = logging.getLogger('') for name, traps in trap_map.items(): builder = world.dungeon_layouts[player][name] - queue = deque(find_current_trap_doors(builder)) + queue = deque(find_current_trap_doors(builder, world, player)) while len(queue) > 0: d = queue.pop() if d.type is DoorType.Interior and d not in traps: @@ -2257,16 +2350,21 @@ def reassign_trap_doors(trap_map, world, player): d.blocked = False for d in traps: change_door_to_trap(d, world, player) - world.spoiler.set_door_type(d.name, 'Trap Door', player) - logger.debug('Trap Door: %s', d.name) + world.spoiler.set_door_type(f'{d.name} ({d.dungeon_name()})', 'Trap Door', player) + logger.debug(f'Trap Door: {d.name} ({d.dungeon_name()})') -def find_current_trap_doors(builder): +def exclude_boss_traps(d): + return ' Boss ' not in d.name and ' Agahnim ' not in d.name and d.name not in ['Skull Spike Corner SW'] + + +def find_current_trap_doors(builder, world, player): + checker = exclude_boss_traps if world.trap_door_mode[player] in ['vanilla', 'optional'] else (lambda x: True) current_doors = [] for region in builder.master_sector.regions: for ext in region.exits: d = ext.door - if d and d.blocked and d.trapFlag != 0: + if d and d.blocked and d.trapFlag != 0 and checker(d): current_doors.append(d) return current_doors @@ -2276,7 +2374,9 @@ def change_door_to_trap(d, world, player): if d.type is DoorType.Interior: kind = room.kind(d) new_kind = None - if kind == DoorKind.TrapTriggerable and d.direction in [Direction.South, Direction.East]: + if kind == DoorKind.Trap: + new_kind = DoorKind.Trap + elif kind == DoorKind.TrapTriggerable and d.direction in [Direction.South, Direction.East]: new_kind = DoorKind.Trap elif kind == DoorKind.Trap2 and d.direction in [Direction.North, Direction.West]: new_kind = DoorKind.Trap @@ -2356,21 +2456,16 @@ def find_big_key_door_candidates(region, checked, used, world, player): if valid and d.dest not in candidates: # interior doors are not separable yet candidates.append(d.dest) elif d.type == DoorType.Normal: - if decoupled: - valid = kind in okay_normals - else: + valid = kind in okay_normals + if valid and not decoupled: d2 = d.dest if d2 not in candidates and d2 not in used: 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 + valid &= kind_b in okay_normals and valid_key_door_pair(d, d2) 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 @@ -2384,13 +2479,14 @@ def find_big_key_door_candidates(region, checked, used, world, player): def find_valid_bk_combination(builder, suggested, start_regions, world, player, drop=True): bk_door_pool = builder.candidates.big bk_doors_needed = suggested - if player in world.custom_door_types: + if player in world.custom_door_types and 'Big Key Door' in world.custom_door_types[player]: custom_bk_doors = world.custom_door_types[player]['Big Key Door'][builder.name] else: custom_bk_doors = [] if custom_bk_doors: bk_door_pool = filter_key_door_pool(bk_door_pool, custom_bk_doors) bk_doors_needed -= len(custom_bk_doors) + bk_doors_needed = max(0, bk_doors_needed) if len(bk_door_pool) < bk_doors_needed: if not drop: return None, 0 @@ -2401,7 +2497,7 @@ def find_valid_bk_combination(builder, suggested, start_regions, world, player, proposal = kth_combination(sample_list[itr], bk_door_pool, bk_doors_needed) proposal.extend(custom_bk_doors) - start_regions = filter_start_regions(builder, start_regions, world, player) + start_regions, event_starts = filter_start_regions(builder, start_regions, world, player) while not validate_bk_layout(proposal, builder, start_regions, world, player): itr += 1 if itr >= len(sample_list): @@ -2452,17 +2548,20 @@ def reassign_big_key_doors(bk_map, world, player): if d1.type is DoorType.Interior: change_door_to_big_key(d1, world, player) d2.bigKey = True # ensure flag is set + if d2.smallKey: + d2.smallKey = False else: world.paired_doors[player].append(PairedDoor(d1.name, d2.name)) change_door_to_big_key(d1, world, player) change_door_to_big_key(d2, world, player) - world.spoiler.set_door_type(d1.name+' <-> '+d2.name, 'Big Key Door', player) - logger.debug(f'Big Key Door: {d1.name} <-> {d2.name}') + world.spoiler.set_door_type(f'{d1.name} <-> {d2.name} ({d1.dungeon_name()})', 'Big Key Door', player) + logger.debug(f'Big Key Door: {d1.name} <-> {d2.name} ({d1.dungeon_name()})') else: d = obj if d.type is DoorType.Interior: change_door_to_big_key(d, world, player) - d.dest.bigKey = True # ensure flag is set + if world.door_type_mode[player] != 'original': + d.dest.bigKey = True # ensure flag is set when bk doors are double sided elif d.type is DoorType.SpiralStairs: pass # we don't have spiral stairs candidates yet that aren't already key doors elif d.type is DoorType.Normal: @@ -2473,12 +2572,14 @@ def reassign_big_key_doors(bk_map, world, player): if stateful_door(d.dest, dest_room.kind(d.dest)): change_door_to_big_key(d.dest, world, player) add_pair(d, d.dest, world, player) - world.spoiler.set_door_type(d.name, 'Big Key Door', player) - logger.debug(f'Big Key Door: {d.name}') + world.spoiler.set_door_type(f'{d.name} ({d.dungeon_name()})', 'Big Key Door', player) + logger.debug(f'Big Key Door: {d.name} ({d.dungeon_name()})') def change_door_to_big_key(d, world, player): d.bigKey = True + if d.smallKey: + d.smallKey = False room = world.get_room(d.roomIndex, player) if room.doorList[d.doorListPos][1] != DoorKind.BigKey: verify_door_list_pos(d, room, world, player) @@ -2522,14 +2623,14 @@ def find_valid_combination(builder, target, start_regions, world, player, drop_k logger = logging.getLogger('') key_door_pool = list(builder.candidates.small) key_doors_needed = target - if player in world.custom_door_types: + if player in world.custom_door_types and 'Key Door' in world.custom_door_types[player]: custom_key_doors = world.custom_door_types[player]['Key Door'][builder.name] else: custom_key_doors = [] if custom_key_doors: # could validate that each custom item is in the candidates key_door_pool = filter_key_door_pool(key_door_pool, custom_key_doors) key_doors_needed -= len(custom_key_doors) - + key_doors_needed = max(0, key_doors_needed) # find valid combination of candidates if len(key_door_pool) < key_doors_needed: if not drop_keys: @@ -2544,9 +2645,10 @@ def find_valid_combination(builder, target, start_regions, world, player, drop_k sample_list = build_sample_list(combinations) proposal = kth_combination(sample_list[itr], key_door_pool, key_doors_needed) proposal.extend(custom_key_doors) - start_regions = filter_start_regions(builder, start_regions, world, player) + builder.key_doors_num = len(proposal) + start_regions, event_starts = filter_start_regions(builder, start_regions, world, player) - key_layout = build_key_layout(builder, start_regions, proposal, world, player) + key_layout = build_key_layout(builder, start_regions, proposal, event_starts, world, player) determine_prize_lock(key_layout, world, player) while not validate_key_layout(key_layout, world, player): itr += 1 @@ -2572,12 +2674,11 @@ def find_valid_combination(builder, target, start_regions, world, player, drop_k # make changes if player not in world.key_logic.keys(): world.key_logic[player] = {} - builder.total_keys = builder.key_doors_num analyze_dungeon(key_layout, world, player) builder.key_door_proposal = proposal world.key_logic[player][builder.name] = key_layout.key_logic world.key_layout[player][builder.name] = key_layout - return builder.key_door_proposal, key_doors_needed + return builder.key_door_proposal, key_doors_needed + len(custom_key_doors) def find_bd_candidates(builder, start_regions, used, world, player): @@ -2618,25 +2719,21 @@ def find_bd_door_candidates(region, checked, used, world, player): room = world.get_room(d.roomIndex, player) position, kind = room.doorList[d.doorListPos] if d.type == DoorType.Interior: - valid = kind in okay_interiors - if valid and d.dest not in candidates: # interior doors are not separable yet + # interior doors are not separable yet + valid = kind in okay_interiors and d.dest not in used + if valid and d.dest not in candidates: candidates.append(d.dest) elif d.type == DoorType.Normal: - if decoupled: - valid = kind in okay_normals - else: + valid = kind in okay_normals + if valid and not decoupled: d2 = d.dest if d2 not in candidates and d2 not in used: 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 + valid &= kind_b in okay_normals and valid_key_door_pair(d, d2) 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 @@ -2654,7 +2751,7 @@ def find_valid_bd_combination(builder, suggested, world, player): bd_door_pool = builder.candidates.bomb_dash bomb_doors_needed, dash_doors_needed = suggested ttl_needed = bomb_doors_needed + dash_doors_needed - if player in world.custom_door_types: + if player in world.custom_door_types and 'Bomb Door' in world.custom_door_types[player]: custom_bomb_doors = world.custom_door_types[player]['Bomb Door'][builder.name] custom_dash_doors = world.custom_door_types[player]['Dash Door'][builder.name] else: @@ -2667,8 +2764,15 @@ def find_valid_bd_combination(builder, suggested, world, player): bd_door_pool = filter_key_door_pool(bd_door_pool, custom_dash_doors) dash_doors_needed -= len(custom_dash_doors) while len(bd_door_pool) < bomb_doors_needed + dash_doors_needed: - bomb_doors_needed = round(len(bd_door_pool) * bomb_doors_needed/ttl_needed) - dash_doors_needed = round(len(bd_door_pool) * dash_doors_needed/ttl_needed) + test = random.choice([True, False]) + if test: + bomb_doors_needed -= 1 + if bomb_doors_needed < 0: + bomb_doors_needed = 0 + else: + dash_doors_needed -= 1 + if dash_doors_needed < 0: + dash_doors_needed = 0 bomb_proposal = random.sample(bd_door_pool, k=bomb_doors_needed) bomb_proposal.extend(custom_bomb_doors) dash_pool = [x for x in bd_door_pool if x not in bomb_proposal] @@ -2696,8 +2800,8 @@ def reassign_bd_doors(bd_map, world, player): elif d.type is DoorType.Normal and not_in_proposal(d): if not d.entranceFlag: world.get_room(d.roomIndex, player).change(d.doorListPos, DoorKind.Normal) - do_bombable_dashable(flat_bomb_proposal, DoorKind.Bombable, world, player) - do_bombable_dashable(flat_dash_proposal, DoorKind.Dashable, world, player) + do_bombable_dashable(pair[0], DoorKind.Bombable, world, player) + do_bombable_dashable(pair[1], DoorKind.Dashable, world, player) def do_bombable_dashable(proposal, kind, world, player): @@ -2723,7 +2827,7 @@ def do_bombable_dashable(proposal, kind, world, player): change_door_to_kind(d1, kind, world, player) change_door_to_kind(d2, kind, world, player) spoiler_type = 'Bomb Door' if kind == DoorKind.Bombable else 'Dash Door' - world.spoiler.set_door_type(d1.name+' <-> '+d2.name, spoiler_type, player) + world.spoiler.set_door_type(f'{d1.name} <-> {d2.name} ({d1.dungeon_name()})', spoiler_type, player) else: d = obj if d.type is DoorType.Interior: @@ -2737,7 +2841,7 @@ def do_bombable_dashable(proposal, kind, world, player): change_door_to_kind(d.dest, kind, world, player) add_pair(d, d.dest, world, player) spoiler_type = 'Bomb Door' if kind == DoorKind.Bombable else 'Dash Door' - world.spoiler.set_door_type(d.name, spoiler_type, player) + world.spoiler.set_door_type(f'{d.name} ({d.dungeon_name()})', spoiler_type, player) def find_current_bd_doors(builder, world): @@ -2852,27 +2956,23 @@ def find_key_door_candidates(region, checked, used, world, player): room = world.get_room(d.roomIndex, player) position, kind = room.doorList[d.doorListPos] if d.type == DoorType.Interior: - valid = kind in okay_interiors - if valid and d.dest not in candidates: # interior doors are not separable yet + valid = kind in okay_interiors and d.dest not in used + # interior doors are not separable yet + if valid and d.dest not in candidates: candidates.append(d.dest) elif d.type == DoorType.SpiralStairs: valid = kind in [DoorKind.StairKey, DoorKind.StairKey2, DoorKind.StairKeyLow] elif d.type == DoorType.Normal: - if decoupled: - valid = kind in okay_normals - else: + valid = kind in okay_normals + if valid and not decoupled: d2 = d.dest if d2 not in candidates and d2 not in used: 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 + valid &= kind_b in okay_normals and valid_key_door_pair(d, d2) 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 @@ -2944,8 +3044,8 @@ def reassign_key_doors(small_map, world, player): world.paired_doors[player].append(PairedDoor(d1.name, d2.name)) change_door_to_small_key(d1, world, player) change_door_to_small_key(d2, world, player) - world.spoiler.set_door_type(d1.name+' <-> '+d2.name, 'Key Door', player) - logger.debug('Key Door: %s', d1.name+' <-> '+d2.name) + world.spoiler.set_door_type(f'{d1.name} <-> {d2.name} ({d1.dungeon_name()})', 'Key Door', player) + logger.debug(f'Key Door: {d1.name} <-> {d2.name} ({d1.dungeon_name()})') else: d = obj if d.type is DoorType.Interior: @@ -2961,8 +3061,8 @@ def reassign_key_doors(small_map, world, player): if stateful_door(d.dest, dest_room.kind(d.dest)): change_door_to_small_key(d.dest, world, player) add_pair(d, d.dest, world, player) - world.spoiler.set_door_type(d.name, 'Key Door', player) - logger.debug('Key Door: %s', d.name) + world.spoiler.set_door_type(f'{d.name} ({d.dungeon_name()})', 'Key Door', player) + logger.debug(f'Key Door: {d.name} ({d.dungeon_name()})') def change_door_to_small_key(d, world, player): @@ -3152,7 +3252,7 @@ def change_pair_type(door, new_type, world, player): room_b.change(door.dest.doorListPos, new_type) add_pair(door, door.dest, world, player) spoiler_type = 'Bomb Door' if new_type == DoorKind.Bombable else 'Dash Door' - world.spoiler.set_door_type(door.name + ' <-> ' + door.dest.name, spoiler_type, player) + world.spoiler.set_door_type(f'{door.name} <-> {door.dest.name} ({door.dungeon_name()})', spoiler_type, player) def remove_pair_type_if_present(door, world, player): @@ -3245,6 +3345,14 @@ def find_accessible_entrances(world, player, builder): return visited_entrances +def find_possible_entrances(world, player, builder): + entrances = [region.name for region in + (portal.door.entrance.parent_region for portal in world.dungeon_portals[player]) + if region.dungeon.name == builder.name] + entrances.extend(drop_entrances[builder.name]) + return entrances + + def valid_inaccessible_region(r): return r.type is not RegionType.Cave or (len(r.exits) > 0 and r.name not in ['Links House', 'Chris Houlihan Room']) @@ -4216,7 +4324,7 @@ default_door_connections = [ ('Eastern Map Valley SW', 'Eastern Dark Square NW'), ('Eastern Attic Start WS', 'Eastern False Switches ES'), ('Eastern Cannonball Hell WS', 'Eastern Single Eyegore ES'), - ('Desert Compass NW', 'Desert Cannonball S'), + ('Desert Compass NE', 'Desert Cannonball S'), ('Desert Beamos Hall NE', 'Desert Tiles 2 SE'), ('PoD Middle Cage N', 'PoD Pit Room S'), ('PoD Pit Room NW', 'PoD Arena Main SW'), @@ -4503,7 +4611,7 @@ door_type_counts = { 'Swamp Palace': (6, 0, 0, 2, 0, 0, 0), 'Palace of Darkness': (6, 1, 1, 3, 2, 0, 0), 'Misery Mire': (6, 3, 5, 2, 0, 0, 0), - 'Skull Woods': (5, 0, 2, 2, 0, 1, 0), + 'Skull Woods': (5, 0, 1, 2, 0, 1, 0), 'Ice Palace': (6, 1, 3, 0, 0, 0, 0), 'Tower of Hera': (1, 1, 0, 0, 0, 0, 0), 'Thieves Town': (3, 1, 2, 1, 1, 0, 0), diff --git a/Doors.py b/Doors.py index 448165d4..2b1b45fb 100644 --- a/Doors.py +++ b/Doors.py @@ -66,7 +66,7 @@ def create_doors(world, player): create_door(player, 'Hyrule Castle Back Hall Down Stairs', Sprl).dir(Dn, 0x01, 0, HTL).ss(A, 0x2a, 0x00), create_door(player, 'Hyrule Castle Throne Room Tapestry', Lgcl), create_door(player, 'Hyrule Castle Tapestry Backwards', Lgcl), - create_door(player, 'Hyrule Castle Throne Room N', Nrml).dir(No, 0x51, Mid, High).pos(1), + create_door(player, 'Hyrule Castle Throne Room N', Nrml).dir(No, 0x51, Mid, High).pos(0), create_door(player, 'Hyrule Castle Throne Room South Stairs', StrS).dir(So, 0x51, Mid, Low), # hyrule dungeon level @@ -207,7 +207,7 @@ def create_doors(world, player): create_door(player, 'Desert East Wing ES', Intr).dir(Ea, 0x85, Bot, High).pos(3), create_door(player, 'Desert East Wing Key Door EN', Intr).dir(Ea, 0x85, Top, High).small_key().pos(1), create_door(player, 'Desert Compass Key Door WN', Intr).dir(We, 0x85, Top, High).small_key().pos(1), - create_door(player, 'Desert Compass NW', Nrml).dir(No, 0x85, Right, High).trap(0x4).pos(0), + create_door(player, 'Desert Compass NE', Nrml).dir(No, 0x85, Right, High).trap(0x4).pos(0), create_door(player, 'Desert Cannonball S', Nrml).dir(So, 0x75, Right, High).pos(1).portal(X, 0x02), create_door(player, 'Desert Arrow Pot Corner S Edge', Open).dir(So, 0x75, None, High).edge(6, Z, 0x20), create_door(player, 'Desert Arrow Pot Corner W Edge', Open).dir(We, 0x75, None, High).edge(2, Z, 0x20), @@ -780,7 +780,7 @@ def create_doors(world, player): create_door(player, 'Ice Freezors Hole', Hole), create_door(player, 'Ice Freezors Bomb Hole', Hole), # combine these two? -- they have to lead to the same spot create_door(player, 'Ice Freezors Ledge Hole', Hole), - create_door(player, 'Ice Freezors Ledge ES', Intr).dir(Ea, 0x7e, Bot, High).pos(2), + create_door(player, 'Ice Freezors Ledge ES', Intr).dir(Ea, 0x7e, Bot, High).pos(1), create_door(player, 'Ice Tall Hint WS', Intr).dir(We, 0x7e, Bot, High).pos(1), create_door(player, 'Ice Tall Hint EN', Nrml).dir(Ea, 0x7e, Top, High).pos(2), create_door(player, 'Ice Tall Hint SE', Nrml).dir(So, 0x7e, Right, High).small_key().pos(0).portal(X, 0x02), @@ -1478,6 +1478,8 @@ def create_doors(world, player): world.get_door('GT Spike Crystal Right to Left Barrier - Orange', player).barrier(CrystalBarrier.Orange) world.get_door('GT Spike Crystal Left to Right Bypass', player).barrier(CrystalBarrier.Blue) + world.get_door('Sanctuary Mirror Route', player).barrier(CrystalBarrier.Orange) + # kill certain doors if world.intensity[player] == 1: # due to ladder & warp being fixed world.get_door('PoD Mimics 2 SW', player).kill() @@ -1524,6 +1526,7 @@ def create_doors(world, player): world.get_door("GT Bob\'s Room SE", player).passage = False world.get_door('Desert Tiles 2 SE', player).bk_shuffle_req = True # key-drop note: allows this to be a portal world.get_door('Swamp Lobby S', player).standard_restricted = True + world.get_door('Sanctuary S', player).standard_restricted = True world.get_door('PoD Mimics 2 SW', player).rupee_bow_restricted = True # bow statue # enemizer logic could get rid of the following restriction world.get_door('PoD Pit Room S', player).rupee_bow_restricted = True # so mimics 1 shouldn't be required diff --git a/DungeonGenerator.py b/DungeonGenerator.py index f982cb6f..f97f8ecf 100644 --- a/DungeonGenerator.py +++ b/DungeonGenerator.py @@ -10,9 +10,9 @@ import time from typing import List from BaseClasses import DoorType, Direction, CrystalBarrier, RegionType, Polarity, PolSlot, flooded_keys, Sector -from BaseClasses import Hook, hook_from_door +from BaseClasses import Hook, hook_from_door, Door from Regions import dungeon_events, flooded_keys_reverse -from Dungeons import dungeon_regions, split_region_starts +from Dungeons import split_region_starts from RoomData import DoorKind from source.dungeon.DungeonStitcher import generate_dungeon_find_proposal @@ -772,6 +772,13 @@ def connect_simple_door(exit_door, region): special_big_key_doors = ['Hyrule Dungeon Cellblock Door', "Thieves Blind's Cell Door"] +std_special_big_key_doors = ['Hyrule Castle Throne Room Tapestry'] + special_big_key_doors + + +def get_special_big_key_doors(world, player): + if world.mode[player] == 'standard': + return std_special_big_key_doors + return special_big_key_doors class ExplorationState(object): @@ -846,13 +853,21 @@ class ExplorationState(object): ret.prize_received = self.prize_received return ret + def init_zelda_event_doors(self, event_starts, player): + for entrance in event_starts: + event_door = Door(player, entrance.name, DoorType.Logical) + event_door.req_event = 'Zelda Drop Off' + event_door.entrance = entrance + event_door.crystal = CrystalBarrier.Orange # always start in orange + self.append_door_to_list(event_door, self.event_doors) + 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): + 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: @@ -873,7 +888,7 @@ class ExplorationState(object): self.ttl_locations += 1 if location not in self.found_locations: self.found_locations.append(location) - if not bk_Flag and (not location.forced_item or 'Big Key' in location.item.name): + if not bk_flag and (not location.forced_item or 'Big Key' in location.item.name): self.bk_found.add(location) if location.name in dungeon_events and location.name not in self.events: if self.flooded_key_check(location): @@ -994,7 +1009,8 @@ class ExplorationState(object): if self.can_traverse(door): if door.controller: door = door.controller - if (door in big_key_door_proposal or door.name in special_big_key_doors) and not self.big_key_opened: + if (door in big_key_door_proposal + or door.name in get_special_big_key_doors(world, player)) 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: @@ -1327,8 +1343,9 @@ def create_dungeon_builders(all_sectors, connections_tuple, world, player, dunge sector = find_sector(r_name, all_sectors) reverse_d_map[sector] = key if world.mode[player] == 'standard': - current_dungeon = dungeon_map['Hyrule Castle'] - standard_stair_check(dungeon_map, current_dungeon, candidate_sectors, global_pole) + if 'Hyrule Castle' in dungeon_map: + current_dungeon = dungeon_map['Hyrule Castle'] + standard_stair_check(dungeon_map, current_dungeon, candidate_sectors, global_pole) complete_dungeons = {x: y for x, y in dungeon_map.items() if sum(len(sector.outstanding_doors) for sector in y.sectors) <= 0} [dungeon_map.pop(key) for key in complete_dungeons.keys()] @@ -1356,7 +1373,8 @@ def create_dungeon_builders(all_sectors, connections_tuple, world, player, dunge sanc_builder = random.choice(lw_builders) assign_sector(sanc, sanc_builder, candidate_sectors, global_pole) - bow_sectors, retro_std_flag = {}, world.bow_mode[player].startswith('retro') and world.mode[player] == 'standard' + retro_std_flag = world.bow_mode[player].startswith('retro') and world.mode[player] == 'standard' + non_hc_sectors = {} free_location_sectors = {} crystal_switches = {} crystal_barriers = {} @@ -1364,7 +1382,9 @@ def create_dungeon_builders(all_sectors, connections_tuple, world, player, dunge neutral_sectors = {} for sector in candidate_sectors: if retro_std_flag and 'Bow' in sector.item_logic: # these need to be distributed outside of HC - bow_sectors[sector] = None + non_hc_sectors[sector] = None + elif world.mode[player] == 'standard' and 'Open Floodgate' in sector.item_logic: + non_hc_sectors[sector] = None elif sector.chest_locations > 0: free_location_sectors[sector] = None elif sector.c_switch: @@ -1375,8 +1395,8 @@ def create_dungeon_builders(all_sectors, connections_tuple, world, player, dunge neutral_sectors[sector] = None else: polarized_sectors[sector] = None - if bow_sectors: - assign_bow_sectors(dungeon_map, bow_sectors, global_pole) + if non_hc_sectors: + assign_non_hc_sectors(dungeon_map, non_hc_sectors, global_pole) leftover = assign_location_sectors_minimal(dungeon_map, free_location_sectors, global_pole, world, player) free_location_sectors = scatter_extra_location_sectors(dungeon_map, leftover, global_pole) for sector in free_location_sectors: @@ -1420,7 +1440,8 @@ def create_dungeon_builders(all_sectors, connections_tuple, world, player, dunge def standard_stair_check(dungeon_map, dungeon, candidate_sectors, global_pole): # this is because there must be at least one non-dead stairway in hc to get out # this check may not be necessary - filtered_sectors = [x for x in candidate_sectors if any(y for y in x.outstanding_doors if not y.dead and y.type == DoorType.SpiralStairs)] + filtered_sectors = [x for x in candidate_sectors if 'Open Floodgate' not in x.item_logic and + any(y for y in x.outstanding_doors if not y.dead and y.type == DoorType.SpiralStairs)] valid = False while not valid: chosen_sector = random.choice(filtered_sectors) @@ -1550,7 +1571,7 @@ def define_sector_features(sectors): sector.bk_required = True for ext in region.exits: door = ext.door - if door is not None: + if door is not None and not door.blocked: if door.crystal == CrystalBarrier.Either: sector.c_switch = True elif door.crystal == CrystalBarrier.Orange: @@ -1562,6 +1583,8 @@ def define_sector_features(sectors): if region.name in ['PoD Mimics 2', 'PoD Bow Statue Right', 'PoD Mimics 1', 'GT Mimics 1', 'GT Mimics 2', 'Eastern Single Eyegore', 'Eastern Duo Eyegores']: sector.item_logic.add('Bow') + if region.name in ['Swamp Lobby', 'Swamp Entrance']: + sector.item_logic.add('Open Floodgate') def assign_sector(sector, dungeon, candidate_sectors, global_pole): @@ -1613,8 +1636,8 @@ def find_sector(r_name, sectors): return None -def assign_bow_sectors(dungeon_map, bow_sectors, global_pole): - sector_list = list(bow_sectors) +def assign_non_hc_sectors(dungeon_map, non_hc_sectors, global_pole): + sector_list = list(non_hc_sectors) random.shuffle(sector_list) population = [] for name in dungeon_map: @@ -1623,7 +1646,7 @@ def assign_bow_sectors(dungeon_map, bow_sectors, global_pole): choices = random.choices(population, k=len(sector_list)) for i, choice in enumerate(choices): builder = dungeon_map[choice] - assign_sector(sector_list[i], builder, bow_sectors, global_pole) + assign_sector(sector_list[i], builder, non_hc_sectors, global_pole) def scatter_extra_location_sectors(dungeon_map, free_location_sectors, global_pole): @@ -1801,6 +1824,7 @@ def ensure_crystal_switches_reachable(dungeon_map, crystal_switches, polarized_s for name, builder in dungeon_map.items(): if builder.c_switch_present and builder.c_switch_required and not builder.c_locked: invalid_builders.append(builder) + random.shuffle(invalid_builders) while len(invalid_builders) > 0: valid_builders = [] for builder in invalid_builders: @@ -1826,6 +1850,7 @@ def ensure_crystal_switches_reachable(dungeon_map, crystal_switches, polarized_s if eq.c_switch: reachable_crystals[hook_from_door(eq.door)] = True valid_ent_sectors = [] + random.shuffle(entrance_sectors) for entrance_sector in entrance_sectors: other_sectors = [x for x in builder.sectors if x != entrance_sector] reachable, access = is_c_switch_reachable(entrance_sector, reachable_crystals, other_sectors) @@ -1843,7 +1868,12 @@ def ensure_crystal_switches_reachable(dungeon_map, crystal_switches, polarized_s while not valid: if len(candidates) <= 0: raise GenerationException(f'need to provide more sophisticated crystal connection for {entrance_sector}') - sector, which_list = random.choice(list(candidates.items())) + # prioritize candidates + if any(x == 'Crystals' for x in candidates.values()): + cand_list = [x for x in candidates.items() if x[1] == 'Crystals'] + else: + cand_list = list(candidates.items()) + sector, which_list = random.choice(cand_list) del candidates[sector] valid = global_pole.is_valid_choice(dungeon_map, builder, [sector]) if which_list == 'Polarized': @@ -3494,6 +3524,8 @@ def identify_branching_issues(dungeon_map, builder_info): unconnected_builders[name] = builder for hook, door_list in unreached_doors.items(): builder.unfulfilled[hook] += len(door_list) + elif package: + builder.throne_door, builder.throne_sector, builder.chosen_lobby = package return unconnected_builders @@ -3511,7 +3543,8 @@ def check_for_valid_layout(builder, sector_list, builder_info): for portal in world.dungeon_portals[player]: if not portal.destination and portal.name in dungeon_portals[builder.name]: possible_regions.add(portal.door.entrance.parent_region.name) - if builder.name in dungeon_drops.keys(): + if builder.name in dungeon_drops.keys() and (builder.name != 'Hyrule Castle' + or world.mode[player] != 'standard'): possible_regions.update(dungeon_drops[builder.name]) independents = find_independent_entrances(possible_regions, world, player) for name, split_build in builder.split_dungeon_map.items(): @@ -3538,7 +3571,8 @@ def check_for_valid_layout(builder, sector_list, builder_info): split_list['Sewers'].remove(temp_builder.throne_door.entrance.parent_region.name) builder.exception_list = list(sector_list) return True, {}, package - except (GenerationException, NeutralizingException, OtherGenException): + except (GenerationException, NeutralizingException, OtherGenException) as e: + logging.getLogger('').debug(f'Bailing on this layout for {builder.name}', exc_info=1) builder.split_dungeon_map = None builder.valid_proposal = None if temp_builder.name == 'Hyrule Castle' and temp_builder.throne_door: diff --git a/EntranceShuffle.py b/EntranceShuffle.py index 658f3e8a..c2806835 100644 --- a/EntranceShuffle.py +++ b/EntranceShuffle.py @@ -2626,6 +2626,8 @@ mandatory_connections = [('Links House S&Q', 'Links House'), ('Bumper Cave Entrance Mirror Spot', 'Death Mountain Entrance'), ('Bumper Cave Ledge Drop', 'West Dark World'), ('Bumper Cave Ledge Mirror Spot', 'Death Mountain Return Ledge'), + ('Bumper Cave Bottom to Top', 'Bumper Cave (top)'), + ('Bumper Cave Top To Bottom', 'Bumper Cave (bottom)'), ('Skull Woods Forest', 'Skull Woods Forest'), ('Desert Ledge Mirror Spot', 'Desert Ledge'), ('Desert Ledge (Northeast) Mirror Spot', 'Desert Ledge (Northeast)'), @@ -2754,6 +2756,8 @@ inverted_mandatory_connections = [('Links House S&Q', 'Inverted Links House'), ('Bumper Cave Entrance Rock', 'Bumper Cave Entrance'), ('Bumper Cave Entrance Drop', 'West Dark World'), ('Bumper Cave Ledge Drop', 'West Dark World'), + ('Bumper Cave Bottom to Top', 'Bumper Cave (top)'), + ('Bumper Cave Top To Bottom', 'Bumper Cave (bottom)'), ('Skull Woods Forest', 'Skull Woods Forest'), ('Paradox Cave Push Block Reverse', 'Paradox Cave Chest Area'), ('Paradox Cave Push Block', 'Paradox Cave Front'), @@ -2977,8 +2981,8 @@ default_connections = [('Links House', 'Links House'), ('C-Shaped House', 'C-Shaped House'), ('Chest Game', 'Chest Game'), ('Dark World Hammer Peg Cave', 'Dark World Hammer Peg Cave'), - ('Bumper Cave (Bottom)', 'Bumper Cave'), - ('Bumper Cave (Top)', 'Bumper Cave'), + ('Bumper Cave (Bottom)', 'Bumper Cave (bottom)'), + ('Bumper Cave (Top)', 'Bumper Cave (top)'), ('Red Shield Shop', 'Red Shield Shop'), ('Dark Sanctuary Hint', 'Dark Sanctuary Hint'), ('Fortune Teller (Dark)', 'Fortune Teller (Dark)'), @@ -3140,7 +3144,7 @@ inverted_default_connections = [('Waterfall of Wishing', 'Waterfall of Wishing' ('Inverted Big Bomb Shop', 'Inverted Big Bomb Shop'), ('Inverted Dark Sanctuary', 'Inverted Dark Sanctuary'), ('Inverted Dark Sanctuary Exit', 'West Dark World'), - ('Old Man Cave (West)', 'Bumper Cave'), + ('Old Man Cave (West)', 'Bumper Cave (bottom)'), ('Old Man Cave (East)', 'Death Mountain Return Cave (left)'), ('Old Man Cave Exit (West)', 'West Dark World'), ('Old Man Cave Exit (East)', 'Dark Death Mountain'), @@ -3149,7 +3153,7 @@ inverted_default_connections = [('Waterfall of Wishing', 'Waterfall of Wishing' ('Bumper Cave (Top)', 'Dark Death Mountain Healer Fairy'), ('Bumper Cave Exit (Top)', 'Death Mountain Return Ledge'), ('Bumper Cave Exit (Bottom)', 'Light World'), - ('Death Mountain Return Cave (West)', 'Bumper Cave'), + ('Death Mountain Return Cave (West)', 'Bumper Cave (top)'), ('Death Mountain Return Cave (East)', 'Death Mountain Return Cave (right)'), ('Death Mountain Return Cave Exit (West)', 'Death Mountain'), ('Death Mountain Return Cave Exit (East)', 'Death Mountain'), diff --git a/Fill.py b/Fill.py index 4d59a35d..e6f6940c 100644 --- a/Fill.py +++ b/Fill.py @@ -24,7 +24,6 @@ def promote_dungeon_items(world): item.advancement = True elif item.map or item.compass: item.priority = True - dungeon_tracking(world) def dungeon_tracking(world): @@ -35,7 +34,6 @@ def dungeon_tracking(world): def fill_dungeons_restrictive(world, shuffled_locations): - dungeon_tracking(world) # with shuffled dungeon items they are distributed as part of the normal item pool for item in world.get_items(): @@ -73,11 +71,13 @@ def fill_dungeons_restrictive(world, shuffled_locations): def fill_restrictive(world, base_state, locations, itempool, key_pool=None, single_player_placement=False, vanilla=False): - def sweep_from_pool(): + def sweep_from_pool(placing_item=None): new_state = base_state.copy() for item in itempool: new_state.collect(item, True) + new_state.placing_item = placing_item new_state.sweep_for_events() + new_state.placing_item = None return new_state unplaced_items = [] @@ -94,7 +94,7 @@ def fill_restrictive(world, base_state, locations, itempool, key_pool=None, sing while any(player_items.values()) and locations: items_to_place = [[itempool.remove(items[-1]), items.pop()][-1] for items in player_items.values() if items] - maximum_exploration_state = sweep_from_pool() + maximum_exploration_state = sweep_from_pool(placing_item=items_to_place[0]) has_beaten_game = world.has_beaten_game(maximum_exploration_state) for item_to_place in items_to_place: @@ -105,6 +105,8 @@ def fill_restrictive(world, base_state, locations, itempool, key_pool=None, sing spot_to_fill = None item_locations = filter_locations(item_to_place, locations, world, vanilla) + verify(item_to_place, item_locations, maximum_exploration_state, single_player_placement, + perform_access_check, key_pool, world) for location in item_locations: spot_to_fill = verify_spot_to_fill(location, item_to_place, maximum_exploration_state, single_player_placement, perform_access_check, key_pool, world) @@ -128,9 +130,6 @@ def fill_restrictive(world, base_state, locations, itempool, key_pool=None, sing raise FillError('No more spots to place %s' % item_to_place) world.push_item(spot_to_fill, item_to_place, False) - if item_to_place.smallkey: - with suppress(ValueError): - key_pool.remove(item_to_place) track_outside_keys(item_to_place, spot_to_fill, world) track_dungeon_items(item_to_place, spot_to_fill, world) locations.remove(spot_to_fill) @@ -143,21 +142,29 @@ def verify_spot_to_fill(location, item_to_place, max_exp_state, single_player_pl key_pool, world): if item_to_place.smallkey or item_to_place.bigkey: # a better test to see if a key can go there location.item = item_to_place + location.event = True + if item_to_place.smallkey: + with suppress(ValueError): + key_pool.remove(item_to_place) test_state = max_exp_state.copy() test_state.stale[item_to_place.player] = True else: test_state = max_exp_state if not single_player_placement or location.player == item_to_place.player: + test_state.sweep_for_events() if location.can_fill(test_state, item_to_place, perform_access_check): - if valid_key_placement(item_to_place, location, key_pool, world): + if valid_key_placement(item_to_place, location, key_pool, test_state, world): if item_to_place.crystal or valid_dungeon_placement(item_to_place, location, world): return location if item_to_place.smallkey or item_to_place.bigkey: location.item = None + location.event = False + if item_to_place.smallkey: + key_pool.append(item_to_place) return None -def valid_key_placement(item, location, key_pool, world): +def valid_key_placement(item, location, key_pool, collection_state, world): if not valid_reserved_placement(item, location, world): return False if ((not item.smallkey and not item.bigkey) or item.player != location.player @@ -174,7 +181,15 @@ def valid_key_placement(item, location, key_pool, world): prize_loc = world.get_location(key_logic.prize_location, location.player) cr_count = world.crystals_needed_for_gt[location.player] wild_keys = world.keyshuffle[item.player] != 'none' - return key_logic.check_placement(unplaced_keys, wild_keys, location if item.bigkey else None, prize_loc, cr_count) + if wild_keys: + reached_keys = {x for x in collection_state.locations_checked + if x.item and x.item.name == key_logic.small_key_name and x.item.player == item.player} + else: + reached_keys = set() # will be calculated using key logic in a moment + self_locking_keys = sum(1 for d, rule in key_logic.door_rules.items() if rule.allow_small + and rule.small_location.item and rule.small_location.item.name == key_logic.small_key_name) + return key_logic.check_placement(unplaced_keys, wild_keys, reached_keys, self_locking_keys, + location if item.bigkey else None, prize_loc, cr_count) else: return not item.is_inside_dungeon_item(world) @@ -205,6 +220,7 @@ def track_outside_keys(item, location, world): if loc_dungeon and loc_dungeon.name == item_dungeon: return # this is an inside key world.key_logic[item.player][item_dungeon].outside_keys += 1 + world.key_logic[item.player][item_dungeon].outside_keys_locations.add(location) def track_dungeon_items(item, location, world): @@ -289,7 +305,7 @@ def last_ditch_placement(item_to_place, locations, world, state, base_state, ite possible_swaps = [x for x in state.locations_checked if x.item.type == 'Crystal'] else: possible_swaps = [x for x in state.locations_checked - if x.item.type not in ['Event', 'Crystal'] and not x.forced_item] + if x.item.type not in ['Event', 'Crystal'] and not x.forced_item and not x.locked] swap_locations = sorted(possible_swaps, key=location_preference) return try_possible_swaps(swap_locations, item_to_place, locations, world, base_state, itempool, key_pool, single_player_placement) @@ -345,7 +361,9 @@ def find_spot_for_item(item_to_place, locations, world, base_state, pool, test_state = maximum_exploration_state if (not single_player_placement or location.player == item_to_place.player) \ and location.can_fill(test_state, item_to_place, perform_access_check) \ - and valid_key_placement(item_to_place, location, pool if (keys_in_itempool and keys_in_itempool[item_to_place.player]) else world.itempool, world): + and valid_key_placement(item_to_place, location, + pool if (keys_in_itempool and keys_in_itempool[item_to_place.player]) else world.itempool, + test_state, world): return location if item_to_place.smallkey or item_to_place.bigkey: location.item = old_item @@ -363,6 +381,7 @@ def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None # handle pot/drop shuffle random.shuffle(world.itempool) + config_sort(world) progitempool = [item for item in world.itempool if item.advancement] prioitempool = [item for item in world.itempool if not item.advancement and item.priority] restitempool = [item for item in world.itempool if not item.advancement and not item.priority] @@ -383,7 +402,7 @@ def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None else: max_trash = gt_count scaled_trash = math.floor(max_trash * scale_factor) - if world.goal[player] in ['triforcehunt', 'trinity']: + if world.goal[player] in ['triforcehunt', 'trinity', 'ganonhunt']: gftower_trash_count = random.randint(scaled_trash, max_trash) else: gftower_trash_count = random.randint(0, scaled_trash) @@ -402,10 +421,16 @@ def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None random.shuffle(fill_locations) fill_locations.reverse() - # Make sure the escape small key is placed first in standard with key shuffle to prevent running out of spots - # todo: crossed - progitempool.sort(key=lambda item: 1 if item.name == 'Small Key (Escape)' - and world.keyshuffle[item.player] != 'none' and world.mode[item.player] == 'standard' else 0) + # Make sure the escape keys ire placed first in standard to prevent running out of spots + def std_item_sort(item): + if world.mode[item.player] == 'standard': + if item.name == 'Small Key (Escape)': + return 1 + if item.name == 'Big Key (Escape)': + return 2 + return 0 + + progitempool.sort(key=std_item_sort) key_pool = [x for x in progitempool if x.smallkey] # sort maps and compasses to the back -- this may not be viable in equitable & ambrosia @@ -455,6 +480,20 @@ def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None ensure_good_items(world) +def config_sort(world): + if world.item_pool_config.verify: + config_sort_helper(world, world.item_pool_config.verify) + elif world.item_pool_config.preferred: + config_sort_helper(world, world.item_pool_config.preferred) + + +def config_sort_helper(world, sort_dict): + pref = list(sort_dict.keys()) + pref_len = len(pref) + world.itempool.sort(key=lambda i: pref_len - pref.index((i.name, i.player)) + if (i.name, i.player) in sort_dict else 0) + + def calc_trash_locations(world, player): total_count, gt_count = 0, 0 for loc in world.get_locations(): @@ -499,10 +538,17 @@ def fast_fill_helper(world, item_pool, fill_locations): def fast_fill(world, item_pool, fill_locations): - while item_pool and fill_locations: + config = world.item_pool_config + fast_pool = [x for x in item_pool if (x.name, x.player) not in config.restricted] + filtered_pool = [x for x in item_pool if (x.name, x.player) in config.restricted] + filtered_fill(world, filtered_pool, fill_locations) + while fast_pool and fill_locations: spot_to_fill = fill_locations.pop() - item_to_place = item_pool.pop() + item_to_place = fast_pool.pop() world.push_item(spot_to_fill, item_to_place, False) + item_pool.clear() + item_pool.extend(filtered_pool) + item_pool.extend(fast_pool) def fast_fill_pot_for_multiworld(world, item_pool, fill_locations): @@ -612,6 +658,40 @@ def sell_keys(world, player): world.itempool.remove(universal_key) +def verify(item_to_place, item_locations, state, spp, pac, key_pool, world): + if world.item_pool_config.verify: + logger = logging.getLogger('') + item_name = 'Bottle' if item_to_place.name.startswith('Bottle') else item_to_place.name + item_player = item_to_place.player + config = world.item_pool_config + if (item_name, item_player) in config.verify: + tests = config.verify[(item_name, item_player)] + issues = [] + for location in item_locations: + if location.name in tests: + expected = tests[location.name] + spot = verify_spot_to_fill(location, item_to_place, state, spp, pac, key_pool, world) + if spot and (item_to_place.smallkey or item_to_place.bigkey): + location.item = None + location.event = False + if item_to_place.smallkey: + key_pool.append(item_to_place) + if (expected and spot) or (not expected and spot is None): + logger.debug(f'Placing {item_name} ({item_player}) at {location.name} was {expected}') + config.verify_count += 1 + if config.verify_count >= config.verify_target: + exit() + else: + issues.append((item_name, item_player, location.name, expected)) + if len(issues) > 0: + for name, player, loc, expected in issues: + if expected: + logger.error(f'Could not place {name} ({player}) at {loc}') + else: + logger.error(f'{name} ({player}) should not be allowed at {loc}') + raise Exception(f'Test failed placing {name}') + + def balance_multiworld_progression(world): state = CollectionState(world) checked_locations = set() diff --git a/InitialSram.py b/InitialSram.py index 8d0ade11..67c7dc83 100644 --- a/InitialSram.py +++ b/InitialSram.py @@ -142,8 +142,8 @@ class InitialSram: 'Big Key (Misery Mire)': (0x367, 0x01), 'Compass (Misery Mire)': (0x365, 0x01), 'Map (Misery Mire)': (0x369, 0x01), 'Big Key (Turtle Rock)': (0x366, 0x08), 'Compass (Turtle Rock)': (0x364, 0x08), 'Map (Turtle Rock)': (0x368, 0x08), 'Big Key (Ganons Tower)': (0x366, 0x04), 'Compass (Ganons Tower)': (0x364, 0x04), 'Map (Ganons Tower)': (0x368, 0x04)} - set_or_table = {'Flippers': (0x356, 1, 0x379, 0x02),'Pegasus Boots': (0x355, 1, 0x379, 0x04), - 'Shovel': (0x34C, 1, 0x38C, 0x04), 'Ocarina': (0x34C, 3, 0x38C, 0x01), + set_or_table = {'Flippers': (0x356, 1, 0x379, 0x02), 'Pegasus Boots': (0x355, 1, 0x379, 0x04), + 'Shovel': (0x34C, 1, 0x38C, 0x04), 'Ocarina': (0x34C, 2, 0x38C, 0x02), 'Ocarina (Activated)': (0x34C, 3, 0x38C, 0x01), 'Mushroom': (0x344, 1, 0x38C, 0x20 | 0x08), 'Magic Powder': (0x344, 2, 0x38C, 0x10), 'Blue Boomerang': (0x341, 1, 0x38C, 0x80), 'Red Boomerang': (0x341, 2, 0x38C, 0x40)} diff --git a/InvertedRegions.py b/InvertedRegions.py index 175b2b76..a02bfbea 100644 --- a/InvertedRegions.py +++ b/InvertedRegions.py @@ -184,7 +184,8 @@ def create_inverted_regions(world, player): create_cave_region(player, 'Chest Game', 'a game of 16 chests', ['Chest Game']), create_cave_region(player, 'Red Shield Shop', 'the rare shop', ['Red Shield Shop - Left', 'Red Shield Shop - Middle', 'Red Shield Shop - Right']), create_cave_region(player, 'Inverted Dark Sanctuary', 'a storyteller', None, ['Inverted Dark Sanctuary Exit']), - create_cave_region(player, 'Bumper Cave', 'a connector', None, ['Bumper Cave Exit (Bottom)', 'Bumper Cave Exit (Top)']), + create_cave_region(player, 'Bumper Cave (bottom)', 'a connector', None, ['Bumper Cave Exit (Bottom)', 'Bumper Cave Bottom to Top']), + create_cave_region(player, 'Bumper Cave (top)', 'a connector', None, ['Bumper Cave Exit (Top)', 'Bumper Cave Top To Bottom']), create_dw_region(player, 'Skull Woods Forest', None, ['Skull Woods First Section Hole (East)', 'Skull Woods First Section Hole (West)', 'Skull Woods First Section Hole (North)', 'Skull Woods First Section Door', 'Skull Woods Second Section Door (East)']), create_dw_region(player, 'Skull Woods Forest (West)', None, ['Skull Woods Second Section Hole', 'Skull Woods Second Section Door (West)', 'Skull Woods Final Section']), diff --git a/ItemList.py b/ItemList.py index ebf2c55d..30e2183a 100644 --- a/ItemList.py +++ b/ItemList.py @@ -6,7 +6,7 @@ import RaceRandom as random from BaseClasses import Region, RegionType, Shop, ShopType, Location, CollectionState, PotItem from EntranceShuffle import connect_entrance from Regions import shop_to_location_table, retro_shops, shop_table_by_location, valid_pot_location -from Fill import FillError, fill_restrictive, get_dungeon_item_pool, is_dungeon_item +from Fill import FillError, fill_restrictive, get_dungeon_item_pool, track_dungeon_items, track_outside_keys from PotShuffle import vanilla_pots from Items import ItemFactory @@ -182,8 +182,12 @@ def get_custom_array_key(item): def generate_itempool(world, player): - if (world.difficulty[player] not in ['normal', 'hard', 'expert'] or world.goal[player] not in ['ganon', 'pedestal', 'dungeons', 'triforcehunt', 'trinity', 'crystals'] - or world.mode[player] not in ['open', 'standard', 'inverted'] or world.timer not in ['none', 'display', 'timed', 'timed-ohko', 'ohko', 'timed-countdown'] or world.progressive not in ['on', 'off', 'random']): + if (world.difficulty[player] not in ['normal', 'hard', 'expert'] + or world.goal[player] not in ['ganon', 'pedestal', 'dungeons', 'triforcehunt', 'trinity', 'crystals', + 'ganonhunt', 'completionist'] + or world.mode[player] not in ['open', 'standard', 'inverted'] + or world.timer not in ['none', 'display', 'timed', 'timed-ohko', 'ohko', 'timed-countdown'] + or world.progressive not in ['on', 'off', 'random']): raise NotImplementedError('Not supported yet') if world.timer in ['ohko', 'timed-ohko']: @@ -283,6 +287,7 @@ def generate_itempool(world, player): for _ in range(0, amt): pool.append('Rupees (20)') + start_inventory = list(world.precollected_items) for item in precollected_items: world.push_precollected(ItemFactory(item, player)) @@ -345,7 +350,7 @@ def generate_itempool(world, player): world.clock_mode = clock_mode goal = world.goal[player] - if goal in ['triforcehunt', 'trinity']: + if goal in ['triforcehunt', 'trinity', 'ganonhunt']: g, t = set_default_triforce(goal, world.treasure_hunt_count[player], world.treasure_hunt_total[player]) world.treasure_hunt_count[player], world.treasure_hunt_total[player] = g, t world.treasure_hunt_icon[player] = 'Triforce Piece' @@ -368,21 +373,7 @@ def generate_itempool(world, player): for i in range(4): next(adv_heart_pieces).advancement = True - beeweights = {'0': {None: 100}, - '1': {None: 75, 'trap': 25}, - '2': {None: 40, 'trap': 40, 'bee': 20}, - '3': {'trap': 50, 'bee': 50}, - '4': {'trap': 100}} - def beemizer(item): - if world.beemizer[item.player] and not item.advancement and not item.priority and not item.type: - choice = random.choices(list(beeweights[world.beemizer[item.player]].keys()), weights=list(beeweights[world.beemizer[item.player]].values()))[0] - return item if not choice else ItemFactory("Bee Trap", player) if choice == 'trap' else ItemFactory("Bee", player) - return item - - if not skip_pool_adjustments: - world.itempool += [beemizer(item) for item in items] - else: - world.itempool += items + world.itempool += items # shuffle medallions mm_medallion, tr_medallion = None, None @@ -392,8 +383,16 @@ def generate_itempool(world, player): custom_medallions = medal_map[player] if 'Misery Mire' in custom_medallions: mm_medallion = custom_medallions['Misery Mire'] + if isinstance(mm_medallion, dict): + mm_medallion = random.choices(list(mm_medallion.keys()), list(mm_medallion.values()), k=1)[0] + if mm_medallion == 'Random': + mm_medallion = None if 'Turtle Rock' in custom_medallions: tr_medallion = custom_medallions['Turtle Rock'] + if isinstance(tr_medallion, dict): + tr_medallion = random.choices(list(tr_medallion.keys()), list(tr_medallion.values()), k=1)[0] + if tr_medallion == 'Random': + tr_medallion = None if not mm_medallion: mm_medallion = ['Ether', 'Quake', 'Bombos'][random.randint(0, 2)] if not tr_medallion: @@ -427,6 +426,38 @@ def generate_itempool(world, player): if world.dropshuffle[player] == 'underworld' and not skip_pool_adjustments: add_drop_contents(world, player) + # modfiy based on start inventory, if any + modify_pool_for_start_inventory(start_inventory, world, player) + + beeweights = {'0': {None: 100}, + '1': {None: 75, 'trap': 25}, + '2': {None: 40, 'trap': 40, 'bee': 20}, + '3': {'trap': 50, 'bee': 50}, + '4': {'trap': 100}} + def beemizer(item): + if world.beemizer[item.player] and not item.advancement and not item.priority and not item.type: + choice = random.choices(list(beeweights[world.beemizer[item.player]].keys()), weights=list(beeweights[world.beemizer[item.player]].values()))[0] + return item if not choice else ItemFactory("Bee Trap", player) if choice == 'trap' else ItemFactory("Bee", player) + return item + + if not skip_pool_adjustments: + world.itempool = [beemizer(item) for item in world.itempool] + + # increase pool if not enough items + ttl_locations = sum(1 for x in world.get_unfilled_locations(player) if '- Prize' not in x.name) + pool_size = count_player_dungeon_item_pool(world, player) + pool_size += sum(1 for x in world.itempool if x.player == player) + + if pool_size < ttl_locations: + retro_bow = world.bow_mode[player].startswith('retro') + amount_to_add = ttl_locations - pool_size + filler_additions = random.choices(list(filler_items.keys()), filler_items.values(), k=amount_to_add) + for item in filler_additions: + item_name = 'Rupees (5)' if retro_bow and item == 'Arrows (10)' else item + world.itempool.append(ItemFactory(item_name, player)) + + + take_any_locations = [ 'Snitch Lady (East)', 'Snitch Lady (West)', 'Bush Covered House', 'Light World Bomb Hut', @@ -813,15 +844,15 @@ def add_pot_contents(world, player): world.itempool.append(ItemFactory(item, player)) -def get_pool_core(world, player, progressive, shuffle, difficulty, treasure_hunt_total, timer, goal, mode, swords, bombbag, - door_shuffle, logic, flute_activated): +def get_pool_core(world, player, progressive, shuffle, difficulty, treasure_hunt_total, timer, goal, mode, swords, + bombbag, door_shuffle, logic, flute_activated): pool = [] placed_items = {} precollected_items = [] clock_mode = None - if goal in ['triforcehunt', 'trinity']: + if goal in ['triforcehunt', 'trinity', 'ganonhunt']: if treasure_hunt_total == 0: - treasure_hunt_total = 30 if goal == 'triforcehunt' else 10 + treasure_hunt_total = 30 if goal in ['triforcehunt', 'ganonhunt'] else 10 # triforce pieces max out triforcepool = ['Triforce Piece'] * min(treasure_hunt_total, max_goal) @@ -924,7 +955,7 @@ def get_pool_core(world, player, progressive, shuffle, difficulty, treasure_hunt elif timer == 'timed-ohko': pool.extend(diff.timedohko) clock_mode = 'countdown-ohko' - if goal in ['triforcehunt', 'trinity']: + if goal in ['triforcehunt', 'trinity', 'ganonhunt']: pool.extend(triforcepool) for extra in diff.extras: @@ -957,6 +988,80 @@ def get_pool_core(world, player, progressive, shuffle, difficulty, treasure_hunt return (pool, placed_items, precollected_items, clock_mode, lamps_needed_for_dark_rooms) +item_alternates = { + # Bows + 'Progressive Bow (Alt)': ('Progressive Bow', 1), + 'Bow': ('Progressive Bow', 1), + 'Silver Arrows': ('Progressive Bow', 2), + # Gloves + 'Power Glove': ('Progressive Glove', 1), + 'Titans Mitts': ('Progressive Glove', 2), + # Swords + 'Sword and Shield': ('Progressive Sword', 1), # could find a way to also remove a shield, but mostly not impactful + 'Fighter Sword': ('Progressive Sword', 1), + 'Master Sword': ('Progressive Sword', 2), + 'Tempered Sword': ('Progressive Sword', 3), + 'Golden Sword': ('Progressive Sword', 4), + # Shields + 'Blue Shield': ('Progressive Shield', 1), + 'Red Shield': ('Progressive Shield', 2), + 'Mirror Shield': ('Progressive Shield', 3), + # Armors + 'Blue Mail': ('Progressive Armor', 1), + 'Red Mail': ('Progressive Armor', 2), + + 'Magic Upgrade (1/4)': ('Magic Upgrade (1/2)', 2), + 'Ocarina': ('Ocarina (Activated)', 1), + 'Ocarina (Activated)': ('Ocarina', 1), + 'Boss Heart Container': ('Sanctuary Heart Container', 1), + 'Sanctuary Heart Container': ('Boss Heart Container', 1), + 'Power Star': ('Triforce Piece', 1) +} + + +def modify_pool_for_start_inventory(start_inventory, world, player): + if (world.customizer and world.customizer.get_item_pool()) or world.custom: + # custom item pools only adjust in dungeon items + for item in start_inventory: + if item.dungeon: + d = world.get_dungeon(item.dungeon, item.player) + match = next((i for i in d.all_items if i.name == item.name), None) + if match: + if match.map or match.compass: + d.dungeon_items.remove(match) + elif match.smallkey: + d.small_keys.remove(match) + elif match.bigkey and d.big_key == match: + d.big_key = None + return + for item in start_inventory: + if item.player == player: + if item in world.itempool: + world.itempool.remove(item) + elif item.name in item_alternates: + alt = item_alternates[item.name] + i = alt[1] + while i > 0: + alt_item = ItemFactory([alt[0]], player)[0] + if alt_item in world.itempool: + world.itempool.remove(alt_item) + i = i-1 + elif 'Bottle' in item.name: + bottle_item = next((x for x in world.itempool if 'Bottle' in item.name and x.player == player), None) + if bottle_item is not None: + world.itempool.remove(bottle_item) + if item.dungeon: + d = world.get_dungeon(item.dungeon, item.player) + match = next((i for i in d.all_items if i.name == item.name), None) + if match: + if match.map or match.compass: + d.dungeon_items.remove(match) + elif match.smallkey: + d.small_keys.remove(match) + elif match.bigkey and d.big_key == match: + d.big_key = None + + def make_custom_item_pool(world, player, progressive, shuffle, difficulty, timer, goal, mode, swords, bombbag, customitemarray): if isinstance(customitemarray,dict) and 1 in customitemarray: customitemarray = customitemarray[1] @@ -983,7 +1088,7 @@ def make_custom_item_pool(world, player, progressive, shuffle, difficulty, timer customitemarray["triforce"] = total_items_to_place # Triforce Pieces - if goal in ['triforcehunt', 'trinity']: + if goal in ['triforcehunt', 'trinity', 'ganonhunt']: g, t = set_default_triforce(goal, customitemarray["triforcepiecesgoal"], customitemarray["triforcepieces"]) customitemarray["triforcepiecesgoal"], customitemarray["triforcepieces"] = g, t @@ -1021,8 +1126,8 @@ def make_custom_item_pool(world, player, progressive, shuffle, difficulty, timer treasure_hunt_count = max(min(customitemarray["triforcepiecesgoal"], max_goal), 1) treasure_hunt_icon = 'Triforce Piece' # Ensure game is always possible to complete here, force sufficient pieces if the player is unwilling. - if ((customitemarray["triforcepieces"] < treasure_hunt_count) and (goal in ['triforcehunt', 'trinity']) - and (customitemarray["triforce"] == 0)): + if ((customitemarray["triforcepieces"] < treasure_hunt_count) + and (goal in ['triforcehunt', 'trinity', 'ganonhunt']) and (customitemarray["triforce"] == 0)): extrapieces = treasure_hunt_count - customitemarray["triforcepieces"] pool.extend(['Triforce Piece'] * extrapieces) itemtotal = itemtotal + extrapieces @@ -1060,6 +1165,22 @@ def make_custom_item_pool(world, player, progressive, shuffle, difficulty, timer # print("Placing " + str(nothings) + " Nothings") pool.extend(['Nothing'] * nothings) + start_inventory = [x for x in world.precollected_items if x.player == player] + if not start_inventory: + if world.logic[player] in ['owglitches', 'nologic']: + precollected_items.append('Pegasus Boots') + if 'Pegasus Boots' in pool: + pool.remove('Pegasus Boots') + pool.append('Rupees (20)') + if world.swords[player] == 'assured': + precollected_items.append('Progressive Sword') + if 'Progressive Sword' in pool: + pool.remove('Progressive Sword') + pool.append('Rupees (50)') + elif 'Fighter Sword' in pool: + pool.remove('Fighter Sword') + pool.append('Rupees (50)') + return (pool, placed_items, precollected_items, clock_mode, treasure_hunt_count, treasure_hunt_icon, lamps_needed_for_dark_rooms) @@ -1144,37 +1265,35 @@ def make_customizer_pool(world, player): place_item('Master Sword Pedestal', 'Triforce') guaranteed_items = alwaysitems + ['Magic Mirror', 'Moon Pearl'] + missing_items = [] if world.shopsanity[player]: guaranteed_items.extend(['Blue Potion', 'Green Potion', 'Red Potion']) if world.keyshuffle[player] == 'universal': guaranteed_items.append('Small Key (Universal)') for item in guaranteed_items: if item not in pool: - pool.append(item) + missing_items.append(item) glove_count = sum(1 for i in pool if i == 'Progressive Glove') glove_count = 2 if next((i for i in pool if i == 'Titans Glove'), None) is not None else glove_count for i in range(glove_count, 2): - pool.append('Progressive Glove') + missing_items.append('Progressive Glove') if world.bombbag[player]: if 'Bomb Upgrade (+10)' not in pool: - pool.append('Bomb Upgrade (+10)') + missing_items.append('Bomb Upgrade (+10)') if world.swords[player] != 'swordless': beam_swords = {'Master Sword', 'Tempered Sword', 'Golden Sword'} sword_count = sum(1 for i in pool if i in 'Progressive Sword') sword_count = 2 if next((i for i in pool if i in beam_swords), None) is not None else sword_count for i in range(sword_count, 2): - pool.append('Progressive Sword') + missing_items.append('Progressive Sword') bow_found = next((i for i in pool if i in {'Bow', 'Progressive Bow'}), None) if not bow_found: - pool.append('Progressive Bow') - - heart_found = next((i for i in pool if i in {'Boss Heart Container', 'Sanctuary Heart Container'}), None) - if heart_found is None: - pool.append('Boss Heart Container') + missing_items.append('Progressive Bow') + logging.getLogger('').warning(f'The following items are not in the custom item pool {", ".join(missing_items)}') g, t = set_default_triforce(world.goal[player], world.treasure_hunt_count[player], world.treasure_hunt_total[player]) @@ -1183,12 +1302,20 @@ def make_customizer_pool(world, player): if pieces < t: pool.extend(['Triforce Piece'] * (t - pieces)) - ttl_locations = sum(1 for x in world.get_unfilled_locations(player) if '- Prize' not in x.name) - pool_size = len(get_player_dungeon_item_pool(world, player)) + len(pool) - - if pool_size < ttl_locations: - amount_to_add = ttl_locations - pool_size - pool.extend(random.choices(list(filler_items.keys()), filler_items.values(), k=amount_to_add)) + if not world.customizer.get_start_inventory(): + if world.logic[player] in ['owglitches', 'nologic']: + precollected_items.append('Pegasus Boots') + if 'Pegasus Boots' in pool: + pool.remove('Pegasus Boots') + pool.append('Rupees (20)') + if world.swords[player] == 'assured': + precollected_items.append('Progressive Sword') + if 'Progressive Sword' in pool: + pool.remove('Progressive Sword') + pool.append('Rupees (50)') + elif 'Fighter Sword' in pool: + pool.remove('Fighter Sword') + pool.append('Rupees (50)') return pool, placed_items, precollected_items, clock_mode, 1 @@ -1204,15 +1331,15 @@ filler_items = { } -def get_player_dungeon_item_pool(world, player): - return [item for dungeon in world.dungeons for item in dungeon.all_items - if dungeon.player == player and item.location is None] +def count_player_dungeon_item_pool(world, player): + return sum(1 for dungeon in world.dungeons for item in dungeon.all_items + if dungeon.player == player and item.location is None and is_dungeon_item(item.name, world, player)) # location pool doesn't support larger values at this time def set_default_triforce(goal, custom_goal, custom_total): triforce_goal, triforce_total = 0, 0 - if goal == 'triforcehunt': + if goal in ['triforcehunt', 'ganonhunt']: triforce_goal, triforce_total = 20, 30 elif goal == 'trinity': triforce_goal, triforce_total = 8, 10 @@ -1267,31 +1394,94 @@ def fill_specific_items(world): for player, placement_list in placements.items(): for location, item in placement_list.items(): loc = world.get_location(location, player) - item_parts = item.split('#') - item_player = player if len(item_parts) < 2 else int(item_parts[1]) - item_name = item_parts[0] - event_flag = False - if is_dungeon_item(item_name, world, item_player): - item_to_place = next(x for x in dungeon_pool - if x.name == item_name and x.player == item_player) - dungeon_pool.remove(item_to_place) - event_flag = True - elif item_name in prize_set: - item_player = player # prizes must be for that player - item_to_place = ItemFactory(item_name, item_player) - prize_pool.remove(item_name) - event_flag = True - else: - item_to_place = next((x for x in world.itempool - if x.name == item_name and x.player == item_player), None) - if item_to_place is None: - item_to_place = ItemFactory(item_name, player) - else: - world.itempool.remove(item_to_place) - world.push_item(loc, item_to_place, False) - # track_outside_keys(item_to_place, spot_to_fill, world) - # track_dungeon_items(item_to_place, spot_to_fill, world) - loc.event = event_flag or item_to_place.advancement + item_to_place, event_flag = get_item_and_event_flag(item, world, player, + dungeon_pool, prize_set, prize_pool) + if item_to_place: + world.push_item(loc, item_to_place, False) + track_outside_keys(item_to_place, loc, world) + track_dungeon_items(item_to_place, loc, world) + loc.event = (event_flag or item_to_place.advancement + or item_to_place.bigkey or item_to_place.smallkey) + advanced_placements = world.customizer.get_advanced_placements() + if advanced_placements: + for player, placement_list in advanced_placements.items(): + for placement in placement_list: + if placement['type'] == 'LocationGroup': + item = placement['item'] + item_to_place, event_flag = get_item_and_event_flag(item, world, player, + dungeon_pool, prize_set, prize_pool) + if not item_to_place: + continue + locations = placement['locations'] + handled = False + while not handled: + if isinstance(locations, dict): + chosen_loc = random.choices(list(locations.keys()), list(locations.values()), k=1)[0] + else: # if isinstance(locations, list): + chosen_loc = random.choice(locations) + if chosen_loc == 'Random': + if is_dungeon_item(item_to_place.name, world, item_to_place.player): + dungeon_pool.append(item_to_place) + elif item_to_place.name in prize_set: + prize_pool.append(item_to_place.name) + else: + world.itempool.append(item_to_place) + else: + loc = world.get_location(chosen_loc, player) + if loc.item: + continue + world.push_item(loc, item_to_place, False) + track_outside_keys(item_to_place, loc, world) + track_dungeon_items(item_to_place, loc, world) + loc.event = (event_flag or item_to_place.advancement + or item_to_place.bigkey or item_to_place.smallkey) + handled = True + elif placement['type'] == 'NotLocationGroup': + item = placement['item'] + item_parts = item.split('#') + item_player = player if len(item_parts) < 2 else int(item_parts[1]) + item_name = item_parts[0] + world.item_pool_config.restricted[(item_name, item_player)] = placement['locations'] + elif placement['type'] == 'PreferredLocationGroup': + item = placement['item'] + item_parts = item.split('#') + item_player = player if len(item_parts) < 2 else int(item_parts[1]) + item_name = item_parts[0] + world.item_pool_config.preferred[(item_name, item_player)] = placement['locations'] + elif placement['type'] == 'Verification': + item = placement['item'] + item_parts = item.split('#') + item_player = player if len(item_parts) < 2 else int(item_parts[1]) + item_name = item_parts[0] + world.item_pool_config.verify[(item_name, item_player)] = placement['locations'] + world.item_pool_config.verify_target += len(placement['locations']) + + +def get_item_and_event_flag(item, world, player, dungeon_pool, prize_set, prize_pool): + item_parts = item.split('#') + item_player = player if len(item_parts) < 2 else int(item_parts[1]) + item_name = item_parts[0] + event_flag = False + if is_dungeon_item(item_name, world, item_player): + item_to_place = next(x for x in dungeon_pool + if x.name == item_name and x.player == item_player) + dungeon_pool.remove(item_to_place) + event_flag = True + elif item_name in prize_set: + item_player = player # prizes must be for that player + item_to_place = ItemFactory(item_name, item_player) + prize_pool.remove(item_name) + event_flag = True + else: + matcher = lambda x: x.name == item_name and x.player == item_player + if item_name == 'Bottle': + matcher = lambda x: x.name.startswith(item_name) and x.player == item_player + item_to_place = next((x for x in world.itempool if matcher(x)), None) + if item_to_place is None: + return None, event_flag + else: + world.itempool.remove(item_to_place) + return item_to_place, event_flag def is_dungeon_item(item, world, player): diff --git a/Items.py b/Items.py index 7121c603..579de263 100644 --- a/Items.py +++ b/Items.py @@ -81,7 +81,7 @@ item_table = {'Bow': (True, False, None, 0x0B, 200, 'You have\nchosen the\narche 'Arrows (5)': (False, False, None, 0x5A, 15, 'This will give\nyou five shots\nwith your bow!', 'and the arrow pack', 'stick-collecting kid', 'sewing kit for sale', 'fungus for arrows', 'archer boy sews again', 'five arrows'), 'Small Magic': (False, False, None, 0x45, 5, 'A bit of magic', 'and the bit of magic', 'bit-o-magic kid', 'magic bit for sale', 'fungus for magic', 'magic boy conjures again', 'a bit of magic'), 'Big Magic': (False, False, None, 0xB4, 40, 'A lot of magic', 'and lots of magic', 'lot-o-magic kid', 'magic refill for sale', 'fungus for magic', 'magic boy conjures again', 'a magic refill'), - 'Chicken': (False, False, None, 0xB3, 999, 'Cucco of Legend', 'and the legendary cucco', 'chicken kid', 'fried chicken for sale', 'fungus for chicken', 'cucco boy clucks again', 'a cucco'), + 'Chicken': (False, False, None, 0xB3, 5, 'Cucco of Legend', 'and the legendary cucco', 'chicken kid', 'fried chicken for sale', 'fungus for chicken', 'cucco boy clucks again', 'a cucco'), 'Bombs (3)': (False, False, None, 0x28, 15, 'I make things\ngo triple\nBOOM!!!', 'and the explosions', 'the bomb-holding kid', 'firecrackers for sale', 'blend fungus into bombs', '\'splosion boy explodes again', 'three bombs'), 'Bombs (10)': (False, False, None, 0x31, 50, 'I make things\ngo BOOM! Ten\ntimes!', 'and the explosions', 'the bomb-holding kid', 'firecrackers for sale', 'blend fungus into bombs', '\'splosion boy explodes again', 'ten bombs'), 'Bomb Upgrade (+10)': (False, False, None, 0x52, 100, 'Increase bomb\nstorage, low\nlow price', 'and the bomb bag', 'boom-enlarging kid', 'bomb boost for sale', 'the shroom goes boom', 'upgrade boy explodes more again', 'bomb capacity'), diff --git a/KeyDoorShuffle.py b/KeyDoorShuffle.py index 7d9aadd2..a5061899 100644 --- a/KeyDoorShuffle.py +++ b/KeyDoorShuffle.py @@ -5,7 +5,7 @@ from collections import defaultdict, deque from BaseClasses import DoorType, dungeon_keys, KeyRuleType, RegionType from Regions import dungeon_events from Dungeons import dungeon_keys, dungeon_bigs, dungeon_table -from DungeonGenerator import ExplorationState, special_big_key_doors, count_locations_exclude_big_chest, prize_or_event +from DungeonGenerator import ExplorationState, get_special_big_key_doors, count_locations_exclude_big_chest, prize_or_event from DungeonGenerator import reserved_location, blind_boss_unavail @@ -14,6 +14,7 @@ class KeyLayout(object): def __init__(self, sector, starts, proposal): self.sector = sector self.start_regions = starts + self.event_starts = [] self.proposal = proposal self.key_logic = KeyLogic(sector.name) @@ -58,13 +59,16 @@ class KeyLogic(object): self.placement_rules = [] self.location_rules = {} self.outside_keys = 0 + self.outside_keys_locations = set() self.dungeon = dungeon_name self.sm_doors = {} self.prize_location = None - def check_placement(self, unplaced_keys, wild_keys, big_key_loc=None, prize_loc=None, cr_count=7): + def check_placement(self, unplaced_keys, wild_keys, reached_keys, self_locking_keys, + big_key_loc=None, prize_loc=None, cr_count=7): for rule in self.placement_rules: - if not rule.is_satisfiable(self.outside_keys, wild_keys, unplaced_keys, big_key_loc, prize_loc, cr_count): + if not rule.is_satisfiable(self.outside_keys_locations, wild_keys, reached_keys, self_locking_keys, + unplaced_keys, big_key_loc, prize_loc, cr_count): return False if big_key_loc: for rule_a, rule_b in itertools.combinations(self.placement_rules, 2): @@ -158,7 +162,8 @@ class PlacementRule(object): left -= rule_needed return False - def is_satisfiable(self, outside_keys, wild_keys, unplaced_keys, big_key_loc, prize_location, cr_count): + def is_satisfiable(self, outside_keys_locations, wild_keys, reached_keys, self_locking_keys, unplaced_keys, + big_key_loc, prize_location, cr_count): if self.prize_relevance and prize_location: if self.prize_relevance == 'BigBomb': if prize_location.item.name not in ['Crystal 5', 'Crystal 6']: @@ -185,10 +190,11 @@ class PlacementRule(object): check_locations = self.check_locations_wo_bk if bk_blocked else self.check_locations_w_bk if not bk_blocked and check_locations is None: return True - available_keys = outside_keys + available_keys = len(outside_keys_locations) # todo: sometimes we need an extra empty chest to accomodate the big key too # dungeon bias seed 563518200 for example threshold = self.needed_keys_wo_bk if bk_blocked else self.needed_keys_w_bk + threshold -= self_locking_keys if not wild_keys: empty_chests = 0 for loc in check_locations: @@ -199,7 +205,8 @@ class PlacementRule(object): place_able_keys = min(empty_chests, unplaced_keys) available_keys += place_able_keys else: - available_keys += unplaced_keys + available_keys += len(reached_keys.difference(outside_keys_locations)) # already placed small keys + available_keys += unplaced_keys # small keys not yet placed return available_keys >= threshold @@ -223,13 +230,14 @@ class KeyCounter(object): return max(self.used_keys + reserve - len(self.key_only_locations), 0) -def build_key_layout(builder, start_regions, proposal, world, player): +def build_key_layout(builder, start_regions, proposal, event_starts, world, player): key_layout = KeyLayout(builder.master_sector, start_regions, proposal) key_layout.flat_prop = flatten_pair_list(key_layout.proposal) key_layout.max_drops = count_key_drops(key_layout.sector) key_layout.max_chests = calc_max_chests(builder, key_layout, world, player) key_layout.big_key_special = check_bk_special(key_layout.sector.region_set(), world, player) key_layout.all_locations = find_all_locations(key_layout.sector) + key_layout.event_starts = list(event_starts.keys()) return key_layout @@ -301,10 +309,10 @@ def analyze_dungeon(key_layout, world, player): key_logic.bk_restricted.update(filter_big_chest(key_counter.free_locations)) # note to self: this is due to the enough_small_locations function in validate_key_layout_sub_loop # I don't like this exception here or there - elif available < possible_smalls and avail_bigs and non_big_locs > 0: - max_ctr = find_max_counter(key_layout) - bk_lockdown = [x for x in max_ctr.free_locations if x not in key_counter.free_locations] - key_logic.bk_restricted.update(filter_big_chest(bk_lockdown)) + # elif available < possible_smalls and avail_bigs and non_big_locs > 0: + # max_ctr = find_max_counter(key_layout) + # bk_lockdown = [x for x in max_ctr.free_locations if x not in key_counter.free_locations] + # key_logic.bk_restricted.update(filter_big_chest(bk_lockdown)) # try to relax the rules here? - smallest requirement that doesn't force a softlock child_queue = deque() for child in key_counter.child_doors.keys(): @@ -1000,7 +1008,7 @@ def find_worst_counter_wo_bk(small_key_num, accessible_set, door, odd_ctr, key_c def open_a_door(door, child_state, flat_proposal, world, player): - if door.bigKey or door.name in special_big_key_doors: + if door.bigKey or door.name in get_special_big_key_doors(world, player): child_state.big_key_opened = True child_state.avail_doors.extend(child_state.big_doors) child_state.opened_doors.extend(set([d.door for d in child_state.big_doors])) @@ -1443,7 +1451,7 @@ def validate_bk_layout(proposal, builder, start_regions, world, player): if loc.forced_big_key(): return True else: - return len(state.bk_found) > 0 + return state.count_locations_exclude_specials(world, player) > 0 return False @@ -1455,6 +1463,7 @@ def validate_key_layout(key_layout, world, player): return True flat_proposal = key_layout.flat_prop state = ExplorationState(dungeon=key_layout.sector.name) + state.init_zelda_event_doors(key_layout.event_starts, player) state.key_locations = key_layout.max_chests state.big_key_special = check_bk_special(key_layout.sector.regions, world, player) for region in key_layout.start_regions: @@ -1482,14 +1491,16 @@ def validate_key_layout_sub_loop(key_layout, state, checked_states, flat_proposa else: ttl_locations = count_locations_exclude_big_chest(state.found_locations, world, player) ttl_small_key_only = count_small_key_only_locations(state) - available_small_locations = cnt_avail_small_locations(ttl_locations, ttl_small_key_only, state, world, player) + available_small_locations = cnt_avail_small_locations(ttl_locations, ttl_small_key_only, state, + key_layout, world, player) available_big_locations = cnt_avail_big_locations(ttl_locations, state, world, player) if invalid_self_locking_key(key_layout, state, prev_state, prev_avail, world, player): return False # todo: allow more key shuffles - refine placement rules # if (not smalls_avail or available_small_locations == 0) and (state.big_key_opened or num_bigs == 0 or available_big_locations == 0): found_forced_bk = state.found_forced_bk() - smalls_done = not smalls_avail or not enough_small_locations(state, available_small_locations) + smalls_done = not smalls_avail or available_small_locations == 0 + # or not enough_small_locations(state, available_small_locations) bk_done = state.big_key_opened or num_bigs == 0 or (available_big_locations == 0 and not found_forced_bk) # prize door should not be opened if the boss is reachable - but not reached yet allow_for_prize_lock = (key_layout.prize_can_lock and @@ -1611,18 +1622,24 @@ def determine_prize_lock(key_layout, world, player): key_layout.prize_can_lock = True -def cnt_avail_small_locations(free_locations, key_only, state, world, player): - if world.keyshuffle[player] == 'none': +def cnt_avail_small_locations(free_locations, key_only, state, key_layout, world, player): + std_flag = world.mode[player] == 'standard' and key_layout.sector.name == 'Hyrule Castle' + if world.keyshuffle[player] == 'none' or std_flag: bk_adj = 1 if state.big_key_opened and not state.big_key_special else 0 - avail_chest_keys = min(free_locations - bk_adj, state.key_locations - key_only) + # this is the secret passage, could expand to Uncle/Links House with appropriate logic + std_adj = 1 if std_flag and world.keyshuffle[player] != 'none' else 0 + avail_chest_keys = min(free_locations + std_adj - bk_adj, state.key_locations - key_only) return max(0, avail_chest_keys + key_only - state.used_smalls) return state.key_locations - state.used_smalls def cnt_avail_small_locations_by_ctr(free_locations, counter, layout, world, player): - if world.keyshuffle[player] == 'none': + std_flag = world.mode[player] == 'standard' and layout.sector.name == 'Hyrule Castle' + if world.keyshuffle[player] == 'none' or std_flag: bk_adj = 1 if counter.big_key_opened and not layout.big_key_special else 0 - avail_chest_keys = min(free_locations - bk_adj, layout.max_chests) + # this is the secret passage, could expand to Uncle/Links House with appropriate logic + std_adj = 1 if std_flag and world.keyshuffle[player] != 'none' else 0 + avail_chest_keys = min(free_locations + std_adj - bk_adj, layout.max_chests) return max(0, avail_chest_keys + len(counter.key_only_locations) - counter.used_keys) return layout.max_chests + len(counter.key_only_locations) - counter.used_keys @@ -1646,6 +1663,7 @@ def create_key_counters(key_layout, world, player): key_layout.found_doors.clear() flat_proposal = key_layout.flat_prop state = ExplorationState(dungeon=key_layout.sector.name) + state.init_zelda_event_doors(key_layout.event_starts, player) if world.doorShuffle[player] == 'vanilla': builder = world.dungeon_layouts[player][key_layout.sector.name] state.key_locations = len(builder.key_door_proposal) - builder.key_drop_cnt @@ -1678,10 +1696,10 @@ def create_key_counters(key_layout, world, player): if door.dest in flat_proposal and door.type != DoorType.SpiralStairs: key_layout.found_doors.add(door.dest) child_state = parent_state.copy() - if door.bigKey or door.name in special_big_key_doors: + if door.bigKey or door.name in get_special_big_key_doors(world, player): key_layout.key_logic.bk_doors.add(door) # open the door, if possible - if can_open_door(door, child_state, world, player): + if can_open_door(door, child_state, key_layout, world, player): open_a_door(door, child_state, flat_proposal, world, player) expand_key_state(child_state, flat_proposal, world, player) code = state_id(child_state, key_layout.flat_prop) @@ -1702,14 +1720,15 @@ def find_outside_connection(region): return None, None -def can_open_door(door, state, world, player): +def can_open_door(door, state, key_layout, world, player): if state.big_key_opened: ttl_locations = count_free_locations(state, world, player) else: ttl_locations = count_locations_exclude_big_chest(state.found_locations, world, player) if door.smallKey: ttl_small_key_only = count_small_key_only_locations(state) - available_small_locations = cnt_avail_small_locations(ttl_locations, ttl_small_key_only, state, world, player) + available_small_locations = cnt_avail_small_locations(ttl_locations, ttl_small_key_only, state, + key_layout, world, player) return available_small_locations > 0 elif door.bigKey: available_big_locations = cnt_avail_big_locations(ttl_locations, state, world, player) @@ -2075,6 +2094,12 @@ def validate_key_placement(key_layout, world, player): if world.bigkeyshuffle[player]: max_counter = find_max_counter(key_layout) big_key_outside = bigkey_name not in (l.item.name for l in max_counter.free_locations if l.item) + for i in world.precollected_items: + if i.player == player and i.name == bigkey_name: + big_key_outside = True + break + if i.player == player and i.name == smallkey_name: + keys_outside += 1 for code, counter in key_layout.key_counters.items(): if len(counter.child_doors) == 0: diff --git a/Main.py b/Main.py index c6e0d618..502afa42 100644 --- a/Main.py +++ b/Main.py @@ -24,6 +24,7 @@ from RoomData import create_rooms from Rules import set_rules from Dungeons import create_dungeons from Fill import distribute_items_restrictive, promote_dungeon_items, fill_dungeons_restrictive, ensure_good_items +from Fill import dungeon_tracking from Fill import sell_potions, sell_keys, balance_multiworld_progression, balance_money_progression, lock_shop_locations from ItemList import generate_itempool, difficulties, fill_prizes, customize_shops, fill_specific_items from Utils import output_path, parse_player_names @@ -35,7 +36,7 @@ from source.classes.CustomSettings import CustomSettings from source.rom.DataTables import init_data_tables -__version__ = '1.0.1.3-x' +__version__ = '1.3.0.0-x' from source.classes.BabelFish import BabelFish @@ -71,9 +72,8 @@ def main(args, seed=None, fish=None): if args.customizer: customized = CustomSettings() customized.load_yaml(args.customizer) - seed = customized.determine_seed() - if seed: - seeded = True + seed = customized.determine_seed(seed) + seeded = True customized.adjust_args(args) world = World(args.multi, args.shuffle, args.door_shuffle, args.logic, args.mode, args.swords, args.difficulty, args.item_functionality, args.timer, args.progressive, args.goal, args.algorithm, @@ -91,6 +91,7 @@ def main(args, seed=None, fish=None): if args.securerandom: world.seed = ''.join(random.choice(string.ascii_uppercase + string.ascii_lowercase + string.digits) for _ in range(9)) + world.boots_hint = args.boots_hint.copy() world.remote_items = args.remote_items.copy() world.mapshuffle = args.mapshuffle.copy() world.compassshuffle = args.compassshuffle.copy() @@ -111,6 +112,8 @@ def main(args, seed=None, fish=None): world.beemizer = args.beemizer.copy() world.intensity = {player: random.randint(1, 3) if args.intensity[player] == 'random' else int(args.intensity[player]) for player in range(1, world.players + 1)} world.door_type_mode = args.door_type_mode.copy() + world.trap_door_mode = args.trap_door_mode.copy() + world.key_logic_algorithm = args.key_logic_algorithm.copy() world.decoupledoors = args.decoupledoors.copy() world.experimental = args.experimental.copy() world.dungeon_counters = args.dungeon_counters.copy() @@ -151,7 +154,7 @@ def main(args, seed=None, fish=None): world.player_names[player].append(name) logger.info('') world.settings = CustomSettings() - world.settings.create_from_world(world) + world.settings.create_from_world(world, args.race) outfilebase = f'DR_{args.outputname if args.outputname else world.seed}' @@ -261,6 +264,7 @@ def main(args, seed=None, fish=None): massage_item_pool(world) if args.print_custom_yaml: world.settings.record_item_pool(world) + dungeon_tracking(world) fill_specific_items(world) logger.info(world.fish.translate("cli", "cli", "placing.dungeon.prizes")) @@ -299,6 +303,7 @@ def main(args, seed=None, fish=None): balance_multiworld_progression(world) # if we only check for beatable, we can do this sanity check first before creating the rom + world.clear_exp_cache() if not world.can_beat_game(log_error=True): raise RuntimeError(world.fish.translate("cli", "cli", "cannot.beat.game")) @@ -518,10 +523,8 @@ def copy_world(world): new_location.item = item item.location = new_location item.world = ret - if location.event: - new_location.event = True - if location.locked: - new_location.locked = True + new_location.event = location.event + new_location.locked = location.locked # these need to be modified properly by set_rules new_location.access_rule = lambda state: True new_location.item_rule = lambda state: True @@ -590,7 +593,8 @@ def create_playthrough(world): world = copy_world(world) # get locations containing progress items - prog_locations = [location for location in world.get_filled_locations() if location.item.advancement] + prog_locations = [location for location in world.get_filled_locations() if location.item.advancement + or world.goal[location.player] == 'completionist'] optional_locations = ['Trench 1 Switch', 'Trench 2 Switch', 'Ice Block Drop', 'Skull Star Tile'] state_cache = [None] collection_spheres = [] @@ -627,13 +631,15 @@ def create_playthrough(world): for num, sphere in reversed(list(enumerate(collection_spheres))): to_delete = set() for location in sphere: + if world.goal[location.player] == 'completionist': + continue # every location for that player is required # we remove the item at location and check if game is still beatable logging.getLogger('').debug('Checking if %s (Player %d) is required to beat the game.', location.item.name, location.item.player) old_item = location.item location.item = None # todo: this is not very efficient, but I'm not sure how else to do it for this backwards logic - # world.clear_exp_cache() - if world.can_beat_game(state_cache[num]): + world.clear_exp_cache() + if world.can_beat_game(state_cache[max(num-1, 0)]): logging.getLogger('').debug(f'{old_item.name} (Player {old_item.player}) is not required') to_delete.add(location) else: diff --git a/MultiServer.py b/MultiServer.py index 4108b8dd..a9f9e9e2 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -46,6 +46,9 @@ class Context: self.lookup_name_to_id = {} self.lookup_id_to_name = {} + self.disable_client_forfeit = False + + async def send_msgs(websocket, msgs): if not websocket or not websocket.open or websocket.closed: return @@ -281,7 +284,10 @@ async def process_client_cmd(ctx : Context, client : Client, cmd, args): if args.startswith('!players'): notify_all(ctx, get_connected_players_string(ctx)) if args.startswith('!forfeit'): - forfeit_player(ctx, client.team, client.slot) + if ctx.disable_client_forfeit: + notify_client(client, 'Client-initiated forfeits are disabled. Please ask the host of this game to forfeit on your behalf.') + else: + forfeit_player(ctx, client.team, client.slot) if args.startswith('!countdown'): try: timer = int(args.split()[1]) diff --git a/PotShuffle.py b/PotShuffle.py index fc109b4d..c1794e94 100644 --- a/PotShuffle.py +++ b/PotShuffle.py @@ -745,11 +745,11 @@ vanilla_pots = { 0xE7: [Pot(68, 5, PotItem.OneRupee, 'Death Mountain Return Cave (right)', obj=RoomObject(0x0AB389, [0x8B, 0x2B, 0xFA])), Pot(72, 5, PotItem.OneRupee, 'Death Mountain Return Cave (right)', obj=RoomObject(0x0AB38C, [0x93, 0x2B, 0xFA]))], 0xE8: [Pot(96, 4, PotItem.Heart, 'Superbunny Cave (Bottom)', obj=RoomObject(0x0AA98E, [0xC3, 0x23, 0xFA]))], - 0xEB: [Pot(206, 8, PotItem.FiveRupees, 'Bumper Cave', obj=RoomObject(0x0AADE7, [0x9F, 0x47, 0xFA])), - Pot(210, 8, PotItem.FiveRupees, 'Bumper Cave', obj=RoomObject(0x0AADEA, [0xA7, 0x47, 0xFA])), - Pot(88, 14, PotItem.SmallMagic, 'Bumper Cave', obj=RoomObject(0x0AADED, [0xB3, 0x73, 0xFA])), - Pot(92, 14, PotItem.Heart, 'Bumper Cave', obj=RoomObject(0x0AADF0, [0xBB, 0x73, 0xFA])), - Pot(96, 14, PotItem.SmallMagic, 'Bumper Cave', obj=RoomObject(0x0AADF3, [0xC3, 0x73, 0xFA]))], + 0xEB: [Pot(206, 8, PotItem.FiveRupees, 'Bumper Cave (bottom)', obj=RoomObject(0x0AADE7, [0x9F, 0x47, 0xFA])), + Pot(210, 8, PotItem.FiveRupees, 'Bumper Cave (bottom)', obj=RoomObject(0x0AADEA, [0xA7, 0x47, 0xFA])), + Pot(88, 14, PotItem.SmallMagic, 'Bumper Cave (bottom)', obj=RoomObject(0x0AADED, [0xB3, 0x73, 0xFA])), + Pot(92, 14, PotItem.Heart, 'Bumper Cave (bottom)', obj=RoomObject(0x0AADF0, [0xBB, 0x73, 0xFA])), + Pot(96, 14, PotItem.SmallMagic, 'Bumper Cave (bottom)', obj=RoomObject(0x0AADF3, [0xC3, 0x73, 0xFA]))], 0xF1: [Pot(64, 5, PotItem.Heart, 'Old Man Cave', obj=RoomObject(0x0AA6B2, [0x83, 0x2B, 0xFA]))], 0xF3: [Pot(0x28, 0x14, PotItem.Nothing, 'Elder House', obj=RoomObject(0x0AA76F, [0x53, 0xA3, 0xFA])), Pot(0x2C, 0x14, PotItem.Nothing, 'Elder House', obj=RoomObject(0x0AA772, [0x5B, 0xA3, 0xFA])), @@ -851,10 +851,10 @@ vanilla_pots = { Pot(100, 22, PotItem.Heart, 'Dark Lake Hylia Ledge Spike Cave', obj=RoomObject(0x0AB62A, [0xCB, 0xB3, 0xFA])), Pot(88, 28, PotItem.Heart, 'Dark Lake Hylia Ledge Spike Cave', obj=RoomObject(0x0AB633, [0xB3, 0xE3, 0xFA])), Pot(100, 28, PotItem.Heart, 'Dark Lake Hylia Ledge Spike Cave', obj=RoomObject(0x0AB636, [0xCB, 0xE3, 0xFA]))], - 0x127: [Pot(24, 25, PotItem.Nothing, 'Dark World Hammer Peg Cave', obj=RoomObject(0x2A801A, [0x33, 0xCB, 0xFA])), - Pot(28, 25, PotItem.Nothing, 'Dark World Hammer Peg Cave', obj=RoomObject(0x2A801D, [0x3B, 0xCB, 0xFA])), - Pot(32, 25, PotItem.Nothing, 'Dark World Hammer Peg Cave', obj=RoomObject(0x2A8020, [0x43, 0xCB, 0xFA])), - Pot(36, 25, PotItem.Nothing, 'Dark World Hammer Peg Cave', obj=RoomObject(0x2A8023, [0x4B, 0xCB, 0xFA]))], + 0x127: [Pot(24, 25, PotItem.Nothing, 'Dark World Hammer Peg Cave', obj=RoomObject(0x2B801A, [0x33, 0xCB, 0xFA])), + Pot(28, 25, PotItem.Nothing, 'Dark World Hammer Peg Cave', obj=RoomObject(0x2B801D, [0x3B, 0xCB, 0xFA])), + Pot(32, 25, PotItem.Nothing, 'Dark World Hammer Peg Cave', obj=RoomObject(0x2B8020, [0x43, 0xCB, 0xFA])), + Pot(36, 25, PotItem.Nothing, 'Dark World Hammer Peg Cave', obj=RoomObject(0x2B8023, [0x4B, 0xCB, 0xFA]))], } diff --git a/README.md b/README.md index f2980840..fbe9eed5 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,47 @@ This is a door randomizer for _The Legend of Zelda: A Link to the Past_ for the based on the Entrance Randomizer found at [KevinCathcart's Github Project.](https://github.com/KevinCathcart/ALttPEntranceRandomizer) See https://alttpr.com/ for more details on the normal randomizer. -# Known Issues +# Documentation +1. [Setup and Installation](#setup-and-installation) +2. [Commonly Missed Things](#commonly-missed-things) (** **Read This If New** **) +3. [Settings](#settings) + 1. [Dungeon Randomization](#dungeon-settings) + 1. [Dungeon Door Shuffle](#door-shuffle) + 2. [Intensity Level](#intensity---intensity-number) + 3. [Key Drop Shuffle (Legacy)](#key-drop-shuffle-legacy---keydropshuffle) + 4. [Door Type Shuffle](#door-type-shuffle) + 5. [Trap Door Removal](#trap-door-removal) + 6. [Key Logic Algorithm](#key-logic-algorithm) + 7. [Decouple Doors](#decouple-doors) + 8. [Pottery](#pottery) + 9. [Small Key Shuffle](#small-key-shuffle) + 10. [Shuffle Enemy Key Drops](#shuffle-enemy-key-drops) + 11. [Experimental Features](#experimental-features) + 12. [Crossed Dungeon Specific Settings](#crossed-dungeon-specific-settings) + 2. [Item Randomization Changes](#item-randomization) + 1. [New "Items"](#new-items) + 2. [Shopsanity](#shopsanity) + 3. [Logic Level](#logic-level) + 4. [Goal](#goal) + 5. [Item Sorting](#item-sorting) + 6. [Forbidden Boss Items](#forbidden-boss-items) + 3. [Customizer](#customizer) + 4. [Entrance Randomization](#entrance-randomization) + 1. [Shuffle Links House](#shuffle-links-house) + 2. [Overworld Map](#overworld-map) + 5. [Enemizer](#enemizer) + 6. [Retro Changes](#retro-changes) + 7. [Standard Changes](#standard-changes) + 8. [Game Options](#game-options) + 9. [Generation Setup & Miscellaneous](#generation-setup--miscellaneous) -[List of Known Issues and Their Status](https://docs.google.com/document/d/1Bk-m-QRvH5iF60ndptKYgyaV7P93D3TiG8xmdxp_bdQ/edit?usp=sharing) +## Setup and Installation -# Feedback and Bug Reports +### Feedback and Bug Reports -Please just DM me on discord for now. I (Aerinon) can be found at the [ALTTP Randomizer discord](https://discordapp.com/invite/alttprandomizer). +You can use the #bug-reports or #door-rando channel at the [ALTTP Randomizer discord](https://discordapp.com/invite/alttprandomizer) to provide feedback or bug reports. -# Installation +### Installation Click on @@ -22,7 +54,7 @@ Go down to Assets and find a build for your system (Windows, Mac, or Linux) Download and unzip. Find the DungeonRandomizer.exe or equivalent -# Installation from source +### Installation from source See these instructions. @@ -36,21 +68,21 @@ To use the CLI, run ```DungeonRandomizer.py```. Alternatively, run ```Gui.py``` for a simple graphical user interface. -# Commonly Missed Things and Differences from other Randomizers +# Commonly Missed Things +#### and Differences from other Randomizers Most of these apply only when the door shuffle is not vanilla. ### Starting Item -You start with a “Mirror Scroll”, a dumbed-down mirror that only works in dungeons, not the overworld and can’t erase blocks like the Mirror. +You start with a “Mirror Scroll” (it looks like a map), a dumbed-down mirror that only works in dungeons, not the overworld, and can’t erase blocks like the Mirror. ### Navigation -* The Pinball Room’s trap door can be removed in the case where it is required to go through to get to the back of Skull Woods. * Holes in Mire Torches Top and Mire Torches Bottom fall through to rooms below (you only need fire to get the chest) * You can Hookshot from the left Mire wooden Bridge to the right one. * In the PoD Arena, you can bonk with Boots between the two blue crystal barriers against the ladder to reach the Arena Bridge chest and door. (Bomb Jump also possible but not in logic - Boots are required) -* Flooded Rooms in Swamp can be traversed backward and may be required. +* Flooded Rooms in Swamp can be traversed backward and may be required. The flippers are needed to get out of the water. ### Other Logic @@ -76,69 +108,189 @@ You start with a “Mirror Scroll”, a dumbed-down mirror that only works in du # Settings +## Dungeon Settings + Only extra settings are found here. All entrance randomizer settings are supported. See their [readme](https://github.com/KevinCathcart/ALttPEntranceRandomizer/blob/master/README.md) -## Door Shuffle (--doorShuffle) +### Door Shuffle -### Basic +* Vanilla - Doors are not shuffled +* Basic - Doors are shuffled only within a single dungeon. +* Paritioned - Dungeons are shuffled in 3 pools: Light World, Early Dark World, Late Dark World. (Late Dark are the four dungeons that require Mitts in vanilla, including Ganons Tower) +* Crossed - Doors are shuffled between dungeons as well. -Doors are shuffled only within a single dungeon. +CLI: `--doorShuffle [vanilla|basic|partitioned|crossed]` -### Crossed +### Intensity (--intensity number) -Doors are shuffled between dungeons as well. +* Level 1 - Normal door and spiral staircases are shuffled +* Level 2 - Same as Level 1 plus open edges and both types of straight staircases are shuffled. +* Level 3 - Same as Level 2 plus Dungeon Lobbies are shuffled -### Vanilla - -Doors are not shuffled. - -## Intensity (--intensity number) - -#### Level 1 -Normal door and spiral staircases are shuffled -#### Level 2 -Same as Level 1 plus open edges and both types of straight staircases are shuffled. -#### Level 3 -Same as Level 2 plus Dungeon Lobbies are shuffled - -## KeyDropShuffle (--keydropshuffle) +### Key Drop Shuffle (Legacy) (--keydropshuffle) Adds 33 new locations to the randomization pool. The 32 small keys found under pots and dropped by enemies and the Big -Key drop location are added to the pool. The keys normally found there are added to the item pool. Retro adds -32 generic keys to the pool instead. +Key drop location are added to the pool. The keys normally found there are added to the item pool. Retro adds +32 generic keys to the pool instead. This has been can be controlled more granularly with the [Pottery](#pottery) and +[Shuffle Enemy Key Drops](#shuffle-enemy-key-drops) -## Crossed Dungeon Specific Settings +### Door Type Shuffle -### Mixed Travel (--mixed_travel value) +Four options here, and all of them only take effect if Dungeon Door Shuffle is not vanilla: -Due to Hammerjump, Hovering in PoD Arena, and the Mire Big Key Chest bomb jump two sections of a supertile that are +* Small Key Doors, Bomb Doors, Dash Doors: This is what was normally shuffled previously +* Adds Big Keys Doors: Big key doors are now shuffled in addition to those above, and Big Key doors are enabled to be on in both vertical directions thanks to a graphic that ended up on the cutting room floor. This does change +* Adds Trap Doors: All trap doors that are permanently shut in vanilla are shuffled, excluding those by bosses. +* Increases all Door Types: This is a chaos mode where each door type per dungeon is randomized between 1 less and 4 more. + +CLI: `--door_type_mode [original|big|all|chaos]` + +### Trap Door Removal + +Options here for making dungeon traversal nicer. Only applies if door shuffle is not vanilla. + +* No Removal: This does not remove any trap doors. +* Removed If Blocking Path: Dungeon generation is relaxed to allow annoying trap doors to be removed if necessary. Note that boss trap doors are never shuffled in this mode. +* Remove Boss Traps: Boss traps are removed, this includes the one near Mothula. +* Remove All Annoying Traps: This removes all trap doors that are annoying, including boss traps. + +If trap doors are shuffled the first two option behave the same. The last option overrides the shuffle because there is nothing left to shuffle. Boss traps are never shuffled. + +In all cases, that the trap door near the mire cutscene chest (Mire Warping Pool ES) is left alone because it enforces the use of fire to get to the chest. + +CLI: `--trap_door_mode [vanilla|optional|boss|oneway]` + +### Key Logic Algorithm + +Determines how small key door logic works. + +* Default: Current key logic. Assumes worse case usage, placement checks, but assumes you can't get to a chest until you have sufficient keys. (May assume items are unreachable) +* Partial Protection: Assumes you always have full inventory and worse case usage. This should account for dark room and bunny revival glitches. +* Strict: For those would like to glitch and be protected from yourselves. Small keys door require all small keys to be available to be in logic. + +CLI: `--key_logic [default|partial|strict]` + +### Decouple Doors + +This is similar to insanity mode in ER where door entrances and exits are not paired anymore. Tends to remove more logic from dungeons as many rooms will not be required to traverse to explore. Hope you like transitions. + +CLI `--decoupledoors` + +### Pottery + +New pottery option that control which pots (and large blocks) are in the locations pool: + +* None: No pots are in the pool, like normal randomizer +* Key Pots: The pots that have keys are in the pool. This is about half of the old keydropshuffle option +* Cave Pots: The pots that are not found in dungeons are in the pool. (Includes the large block in Spike Cave). Does + not include key pots. +* Cave + Keys Pots: Both non-dungeon pots and pots that used to have keys are in the pool. +* Reduced Dungeon Pots: Same as Cave+Keys but also roughly a quarter of dungeon pots are added to the location pool picked at random. This is a dynamic mode so pots in the pool will be colored. Pots out of the pool will have vanilla contents. +* Clustered Dungeon Pots: Like reduced but pots are grouped by logical sets and roughly 50% of pots are chosen from those groups. This is a dynamic mode like the above. +* Excludes Empty Pots: All pots that had some sort of objects under them are chosen to be in the location pool. This excludes most large blocks and some pots out of dungeons. +* Dungeon Pots: The pots that are in dungeons are in the pool. (Includes serveral large blocks) +* Lottery: All pots and large blocks are in the pool. + +By default, switches remain in their vanilla location (unless you turn on the legacy option below) + +CLI `--pottery