diff --git a/Adjuster.py b/Adjuster.py index c6f42e6e..a6e964a8 100755 --- a/Adjuster.py +++ b/Adjuster.py @@ -36,6 +36,7 @@ def main(): parser.add_argument('--ow_palettes', default='default', choices=['default', 'random', 'blackout']) parser.add_argument('--uw_palettes', default='default', choices=['default', 'random', 'blackout']) parser.add_argument('--reduce_flashing', help='Reduce some in-game flashing.', action='store_true') + parser.add_argument('--shuffle_sfx', help='Shuffles sound sfx', action='store_true') parser.add_argument('--sprite', help='''\ Path to a sprite sheet to use for Link. Needs to be in binary format and have a length of 0x7000 (28672) bytes, diff --git a/AdjusterMain.py b/AdjusterMain.py index bc463444..7d7e2f6e 100644 --- a/AdjusterMain.py +++ b/AdjusterMain.py @@ -25,7 +25,7 @@ def adjust(args): args.sprite = None apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.fastmenu, args.disablemusic, - args.sprite, args.ow_palettes, args.uw_palettes, args.reduce_flashing) + args.sprite, args.ow_palettes, args.uw_palettes, args.reduce_flashing, args.shuffle_sfx) output_path.cached_path = args.outputpath rom.write_to_file(output_path('%s.sfc' % outfilebase)) diff --git a/BaseClasses.py b/BaseClasses.py index 0e3e8af4..4ea4bf3d 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -77,6 +77,7 @@ class World(object): self._room_cache = {} self.dungeon_layouts = {} self.inaccessible_regions = {} + self.enabled_entrances = {} self.key_logic = {} self.pool_adjustment = {} self.key_layout = defaultdict(dict) @@ -96,6 +97,7 @@ class World(object): set_player_attr('player_names', []) set_player_attr('remote_items', False) set_player_attr('required_medallions', ['Ether', 'Quake']) + set_player_attr('bottle_refills', ['Bottle (Green Potion)', 'Bottle (Green Potion)']) set_player_attr('swamp_patch_required', False) set_player_attr('powder_patch_required', False) set_player_attr('ganon_at_pyramid', True) @@ -114,6 +116,7 @@ class World(object): set_player_attr('compassshuffle', False) set_player_attr('keyshuffle', False) set_player_attr('bigkeyshuffle', False) + set_player_attr('bombbag', False) set_player_attr('difficulty_requirements', None) set_player_attr('boss_shuffle', 'none') set_player_attr('enemy_shuffle', 'none') @@ -139,6 +142,9 @@ class World(object): set_player_attr('standardize_palettes', 'standardize') set_player_attr('force_fix', {'gt': False, 'sw': False, 'pod': False, 'tr': False}) + set_player_attr('exp_cache', defaultdict(dict)) + set_player_attr('enabled_entrances', {}) + 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)})' @@ -393,6 +399,10 @@ class World(object): def clear_location_cache(self): self._cached_locations = None + def clear_exp_cache(self): + for p in range(1, self.players + 1): + self.exp_cache[p].clear() + def get_unfilled_locations(self, player=None): return [location for location in self.get_locations() if (player is None or location.player == player) and location.item is None] @@ -425,8 +435,10 @@ class World(object): else: return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1))) - def can_beat_game(self, starting_state=None): + def can_beat_game(self, starting_state=None, log_error=False): if starting_state: + if self.has_beaten_game(starting_state): + return True state = starting_state.copy() else: state = CollectionState(self) @@ -445,6 +457,9 @@ class World(object): if not sphere: # ran out of places and did not finish yet, quit + if log_error: + missing_locations = ", ".join([x.name for x in prog_locations]) + logging.getLogger('').error(f'Cannot reach the following locations: {missing_locations}') return False for location in sphere: @@ -459,17 +474,25 @@ class World(object): class CollectionState(object): - def __init__(self, parent): - self.prog_items = Counter() + def __init__(self, parent, skip_init=False): self.world = parent - 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 = [] - self.path = {} - self.locations_checked = set() - self.stale = {player: True for player in range(1, parent.players + 1)} - for item in parent.precollected_items: - self.collect(item, True) + if not skip_init: + self.prog_items = 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 = [] + self.path = {} + self.locations_checked = set() + self.stale = {player: True for player in range(1, parent.players + 1)} + for item in parent.precollected_items: + self.collect(item, True) + # reached vs. opened in the counter + self.door_counter = {player: (Counter(), Counter()) for player in range(1, parent.players + 1)} + self.reached_doors = {player: set() for player in range(1, parent.players + 1)} + 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.trace = None def update_reachable_regions(self, player): self.stale[player] = False @@ -480,68 +503,386 @@ class CollectionState(object): start = self.world.get_region('Menu', player) if not start in rrp: rrp[start] = CrystalBarrier.Orange - for exit in start.exits: - bc[exit] = CrystalBarrier.Orange + for conn in start.exits: + bc[conn] = CrystalBarrier.Orange 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) + + def traverse_world(self, queue, rrp, bc, player): # run BFS on all connections, and keep track of those blocked by missing items - while True: - try: - connection, crystal_state = queue.popleft() - new_region = connection.connected_region - if new_region is None or new_region in rrp and (new_region.type != RegionType.Dungeon or (rrp[new_region] & crystal_state) == crystal_state): + while len(queue) > 0: + connection, crystal_state = queue.popleft() + new_region = connection.connected_region + if not self.should_visit(new_region, rrp, crystal_state, player): + if not new_region or not self.dungeon_limits or self.possibly_connected_to_dungeon(new_region, player): bc.pop(connection, None) - elif connection.can_reach(self): - if new_region.type == RegionType.Dungeon: - new_crystal_state = crystal_state - for exit in new_region.exits: - door = exit.door - if door is not None and door.crystal == CrystalBarrier.Either and door.entrance.can_reach(self): - new_crystal_state = CrystalBarrier.Either - break - if new_region in rrp: - new_crystal_state |= rrp[new_region] + elif connection.can_reach(self): + bc.pop(connection, None) + if new_region.type == RegionType.Dungeon: + new_crystal_state = crystal_state + if new_region in rrp: + new_crystal_state |= rrp[new_region] - rrp[new_region] = new_crystal_state - - for exit in new_region.exits: - door = exit.door - if door is not None and not door.blocked: + rrp[new_region] = new_crystal_state + for conn in new_region.exits: + door = conn.door + if door is not None and not door.blocked: + if self.valid_crystal(door, new_crystal_state): door_crystal_state = door.crystal if door.crystal else new_crystal_state - bc[exit] = door_crystal_state - queue.append((exit, door_crystal_state)) - elif door is None: - queue.append((exit, new_crystal_state)) - else: - new_crystal_state = CrystalBarrier.Orange - rrp[new_region] = new_crystal_state - bc.pop(connection, None) - for exit in new_region.exits: - bc[exit] = new_crystal_state - queue.append((exit, new_crystal_state)) + bc[conn] = door_crystal_state + queue.append((conn, door_crystal_state)) + elif door is None: + # note: no door in dungeon indicates what exactly? (always traversable)? + queue.append((conn, new_crystal_state)) + else: + new_crystal_state = CrystalBarrier.Orange + rrp[new_region] = new_crystal_state + for conn in new_region.exits: + bc[conn] = new_crystal_state + queue.append((conn, new_crystal_state)) - self.path[new_region] = (new_region.name, self.path.get(connection, None)) + self.path[new_region] = (new_region.name, self.path.get(connection, None)) - # Retry connections if the new region can unblock them - if new_region.name in indirect_connections: - new_entrance = self.world.get_entrance(indirect_connections[new_region.name], player) - if new_entrance in bc and new_entrance not in queue and new_entrance.parent_region in rrp: - queue.append((new_entrance, rrp[new_entrance.parent_region])) - except IndexError: - break + # Retry connections if the new region can unblock them + if new_region.name in indirect_connections: + new_entrance = self.world.get_entrance(indirect_connections[new_region.name], player) + if new_entrance in bc and new_entrance.parent_region in rrp: + new_crystal_state = rrp[new_entrance.parent_region] + if (new_entrance, new_crystal_state) not in queue: + queue.append((new_entrance, new_crystal_state)) + # else those connections that are not accessible yet + if self.is_small_door(connection): + door = connection.door if connection.door.smallKey else connection.door.controller + dungeon_name = connection.parent_region.dungeon.name + 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) + if not connection.can_reach(self): + checklist_key = 'Universal' if self.world.retro[player] else dungeon_name + checklist = self.dungeons_to_check[player][checklist_key] + checklist[connection.name] = (connection, crystal_state) + elif door.name not in self.opened_doors[player]: + opened_doors = self.opened_doors[player] + door = connection.door if connection.door.smallKey else connection.door.controller + if door.name not in opened_doors: + self.door_counter[player][1][dungeon_name] += 1 + opened_doors.add(door.name) + key_logic = self.world.key_logic[player][dungeon_name] + if key_logic.sm_doors[door]: + opened_doors.add(key_logic.sm_doors[door].name) + def should_visit(self, new_region, rrp, crystal_state, player): + if not new_region: + return False + if self.dungeon_limits and not self.possibly_connected_to_dungeon(new_region, player): + return False + if new_region not in rrp: + return True + if new_region.type != RegionType.Dungeon: + return False + return (rrp[new_region] & crystal_state) != crystal_state + + def possibly_connected_to_dungeon(self, new_region, player): + if new_region.dungeon: + return new_region.dungeon.name in self.dungeon_limits + else: + return new_region.name in self.world.inaccessible_regions[player] + + @staticmethod + def valid_crystal(door, new_crystal_state): + return (not door.crystal or door.crystal == CrystalBarrier.Either or new_crystal_state == CrystalBarrier.Either + or new_crystal_state == door.crystal) + + def check_key_doors_in_dungeons(self, rrp, player): + for dungeon_name, checklist in self.dungeons_to_check[player].items(): + if self.apply_dungeon_exploration(rrp, player, dungeon_name, checklist): + continue + init_door_candidates = self.should_explore_child_state(self, dungeon_name, player) + key_total = self.prog_items[(dungeon_keys[dungeon_name], player)] # todo: universal + remaining_keys = key_total - self.door_counter[player][1][dungeon_name] + if not init_door_candidates or remaining_keys == 0: + continue + dungeon_doors = {x.name for x in self.world.key_logic[player][dungeon_name].sm_doors.keys()} + + def valid_d_door(x): + return x in dungeon_doors + + child_states = deque() + child_states.append(self) + visited_opened_doors = set() + visited_opened_doors.add(frozenset(self.opened_doors[player])) + terminal_states, common_regions, common_bc, common_doors = [], {}, {}, set() + while len(child_states) > 0: + next_child = child_states.popleft() + door_candidates = CollectionState.should_explore_child_state(next_child, dungeon_name, player) + child_checklist = next_child.dungeons_to_check[player][dungeon_name] + if door_candidates: + for chosen_door in door_candidates: + child_state = next_child.copy() + child_queue = deque() + child_state.door_counter[player][1][dungeon_name] += 1 + if isinstance(chosen_door, tuple): + child_state.opened_doors[player].add(chosen_door[0]) + child_state.opened_doors[player].add(chosen_door[1]) + if chosen_door[0] in child_checklist: + child_queue.append(child_checklist[chosen_door[0]]) + if chosen_door[1] in child_checklist: + child_queue.append(child_checklist[chosen_door[1]]) + else: + child_state.opened_doors[player].add(chosen_door) + if chosen_door in child_checklist: + child_queue.append(child_checklist[chosen_door]) + if child_state.opened_doors[player] not in visited_opened_doors: + done = False + while not done: + rrp_ = child_state.reachable_regions[player] + bc_ = child_state.blocked_connections[player] + child_state.set_dungeon_limits(player, dungeon_name) + child_queue.extend([(x, y) for x, y in bc_.items() + if child_state.possibly_connected_to_dungeon(x.parent_region, + player)]) + child_state.traverse_world(child_queue, rrp_, bc_, player) + new_events = child_state.sweep_for_events_once(player) + child_state.stale[player] = False + if new_events: + for conn in bc_: + if conn.parent_region.dungeon and conn.parent_region.dungeon.name == dungeon_name: + child_queue.append((conn, bc_[conn])) + done = not new_events + if child_state.opened_doors[player] not in visited_opened_doors: + visited_opened_doors.add(frozenset(child_state.opened_doors[player])) + child_states.append(child_state) + else: + terminal_states.append(next_child) + common_regions, common_bc, common_doors, first = {}, {}, set(), True + bc = self.blocked_connections[player] + for term_state in terminal_states: + t_rrp = term_state.reachable_regions[player] + t_bc = term_state.blocked_connections[player] + if first: + first = False + common_regions = {x: y for x, y in t_rrp.items() if x not in rrp or y != rrp[x]} + common_bc = {x: y for x, y in t_bc.items() if x not in bc} + common_doors = {x for x in term_state.opened_doors[player] - self.opened_doors[player] + if valid_d_door(x)} + else: + cm_rrp = {x: y for x, y in t_rrp.items() if x not in rrp or y != rrp[x]} + common_regions = {k: self.comb_crys(v, cm_rrp[k]) for k, v in common_regions.items() + if k in cm_rrp and self.crys_agree(v, cm_rrp[k])} + common_bc.update({x: y for x, y in t_bc.items() if x not in bc and x not in common_bc}) + common_doors &= {x for x in term_state.opened_doors[player] - self.opened_doors[player] + if valid_d_door(x)} + + terminal_queue = deque() + for door in common_doors: + pair = self.find_door_pair(player, dungeon_name, door) + if door not in self.reached_doors[player]: + self.door_counter[player][0][dungeon_name] += 1 + self.reached_doors[player].add(door) + if pair not in self.reached_doors[player]: + self.reached_doors[player].add(pair) + self.opened_doors[player].add(door) + if door in checklist: + terminal_queue.append(checklist[door]) + if pair not in self.opened_doors[player]: + self.door_counter[player][1][dungeon_name] += 1 + + self.set_dungeon_limits(player, dungeon_name) + rrp_ = self.reachable_regions[player] + bc_ = self.blocked_connections[player] + for block, crystal in bc_.items(): + if (block, crystal) not in terminal_queue and self.possibly_connected_to_dungeon(block.connected_region, player): + terminal_queue.append((block, crystal)) + self.traverse_world(terminal_queue, rrp_, bc_, player) + self.dungeon_limits = None + + rrp = self.reachable_regions[player] + missing_regions = {x: y for x, y in common_regions.items() if x not in rrp} + paths = {} + for k in missing_regions: + rrp[k] = missing_regions[k] + possible_path = terminal_states[0].path[k] + self.path[k] = paths[k] = possible_path + missing_bc = {} + for blocked, crystal in common_bc.items(): + if (blocked not in bc and blocked.parent_region in rrp + and self.should_visit(blocked.connected_region, rrp, crystal, player)): + missing_bc[blocked] = crystal + for k in missing_bc: + bc[k] = missing_bc[k] + self.record_dungeon_exploration(player, dungeon_name, checklist, + common_doors, missing_regions, missing_bc, paths) + checklist.clear() + + @staticmethod + def comb_crys(a, b): + return a if a == b or a != CrystalBarrier.Either else b + + @staticmethod + def crys_agree(a, b): + return a == b or a == CrystalBarrier.Either or b == CrystalBarrier.Either + + def find_door_pair(self, player, dungeon_name, name): + for door in self.world.key_logic[player][dungeon_name].sm_doors.keys(): + if door.name == name: + paired_door = self.world.key_logic[player][dungeon_name].sm_doors[door] + return paired_door.name if paired_door else None + return None + + def set_dungeon_limits(self, player, dungeon_name): + if self.world.retro[player] and self.world.mode[player] == 'standard': + self.dungeon_limits = ['Hyrule Castle', 'Agahnims Tower'] + else: + self.dungeon_limits = [dungeon_name] + + @staticmethod + def should_explore_child_state(state, dungeon_name, player): + small_key_name = dungeon_keys[dungeon_name] + key_total = state.prog_items[(small_key_name, player)] + remaining_keys = key_total - state.door_counter[player][1][dungeon_name] + unopened_doors = state.door_counter[player][0][dungeon_name] - state.door_counter[player][1][dungeon_name] + if remaining_keys > 0 and unopened_doors > 0: + key_logic = state.world.key_logic[player][dungeon_name] + door_candidates, skip = [], set() + for door, paired in key_logic.sm_doors.items(): + if door.name in state.reached_doors[player] and door.name not in state.opened_doors[player]: + if door.name not in skip: + if paired: + door_candidates.append((door.name, paired.name)) + skip.add(paired.name) + else: + door_candidates.append(door.name) + return door_candidates + return None + + @staticmethod + def print_rrp(rrp): + logger = logging.getLogger('') + logger.debug('RRP Checking') + for region, packet in rrp.items(): + new_crystal_state, logic, path = packet + logger.debug(f'\nRegion: {region.name} (CS: {str(new_crystal_state)})') + for i in range(0, len(logic)): + logger.debug(f'{logic[i]}') + logger.debug(f'{",".join(str(x) for x in path[i])}') def copy(self): - ret = CollectionState(self.world) + ret = CollectionState(self.world, skip_init=True) ret.prog_items = self.prog_items.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) ret.path = copy.copy(self.path) ret.locations_checked = copy.copy(self.locations_checked) + ret.stale = {player: self.stale[player] for player in range(1, self.world.players + 1)} + ret.door_counter = {player: (copy.copy(self.door_counter[player][0]), copy.copy(self.door_counter[player][1])) + for player in range(1, self.world.players + 1)} + ret.reached_doors = {player: copy.copy(self.reached_doors[player]) for player in range(1, self.world.players + 1)} + ret.opened_doors = {player: copy.copy(self.opened_doors[player]) for player in range(1, self.world.players + 1)} + ret.dungeons_to_check = { + 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)} return ret + def apply_dungeon_exploration(self, rrp, player, dungeon_name, checklist): + bc = self.blocked_connections[player] + ec = self.world.exp_cache[player] + prog_set = self.reduce_prog_items(player, dungeon_name) + exp_key = (prog_set, frozenset(checklist)) + if dungeon_name in ec and exp_key in ec[dungeon_name]: + # apply + common_doors, missing_regions, missing_bc, paths = ec[dungeon_name][exp_key] + terminal_queue = deque() + for door in common_doors: + pair = self.find_door_pair(player, dungeon_name, door) + if door not in self.reached_doors[player]: + self.door_counter[player][0][dungeon_name] += 1 + self.reached_doors[player].add(door) + if pair not in self.reached_doors[player]: + self.reached_doors[player].add(pair) + self.opened_doors[player].add(door) + if door in checklist: + terminal_queue.append(checklist[door]) + if pair not in self.opened_doors[player]: + self.door_counter[player][1][dungeon_name] += 1 + + self.set_dungeon_limits(player, dungeon_name) + rrp_ = self.reachable_regions[player] + bc_ = self.blocked_connections[player] + for block, crystal in bc_.items(): + if (block, crystal) not in terminal_queue and self.possibly_connected_to_dungeon(block.connected_region, player): + terminal_queue.append((block, crystal)) + self.traverse_world(terminal_queue, rrp_, bc_, player) + self.dungeon_limits = None + + for k in missing_regions: + rrp[k] = missing_regions[k] + for r, path in paths.items(): + self.path[r] = path + for k in missing_bc: + bc[k] = missing_bc[k] + + return True + return False + + def record_dungeon_exploration(self, player, dungeon_name, checklist, + common_doors, missing_regions, missing_bc, paths): + ec = self.world.exp_cache[player] + prog_set = self.reduce_prog_items(player, dungeon_name) + exp_key = (prog_set, frozenset(checklist)) + ec[dungeon_name][exp_key] = (common_doors, missing_regions, missing_bc, paths) + + def reduce_prog_items(self, player, dungeon_name): + # todo: possibly could include an analysis of dungeon items req. like Hammer, Hookshot, etc + # cross dungeon requirements may be necessary for keysanity - which invalidates the above + # todo: universal smalls where needed + life_count, bottle_count = 0, 0 + reduced = Counter() + for item, cnt in self.prog_items.items(): + item_name, item_player = item + if item_player == player and self.check_if_progressive(item_name): + if item_name.startswith('Bottle'): # I think magic requirements can require multiple bottles + bottle_count += cnt + elif item_name in ['Boss Heart Container', 'Sanctuary Heart Container', 'Piece of Heart']: + if 'Container' in item_name: + life_count += 1 + elif 'Piece of Heart' == item_name: + life_count += .25 + else: + reduced[item] = cnt + if bottle_count > 0: + reduced[('Bottle', player)] = 1 + if life_count >= 1: + reduced[('Heart Container', player)] = 1 + return frozenset(reduced.items()) + + @staticmethod + def check_if_progressive(item_name): + return (item_name in + ['Bow', 'Progressive Bow', 'Progressive Bow (Alt)', 'Book of Mudora', 'Hammer', 'Hookshot', + 'Magic Mirror', 'Ocarina', 'Pegasus Boots', 'Power Glove', 'Cape', 'Mushroom', 'Shovel', + 'Lamp', 'Magic Powder', 'Moon Pearl', 'Cane of Somaria', 'Fire Rod', 'Flippers', 'Ice Rod', + 'Titans Mitts', 'Bombos', 'Ether', 'Quake', 'Master Sword', 'Tempered Sword', 'Fighter Sword', + 'Golden Sword', 'Progressive Sword', 'Progressive Glove', 'Silver Arrows', 'Green Pendant', + 'Blue Pendant', 'Red Pendant', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', + 'Crystal 6', 'Crystal 7', 'Blue Boomerang', 'Red Boomerang', 'Blue Shield', 'Red Shield', + 'Mirror Shield', 'Progressive Shield', 'Bug Catching Net', 'Cane of Byrna', + 'Boss Heart Container', 'Sanctuary Heart Container', 'Piece of Heart', 'Magic Upgrade (1/2)', + 'Magic Upgrade (1/4)'] + or item_name.startswith(('Bottle', 'Small Key', 'Big Key'))) + def can_reach(self, spot, resolution_hint=None, player=None): try: spot_type = spot.spot_type @@ -557,6 +898,16 @@ class CollectionState(object): return spot.can_reach(self) + def sweep_for_events_once(self, player): + locations = self.world.get_filled_locations(player) + 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) + 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) def sweep_for_events(self, key_only=False, locations=None): # this may need improvement @@ -604,6 +955,18 @@ class CollectionState(object): or not self.location_can_be_flooded(flood_location)) return True + @staticmethod + def is_small_door(connection): + return connection and connection.door and (connection.door.smallKey or + CollectionState.is_controlled_by_small(connection)) + + @staticmethod + def is_controlled_by_small(connection): + return connection.door.controller and connection.door.controller.smallKey + + def is_door_open(self, door_name, player): + return door_name in self.opened_doors[player] + @staticmethod def location_can_be_flooded(location): return location.parent_region.name in ['Swamp Trench 1 Alcove', 'Swamp Trench 2 Alcove'] @@ -686,8 +1049,7 @@ class CollectionState(object): # In the future, this can be used to check if the player starts without bombs def can_use_bombs(self, player): - StartingBombs = True - return StartingBombs or self.has('Bomb Upgrade (+10)', player) + return (not self.world.bombbag[player] or self.has('Bomb Upgrade (+10)', player)) def can_hit_crystal(self, player): return (self.can_use_bombs(player) @@ -719,6 +1081,7 @@ class CollectionState(object): def can_get_good_bee(self, player): cave = self.world.get_region('Good Bee Cave', player) return ( + self.can_use_bombs(player) and self.has_bottle(player) and self.has('Bug Catching Net', player) and (self.has_Boots(player) or (self.has_sword(player) and self.has('Quake', player))) and @@ -807,6 +1170,8 @@ class CollectionState(object): def collect(self, item, event=False, location=None): if location: self.locations_checked.add(location) + if not item: + return changed = False if item.name.startswith('Progressive '): if 'Sword' in item.name: @@ -1282,6 +1647,7 @@ class Door(object): self.bk_shuffle_req = False self.standard_restricted = False # flag if portal is not allowed in HC in standard self.lw_restricted = False # flag if portal is not allowed in DW + self.rupee_bow_restricted = False # flag if portal is not allowed in HC in standard+rupee_bow # self.incognitoPos = -1 # self.sectorLink = False @@ -1483,6 +1849,7 @@ class Sector(object): self.entrance_sector = None self.destination_entrance = False self.equations = None + self.item_logic = set() def region_set(self): if self.r_name_set is None: @@ -1735,9 +2102,7 @@ class Location(object): return self.always_allow(state, item) or (self.parent_region.can_fill(item) and self.item_rule(item) and (not check_access or self.can_reach(state))) def can_reach(self, state): - if self.parent_region.can_reach(state) and self.access_rule(state): - return True - return False + return self.parent_region.can_reach(state) and self.access_rule(state) def forced_big_key(self): if self.forced_item and self.forced_item.bigkey and self.player == self.forced_item.player: @@ -1764,6 +2129,12 @@ class Location(object): world = self.parent_region.world if self.parent_region and self.parent_region.world else None return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})' + def __eq__(self, other): + return self.name == other.name and self.player == other.player + + def __hash__(self): + return hash((self.name, self.player)) + class Item(object): @@ -1807,6 +2178,15 @@ class Item(object): def compass(self): return self.type == 'Compass' + @property + def dungeon(self): + if not self.smallkey and not self.bigkey and not self.map and not self.compass: + return None + item_dungeon = self.name.split('(')[1][:-1] + if item_dungeon == 'Escape': + item_dungeon = 'Hyrule Castle' + return item_dungeon + def __str__(self): return str(self.__unicode__()) @@ -1893,6 +2273,7 @@ class Spoiler(object): self.doorTypes = {} self.lobbies = {} self.medallions = {} + self.bottles = {} self.playthrough = {} self.unreachables = [] self.startinventory = [] @@ -1936,6 +2317,15 @@ class Spoiler(object): self.medallions[f'Misery Mire ({self.world.get_player_names(player)})'] = self.world.required_medallions[player][0] self.medallions[f'Turtle Rock ({self.world.get_player_names(player)})'] = self.world.required_medallions[player][1] + self.bottles = OrderedDict() + if self.world.players == 1: + self.bottles['Waterfall Bottle'] = self.world.bottle_refills[1][0] + self.bottles['Pyramid Bottle'] = self.world.bottle_refills[1][1] + else: + for player in range(1, self.world.players + 1): + self.bottles[f'Waterfall Bottle ({self.world.get_player_names(player)})'] = self.world.bottle_refills[player][0] + self.bottles[f'Pyramid Bottle ({self.world.get_player_names(player)})'] = self.world.bottle_refills[player][1] + self.startinventory = list(map(str, self.world.precollected_items)) self.locations = OrderedDict() @@ -2013,9 +2403,11 @@ class Spoiler(object): 'logic': self.world.logic, 'mode': self.world.mode, 'retro': self.world.retro, + 'bombbag': self.world.bombbag, 'weapons': self.world.swords, 'goal': self.world.goal, 'shuffle': self.world.shuffle, + 'shufflelinks': self.world.shufflelinks, 'door_shuffle': self.world.doorShuffle, 'intensity': self.world.intensity, 'item_pool': self.world.difficulty, @@ -2054,6 +2446,7 @@ class Spoiler(object): out.update(self.locations) out['Starting Inventory'] = self.startinventory out['Special'] = self.medallions + out['Bottles'] = self.bottles if self.hashes: out['Hashes'] = {f"{self.world.player_names[player][team]} (Team {team+1})": hash for (player, team), hash in self.hashes.items()} if self.shops: @@ -2090,6 +2483,7 @@ class Spoiler(object): outfile.write('Difficulty: %s\n' % self.metadata['item_pool'][player]) outfile.write('Item Functionality: %s\n' % self.metadata['item_functionality'][player]) outfile.write('Entrance Shuffle: %s\n' % self.metadata['shuffle'][player]) + outfile.write(f"Link's House Shuffled: {'Yes' if self.metadata['shufflelinks'][player] else 'No'}\n") outfile.write('Door Shuffle: %s\n' % self.metadata['door_shuffle'][player]) outfile.write('Intensity: %s\n' % self.metadata['intensity'][player]) addition = ' (Random)' if self.world.crystals_gt_orig[player] == 'random' else '' @@ -2111,6 +2505,7 @@ class Spoiler(object): outfile.write('Experimental: %s\n' % ('Yes' if self.metadata['experimental'][player] else 'No')) outfile.write('Key Drops shuffled: %s\n' % ('Yes' if self.metadata['keydropshuffle'][player] else 'No')) outfile.write(f"Shopsanity: {'Yes' if self.metadata['shopsanity'][player] else 'No'}\n") + outfile.write('Bombbag: %s\n' % ('Yes' if self.metadata['bombbag'][player] else 'No')) if self.doors: outfile.write('\n\nDoors:\n\n') outfile.write('\n'.join( @@ -2138,6 +2533,9 @@ class Spoiler(object): outfile.write('\n\nMedallions:\n') for dungeon, medallion in self.medallions.items(): outfile.write(f'\n{dungeon}: {medallion} Medallion') + outfile.write('\n\nBottle Refills:\n') + for fairy, bottle in self.bottles.items(): + outfile.write(f'\n{fairy}: {bottle}') if self.startinventory: outfile.write('\n\nStarting Inventory:\n\n') outfile.write('\n'.join(self.startinventory)) @@ -2154,7 +2552,7 @@ class Spoiler(object): for player in range(1, self.world.players + 1): if self.world.boss_shuffle[player] != 'none': - bossmap = self.bosses[player] if self.world.players > 1 else self.bosses + bossmap = self.bosses[str(player)] if self.world.players > 1 else self.bosses outfile.write(f'\n\nBosses ({self.world.get_player_names(player)}):\n\n') outfile.write('\n'.join([f'{x}: {y}' for x, y in bossmap.items() if y not in ['Agahnim', 'Agahnim 2', 'Ganon']])) @@ -2197,6 +2595,22 @@ dungeon_names = [ 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace', 'Misery Mire', 'Turtle Rock', 'Ganons Tower' ] +dungeon_keys = { + 'Hyrule Castle': 'Small Key (Escape)', + 'Eastern Palace': 'Small Key (Eastern Palace)', + 'Desert Palace': 'Small Key (Desert Palace)', + 'Tower of Hera': 'Small Key (Tower of Hera)', + 'Agahnims Tower': 'Small Key (Agahnims Tower)', + 'Palace of Darkness': 'Small Key (Palace of Darkness)', + 'Swamp Palace': 'Small Key (Swamp Palace)', + 'Skull Woods': 'Small Key (Skull Woods)', + 'Thieves Town': 'Small Key (Thieves Town)', + 'Ice Palace': 'Small Key (Ice Palace)', + 'Misery Mire': 'Small Key (Misery Mire)', + 'Turtle Rock': 'Small Key (Turtle Rock)', + 'Ganons Tower': 'Small Key (Ganons Tower)', + 'Universal': 'Small Key (Universal)' +} class PotItem(FastEnum): Nothing = 0x0 @@ -2272,7 +2686,7 @@ access_mode = {"items": 0, "locations": 1, "none": 2} boss_mode = {"none": 0, "simple": 1, "full": 2, "random": 3, "chaos": 3} enemy_mode = {"none": 0, "shuffled": 1, "random": 2, "chaos": 2, "legacy": 3} -# byte 7: HHHD DP?? (enemy_health, enemy_dmg, potshuffle, ?) +# byte 7: HHHD DPBS (enemy_health, enemy_dmg, potshuffle, bomb logic, shuffle links) e_health = {"default": 0, "easy": 1, "normal": 2, "hard": 3, "expert": 4} e_dmg = {"default": 0, "shuffled": 1, "random": 2} @@ -2303,7 +2717,8 @@ class Settings(object): | (0x20 if w.mapshuffle[p] else 0) | (0x10 if w.compassshuffle[p] else 0) | (boss_mode[w.boss_shuffle[p]] << 2) | (enemy_mode[w.enemy_shuffle[p]]), - (e_health[w.enemy_health[p]] << 5) | (e_dmg[w.enemy_damage[p]] << 3) | (0x4 if w.potshuffle[p] else 0)]) + (e_health[w.enemy_health[p]] << 5) | (e_dmg[w.enemy_damage[p]] << 3) | (0x4 if w.potshuffle[p] else 0) + | (0x2 if w.bombbag[p] else 0) | (1 if w.shufflelinks[p] else 0)]) return base64.b64encode(code, "+-".encode()).decode() @staticmethod @@ -2347,3 +2762,11 @@ class Settings(object): args.enemy_health[p] = r(e_health)[(settings[7] & 0xE0) >> 5] args.enemy_damage[p] = r(e_dmg)[(settings[7] & 0x18) >> 3] args.shufflepots[p] = True if settings[7] & 0x4 else False + args.bombbag[p] = True if settings[7] & 0x2 else False + args.shufflelinks[p] = True if settings[7] & 0x1 else False + + +class KeyRuleType(FastEnum): + WorstCase = 0 + AllowSmall = 1 + Lock = 2 diff --git a/Bosses.py b/Bosses.py index 253717bd..d577c66d 100644 --- a/Bosses.py +++ b/Bosses.py @@ -32,7 +32,7 @@ def LanmolasDefeatRule(state, player): state.has('Fire Rod', player) or state.has('Ice Rod', player) or state.has('Cane of Somaria', player) or - state.has('Cane of Byrna', player) or + (state.has('Cane of Byrna', player) and state.can_use_bombs(player)) or state.can_shoot_arrows(player)) def MoldormDefeatRule(state, player): @@ -51,7 +51,7 @@ def ArrghusDefeatRule(state, player): return True return ((state.has('Fire Rod', player) and (state.can_shoot_arrows(player) or state.can_extend_magic(player, 12))) or #assuming mostly gitting two puff with one shot - (state.has('Ice Rod', player) and (state.can_shoot_arrows(player) or state.can_extend_magic(player, 16)))) + (state.has('Ice Rod', player) and state.can_use_bombs(player) and (state.can_shoot_arrows(player) or state.can_extend_magic(player, 16)))) def MothulaDefeatRule(state, player): @@ -92,7 +92,7 @@ def KholdstareDefeatRule(state, player): ) def VitreousDefeatRule(state, player): - return state.can_shoot_arrows(player) or state.has_blunt_weapon(player) + return (state.can_shoot_arrows(player) and state.can_use_bombs(player)) or state.has_blunt_weapon(player) def TrinexxDefeatRule(state, player): if not (state.has('Fire Rod', player) and state.has('Ice Rod', player)): @@ -176,16 +176,16 @@ def place_bosses(world, player): if world.boss_shuffle[player] == "simple": # vanilla bosses shuffled bosses = placeable_bosses + ['Armos Knights', 'Lanmolas', 'Moldorm'] - else: # all bosses present, the three duplicates chosen at random - bosses = all_bosses + [random.choice(placeable_bosses) for _ in range(3)] + else: # all bosses present, the three duplicates chosen at random + bosses = all_bosses + random.sample(placeable_bosses, 3) logging.getLogger('').debug('Bosses chosen %s', bosses) - random.shuffle(bosses) for [loc, level] in boss_locations: loc_text = loc + (' ('+level+')' if level else '') - boss = next((b for b in bosses if can_place_boss(world, player, b, loc, level)), None) - if not boss: + try: + boss = random.choice([b for b in bosses if can_place_boss(world, player, b, loc, level)]) + except IndexError: raise FillError('Could not place boss for location %s' % loc_text) bosses.remove(boss) diff --git a/CLI.py b/CLI.py index d87e1e2c..bb9ab0a2 100644 --- a/CLI.py +++ b/CLI.py @@ -96,13 +96,14 @@ def parse_cli(argv, no_defaults=False): for name in ['logic', 'mode', 'swords', 'goal', 'difficulty', 'item_functionality', 'shuffle', 'door_shuffle', 'intensity', 'crystals_ganon', 'crystals_gt', 'openpyramid', 'mapshuffle', 'compassshuffle', 'keyshuffle', 'bigkeyshuffle', 'startinventory', + 'bombbag', 'triforce_pool_min', 'triforce_pool_max', 'triforce_goal_min', 'triforce_goal_max', 'triforce_min_difference', 'triforce_goal', 'triforce_pool', 'shufflelinks', 'pseudoboots', 'retro', 'accessibility', 'hints', 'beemizer', 'experimental', 'dungeon_counters', 'shufflebosses', 'shuffleenemies', 'enemy_health', 'enemy_damage', 'shufflepots', 'ow_palettes', 'uw_palettes', 'sprite', 'disablemusic', 'quickswap', 'fastmenu', 'heartcolor', 'heartbeep', 'remote_items', 'shopsanity', 'keydropshuffle', 'mixed_travel', 'standardize_palettes', 'code', - 'reduce_flashing']: + 'reduce_flashing', 'shuffle_sfx']: value = getattr(defaults, name) if getattr(playerargs, name) is None else getattr(playerargs, name) if player == 1: setattr(ret, name, {1: value}) @@ -126,6 +127,7 @@ def parse_settings(): settings = { "lang": "en", "retro": False, + "bombbag": False, "mode": "open", "logic": "noglitches", "goal": "ganon", @@ -190,6 +192,7 @@ def parse_settings(): "ow_palettes": "default", "uw_palettes": "default", "reduce_flashing": False, + "shuffle_sfx": False, # Spoiler defaults to TRUE # Playthrough defaults to TRUE diff --git a/DoorShuffle.py b/DoorShuffle.py index 67e376f6..3d3a6eda 100644 --- a/DoorShuffle.py +++ b/DoorShuffle.py @@ -1,22 +1,21 @@ import RaceRandom as random from collections import defaultdict, deque import logging -import operator as op import time from enum import unique, Flag from typing import DefaultDict, Dict, List -from functools import reduce -from BaseClasses import RegionType, Region, Door, DoorType, Direction, Sector, CrystalBarrier, DungeonInfo +from BaseClasses import RegionType, Region, Door, DoorType, Direction, Sector, CrystalBarrier, DungeonInfo, dungeon_keys from Doors import reset_portals from Dungeons import dungeon_regions, region_starts, standard_starts, split_region_starts -from Dungeons import dungeon_bigs, dungeon_keys, dungeon_hints +from Dungeons import dungeon_bigs, dungeon_hints from Items import ItemFactory from RoomData import DoorKind, PairedDoor, reset_rooms from DungeonGenerator import ExplorationState, convert_regions, generate_dungeon, pre_validate, determine_required_paths, drop_entrances from DungeonGenerator import create_dungeon_builders, split_dungeon_builder, simple_dungeon_builder, default_dungeon_entrances from DungeonGenerator import dungeon_portals, dungeon_drops, GenerationException -from KeyDoorShuffle import analyze_dungeon, validate_vanilla_key_logic, build_key_layout, validate_key_layout +from KeyDoorShuffle import analyze_dungeon, build_key_layout, validate_key_layout, determine_prize_lock +from Utils import ncr, kth_combination def link_doors(world, player): @@ -102,6 +101,8 @@ def link_doors_main(world, player): connect_portal(portal, world, player) if not world.doorShuffle[player] == 'vanilla': fix_big_key_doors_with_ugly_smalls(world, player) + else: + unmark_ugly_smalls(world, player) if world.doorShuffle[player] == 'vanilla': for entrance, ext in open_edges: connect_two_way(world, entrance, ext, player) @@ -214,8 +215,8 @@ def vanilla_key_logic(world, player): analyze_dungeon(key_layout, world, player) world.key_logic[player][builder.name] = key_layout.key_logic 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) + # 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) # some useful functions @@ -317,6 +318,13 @@ def connect_one_way(world, entrancename, exitname, player): y.dest = x +def unmark_ugly_smalls(world, player): + for d in ['Eastern Hint Tile Blocked Path SE', 'Eastern Darkness S', 'Thieves Hallway SE', 'Mire Left Bridge S', + 'TR Lava Escape SE', 'GT Hidden Spikes SE']: + door = world.get_door(d, player) + door.smallKey = False + + def fix_big_key_doors_with_ugly_smalls(world, player): remove_ugly_small_key_doors(world, player) unpair_big_key_doors(world, player) @@ -360,7 +368,8 @@ def choose_portals(world, player): if world.doorShuffle[player] in ['basic', 'crossed']: cross_flag = world.doorShuffle[player] == 'crossed' - bk_shuffle = world.bigkeyshuffle[player] + # key drops allow the big key in the right place in Desert Tiles 2 + bk_shuffle = world.bigkeyshuffle[player] or world.keydropshuffle[player] std_flag = world.mode[player] == 'standard' # roast incognito doors world.get_room(0x60, player).delete(5) @@ -407,6 +416,7 @@ def choose_portals(world, player): for dungeon, info in shuffled_info: outstanding_portals = list(dungeon_portals[dungeon]) hc_flag = std_flag and dungeon == 'Hyrule Castle' + rupee_bow_flag = hc_flag and world.retro[player] # rupee bow if hc_flag: sanc = world.get_portal('Sanctuary', player) sanc.destination = True @@ -416,14 +426,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, need_passage=True, crossed=cross_flag, - bk_shuffle=bk_shuffle) + bk_shuffle=bk_shuffle, rupee_bow=rupee_bow_flag) choice, portal = assign_portal(candidates, possible_portals, 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, dead_end_allowed=True, - crossed=cross_flag, bk_shuffle=bk_shuffle) + crossed=cross_flag, bk_shuffle=bk_shuffle, 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, world, player) if choice.deadEnd: @@ -435,7 +445,7 @@ def choose_portals(world, player): the_rest = info.total - len(portal_assignment[dungeon]) for i in range(0, the_rest): candidates = find_portal_candidates(master_door_list, dungeon, crossed=cross_flag, - bk_shuffle=bk_shuffle, standard=hc_flag) + bk_shuffle=bk_shuffle, standard=hc_flag, rupee_bow=rupee_bow_flag) choice, portal = assign_portal(candidates, outstanding_portals, world, player) clean_up_portal_assignment(portal_assignment, dungeon, portal, master_door_list, outstanding_portals) @@ -503,6 +513,9 @@ def analyze_portals(world, player): raise Exception('please inspect this case') if len(reachable_portals) == 1: info.sole_entrance = reachable_portals[0] + if world.intensity[player] < 2 and world.doorShuffle[player] == 'basic' and dungeon == 'Desert Palace': + if len(inaccessible_portals) == 1 and inaccessible_portals[0] == 'Desert Back': + info.required_passage.clear() # can't make a passage at this intensity level, something else must exit info_map[dungeon] = info for dungeon, info in info_map.items(): @@ -553,7 +566,7 @@ def disconnect_portal(portal, world, player): def find_portal_candidates(door_list, dungeon, need_passage=False, dead_end_allowed=False, crossed=False, - bk_shuffle=False, standard=False): + bk_shuffle=False, standard=False, rupee_bow=False): ret = [x for x in door_list if bk_shuffle or not x.bk_shuffle_req] if crossed: ret = [x for x in ret if not x.dungeonLink or x.dungeonLink == dungeon or x.dungeonLink.startswith('link')] @@ -565,6 +578,8 @@ def find_portal_candidates(door_list, dungeon, need_passage=False, dead_end_allo ret = [x for x in ret if not x.deadEnd] if standard: ret = [x for x in ret if not x.standard_restricted] + if rupee_bow: + ret = [x for x in ret if not x.rupee_bow_restricted] return ret @@ -742,7 +757,7 @@ def handle_split_dungeons(dungeon_builders, recombinant_builders, entrances_map, def main_dungeon_generation(dungeon_builders, recombinant_builders, connections_tuple, world, player): entrances_map, potentials, connections = connections_tuple - enabled_entrances = {} + enabled_entrances = world.enabled_entrances[player] = {} sector_queue = deque(dungeon_builders.values()) last_key, loops = None, 0 logging.getLogger('').info(world.fish.translate("cli", "cli", "generating.dungeon")) @@ -1071,6 +1086,7 @@ def assign_cross_keys(dungeon_builders, world, player): # Step 3: Initial valid combination find - reduce flex if needed for name, builder in dungeon_builders.items(): suggested = builder.key_doors_num - builder.key_drop_cnt + builder.total_keys = builder.key_doors_num find_valid_combination(builder, start_regions_map[name], world, player) actual_chest_keys = builder.key_doors_num - builder.key_drop_cnt if actual_chest_keys < suggested: @@ -1087,6 +1103,7 @@ def assign_cross_keys(dungeon_builders, world, player): name = builder.name logger.debug('Cross Dungeon: Increasing key count by 1 for %s', name) builder.key_doors_num += 1 + builder.total_keys = builder.key_doors_num result = find_valid_combination(builder, start_regions_map[name], world, player, drop_keys=False) if result: remaining -= 1 @@ -1102,9 +1119,9 @@ def assign_cross_keys(dungeon_builders, world, player): logger.debug('Cross Dungeon: Keys unable to assign in pool %s', remaining) # Last Step: Adjust Small Key Dungeon Pool - if not world.retro[player]: - for name, builder in dungeon_builders.items(): - reassign_key_doors(builder, world, player) + for name, builder in dungeon_builders.items(): + reassign_key_doors(builder, world, player) + if not world.retro[player]: log_key_logic(builder.name, world.key_logic[player][builder.name]) actual_chest_keys = max(builder.key_doors_num - builder.key_drop_cnt, 0) dungeon = world.get_dungeon(name, player) @@ -1345,10 +1362,8 @@ def combine_layouts(recombinant_builders, dungeon_builders, entrances_map): if recombine.master_sector is None: recombine.master_sector = builder.master_sector recombine.master_sector.name = recombine.name - recombine.pre_open_stonewalls = builder.pre_open_stonewalls else: recombine.master_sector.regions.extend(builder.master_sector.regions) - recombine.pre_open_stonewalls.update(builder.pre_open_stonewalls) recombine.layout_starts = list(entrances_map[recombine.name]) dungeon_builders[recombine.name] = recombine @@ -1380,7 +1395,7 @@ def shuffle_key_doors(builder, world, player): skips.append(world.get_door(dp.door_a, player)) break num_key_doors += 1 - builder.key_doors_num = num_key_doors + builder.key_doors_num = builder.total_keys = num_key_doors find_small_key_door_candidates(builder, start_regions, world, player) find_valid_combination(builder, start_regions, world, player) reassign_key_doors(builder, world, player) @@ -1443,7 +1458,16 @@ def find_valid_combination(builder, start_regions, world, player, drop_keys=True random.shuffle(sample_list) proposal = kth_combination(sample_list[itr], builder.candidates, builder.key_doors_num) + # eliminate start region if portal marked as destination + excluded = {} + 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 + start_regions = [x for x in start_regions if x not in excluded.keys()] + key_layout = build_key_layout(builder, start_regions, proposal, world, player) + determine_prize_lock(key_layout, world, player) while not validate_key_layout(key_layout, world, player): itr += 1 stop_early = False @@ -1561,7 +1585,7 @@ def find_key_door_candidates(region, checked, world, player): 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 + valid = kind in okay_normals and kind_b in okay_normals and valid_key_door_pair(d, d2) else: valid = kind in okay_normals if valid and 0 <= d2.doorListPos < 4: @@ -1578,26 +1602,10 @@ def find_key_door_candidates(region, checked, world, player): return candidates, checked_doors -def kth_combination(k, l, r): - if r == 0: - return [] - elif len(l) == r: - return l - else: - i = ncr(len(l)-1, r-1) - if k < i: - return l[0:1] + kth_combination(k, l[1:], r-1) - else: - return kth_combination(k-i, l[1:], r) - - -def ncr(n, r): - if r == 0: - return 1 - r = min(r, n-r) - numerator = reduce(op.mul, range(n, n-r, -1), 1) - denominator = reduce(op.mul, range(1, r+1), 1) - return numerator / denominator +def valid_key_door_pair(door1, door2): + if door1.roomIndex != door2.roomIndex: + return True + return len(door1.entrance.parent_region.exits) <= 1 or len(door2.entrance.parent_region.exits) <= 1 def reassign_key_doors(builder, world, player): @@ -1828,6 +1836,10 @@ def find_inaccessible_regions(world, player): if connect.type is not RegionType.Dungeon or connect.name.endswith(' Portal'): queue.append(connect) world.inaccessible_regions[player].extend([r.name for r in all_regions.difference(visited_regions) if valid_inaccessible_region(r)]) + if world.mode[player] == 'inverted': + ledge = world.get_region('Hyrule Castle Ledge', player) + if any(x for x in ledge.exits if x.connected_region.name == 'Agahnims Tower Portal'): + world.inaccessible_regions[player].append('Hyrule Castle Ledge') logger = logging.getLogger('') logger.debug('Inaccessible Regions:') for r in world.inaccessible_regions[player]: @@ -1912,14 +1924,18 @@ def check_required_paths(paths, world, player): if dungeon_name in world.dungeon_layouts[player].keys(): builder = world.dungeon_layouts[player][dungeon_name] if len(paths[dungeon_name]) > 0: - states_to_explore = defaultdict(list) + states_to_explore = {} for path in paths[dungeon_name]: if type(path) is tuple: - states_to_explore[tuple([path[0]])] = path[1] + states_to_explore[tuple([path[0]])] = (path[1], 'any') else: - states_to_explore[tuple(builder.path_entrances)].append(path) + common_starts = tuple(builder.path_entrances) + if common_starts not in states_to_explore: + states_to_explore[common_starts] = ([], 'all') + states_to_explore[common_starts][0].append(path) cached_initial_state = None - for start_regs, dest_regs in states_to_explore.items(): + for start_regs, info in states_to_explore.items(): + dest_regs, path_type = info if type(dest_regs) is not list: dest_regs = [dest_regs] check_paths = convert_regions(dest_regs, world, player) @@ -1936,11 +1952,17 @@ def check_required_paths(paths, world, player): cached_initial_state = state else: state = cached_initial_state - valid, bad_region = check_if_regions_visited(state, check_paths) + if path_type == 'any': + valid, bad_region = check_if_any_regions_visited(state, check_paths) + else: + valid, bad_region = check_if_all_regions_visited(state, check_paths) if not valid: if check_for_pinball_fix(state, bad_region, world, player): explore_state(state, world, player) - valid, bad_region = check_if_regions_visited(state, check_paths) + if path_type == 'any': + valid, bad_region = check_if_any_regions_visited(state, check_paths) + else: + valid, bad_region = check_if_all_regions_visited(state, check_paths) if not valid: raise Exception('%s cannot reach %s' % (dungeon_name, bad_region.name)) @@ -1980,7 +2002,7 @@ def explore_state_not_inaccessible(state, world, player): state.add_all_doors_check_unattached(connect_region, world, player) -def check_if_regions_visited(state, check_paths): +def check_if_any_regions_visited(state, check_paths): valid = False breaking_region = None for region_target in check_paths: @@ -1992,6 +2014,13 @@ def check_if_regions_visited(state, check_paths): return valid, breaking_region +def check_if_all_regions_visited(state, check_paths): + for region_target in check_paths: + if not state.visited_at_all(region_target): + return False, region_target + return True, None + + def check_for_pinball_fix(state, bad_region, world, player): pinball_region = world.get_region('Skull Pinball', player) # todo: lobby shuffle @@ -2015,10 +2044,10 @@ class DROptions(Flag): Town_Portal = 0x02 # If on, Players will start with mirror scroll Map_Info = 0x04 Debug = 0x08 - Rails = 0x10 # If on, draws rails + Fix_EG = 0x10 # used to be Rails = 0x10 # Unused bit now OriginalPalettes = 0x20 - Open_PoD_Wall = 0x40 # If on, pre opens the PoD wall, no bow required - Open_Desert_Wall = 0x80 # If on, pre opens the desert wall, no fire required + # Open_PoD_Wall = 0x40 # No longer pre-opening pod wall - unused + # Open_Desert_Wall = 0x80 # No longer pre-opening desert wall - unused Hide_Total = 0x100 DarkWorld_Spawns = 0x200 diff --git a/Doors.py b/Doors.py index b0979a5d..a23f46f0 100644 --- a/Doors.py +++ b/Doors.py @@ -1490,8 +1490,11 @@ def create_doors(world, player): world.get_door('GT Petting Zoo SE', player).dead_end() world.get_door('GT DMs Room SW', player).dead_end() 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 (todo) - world.get_door('Swamp Lobby S', player).standard_restricted = True # key-drop note (todo) + 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('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 # can't unlink from boss right now world.get_door('Hera Lobby S', player).dungeonLink = 'Tower of Hera' diff --git a/DungeonGenerator.py b/DungeonGenerator.py index 1c49cba0..f88499bd 100644 --- a/DungeonGenerator.py +++ b/DungeonGenerator.py @@ -39,14 +39,15 @@ def pre_validate(builder, entrance_region_names, split_dungeon, world, player): proposed_map = {} doors_to_connect = {} all_regions = set() - bk_needed = False bk_special = False for sector in builder.sectors: for door in sector.outstanding_doors: doors_to_connect[door.name] = door all_regions.update(sector.regions) - bk_needed = bk_needed or determine_if_bk_needed(sector, split_dungeon, world, player) - bk_special = bk_special or check_for_special(sector) + bk_special |= check_for_special(sector.regions) + bk_needed = False + for sector in builder.sectors: + bk_needed |= determine_if_bk_needed(sector, split_dungeon, bk_special, world, player) paths = determine_paths_for_dungeon(world, player, all_regions, builder.name) dungeon, hangers, hooks = gen_dungeon_info(builder.name, builder.sectors, entrance_regions, all_regions, proposed_map, doors_to_connect, bk_needed, bk_special, world, player) @@ -55,24 +56,6 @@ def pre_validate(builder, entrance_region_names, split_dungeon, world, player): def generate_dungeon(builder, entrance_region_names, split_dungeon, world, player): - stonewalls = check_for_stonewalls(builder) - sector = generate_dungeon_main(builder, entrance_region_names, split_dungeon, world, player) - for stonewall in stonewalls: - if not stonewall_valid(stonewall): - builder.pre_open_stonewalls.add(stonewall) - return sector - - -def check_for_stonewalls(builder): - stonewalls = set() - for sector in builder.sectors: - for door in sector.outstanding_doors: - if door.stonewall: - stonewalls.add(door) - return stonewalls - - -def generate_dungeon_main(builder, entrance_region_names, split_dungeon, world, player): if builder.valid_proposal: # we made this earlier in gen, just use it proposed_map = builder.valid_proposal else: @@ -101,19 +84,37 @@ def generate_dungeon_find_proposal(builder, entrance_region_names, split_dungeon excluded = {} for region in entrance_regions: portal = next((x for x in world.dungeon_portals[player] if x.door.entrance.parent_region == region), None) - if portal and portal.destination: - excluded[region] = None + if portal: + if portal.destination: + excluded[region] = None + elif len(entrance_regions) > 1: + p_region = portal.door.entrance.connected_region + access_region = next(x.parent_region for x in p_region.entrances + if x.parent_region.type in [RegionType.LightWorld, RegionType.DarkWorld]) + if (access_region.name in world.inaccessible_regions[player] and + region.name not in world.enabled_entrances[player]): + excluded[region] = None + elif len(region.entrances) == 1: # for holes + access_region = next(x.parent_region for x in region.entrances + if x.parent_region.type in [RegionType.LightWorld, RegionType.DarkWorld] + or x.parent_region.name == 'Sewer Drop') + if access_region.name == 'Sewer Drop': + access_region = next(x.parent_region for x in access_region.entrances) + if (access_region.name in world.inaccessible_regions[player] and + region.name not in world.enabled_entrances[player]): + excluded[region] = None entrance_regions = [x for x in entrance_regions if x not in excluded.keys()] doors_to_connect = {} all_regions = set() - bk_needed = False bk_special = False for sector in builder.sectors: for door in sector.outstanding_doors: doors_to_connect[door.name] = door all_regions.update(sector.regions) - bk_needed = bk_needed or determine_if_bk_needed(sector, split_dungeon, world, player) - bk_special = bk_special or check_for_special(sector) + bk_special |= check_for_special(sector.regions) + bk_needed = False + for sector in builder.sectors: + bk_needed |= determine_if_bk_needed(sector, split_dungeon, bk_special, world, player) proposed_map = {} choices_master = [[]] depth = 0 @@ -187,8 +188,8 @@ def generate_dungeon_find_proposal(builder, entrance_region_names, split_dungeon return proposed_map -def determine_if_bk_needed(sector, split_dungeon, world, player): - if not split_dungeon: +def determine_if_bk_needed(sector, split_dungeon, bk_special, world, player): + if not split_dungeon or bk_special: for region in sector.regions: for ext in region.exits: door = world.check_for_door(ext.name, player) @@ -197,8 +198,8 @@ def determine_if_bk_needed(sector, split_dungeon, world, player): return False -def check_for_special(sector): - for region in sector.regions: +def check_for_special(regions): + for region in regions: for loc in region.locations: if loc.forced_big_key(): return True @@ -417,6 +418,8 @@ def check_valid(name, dungeon, hangers, hooks, proposed_map, doors_to_connect, a # origin has no more hooks, but not all doors have been proposed if not world.bigkeyshuffle[player]: possible_bks = len(dungeon['Origin'].possible_bk_locations) + if bk_special and check_for_special(dungeon['Origin'].visited_regions): + possible_bks = 1 true_origin_hooks = [x for x in dungeon['Origin'].hooks.keys() if not x.bigKey or possible_bks > 0 or not bk_needed] if len(true_origin_hooks) == 0 and len(proposed_map.keys()) < len(doors_to_connect): return False @@ -450,7 +453,8 @@ def check_valid(name, dungeon, hangers, hooks, proposed_map, doors_to_connect, a bk_possible = not bk_needed or (world.bigkeyshuffle[player] and not bk_special) for piece in dungeon.values(): all_visited.update(piece.visited_regions) - if not bk_possible and len(piece.possible_bk_locations) > 0: + if ((not bk_possible and len(piece.possible_bk_locations) > 0) or + (bk_special and check_for_special(piece.visited_regions))): bk_possible = True if len(all_regions.difference(all_visited)) > 0: return False @@ -572,7 +576,8 @@ def determine_paths_for_dungeon(world, player, all_regions, name): paths.append(boss) if 'Thieves Boss' in all_r_names: paths.append('Thieves Boss') - paths.append(('Thieves Blind\'s Cell', 'Thieves Boss')) + if world.get_dungeon("Thieves Town", player).boss.enemizer_name == 'Blind': + paths.append(('Thieves Blind\'s Cell', 'Thieves Boss')) for drop_check in drop_path_checks: if drop_check in all_r_names: paths.append((drop_check, non_hole_portals)) @@ -598,35 +603,6 @@ def winnow_hangers(hangers, hooks): hangers[hanger].remove(door) -def stonewall_valid(stonewall): - bad_door = stonewall.dest - if bad_door.blocked: - return True # great we're done with this one - loop_region = stonewall.entrance.parent_region - start_regions = [bad_door.entrance.parent_region] - if bad_door.dependents: - for dep in bad_door.dependents: - start_regions.append(dep.entrance.parent_region) - queue = deque(start_regions) - visited = set(start_regions) - while len(queue) > 0: - region = queue.popleft() - if region == loop_region: - return False # guaranteed loop - possible_entrances = list(region.entrances) - for entrance in possible_entrances: - parent = entrance.parent_region - if parent.type != RegionType.Dungeon: - return False # you can get stuck from an entrance - else: - door = entrance.door - if (door is None or (door != stonewall and not door.blocked)) and parent not in visited: - visited.add(parent) - queue.append(parent) - # we didn't find anything bad - return True - - def create_graph_piece_from_state(door, o_state, b_state, proposed_map, exception): # todo: info about dungeon events - not sure about that graph_piece = GraphPiece() @@ -807,6 +783,10 @@ class ExplorationState(object): self.dungeon = dungeon self.pinball_used = False + self.prize_door_set = {} + self.prize_doors = [] + self.prize_doors_opened = False + def copy(self): ret = ExplorationState(dungeon=self.dungeon) ret.unattached_doors = list(self.unattached_doors) @@ -833,6 +813,10 @@ class ExplorationState(object): ret.non_door_entrances = list(self.non_door_entrances) ret.dungeon = self.dungeon ret.pinball_used = self.pinball_used + + ret.prize_door_set = dict(self.prize_door_set) + ret.prize_doors = list(self.prize_doors) + ret.prize_doors_opened = self.prize_doors_opened return ret def next_avail_door(self): @@ -842,6 +826,8 @@ class ExplorationState(object): return exp_door def visit_region(self, region, key_region=None, key_checks=False, bk_Flag=False): + if region.type != RegionType.Dungeon: + self.crystal = CrystalBarrier.Orange if self.crystal == CrystalBarrier.Either: if region not in self.visited_blue: self.visited_blue.append(region) @@ -868,6 +854,8 @@ class ExplorationState(object): if location.name in flooded_keys_reverse.keys() and self.location_found( flooded_keys_reverse[location.name]): self.perform_event(flooded_keys_reverse[location.name], key_region) + if '- Prize' in location.name: + self.prize_received = True def flooded_key_check(self, location): if location.name not in flooded_keys.keys(): @@ -1103,9 +1091,9 @@ def valid_region_to_explore_in_regions(region, all_regions, world, player): def valid_region_to_explore(region, name, world, player): if region is None: return False - return (region.type == RegionType.Dungeon and region.dungeon.name in name)\ - or region.name in world.inaccessible_regions[player]\ - or (region.name == 'Hyrule Castle Ledge' and world.mode[player] == 'standard') + return ((region.type == RegionType.Dungeon and region.dungeon and region.dungeon.name in name) + or region.name in world.inaccessible_regions[player] + or (region.name == 'Hyrule Castle Ledge' and world.mode[player] == 'standard')) def get_doors(world, region, player): @@ -1172,9 +1160,8 @@ class DungeonBuilder(object): self.path_entrances = None # used for pathing/key doors, I think self.split_flag = False - self.pre_open_stonewalls = set() # used by stonewall system - self.candidates = None + self.total_keys = None self.key_doors_num = None self.combo_size = None self.flex = 0 @@ -1249,6 +1236,9 @@ def create_dungeon_builders(all_sectors, connections_tuple, world, player, for r_name in ['Hyrule Dungeon Cellblock', 'Sanctuary']: # need to deliver zelda assign_sector(find_sector(r_name, candidate_sectors), current_dungeon, candidate_sectors, global_pole) + if key == 'Thieves Town' and world.get_dungeon("Thieves Town", player).boss.enemizer_name == 'Blind': + assign_sector(find_sector("Thieves Blind's Cell", candidate_sectors), current_dungeon, + candidate_sectors, global_pole) entrances_map, potentials, connections = connections_tuple accessible_sectors, reverse_d_map = set(), {} for key in dungeon_entrances.keys(): @@ -1290,13 +1280,16 @@ def create_dungeon_builders(all_sectors, connections_tuple, world, player, sanc_builder = random.choice(lw_builders) assign_sector(sanc, sanc_builder, candidate_sectors, global_pole) + bow_sectors, retro_std_flag = {}, world.retro[player] and world.mode[player] == 'standard' free_location_sectors = {} crystal_switches = {} crystal_barriers = {} polarized_sectors = {} neutral_sectors = {} for sector in candidate_sectors: - if sector.chest_locations > 0: + if retro_std_flag and 'Bow' in sector.item_logic: # these need to be distributed outside of HC + bow_sectors[sector] = None + elif sector.chest_locations > 0: free_location_sectors[sector] = None elif sector.c_switch: crystal_switches[sector] = None @@ -1306,6 +1299,8 @@ def create_dungeon_builders(all_sectors, connections_tuple, world, player, neutral_sectors[sector] = None else: polarized_sectors[sector] = None + if bow_sectors: + assign_bow_sectors(dungeon_map, bow_sectors, global_pole) assign_location_sectors(dungeon_map, free_location_sectors, global_pole) leftover = assign_crystal_switch_sectors(dungeon_map, crystal_switches, crystal_barriers, global_pole) ensure_crystal_switches_reachable(dungeon_map, leftover, polarized_sectors, crystal_barriers, global_pole) @@ -1471,6 +1466,9 @@ def define_sector_features(sectors): sector.blue_barrier = True if door.bigKey: sector.bk_required = True + 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') def assign_sector(sector, dungeon, candidate_sectors, global_pole): @@ -1521,6 +1519,19 @@ def find_sector(r_name, sectors): return None +def assign_bow_sectors(dungeon_map, bow_sectors, global_pole): + sector_list = list(bow_sectors) + random.shuffle(sector_list) + population = [] + for name in dungeon_map: + if name != 'Hyrule Castle': + population.append(name) + 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) + + def assign_location_sectors(dungeon_map, free_location_sectors, global_pole): valid = False choices = None @@ -3274,6 +3285,7 @@ def check_for_valid_layout(builder, sector_list, builder_info): possible_regions.add(portal.door.entrance.parent_region.name) if builder.name in dungeon_drops.keys(): 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(): name_bits = name.split(" ") orig_name = " ".join(name_bits[:-1]) @@ -3286,7 +3298,8 @@ def check_for_valid_layout(builder, sector_list, builder_info): if r_name not in entrance_regions: entrance_regions.append(r_name) # entrance_regions = [x for x in entrance_regions if x not in split_check_entrance_invalid] - proposal = generate_dungeon_find_proposal(split_build, entrance_regions, True, world, player) + split = any(x for x in independents if x not in entrance_regions) + proposal = generate_dungeon_find_proposal(split_build, entrance_regions, split, world, player) # record split proposals builder.valid_proposal[name] = proposal builder.exception_list = list(sector_list) @@ -3301,6 +3314,32 @@ def check_for_valid_layout(builder, sector_list, builder_info): return len(unreached_doors) == 0, unreached_doors +def find_independent_entrances(entrance_regions, world, player): + independents = set() + for region in entrance_regions: + portal = next((x for x in world.dungeon_portals[player] if x.door.entrance.parent_region.name == region), None) + if portal: + if portal.destination: + continue + elif len(entrance_regions) > 1: + p_region = portal.door.entrance.connected_region + access_region = next(x.parent_region for x in p_region.entrances + if x.parent_region.type in [RegionType.LightWorld, RegionType.DarkWorld]) + if access_region.name in world.inaccessible_regions[player]: + continue + else: + r = world.get_region(region, player) + access_region = next(x.parent_region for x in r.entrances + if x.parent_region.type in [RegionType.LightWorld, RegionType.DarkWorld] + or x.parent_region.name == 'Sewer Drop') + if access_region.name == 'Sewer Drop': + access_region = next(x.parent_region for x in access_region.entrances) + if access_region.name in world.inaccessible_regions[player]: + continue + independents.add(region) + return independents + + def resolve_equations(builder, sector_list): unreached_doors = defaultdict(list) equations = {x: y for x, y in copy_door_equations(builder, sector_list).items() if len(y) > 0} @@ -3823,7 +3862,7 @@ dungeon_boss_sectors = { 'Palace of Darkness': ['PoD Boss'], 'Swamp Palace': ['Swamp Boss'], 'Skull Woods': ['Skull Boss'], - 'Thieves Town': ['Thieves Blind\'s Cell', 'Thieves Boss'], + 'Thieves Town': ['Thieves Boss'], 'Ice Palace': ['Ice Boss'], 'Misery Mire': ['Mire Boss'], 'Turtle Rock': ['TR Boss'], diff --git a/Dungeons.py b/Dungeons.py index 596da920..6fe38cfb 100644 --- a/Dungeons.py +++ b/Dungeons.py @@ -375,6 +375,38 @@ flexible_starts = { 'Skull Woods': ['Skull Left Drop', 'Skull Pinball'] } + +class DungeonInfo: + + def __init__(self, free, keys, bk, map, compass, bk_drop, drops, prize=None): + # todo reduce static maps ideas: prize, bk_name, sm_name, cmp_name, map_name): + self.free_items = free + self.key_num = keys + self.bk_present = bk + self.map_present = map + self.compass_present = compass + self.bk_drops = bk_drop + self.key_drops = drops + self.prize = prize + + +dungeon_table = { + 'Hyrule Castle': DungeonInfo(6, 1, False, True, False, True, 3, None), + 'Eastern Palace': DungeonInfo(3, 0, True, True, True, False, 2, 'Eastern Palace - Prize'), + 'Desert Palace': DungeonInfo(2, 1, True, True, True, False, 3, 'Desert Palace - Prize'), + 'Tower of Hera': DungeonInfo(2, 1, True, True, True, False, 0, 'Tower of Hera - Prize'), + 'Agahnims Tower': DungeonInfo(0, 2, False, False, False, False, 2, None), + 'Palace of Darkness': DungeonInfo(5, 6, True, True, True, False, 0, 'Palace of Darkness - Prize'), + 'Swamp Palace': DungeonInfo(6, 1, True, True, True, False, 5, 'Swamp Palace - Prize'), + 'Skull Woods': DungeonInfo(2, 3, True, True, True, False, 2, 'Skull Woods - Prize'), + 'Thieves Town': DungeonInfo(4, 1, True, True, True, False, 2, "Thieves' Town - Prize"), + 'Ice Palace': DungeonInfo(3, 2, True, True, True, False, 4, 'Ice Palace - Prize'), + 'Misery Mire': DungeonInfo(2, 3, True, True, True, False, 3, 'Misery Mire - Prize'), + 'Turtle Rock': DungeonInfo(5, 4, True, True, True, False, 2, 'Turtle Rock - Prize'), + 'Ganons Tower': DungeonInfo(20, 4, True, True, True, False, 4, None), +} + + dungeon_keys = { 'Hyrule Castle': 'Small Key (Escape)', 'Eastern Palace': 'Small Key (Eastern Palace)', @@ -407,6 +439,7 @@ dungeon_bigs = { 'Ganons Tower': 'Big Key (Ganons Tower)' } + dungeon_hints = { 'Hyrule Castle': 'in Hyrule Castle', 'Eastern Palace': 'in Eastern Palace', diff --git a/EntranceShuffle.py b/EntranceShuffle.py index 1b8ce162..b8715b06 100644 --- a/EntranceShuffle.py +++ b/EntranceShuffle.py @@ -109,6 +109,7 @@ def link_entrances(world, player): links_house_doors = [i for i in LW_Single_Cave_Doors if i not in Isolated_LH_Doors_Open] links_house = random.choice(links_house_doors) connect_two_way(world, links_house, 'Links House Exit', player) + connect_exit(world, 'Chris Houlihan Room Exit', links_house, player) # should match link's house if links_house in bomb_shop_doors: bomb_shop_doors.remove(links_house) if links_house in blacksmith_doors: @@ -173,6 +174,7 @@ def link_entrances(world, player): links_house_doors = [i for i in lw_entrances if i not in Isolated_LH_Doors_Open] links_house = random.choice(links_house_doors) connect_two_way(world, links_house, 'Links House Exit', player) + connect_exit(world, 'Chris Houlihan Room Exit', links_house, player) # should match link's house if links_house in lw_entrances: lw_entrances.remove(links_house) @@ -215,6 +217,13 @@ def link_entrances(world, player): if bomb_shop in dw_entrances: dw_entrances.remove(bomb_shop) + # standard mode cannot have Bonk Fairy Light be a connector in case of starting boots + # or boots are in links house, etc. + removed = False + if world.mode[player] == 'standard' and 'Bonk Fairy (Light)' in lw_entrances: + lw_entrances.remove('Bonk Fairy (Light)') + removed = True + # place the old man cave's entrance somewhere in the light world random.shuffle(lw_entrances) old_man_entrance = lw_entrances.pop() @@ -226,6 +235,8 @@ def link_entrances(world, player): # now scramble the rest connect_caves(world, lw_entrances, dw_entrances, caves, player) + if removed: + lw_entrances.append('Bonk Fairy (Light)') # scramble holes scramble_holes(world, player) @@ -328,6 +339,7 @@ def link_entrances(world, player): links_house_doors = [i for i in lw_entrances + lw_must_exits if i not in Isolated_LH_Doors_Open] links_house = random.choice(links_house_doors) connect_two_way(world, links_house, 'Links House Exit', player) + connect_exit(world, 'Chris Houlihan Room Exit', links_house, player) # should match link's house if links_house in lw_entrances: lw_entrances.remove(links_house) if links_house in lw_must_exits: @@ -395,14 +407,23 @@ def link_entrances(world, player): if bomb_shop in dw_entrances: dw_entrances.remove(bomb_shop) + # standard mode cannot have Bonk Fairy Light be a connector in case of + # starting boots or boots are in links house, etc. + removed = False + if world.mode[player] == 'standard' and 'Bonk Fairy (Light)' in lw_entrances: + lw_entrances.remove('Bonk Fairy (Light)') + removed = True + # place the old man cave's entrance somewhere in the light world old_man_entrance = lw_entrances.pop() connect_two_way(world, old_man_entrance, 'Old Man Cave Exit (West)', player) - # now scramble the rest connect_caves(world, lw_entrances, dw_entrances, caves, player) + if removed: + lw_entrances.append('Bonk Fairy (Light)') + # scramble holes scramble_holes(world, player) @@ -449,6 +470,7 @@ def link_entrances(world, player): links_house_doors = [i for i in links_house_doors if i not in exclusions] links_house = random.choice(list(links_house_doors)) connect_two_way(world, links_house, 'Links House Exit', player) + connect_exit(world, 'Chris Houlihan Room Exit', links_house, player) # should match link's house if links_house in entrances: entrances.remove(links_house) elif links_house in must_exits: @@ -487,16 +509,24 @@ def link_entrances(world, player): connect_entrance(world, bomb_shop, 'Big Bomb Shop', player) entrances.remove(bomb_shop) + # standard mode cannot have Bonk Fairy Light be a connector in case of + # starting boots or boots are in links house, etc. + removed = False + if world.mode[player] == 'standard' and 'Bonk Fairy (Light)' in entrances: + entrances.remove('Bonk Fairy (Light)') + removed = True # place the old man cave's entrance somewhere random.shuffle(entrances) old_man_entrance = entrances.pop() connect_two_way(world, old_man_entrance, 'Old Man Cave Exit (West)', player) - # now scramble the rest connect_caves(world, entrances, [], caves, player) + if removed: + entrances.append('Bonk Fairy (Light)') + # scramble holes scramble_holes(world, player) @@ -911,6 +941,7 @@ def link_entrances(world, player): links_house_doors = [i for i in links_house_doors if i not in exclusions] links_house = random.choice(links_house_doors) connect_two_way(world, links_house, 'Links House Exit', player) + connect_exit(world, 'Chris Houlihan Room Exit', links_house, player) # should match link's house exit_pool.remove(links_house) doors.remove(links_house) @@ -971,6 +1002,13 @@ def link_entrances(world, player): connect_entrance(world, bomb_shop, 'Big Bomb Shop', player) doors.remove(bomb_shop) + # standard mode cannot have Bonk Fairy Light be a connector in case of + # starting boots or boots are in links house, etc. + removed = False + if world.mode[player] == 'standard' and 'Bonk Fairy (Light)' in doors: + doors.remove('Bonk Fairy (Light)') + removed = True + # handle remaining caves for cave in caves: if isinstance(cave, str): @@ -980,6 +1018,9 @@ def link_entrances(world, player): connect_exit(world, exit, exit_pool.pop(), player) connect_entrance(world, doors.pop(), exit, player) + if removed: + doors.append('Bonk Fairy (Light)') + # place remaining doors connect_doors(world, doors, door_targets, player) elif world.shuffle[player] == 'insanity_legacy': @@ -1265,6 +1306,7 @@ def link_inverted_entrances(world, player): links_house_doors = [i for i in DW_Single_Cave_Doors if i not in Inverted_Dark_Sanctuary_Doors + Isolated_LH_Doors] links_house = random.choice(links_house_doors) connect_two_way(world, links_house, 'Inverted Links House Exit', player) + connect_exit(world, 'Chris Houlihan Room Exit', links_house, player) # should match link's house if links_house in bomb_shop_doors: bomb_shop_doors.remove(links_house) if links_house in blacksmith_doors: @@ -1345,6 +1387,7 @@ def link_inverted_entrances(world, player): links_house_doors = [i for i in dw_entrances if i not in Inverted_Dark_Sanctuary_Doors + Isolated_LH_Doors] links_house = random.choice(links_house_doors) connect_two_way(world, links_house, 'Inverted Links House Exit', player) + connect_exit(world, 'Chris Houlihan Room Exit', links_house, player) # should match link's house if links_house in dw_entrances: dw_entrances.remove(links_house) @@ -1474,6 +1517,7 @@ def link_inverted_entrances(world, player): links_house_doors = [i for i in dw_entrances if i not in Inverted_Dark_Sanctuary_Doors + Isolated_LH_Doors] links_house = random.choice(links_house_doors) connect_two_way(world, links_house, 'Inverted Links House Exit', player) + connect_exit(world, 'Chris Houlihan Room Exit', links_house, player) # should match link's house if links_house in dw_entrances: dw_entrances.remove(links_house) @@ -1611,6 +1655,7 @@ def link_inverted_entrances(world, player): links_house_doors = [i for i in entrances + must_exits if i not in Inverted_Dark_Sanctuary_Doors + Isolated_LH_Doors] links_house = random.choice(links_house_doors) connect_two_way(world, links_house, 'Inverted Links House Exit', player) + connect_exit(world, 'Chris Houlihan Room Exit', links_house, player) # should match link's house if links_house in entrances: entrances.remove(links_house) elif links_house in must_exits: @@ -1744,6 +1789,7 @@ def link_inverted_entrances(world, player): links_house_doors = [i for i in doors if i not in Inverted_Dark_Sanctuary_Doors + Isolated_LH_Doors] links_house = random.choice(links_house_doors) connect_two_way(world, links_house, 'Inverted Links House Exit', player) + connect_exit(world, 'Chris Houlihan Room Exit', links_house, player) # should match link's house doors.remove(links_house) exit_pool.remove(links_house) @@ -2120,10 +2166,11 @@ def connect_doors(world, doors, targets, player): """This works inplace""" random.shuffle(doors) random.shuffle(targets) - while doors: - door = doors.pop() - target = targets.pop() + placing = min(len(doors), len(targets)) + for door, target in zip(doors, targets): connect_entrance(world, door, target, player) + doors[:] = doors[placing:] + targets[:] = targets[placing:] def skull_woods_shuffle(world, player): @@ -2373,7 +2420,7 @@ Cave_Exits_Base = [['Elder House Exit (East)', 'Elder House Exit (West)'], ['Death Mountain Return Cave Exit (West)', 'Death Mountain Return Cave Exit (East)'], ['Fairy Ascension Cave Exit (Bottom)', 'Fairy Ascension Cave Exit (Top)'], ['Bumper Cave Exit (Top)', 'Bumper Cave Exit (Bottom)'], - ['Hookshot Cave Exit (South)', 'Hookshot Cave Exit (North)']] + ['Hookshot Cave Back Exit', 'Hookshot Cave Front Exit']] Cave_Exits_Base += [('Superbunny Cave Exit (Bottom)', 'Superbunny Cave Exit (Top)'), ('Spiral Cave Exit (Top)', 'Spiral Cave Exit')] @@ -3115,6 +3162,10 @@ mandatory_connections = [('Links House S&Q', 'Links House'), ('Dark Death Mountain Drop (West)', 'Dark Death Mountain (West Bottom)'), ('East Death Mountain (Top) Mirror Spot', 'East Death Mountain (Top)'), ('Superbunny Cave Climb', 'Superbunny Cave (Top)'), + ('Hookshot Cave Front to Middle', 'Hookshot Cave (Middle)'), + ('Hookshot Cave Middle to Front', 'Hookshot Cave (Front)'), + ('Hookshot Cave Middle to Back', 'Hookshot Cave (Back)'), + ('Hookshot Cave Back to Middle', 'Hookshot Cave (Middle)'), ('Turtle Rock Teleporter', 'Turtle Rock (Top)'), ('Turtle Rock Drop', 'Dark Death Mountain (Top)'), ('Floating Island Drop', 'Dark Death Mountain (Top)'), @@ -3233,6 +3284,10 @@ inverted_mandatory_connections = [('Links House S&Q', 'Inverted Links House'), ('Turtle Rock Tail Drop', 'Turtle Rock (Top)'), ('Turtle Rock Drop', 'Dark Death Mountain'), ('Superbunny Cave Climb', 'Superbunny Cave (Top)'), + ('Hookshot Cave Front to Middle', 'Hookshot Cave (Middle)'), + ('Hookshot Cave Middle to Front', 'Hookshot Cave (Front)'), + ('Hookshot Cave Middle to Back', 'Hookshot Cave (Back)'), + ('Hookshot Cave Back to Middle', 'Hookshot Cave (Middle)'), ('Desert Ledge Drop', 'Light World'), ('Floating Island Drop', 'Dark Death Mountain'), ('Dark Lake Hylia Central Island Teleporter', 'Lake Hylia Central Island'), @@ -3284,7 +3339,6 @@ inverted_mandatory_connections = [('Links House S&Q', 'Inverted Links House'), ('EDDM Flute', 'The Sky'), ('Dark Grassy Lawn Flute', 'The Sky'), ('Hammer Peg Area Flute', 'The Sky'), - ('Chris Houlihan Room Exit', 'Pyramid Ledge'), ('Bush Covered Lawn Inner Bushes', 'Light World'), ('Bush Covered Lawn Outer Bushes', 'Bush Covered Lawn'), ('Bush Covered Lawn Mirror Spot', 'Dark Grassy Lawn'), @@ -3428,16 +3482,16 @@ default_connections = [('Links House', 'Links House'), ('Dark Desert Hint', 'Dark Desert Hint'), ('Dark Desert Fairy', 'Dark Desert Healer Fairy'), ('Spike Cave', 'Spike Cave'), - ('Hookshot Cave', 'Hookshot Cave'), + ('Hookshot Cave', 'Hookshot Cave (Front)'), ('Superbunny Cave (Top)', 'Superbunny Cave (Top)'), ('Cave Shop (Dark Death Mountain)', 'Cave Shop (Dark Death Mountain)'), ('Dark Death Mountain Fairy', 'Dark Death Mountain Healer Fairy'), ('Superbunny Cave (Bottom)', 'Superbunny Cave (Bottom)'), ('Superbunny Cave Exit (Top)', 'Dark Death Mountain (Top)'), ('Superbunny Cave Exit (Bottom)', 'Dark Death Mountain (East Bottom)'), - ('Hookshot Cave Exit (South)', 'Dark Death Mountain (Top)'), - ('Hookshot Cave Exit (North)', 'Death Mountain Floating Island (Dark World)'), - ('Hookshot Cave Back Entrance', 'Hookshot Cave'), + ('Hookshot Cave Front Exit', 'Dark Death Mountain (Top)'), + ('Hookshot Cave Back Exit', 'Death Mountain Floating Island (Dark World)'), + ('Hookshot Cave Back Entrance', 'Hookshot Cave (Back)'), ('Mimic Cave', 'Mimic Cave'), ('Pyramid Hole', 'Pyramid'), @@ -3562,13 +3616,13 @@ inverted_default_connections = [('Waterfall of Wishing', 'Waterfall of Wishing' ('Dark Desert Hint', 'Dark Desert Hint'), ('Dark Desert Fairy', 'Dark Desert Healer Fairy'), ('Spike Cave', 'Spike Cave'), - ('Hookshot Cave', 'Hookshot Cave'), + ('Hookshot Cave', 'Hookshot Cave (Front)'), ('Superbunny Cave (Top)', 'Superbunny Cave (Top)'), ('Cave Shop (Dark Death Mountain)', 'Cave Shop (Dark Death Mountain)'), ('Superbunny Cave (Bottom)', 'Superbunny Cave (Bottom)'), ('Superbunny Cave Exit (Bottom)', 'Dark Death Mountain (East Bottom)'), - ('Hookshot Cave Exit (North)', 'Death Mountain Floating Island (Dark World)'), - ('Hookshot Cave Back Entrance', 'Hookshot Cave'), + ('Hookshot Cave Back Exit', 'Death Mountain Floating Island (Dark World)'), + ('Hookshot Cave Back Entrance', 'Hookshot Cave (Back)'), ('Mimic Cave', 'Mimic Cave'), ('Inverted Pyramid Hole', 'Pyramid'), ('Inverted Links House', 'Inverted Links House'), @@ -3589,7 +3643,7 @@ inverted_default_connections = [('Waterfall of Wishing', 'Waterfall of Wishing' ('Death Mountain Return Cave (East)', 'Death Mountain Return Cave'), ('Death Mountain Return Cave Exit (West)', 'Death Mountain'), ('Death Mountain Return Cave Exit (East)', 'Death Mountain'), - ('Hookshot Cave Exit (South)', 'Dark Death Mountain'), + ('Hookshot Cave Front Exit', 'Dark Death Mountain'), ('Superbunny Cave Exit (Top)', 'Dark Death Mountain'), ('Pyramid Exit', 'Light World'), ('Inverted Pyramid Entrance', 'Bottom of Pyramid')] @@ -3937,8 +3991,8 @@ exit_ids = {'Links House Exit': (0x01, 0x00), 'Bumper Cave Exit (Bottom)': (0x16, 0x17), 'Superbunny Cave Exit (Top)': (0x14, 0x15), 'Superbunny Cave Exit (Bottom)': (0x13, 0x14), - 'Hookshot Cave Exit (South)': (0x3A, 0x3B), - 'Hookshot Cave Exit (North)': (0x3B, 0x3C), + 'Hookshot Cave Front Exit': (0x3A, 0x3B), + 'Hookshot Cave Back Exit': (0x3B, 0x3C), 'Ganons Tower Exit': (0x37, 0x38), 'Inverted Ganons Tower Exit': (0x37, 0x38), 'Pyramid Exit': (0x36, 0x37), diff --git a/Fill.py b/Fill.py index 7572b350..6a5a8fc9 100644 --- a/Fill.py +++ b/Fill.py @@ -1,4 +1,6 @@ import RaceRandom as random +import collections +import itertools import logging from BaseClasses import CollectionState @@ -211,7 +213,7 @@ def fill_restrictive(world, base_state, locations, itempool, keys_in_itempool = and valid_key_placement(item_to_place, location, itempool if (keys_in_itempool and keys_in_itempool[item_to_place.player]) else world.itempool, world): spot_to_fill = location break - elif item_to_place.smallkey or item_to_place.bigkey: + if item_to_place.smallkey or item_to_place.bigkey: location.item = None if spot_to_fill is None: @@ -221,7 +223,10 @@ def fill_restrictive(world, base_state, locations, itempool, keys_in_itempool = if world.accessibility[item_to_place.player] != 'none': logging.getLogger('').warning('Not all items placed. Game beatable anyway. (Could not place %s)' % item_to_place) continue - raise FillError('No more spots to place %s' % item_to_place) + spot_to_fill = last_ditch_placement(item_to_place, locations, world, maximum_exploration_state, + base_state, itempool, keys_in_itempool, single_player_placement) + if spot_to_fill is None: + raise FillError('No more spots to place %s' % item_to_place) world.push_item(spot_to_fill, item_to_place, False) track_outside_keys(item_to_place, spot_to_fill, world) @@ -240,7 +245,11 @@ def valid_key_placement(item, location, itempool, world): return True key_logic = world.key_logic[item.player][dungeon.name] unplaced_keys = len([x for x in itempool if x.name == key_logic.small_key_name and x.player == item.player]) - return key_logic.check_placement(unplaced_keys, location if item.bigkey else None) + prize_loc = None + if key_logic.prize_location: + prize_loc = world.get_location(key_logic.prize_location, location.player) + cr_count = world.crystals_needed_for_gt[location.player] + return key_logic.check_placement(unplaced_keys, location if item.bigkey else None, prize_loc, cr_count) else: inside_dungeon_item = ((item.smallkey and not world.keyshuffle[item.player]) or (item.bigkey and not world.bigkeyshuffle[item.player])) @@ -250,9 +259,7 @@ def valid_key_placement(item, location, itempool, world): def track_outside_keys(item, location, world): if not item.smallkey: return - item_dungeon = item.name.split('(')[1][:-1] - if item_dungeon == 'Escape': - item_dungeon = 'Hyrule Castle' + item_dungeon = item.dungeon if location.player == item.player: loc_dungeon = location.parent_region.dungeon if loc_dungeon and loc_dungeon.name == item_dungeon: @@ -260,6 +267,80 @@ def track_outside_keys(item, location, world): world.key_logic[item.player][item_dungeon].outside_keys += 1 +def last_ditch_placement(item_to_place, locations, world, state, base_state, itempool, + keys_in_itempool=None, single_player_placement=False): + def location_preference(loc): + if not loc.item.advancement: + return 1 + if loc.item.type and loc.item.type != 'Sword': + if loc.item.type in ['Map', 'Compass']: + return 2 + else: + return 3 + return 4 + + if item_to_place.type == 'Crystal': + 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] + swap_locations = sorted(possible_swaps, key=location_preference) + + for location in swap_locations: + old_item = location.item + new_pool = list(itempool) + [old_item] + new_spot = find_spot_for_item(item_to_place, [location], world, base_state, new_pool, + keys_in_itempool, single_player_placement) + if new_spot: + restore_item = new_spot.item + new_spot.item = item_to_place + swap_spot = find_spot_for_item(old_item, locations, world, base_state, itempool, + keys_in_itempool, single_player_placement) + if swap_spot: + logging.getLogger('').debug(f'Swapping {old_item} for {item_to_place}') + world.push_item(swap_spot, old_item, False) + swap_spot.event = True + locations.remove(swap_spot) + locations.append(new_spot) + return new_spot + else: + new_spot.item = restore_item + else: + location.item = old_item + return None + + +def find_spot_for_item(item_to_place, locations, world, base_state, pool, + keys_in_itempool=None, single_player_placement=False): + def sweep_from_pool(): + new_state = base_state.copy() + for item in pool: + new_state.collect(item, True) + new_state.sweep_for_events() + return new_state + for location in locations: + maximum_exploration_state = sweep_from_pool() + perform_access_check = True + old_item = None + if world.accessibility[item_to_place.player] == 'none': + perform_access_check = not world.has_beaten_game(maximum_exploration_state, item_to_place.player) if single_player_placement else not world.has_beaten_game(maximum_exploration_state) + + if item_to_place.smallkey or item_to_place.bigkey: # a better test to see if a key can go there + old_item = location.item + location.item = item_to_place + test_state = maximum_exploration_state.copy() + test_state.stale[item_to_place.player] = True + else: + 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): + return location + if item_to_place.smallkey or item_to_place.bigkey: + location.item = old_item + return None + + def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None): # If not passed in, then get a shuffled list of locations to fill in if not fill_locations: @@ -420,9 +501,8 @@ def sell_keys(world, player): def balance_multiworld_progression(world): state = CollectionState(world) - checked_locations = [] - unchecked_locations = world.get_locations().copy() - random.shuffle(unchecked_locations) + checked_locations = set() + unchecked_locations = set(world.get_locations()) reachable_locations_count = {} for player in range(1, world.players + 1): @@ -430,7 +510,7 @@ def balance_multiworld_progression(world): def get_sphere_locations(sphere_state, locations): sphere_state.sweep_for_events(key_only=True, locations=locations) - return [loc for loc in locations if sphere_state.can_reach(loc) and sphere_state.not_flooding_a_key(sphere_state.world, loc)] + return {loc for loc in locations if sphere_state.can_reach(loc) and sphere_state.not_flooding_a_key(sphere_state.world, loc)} while True: sphere_locations = get_sphere_locations(state, unchecked_locations) @@ -441,38 +521,42 @@ def balance_multiworld_progression(world): if checked_locations: threshold = max(reachable_locations_count.values()) - 20 - balancing_players = [player for player, reachables in reachable_locations_count.items() if reachables < threshold] - if balancing_players is not None and len(balancing_players) > 0: + balancing_players = {player for player, reachables in reachable_locations_count.items() if reachables < threshold} + if balancing_players: balancing_state = state.copy() balancing_unchecked_locations = unchecked_locations.copy() balancing_reachables = reachable_locations_count.copy() balancing_sphere = sphere_locations.copy() - candidate_items = [] + candidate_items = collections.defaultdict(set) while True: for location in balancing_sphere: if location.event and (world.keyshuffle[location.item.player] or not location.item.smallkey) and (world.bigkeyshuffle[location.item.player] or not location.item.bigkey): balancing_state.collect(location.item, True, location) - if location.item.player in balancing_players and not location.locked: - candidate_items.append(location) + player = location.item.player + if player in balancing_players and not location.locked and location.player != player: + candidate_items[player].add(location) balancing_sphere = get_sphere_locations(balancing_state, balancing_unchecked_locations) for location in balancing_sphere: balancing_unchecked_locations.remove(location) balancing_reachables[location.player] += 1 - if world.has_beaten_game(balancing_state) or all([reachables >= threshold for reachables in balancing_reachables.values()]): + if world.has_beaten_game(balancing_state) or all(reachables >= threshold for reachables in balancing_reachables.values()): break elif not balancing_sphere: raise RuntimeError('Not all required items reachable. Something went terribly wrong here.') - unlocked_locations = [l for l in unchecked_locations if l not in balancing_unchecked_locations] + unlocked_locations = collections.defaultdict(set) + for l in unchecked_locations: + if l not in balancing_unchecked_locations: + unlocked_locations[l.player].add(l) items_to_replace = [] for player in balancing_players: - locations_to_test = [l for l in unlocked_locations if l.player == player] - # only replace items that end up in another player's world - items_to_test = [l for l in candidate_items if l.item.player == player and l.player != player] + locations_to_test = unlocked_locations[player] + items_to_test = candidate_items[player] while items_to_test: testing = items_to_test.pop() reducing_state = state.copy() - for location in [*[l for l in items_to_replace if l.item.player == player], *items_to_test]: + for location in itertools.chain((l for l in items_to_replace if l.item.player == player), + items_to_test): reducing_state.collect(location.item, True, location) reducing_state.sweep_for_events(locations=locations_to_test) @@ -486,33 +570,44 @@ def balance_multiworld_progression(world): items_to_replace.append(testing) replaced_items = False - replacement_locations = [l for l in checked_locations if not l.event and not l.locked] + # sort then shuffle to maintain deterministic behaviour, + # while allowing use of set for better algorithm growth behaviour elsewhere + replacement_locations = sorted((l for l in checked_locations if not l.event and not l.locked), + key=lambda loc: (loc.name, loc.player)) + random.shuffle(replacement_locations) + items_to_replace.sort(key=lambda item: (item.name, item.player)) + random.shuffle(items_to_replace) while replacement_locations and items_to_replace: - new_location = replacement_locations.pop() old_location = items_to_replace.pop() + for new_location in replacement_locations: + if (new_location.can_fill(state, old_location.item, False) and + old_location.can_fill(state, new_location.item, False)): + replacement_locations.remove(new_location) + new_location.item, old_location.item = old_location.item, new_location.item + if world.shopsanity[new_location.player]: + check_shop_swap(new_location) + if world.shopsanity[old_location.player]: + check_shop_swap(old_location) + new_location.event, old_location.event = True, False + logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, " + f"displacing {old_location.item} into {old_location}") + state.collect(new_location.item, True, new_location) + replaced_items = True + break + else: + logging.warning(f"Could not Progression Balance {old_location.item}") - while not new_location.can_fill(state, old_location.item, False) or (new_location.item and not old_location.can_fill(state, new_location.item, False)): - replacement_locations.insert(0, new_location) - new_location = replacement_locations.pop() - - new_location.item, old_location.item = old_location.item, new_location.item - if world.shopsanity[new_location.player]: - check_shop_swap(new_location) - if world.shopsanity[old_location.player]: - check_shop_swap(old_location) - new_location.event, old_location.event = True, False - state.collect(new_location.item, True, new_location) - replaced_items = True if replaced_items: - for location in get_sphere_locations(state, [l for l in unlocked_locations if l.player in balancing_players]): + unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]} + for location in get_sphere_locations(state, unlocked): unchecked_locations.remove(location) reachable_locations_count[location.player] += 1 - sphere_locations.append(location) + sphere_locations.add(location) for location in sphere_locations: if location.event and (world.keyshuffle[location.item.player] or not location.item.smallkey) and (world.bigkeyshuffle[location.item.player] or not location.item.bigkey): state.collect(location.item, True, location) - checked_locations.extend(sphere_locations) + checked_locations |= sphere_locations if world.has_beaten_game(state): break @@ -553,7 +648,7 @@ def balance_money_progression(world): base_value = sum(rupee_rooms.values()) available_money = {player: base_value for player in range(1, world.players+1)} for loc in world.get_locations(): - if loc.item.name in rupee_chart: + if loc.item and loc.item.name in rupee_chart: available_money[loc.item.player] += rupee_chart[loc.item.name] total_price = {player: 0 for player in range(1, world.players+1)} @@ -618,7 +713,7 @@ def balance_money_progression(world): slot = shop_to_location_table[location.parent_region.name].index(location.name) shop = location.parent_region.shop shop_item = shop.inventory[slot] - if interesting_item(location, location.item, world, location.item.player): + if location.item and interesting_item(location, location.item, world, location.item.player): if location.item.name.startswith('Rupee') and loc_player == location.item.player: if shop_item['price'] < rupee_chart[location.item.name]: wallet[loc_player] -= shop_item['price'] # will get picked up in the location_free block @@ -638,20 +733,21 @@ def balance_money_progression(world): if location_free: state.collect(location.item, True, location) unchecked_locations.remove(location) - if location.item.name.startswith('Rupee'): - wallet[location.item.player] += rupee_chart[location.item.name] - if location.item.name != 'Rupees (300)': + if location.item: + if location.item.name.startswith('Rupee'): + wallet[location.item.player] += rupee_chart[location.item.name] + if location.item.name != 'Rupees (300)': + balance_locations[location.item.player].add(location) + if interesting_item(location, location.item, world, location.item.player): + checked_locations.append(location) + elif location.item.name in acceptable_balancers: balance_locations[location.item.player].add(location) - if interesting_item(location, location.item, world, location.item.player): - checked_locations.append(location) - elif location.item.name in acceptable_balancers: - balance_locations[location.item.player].add(location) for room, income in rupee_rooms.items(): for player in range(1, world.players+1): if room not in rooms_visited[player] and world.get_region(room, player) in state.reachable_regions[player]: wallet[player] += income rooms_visited[player].add(room) - if checked_locations: + if checked_locations or len(unchecked_locations) == 0: if world.has_beaten_game(state): done = True continue @@ -661,14 +757,17 @@ def balance_money_progression(world): solvent = set() insolvent = set() for player in range(1, world.players+1): - if wallet[player] >= sphere_costs[player] > 0: + if wallet[player] >= sphere_costs[player] >= 0: solvent.add(player) if sphere_costs[player] > 0 and sphere_costs[player] > wallet[player]: insolvent.add(player) - if len(solvent) == 0: - target_player = min(insolvent, key=lambda p: sphere_costs[p]-wallet[p]) - difference = sphere_costs[target_player]-wallet[target_player] - logger.debug(f'Money balancing needed: Player {target_player} short {difference}') + if len([p for p in solvent if len(locked_by_money[p]) > 0]) == 0: + if len(insolvent) > 0: + target_player = min(insolvent, key=lambda p: sphere_costs[p]-wallet[p]) + difference = sphere_costs[target_player]-wallet[target_player] + logger.debug(f'Money balancing needed: Player {target_player} short {difference}') + else: + difference = 0 while difference > 0: swap_targets = [x for x in unchecked_locations if x not in sphere_locations and x.item.name.startswith('Rupees') and x.item.player == target_player] if len(swap_targets) == 0: @@ -705,10 +804,10 @@ def balance_money_progression(world): for player in solvent: wallet[player] -= sphere_costs[player] for location in locked_by_money[player]: - if location == 'Kiki': + if isinstance(location, str) and location == 'Kiki': kiki_paid[player] = True else: state.collect(location.item, True, location) unchecked_locations.remove(location) - if location.item.name.startswith('Rupee'): + if location.item and location.item.name.startswith('Rupee'): wallet[location.item.player] += rupee_chart[location.item.name] diff --git a/Gui.py b/Gui.py index 4cc5b106..55defa7a 100755 --- a/Gui.py +++ b/Gui.py @@ -24,6 +24,13 @@ from source.classes.BabelFish import BabelFish from source.classes.Empty import Empty +def check_python_version(fish): + import sys + version = sys.version_info + if version.major < 3 or version.minor < 7: + messagebox.showinfo("Door Shuffle " + ESVersion, fish.translate("cli","cli","old.python.version") % sys.version) + + def guiMain(args=None): # Save settings to file def save_settings(args): @@ -188,6 +195,8 @@ def guiMain(args=None): # load adjust settings into options loadadjustargs(self, self.settings) + check_python_version(self.fish) + # run main window mainWindow.mainloop() diff --git a/InvertedRegions.py b/InvertedRegions.py index d935ed28..589f6f87 100644 --- a/InvertedRegions.py +++ b/InvertedRegions.py @@ -199,8 +199,11 @@ def create_inverted_regions(world, player): create_cave_region(player, 'Superbunny Cave (Top)', 'a connector', ['Superbunny Cave - Top', 'Superbunny Cave - Bottom'], ['Superbunny Cave Exit (Top)']), create_cave_region(player, 'Superbunny Cave (Bottom)', 'a connector', None, ['Superbunny Cave Climb', 'Superbunny Cave Exit (Bottom)']), create_cave_region(player, 'Spike Cave', 'Spike Cave', ['Spike Cave']), - create_cave_region(player, 'Hookshot Cave', 'a connector', ['Hookshot Cave - Top Right', 'Hookshot Cave - Top Left', 'Hookshot Cave - Bottom Right', 'Hookshot Cave - Bottom Left'], - ['Hookshot Cave Exit (South)', 'Hookshot Cave Exit (North)']), + create_cave_region(player, 'Hookshot Cave (Front)', 'a connector', ['Hookshot Cave - Top Right', 'Hookshot Cave - Top Left', 'Hookshot Cave - Bottom Right', 'Hookshot Cave - Bottom Left'], + ['Hookshot Cave Front to Middle', 'Hookshot Cave Front Exit']), + create_cave_region(player, 'Hookshot Cave (Back)', 'a connector', None, ['Hookshot Cave Back to Middle', 'Hookshot Cave Back Exit']), + create_cave_region(player, 'Hookshot Cave (Middle)', 'a connector', None, ['Hookshot Cave Middle to Back', 'Hookshot Cave Middle to Front']), + create_dw_region(player, 'Death Mountain Floating Island (Dark World)', None, ['Floating Island Drop', 'Hookshot Cave Back Entrance']), create_cave_region(player, 'Mimic Cave', 'Mimic Cave', ['Mimic Cave']), diff --git a/ItemList.py b/ItemList.py index a16219f4..9e0b0411 100644 --- a/ItemList.py +++ b/ItemList.py @@ -4,7 +4,6 @@ import math import RaceRandom as random from BaseClasses import Region, RegionType, Shop, ShopType, Location, CollectionState -from Bosses import place_bosses from Dungeons import get_dungeon_item_pool from EntranceShuffle import connect_entrance from Regions import shop_to_location_table, retro_shops, shop_table_by_location @@ -37,7 +36,7 @@ Difficulty = namedtuple('Difficulty', ['baseitems', 'bottles', 'bottle_count', 'same_bottle', 'progressiveshield', 'basicshield', 'progressivearmor', 'basicarmor', 'swordless', 'progressivesword', 'basicsword', 'basicbow', 'timedohko', 'timedother', - 'retro', + 'retro', 'bombbag', 'extras', 'progressive_sword_limit', 'progressive_shield_limit', 'progressive_armor_limit', 'progressive_bottle_limit', 'progressive_bow_limit', 'heart_piece_limit', 'boss_heart_container_limit']) @@ -61,6 +60,7 @@ difficulties = { timedohko = ['Green Clock'] * 25, timedother = ['Green Clock'] * 20 + ['Blue Clock'] * 10 + ['Red Clock'] * 10, retro = ['Small Key (Universal)'] * 18 + ['Rupees (20)'] * 10, + bombbag = ['Bomb Upgrade (+10)'] * 2, extras = [normalfirst15extra, normalsecond15extra, normalthird10extra, normalfourth5extra, normalfinal25extra], progressive_sword_limit = 4, progressive_shield_limit = 3, @@ -86,6 +86,7 @@ difficulties = { timedohko = ['Green Clock'] * 25, timedother = ['Green Clock'] * 20 + ['Blue Clock'] * 10 + ['Red Clock'] * 10, retro = ['Small Key (Universal)'] * 13 + ['Rupees (5)'] * 15, + bombbag = ['Bomb Upgrade (+10)'] * 2, extras = [normalfirst15extra, normalsecond15extra, normalthird10extra, normalfourth5extra, normalfinal25extra], progressive_sword_limit = 3, progressive_shield_limit = 2, @@ -111,6 +112,7 @@ difficulties = { timedohko = ['Green Clock'] * 20 + ['Red Clock'] * 5, timedother = ['Green Clock'] * 20 + ['Blue Clock'] * 10 + ['Red Clock'] * 10, retro = ['Small Key (Universal)'] * 13 + ['Rupees (5)'] * 15, + bombbag = ['Bomb Upgrade (+10)'] * 2, extras = [normalfirst15extra, normalsecond15extra, normalthird10extra, normalfourth5extra, normalfinal25extra], progressive_sword_limit = 2, progressive_shield_limit = 1, @@ -251,10 +253,10 @@ def generate_itempool(world, player): # set up item pool if world.custom: - (pool, placed_items, precollected_items, clock_mode, treasure_hunt_count, treasure_hunt_icon, lamps_needed_for_dark_rooms) = make_custom_item_pool(world.progressive, world.shuffle[player], world.difficulty[player], world.timer, world.goal[player], world.mode[player], world.swords[player], world.retro[player], world.customitemarray) + (pool, placed_items, precollected_items, clock_mode, treasure_hunt_count, treasure_hunt_icon, lamps_needed_for_dark_rooms) = make_custom_item_pool(world.progressive, world.shuffle[player], world.difficulty[player], world.timer, world.goal[player], world.mode[player], world.swords[player], world.retro[player], world.bombbag[player], world.customitemarray) world.rupoor_cost = min(world.customitemarray[player]["rupoorcost"], 9999) else: - (pool, placed_items, precollected_items, clock_mode, lamps_needed_for_dark_rooms) = get_pool_core(world.progressive, world.shuffle[player], world.difficulty[player], world.treasure_hunt_total[player], world.timer, world.goal[player], world.mode[player], world.swords[player], world.retro[player], world.doorShuffle[player], world.logic[player]) + (pool, placed_items, precollected_items, clock_mode, lamps_needed_for_dark_rooms) = get_pool_core(world.progressive, world.shuffle[player], world.difficulty[player], world.treasure_hunt_total[player], world.timer, world.goal[player], world.mode[player], world.swords[player], world.retro[player], world.bombbag[player], world.doorShuffle[player], world.logic[player]) if player in world.pool_adjustment.keys(): amt = world.pool_adjustment[player] @@ -284,7 +286,7 @@ def generate_itempool(world, player): if item in ['Hammer', 'Fire Rod', 'Cane of Somaria', 'Cane of Byrna']: if item not in possible_weapons: possible_weapons.append(item) - if item in ['Bombs (10)']: + if not world.bombbag[player] and item in ['Bombs (10)']: if item not in possible_weapons and world.doorShuffle[player] != 'crossed': possible_weapons.append(item) starting_weapon = random.choice(possible_weapons) @@ -315,6 +317,11 @@ def generate_itempool(world, player): p_item = next(item for item in items if item.name == potion and item.player == player) p_item.priority = True # don't beemize one of each potion + if world.bombbag[player]: + for item in items: + if item.name == 'Bomb Upgrade (+10)' and item.player == player: + item.advancement = True + world.lamps_needed_for_dark_rooms = lamps_needed_for_dark_rooms if clock_mode is not None: @@ -339,11 +346,11 @@ def generate_itempool(world, player): # rather than making all hearts/heart pieces progression items (which slows down generation considerably) # We mark one random heart container as an advancement item (or 4 heart pieces in expert mode) if world.difficulty[player] in ['normal', 'hard'] and not (world.custom and world.customitemarray[player]["heartcontainer"] == 0): - [item for item in items if item.name == 'Boss Heart Container'][0].advancement = True + next(item for item in items if item.name == 'Boss Heart Container').advancement = True elif world.difficulty[player] in ['expert'] and not (world.custom and world.customitemarray[player]["heartpiece"] < 4): - adv_heart_pieces = [item for item in items if item.name == 'Piece of Heart'][0:4] - for hp in adv_heart_pieces: - hp.advancement = True + adv_heart_pieces = (item for item in items if item.name == 'Piece of Heart') + for i in range(4): + next(adv_heart_pieces).advancement = True beeweights = {'0': {None: 100}, '1': {None: 75, 'trap': 25}, @@ -363,7 +370,15 @@ def generate_itempool(world, player): tr_medallion = ['Ether', 'Quake', 'Bombos'][random.randint(0, 2)] world.required_medallions[player] = (mm_medallion, tr_medallion) - place_bosses(world, player) + # shuffle bottle refills + if world.difficulty[player] in ['hard', 'expert']: + waterfall_bottle = hardbottles[random.randint(0, 5)] + pyramid_bottle = hardbottles[random.randint(0, 5)] + else: + waterfall_bottle = normalbottles[random.randint(0, 6)] + pyramid_bottle = normalbottles[random.randint(0, 6)] + world.bottle_refills[player] = (waterfall_bottle, pyramid_bottle) + set_up_shops(world, player) if world.retro[player]: @@ -520,6 +535,15 @@ def set_up_shops(world, player): rss.locked = True cap_shop = world.get_region('Capacity Upgrade', player).shop cap_shop.inventory[1] = None # remove arrow capacity upgrades in retro + if world.bombbag[player]: + if world.shopsanity[player]: + removals = [item for item in world.itempool if item.name == 'Bomb Upgrade (+5)' and item.player == player] + for remove in removals: + world.itempool.remove(remove) + world.itempool.append(ItemFactory('Rupees (50)', player)) # replace the bomb upgrade + else: + cap_shop = world.get_region('Capacity Upgrade', player).shop + cap_shop.inventory[0] = cap_shop.inventory[1] # remove bomb capacity upgrades in bombbag def customize_shops(world, player): @@ -561,7 +585,7 @@ def customize_shops(world, player): shop.shopkeeper_config = shopkeeper # handle capacity upgrades - randomly choose a bomb bunch or arrow bunch to become capacity upgrades if world.difficulty[player] == 'normal': - if not found_bomb_upgrade and len(possible_replacements) > 0: + if not found_bomb_upgrade and len(possible_replacements) > 0 and not world.bombbag[player]: choices = [] for shop, idx, loc, item in possible_replacements: if item.name in ['Bombs (3)', 'Bombs (10)']: @@ -587,6 +611,7 @@ def customize_shops(world, player): upgrade.location = loc change_shop_items_to_rupees(world, player, shops_to_customize) balance_prices(world, player) + check_hints(world, player) def randomize_price(price): @@ -690,6 +715,15 @@ def balance_prices(world, player): # loc.parent_region.shop.inventory[slot]['price'] = new_price +def check_hints(world, player): + if world.shuffle[player] in ['simple', 'restricted', 'full', 'crossed', 'insanity']: + for shop, location_list in shop_to_location_table.items(): + if shop in ['Capacity Upgrade', 'Light World Death Mountain Shop', 'Potion Shop']: + continue # near the queen, near potions, and near 7 chests are fine + for loc_name in location_list: # other shops are indistinguishable in ER + world.get_location(loc_name, player).hint_text = f'for sale' + + repeatable_shop_items = ['Single Arrow', 'Arrows (10)', 'Bombs (3)', 'Bombs (10)', 'Red Potion', 'Small Heart', 'Blue Shield', 'Red Shield', 'Bee', 'Small Key (Universal)', 'Blue Potion', 'Green Potion'] @@ -709,7 +743,7 @@ rupee_chart = {'Rupee (1)': 1, 'Rupees (5)': 5, 'Rupees (20)': 20, 'Rupees (50)' 'Rupees (100)': 100, 'Rupees (300)': 300} -def get_pool_core(progressive, shuffle, difficulty, treasure_hunt_total, timer, goal, mode, swords, retro, door_shuffle, logic): +def get_pool_core(progressive, shuffle, difficulty, treasure_hunt_total, timer, goal, mode, swords, retro, bombbag, door_shuffle, logic): pool = [] placed_items = {} precollected_items = [] @@ -756,6 +790,11 @@ def get_pool_core(progressive, shuffle, difficulty, treasure_hunt_total, timer, diff = difficulties[difficulty] pool.extend(diff.baseitems) + if bombbag: + pool = [item.replace('Bomb Upgrade (+5)','Rupees (5)') for item in pool] + pool = [item.replace('Bomb Upgrade (+10)','Rupees (5)') for item in pool] + pool.extend(diff.bombbag) + # expert+ difficulties produce the same contents for # all bottles, since only one bottle is available if diff.same_bottle: @@ -850,7 +889,7 @@ def get_pool_core(progressive, shuffle, difficulty, treasure_hunt_total, timer, pool.extend(['Small Key (Universal)']) return (pool, placed_items, precollected_items, clock_mode, lamps_needed_for_dark_rooms) -def make_custom_item_pool(progressive, shuffle, difficulty, timer, goal, mode, swords, retro, customitemarray): +def make_custom_item_pool(progressive, shuffle, difficulty, timer, goal, mode, swords, retro, bombbag, customitemarray): if isinstance(customitemarray,dict) and 1 in customitemarray: customitemarray = customitemarray[1] pool = [] @@ -966,20 +1005,21 @@ def test(): for shuffle in ['full', 'insanity_legacy']: for logic in ['noglitches', 'minorglitches', 'owglitches', 'nologic']: for retro in [True, False]: - for door_shuffle in ['basic', 'crossed', 'vanilla']: - out = get_pool_core(progressive, shuffle, difficulty, 30, timer, goal, mode, swords, retro, door_shuffle, logic) - count = len(out[0]) + len(out[1]) + for bombbag in [True, False]: + for door_shuffle in ['basic', 'crossed', 'vanilla']: + out = get_pool_core(progressive, shuffle, difficulty, 30, timer, goal, mode, swords, retro, bombbag, door_shuffle, logic) + count = len(out[0]) + len(out[1]) - correct_count = total_items_to_place - if goal == 'pedestal' and swords != 'vanilla': - # pedestal goals generate one extra item - correct_count += 1 - if retro: - correct_count += 28 - try: - assert count == correct_count, "expected {0} items but found {1} items for {2}".format(correct_count, count, (progressive, shuffle, difficulty, timer, goal, mode, swords, retro)) - except AssertionError as e: - print(e) + correct_count = total_items_to_place + if goal == 'pedestal' and swords != 'vanilla': + # pedestal goals generate one extra item + correct_count += 1 + if retro: + correct_count += 28 + try: + assert count == correct_count, "expected {0} items but found {1} items for {2}".format(correct_count, count, (progressive, shuffle, difficulty, timer, goal, mode, swords, retro, bombbag)) + except AssertionError as e: + print(e) if __name__ == '__main__': test() diff --git a/KeyDoorShuffle.py b/KeyDoorShuffle.py index 549a739c..945ad601 100644 --- a/KeyDoorShuffle.py +++ b/KeyDoorShuffle.py @@ -2,9 +2,9 @@ import itertools import logging from collections import defaultdict, deque -from BaseClasses import DoorType +from BaseClasses import DoorType, dungeon_keys, KeyRuleType, RegionType from Regions import dungeon_events -from Dungeons import dungeon_keys, dungeon_bigs +from Dungeons import dungeon_keys, dungeon_bigs, dungeon_table from DungeonGenerator import ExplorationState, special_big_key_doors @@ -25,6 +25,9 @@ class KeyLayout(object): self.all_locations = set() self.item_locations = set() + self.found_doors = set() + self.prize_relevant = None + self.prize_can_lock = None # if true, then you may need to beat the bo # bk special? # bk required? True if big chests or big doors exists @@ -35,6 +38,7 @@ class KeyLayout(object): self.max_chests = calc_max_chests(builder, self, world, player) self.all_locations = set() self.item_locations = set() + self.prize_relevant = None class KeyLogic(object): @@ -54,10 +58,12 @@ class KeyLogic(object): self.location_rules = {} self.outside_keys = 0 self.dungeon = dungeon_name + self.sm_doors = {} + self.prize_location = None - def check_placement(self, unplaced_keys, big_key_loc=None): + def check_placement(self, unplaced_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, unplaced_keys): + if not rule.is_satisfiable(self.outside_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): @@ -65,6 +71,15 @@ class KeyLogic(object): return False return True + def reset(self): + self.door_rules.clear() + self.bk_restricted.clear() + self.bk_locked.clear() + self.sm_restricted.clear() + self.bk_doors.clear() + self.bk_chests.clear() + self.placement_rules.clear() + class DoorRules(object): @@ -79,6 +94,8 @@ class DoorRules(object): self.small_location = None self.opposite = None + self.new_rules = {} # keyed by type, or type+lock_item -> number + class LocationRule(object): def __init__(self): @@ -101,9 +118,11 @@ class PlacementRule(object): self.needed_keys_w_bk = None self.needed_keys_wo_bk = None self.check_locations_w_bk = None + self.special_bk_avail = False self.check_locations_wo_bk = None self.bk_relevant = True self.key_reduced = False + self.prize_relevance = None def contradicts(self, rule, unplaced_keys, big_key_loc): bk_blocked = big_key_loc in self.bk_conditional_set if self.bk_conditional_set else False @@ -112,6 +131,10 @@ class PlacementRule(object): rule_locations = rule.check_locations_wo_bk if rule_blocked else rule.check_locations_w_bk if check_locations is None or rule_locations is None: return False + if not bk_blocked and big_key_loc not in check_locations: # bk is not available, so rule doesn't apply + return False + if not rule_blocked and big_key_loc not in rule_locations: # bk is not available, so rule doesn't apply + return False check_locations = check_locations - {big_key_loc} rule_locations = rule_locations - {big_key_loc} threshold = self.needed_keys_wo_bk if bk_blocked else self.needed_keys_w_bk @@ -134,13 +157,30 @@ class PlacementRule(object): left -= rule_needed return False - def is_satisfiable(self, outside_keys, unplaced_keys): + def is_satisfiable(self, outside_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']: + return True + elif self.prize_relevance == 'GT': + if 'Crystal' not in prize_location.item.name or cr_count < 7: + return True bk_blocked = False if self.bk_conditional_set: for loc in self.bk_conditional_set: if loc.item and loc.item.bigkey: bk_blocked = True break + elif len(self.check_locations_w_bk) > self.needed_keys_w_bk: + def loc_has_bk(l): + return (big_key_loc is not None and big_key_loc == l) or (l.item and l.item.bigkey) + + # todo: sometimes the bk avail rule doesn't mean the bk must be avail or this rule is invalid + # but sometimes it certainly does + # check threshold vs len(check_loc) maybe to determine bk isn't relevant? + bk_found = self.special_bk_avail or any(loc for loc in self.check_locations_w_bk if loc_has_bk(loc)) + if not bk_found: + return True 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 @@ -170,6 +210,8 @@ class KeyCounter(object): self.important_location = False self.other_locations = {} self.important_locations = {} + self.prize_doors_opened = False + self.prize_received = False def used_smalls_loc(self, reserve=0): return max(self.used_keys + reserve - len(self.key_only_locations), 0) @@ -209,16 +251,30 @@ def calc_max_chests(builder, key_layout, world, player): def analyze_dungeon(key_layout, world, player): + key_layout.key_logic.reset() key_layout.key_counters = create_key_counters(key_layout, world, player) key_logic = key_layout.key_logic + for door in key_layout.proposal: + if isinstance(door, tuple): + key_logic.sm_doors[door[0]] = door[1] + key_logic.sm_doors[door[1]] = door[0] + else: + if door.dest and door.type != DoorType.SpiralStairs: + key_logic.sm_doors[door] = door.dest + key_logic.sm_doors[door.dest] = door + else: + key_logic.sm_doors[door] = None find_bk_locked_sections(key_layout, world, player) key_logic.bk_chests.update(find_big_chest_locations(key_layout.all_chest_locations)) key_logic.bk_chests.update(find_big_key_locked_locations(key_layout.all_chest_locations)) + key_logic.prize_location = dungeon_table[key_layout.sector.name].prize if world.retro[player] and world.mode[player] != 'standard': return - original_key_counter = find_counter({}, False, key_layout) + original_key_counter = find_counter({}, False, key_layout, False) + if key_layout.big_key_special and forced_big_key_avail(original_key_counter.other_locations) is not None: + original_key_counter = find_counter({}, True, key_layout, False) queue = deque([(None, original_key_counter)]) doors_completed = set() visited_cid = set() @@ -237,28 +293,39 @@ def analyze_dungeon(key_layout, world, player): if not big_avail: if chest_keys == non_big_locs and chest_keys > 0 and available <= possible_smalls and not avail_bigs: 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)) # 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(): - if not child.bigKey or not key_layout.big_key_special or big_avail: + if can_open_door_by_counter(child, key_counter, key_layout, world, player): odd_counter = create_odd_key_counter(child, key_counter, key_layout, world, player) empty_flag = empty_counter(odd_counter) child_queue.append((child, odd_counter, empty_flag)) while len(child_queue) > 0: child, odd_counter, empty_flag = child_queue.popleft() - if not child.bigKey and child not in doors_completed: - best_counter = find_best_counter(child, odd_counter, key_counter, key_layout, world, player, False, empty_flag) - rule = create_rule(best_counter, key_counter, key_layout, world, player) + prize_flag = key_counter.prize_doors_opened + if child in key_layout.flat_prop and child not in doors_completed: + best_counter = find_best_counter(child, key_layout, odd_counter, False, empty_flag) + rule = create_rule(best_counter, key_counter, world, player) + create_worst_case_rule(rule, best_counter, world, player) check_for_self_lock_key(rule, child, best_counter, key_layout, world, player) bk_restricted_rules(rule, child, odd_counter, empty_flag, key_counter, key_layout, world, player) key_logic.door_rules[child.name] = rule + elif not child.bigKey and child not in doors_completed: + prize_flag = True doors_completed.add(child) - next_counter = find_next_counter(child, key_counter, key_layout) + next_counter = find_next_counter(child, key_counter, key_layout, prize_flag) ctr_id = cid(next_counter, key_layout) if ctr_id not in visited_cid: queue.append((child, next_counter)) visited_cid.add(ctr_id) - check_rules(original_key_counter, key_layout, world, player) + # todo: why is this commented out? + # check_rules(original_key_counter, key_layout, world, player) # Flip bk rules if more restrictive, to prevent placing a big key in a softlocking location for rule in key_logic.door_rules.values(): @@ -274,6 +341,8 @@ def create_exhaustive_placement_rules(key_layout, world, player): key_logic = key_layout.key_logic max_ctr = find_max_counter(key_layout) for code, key_counter in key_layout.key_counters.items(): + if skip_key_counter_due_to_prize(key_layout, key_counter): + continue # we have the prize, we are not concerned about this case accessible_loc = set() accessible_loc.update(key_counter.free_locations) accessible_loc.update(key_counter.key_only_locations) @@ -294,13 +363,16 @@ def create_exhaustive_placement_rules(key_layout, world, player): else: placement_self_lock_adjustment(rule, max_ctr, blocked_loc, key_counter, world, player) rule.check_locations_w_bk = accessible_loc - check_sm_restriction_needed(key_layout, max_ctr, rule, blocked_loc) + if key_layout.big_key_special: + rule.special_bk_avail = forced_big_key_avail(key_counter.important_locations) is not None + # check_sm_restriction_needed(key_layout, max_ctr, rule, blocked_loc) else: if big_key_progress(key_counter) and only_sm_doors(key_counter): create_inclusive_rule(key_layout, max_ctr, code, key_counter, blocked_loc, accessible_loc, min_keys, world, player) rule.bk_conditional_set = blocked_loc rule.needed_keys_wo_bk = min_keys rule.check_locations_wo_bk = set(filter_big_chest(accessible_loc)) + rule.prize_relevance = key_layout.prize_relevant if rule_prize_relevant(key_counter) else None if valid_rule: key_logic.placement_rules.append(rule) adjust_locations_rules(key_logic, rule, accessible_loc, key_layout, key_counter, max_ctr) @@ -308,6 +380,14 @@ def create_exhaustive_placement_rules(key_layout, world, player): refine_location_rules(key_layout) +def rule_prize_relevant(key_counter): + return not key_counter.prize_doors_opened and not key_counter.prize_received + + +def skip_key_counter_due_to_prize(key_layout, key_counter): + return key_layout.prize_relevant and key_counter.prize_received and not key_counter.prize_doors_opened + + def placement_self_lock_adjustment(rule, max_ctr, blocked_loc, ctr, world, player): if len(blocked_loc) == 1 and world.accessibility[player] != 'locations': blocked_others = set(max_ctr.other_locations).difference(set(ctr.other_locations)) @@ -320,6 +400,7 @@ def placement_self_lock_adjustment(rule, max_ctr, blocked_loc, ctr, world, playe rule.needed_keys_w_bk -= 1 +# this rule is suspect - commented out usages for now def check_sm_restriction_needed(key_layout, max_ctr, rule, blocked): if rule.needed_keys_w_bk == key_layout.max_chests + len(max_ctr.key_only_locations): key_layout.key_logic.sm_restricted.update(blocked.difference(max_ctr.key_only_locations)) @@ -402,7 +483,7 @@ def refine_placement_rules(key_layout, max_ctr): if rule.needed_keys_wo_bk == 0: rules_to_remove.append(rule) if len(rule.check_locations_wo_bk) < rule.needed_keys_wo_bk or rule.needed_keys_wo_bk > key_layout.max_chests: - if len(rule.bk_conditional_set) > 0: + if not rule.prize_relevance and len(rule.bk_conditional_set) > 0: key_logic.bk_restricted.update(rule.bk_conditional_set) rules_to_remove.append(rule) changed = True # impossible for bk to be here, I think @@ -413,7 +494,8 @@ def refine_placement_rules(key_layout, max_ctr): rule_b = temp if rule_a.bk_conditional_set and rule_b.check_locations_w_bk: common_needed = min(rule_a.needed_keys_wo_bk, rule_b.needed_keys_w_bk) - if len(rule_b.check_locations_w_bk & rule_a.check_locations_wo_bk) < common_needed: + common_locs = len(rule_b.check_locations_w_bk & rule_a.check_locations_wo_bk) + if (common_needed - common_locs) * 2 > key_layout.max_chests: key_logic.bk_restricted.update(rule_a.bk_conditional_set) rules_to_remove.append(rule_a) changed = True @@ -478,7 +560,7 @@ def create_inclusive_rule(key_layout, max_ctr, code, key_counter, blocked_loc, a else: placement_self_lock_adjustment(rule, max_ctr, blocked_loc, key_counter, world, player) rule.check_locations_w_bk = accessible_loc - check_sm_restriction_needed(key_layout, max_ctr, rule, blocked_loc) + # check_sm_restriction_needed(key_layout, max_ctr, rule, blocked_loc) key_logic.placement_rules.append(rule) adjust_locations_rules(key_logic, rule, accessible_loc, key_layout, key_counter, max_ctr) @@ -538,6 +620,8 @@ def relative_empty_counter(odd_counter, key_counter): return False if len(set(odd_counter.free_locations).difference(key_counter.free_locations)) > 0: return False + if len(set(odd_counter.other_locations).difference(key_counter.other_locations)) > 0: + return False # important only if len(set(odd_counter.important_locations).difference(key_counter.important_locations)) > 0: return False @@ -594,33 +678,50 @@ def unique_child_door_2(child, key_counter): return True -def find_best_counter(door, odd_counter, key_counter, key_layout, world, player, skip_bk, empty_flag): # try to waste as many keys as possible? - ignored_doors = {door, door.dest} if door is not None else {} - finished = False - opened_doors = dict(key_counter.open_doors) - bk_opened = key_counter.big_key_opened - # new_counter = key_counter - last_counter = key_counter - while not finished: - door_set = find_potential_open_doors(last_counter, ignored_doors, key_layout, skip_bk) - if door_set is None or len(door_set) == 0: - finished = True - continue - for new_door in door_set: - proposed_doors = {**opened_doors, **dict.fromkeys([new_door, new_door.dest])} - bk_open = bk_opened or new_door.bigKey - new_counter = find_counter(proposed_doors, bk_open, key_layout) - bk_open = new_counter.big_key_opened - # this means the new_door invalidates the door / leads to the same stuff - if not empty_flag and relative_empty_counter(odd_counter, new_counter): - ignored_doors.add(new_door) - elif empty_flag or key_wasted(new_door, door, last_counter, new_counter, key_layout, world, player): - last_counter = new_counter - opened_doors = proposed_doors - bk_opened = bk_open - else: - ignored_doors.add(new_door) - return last_counter +# def find_best_counter(door, odd_counter, key_counter, key_layout, world, player, skip_bk, empty_flag): # try to waste as many keys as possible? +# ignored_doors = {door, door.dest} if door is not None else {} +# finished = False +# opened_doors = dict(key_counter.open_doors) +# bk_opened = key_counter.big_key_opened +# # new_counter = key_counter +# last_counter = key_counter +# while not finished: +# door_set = find_potential_open_doors(last_counter, ignored_doors, key_layout, skip_bk) +# if door_set is None or len(door_set) == 0: +# finished = True +# continue +# for new_door in door_set: +# proposed_doors = {**opened_doors, **dict.fromkeys([new_door, new_door.dest])} +# bk_open = bk_opened or new_door.bigKey +# new_counter = find_counter(proposed_doors, bk_open, key_layout) +# bk_open = new_counter.big_key_opened +# # this means the new_door invalidates the door / leads to the same stuff +# if not empty_flag and relative_empty_counter(odd_counter, new_counter): +# ignored_doors.add(new_door) +# elif empty_flag or key_wasted(new_door, door, last_counter, new_counter, key_layout, world, player): +# last_counter = new_counter +# opened_doors = proposed_doors +# bk_opened = bk_open +# else: +# ignored_doors.add(new_door) +# return last_counter + + +def find_best_counter(door, key_layout, odd_counter, skip_bk, empty_flag): + best, best_ctr, locations = 0, None, 0 + for code, counter in key_layout.key_counters.items(): + if door not in counter.open_doors: + if best_ctr is None or counter.used_keys > best or (counter.used_keys == best and count_locations(counter) > locations): + if not skip_bk or not counter.big_key_opened: + if empty_flag or not relative_empty_counter(odd_counter, counter): + best = counter.used_keys + best_ctr = counter + locations = count_locations(counter) + return best_ctr + + +def count_locations(ctr): + return len(ctr.free_locations) + len(ctr.key_only_locations) + len(ctr.other_locations) + len(ctr.important_locations) def find_worst_counter(door, odd_counter, key_counter, key_layout, skip_bk): # try to waste as many keys as possible? @@ -638,7 +739,7 @@ def find_worst_counter(door, odd_counter, key_counter, key_layout, skip_bk): # for new_door in door_set: proposed_doors = {**opened_doors, **dict.fromkeys([new_door, new_door.dest])} bk_open = bk_opened or new_door.bigKey - new_counter = find_counter(proposed_doors, bk_open, key_layout) + new_counter = find_counter(proposed_doors, bk_open, key_layout, key_counter.prize_doors_opened) bk_open = new_counter.big_key_opened if not new_door.bigKey and progressive_ctr(new_counter, last_counter) and relative_empty_counter_2(odd_counter, new_counter): ignored_doors.add(new_door) @@ -690,7 +791,7 @@ def key_wasted(new_door, old_door, old_counter, new_counter, key_layout, world, for new_child in new_children: proposed_doors = {**opened_doors, **dict.fromkeys([new_child, new_child.dest])} bk_open = bk_opened or new_door.bigKey - new_counter = find_counter(proposed_doors, bk_open, key_layout) + new_counter = find_counter(proposed_doors, bk_open, key_layout, current_counter.prize_doors_opened) if key_wasted(new_child, old_door, current_counter, new_counter, key_layout, world, player): wasted_keys += 1 if new_avail - wasted_keys < old_avail: @@ -698,10 +799,11 @@ def key_wasted(new_door, old_door, old_counter, new_counter, key_layout, world, return False -def find_next_counter(new_door, old_counter, key_layout): +def find_next_counter(new_door, old_counter, key_layout, prize_flag=None): proposed_doors = {**old_counter.open_doors, **dict.fromkeys([new_door, new_door.dest])} bk_open = old_counter.big_key_opened or new_door.bigKey - return find_counter(proposed_doors, bk_open, key_layout) + prize_flag = prize_flag if prize_flag else old_counter.prize_doors_opened + return find_counter(proposed_doors, bk_open, key_layout, prize_flag) def check_special_locations(locations): @@ -717,7 +819,7 @@ def calc_avail_keys(key_counter, world, player): return raw_avail - key_counter.used_keys -def create_rule(key_counter, prev_counter, key_layout, world, player): +def create_rule(key_counter, prev_counter, world, player): # prev_chest_keys = available_chest_small_keys(prev_counter, world) # prev_avail = prev_chest_keys + len(prev_counter.key_only_locations) chest_keys = available_chest_small_keys(key_counter, world, player) @@ -736,6 +838,11 @@ def create_rule(key_counter, prev_counter, key_layout, world, player): return DoorRules(rule_num, is_valid) +def create_worst_case_rule(rules, key_counter, world, player): + required_keys = key_counter.used_keys + 1 # this makes more sense, if key_counter has wasted all keys + rules.new_rules[KeyRuleType.WorstCase] = required_keys + + def check_for_self_lock_key(rule, door, parent_counter, key_layout, world, player): if world.accessibility[player] != 'locations': counter = find_inverted_counter(door, parent_counter, key_layout, world, player) @@ -744,11 +851,12 @@ def check_for_self_lock_key(rule, door, parent_counter, key_layout, world, playe if len(counter.free_locations) == 1 and len(counter.key_only_locations) == 0 and not counter.important_location: rule.allow_small = True rule.small_location = next(iter(counter.free_locations)) + rule.new_rules[KeyRuleType.AllowSmall] = rule.new_rules[KeyRuleType.WorstCase] - 1 def find_inverted_counter(door, parent_counter, key_layout, world, player): # open all doors in counter - counter = open_all_counter(parent_counter, key_layout, door=door) + counter = open_all_counter(parent_counter, key_layout, world, player, door=door) max_counter = find_max_counter(key_layout) # find the difference inverted_counter = KeyCounter(key_layout.max_chests) @@ -764,7 +872,7 @@ def find_inverted_counter(door, parent_counter, key_layout, world, player): return inverted_counter -def open_all_counter(parent_counter, key_layout, door=None, skipBk=False): +def open_all_counter(parent_counter, key_layout, world, player, door=None, skipBk=False): changed = True counter = parent_counter proposed_doors = dict.fromkeys(parent_counter.open_doors.keys()) @@ -776,14 +884,12 @@ def open_all_counter(parent_counter, key_layout, door=None, skipBk=False): if skipBk: if not child.bigKey: doors_to_open[child] = None - elif not child.bigKey or not key_layout.big_key_special or counter.big_key_opened: + elif can_open_door_by_counter(child, counter, key_layout, world, player): doors_to_open[child] = None if len(doors_to_open.keys()) > 0: proposed_doors = {**proposed_doors, **doors_to_open} - bk_hint = counter.big_key_opened - for d in doors_to_open.keys(): - bk_hint = bk_hint or d.bigKey - counter = find_counter(proposed_doors, bk_hint, key_layout) + bk_hint = counter.big_key_opened or any(d.bigKey for d in doors_to_open.keys()) + counter = find_counter(proposed_doors, bk_hint, key_layout, True) changed = True return counter @@ -804,7 +910,7 @@ def open_some_counter(parent_counter, key_layout, ignored_doors): bk_hint = counter.big_key_opened for d in doors_to_open.keys(): bk_hint = bk_hint or d.bigKey - counter = find_counter(proposed_doors, bk_hint, key_layout) + counter = find_counter(proposed_doors, bk_hint, key_layout, parent_counter.prize_doors_opened) changed = True return counter @@ -845,16 +951,16 @@ def big_key_drop_available(key_counter): def bk_restricted_rules(rule, door, odd_counter, empty_flag, key_counter, key_layout, world, player): if key_counter.big_key_opened: return - best_counter = find_best_counter(door, odd_counter, key_counter, key_layout, world, player, True, empty_flag) - bk_rule = create_rule(best_counter, key_counter, key_layout, world, player) + best_counter = find_best_counter(door, key_layout, odd_counter, True, empty_flag) + bk_rule = create_rule(best_counter, key_counter, world, player) if bk_rule.small_key_num >= rule.small_key_num: return door_open = find_next_counter(door, best_counter, key_layout) ignored_doors = dict_intersection(best_counter.child_doors, door_open.child_doors) dest_ignored = [] - for door in ignored_doors.keys(): - if door.dest not in ignored_doors: - dest_ignored.append(door.dest) + for d in ignored_doors.keys(): + if d.dest not in ignored_doors: + dest_ignored.append(d.dest) ignored_doors = {**ignored_doors, **dict.fromkeys(dest_ignored)} post_counter = open_some_counter(door_open, key_layout, ignored_doors.keys()) unique_loc = dict_difference(post_counter.free_locations, best_counter.free_locations) @@ -862,8 +968,8 @@ def bk_restricted_rules(rule, door, odd_counter, empty_flag, key_counter, key_la if len(unique_loc) > 0: # and bk_rule.is_valid rule.alternate_small_key = bk_rule.small_key_num rule.alternate_big_key_loc.update(unique_loc) - # elif not bk_rule.is_valid: - # key_layout.key_logic.bk_restricted.update(unique_loc) + if not door.bigKey: + rule.new_rules[(KeyRuleType.Lock, key_layout.key_logic.bk_name)] = best_counter.used_keys + 1 def find_worst_counter_wo_bk(small_key_num, accessible_set, door, odd_ctr, key_counter, key_layout): @@ -887,12 +993,19 @@ def find_worst_counter_wo_bk(small_key_num, accessible_set, door, odd_ctr, key_c return worst_counter, post_counter, bk_rule_num -def open_a_door(door, child_state, flat_proposal): +def open_a_door(door, child_state, flat_proposal, world, player): if door.bigKey or door.name in special_big_key_doors: 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])) child_state.big_doors.clear() + elif door in child_state.prize_door_set: + child_state.prize_doors_opened = True + for exp_door in child_state.prize_doors: + new_region = exp_door.door.entrance.parent_region + child_state.visit_region(new_region, key_checks=True) + child_state.add_all_doors_check_keys(new_region, flat_proposal, world, player) + child_state.prize_doors.clear() else: child_state.opened_doors.append(door) doors_to_open = [x for x in child_state.small_doors if x.door == door] @@ -935,6 +1048,7 @@ def only_sm_doors(key_counter): return False return True + # doesn't count dest doors def count_unique_small_doors(key_counter, proposal): cnt = 0 @@ -949,7 +1063,7 @@ def count_unique_small_doors(key_counter, proposal): def exist_relevant_big_doors(key_counter, key_layout): - bk_counter = find_counter(key_counter.open_doors, True, key_layout, False) + bk_counter = find_counter(key_counter.open_doors, True, key_layout, key_counter.prize_doors_opened, False) if bk_counter is not None: diff = dict_difference(bk_counter.free_locations, key_counter.free_locations) if len(diff) > 0: @@ -982,14 +1096,6 @@ def filter_big_chest(locations): return [x for x in locations if '- Big Chest' not in x.name] -def count_locations_exclude_logic(locations, key_logic): - cnt = 0 - for loc in locations: - if not location_is_bk_locked(loc, key_logic) and not loc.forced_item and not prize_or_event(loc): - cnt += 1 - return cnt - - def location_is_bk_locked(loc, key_logic): return loc in key_logic.bk_chests or loc in key_logic.bk_locked @@ -998,18 +1104,36 @@ def prize_or_event(loc): return loc.name in dungeon_events or '- Prize' in loc.name or loc.name in ['Agahnim 1', 'Agahnim 2'] -def count_free_locations(state): +def boss_unavail(loc, world, player): + # todo: ambrosia + # return world.bossdrops[player] == 'ambrosia' and "- Boss" in loc.name + return False + + +def blind_boss_unavail(loc, state, world, player): + if loc.name == "Thieves' Town - Boss": + # todo: check attic + return (loc.parent_region.dungeon.boss.name == 'Blind' and + (not any(x for x in state.found_locations if x.name == 'Suspicious Maiden') or + (world.get_region('Thieves Attic Window', player).dungeon.name == 'Thieves Town' and + not any(x for x in state.found_locations if x.name == 'Attic Cracked Floor')))) + return False + + +def count_free_locations(state, world, player): cnt = 0 for loc in state.found_locations: - if not prize_or_event(loc) and not loc.forced_item: + if (not prize_or_event(loc) and not loc.forced_item and not boss_unavail(loc, world, player) + and not blind_boss_unavail(loc, state, world, player)): cnt += 1 return cnt -def count_locations_exclude_big_chest(state): +def count_locations_exclude_big_chest(state, world, player): cnt = 0 for loc in state.found_locations: - if '- Big Chest' not in loc.name and not loc.forced_item and not prize_or_event(loc): + if ('- Big Chest' not in loc.name and not loc.forced_item and not boss_unavail(loc, world, player) + and not prize_or_event(loc) and not blind_boss_unavail(loc, state, world, player)): cnt += 1 return cnt @@ -1197,7 +1321,7 @@ def check_rules_deep(original_counter, key_layout, world, player): elif not door.bigKey: can_open = True if can_open: - can_progress = smalls_opened or not big_maybe_not_found + can_progress = (big_avail or not big_maybe_not_found) if door.bigKey else smalls_opened next_counter = find_next_counter(door, counter, key_layout) c_id = cid(next_counter, key_layout) if c_id not in completed: @@ -1265,6 +1389,22 @@ def check_bk_special(regions, world, player): return False +def forced_big_key_avail(locations): + for loc in locations: + if loc.forced_big_key(): + return loc + return None + + +def prize_relevance(key_layout, dungeon_entrance): + if len(key_layout.start_regions) > 1 and dungeon_entrance and dungeon_table[key_layout.key_logic.dungeon].prize: + if dungeon_entrance.name in ['Ganons Tower', 'Inverted Ganons Tower']: + return 'GT' + elif dungeon_entrance.name == 'Pyramid Fairy': + return 'BigBomb' + return None + + # Soft lock stuff def validate_key_layout(key_layout, world, player): # retro is all good - except for hyrule castle in standard mode @@ -1275,8 +1415,15 @@ def validate_key_layout(key_layout, world, 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: - state.visit_region(region, key_checks=True) - state.add_all_doors_check_keys(region, flat_proposal, world, player) + dungeon_entrance, portal_door = find_outside_connection(region) + prize_relevant_flag = prize_relevance(key_layout, dungeon_entrance) + if prize_relevant_flag: + state.append_door_to_list(portal_door, state.prize_doors) + state.prize_door_set[portal_door] = dungeon_entrance + key_layout.prize_relevant = prize_relevant_flag + else: + state.visit_region(region, key_checks=True) + state.add_all_doors_check_keys(region, flat_proposal, world, player) return validate_key_layout_sub_loop(key_layout, state, {}, flat_proposal, None, 0, world, player) @@ -1287,7 +1434,10 @@ def validate_key_layout_sub_loop(key_layout, state, checked_states, flat_proposa if not smalls_avail and num_bigs == 0: return True # I think that's the end # todo: fix state to separate out these types - ttl_locations = count_free_locations(state) if state.big_key_opened else count_locations_exclude_big_chest(state) + if state.big_key_opened: + ttl_locations = count_free_locations(state, world, player) + else: + ttl_locations = count_locations_exclude_big_chest(state, 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_big_locations = cnt_avail_big_locations(ttl_locations, state, world, player) @@ -1298,17 +1448,23 @@ def validate_key_layout_sub_loop(key_layout, state, checked_states, flat_proposa found_forced_bk = state.found_forced_bk() smalls_done = not smalls_avail 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) - if smalls_done and bk_done: + # 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 + not any(x for x in state.found_locations if '- Prize' in x.name)) + prize_done = not key_layout.prize_relevant or state.prize_doors_opened or allow_for_prize_lock + if smalls_done and bk_done and prize_done: return False else: + # todo: pretty sure you should OR these paths together, maybe when there's one location and it can + # either be small or big key if smalls_avail and available_small_locations > 0: for exp_door in state.small_doors: state_copy = state.copy() - open_a_door(exp_door.door, state_copy, flat_proposal) + open_a_door(exp_door.door, state_copy, flat_proposal, world, player) state_copy.used_smalls += 1 if state_copy.used_smalls > ttl_small_key_only: state_copy.used_locations += 1 - code = state_id(state_copy, flat_proposal) + code = validate_id(state_copy, flat_proposal) if code not in checked_states.keys(): valid = validate_key_layout_sub_loop(key_layout, state_copy, checked_states, flat_proposal, state, available_small_locations, world, player) @@ -1319,10 +1475,24 @@ def validate_key_layout_sub_loop(key_layout, state, checked_states, flat_proposa return False if not state.big_key_opened and (available_big_locations >= num_bigs > 0 or (found_forced_bk and num_bigs > 0)): state_copy = state.copy() - open_a_door(state.big_doors[0].door, state_copy, flat_proposal) + open_a_door(state.big_doors[0].door, state_copy, flat_proposal, world, player) if not found_forced_bk: state_copy.used_locations += 1 - code = state_id(state_copy, flat_proposal) + code = validate_id(state_copy, flat_proposal) + if code not in checked_states.keys(): + valid = validate_key_layout_sub_loop(key_layout, state_copy, checked_states, flat_proposal, + state, available_small_locations, world, player) + checked_states[code] = valid + else: + valid = checked_states[code] + if not valid: + return False + # todo: feel like you only open these if the boss is available??? + # todo: or if a crystal isn't valid placement on this boss + if not state.prize_doors_opened and key_layout.prize_relevant: + state_copy = state.copy() + open_a_door(next(iter(state_copy.prize_door_set)), state_copy, flat_proposal, world, player) + code = validate_id(state_copy, flat_proposal) if code not in checked_states.keys(): valid = validate_key_layout_sub_loop(key_layout, state_copy, checked_states, flat_proposal, state, available_small_locations, world, player) @@ -1341,7 +1511,7 @@ def invalid_self_locking_key(key_layout, state, prev_state, prev_avail, world, p state_copy = state.copy() while len(new_bk_doors) > 0: for door in new_bk_doors: - open_a_door(door.door, state_copy, key_layout.flat_prop) + open_a_door(door.door, state_copy, key_layout.flat_prop, world, player) new_bk_doors = set(state_copy.big_doors).difference(set(prev_state.big_doors)) expand_key_state(state_copy, key_layout.flat_prop, world, player) new_locations = set(state_copy.found_locations).difference(set(prev_state.found_locations)) @@ -1365,6 +1535,39 @@ def enough_small_locations(state, avail_small_loc): return avail_small_loc >= len(unique_d_set) +def determine_prize_lock(key_layout, world, player): + if ((world.retro[player] and (world.mode[player] != 'standard' or key_layout.sector.name != 'Hyrule Castle')) + or world.logic[player] == 'nologic'): + return # done, doesn't matter what + flat_proposal = key_layout.flat_prop + state = ExplorationState(dungeon=key_layout.sector.name) + state.key_locations = key_layout.max_chests + state.big_key_special = check_bk_special(key_layout.sector.regions, world, player) + prize_lock_possible = False + for region in key_layout.start_regions: + dungeon_entrance, portal_door = find_outside_connection(region) + prize_relevant_flag = prize_relevance(key_layout, dungeon_entrance) + if prize_relevant_flag: + state.append_door_to_list(portal_door, state.prize_doors) + state.prize_door_set[portal_door] = dungeon_entrance + key_layout.prize_relevant = prize_relevant_flag + prize_lock_possible = True + else: + state.visit_region(region, key_checks=True) + state.add_all_doors_check_keys(region, flat_proposal, world, player) + if not prize_lock_possible: + return # done, no prize entrances to worry about + expand_key_state(state, flat_proposal, world, player) + while len(state.small_doors) > 0 or len(state.big_doors) > 0: + if len(state.big_doors) > 0: + open_a_door(state.big_doors[0].door, state, flat_proposal, world, player) + elif len(state.small_doors) > 0: + open_a_door(state.small_doors[0].door, state, flat_proposal, world, player) + expand_key_state(state, flat_proposal, world, player) + if any(x for x in state.found_locations if '- Prize' in x.name): + key_layout.prize_can_lock = True + + def cnt_avail_small_locations(free_locations, key_only, state, world, player): if not world.keyshuffle[player] and not world.retro[player]: bk_adj = 1 if state.big_key_opened and not state.big_key_special else 0 @@ -1373,29 +1576,54 @@ def cnt_avail_small_locations(free_locations, key_only, state, world, player): return state.key_locations - state.used_smalls +def cnt_avail_small_locations_by_ctr(free_locations, counter, layout, world, player): + if not world.keyshuffle[player] and not world.retro[player]: + 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) + 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 + + def cnt_avail_big_locations(ttl_locations, state, world, player): if not world.bigkeyshuffle[player]: return max(0, ttl_locations - state.used_locations) if not state.big_key_special else 0 return 1 if not state.big_key_special else 0 +def cnt_avail_big_locations_by_ctr(ttl_locations, counter, layout, world, player): + if not world.bigkeyshuffle[player]: + bk_adj = 1 if counter.big_key_opened and not layout.big_key_special else 0 + used_locations = max(0, counter.used_keys - len(counter.key_only_locations)) + bk_adj + return max(0, ttl_locations - used_locations) if not layout.big_key_special else 0 + return 1 if not layout.big_key_special else 0 + + def create_key_counters(key_layout, world, player): key_counters = {} + key_layout.found_doors.clear() flat_proposal = key_layout.flat_prop state = ExplorationState(dungeon=key_layout.sector.name) if world.doorShuffle[player] == 'vanilla': - state.key_locations = len(world.get_dungeon(key_layout.sector.name, player).small_keys) + builder = world.dungeon_layouts[player][key_layout.sector.name] + state.key_locations = len(builder.key_door_proposal) - builder.key_drop_cnt else: - state.key_locations = world.dungeon_layouts[player][key_layout.sector.name].key_doors_num - state.big_key_special, special_region = False, None + builder = world.dungeon_layouts[player][key_layout.sector.name] + state.key_locations = builder.total_keys - builder.key_drop_cnt + state.big_key_special = False for region in key_layout.sector.regions: for location in region.locations: if location.forced_big_key(): state.big_key_special = True - special_region = region for region in key_layout.start_regions: - state.visit_region(region, key_checks=True) - state.add_all_doors_check_keys(region, flat_proposal, world, player) + dungeon_entrance, portal_door = find_outside_connection(region) + prize_relevant_flag = prize_relevance(key_layout, dungeon_entrance) + if prize_relevant_flag: + state.append_door_to_list(portal_door, state.prize_doors) + state.prize_door_set[portal_door] = dungeon_entrance + key_layout.prize_relevant = prize_relevant_flag + else: + state.visit_region(region, key_checks=True) + state.add_all_doors_check_keys(region, flat_proposal, world, player) expand_key_state(state, flat_proposal, world, player) code = state_id(state, key_layout.flat_prop) key_counters[code] = create_key_counter(state, key_layout, world, player) @@ -1403,12 +1631,15 @@ def create_key_counters(key_layout, world, player): while len(queue) > 0: next_key_counter, parent_state = queue.popleft() for door in next_key_counter.child_doors: + key_layout.found_doors.add(door) + 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: key_layout.key_logic.bk_doors.add(door) # open the door, if possible - if not door.bigKey or not child_state.big_key_special or child_state.visited_at_all(special_region): - open_a_door(door, child_state, flat_proposal) + if can_open_door(door, child_state, 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) if code not in key_counters.keys(): @@ -1418,9 +1649,57 @@ def create_key_counters(key_layout, world, player): return key_counters +def find_outside_connection(region): + portal = next((x for x in region.entrances if ' Portal' in x.parent_region.name), None) + if portal: + dungeon_entrance = next(x for x in portal.parent_region.entrances + if x.parent_region.type in [RegionType.LightWorld, RegionType.DarkWorld]) + portal_entrance = next(x for x in portal.parent_region.entrances if x.parent_region == region) + return dungeon_entrance, portal_entrance.door + return None, None + + +def can_open_door(door, state, world, player): + if state.big_key_opened: + ttl_locations = count_free_locations(state, world, player) + else: + ttl_locations = count_locations_exclude_big_chest(state, 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) + return available_small_locations > 0 + elif door.bigKey: + available_big_locations = cnt_avail_big_locations(ttl_locations, state, world, player) + found_forced_bk = state.found_forced_bk() + return not state.big_key_opened and (available_big_locations > 0 or found_forced_bk) + else: + return True + + +def can_open_door_by_counter(door, counter: KeyCounter, layout, world, player): + if counter.big_key_opened: + ttl_locations = len(counter.free_locations) + else: + ttl_locations = len([x for x in counter.free_locations if '- Big Chest' not in x.name]) + + if door.smallKey: + # ttl_small_key_only = len(counter.key_only_locations) + return cnt_avail_small_locations_by_ctr(ttl_locations, counter, layout, world, player) > 0 + elif door.bigKey: + if counter.big_key_opened: + return False + if layout.big_key_special: + return any(x for x in counter.other_locations.keys() if x.forced_item and x.forced_item.bigkey) + else: + available_big_locations = cnt_avail_big_locations_by_ctr(ttl_locations, counter, layout, world, player) + return available_big_locations > 0 + else: + return True + + def create_key_counter(state, key_layout, world, player): key_counter = KeyCounter(key_layout.max_chests) - key_counter.child_doors.update(dict.fromkeys(unique_doors(state.small_doors+state.big_doors))) + key_counter.child_doors.update(dict.fromkeys(unique_doors(state.small_doors+state.big_doors+state.prize_doors))) for loc in state.found_locations: if important_location(loc, world, player): key_counter.important_location = True @@ -1437,6 +1716,10 @@ def create_key_counter(state, key_layout, world, player): key_counter.open_doors.update(dict.fromkeys(state.opened_doors)) key_counter.used_keys = count_unique_sm_doors(state.opened_doors) key_counter.big_key_opened = state.big_key_opened + if len(state.prize_door_set) > 0 and state.prize_doors_opened: + key_counter.prize_doors_opened = True + if any(x for x in key_counter.important_locations if '- Prize' in x.name): + key_counter.prize_received = True return key_counter @@ -1487,11 +1770,23 @@ def state_id(state, flat_proposal): s_id = '1' if state.big_key_opened else '0' for d in flat_proposal: s_id += '1' if d in state.opened_doors else '0' + if len(state.prize_door_set) > 0: + s_id += '1' if state.prize_doors_opened else '0' return s_id -def find_counter(opened_doors, bk_hint, key_layout, raise_on_error=True): - counter = find_counter_hint(opened_doors, bk_hint, key_layout) +def validate_id(state, flat_proposal): + s_id = '1' if state.big_key_opened else '0' + for d in flat_proposal: + s_id += '1' if d in state.opened_doors else '0' + if len(state.prize_door_set) > 0: + s_id += '1' if state.prize_doors_opened else '0' + s_id += str(state.used_locations) + return s_id + + +def find_counter(opened_doors, bk_hint, key_layout, prize_flag, raise_on_error=True): + counter = find_counter_hint(opened_doors, bk_hint, key_layout, prize_flag) if counter is not None: return counter more_doors = [] @@ -1500,43 +1795,47 @@ def find_counter(opened_doors, bk_hint, key_layout, raise_on_error=True): if door.dest not in opened_doors.keys(): more_doors.append(door.dest) if len(more_doors) > len(opened_doors.keys()): - counter = find_counter_hint(dict.fromkeys(more_doors), bk_hint, key_layout) + counter = find_counter_hint(dict.fromkeys(more_doors), bk_hint, key_layout, prize_flag) if counter is not None: return counter if raise_on_error: - raise Exception('Unable to find door permutation. Init CID: %s' % counter_id(opened_doors, bk_hint, key_layout.flat_prop)) + cid = counter_id(opened_doors, bk_hint, key_layout.flat_prop, key_layout.prize_relevant, prize_flag) + raise Exception(f'Unable to find door permutation. Init CID: {cid}') return None -def find_counter_hint(opened_doors, bk_hint, key_layout): - cid = counter_id(opened_doors, bk_hint, key_layout.flat_prop) +def find_counter_hint(opened_doors, bk_hint, key_layout, prize_flag): + cid = counter_id(opened_doors, bk_hint, key_layout.flat_prop, key_layout.prize_relevant, prize_flag) if cid in key_layout.key_counters.keys(): return key_layout.key_counters[cid] if not bk_hint: - cid = counter_id(opened_doors, True, key_layout.flat_prop) + cid = counter_id(opened_doors, True, key_layout.flat_prop, key_layout.prize_relevant, prize_flag) if cid in key_layout.key_counters.keys(): return key_layout.key_counters[cid] return None def find_max_counter(key_layout): - max_counter = find_counter_hint(dict.fromkeys(key_layout.flat_prop), False, key_layout) + max_counter = find_counter_hint(dict.fromkeys(key_layout.found_doors), False, key_layout, True) if max_counter is None: raise Exception("Max Counter is none - something is amiss") if len(max_counter.child_doors) > 0: - max_counter = find_counter_hint(dict.fromkeys(key_layout.flat_prop), True, key_layout) + max_counter = find_counter_hint(dict.fromkeys(key_layout.found_doors), True, key_layout, True) return max_counter -def counter_id(opened_doors, bk_unlocked, flat_proposal): +def counter_id(opened_doors, bk_unlocked, flat_proposal, prize_relevant, prize_flag): s_id = '1' if bk_unlocked else '0' for d in flat_proposal: s_id += '1' if d in opened_doors.keys() else '0' + if prize_relevant: + s_id += '1' if prize_flag else '0' return s_id def cid(counter, key_layout): - return counter_id(counter.open_doors, counter.big_key_opened, key_layout.flat_prop) + return counter_id(counter.open_doors, counter.big_key_opened, key_layout.flat_prop, + key_layout.prize_relevant, counter.prize_doors_opened) # class SoftLockException(Exception): @@ -1746,8 +2045,20 @@ def validate_key_placement(key_layout, world, player): found_locations = set(i for i in counter.free_locations if big_found or "- Big Chest" not in i.name) found_keys = sum(1 for i in found_locations if i.item is not None and i.item.name == smallkey_name and i.item.player == player) + \ len(counter.key_only_locations) + keys_outside + if key_layout.prize_relevant: + found_prize = any(x for x in counter.important_locations if '- Prize' in x.name) + if not found_prize and dungeon_table[key_layout.sector.name].prize: + prize_loc = world.get_location(dungeon_table[key_layout.sector.name].prize, player) + if key_layout.prize_relevant == 'BigBomb': + found_prize = prize_loc.item.name not in ['Crystal 5', 'Crystal 6'] + elif key_layout.prize_relevant == 'GT': + found_prize = 'Crystal' not in prize_loc.item.name or world.crystals_needed_for_gt[player] < 7 + else: + found_prize = False can_progress = (not counter.big_key_opened and big_found and any(d.bigKey for d in counter.child_doors)) or \ - found_keys > counter.used_keys and any(not d.bigKey for d in counter.child_doors) + found_keys > counter.used_keys and any(not d.bigKey for d in counter.child_doors) or \ + self_locked_child_door(key_layout, counter) or \ + (key_layout.prize_relevant and not counter.prize_doors_opened and found_prize) if not can_progress: missing_locations = set(max_counter.free_locations.keys()).difference(found_locations) missing_items = [l for l in missing_locations if l.item is None or (l.item.name != smallkey_name and l.item.name != bigkey_name) or "- Boss" in l.name] @@ -1761,3 +2072,11 @@ def validate_key_placement(key_layout, world, player): return True + +def self_locked_child_door(key_layout, counter): + if len(counter.child_doors) == 1: + door = next(iter(counter.child_doors.keys())) + return door.smallKey and key_layout.key_logic.door_rules[door.name].allow_small + return False + + diff --git a/Main.py b/Main.py index 96235926..573f935f 100644 --- a/Main.py +++ b/Main.py @@ -10,6 +10,7 @@ import time import zlib from BaseClasses import World, CollectionState, Item, Region, Location, Shop, Entrance, Settings +from Bosses import place_bosses from Items import ItemFactory from KeyDoorShuffle import validate_key_placement from OverworldGlitchRules import create_owg_connections @@ -28,14 +29,24 @@ from Fill import sell_potions, sell_keys, balance_multiworld_progression, balanc from ItemList import generate_itempool, difficulties, fill_prizes, customize_shops from Utils import output_path, parse_player_names -__version__ = '0.4.1-dev' +__version__ = '1.0.0-dev' + +from source.classes.BabelFish import BabelFish class EnemizerError(RuntimeError): pass +def check_python_version(): + import sys + version = sys.version_info + if version.major < 3 or version.minor < 7: + logging.warning(BabelFish().translate("cli","cli","old.python.version"), sys.version) + + def main(args, seed=None, fish=None): + check_python_version() if args.outputpath: os.makedirs(args.outputpath, exist_ok=True) output_path.cached_path = args.outputpath @@ -69,6 +80,7 @@ def main(args, seed=None, fish=None): world.compassshuffle = args.compassshuffle.copy() world.keyshuffle = args.keyshuffle.copy() world.bigkeyshuffle = args.bigkeyshuffle.copy() + world.bombbag = args.bombbag.copy() world.crystals_needed_for_ganon = {player: random.randint(0, 7) if args.crystals_ganon[player] == 'random' else int(args.crystals_ganon[player]) for player in range(1, world.players + 1)} world.crystals_needed_for_gt = {player: random.randint(0, 7) if args.crystals_gt[player] == 'random' else int(args.crystals_gt[player]) for player in range(1, world.players + 1)} world.crystals_ganon_orig = args.crystals_ganon.copy() @@ -135,6 +147,7 @@ def main(args, seed=None, fish=None): create_rooms(world, player) create_dungeons(world, player) adjust_locations(world, player) + place_bosses(world, player) if any(world.potshuffle.values()): logger.info(world.fish.translate("cli", "cli", "shuffling.pots")) @@ -233,7 +246,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 - if not world.can_beat_game(): + if not world.can_beat_game(log_error=True): raise RuntimeError(world.fish.translate("cli","cli","cannot.beat.game")) for player in range(1, world.players+1): @@ -258,11 +271,11 @@ def main(args, seed=None, fish=None): rom = JsonRom() if args.jsonout or use_enemizer else LocalRom(args.rom) if use_enemizer and (args.enemizercli or not args.jsonout): - base_patch = LocalRom(args.rom) # update base2current.json (side effect) + local_rom = LocalRom(args.rom) # update base2current.json (side effect) if args.rom and not(os.path.isfile(args.rom)): raise RuntimeError("Could not find valid base rom for enemizing at expected path %s." % args.rom) if os.path.exists(args.enemizercli): - patch_enemizer(world, player, rom, args.rom, args.enemizercli, sprite_random_on_hit) + patch_enemizer(world, player, rom, local_rom, args.enemizercli, sprite_random_on_hit) enemized = True if not args.jsonout: rom = LocalRom.fromJsonRom(rom, args.rom, 0x400000) @@ -282,7 +295,8 @@ def main(args, seed=None, fish=None): apply_rom_settings(rom, args.heartbeep[player], args.heartcolor[player], args.quickswap[player], args.fastmenu[player], args.disablemusic[player], args.sprite[player], - args.ow_palettes[player], args.uw_palettes[player], args.reduce_flashing[player]) + args.ow_palettes[player], args.uw_palettes[player], args.reduce_flashing[player], + args.shuffle_sfx[player]) if args.jsonout: jsonout[f'patch_t{team}_p{player}'] = rom.patches @@ -350,6 +364,7 @@ def copy_world(world): ret.player_names = copy.deepcopy(world.player_names) ret.remote_items = world.remote_items.copy() ret.required_medallions = world.required_medallions.copy() + ret.bottle_refills = world.bottle_refills.copy() ret.swamp_patch_required = world.swamp_patch_required.copy() ret.ganon_at_pyramid = world.ganon_at_pyramid.copy() ret.powder_patch_required = world.powder_patch_required.copy() @@ -372,6 +387,7 @@ def copy_world(world): ret.compassshuffle = world.compassshuffle.copy() ret.keyshuffle = world.keyshuffle.copy() ret.bigkeyshuffle = world.bigkeyshuffle.copy() + ret.bombbag = world.bombbag.copy() ret.crystals_needed_for_ganon = world.crystals_needed_for_ganon.copy() ret.crystals_needed_for_gt = world.crystals_needed_for_gt.copy() ret.crystals_ganon_orig = world.crystals_ganon_orig.copy() @@ -389,6 +405,8 @@ def copy_world(world): ret.mixed_travel = world.mixed_travel.copy() ret.standardize_palettes = world.standardize_palettes.copy() + ret.exp_cache = world.exp_cache.copy() + for player in range(1, world.players + 1): if world.mode[player] != 'inverted': create_regions(ret, player) @@ -448,6 +466,7 @@ def copy_world(world): # these need to be modified properly by set_rules new_location.access_rule = lambda state: True new_location.item_rule = lambda state: True + new_location.forced_item = location.forced_item # copy remaining itempool. No item in itempool should have an assigned location for item in world.itempool: @@ -525,11 +544,11 @@ def create_playthrough(world): while sphere_candidates: state.sweep_for_events(key_only=True) - sphere = [] + sphere = set() # build up spheres of collection radius. Everything in each sphere is independent from each other in dependencies and only depends on lower spheres for location in sphere_candidates: if state.can_reach(location) and state.not_flooding_a_key(world, location): - sphere.append(location) + sphere.add(location) for location in sphere: sphere_candidates.remove(location) @@ -550,21 +569,24 @@ def create_playthrough(world): # in the second phase, we cull each sphere such that the game is still beatable, reducing each range of influence to the bare minimum required inside it for num, sphere in reversed(list(enumerate(collection_spheres))): - to_delete = [] + to_delete = set() for location in sphere: # 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]): - to_delete.append(location) + # logging.getLogger('').debug(f'{old_item.name} (Player {old_item.player}) is not required') + to_delete.add(location) else: # still required, got to keep it around + # logging.getLogger('').debug(f'{old_item.name} (Player {old_item.player}) is required') location.item = old_item # cull entries in spheres for spoiler walkthrough at end - for location in to_delete: - sphere.remove(location) + sphere -= to_delete # second phase, sphere 0 for item in [i for i in world.precollected_items if i.advancement]: @@ -580,7 +602,7 @@ def create_playthrough(world): # used to access it was deemed not required.) So we need to do one final sphere collection pass # to build up the correct spheres - required_locations = [item for sphere in collection_spheres for item in sphere] + required_locations = {item for sphere in collection_spheres for item in sphere} state = CollectionState(world) collection_spheres = [] while required_locations: @@ -596,7 +618,10 @@ def create_playthrough(world): logging.getLogger('').debug(world.fish.translate("cli","cli","building.final.spheres"), len(collection_spheres), len(sphere), len(required_locations)) if not sphere: - raise RuntimeError(world.fish.translate("cli","cli","cannot.reach.required")) + if world.has_beaten_game(state): + required_locations.clear() + else: + raise RuntimeError(world.fish.translate("cli","cli","cannot.reach.required")) # store the required locations for statistical analysis old_world.required_locations = [(location.name, location.player) for sphere in collection_spheres for location in sphere] @@ -617,7 +642,7 @@ def create_playthrough(world): old_world.spoiler.paths = dict() for player in range(1, world.players + 1): old_world.spoiler.paths.update({location.gen_name(): get_path(state, location.parent_region) for sphere in collection_spheres for location in sphere if location.player == player}) - for _, path in dict(old_world.spoiler.paths).items(): + for path in dict(old_world.spoiler.paths).values(): if any(exit == 'Pyramid Fairy' for (_, exit) in path): if world.mode[player] != 'inverted': old_world.spoiler.paths[str(world.get_region('Big Bomb Shop', player))] = get_path(state, world.get_region('Big Bomb Shop', player)) @@ -625,6 +650,6 @@ def create_playthrough(world): old_world.spoiler.paths[str(world.get_region('Inverted Big Bomb Shop', player))] = get_path(state, world.get_region('Inverted Big Bomb Shop', player)) # we can finally output our playthrough - old_world.spoiler.playthrough = OrderedDict([("0", [str(item) for item in world.precollected_items if item.advancement])]) + old_world.spoiler.playthrough = {"0": [str(item) for item in world.precollected_items if item.advancement]} for i, sphere in enumerate(collection_spheres): old_world.spoiler.playthrough[str(i + 1)] = {location.gen_name(): str(location.item) for location in sphere} diff --git a/MultiClient.py b/MultiClient.py index bf64a2d7..501630e0 100644 --- a/MultiClient.py +++ b/MultiClient.py @@ -846,7 +846,7 @@ async def track_locations(ctx : Context, roomid, roomdata): new_locations.append(Regions.lookup_name_to_id[location]) try: - if roomid in location_shop_ids: + if ctx.shop_mode or ctx.retro_mode: misc_data = await snes_read(ctx, SHOP_ADDR, SHOP_SRAM_LEN) for cnt, b in enumerate(misc_data): my_check = Regions.shop_table_by_location_id[0x400000 + cnt] diff --git a/Mystery.py b/Mystery.py index 1ab4a90b..9cf0e729 100644 --- a/Mystery.py +++ b/Mystery.py @@ -31,6 +31,7 @@ def main(): parser.add_argument('--rom') parser.add_argument('--enemizercli') parser.add_argument('--outputpath') + parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical']) for player in range(1, multiargs.multi + 1): parser.add_argument(f'--p{player}', help=argparse.SUPPRESS) args = parser.parse_args() @@ -63,6 +64,7 @@ def main(): erargs.race = True erargs.outputname = seedname erargs.outputpath = args.outputpath + erargs.loglevel = args.loglevel if args.rom: erargs.rom = args.rom @@ -97,7 +99,8 @@ def get_weights(path): raise Exception(f'Failed to read weights file: {e}') def roll_settings(weights): - def get_choice(option, root=weights): + def get_choice(option, root=None): + root = weights if root is None else root if option not in root: return None if type(root[option]) is not dict: @@ -112,13 +115,24 @@ def roll_settings(weights): return default return choice + while True: + subweights = weights.get('subweights', {}) + if len(subweights) == 0: + break + chances = ({k: int(v['chance']) for (k, v) in subweights.items()}) + subweight_name = random.choices(list(chances.keys()), weights=list(chances.values()))[0] + subweights = weights.get('subweights', {}).get(subweight_name, {}).get('weights', {}) + subweights['subweights'] = subweights.get('subweights', {}) + weights = {**weights, **subweights} + ret = argparse.Namespace() glitches_required = get_choice('glitches_required') - if glitches_required not in ['none', 'no_logic']: - print("Only NMG and No Logic supported") - glitches_required = 'none' - ret.logic = {'none': 'noglitches', 'no_logic': 'nologic'}[glitches_required] + if glitches_required is not None: + if glitches_required not in ['none', 'no_logic']: + print("Only NMG and No Logic supported") + glitches_required = 'none' + ret.logic = {'none': 'noglitches', 'no_logic': 'nologic'}[glitches_required] item_placement = get_choice('item_placement') # not supported in ER @@ -150,39 +164,44 @@ def roll_settings(weights): ret.standardize_palettes = get_choice('standardize_palettes') if 'standardize_palettes' in weights else 'standardize' goal = get_choice('goals') - ret.goal = {'ganon': 'ganon', - 'fast_ganon': 'crystals', - 'dungeons': 'dungeons', - 'pedestal': 'pedestal', - 'triforce-hunt': 'triforcehunt' - }[goal] + if goal is not None: + ret.goal = {'ganon': 'ganon', + 'fast_ganon': 'crystals', + 'dungeons': 'dungeons', + 'pedestal': 'pedestal', + 'triforce-hunt': 'triforcehunt' + }[goal] ret.openpyramid = goal == 'fast_ganon' if ret.shuffle in ['vanilla', 'dungeonsfull', 'dungeonssimple'] else False ret.crystals_gt = get_choice('tower_open') ret.crystals_ganon = get_choice('ganon_open') - - if ret.goal == 'triforcehunt': - goal_min = get_choice_default('triforce_goal_min', default=20) - goal_max = get_choice_default('triforce_goal_max', default=20) - pool_min = get_choice_default('triforce_pool_min', default=30) - pool_max = get_choice_default('triforce_pool_max', default=30) - ret.triforce_goal = random.randint(int(goal_min), int(goal_max)) - min_diff = get_choice_default('triforce_min_difference', default=10) - ret.triforce_pool = random.randint(max(int(pool_min), ret.triforce_goal + int(min_diff)), int(pool_max)) + + goal_min = get_choice_default('triforce_goal_min', default=20) + goal_max = get_choice_default('triforce_goal_max', default=20) + pool_min = get_choice_default('triforce_pool_min', default=30) + pool_max = get_choice_default('triforce_pool_max', default=30) + ret.triforce_goal = random.randint(int(goal_min), int(goal_max)) + min_diff = get_choice_default('triforce_min_difference', default=10) + ret.triforce_pool = random.randint(max(int(pool_min), ret.triforce_goal + int(min_diff)), int(pool_max)) + ret.mode = get_choice('world_state') if ret.mode == 'retro': ret.mode = 'open' ret.retro = True ret.retro = get_choice('retro') == 'on' # this overrides world_state if used + ret.bombbag = get_choice('bombbag') == 'on' + ret.hints = get_choice('hints') == 'on' - ret.swords = {'randomized': 'random', - 'assured': 'assured', - 'vanilla': 'vanilla', - 'swordless': 'swordless' - }[get_choice('weapons')] + swords = get_choice('weapons') + if swords is not None: + ret.swords = {'randomized': 'random', + 'assured': 'assured', + 'vanilla': 'vanilla', + 'swordless': 'swordless' + }[swords] ret.difficulty = get_choice('item_pool') @@ -232,6 +251,7 @@ def roll_settings(weights): ret.heartbeep = get_choice('heartbeep', romweights) ret.ow_palettes = get_choice('ow_palettes', romweights) ret.uw_palettes = get_choice('uw_palettes', romweights) + ret.shuffle_sfx = get_choice('shuffle_sfx', romweights) == 'on' return ret diff --git a/PotShuffle.py b/PotShuffle.py index d62f52eb..a0c048bf 100644 --- a/PotShuffle.py +++ b/PotShuffle.py @@ -51,7 +51,7 @@ vanilla_pots = { 43: [Pot(16, 5, PotItem.Heart, 'PoD Sexy Statue'), Pot(44, 5, PotItem.Switch, 'PoD Sexy Statue'), Pot(16, 6, PotItem.Heart, 'PoD Sexy Statue'), Pot(44, 6, PotItem.Bomb, 'PoD Sexy Statue'), Pot(16, 7, PotItem.Heart, 'PoD Sexy Statue'), Pot(44, 7, PotItem.Bomb, 'PoD Sexy Statue'), Pot(146, 21, PotItem.Bomb, 'PoD Map Balcony'), Pot(170, 21, PotItem.FiveArrows, 'PoD Map Balcony'), Pot(146, 22, PotItem.Bomb, 'PoD Map Balcony'), Pot(170, 22, PotItem.FiveArrows, 'PoD Map Balcony')], - 44: [Pot(108, 24, PotItem.Heart, 'Hookshot Cave'), Pot(112, 24, PotItem.Heart, 'Hookshot Cave')], + 44: [Pot(108, 24, PotItem.Heart, 'Hookshot Cave (Middle)'), Pot(112, 24, PotItem.Heart, 'Hookshot Cave (Middle)')], 47: [Pot(28, 7, PotItem.Heart, 'Kakariko Well (top)'), Pot(32, 7, PotItem.Heart, 'Kakariko Well (top)'), Pot(28, 9, PotItem.FiveRupees, 'Kakariko Well (top)'), Pot(32, 9, PotItem.FiveRupees, 'Kakariko Well (top)'), Pot(172, 19, PotItem.FiveRupees, 'Kakariko Well (top)'), Pot(180, 19, PotItem.FiveRupees, 'Kakariko Well (top)'), Pot(104, 27, PotItem.Heart, 'Kakariko Well (bottom)'), Pot(104, 28, PotItem.Heart, 'Kakariko Well (bottom)')], 49: [Pot(92, 28, PotItem.Bomb, 'Hera Beetles'), Pot(96, 28, PotItem.Nothing, 'Hera Beetles')], @@ -66,8 +66,8 @@ vanilla_pots = { 55: [Pot(60, 6, PotItem.Key, 'Swamp Trench 1 Alcove'), Pot(48, 20, PotItem.Nothing, 'Swamp Trench 1 Key Ledge')], 56: [Pot(164, 12, PotItem.Bomb, 'Swamp Pot Row'), Pot(164, 13, PotItem.FiveRupees, 'Swamp Pot Row'), Pot(164, 18, PotItem.Bomb, 'Swamp Pot Row'), Pot(164, 19, PotItem.Key, 'Swamp Pot Row')], 57: [Pot(12, 20, PotItem.Heart, 'Skull Spike Corner'), Pot(48, 28, PotItem.FiveArrows, 'Skull Spike Corner'), Pot(100, 22, PotItem.SmallMagic, 'Skull Final Drop'), Pot(100, 26, PotItem.FiveArrows, 'Skull Final Drop')], - 60: [Pot(24, 8, PotItem.SmallMagic, 'Hookshot Cave'), Pot(64, 12, PotItem.FiveRupees, 'Hookshot Cave'), Pot(20, 14, PotItem.OneRupee, 'Hookshot Cave'), Pot(20, 19, PotItem.Nothing, 'Hookshot Cave'), - Pot(68, 18, PotItem.FiveRupees, 'Hookshot Cave'), Pot(96, 19, PotItem.Heart, 'Hookshot Cave'), Pot(64, 20, PotItem.FiveRupees, 'Hookshot Cave'), Pot(64, 26, PotItem.FiveRupees, 'Hookshot Cave')], + 60: [Pot(24, 8, PotItem.SmallMagic, 'Hookshot Cave (Front)'), Pot(64, 12, PotItem.FiveRupees, 'Hookshot Cave (Front)'), Pot(20, 14, PotItem.OneRupee, 'Hookshot Cave (Front)'), Pot(20, 19, PotItem.Nothing, 'Hookshot Cave (Front)'), + Pot(68, 18, PotItem.FiveRupees, 'Hookshot Cave (Front)'), Pot(96, 19, PotItem.Heart, 'Hookshot Cave (Front)'), Pot(64, 20, PotItem.FiveRupees, 'Hookshot Cave (Front)'), Pot(64, 26, PotItem.FiveRupees, 'Hookshot Cave (Front)')], 61: [Pot(76, 12, PotItem.Bomb, 'GT Mini Helmasaur Room'), Pot(112, 12, PotItem.Bomb, 'GT Mini Helmasaur Room'), Pot(24, 22, PotItem.Heart, 'GT Crystal Inner Circle'), Pot(40, 22, PotItem.FiveArrows, 'GT Crystal Inner Circle'), Pot(32, 24, PotItem.Heart, 'GT Crystal Inner Circle'), Pot(20, 26, PotItem.FiveRupees, 'GT Crystal Inner Circle'), Pot(36, 26, PotItem.BigMagic, 'GT Crystal Inner Circle')], 62: [Pot(96, 6, PotItem.Bomb, 'Ice Stalfos Hint'), Pot(100, 6, PotItem.SmallMagic, 'Ice Stalfos Hint'), Pot(88, 10, PotItem.Heart, 'Ice Stalfos Hint'), Pot(92, 10, PotItem.SmallMagic, 'Ice Stalfos Hint')], diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 163c6087..7a224c7c 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,4 +1,88 @@ -### Bug Fix +# New Features -Fix for boss music in non-DR modes (Thanks codemann8) +## Shuffle SFX +Shuffles a large portion of the sounds effects. Can be used with the adjuster. + +CLI: ```--shuffle_sfx``` + +## Bomb Logic + +When enabling this option, you do not start with bomb capacity but rather you must find 1 of 2 bomb bags. (They are represented by the +10 capacity item.) Bomb capacity upgrades are otherwise unavailable. + +CLI: ```--bombbag``` + + +# Bug Fixes and Notes. + +* 0.5.1.7 + * Baserom update + * Fix for Inverted Mode: Dark Lake Hylia shop defaults to selling a blue potion + * Fix for Ijwu's enemizer: Boss door in Thieves' Town no longer closes after the maiden hint if Blind is shuffled to Theives' Town in boss shuffle + crossed mode + * No logic now sets the AllowAccidentalMajorGlitches flag in the rom appropriately + * Houlihan room now exits wherever Link's House is shuffled to + * Rom fixes from Catobat and Codemann8. Thanks! +* 0.5.1.6 + * Rules fixes for TT (Boss and Cell) can now have TT Big Key if not otherwise required (boss shuffle + crossed dungeon) + * BUg fix for money balancing + * Add some bomb assumptions for bosses in bombbag mode +* 0.5.1.5 + * Fix for hard pool capacity upgrades missing + * Bonk Fairy (Light) is no longer in logic for ER Standard and is forbidden to be a connector, so rain state isn't exitable + * Bug fix for retro + enemizer and arrows appearing under pots + * Added bombbag and shufflelinks to settings code + * Catobat fixes: + * Fairy refills in spoiler + * Subweights support in mystery + * More defaults for mystery weights + * Less camera jank for straight stair transitions + * Bug with Straight stairs with vanilla doors where Link's walking animation stopped early is fixed +* 0.5.1.4 + * Revert quadrant glitch fix for baserom + * Fix for inverted +* 0.5.1.3 + * Certain lobbies forbidden in standard when rupee bow is enabled + * PoD EG disarmed when mirroring (except in nologic) + * Fixed issue with key logic + * Updated baserom +* 0.5.1.2 + * Allowed Blind's Cell to be shuffled anywhere if Blind is not the boss of Thieves Town + * Remove unique annotation from a FastEnum that was causing problems + * Updated prevent mixed_travel setting to prevent more mixed travel + * Prevent key door loops on the same supertile where you could have spent 2 keys on one logical door + * Promoted dynamic soft-lock prevention on "stonewalls" from experimental to be the primary prevention (Stonewalls are now never pre-opened) + * Fix to money balancing algorithm with small item_pool, thanks Catobat + * Many fixes and refinements to key logic and generation +* 0.5.1.1 + * Shop hints in ER are now more generic instead of using "near X" because they aren't near that anymore + * Added memory location for mutliworld scripts to read what item was just obtain (longer than one frame) + * Fix for bias in boss shuffle "full" + * Fix for certain lone big chests in keysanity (allowed you to get contents without big key) + * Fix for pinball checking + * Fix for multi-entrance dungeons + * 2 fixes for big key placement logic + * ensure big key is placed early if the validator assumes it) + * Open big key doors appropriately when generating rules and big key is forced somewhere + * Updated cutoff entrances for intensity 3 +* 0.5.1.0 + * Large logic refactor introducing a new method of key logic + * Some performance optimization + * Some outstanding bug fixes (boss shuffle "full" picks three unique bosses to be duplicated, e.g.) +* 0.5.0.3 + * Fixed a bug in retro+vanilla and big key placement + * Fixed a problem with shops not registering in the Multiclient until you visit one + * Fixed a bug in the Mystery code with sfx +* 0.5.0.2 + * --shuffle_sfx option added +* 0.5.0.1 + * --bombbag option added +* 0.5.0.0 + * Handles headered roms for enemizer (Thanks compiling) + * Warning added for earlier version of python (Thanks compiling) + * Minor logic issue for defeating Aga in standard (Thanks compiling) + * Fix for boss music in non-DR modes (Thanks codemann8) + +# Known Issues + +* Shopsanity Issues + * Forfeit in Multiworld not granting all shop items \ No newline at end of file diff --git a/Regions.py b/Regions.py index b2af454a..8ea63502 100644 --- a/Regions.py +++ b/Regions.py @@ -190,8 +190,11 @@ def create_regions(world, player): create_cave_region(player, 'Superbunny Cave (Top)', 'a connector', ['Superbunny Cave - Top', 'Superbunny Cave - Bottom'], ['Superbunny Cave Exit (Top)']), create_cave_region(player, 'Superbunny Cave (Bottom)', 'a connector', None, ['Superbunny Cave Climb', 'Superbunny Cave Exit (Bottom)']), create_cave_region(player, 'Spike Cave', 'Spike Cave', ['Spike Cave']), - create_cave_region(player, 'Hookshot Cave', 'a connector', ['Hookshot Cave - Top Right', 'Hookshot Cave - Top Left', 'Hookshot Cave - Bottom Right', 'Hookshot Cave - Bottom Left'], - ['Hookshot Cave Exit (South)', 'Hookshot Cave Exit (North)']), + create_cave_region(player, 'Hookshot Cave (Front)', 'a connector', ['Hookshot Cave - Top Right', 'Hookshot Cave - Top Left', 'Hookshot Cave - Bottom Right', 'Hookshot Cave - Bottom Left'], + ['Hookshot Cave Front to Middle', 'Hookshot Cave Front Exit']), + create_cave_region(player, 'Hookshot Cave (Back)', 'a connector', None, ['Hookshot Cave Back to Middle', 'Hookshot Cave Back Exit']), + create_cave_region(player, 'Hookshot Cave (Middle)', 'a connector', None, ['Hookshot Cave Middle to Back', 'Hookshot Cave Middle to Front']), + create_dw_region(player, 'Death Mountain Floating Island (Dark World)', None, ['Floating Island Drop', 'Hookshot Cave Back Entrance', 'Floating Island Mirror Spot']), create_lw_region(player, 'Death Mountain Floating Island (Light World)', ['Floating Island']), create_dw_region(player, 'Turtle Rock (Top)', None, ['Turtle Rock Drop']), @@ -954,6 +957,7 @@ def create_shops(world, player): if world.mode[player] == 'inverted' and region_name == 'Dark Lake Hylia Shop': locked = True inventory = [('Blue Potion', 160), ('Blue Shield', 50), ('Bombs (10)', 50)] + custom = True region = world.get_region(region_name, player) shop = Shop(region, room_id, type, shopkeeper, custom, locked, sram) region.shop = shop @@ -1371,9 +1375,9 @@ location_table = {'Mushroom': (0x180013, 0x186338, False, 'in the woods'), 'Red Shield Shop - Left': (None, None, False, 'for sale as a curiosity'), 'Red Shield Shop - Middle': (None, None, False, 'for sale as a curiosity'), 'Red Shield Shop - Right': (None, None, False, 'for sale as a curiosity'), - 'Potion Shop - Left': (None, None, False, 'for sale near the witch'), - 'Potion Shop - Middle': (None, None, False, 'for sale near the witch'), - 'Potion Shop - Right': (None, None, False, 'for sale near the witch'), + 'Potion Shop - Left': (None, None, False, 'for sale near potions'), + 'Potion Shop - Middle': (None, None, False, 'for sale near potions'), + 'Potion Shop - Right': (None, None, False, 'for sale near potions'), } lookup_id_to_name = {data[0]: name for name, data in location_table.items() if type(data[0]) == int} diff --git a/Rom.py b/Rom.py index 355509c0..26594bb4 100644 --- a/Rom.py +++ b/Rom.py @@ -28,9 +28,11 @@ from Utils import output_path, local_path, int16_as_bytes, int32_as_bytes, snes_ from Items import ItemFactory from EntranceShuffle import door_addresses, exit_ids +from source.classes.SFX import randomize_sfx + JAP10HASH = '03a63945398191337e896e5771f77173' -RANDOMIZERBASEHASH = '988f1546b14d8f2e6ee30b9de44882da' +RANDOMIZERBASEHASH = '5112ddd931bda3d9979097bc39a5e768' class JsonRom(object): @@ -86,10 +88,12 @@ class LocalRom(object): self.name = name self.hash = hash self.orig_buffer = None + self.file = file + self.has_smc_header = False if not os.path.isfile(file): raise RuntimeError("Could not find valid local base rom for patching at expected path %s." % file) with open(file, 'rb') as stream: - self.buffer = read_rom(stream) + self.buffer, self.has_smc_header = read_rom(stream) if patch: self.patch_base_rom() self.orig_buffer = self.buffer.copy() @@ -98,8 +102,7 @@ class LocalRom(object): self.buffer[address] = value def write_bytes(self, startaddress, values): - for i, value in enumerate(values): - self.write_byte(startaddress + i, value) + self.buffer[startaddress:startaddress + len(values)] = values def write_to_file(self, file): with open(file, 'wb') as outfile: @@ -187,12 +190,21 @@ def write_int32s(rom, startaddress, values): def read_rom(stream): "Reads rom into bytearray and strips off any smc header" buffer = bytearray(stream.read()) + has_smc_header = False if len(buffer)%0x400 == 0x200: buffer = buffer[0x200:] - return buffer + has_smc_header = True + return buffer, has_smc_header -def patch_enemizer(world, player, rom, baserom_path, enemizercli, random_sprite_on_hit): - baserom_path = os.path.abspath(baserom_path) +def patch_enemizer(world, player, rom, local_rom, enemizercli, random_sprite_on_hit): + baserom_path = os.path.abspath(local_rom.file) + unheadered_path = None + if local_rom.has_smc_header: + headered_path = baserom_path + unheadered_path = baserom_path = os.path.abspath(output_path('unheadered_rom.sfc')) + with open(headered_path, 'rb') as headered: + with open(baserom_path, 'wb') as unheadered: + unheadered.write(headered.read()[0x200:]) basepatch_path = os.path.abspath(local_path(os.path.join("data","base2current.json"))) enemizer_basepatch_path = os.path.join(os.path.dirname(enemizercli), "enemizerBasePatch.json") randopatch_path = os.path.abspath(output_path('enemizer_randopatch.json')) @@ -321,6 +333,7 @@ def patch_enemizer(world, player, rom, baserom_path, enemizercli, random_sprite_ rom.write_bytes(0xEA081, [0x5c, 0x00, 0x80, 0xb7, 0xc9, 0x6, 0xf0, 0x24, 0xad, 0x3, 0x4, 0x29, 0x20, 0xf0, 0x1d]) rom.write_byte(0x200101, 0) # Do not close boss room door on entry. + rom.write_byte(0x1B0101, 0) # Do not close boss room door on entry. (for Ijwu's enemizer) if random_sprite_on_hit: _populate_sprite_table() @@ -336,6 +349,12 @@ def patch_enemizer(world, player, rom, baserom_path, enemizercli, random_sprite_ rom.write_bytes(0x307000 + (i * 0x8000), sprite.palette) rom.write_bytes(0x307078 + (i * 0x8000), sprite.glove_palette) + if local_rom.has_smc_header: + try: + os.remove(unheadered_path) + except OSError: + pass + try: os.remove(randopatch_path) except OSError: @@ -653,11 +672,25 @@ def patch_rom(world, rom, player, team, enemized, is_mystery=False): dr_flags |= DROptions.Debug if world.doorShuffle[player] == 'crossed' and world.logic[player] != 'nologic'\ and world.mixed_travel[player] == 'prevent': - dr_flags |= DROptions.Rails + # PoD Falling Bridge or Hammjump + # 1FA607: db $2D, $79, $69 ; 0x0069: Vertical Rail ↕ | { 0B, 1E } | Size: 05 + # 1FA60A: db $14, $99, $5D ; 0x005D: Large Horizontal Rail ↔ | { 05, 26 } | Size: 01 + rom.write_bytes(0xfa607, [0x2d, 0x79, 0x69, 0x14, 0x99, 0x5d]) + # PoD Arena + # 1FA573: db $D4, $B2, $22 ; 0x0022: Horizontal Rail ↔ | { 35, 2C } | Size: 02 + # 1FA576: db $D4, $CE, $22 ; 0x0022: Horizontal Rail ↔ | { 35, 33 } | Size: 01 + # 1FA579: db $D9, $AE, $69 ; 0x0069: Vertical Rail ↕ | { 36, 2B } | Size: 06 + rom.write_bytes(0xfa573, [0xd4, 0xb2, 0x22, 0xd4, 0xce, 0x22, 0xd9, 0xae, 0x69]) + # Mire BK Pond + # 1FB1FC: db $C8, $9D, $69 ; 0x0069: Vertical Rail ↕ | { 32, 27 } | Size: 01 + # 1FB1FF: db $B4, $AC, $5D ; 0x005D: Large Horizontal Rail ↔ | { 2D, 2B } | Size: 00 + rom.write_bytes(0xfb1fc, [0xc8, 0x9d, 0x69, 0xb4, 0xac, 0x5d]) if world.standardize_palettes[player] == 'original': dr_flags |= DROptions.OriginalPalettes if world.experimental[player]: dr_flags |= DROptions.DarkWorld_Spawns + if world.logic[player] != 'nologic': + dr_flags |= DROptions.Fix_EG # fix hc big key problems (map and compass too) @@ -724,13 +757,6 @@ def patch_rom(world, rom, player, team, enemized, is_mystery=False): rom.write_bytes(paired_door.address_a(world, player), paired_door.rom_data_a(world, player)) rom.write_bytes(paired_door.address_b(world, player), paired_door.rom_data_b(world, player)) if world.doorShuffle[player] != 'vanilla': - if not world.experimental[player]: - for builder in world.dungeon_layouts[player].values(): - for stonewall in builder.pre_open_stonewalls: - if stonewall.name == 'Desert Wall Slide NW': - dr_flags |= DROptions.Open_Desert_Wall - elif stonewall.name == 'PoD Bow Statue Down Ladder': - dr_flags |= DROptions.Open_PoD_Wall for name, pair in boss_indicator.items(): dungeon_id, boss_door = pair opposite_door = world.get_door(boss_door, player).dest @@ -1032,7 +1058,7 @@ def patch_rom(world, rom, player, team, enemized, is_mystery=False): rom.write_bytes(0x184000, [ # original_item, limit, replacement_item, filler 0x12, 0x01, 0x35, 0xFF, # lamp -> 5 rupees - 0x51, 0x06, 0x52, 0xFF, # 6 +5 bomb upgrades -> +10 bomb upgrade + 0x51, 0x00 if world.bombbag[player] else 0x06, 0x31 if world.bombbag[player] else 0x52, 0xFF, # 6 +5 bomb upgrades -> +10 bomb upgrade. If bombbag -> turns into Bombs (10) 0x53, 0x06, 0x54, 0xFF, # 6 +5 arrow upgrades -> +10 arrow upgrade 0x58, 0x01, 0x36 if world.retro[player] else 0x43, 0xFF, # silver arrows -> single arrow (red 20 in retro mode) 0x3E, difficulty.boss_heart_container_limit, 0x47, 0xff, # boss heart -> green 20 @@ -1041,12 +1067,8 @@ def patch_rom(world, rom, player, team, enemized, is_mystery=False): ]) # set Fountain bottle exchange items - if world.difficulty[player] in ['hard', 'expert']: - rom.write_byte(0x348FF, [0x16, 0x2B, 0x2C, 0x2D, 0x3C, 0x48][random.randint(0, 5)]) - rom.write_byte(0x3493B, [0x16, 0x2B, 0x2C, 0x2D, 0x3C, 0x48][random.randint(0, 5)]) - else: - rom.write_byte(0x348FF, [0x16, 0x2B, 0x2C, 0x2D, 0x3C, 0x3D, 0x48][random.randint(0, 6)]) - rom.write_byte(0x3493B, [0x16, 0x2B, 0x2C, 0x2D, 0x3C, 0x3D, 0x48][random.randint(0, 6)]) + rom.write_byte(0x348FF, ItemFactory(world.bottle_refills[player][0], player).code) + rom.write_byte(0x3493B, ItemFactory(world.bottle_refills[player][1], player).code) #enable Fat Fairy Chests rom.write_bytes(0x1FC16, [0xB1, 0xC6, 0xF9, 0xC9, 0xC6, 0xF9]) @@ -1169,7 +1191,10 @@ def patch_rom(world, rom, player, team, enemized, is_mystery=False): equip[0x36C] = 0x18 equip[0x36D] = 0x18 equip[0x379] = 0x68 - starting_max_bombs = 10 + if world.bombbag[player]: + starting_max_bombs = 0 + else: + starting_max_bombs = 10 starting_max_arrows = 30 startingstate = CollectionState(world) @@ -1426,6 +1451,8 @@ def patch_rom(world, rom, player, team, enemized, is_mystery=False): rom.write_byte(0x180176, 0x0A if world.retro[player] else 0x00) # wood arrow cost rom.write_byte(0x180178, 0x32 if world.retro[player] else 0x00) # silver arrow cost rom.write_byte(0x301FC, 0xDA if world.retro[player] else 0xE1) # rupees replace arrows under pots + if enemized: + rom.write_byte(0x1B152e, 0xDA if world.retro[player] else 0xE1) rom.write_byte(0x30052, 0xDB if world.retro[player] else 0xE2) # replace arrows in fish prize from bottle merchant rom.write_bytes(0xECB4E, [0xA9, 0x00, 0xEA, 0xEA] if world.retro[player] else [0xAF, 0x77, 0xF3, 0x7E]) # Thief steals rupees instead of arrows rom.write_bytes(0xF0D96, [0xA9, 0x00, 0xEA, 0xEA] if world.retro[player] else [0xAF, 0x77, 0xF3, 0x7E]) # Pikit steals rupees instead of arrows @@ -1436,6 +1463,7 @@ def patch_rom(world, rom, player, team, enemized, is_mystery=False): rom.write_byte(0x1800A3, 0x01) # enable correct world setting behaviour after agahnim kills rom.write_byte(0x1800A4, 0x01 if world.logic[player] != 'nologic' else 0x00) # enable POD EG fix rom.write_byte(0x180042, 0x01 if world.save_and_quit_from_boss else 0x00) # Allow Save and Quit after boss kill + rom.write_byte(0x180358, 0x01 if world.logic[player] == 'nologic' else 0x00) # remove shield from uncle rom.write_bytes(0x6D253, [0x00, 0x00, 0xf6, 0xff, 0x00, 0x0E]) @@ -1461,7 +1489,7 @@ def patch_rom(world, rom, player, team, enemized, is_mystery=False): rom.write_bytes(0x180188, [0, 0, 10]) # Zelda respawn refills (magic, bombs, arrows) rom.write_bytes(0x18018B, [0, 0, 10]) # Mantle respawn refills (magic, bombs, arrows) bow_max, bow_small = 70, 10 - elif uncle_location.item is not None and uncle_location.item.name in ['Bombs (10)']: + elif uncle_location.item is not None and uncle_location.item.name in ['Bomb Upgrade (+10)' if world.bombbag[player] else 'Bombs (10)']: rom.write_byte(0x18004E, 2) # Escape Fill (bombs) rom.write_bytes(0x180185, [0, 50, 0]) # Uncle respawn refills (magic, bombs, arrows) rom.write_bytes(0x180188, [0, 3, 0]) # Zelda respawn refills (magic, bombs, arrows) @@ -1591,13 +1619,16 @@ def write_custom_shops(rom, world, player): loc_item = ItemFactory(item['item'], player) if (not world.shopsanity[player] and shop.region.name == 'Capacity Upgrade' and world.difficulty[player] != 'normal'): - continue # skip cap upgrades except in normal/shopsanity - item_id = loc_item.code - price = int16_as_bytes(item['price']) - replace = ItemFactory(item['replacement'], player).code if item['replacement'] else 0xFF - replace_price = int16_as_bytes(item['replacement_price']) + # really should be 5A instead of B0 -- surprise!!! + item_id, price, replace, replace_price, item_max = 0xB0, [0, 0], 0xFF, [0, 0], 1 + else: + item_id = loc_item.code + price = int16_as_bytes(item['price']) + replace = ItemFactory(item['replacement'], player).code if item['replacement'] else 0xFF + replace_price = int16_as_bytes(item['replacement_price']) + item_max = item['max'] item_player = 0 if item['player'] == player else item['player'] - item_data = [shop_id, item_id] + price + [item['max'], replace] + replace_price + [item_player] + item_data = [shop_id, item_id] + price + [item_max, replace] + replace_price + [item_player] items_data.extend(item_data) rom.write_bytes(0x184800, shop_data) @@ -1625,7 +1656,7 @@ def hud_format_text(text): def apply_rom_settings(rom, beep, color, quickswap, fastmenu, disable_music, sprite, - ow_palettes, uw_palettes, reduce_flashing): + ow_palettes, uw_palettes, reduce_flashing, shuffle_sfx): if not os.path.exists("data/sprites/official/001.link.1.zspr") and rom.orig_buffer: dump_zspr(rom.orig_buffer[0x80000:0x87000], rom.orig_buffer[0xdd308:0xdd380], @@ -1728,6 +1759,9 @@ def apply_rom_settings(rom, beep, color, quickswap, fastmenu, disable_music, spr elif uw_palettes == 'blackout': blackout_uw_palettes(rom) + if shuffle_sfx: + randomize_sfx(rom) + if isinstance(rom, LocalRom): rom.write_crc() @@ -2275,6 +2309,12 @@ def set_inverted_mode(world, player, rom): write_int16(rom, snes_to_pc(0x02E8D5), 0x07C8) write_int16(rom, snes_to_pc(0x02E8F7), 0x01F8) rom.write_byte(snes_to_pc(0x08D40C), 0xD0) # morph proof + rom.write_byte(snes_to_pc(0x1BC428), 0x00) # remove diggable light world portals + rom.write_byte(snes_to_pc(0x1BC43A), 0x00) + rom.write_byte(snes_to_pc(0x1BC590), 0x00) + rom.write_byte(snes_to_pc(0x1BC5A1), 0x00) + rom.write_byte(snes_to_pc(0x1BC5B1), 0x00) + rom.write_byte(snes_to_pc(0x1BC5C7), 0x00) # the following bytes should only be written in vanilla # or they'll overwrite the randomizer's shuffles if world.shuffle[player] == 'vanilla': @@ -2771,7 +2811,7 @@ def write_pots_to_rom(rom, pot_contents): pots = [pot for pot in pot_contents[i] if pot.item != PotItem.Nothing] if len(pots) > 0: write_int16(rom, pot_item_room_table_lookup + 2*i, n) - rom.write_bytes(n, itertools.chain(*((pot.x,pot.y,pot.item) for pot in pots))) + rom.write_bytes(n, list(itertools.chain.from_iterable(((pot.x, pot.y, pot.item) for pot in pots)))) n += 3*len(pots) + 2 rom.write_bytes(n - 2, [0xFF,0xFF]) else: diff --git a/Rules.py b/Rules.py index 3762b9bb..83a2175b 100644 --- a/Rules.py +++ b/Rules.py @@ -3,7 +3,7 @@ import logging from collections import deque import OverworldGlitchRules -from BaseClasses import CollectionState, RegionType, DoorType, Entrance, CrystalBarrier +from BaseClasses import CollectionState, RegionType, DoorType, Entrance, CrystalBarrier, KeyRuleType from RoomData import DoorKind from OverworldGlitchRules import overworld_glitches_rules @@ -274,9 +274,11 @@ def global_rules(world, player): set_rule(world.get_location('Thieves\' Town - Big Chest', player), lambda state: state.has('Hammer', player)) for entrance in ['Thieves Basement Block Path', 'Thieves Blocked Entry Path', 'Thieves Conveyor Block Path', 'Thieves Conveyor Bridge Block Path']: set_rule(world.get_entrance(entrance, player), lambda state: state.can_lift_rocks(player)) - for location in ['Thieves\' Town - Blind\'s Cell', 'Thieves\' Town - Boss']: - forbid_item(world.get_location(location, player), 'Big Key (Thieves Town)', player) - forbid_item(world.get_location('Thieves\' Town - Blind\'s Cell', player), 'Big Key (Thieves Town)', player) + + # I think these rules are unnecessary now - testing needed + # for location in ['Thieves\' Town - Blind\'s Cell', 'Thieves\' Town - Boss']: + # forbid_item(world.get_location(location, player), 'Big Key (Thieves Town)', player) + # forbid_item(world.get_location('Thieves\' Town - Blind\'s Cell', player), 'Big Key (Thieves Town)', player) for location in ['Suspicious Maiden', 'Thieves\' Town - Blind\'s Cell']: set_rule(world.get_location(location, player), lambda state: state.has('Big Key (Thieves Town)', player)) set_rule(world.get_location('Revealing Light', player), lambda state: state.has('Shining Light', player) and state.has('Maiden Rescued', player)) @@ -304,7 +306,11 @@ def global_rules(world, player): set_rule(world.get_entrance('Mire Lobby Gap', player), lambda state: state.has_Boots(player) or state.has('Hookshot', player)) set_rule(world.get_entrance('Mire Post-Gap Gap', player), lambda state: state.has_Boots(player) or state.has('Hookshot', player)) set_rule(world.get_entrance('Mire Falling Bridge WN', player), lambda state: state.has_Boots(player) or state.has('Hookshot', player)) # this is due to the fact the the door opposite is blocked - set_rule(world.get_entrance('Mire 2 NE', player), lambda state: state.has_sword(player) or state.has('Fire Rod', player) or state.has('Ice Rod', player) or state.has('Hammer', player) or state.has('Cane of Somaria', player) or state.can_shoot_arrows(player)) # need to defeat wizzrobes, bombs don't work ... + set_rule(world.get_entrance('Mire 2 NE', player), lambda state: state.has_sword(player) or + (state.has('Fire Rod', player) and (state.can_use_bombs(player) or state.can_extend_magic(player, 9))) or # 9 fr shots or 8 with some bombs + (state.has('Ice Rod', player) and state.can_use_bombs(player)) or # freeze popo and throw, bomb to finish + state.has('Hammer', player) or state.has('Cane of Somaria', player) or state.can_shoot_arrows(player)) # need to defeat wizzrobes, bombs don't work ... + # byrna could work with sufficient magic set_rule(world.get_location('Misery Mire - Spike Chest', player), lambda state: (state.world.can_take_damage and state.has_hearts(player, 4)) or state.has('Cane of Byrna', player) or state.has('Cape', player)) set_rule(world.get_entrance('Mire Left Bridge Hook Path', player), lambda state: state.has('Hookshot', player)) set_rule(world.get_entrance('Mire Tile Room NW', player), lambda state: state.has_fire_source(player)) @@ -561,7 +567,8 @@ def global_rules(world, player): def bomb_rules(world, player): bonkable_doors = ['Two Brothers House Exit (West)', 'Two Brothers House Exit (East)'] # Technically this is incorrectly defined, but functionally the same as what is intended. bombable_doors = ['Ice Rod Cave', 'Light World Bomb Hut', 'Light World Death Mountain Shop', 'Mini Moldorm Cave', - 'Hookshot Cave Exit (South)', 'Hookshot Cave Exit (North)', 'Dark Lake Hylia Ledge Fairy', 'Hype Cave', 'Brewery'] + 'Hookshot Cave Back to Middle', 'Hookshot Cave Front to Middle', 'Hookshot Cave Middle to Front','Hookshot Cave Middle to Back', + 'Dark Lake Hylia Ledge Fairy', 'Hype Cave', 'Brewery'] for entrance in bonkable_doors: add_rule(world.get_entrance(entrance, player), lambda state: state.can_use_bombs(player) or state.has_Boots(player)) for entrance in bombable_doors: @@ -576,9 +583,10 @@ def bomb_rules(world, player): for location in bombable_items: add_rule(world.get_location(location, player), lambda state: state.can_use_bombs(player)) - cave_kill_locations = ['Mini Moldorm Cave - Far Left', 'Mini Moldorm Cave - Far Right', 'Mini Moldorm Cave - Left', 'Mini Moldorm Cave - Right', 'Mini Moldorm Cave - Generous Guy'] + cave_kill_locations = ['Mini Moldorm Cave - Far Left', 'Mini Moldorm Cave - Far Right', 'Mini Moldorm Cave - Left', 'Mini Moldorm Cave - Right', 'Mini Moldorm Cave - Generous Guy', 'Spiral Cave'] for location in cave_kill_locations: add_rule(world.get_location(location, player), lambda state: state.can_kill_most_things(player) or state.can_use_bombs(player)) + add_rule(world.get_entrance('Spiral Cave (top to bottom)', player), lambda state: state.can_kill_most_things(player) or state.can_use_bombs(player)) paradox_switch_chests = ['Paradox Cave Lower - Far Left', 'Paradox Cave Lower - Left', 'Paradox Cave Lower - Right', 'Paradox Cave Lower - Far Right', 'Paradox Cave Lower - Middle'] for location in paradox_switch_chests: @@ -706,7 +714,8 @@ def default_rules(world, player): set_rule(world.get_entrance('Broken Bridge (East)', player), lambda state: state.has('Hookshot', player)) set_rule(world.get_entrance('East Death Mountain Teleporter', player), lambda state: state.can_lift_heavy_rocks(player)) set_rule(world.get_entrance('Fairy Ascension Rocks', player), lambda state: state.can_lift_heavy_rocks(player)) - set_rule(world.get_entrance('Paradox Cave Push Block Reverse', player), lambda state: state.has('Mirror', player)) # can erase block + # can erase block - overridden in noglitches + set_rule(world.get_entrance('Paradox Cave Push Block Reverse', player), lambda state: state.has_Mirror(player)) set_rule(world.get_entrance('Death Mountain (Top)', player), lambda state: state.has('Hammer', player)) set_rule(world.get_entrance('Turtle Rock Teleporter', player), lambda state: state.can_lift_heavy_rocks(player) and state.has('Hammer', player)) set_rule(world.get_entrance('East Death Mountain (Top)', player), lambda state: state.has('Hammer', player)) @@ -843,7 +852,8 @@ def inverted_rules(world, player): set_rule(world.get_entrance('Broken Bridge (East)', player), lambda state: state.has('Hookshot', player) and state.has_Pearl(player)) set_rule(world.get_entrance('Dark Death Mountain Teleporter (East Bottom)', player), lambda state: state.can_lift_heavy_rocks(player)) set_rule(world.get_entrance('Fairy Ascension Rocks', player), lambda state: state.can_lift_heavy_rocks(player) and state.has_Pearl(player)) - set_rule(world.get_entrance('Paradox Cave Push Block Reverse', player), lambda state: state.has('Mirror', player)) # can erase block + # can erase block - overridden in noglitches + set_rule(world.get_entrance('Paradox Cave Push Block Reverse', player), lambda state: state.has_Mirror(player)) set_rule(world.get_entrance('Death Mountain (Top)', player), lambda state: state.has('Hammer', player) and state.has_Pearl(player)) set_rule(world.get_entrance('Dark Death Mountain Teleporter (East)', player), lambda state: state.can_lift_heavy_rocks(player) and state.has('Hammer', player) and state.has_Pearl(player)) # bunny cannot use hammer set_rule(world.get_entrance('East Death Mountain (Top)', player), lambda state: state.has('Hammer', player) and state.has_Pearl(player)) # bunny can not use hammer @@ -1149,7 +1159,7 @@ def standard_rules(world, player): set_rule(entrance, lambda state: state.has('Zelda Delivered', player)) set_rule(world.get_entrance('Sanctuary Exit', player), lambda state: state.has('Zelda Delivered', player)) # zelda should be saved before agahnim is in play - set_rule(world.get_location('Agahnim 1', player), lambda state: state.has('Zelda Delivered', player)) + add_rule(world.get_location('Agahnim 1', player), lambda state: state.has('Zelda Delivered', player)) # too restrictive for crossed? def uncle_item_rule(item): @@ -1160,7 +1170,7 @@ def standard_rules(world, player): def bomb_escape_rule(): loc = world.get_location("Link's Uncle", player) - return loc.item and loc.item.name == 'Bombs (10)' + return loc.item and loc.item.name in ['Bomb Upgrade (+10)' if world.bombbag[player] else 'Bombs (10)'] def standard_escape_rule(state): return state.can_kill_most_things(player) or bomb_escape_rule() @@ -1206,7 +1216,7 @@ def standard_rules(world, player): 'North Fairy Cave', 'North Fairy Cave Drop', 'Lost Woods Gamble', 'Snitch Lady (East)', 'Snitch Lady (West)', 'Tavern (Front)', 'Bush Covered House', 'Light World Bomb Hut', 'Kakariko Shop', 'Long Fairy Cave', 'Good Bee Cave', '20 Rupee Cave', 'Cave Shop (Lake Hylia)', - 'Waterfall of Wishing', 'Hyrule Castle Main Gate', '50 Rupee Cave', + 'Waterfall of Wishing', 'Hyrule Castle Main Gate', '50 Rupee Cave', 'Bonk Fairy (Light)', 'Fortune Teller (Light)', 'Lake Hylia Fairy', 'Light Hype Fairy', 'Desert Fairy', 'Lumberjack House', 'Lake Hylia Fortune Teller', 'Kakariko Gamble Game', 'Top of Pyramid']: add_rule(world.get_entrance(entrance, player), lambda state: state.has('Zelda Delivered', player)) @@ -1671,7 +1681,7 @@ def set_bunny_rules(world, player, inverted): # regions for the exits of multi-entrace caves/drops that bunny cannot pass # Note spiral cave may be technically passible, but it would be too absurd to require since OHKO mode is a thing. - bunny_impassable_caves = ['Bumper Cave', 'Two Brothers House', 'Hookshot Cave', + bunny_impassable_caves = ['Bumper Cave', 'Two Brothers House', 'Hookshot Cave (Middle)', 'Pyramid', 'Spiral Cave (Top)', 'Fairy Ascension Cave (Drop)'] bunny_accessible_locations = ['Link\'s Uncle', 'Sahasrahla', 'Sick Kid', 'Lost Woods Hideout', 'Lumberjack Tree', 'Checkerboard Cave', 'Potion Shop', 'Spectacle Rock Cave', 'Pyramid', @@ -1939,14 +1949,12 @@ bunny_impassible_doors = { def add_key_logic_rules(world, player): key_logic = world.key_logic[player] for d_name, d_logic in key_logic.items(): - for door_name, keys in d_logic.door_rules.items(): - spot = world.get_entrance(door_name, player) - if not world.retro[player] or world.mode[player] != 'standard' or not retro_in_hc(spot): - rule = create_advanced_key_rule(d_logic, player, keys) - if keys.opposite: - rule = or_rule(rule, create_advanced_key_rule(d_logic, player, keys.opposite)) - add_rule(spot, rule) - + for door_name, rule in d_logic.door_rules.items(): + door_entrance = world.get_entrance(door_name, player) + add_rule(door_entrance, eval_small_key_door(door_name, d_name, player)) + if door_entrance.door.dependents: + for dep in door_entrance.door.dependents: + add_rule(dep.entrance, eval_small_key_door(door_name, d_name, player)) for location in d_logic.bk_restricted: if not location.forced_item: forbid_item(location, d_logic.bk_name, player) @@ -1955,7 +1963,10 @@ def add_key_logic_rules(world, player): for door in d_logic.bk_doors: add_rule(world.get_entrance(door.name, player), create_rule(d_logic.bk_name, player)) for chest in d_logic.bk_chests: - add_rule(world.get_location(chest.name, player), create_rule(d_logic.bk_name, player)) + big_chest = world.get_location(chest.name, player) + add_rule(big_chest, create_rule(d_logic.bk_name, player)) + if len(d_logic.bk_doors) == 0 and len(d_logic.bk_chests) <= 1: + set_always_allow(big_chest, allow_big_key_in_big_chest(d_logic.bk_name, player)) if world.retro[player]: for d_name, layout in world.key_layout[player].items(): for door in layout.flat_prop: @@ -1963,6 +1974,40 @@ def add_key_logic_rules(world, player): add_rule(door.entrance, create_key_rule('Small Key (Universal)', player, 1)) +def eval_small_key_door_main(state, door_name, dungeon, player): + if state.is_door_open(door_name, player): + return True + key_logic = state.world.key_logic[player][dungeon] + door_rule = key_logic.door_rules[door_name] + door_openable = False + for ruleType, number in door_rule.new_rules.items(): + if door_openable: + return True + if ruleType == KeyRuleType.WorstCase: + door_openable |= state.has_sm_key(key_logic.small_key_name, player, number) + elif ruleType == KeyRuleType.AllowSmall: + if (door_rule.small_location.item and door_rule.small_location.item.name == key_logic.small_key_name + and door_rule.small_location.item.player == player): + return True # always okay if allow small is on + elif isinstance(ruleType, tuple): + lock, lock_item = ruleType + # this doesn't track logical locks yet, i.e. hammer locks the item and hammer is there, but the item isn't + for loc in door_rule.alternate_big_key_loc: + spot = state.world.get_location(loc, player) + if spot.item and spot.item.name == lock_item: + door_openable |= state.has_sm_key(key_logic.small_key_name, player, number) + break + return door_openable + + +def eval_small_key_door(door_name, dungeon, player): + return lambda state: eval_small_key_door_main(state, door_name, dungeon, player) + + +def allow_big_key_in_big_chest(bk_name, player): + return lambda state, item: item.name == bk_name and item.player == player + + def retro_in_hc(spot): return spot.parent_region.dungeon.name == 'Hyrule Castle' if spot.parent_region.dungeon else False diff --git a/Utils.py b/Utils.py index cf3db3fb..b8cf5497 100644 --- a/Utils.py +++ b/Utils.py @@ -1,10 +1,12 @@ #!/usr/bin/env python3 import os import re +import operator as op import subprocess import sys import xml.etree.ElementTree as ET from collections import defaultdict +from functools import reduce def int16_as_bytes(value): @@ -116,6 +118,28 @@ def make_new_base2current(old_rom='Zelda no Densetsu - Kamigami no Triforce (Jap return "New Rom Hash: " + basemd5.hexdigest() +def kth_combination(k, l, r): + if r == 0: + return [] + elif len(l) == r: + return l + else: + i = ncr(len(l)-1, r-1) + if k < i: + return l[0:1] + kth_combination(k, l[1:], r-1) + else: + return kth_combination(k-i, l[1:], r) + + +def ncr(n, r): + if r == 0: + return 1 + r = min(r, n-r) + numerator = reduce(op.mul, range(n, n-r, -1), 1) + denominator = reduce(op.mul, range(1, r+1), 1) + return numerator / denominator + + entrance_offsets = { 'Sanctuary': 0x2, 'HC West': 0x3, diff --git a/asm/doorrando.asm b/asm/doorrando.asm index 807095c7..8cceeba0 100644 --- a/asm/doorrando.asm +++ b/asm/doorrando.asm @@ -9,6 +9,7 @@ ; Normal doors use $FE to store the trap door indicator ; Normal doors use $045e to store Y coordinate when transitioning to in-room stairs ; Normal doors use $045f to determine the order in which supertile quadrants are drawn +; Straight stairs use $046d to store X coordinate on animation start ; Spiral doors use $045e to store stair type ; Gfx uses $b1 to for sub-sub-sub-module thing diff --git a/asm/doortables.asm b/asm/doortables.asm index 8ee0dd60..90678ca3 100644 --- a/asm/doortables.asm +++ b/asm/doortables.asm @@ -582,6 +582,8 @@ dw $00bc, $00a2, $00a3, $00c2, $001a, $0049, $0014, $008c dw $009f, $0066, $005d, $00a8, $00a9, $00aa, $00b9, $0052 ; HC West Hall, TR Dash Bridge, TR Hub, Pod Arena, GT Petting Zoo, Ice Spike Cross dw $0050, $00c5, $00c6, $0009, $0003, $002a, $007d, $005e +; Sewer Drop, Mire Cross, GT Crystal Circles +dw $0011, $00b2, $003d dw $ffff ; dungeon tables @@ -679,6 +681,8 @@ db $00,$07,$20,$20,$07,$07,$07,$07,$07,$20,$20,$07,$20,$20,$20,$20 db $07,$07,$02,$02,$02,$02,$07,$07,$07,$20,$20,$07,$20,$20,$20,$07 ;27f300 +DungeonTilesets: +db $04,$04,$05,$12,$04,$08,$07,$0C,$09,$0B,$05,$0A,$0D,$0E,$06,$06 ; ;org $27ff00 diff --git a/asm/drhooks.asm b/asm/drhooks.asm index 86ae1ff1..a5815d98 100644 --- a/asm/drhooks.asm +++ b/asm/drhooks.asm @@ -76,6 +76,9 @@ nop : jsl OverridePaletteHeader org $02817e ; Bank02.asm : 414 (LDA $02811E, X) jsl FixAnimatedTiles +org $0aef43 ; UnderworldMap_RecoverGFX +jsl FixCloseDungeonMap + org $028a06 ; Bank02.asm : 1941 Dungeon_ResetTorchBackgroundAndPlayer JSL FixWallmasterLamp @@ -125,6 +128,11 @@ org $07a955 ; <- Bank07.asm : around 6564 (JP is a bit different) (STZ $05FC : S jsl BlockEraseFix nop #2 +org $02A0A8 +Mirror_SaveRoomData: +org $07A95B ; < bank_07.asm ; #_07A95B: JSL Mirror_SaveRoomData +jsl EGFixOnMirror + org $02b82a jsl FixShopCode @@ -181,9 +189,9 @@ Main_ShowTextMessage: ; Conditionally disable UW music changes in Door Rando org $028ADB ; <- Bank02.asm:2088-2095 (LDX.b #$14 : LDA $A0 ...) -JSL.l Underworld_DoorDown_Entry : CPX #$10 -db $B0, $21 ; BCS $028B04 -BRA + : NOP #6 : + +JSL.l Underworld_DoorDown_Entry : CPX #$FF +BEQ + : db $80, $1C ; BRA $028B04 +NOP #6 : + org $02C3F2 ; <- Bank02.asm:10521 Unused call Underworld_DoorDown_Call: diff --git a/asm/gfx.asm b/asm/gfx.asm index b22fba62..94cb8848 100644 --- a/asm/gfx.asm +++ b/asm/gfx.asm @@ -45,6 +45,16 @@ FixAnimatedTiles: + LDA $02802E, X ; what we wrote over RTL +FixCloseDungeonMap: + LDA.l DRMode : CMP #$02 : BNE .vanilla + LDA $040C : BMI .vanilla + LSR : TAX + LDA.l DungeonTilesets,x + RTL + .vanilla + LDA $7EC20E + RTL + FixWallmasterLamp: ORA $0458 STY $1C : STA $1D : RTL ; what we wrote over diff --git a/asm/normal.asm b/asm/normal.asm index aabb24de..13323d88 100644 --- a/asm/normal.asm +++ b/asm/normal.asm @@ -73,8 +73,9 @@ TrapDoorFixer: rts Cleanup: - stz $047a - inc $11 + lda.l DRFlags : and #$10 : beq + + stz $047a + + inc $11 lda $ef rts @@ -149,15 +150,14 @@ LoadRoomVert: .notEdge lda $01 : and #$03 : cmp #$03 : bne .normal jsr ScrollToInroomStairs + stz $046d bra .end .normal ldy #$01 : jsr ShiftVariablesMainDir jsr PrepScrollToNormal .scroll - lda $01 : and #$40 : pha + lda $01 : and #$40 : sta $046d jsr ScrollX - pla : beq .end - ldy #$00 : jsr ApplyScroll .end plb ; restore db register rts @@ -290,6 +290,11 @@ StraightStairsAdj: stx $0464 : sty $012e ; what we wrote over lda.l DRMode : beq + lda $045e : bne .toInroom + lda $046d : beq .noScroll + sta $22 + ldy #$00 : jsr ApplyScroll + stz $046d + .noScroll jsr GetTileAttribute : tax lda $11 : cmp #$12 : beq .goingNorth lda $a2 : cmp #$51 : bne ++ @@ -337,9 +342,10 @@ db $d0, $f6, $10, $1a, $f0, $00 StraightStairsFix: { + pha lda.l DRMode : bne + - !add $20 : sta $20 ;what we wrote over - + rtl + pla : !add $20 : sta $20 : rtl ;what we wrote over + + pla : rtl } StraightStairLayerFix: diff --git a/asm/overrides.asm b/asm/overrides.asm index f9842866..91029a8a 100644 --- a/asm/overrides.asm +++ b/asm/overrides.asm @@ -35,11 +35,7 @@ rtl OnFileLoadOverride: jsl OnFileLoad ; what I wrote over - lda.l DRFlags : and #$80 : beq + ;flag is off - lda $7ef086 : ora #$80 : sta $7ef086 - + lda.l DRFlags : and #$40 : beq + ;flag is off - lda $7ef036 : ora #$80 : sta $7ef036 - + lda.l DRFlags : and #$02 : beq + + + lda.l DRFlags : and #$02 : beq + ; Mirror Scroll lda $7ef353 : bne + lda #$01 : sta $7ef353 + rtl @@ -51,6 +47,12 @@ MirrorCheckOverride: rtl + lda.l DRScroll : rtl +EGFixOnMirror: + lda.l DRFlags : and #$10 : beq + + stz $047a + + jsl Mirror_SaveRoomData + rtl + BlockEraseFix: lda $7ef353 : and #$02 : beq + stz $05fc : stz $05fd diff --git a/asm/scroll.asm b/asm/scroll.asm index f66918c8..435b5c98 100644 --- a/asm/scroll.asm +++ b/asm/scroll.asm @@ -168,7 +168,11 @@ ScrollX: ;change the X offset variables pla : sta $00 sep #$30 - lda $04 : sta $22 + lda $04 : ldx $046d : bne .straight + sta $22 : bra + + .straight + sta $046d ; set X position later + + lda $00 : sta $23 : sta $0609 : sta $060d lda $01 : sta $a9 lda $0e : asl : ora $ac : sta $ac diff --git a/data/base2current.bps b/data/base2current.bps index 5f411710..195cdbab 100644 Binary files a/data/base2current.bps and b/data/base2current.bps differ diff --git a/mystery_example.yml b/mystery_example.yml index e349063d..065fefc1 100644 --- a/mystery_example.yml +++ b/mystery_example.yml @@ -16,6 +16,9 @@ pot_shuffle: on: 1 off: 3 + bombbag: + on: 1 + off: 4 entrance_shuffle: none: 15 dungeonssimple: 3 @@ -132,3 +135,6 @@ half: 0 quarter: 1 off: 0 + shuffle_sfx: + on: 1 + off: 1 diff --git a/mystery_example_subweights.yml b/mystery_example_subweights.yml new file mode 100644 index 00000000..1ec43cac --- /dev/null +++ b/mystery_example_subweights.yml @@ -0,0 +1,57 @@ + description: Example for subweights + glitches_required: none + world_state: open + goals: ganon + weapons: randomized + entrance_shuffle: none + intensity: 3 + subweights: + vanilla: + chance: 25 + weights: + door_shuffle: vanilla + keydropshuffle: + on: 40 + off: 60 + basic: + chance: 25 + weights: + door_shuffle: basic + keydropshuffle: + on: 70 + off: 30 + crossed: + chance: 25 + weights: + door_shuffle: crossed + keydropshuffle: + on: 90 + off: 10 + chaos: + chance: 25 + weights: + door_shuffle: crossed + entrance_shuffle: + none: 30 + crossed: 70 + keydropshuffle: + on: 90 + off: 10 + shopsanity: + on: 50 + off: 50 + bombbag: + on: 25 + off: 75 + subweights: + normal: + chance: 40 + weights: {} + swordless: + chance: 20 + weights: + weapons: swordless + keysanity: + chance: 40 + weights: + dungeon_items: full diff --git a/resources/app/cli/args.json b/resources/app/cli/args.json index 47bb3987..a0113222 100644 --- a/resources/app/cli/args.json +++ b/resources/app/cli/args.json @@ -199,6 +199,10 @@ "action": "store_true", "type": "bool" }, + "shuffle_sfx": { + "action": "store_true", + "type": "bool" + }, "mapshuffle": { "action": "store_true", "type": "bool" @@ -220,6 +224,10 @@ "type": "bool", "help": "suppress" }, + "bombbag": { + "action": "store_true", + "type": "bool" + }, "retro": { "action": "store_true", "type": "bool" diff --git a/resources/app/cli/lang/en.json b/resources/app/cli/lang/en.json index ef1a8f3d..67efab1d 100644 --- a/resources/app/cli/lang/en.json +++ b/resources/app/cli/lang/en.json @@ -2,7 +2,7 @@ "cli": { "yes": "Yes", "no": "No", - "app.title": "ALttP Door Randomizer Version %s - Seed: %s, Code: %s", + "app.title": "ALttP Door Randomizer Version %s : --seed %s --code %s", "version": "Version", "seed": "Seed", "player": "Player", @@ -52,7 +52,8 @@ "enemizer.nothing.applied": "No Enemizer options will be applied until this is resolved.", "building.collection.spheres": "Building up collection spheres", "building.calculating.spheres": "Calculated sphere %i, containing %i of %i progress items.", - "building.final.spheres": "Calculated final sphere %i, containing %i of %i progress items." + "building.final.spheres": "Calculated final sphere %i, containing %i of %i progress items.", + "old.python.version": "Door Rando may have issues with python versions earlier than 3.7. Detected version: %s" }, "help": { "lang": [ "App Language, if available, defaults to English" ], @@ -263,6 +264,7 @@ "and a few other little things make this more like Zelda-1. (default: %(default)s)" ], "pseudoboots": [ " Players starts with pseudo boots that allow dashing but no item checks (default: %(default)s"], + "bombbag": ["Start with 0 bomb capacity. Two capacity upgrades (+10) are added to the pool (default: %(default)s)" ], "startinventory": [ "Specifies a list of items that will be in your starting inventory (separated by commas). (default: %(default)s)" ], "usestartinventory": [ "Toggle usage of Starting Inventory." ], "custom": [ "Not supported." ], @@ -295,6 +297,7 @@ "sprite that will be extracted." ], "reduce_flashing": [ "Reduce some in-game flashing (default: %(default)s)" ], + "shuffle_sfx": [ "Shuffle sounds effects (default: %(default)s)" ], "create_rom": [ "Create an output rom file. (default: %(default)s)" ], "gui": [ "Launch the GUI. (default: %(default)s)" ], "jsonout": [ diff --git a/resources/app/gui/adjust/overview/widgets.json b/resources/app/gui/adjust/overview/widgets.json index b61fff0e..85efcf1f 100644 --- a/resources/app/gui/adjust/overview/widgets.json +++ b/resources/app/gui/adjust/overview/widgets.json @@ -2,7 +2,8 @@ "checkboxes": { "nobgm": { "type": "checkbox" }, "quickswap": { "type": "checkbox" }, - "reduce_flashing": {"type": "checkbox"} + "reduce_flashing": {"type": "checkbox"}, + "shuffle_sfx": {"type": "checkbox"} }, "leftAdjustFrame": { "heartcolor": { diff --git a/resources/app/gui/lang/en.json b/resources/app/gui/lang/en.json index 0d9e3836..c4cd8a11 100644 --- a/resources/app/gui/lang/en.json +++ b/resources/app/gui/lang/en.json @@ -3,6 +3,7 @@ "adjust.nobgm": "Disable Music & MSU-1", "adjust.quickswap": "L/R Quickswapping", "adjust.reduce_flashing": "Reduce Flashing", + "adjust.shuffle_sfx": "Shuffle Sound Effects", "adjust.heartcolor": "Heart Color", "adjust.heartcolor.red": "Red", @@ -134,6 +135,7 @@ "randomizer.gameoptions.nobgm": "Disable Music & MSU-1", "randomizer.gameoptions.quickswap": "L/R Quickswapping", "randomizer.gameoptions.reduce_flashing": "Reduce Flashing", + "randomizer.gameoptions.shuffle_sfx": "Shuffle Sound Effects", "randomizer.gameoptions.heartcolor": "Heart Color", "randomizer.gameoptions.heartcolor.red": "Red", @@ -190,6 +192,7 @@ "randomizer.item.hints": "Include Helpful Hints", "randomizer.item.retro": "Retro mode (universal keys)", "randomizer.item.pseudoboots": "Start with Pseudo Boots", + "randomizer.item.bombbag": "Bombbag", "randomizer.item.worldstate": "World State", "randomizer.item.worldstate.standard": "Standard", diff --git a/resources/app/gui/randomize/gameoptions/widgets.json b/resources/app/gui/randomize/gameoptions/widgets.json index 63556e0f..6efe32c8 100644 --- a/resources/app/gui/randomize/gameoptions/widgets.json +++ b/resources/app/gui/randomize/gameoptions/widgets.json @@ -2,7 +2,8 @@ "checkboxes": { "nobgm": { "type": "checkbox" }, "quickswap": { "type": "checkbox" }, - "reduce_flashing": {"type": "checkbox"} + "reduce_flashing": {"type": "checkbox"}, + "shuffle_sfx": {"type": "checkbox"} }, "leftRomOptionsFrame": { "heartcolor": { diff --git a/resources/app/gui/randomize/item/widgets.json b/resources/app/gui/randomize/item/widgets.json index a6f10a14..038e668c 100644 --- a/resources/app/gui/randomize/item/widgets.json +++ b/resources/app/gui/randomize/item/widgets.json @@ -1,6 +1,7 @@ { "checkboxes": { "retro": { "type": "checkbox" }, + "bombbag": { "type": "checkbox" }, "shopsanity": { "type": "checkbox" }, "hints": { "type": "checkbox" diff --git a/source/classes/SFX.py b/source/classes/SFX.py new file mode 100644 index 00000000..ed6d93f6 --- /dev/null +++ b/source/classes/SFX.py @@ -0,0 +1,191 @@ +import random +from Utils import int16_as_bytes + + +class SFX(object): + + def __init__(self, name, sfx_set, orig_id, addr, chain, accomp=False): + self.name = name + self.sfx_set = sfx_set + self.orig_id = orig_id + self.addr = addr + self.chain = chain + self.accomp = accomp + + self.target_set = None + self.target_id = None + self.target_chain = None + + +def init_sfx_data(): + sfx_pool = [SFX('Slash1', 0x02, 0x01, 0x2614, []), SFX('Slash2', 0x02, 0x02, 0x2625, []), + SFX('Slash3', 0x02, 0x03, 0x2634, []), SFX('Slash4', 0x02, 0x04, 0x2643, []), + SFX('Wall clink', 0x02, 0x05, 0x25DD, []), SFX('Bombable door clink', 0x02, 0x06, 0x25D7, []), + SFX('Fwoosh shooting', 0x02, 0x07, 0x25B7, []), SFX('Arrow hitting wall', 0x02, 0x08, 0x25E3, []), + SFX('Boomerang whooshing', 0x02, 0x09, 0x25AD, []), SFX('Hookshot', 0x02, 0x0A, 0x25C7, []), + SFX('Placing bomb', 0x02, 0x0B, 0x2478, []), + SFX('Bomb exploding/Quake/Bombos/Exploding wall', 0x02, 0x0C, 0x269C, []), + SFX('Powder', 0x02, 0x0D, 0x2414, [0x3f]), SFX('Fire rod shot', 0x02, 0x0E, 0x2404, []), + SFX('Ice rod shot', 0x02, 0x0F, 0x24C3, []), SFX('Hammer use', 0x02, 0x10, 0x23FA, []), + SFX('Hammering peg', 0x02, 0x11, 0x23F0, []), SFX('Digging', 0x02, 0x12, 0x23CD, []), + SFX('Flute use', 0x02, 0x13, 0x23A0, [0x3e]), SFX('Cape on', 0x02, 0x14, 0x2380, []), + SFX('Cape off/Wallmaster grab', 0x02, 0x15, 0x2390, []), SFX('Staircase', 0x02, 0x16, 0x232C, []), + SFX('Staircase', 0x02, 0x17, 0x2344, []), SFX('Staircase', 0x02, 0x18, 0x2356, []), + SFX('Staircase', 0x02, 0x19, 0x236E, []), SFX('Tall grass/Hammer hitting bush', 0x02, 0x1A, 0x2316, []), + SFX('Mire shallow water', 0x02, 0x1B, 0x2307, []), SFX('Shallow water', 0x02, 0x1C, 0x2301, []), + SFX('Lifting object', 0x02, 0x1D, 0x22BB, []), SFX('Cutting grass', 0x02, 0x1E, 0x2577, []), + SFX('Item breaking', 0x02, 0x1F, 0x22E9, []), SFX('Item falling in pit', 0x02, 0x20, 0x22DA, []), + SFX('Bomb hitting ground/General bang', 0x02, 0x21, 0x22CF, []), + SFX('Pushing object/Armos bounce', 0x02, 0x22, 0x2107, []), SFX('Boots dust', 0x02, 0x23, 0x22B1, []), + SFX('Splashing', 0x02, 0x24, 0x22A5, [0x3d]), SFX('Mire shallow water again?', 0x02, 0x25, 0x2296, []), + SFX('Link taking damage', 0x02, 0x26, 0x2844, []), SFX('Fainting', 0x02, 0x27, 0x2252, []), + SFX('Item splash', 0x02, 0x28, 0x2287, []), SFX('Rupee refill', 0x02, 0x29, 0x243F, [0x3b]), + SFX('Fire rod shot hitting wall/Bombos spell', 0x02, 0x2A, 0x2033, []), + SFX('Heart beep/Text box', 0x02, 0x2B, 0x1FF2, []), SFX('Sword up', 0x02, 0x2C, 0x1FD9, [0x3a]), + SFX('Magic drain', 0x02, 0x2D, 0x20A6, []), SFX('GT opening', 0x02, 0x2E, 0x1FCA, [0x39]), + SFX('GT opening/Water drain', 0x02, 0x2F, 0x1F47, [0x38]), SFX('Cucco', 0x02, 0x30, 0x1EF1, []), + SFX('Fairy', 0x02, 0x31, 0x20CE, []), SFX('Bug net', 0x02, 0x32, 0x1D47, []), + SFX('Teleport2', 0x02, 0x33, 0x1CDC, [], True), SFX('Teleport1', 0x02, 0x34, 0x1F6F, [0x33]), + SFX('Quake/Vitreous/Zora king/Armos/Pyramid/Lanmo', 0x02, 0x35, 0x1C67, [0x36]), + SFX('Mire entrance (extends above)', 0x02, 0x36, 0x1C64, [], True), + SFX('Spin charged', 0x02, 0x37, 0x1A43, []), SFX('Water sound', 0x02, 0x38, 0x1F6F, [], True), + SFX('GT opening thunder', 0x02, 0x39, 0x1F9C, [], True), SFX('Sword up', 0x02, 0x3A, 0x1FE7, [], True), + SFX('Quiet rupees', 0x02, 0x3B, 0x2462, [], True), SFX('Error beep', 0x02, 0x3C, 0x1A37, []), + SFX('Big splash', 0x02, 0x3D, 0x22AB, [], True), SFX('Flute again', 0x02, 0x3E, 0x23B5, [], True), + SFX('Powder paired', 0x02, 0x3F, 0x2435, [], True), + + SFX('Sword beam', 0x03, 0x01, 0x1A18, []), + SFX('TR opening', 0x03, 0x02, 0x254E, []), SFX('Pyramid hole', 0x03, 0x03, 0x224A, []), + SFX('Angry soldier', 0x03, 0x04, 0x220E, []), SFX('Lynel shot/Javelin toss', 0x03, 0x05, 0x25B7, []), + SFX('BNC swing/Phantom ganon/Helma tail/Arrghus swoosh', 0x03, 0x06, 0x21F5, []), + SFX('Cannon fire', 0x03, 0x07, 0x223D, []), SFX('Damage to enemy; $0BEX.4=1', 0x03, 0x08, 0x21E6, []), + SFX('Enemy death', 0x03, 0x09, 0x21C1, []), SFX('Collecting rupee', 0x03, 0x0A, 0x21A9, []), + SFX('Collecting heart', 0x03, 0x0B, 0x2198, []), + SFX('Non-blank text character', 0x03, 0x0C, 0x218E, []), + SFX('HUD heart (used explicitly by sanc heart?)', 0x03, 0x0D, 0x21B5, []), + SFX('Opening chest', 0x03, 0x0E, 0x2182, []), + SFX('♪Do do do doooooo♫', 0x03, 0x0F, 0x24B9, [0x3C, 0x3D, 0x3E, 0x3F]), + SFX('Opening/Closing map (paired)', 0x03, 0x10, 0x216D, [0x3b]), + SFX('Opening item menu/Bomb shop guy breathing', 0x03, 0x11, 0x214F, []), + SFX('Closing item menu/Bomb shop guy breathing', 0x03, 0x12, 0x215E, []), + SFX('Throwing object (sprites use it as well)/Stalfos jump', 0x03, 0x13, 0x213B, []), + SFX('Key door/Trinecks/Dash key landing/Stalfos Knight collapse', 0x03, 0x14, 0x246C, []), + SFX('Door closing/OW door opening/Chest opening (w/ $29 in $012E)', 0x03, 0x15, 0x212F, []), + SFX('Armos Knight thud', 0x03, 0x16, 0x2123, []), SFX('Rat squeak', 0x03, 0x17, 0x25A6, []), + SFX('Dragging/Mantle moving', 0x03, 0x18, 0x20DD, []), + SFX('Fireball/Laser shot; Somehow used by Trinexx???', 0x03, 0x19, 0x250A, []), + SFX('Chest reveal jingle ', 0x03, 0x1A, 0x1E8A, [0x38]), + SFX('Puzzle jingle', 0x03, 0x1B, 0x20B6, [0x3a]), SFX('Damage to enemy', 0x03, 0x1C, 0x1A62, []), + SFX('Potion refill/Magic drain', 0x03, 0x1D, 0x20A6, []), + SFX('Flapping (Duck/Cucco swarm/Ganon bats/Keese/Raven/Vulture)', 0x03, 0x1E, 0x2091, []), + SFX('Link falling', 0x03, 0x1F, 0x204B, []), SFX('Menu/Text cursor moved', 0x03, 0x20, 0x276C, []), + SFX('Damage to boss', 0x03, 0x21, 0x27E2, []), SFX('Boss dying/Deleting file', 0x03, 0x22, 0x26CF, []), + SFX('Spin attack/Medallion swoosh', 0x03, 0x23, 0x2001, [0x39]), + SFX('OW map perspective change', 0x03, 0x24, 0x2043, []), + SFX('Pressure switch', 0x03, 0x25, 0x1E9D, []), + SFX('Lightning/Game over/Laser/Ganon bat/Trinexx lunge', 0x03, 0x26, 0x1E7B, []), + SFX('Agahnim charge', 0x03, 0x27, 0x1E40, []), SFX('Agahnim/Ganon teleport', 0x03, 0x28, 0x26F7, []), + SFX('Agahnim shot', 0x03, 0x29, 0x1E21, []), + SFX('Somaria/Byrna/Ether spell/Helma fire ball', 0x03, 0x2A, 0x1E12, []), + SFX('Electrocution', 0x03, 0x2B, 0x1DF3, []), SFX('Bees', 0x03, 0x2C, 0x1DC0, []), + SFX('Milestone, also via text', 0x03, 0x2D, 0x1DA9, [0x37]), + SFX('Collecting heart container', 0x03, 0x2E, 0x1D5D, [0x35, 0x34]), + SFX('Collecting absorbable key', 0x03, 0x2F, 0x1D80, [0x33]), + SFX('Byrna spark/Item plop/Magic bat zap/Blob emerge', 0x03, 0x30, 0x1B53, []), + SFX('Sprite falling/Moldorm shuffle', 0x03, 0x31, 0x1ACA, []), + SFX('Bumper boing/Somaria punt/Blob transmutation/Sprite boings', 0x03, 0x32, 0x1A78, []), + SFX('Jingle (paired $2F→$33)', 0x03, 0x33, 0x1D93, [], True), + SFX('Depressing jingle (paired $2E→$35→$34)', 0x03, 0x34, 0x1D66, [], True), + SFX('Ugly jingle (paired $2E→$35→$34)', 0x03, 0x35, 0x1D73, [], True), + SFX('Wizzrobe shot/Helma fireball split/Mothula beam/Blue balls', 0x03, 0x36, 0x1AA7, []), + SFX('Dinky jingle (paired $2D→$37)', 0x03, 0x37, 0x1DB4, [], True), + SFX('Apathetic jingle (paired $1A→$38)', 0x03, 0x38, 0x1E93, [], True), + SFX('Quiet swish (paired $23→$39)', 0x03, 0x39, 0x2017, [], True), + SFX('Defective jingle (paired $1B→$3A)', 0x03, 0x3A, 0x20C0, [], True), + SFX('Petulant jingle (paired $10→$3B)', 0x03, 0x3B, 0x2176, [], True), + SFX('Triumphant jingle (paired $0F→$3C→$3D→$3E→$3F)', 0x03, 0x3C, 0x248A, [], True), + SFX('Less triumphant jingle ($0F→$3C→$3D→$3E→$3F)', 0x03, 0x3D, 0x2494, [], True), + SFX('"You tried, I guess" jingle (paired $0F→$3C→$3D→$3E→$3F)', 0x03, 0x3E, 0x249E, [], True), + SFX('"You didn\'t really try" jingle (paired $0F→$3C→$3D→$3E→$3F)', 0x03, 0x3F, 0x2480, [], True)] + return sfx_pool + + +def shuffle_sfx_data(): + sfx_pool = init_sfx_data() + sfx_map = {2: {}, 3: {}} + accompaniment_map = {2: set(), 3: set()} + candidates = [] + for sfx in sfx_pool: + sfx_map[sfx.sfx_set][sfx.orig_id] = sfx + if not sfx.accomp: + candidates.append((sfx.sfx_set, sfx.orig_id)) + else: + accompaniment_map[sfx.sfx_set].add(sfx.orig_id) + chained_sfx = [x for x in sfx_pool if len(x.chain) > 0] + + random.shuffle(candidates) + + # place chained sfx first + random.shuffle(chained_sfx) # todo: sort largest to smallest + chained_sfx = sorted(chained_sfx, key=lambda x: len(x.chain), reverse=True) + for chained in chained_sfx: + chosen_slot = next(x for x in candidates if len(accompaniment_map[x[0]]) - len(chained.chain) >= 0) + if chosen_slot is None: + raise Exception('Something went wrong with sfx chains') + chosen_set, chosen_id = chosen_slot + chained.target_set, chained.target_id = chosen_slot + chained.target_chain = [] + for downstream in chained.chain: + next_slot = accompaniment_map[chosen_set].pop() + ds_acc = sfx_map[chained.sfx_set][downstream] + ds_acc.target_set, ds_acc.target_id = chosen_set, next_slot + chained.target_chain.append(next_slot) + candidates.remove(chosen_slot) + sfx_pool.remove(chained) + + unchained_sfx = [x for x in sfx_pool if not x.accomp] + # do the rest + for sfx in unchained_sfx: + chosen_slot = candidates.pop() + sfx.target_set, sfx.target_id = chosen_slot + + return sfx_map + + +sfx_table = { + 2: 0x1a8c29, + 3: 0x1A8D25 +} + +# 0x1a8c29 +# d8059 + +sfx_accompaniment_table = { + 2: 0x1A8CA7, + 3: 0x1A8DA3 +} + + +def randomize_sfx(rom): + sfx_map = shuffle_sfx_data() + + for shuffled_sfx in sfx_map.values(): + for sfx in shuffled_sfx.values(): + base_address = sfx_table[sfx.target_set] + rom.write_bytes(base_address + (sfx.target_id * 2) - 2, int16_as_bytes(sfx.addr)) + ac_base = sfx_accompaniment_table[sfx.target_set] + last = sfx.target_id + if sfx.target_chain: + for chained in sfx.target_chain: + rom.write_byte(ac_base + last - 1, chained) + last = chained + rom.write_byte(ac_base + last - 1, 0) + + + + + + + + + diff --git a/source/classes/constants.py b/source/classes/constants.py index 04cbde2e..b184643b 100644 --- a/source/classes/constants.py +++ b/source/classes/constants.py @@ -57,6 +57,7 @@ SETTINGSTOPROCESS = { "item": { "hints": "hints", "retro": "retro", + "bombbag": "bombbag", "shopsanity": "shopsanity", "pseudoboots": "pseudoboots", "worldstate": "mode", @@ -107,7 +108,8 @@ SETTINGSTOPROCESS = { "menuspeed": "fastmenu", "owpalettes": "ow_palettes", "uwpalettes": "uw_palettes", - "reduce_flashing": "reduce_flashing" + "reduce_flashing": "reduce_flashing", + "shuffle_sfx": "shuffle_sfx", }, "generation": { "createspoiler": "create_spoiler", diff --git a/source/gui/adjust/overview.py b/source/gui/adjust/overview.py index 4ae57e2e..7e16b1a9 100644 --- a/source/gui/adjust/overview.py +++ b/source/gui/adjust/overview.py @@ -103,6 +103,7 @@ def adjust_page(top, parent, settings): "quickswap": "quickswap", "nobgm": "disablemusic", "reduce_flashing": "reduce_flashing", + "shuffle_sfx": "shuffle_sfx", } guiargs = Namespace() for option in options: