diff --git a/BaseClasses.py b/BaseClasses.py index 099d4d53..25aebbb1 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -96,6 +96,7 @@ class World(object): self.rooms = [] self._room_cache = {} self.dungeon_layouts = {} + self.dungeon_pool = {} self.inaccessible_regions = {} self.enabled_entrances = {} self.key_logic = {} @@ -164,6 +165,7 @@ class World(object): set_player_attr('colorizepots', True) set_player_attr('pot_pool', {}) set_player_attr('decoupledoors', False) + set_player_attr('door_self_loops', False) set_player_attr('door_type_mode', 'original') set_player_attr('trap_door_mode', 'optional') set_player_attr('key_logic_algorithm', 'default') @@ -347,7 +349,7 @@ class World(object): else: if self.shuffle[player] not in ['vanilla', 'dungeonssimple', 'dungeonsfull']: return False - elif self.goal[player] in ['crystals', 'trinity']: + elif self.goal[player] in ['crystals', 'trinity', 'ganonhunt']: return True else: return False @@ -2023,6 +2025,7 @@ class Door(object): self.dest = None self.blocked = False # Indicates if the door is normally blocked off as an exit. (Sanc door or always closed) self.blocked_orig = False + self.trapped = False self.stonewall = False # Indicate that the door cannot be enter until exited (Desert Torches, PoD Eye Statue) self.smallKey = False # There's a small key door on this side self.bigKey = False # There's a big key door on this side @@ -2133,7 +2136,7 @@ class Door(object): return self def no_exit(self): - self.blocked = self.blocked_orig = True + self.blocked = self.blocked_orig = self.trapped = True return self def no_entrance(self): @@ -2869,6 +2872,7 @@ class Spoiler(object): 'trap_door_mode': self.world.trap_door_mode, 'key_logic': self.world.key_logic_algorithm, 'decoupledoors': self.world.decoupledoors, + 'door_self_loops': self.world.door_self_loops, 'dungeon_counters': self.world.dungeon_counters, 'item_pool': self.world.difficulty, 'item_functionality': self.world.difficulty_adjustments, @@ -2898,6 +2902,7 @@ class Spoiler(object): 'triforcegoal': self.world.treasure_hunt_count, 'triforcepool': self.world.treasure_hunt_total, 'race': self.world.settings.world_rep['meta']['race'], + 'user_notes': self.world.settings.world_rep['meta']['user_notes'], 'code': {p: Settings.make_code(self.world, p) for p in range(1, self.world.players + 1)}, 'seed': self.world.seed } @@ -3047,6 +3052,8 @@ class Spoiler(object): outfile.write('ALttP Overworld Randomizer - Seed: %s\n\n' % (self.world.seed)) for k,v in self.metadata["versions"].items(): outfile.write((k + ' Version:').ljust(line_width) + '%s\n' % v) + if self.metadata['user_notes']: + outfile.write('User Notes:'.ljust(line_width) + '%s\n' % self.metadata['user_notes']) outfile.write('Filling Algorithm:'.ljust(line_width) + '%s\n' % self.world.algorithm) outfile.write('Players:'.ljust(line_width) + '%d\n' % self.world.players) outfile.write('Teams:'.ljust(line_width) + '%d\n' % self.world.teams) @@ -3112,6 +3119,7 @@ class Spoiler(object): outfile.write('Door Type Mode:'.ljust(line_width) + '%s\n' % self.metadata['door_type_mode'][player]) outfile.write('Trap Door Mode:'.ljust(line_width) + '%s\n' % self.metadata['trap_door_mode'][player]) outfile.write('Decouple Doors:'.ljust(line_width) + '%s\n' % yn(self.metadata['decoupledoors'][player])) + outfile.write('Spiral Stairs Self-Loop:'.ljust(line_width) + '%s\n' % yn(self.metadata['door_self_loops'][player])) outfile.write('Experimental:'.ljust(line_width) + '%s\n' % yn(self.metadata['experimental'][player])) outfile.write('Dungeon Counters:'.ljust(line_width) + '%s\n' % self.metadata['dungeon_counters'][player]) outfile.write('\n') @@ -3393,7 +3401,7 @@ class Pot(object): # byte 0: DDDE EEEE (DR, ER) -dr_mode = {"basic": 1, "crossed": 2, "vanilla": 0, "partitioned": 3} +dr_mode = {"basic": 1, "crossed": 2, "vanilla": 0, "partitioned": 3, 'paired': 4} er_mode = {"vanilla": 0, "simple": 1, "restricted": 2, "full": 3, "crossed": 4, "insanity": 5, 'lite': 8, 'lean': 9, "dungeonsfull": 7, "dungeonssimple": 6, "swapped": 10} @@ -3418,10 +3426,10 @@ mixed_travel_mode = {"prevent": 0, "allow": 1, "force": 2} pottery_mode = {'none': 0, 'keys': 2, 'lottery': 3, 'dungeon': 4, 'cave': 5, 'cavekeys': 6, 'reduced': 7, 'clustered': 8, 'nonempty': 9} -# byte 5: CCCC CTTX (crystals gt, ctr2, experimental) +# byte 5: SCCC CTTX (self-loop doors, crystals gt, ctr2, experimental) counter_mode = {"default": 0, "off": 1, "on": 2, "pickup": 3} -# byte 6: CCCC CPAA (crystals ganon, pyramid, access +# byte 6: ?CCC CPAA (crystals ganon, pyramid, access access_mode = {"items": 0, "locations": 1, "none": 2} # byte 7: B?MC DDEE (big, ?, maps, compass, door_type, enemies) @@ -3450,7 +3458,7 @@ flutespot_mode = {"vanilla": 0, "balanced": 1, "random": 2} # byte 13: FBBB TTSS (flute_mode, bow_mode, take_any, small_key_mode) flute_mode = {'normal': 0, 'active': 1} -keyshuffle_mode = {'none': 0, 'wild': 1, 'universal': 2} # reserved 8 modes? +keyshuffle_mode = {'none': 0, 'off': 0, 'wild': 1, 'on': 1, 'universal': 2} # reserved 8 modes? take_any_mode = {'none': 0, 'random': 1, 'fixed': 2} bow_mode = {'progressive': 0, 'silvers': 1, 'retro': 2, 'retro_silvers': 3} @@ -3486,7 +3494,8 @@ class Settings(object): (0x80 if w.shuffletavern[p] else 0) | (0x10 if w.dropshuffle[p] else 0) | (pottery_mode[w.pottery[p]]), - ((8 if w.crystals_gt_orig[p] == "random" else int(w.crystals_gt_orig[p])) << 3) + (0x80 if w.door_self_loops[p] else 0) + | ((8 if w.crystals_gt_orig[p] == "random" else int(w.crystals_gt_orig[p])) << 3) | (counter_mode[w.dungeon_counters[p]] << 1) | (1 if w.experimental[p] else 0), ((8 if w.crystals_ganon_orig[p] == "random" else int(w.crystals_ganon_orig[p])) << 3) @@ -3512,8 +3521,8 @@ class Settings(object): (flute_mode[w.flute_mode[p]] << 7 | bow_mode[w.bow_mode[p]] << 4 | take_any_mode[w.take_any[p]] << 2 | keyshuffle_mode[w.keyshuffle[p]]), - ((0x80 if w.pseudoboots[p] else 0) | overworld_map_mode[w.overworld_map[p]] << 6 - | trap_door_mode[w.trap_door_mode[p]] << 4 | key_logic_algo[w.key_logic_algorithm[p]]), + ((0x80 if w.pseudoboots[p] else 0) | overworld_map_mode[w.overworld_map[p]] << 5 + | trap_door_mode[w.trap_door_mode[p]] << 3 | key_logic_algo[w.key_logic_algorithm[p]]), ]) return base64.b64encode(code, "+-".encode()).decode() @@ -3551,12 +3560,13 @@ class Settings(object): args.dropshuffle[p] = True if settings[4] & 0x10 else False args.pottery[p] = r(pottery_mode)[settings[4] & 0x0F] + args.door_self_loops[p] = True if settings[5] & 0x80 else False args.dungeon_counters[p] = r(counter_mode)[(settings[5] & 0x6) >> 1] - cgt = (settings[5] & 0xf8) >> 3 + cgt = (settings[5] & 0x78) >> 3 args.crystals_gt[p] = "random" if cgt == 8 else cgt args.experimental[p] = True if settings[5] & 0x1 else False - cgan = (settings[6] & 0xf8) >> 3 + cgan = (settings[6] & 0x78) >> 3 args.crystals_ganon[p] = "random" if cgan == 8 else cgan args.openpyramid[p] = True if settings[6] & 0x4 else False @@ -3596,8 +3606,8 @@ class Settings(object): if len(settings) > 14: args.pseudoboots[p] = True if settings[14] & 0x80 else False - args.overworld_map[p] = r(overworld_map_mode)[(settings[14] & 0x60) >> 6] - args.trap_door_mode[p] = r(trap_door_mode)[(settings[14] & 0x14) >> 4] + args.overworld_map[p] = r(overworld_map_mode)[(settings[14] & 0x60) >> 5] + args.trap_door_mode[p] = r(trap_door_mode)[(settings[14] & 0x18) >> 3] args.key_logic_algorithm[p] = r(key_logic_algo)[settings[14] & 0x07] diff --git a/CHANGELOG.md b/CHANGELOG.md index b156a0dd..1f235164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.3.2.1 +- \~Merged in DR v1.2.0.20~ +- Some minor Swapped ER improvements +- Fixed generation error with Flute Activation + ## 0.3.2.0 - New Swapped ER mode option - Fixed issue with flipper rules not properly getting pearl requirement added diff --git a/CLI.py b/CLI.py index d9ff24f3..72fc7a48 100644 --- a/CLI.py +++ b/CLI.py @@ -134,7 +134,7 @@ def parse_cli(argv, no_defaults=False): 'shuffle', 'door_shuffle', 'intensity', 'crystals_ganon', 'crystals_gt', 'openpyramid', 'mapshuffle', 'compassshuffle', 'keyshuffle', 'bigkeyshuffle', 'startinventory', 'usestartinventory', 'bombbag', 'shuffleganon', 'overworld_map', 'restrict_boss_items', - 'triforce_pool_min', 'triforce_pool_max', 'triforce_goal_min', 'triforce_goal_max', + 'triforce_pool_min', 'triforce_pool_max', 'triforce_goal_min', 'triforce_goal_max', 'triforce_max_difference', 'triforce_min_difference', 'triforce_goal', 'triforce_pool', 'shufflelinks', 'shuffletavern', 'pseudoboots', 'retro', 'accessibility', 'hints', 'beemizer', 'experimental', 'dungeon_counters', 'shufflebosses', 'shuffleenemies', 'enemy_health', 'enemy_damage', 'shufflepots', @@ -142,7 +142,7 @@ def parse_cli(argv, no_defaults=False): 'heartbeep', 'remote_items', 'shopsanity', 'dropshuffle', 'pottery', 'keydropshuffle', 'mixed_travel', 'standardize_palettes', 'code', 'reduce_flashing', 'shuffle_sfx', 'msu_resume', 'collection_rate', 'colorizepots', 'decoupledoors', 'door_type_mode', - 'bonk_drops', 'trap_door_mode', 'key_logic_algorithm']: + 'bonk_drops', 'trap_door_mode', 'key_logic_algorithm', 'door_self_loops']: value = getattr(defaults, name) if getattr(playerargs, name) is None else getattr(playerargs, name) if player == 1: setattr(ret, name, {1: value}) @@ -227,6 +227,7 @@ def parse_settings(): "trap_door_mode": "optional", "key_logic_algorithm": "default", "decoupledoors": False, + "door_self_loops": False, "experimental": False, "dungeon_counters": "default", "mixed_travel": "prevent", @@ -239,6 +240,7 @@ def parse_settings(): "triforce_goal_min": 0, "triforce_goal_max": 0, "triforce_min_difference": 0, + "triforce_max_difference": 10000, "code": "", "multi": 1, @@ -354,7 +356,8 @@ def parse_settings(): "outputpath": os.path.join("."), "saveonexit": "ask", "outputname": "", - "startinventoryarray": {} + "startinventoryarray": {}, + "notes": "" } if sys.platform.lower().find("windows"): diff --git a/DoorShuffle.py b/DoorShuffle.py index 86410ed4..5b73aa5e 100644 --- a/DoorShuffle.py +++ b/DoorShuffle.py @@ -88,7 +88,8 @@ def link_doors_prep(world, player): find_inaccessible_regions(world, player) - if world.intensity[player] >= 3 and world.doorShuffle[player] != 'vanilla': + create_dungeon_pool(world, player) + if world.intensity[player] >= 3 and world.doorShuffle[player] != 'vanilla': choose_portals(world, player) else: if world.shuffle[player] == 'vanilla': @@ -133,6 +134,20 @@ def create_dungeon_pool(world, player): pool = None if world.doorShuffle[player] == 'basic': pool = [([name], regions) for name, regions in dungeon_regions.items()] + elif world.doorShuffle[player] == 'paired': + dungeon_pool = list(dungeon_regions.keys()) + groups = [] + while dungeon_pool: + if len(dungeon_pool) == 3: + groups.append(list(dungeon_pool)) + dungeon_pool.clear() + else: + choice_a = random.choice(dungeon_pool) + dungeon_pool.remove(choice_a) + choice_b = random.choice(dungeon_pool) + dungeon_pool.remove(choice_b) + groups.append([choice_a, choice_b]) + pool = [(group, list(chain.from_iterable([dungeon_regions[d] for d in group]))) for group in groups] elif world.doorShuffle[player] == 'partitioned': groups = [['Hyrule Castle', 'Eastern Palace', 'Desert Palace', 'Tower of Hera', 'Agahnims Tower'], ['Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town'], @@ -143,38 +158,17 @@ def create_dungeon_pool(world, player): elif world.doorShuffle[player] != 'vanilla': logging.getLogger('').error('Invalid door shuffle setting: %s' % world.doorShuffle[player]) raise Exception('Invalid door shuffle setting: %s' % world.doorShuffle[player]) - return pool + world.dungeon_pool[player] = pool def link_doors_main(world, player): - pool = create_dungeon_pool(world, player) + pool = world.dungeon_pool[player] if pool: main_dungeon_pool(pool, world, player) if world.doorShuffle[player] != 'vanilla': create_door_spoiler(world, player) -# todo: I think this function is not necessary -def mark_regions(world, player): - # traverse dungeons and make sure dungeon property is assigned - player_dungeons = [dungeon for dungeon in world.dungeons if dungeon.player == player] - for dungeon in player_dungeons: - queue = deque(dungeon.regions) - while len(queue) > 0: - region = world.get_region(queue.popleft(), player) - if region.name not in dungeon.regions: - dungeon.regions.append(region.name) - region.dungeon = dungeon - for ext in region.exits: - d = world.check_for_door(ext.name, player) - connected = ext.connected_region - if d is not None and connected is not None: - if d.dest is not None and connected.name not in dungeon.regions and connected.type == RegionType.Dungeon and connected.name not in queue: - queue.append(connected) # needs to be added - elif connected is not None and connected.name not in dungeon.regions and connected.type == RegionType.Dungeon and connected.name not in queue: - queue.append(connected) # needs to be added - - def create_door_spoiler(world, player): logger = logging.getLogger('') shuffled_door_types = [DoorType.Normal, DoorType.SpiralStairs] @@ -438,17 +432,7 @@ def pair_existing_key_doors(world, player, door_a, door_b): def choose_portals(world, player): if world.doorShuffle[player] != ['vanilla']: shuffle_flag = world.doorShuffle[player] != 'basic' - allowed = {} - if world.doorShuffle[player] == 'basic': - allowed = {name: {name} for name in dungeon_regions} - elif world.doorShuffle[player] == 'partitioned': - groups = [['Hyrule Castle', 'Eastern Palace', 'Desert Palace', 'Tower of Hera', 'Agahnims Tower'], - ['Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town'], - ['Ice Palace', 'Misery Mire', 'Turtle Rock', 'Ganons Tower']] - allowed = {name: set(group) for group in groups for name in group} - elif world.doorShuffle[player] == 'crossed': - all_dungeons = set(dungeon_regions.keys()) - allowed = {name: all_dungeons for name in dungeon_regions} + allowed = {name: set(group[0]) for group in world.dungeon_pool[player] for name in group[0]} # key drops allow the big key in the right place in Desert Tiles 2 bk_shuffle = world.bigkeyshuffle[player] or world.pottery[player] not in ['none', 'cave'] @@ -593,7 +577,7 @@ def customizer_portals(master_door_list, world, player): assigned_doors.add(door) # restricts connected doors to the customized portals if assigned_doors: - pool = create_dungeon_pool(world, player) + pool = world.dungeon_pool[player] if pool: pool_map = {} for pool, region_list in pool: @@ -1860,12 +1844,12 @@ def shuffle_trap_doors(door_type_pools, paths, start_regions_map, all_custom, wo builder.candidates.trap = filter_key_door_pool(builder.candidates.trap, all_custom[dungeon]) remaining -= len(custom_trap_doors[dungeon]) ttl += len(builder.candidates.trap) - if ttl == 0: + if ttl == 0 and all(len(custom_trap_doors[dungeon]) == 0 for dungeon in pool): continue for dungeon in pool: builder = world.dungeon_layouts[player][dungeon] proportion = len(builder.candidates.trap) - calc = int(round(proportion * door_type_pool.traps/ttl)) + calc = 0 if ttl == 0 else int(round(proportion * door_type_pool.traps/ttl)) suggested = min(proportion, calc) remaining -= suggested suggestion_map[dungeon] = suggested @@ -1963,7 +1947,7 @@ def shuffle_big_key_doors(door_type_pools, used_doors, start_regions_map, all_cu if flex_map[dungeon] > 0: queue.append(dungeon) # time to re-assign - reassign_big_key_doors(bk_map, world, player) + reassign_big_key_doors(bk_map, used_doors, world, player) for name, big_list in bk_map.items(): used_doors.update(flatten_pair_list(big_list)) return used_doors @@ -1997,7 +1981,10 @@ def shuffle_small_key_doors(door_type_pools, used_doors, start_regions_map, all_ remaining = max(0, remaining) for dungeon in pool: builder = world.dungeon_layouts[player][dungeon] - calculated = int(round(builder.key_doors_num*total_keys/ttl)) + if ttl == 0: + calculated = 0 + else: + calculated = int(round(builder.key_doors_num*total_keys/ttl)) max_keys = max(0, builder.location_cnt - calc_used_dungeon_items(builder, world, player)) cand_len = max(0, len(builder.candidates.small) - builder.key_drop_cnt) limit = min(max_keys, cand_len, max_computation) @@ -2048,7 +2035,7 @@ def shuffle_small_key_doors(door_type_pools, used_doors, start_regions_map, all_ else: builder.key_doors_num -= 1 # time to re-assign - reassign_key_doors(small_map, world, player) + reassign_key_doors(small_map, used_doors, world, player) for dungeon_name in pool: if world.keyshuffle[player] != 'universal': builder = world.dungeon_layouts[player][dungeon_name] @@ -2130,7 +2117,7 @@ def shuffle_bomb_dash_doors(door_type_pools, used_doors, start_regions_map, all_ suggestion_map[dungeon] = pair queue.append(dungeon) # time to re-assign - reassign_bd_doors(bd_map, world, player) + reassign_bd_doors(bd_map, used_doors, world, player) for name, pair in bd_map.items(): used_doors.update(flatten_pair_list(pair[0])) used_doors.update(flatten_pair_list(pair[1])) @@ -2227,9 +2214,10 @@ def find_valid_trap_combination(builder, suggested, start_regions, paths, world, sample_list = build_sample_list(combinations, 1000) proposal = kth_combination(sample_list[itr], trap_door_pool, trap_doors_needed) proposal.extend(custom_trap_doors) + filtered_proposal = [x for x in proposal if x.name not in trap_door_exceptions] start_regions, event_starts = filter_start_regions(builder, start_regions, world, player) - while not validate_trap_layout(proposal, builder, start_regions, paths, world, player): + while not validate_trap_layout(filtered_proposal, builder, start_regions, paths, world, player): itr += 1 if itr >= len(sample_list): if not drop: @@ -2264,6 +2252,12 @@ def filter_start_regions(builder, start_regions, world, player): portal_entrance_region = portal.door.entrance.parent_region.name if portal_entrance_region not in builder.path_entrances: excluded[region] = None + if not portal: + drop_region = next((x.parent_region for x in region.entrances + if x.parent_region.type in [RegionType.LightWorld, RegionType.DarkWorld] + or x.parent_region.name == 'Sewer Drop'), None) + if drop_region and drop_region.name in world.inaccessible_regions[player]: + excluded[region] = None if std_flag and (not portal or portal.find_portal_entrance().parent_region.name != 'Hyrule Castle Courtyard'): excluded[region] = None if portal is None: @@ -2359,10 +2353,12 @@ def reassign_trap_doors(trap_map, world, player): elif kind in [DoorKind.Trap2, DoorKind.TrapTriggerable]: room.change(d.doorListPos, DoorKind.Normal) d.blocked = False + d.trapped = False # connect_one_way(world, d.name, d.dest.name, player) elif d.type is DoorType.Normal and d not in traps: world.get_room(d.roomIndex, player).change(d.doorListPos, DoorKind.Normal) d.blocked = False + d.trapped = False for d in traps: change_door_to_trap(d, world, player) world.spoiler.set_door_type(f'{d.name} ({d.dungeon_name()})', 'Trap Door', player) @@ -2400,24 +2396,45 @@ def change_door_to_trap(d, world, player): elif d.direction in [Direction.North, Direction.West]: new_kind = DoorKind.TrapTriggerable if new_kind: - d.blocked = True + d.blocked = is_trap_door_blocked(d) + d.trapped = True pos = 3 if d.type == DoorType.Normal else 4 verify_door_list_pos(d, room, world, player, pos) d.trapFlag = {0: 0x4, 1: 0x2, 2: 0x1, 3: 0x8}[d.doorListPos] room.change(d.doorListPos, new_kind) - if d.entrance.connected_region is not None: + if d.entrance.connected_region is not None and d.blocked: d.entrance.connected_region.entrances.remove(d.entrance) d.entrance.connected_region = None elif d.type is DoorType.Normal: - d.blocked = True + d.blocked = is_trap_door_blocked(d) + d.trapped = True verify_door_list_pos(d, room, world, player, pos=3) d.trapFlag = {0: 0x4, 1: 0x2, 2: 0x1}[d.doorListPos] room.change(d.doorListPos, DoorKind.Trap) - if d.entrance.connected_region is not None: + if d.entrance.connected_region is not None and d.blocked: d.entrance.connected_region.entrances.remove(d.entrance) d.entrance.connected_region = None +trap_door_exceptions = { + 'PoD Mimics 2 SW', 'TR Twin Pokeys NW', 'Thieves Blocked Entry SW', 'Hyrule Dungeon Armory Interior Key Door N', + 'Desert Compass Key Door WN', 'TR Tile Room SE', 'Mire Cross SW', 'Tower Circle of Pots ES', + 'Eastern Single Eyegore ES', 'Eastern Duo Eyegores SE', 'Swamp Push Statue S', + 'Skull 2 East Lobby WS', 'GT Hope Room WN', 'Eastern Courtyard Ledge S', 'Ice Lobby SE', 'GT Speed Torch WN', + 'Ice Switch Room ES', 'Ice Switch Room NE', 'Skull Torch Room WS', 'GT Speed Torch NE', 'GT Speed Torch WS', + 'GT Torch Cross WN', 'Mire Tile Room SW', 'Mire Tile Room ES', 'TR Torches WN', 'PoD Lobby N', 'PoD Middle Cage S', + 'Ice Bomb Jump NW', 'GT Hidden Spikes SE', 'Ice Tall Hint EN', 'GT Conveyor Cross EN', 'Eastern Pot Switch WN', + 'Thieves Conveyor Maze WN', 'Thieves Conveyor Maze SW', 'Eastern Dark Square Key Door WN', 'Eastern Lobby NW', + 'Eastern Lobby NE', 'Ice Cross Bottom SE', 'Desert Back Lobby S', 'Desert West S', + 'Desert West Lobby ES', 'Mire Hidden Shooters SE', 'Mire Hidden Shooters ES', 'Mire Hidden Shooters WS', + 'Tower Dark Pits EN', 'Tower Dark Maze ES', 'TR Tongue Pull WS', +} + + +def is_trap_door_blocked(door): + return door.name not in trap_door_exceptions + + def find_big_key_candidates(builder, start_regions, used, world, player): if world.door_type_mode[player] != 'original': # big, all, chaos # traverse dungeon and find candidates @@ -2540,7 +2557,7 @@ def find_current_bk_doors(builder): return current_doors -def reassign_big_key_doors(bk_map, world, player): +def reassign_big_key_doors(bk_map, used_doors, world, player): logger = logging.getLogger('') for name, big_doors in bk_map.items(): flat_proposal = flatten_pair_list(big_doors) @@ -2549,11 +2566,11 @@ def reassign_big_key_doors(bk_map, world, player): while len(queue) > 0: d = queue.pop() if d.type is DoorType.Interior and d not in flat_proposal and d.dest not in flat_proposal: - if not d.entranceFlag: + if not d.entranceFlag and d not in used_doors and d.dest not in used_doors: world.get_room(d.roomIndex, player).change(d.doorListPos, DoorKind.Normal) d.bigKey = False - elif d.type is DoorType.Normal and d not in flat_proposal: - if not d.entranceFlag: + elif d.type is DoorType.Normal and d not in flat_proposal : + if not d.entranceFlag and d not in used_doors: world.get_room(d.roomIndex, player).change(d.doorListPos, DoorKind.Normal) d.bigKey = False for obj in big_doors: @@ -2796,7 +2813,7 @@ def find_valid_bd_combination(builder, suggested, world, player): return bomb_proposal, dash_proposal, ttl_needed -def reassign_bd_doors(bd_map, world, player): +def reassign_bd_doors(bd_map, used_doors, world, player): for name, pair in bd_map.items(): flat_bomb_proposal = flatten_pair_list(pair[0]) flat_dash_proposal = flatten_pair_list(pair[1]) @@ -2809,10 +2826,10 @@ def reassign_bd_doors(bd_map, world, player): queue = deque(find_current_bd_doors(builder, world)) while len(queue) > 0: d = queue.pop() - if d.type is DoorType.Interior and not_in_proposal(d): + if d.type is DoorType.Interior and not_in_proposal(d) and d not in used_doors and d.dest not in used_doors: if not d.entranceFlag: world.get_room(d.roomIndex, player).change(d.doorListPos, DoorKind.Normal) - elif d.type is DoorType.Normal and not_in_proposal(d): + elif d.type is DoorType.Normal and not_in_proposal(d) and d not in used_doors: if not d.entranceFlag: world.get_room(d.roomIndex, player).change(d.doorListPos, DoorKind.Normal) do_bombable_dashable(pair[0], DoorKind.Bombable, world, player) @@ -3004,7 +3021,7 @@ def valid_key_door_pair(door1, door2): return len(door1.entrance.parent_region.exits) <= 1 or len(door2.entrance.parent_region.exits) <= 1 -def reassign_key_doors(small_map, world, player): +def reassign_key_doors(small_map, used_doors, world, player): logger = logging.getLogger('') for name, small_doors in small_map.items(): logger.debug(f'Key doors for {name}') @@ -3025,13 +3042,13 @@ def reassign_key_doors(small_map, world, player): room.delete(d.doorListPos) d.smallKey = False elif d.type is DoorType.Interior and d not in flat_proposal and d.dest not in flat_proposal: - if not d.entranceFlag: + if not d.entranceFlag and d not in used_doors and d.dest not in used_doors: world.get_room(d.roomIndex, player).change(d.doorListPos, DoorKind.Normal) d.smallKey = False d.dest.smallKey = False queue.remove(d.dest) elif d.type is DoorType.Normal and d not in flat_proposal: - if not d.entranceFlag: + if not d.entranceFlag and d not in used_doors: world.get_room(d.roomIndex, player).change(d.doorListPos, DoorKind.Normal) d.smallKey = False for dp in world.paired_doors[player]: diff --git a/DungeonGenerator.py b/DungeonGenerator.py index 3e67fa67..5f34fbf2 100644 --- a/DungeonGenerator.py +++ b/DungeonGenerator.py @@ -1677,10 +1677,24 @@ def assign_location_sectors_minimal(dungeon_map, free_location_sectors, global_p random.shuffle(sector_list) orig_location_set = build_orig_location_set(dungeon_map) num_dungeon_items = requested_dungeon_items(world, player) + locations_to_distribute = sum(sector.chest_locations for sector in free_location_sectors.keys()) + reserved_per_dungeon = {d_name: count_reserved_locations(world, player, orig_location_set[d_name]) + for d_name in dungeon_map.keys()} + base_free, found_enough = 2, False + while not found_enough: + needed = sum(max(0, max(base_free, reserved_per_dungeon[d]) + num_dungeon_items - len(orig_location_set[d])) + for d in dungeon_map.keys()) + if needed > locations_to_distribute: + if base_free == 0: + raise Exception('Unable to meet minimum requirements, check for customizer problems') + base_free -= 1 + else: + found_enough = True d_idx = {builder.name: i for i, builder in enumerate(dungeon_map.values())} next_sector = sector_list.pop() while not valid: - choice, totals, location_set = weighted_random_location(dungeon_map, choices, orig_location_set, world, player) + choice, totals, location_set = weighted_random_location(dungeon_map, choices, orig_location_set, + base_free, world, player) if not choice: break choices[choice].append(next_sector) @@ -1691,7 +1705,7 @@ def assign_location_sectors_minimal(dungeon_map, free_location_sectors, global_p valid = True for d_name, idx in d_idx.items(): free_items = count_reserved_locations(world, player, location_set[d_name]) - target = max(free_items, 2) + num_dungeon_items + target = max(free_items, base_free) + num_dungeon_items if totals[idx] < target: valid = False break @@ -1699,8 +1713,7 @@ def assign_location_sectors_minimal(dungeon_map, free_location_sectors, global_p if len(sector_list) == 0: choices = defaultdict(list) sector_list = list(free_location_sectors) - else: - next_sector = sector_list.pop() + next_sector = sector_list.pop() else: choices[choice].remove(next_sector) for builder, choice_list in choices.items(): @@ -1709,7 +1722,7 @@ def assign_location_sectors_minimal(dungeon_map, free_location_sectors, global_p return free_location_sectors -def weighted_random_location(dungeon_map, choices, orig_location_set, world, player): +def weighted_random_location(dungeon_map, choices, orig_location_set, base_free, world, player): population = [] totals = [] location_set = {x: set(y) for x, y in orig_location_set.items()} @@ -1720,7 +1733,7 @@ def weighted_random_location(dungeon_map, choices, orig_location_set, world, pla builder_set = location_set[dungeon_builder.name] builder_set.update(set().union(*(s.chest_location_set for s in choices[dungeon_builder]))) free_items = count_reserved_locations(world, player, builder_set) - target = max(free_items, 2) + num_dungeon_items + target = max(free_items, base_free) + num_dungeon_items if ttl < target: population.append(dungeon_builder) choice = random.choice(population) if len(population) > 0 else None @@ -1775,7 +1788,7 @@ def count_reserved_locations(world, player, proposed_set): return 2 -def assign_crystal_switch_sectors(dungeon_map, crystal_switches, crystal_barriers, global_pole, assign_one=False): +def assign_crystal_switch_sectors(dungeon_map, crystal_switches, crystal_barriers, global_pole): population = [] some_c_switches_present = False for name, builder in dungeon_map.items(): @@ -1784,7 +1797,7 @@ def assign_crystal_switch_sectors(dungeon_map, crystal_switches, crystal_barrier if builder.c_switch_present and not builder.c_locked: some_c_switches_present = True if len(population) == 0: # nothing needs a switch - if assign_one and not some_c_switches_present: # something should have one + if len(crystal_barriers) > 0 and not some_c_switches_present: # something should have one if len(crystal_switches) == 0: raise GenerationException('No crystal switches to assign. Ref %s' % next(iter(dungeon_map.keys()))) valid, builder_choice, switch_choice = False, None, None @@ -3139,8 +3152,7 @@ def balance_split(candidate_sectors, dungeon_map, global_pole, builder_info): check_for_forced_assignments(dungeon_map, candidate_sectors, global_pole) check_for_forced_crystal(dungeon_map, candidate_sectors, global_pole) crystal_switches, crystal_barriers, neutral_sectors, polarized_sectors = categorize_sectors(candidate_sectors) - leftover = assign_crystal_switch_sectors(dungeon_map, crystal_switches, crystal_barriers, - global_pole, len(crystal_barriers) > 0) + 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) for sector in leftover: if sector.polarity().is_neutral(): diff --git a/ItemList.py b/ItemList.py index bb23ecec..c6462f62 100644 --- a/ItemList.py +++ b/ItemList.py @@ -212,7 +212,8 @@ def generate_itempool(world, player): loc.locked = True loc.forced_item = loc.item - if not world.is_tile_swapped(0x18, player): + if (world.flute_mode[player] != 'active' and not world.is_tile_swapped(0x18, player) + and 'Ocarina (Activated)' not in list(map(str, [i for i in world.precollected_items if i.player == player]))): region = world.get_region('Kakariko Village',player) loc = Location(player, "Flute Activation", parent=region) @@ -1361,19 +1362,6 @@ def make_custom_item_pool(world, player, progressive, shuffle, difficulty, timer return (pool, placed_items, precollected_items, clock_mode, treasure_hunt_count, treasure_hunt_total, treasure_hunt_icon, lamps_needed_for_dark_rooms) -def set_default_triforce(goal, custom_goal, custom_total): - triforce_goal, triforce_total = 0, 0 - if goal == 'triforcehunt': - triforce_goal, triforce_total = 20, 30 - elif goal == 'trinity': - triforce_goal, triforce_total = 8, 10 - if custom_goal > 0: - triforce_goal = max(min(custom_goal, 128), 1) - if custom_total > 0 or custom_goal > 0: - triforce_total = max(min(custom_total, 128), triforce_goal) #128 max to ensure other progression can fit. - return (triforce_goal, triforce_total) - - def make_customizer_pool(world, player): pool = [] placed_items = {} @@ -1486,7 +1474,8 @@ def make_customizer_pool(world, player): bow_found = next((i for i in pool if i in {'Bow', 'Progressive Bow'}), None) if not bow_found: missing_items.append('Progressive Bow') - logging.getLogger('').warning(f'The following items are not in the custom item pool {", ".join(missing_items)}') + if missing_items: + logging.getLogger('').warning(f'The following items are not in the custom item pool {", ".join(missing_items)}') g, t = set_default_triforce(world.goal[player], world.treasure_hunt_count[player], world.treasure_hunt_total[player]) @@ -1495,20 +1484,23 @@ def make_customizer_pool(world, player): if pieces < t: pool.extend(['Triforce Piece'] * (t - pieces)) - if not world.customizer.get_start_inventory(): - if world.logic[player] in ['owglitches', 'nologic']: - precollected_items.append('Pegasus Boots') - if 'Pegasus Boots' in pool: - pool.remove('Pegasus Boots') - pool.append('Rupees (20)') - if world.swords[player] == 'assured': - precollected_items.append('Progressive Sword') - if 'Progressive Sword' in pool: - pool.remove('Progressive Sword') - pool.append('Rupees (50)') - elif 'Fighter Sword' in pool: - pool.remove('Fighter Sword') - pool.append('Rupees (50)') + sphere_0 = world.customizer.get_start_inventory() + no_start_inventory = not sphere_0 or not sphere_0[player] + init_equip = [] if no_start_inventory else sphere_0[player] + if (world.logic[player] in ['owglitches', 'nologic'] + and (no_start_inventory or all(x != 'Pegasus Boots' for x in init_equip))): + precollected_items.append('Pegasus Boots') + if 'Pegasus Boots' in pool: + pool.remove('Pegasus Boots') + pool.append('Rupees (20)') + if world.swords[player] == 'assured' and (no_start_inventory or all(' Sword' not in x for x in init_equip)): + precollected_items.append('Progressive Sword') + if 'Progressive Sword' in pool: + pool.remove('Progressive Sword') + pool.append('Rupees (50)') + elif 'Fighter Sword' in pool: + pool.remove('Fighter Sword') + pool.append('Rupees (50)') return pool, placed_items, precollected_items, clock_mode, 1 @@ -1577,10 +1569,13 @@ def fill_specific_items(world): dungeon_pool, prize_set, prize_pool) if item_to_place: world.push_item(loc, item_to_place, False) + loc.locked = True track_outside_keys(item_to_place, loc, world) track_dungeon_items(item_to_place, loc, world) loc.event = (event_flag or item_to_place.advancement or item_to_place.bigkey or item_to_place.smallkey) + else: + raise Exception(f'Did not find "{item}" in item pool to place at "{location}"') advanced_placements = world.customizer.get_advanced_placements() if advanced_placements: for player, placement_list in advanced_placements.items(): @@ -1590,7 +1585,7 @@ def fill_specific_items(world): item_to_place, event_flag = get_item_and_event_flag(item, world, player, dungeon_pool, prize_set, prize_pool) if not item_to_place: - continue + raise Exception(f'Did not find "{item}" in item pool to place for a LocationGroup"') locations = placement['locations'] handled = False while not handled: @@ -1610,6 +1605,7 @@ def fill_specific_items(world): if loc.item: continue world.push_item(loc, item_to_place, False) + loc.locked = True track_outside_keys(item_to_place, loc, world) track_dungeon_items(item_to_place, loc, world) loc.event = (event_flag or item_to_place.advancement diff --git a/Main.py b/Main.py index c426cef7..d7542d88 100644 --- a/Main.py +++ b/Main.py @@ -36,7 +36,7 @@ from source.overworld.EntranceShuffle2 import link_entrances_new from source.tools.BPS import create_bps_from_data from source.classes.CustomSettings import CustomSettings -version_number = '1.2.0.17' +version_number = '1.2.0.20' version_branch = '-u' __version__ = f'{version_number}{version_branch}' @@ -122,6 +122,7 @@ def main(args, seed=None, fish=None): world.trap_door_mode = args.trap_door_mode.copy() world.key_logic_algorithm = args.key_logic_algorithm.copy() world.decoupledoors = args.decoupledoors.copy() + world.door_self_loops = args.door_self_loops.copy() world.experimental = args.experimental.copy() world.dungeon_counters = args.dungeon_counters.copy() world.fish = fish @@ -131,8 +132,6 @@ def main(args, seed=None, fish=None): world.potshuffle = args.shufflepots.copy() world.mixed_travel = args.mixed_travel.copy() world.standardize_palettes = args.standardize_palettes.copy() - world.treasure_hunt_count = {k: int(v) for k, v in args.triforce_goal.items()} - world.treasure_hunt_total = {k: int(v) for k, v in args.triforce_pool.items()} world.shufflelinks = args.shufflelinks.copy() world.shuffletavern = args.shuffletavern.copy() world.pseudoboots = args.pseudoboots.copy() @@ -142,6 +141,26 @@ def main(args, seed=None, fish=None): world.collection_rate = args.collection_rate.copy() world.colorizepots = args.colorizepots.copy() + world.treasure_hunt_count = {} + world.treasure_hunt_total = {} + for p in args.triforce_goal: + if int(args.triforce_goal[p]) != 0 or int(args.triforce_pool[p]) != 0 or int(args.triforce_goal_min[p]) != 0 or int(args.triforce_goal_max[p]) != 0 or int(args.triforce_pool_min[p]) != 0 or int(args.triforce_pool_max[p]) != 0: + if int(args.triforce_goal[p]) != 0: + world.treasure_hunt_count[p] = int(args.triforce_goal[p]) + elif int(args.triforce_goal_min[p]) != 0 and int(args.triforce_goal_max[p]) != 0: + world.treasure_hunt_count[p] = random.randint(int(args.triforce_goal_min[p]), int(args.triforce_goal_max[p])) + else: + world.treasure_hunt_count[p] = 8 if world.goal[p] == 'trinity' else 20 + if int(args.triforce_pool[p]) != 0: + world.treasure_hunt_total[p] = int(args.triforce_pool[p]) + elif int(args.triforce_pool_min[p]) != 0 and int(args.triforce_pool_max[p]) != 0: + world.treasure_hunt_total[p] = random.randint(max(int(args.triforce_pool_min[p]), world.treasure_hunt_count[p] + int(args.triforce_min_difference[p])), min(int(args.triforce_pool_max[p]), world.treasure_hunt_count[p] + int(args.triforce_max_difference[p]))) + else: + world.treasure_hunt_total[p] = 10 if world.goal[p] == 'trinity' else 30 + else: + # this will be handled in ItemList.py and custom item pool is used to determine the numbers + world.treasure_hunt_count[p], world.treasure_hunt_total[p] = 0, 0 + world.rom_seeds = {player: random.randint(0, 999999999) for player in range(1, world.players + 1)} world.finish_init() @@ -165,7 +184,7 @@ def main(args, seed=None, fish=None): world.player_names[player].append(name) logger.info('') world.settings = CustomSettings() - world.settings.create_from_world(world, args.race) + world.settings.create_from_world(world, args) outfilebase = f'OR_{args.outputname if args.outputname else world.seed}' @@ -501,6 +520,7 @@ def copy_world(world): ret.beemizer = world.beemizer.copy() ret.intensity = world.intensity.copy() ret.decoupledoors = world.decoupledoors.copy() + ret.door_self_loops = world.door_self_loops.copy() ret.experimental = world.experimental.copy() ret.shopsanity = world.shopsanity.copy() ret.dropshuffle = world.dropshuffle.copy() @@ -814,7 +834,9 @@ def create_playthrough(world): logging.getLogger('').debug(world.fish.translate("cli", "cli", "building.calculating.spheres"), len(collection_spheres), len(sphere), len(prog_locations)) if not sphere: - logging.getLogger('').error(world.fish.translate("cli", "cli", "cannot.reach.items"), [world.fish.translate("cli","cli","cannot.reach.item") % (location.item.name, location.item.player, location.name, location.player) for location in sphere_candidates]) + if world.accessibility[location.item.player] != 'none': + logging.getLogger('').error(world.fish.translate("cli", "cli", "cannot.reach.items"), + [world.fish.translate("cli","cli","cannot.reach.item") % (location.item.name, location.item.player, location.name, location.player) for location in sphere_candidates]) if any([location.name not in optional_locations and world.accessibility[location.item.player] != 'none' for location in sphere_candidates]): raise RuntimeError(world.fish.translate("cli", "cli", "cannot.reach.progression")) else: @@ -877,6 +899,7 @@ def create_playthrough(world): if world.has_beaten_game(state): required_locations.clear() else: + logging.getLogger('').error(world.fish.translate("cli", "cli", "cannot.reach.items"), [world.fish.translate("cli","cli","cannot.reach.item") % (loc.item.name, loc.item.player, loc.name, loc.player) for loc in required_locations]) raise RuntimeError(world.fish.translate("cli","cli","cannot.reach.required")) # store the required locations for statistical analysis diff --git a/MultiClient.py b/MultiClient.py index 5b8ebd30..78fd0f5f 100644 --- a/MultiClient.py +++ b/MultiClient.py @@ -66,6 +66,8 @@ class Context: self.lookup_name_to_id = {} self.lookup_id_to_name = {} + self.pottery_locations_enabled = None + def color_code(*args): codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34, 'magenta': 35, 'cyan': 36, 'white': 37 , 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43, @@ -96,6 +98,8 @@ SHOP_SRAM_START = WRAM_START + 0x0164B8 # 2 bytes? ITEM_SRAM_SIZE = 0x250 SHOP_SRAM_LEN = 0x29 # 41 tracked items +POT_LOCATION_TABLE = 0x142A60 + RECV_PROGRESS_ADDR = SAVEDATA_START + 0x4D0 # 2 bytes RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte @@ -827,12 +831,14 @@ def get_location_name_from_address(ctx, address): def filter_location(ctx, location): + if location in location_table_pot_items: + tile_idx, mask = location_table_pot_items[location] + tracking_data = ctx.pottery_locations_enabled + tile_pots = tracking_data[tile_idx] | (tracking_data[tile_idx+1] << 8) + return (mask & tile_pots) == 0 if (not ctx.key_drop_mode and location in PotShuffle.key_drop_data and PotShuffle.key_drop_data[location][0] == 'Drop'): return True - if (not ctx.pottery_mode and location in PotShuffle.key_drop_data - and PotShuffle.key_drop_data[location][0] == 'Pot'): - return True if not ctx.shop_mode and location in Regions.flat_normal_shops: return True if not ctx.retro_mode and location in Regions.flat_retro_shops: @@ -1006,6 +1012,9 @@ async def game_watcher(ctx : Context): logging.warning("ROM change detected, please reconnect to the multiworld server") await disconnect(ctx) + if ctx.pottery_locations_enabled is None: + ctx.pottery_locations_enabled = await snes_read(ctx, POT_LOCATION_TABLE, 0x250) + gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) if gamemode is None or gamemode[0] not in INGAME_MODES: continue diff --git a/OverworldShuffle.py b/OverworldShuffle.py index a76e4b33..f260efb2 100644 --- a/OverworldShuffle.py +++ b/OverworldShuffle.py @@ -7,7 +7,7 @@ from OWEdges import OWTileRegions, OWEdgeGroups, OWEdgeGroupsTerrain, OWExitType from OverworldGlitchRules import create_owg_connections from Utils import bidict -version_number = '0.3.2.0' +version_number = '0.3.2.1' # branch indicator is intentionally different across branches version_branch = '' diff --git a/PotShuffle.py b/PotShuffle.py index 5378943e..76d5733c 100644 --- a/PotShuffle.py +++ b/PotShuffle.py @@ -1022,7 +1022,7 @@ key_drop_data = { 'Ice Palace - Jelly Key Drop': ['Drop', (0x09DA21, 0xE, 3), 'dropped in Ice Palace', 'Small Key (Ice Palace)'], 'Ice Palace - Conveyor Key Drop': ['Drop', (0x09DE08, 0x3E, 8), 'dropped in Ice Palace', 'Small Key (Ice Palace)'], 'Ice Palace - Hammer Block Key Drop': ['Pot', 0x3F, 'under a block in Ice Palace', 'Small Key (Ice Palace)'], - 'Ice Palace - Many Pots Pot Key': ['Pot', 0x9F, 'int a pot in Ice Palace', 'Small Key (Ice Palace)'], + 'Ice Palace - Many Pots Pot Key': ['Pot', 0x9F, 'in a pot in Ice Palace', 'Small Key (Ice Palace)'], 'Misery Mire - Spikes Pot Key': ['Pot', 0xB3, 'in a pot in Misery Mire', 'Small Key (Misery Mire)'], 'Misery Mire - Fishbone Pot Key': ['Pot', 0xA1, 'in a pot in forgotten Mire', 'Small Key (Misery Mire)'], 'Misery Mire - Conveyor Crystal Key Drop': ['Drop', (0x09E7FB, 0xC1, 9), 'dropped in Misery Mire', 'Small Key (Misery Mire)'], diff --git a/RELEASENOTES.md b/RELEASENOTES.md index e3d09589..336c604e 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -57,7 +57,7 @@ Please see [Customizer documentation](docs/Customizer.md) on how to create custo ## New Goals -### Triforce Hunt + Ganon +### Ganonhunt Collect the requisite triforce pieces, then defeat Ganon. (Aga2 not required). Use `ganonhunt` on CLI ### Completionist @@ -109,6 +109,31 @@ These are now independent of retro mode and have three options: None, Random, an # Bug Fixes and Notes +* 1.2.0.20u + * New generation feature that allows Spiral Stair to link to themselves (thank Catobat) + * Added logic for trap doors that could be opened using existing room triggers + * Fixed a problem with inverted generation and the experimental flag + * Added a notes field for user added notes either via CLI or Customizer (thanks Hiimcody and Codemann) + * Fixed a typo for a specific pot hint + * Fix for Hera Boss music (thanks Codemann) +* 1.1.6 (from Stable) + * Minor issue with dungeon counter hud interfering with timer +* 1.2.0.19u + * Added min/max for triforce pool, goal, and difference for CLI and Customizer. (Thanks Catobat) + * Fixed a bug with dungeon generation + * Multiworld: Fixed /missing command to not list all the pots + * Changed the "Ganonhunt" goal to use open pyramid on the Auto setting + * Customizer: Fixed the example yaml for shopsanity +* 1.2.0.18u + * Fixed an issue with pyramid hole being in logic when it is not opened. + * Crystal cutscene at GT use new symmetrical layouts (thanks Codemann) + * Fix for Hera Boss music (thanks Codemann) + * Fixed an issue where certain vanilla door types would not allow other types to be placed. + * Customizer: fixed an issue where last ditch placements would move customized items. Those are now locked and the generation will fail instead if no alternatives are found. + * Customizer: fixed an issue with assured sword and start_inventory + * Customizer: warns when trying to specifically place an item that's not in the item pool + * Fixed "accessibility: none" displaying a spoiling message + * Fixed warning message about custom item pool when it is fine * 1.2.0.17u * Fixed logic bug that allowed Pearl to be behind Graveyard Cave or King's Tomb entrances with only Mirror and West Dark World access (cross world shuffles only) * Removed backup locations for Dungeon Only and Major Only algorithms. If item cannot be placed in the appropriate location, the seed will fail to generate instead diff --git a/Rom.py b/Rom.py index a19ff73c..b57b1564 100644 --- a/Rom.py +++ b/Rom.py @@ -38,7 +38,7 @@ from source.dungeon.RoomList import Room0127 JAP10HASH = '03a63945398191337e896e5771f77173' -RANDOMIZERBASEHASH = '32f6a9f479f6ccb7e66e9906ff0d0e4c' +RANDOMIZERBASEHASH = 'da111397d4118054e5ab4b9375cfb9e4' class JsonRom(object): diff --git a/Rules.py b/Rules.py index f1383ee6..4cdd6e61 100644 --- a/Rules.py +++ b/Rules.py @@ -69,7 +69,8 @@ def set_rules(world, player): elif world.goal[player] == 'completionist': add_rule(world.get_location('Ganon', player), lambda state: state.everything(player)) - if not world.is_tile_swapped(0x18, player): + if (world.flute_mode[player] != 'active' and not world.is_tile_swapped(0x18, player) + and 'Ocarina (Activated)' not in list(map(str, [i for i in world.precollected_items if i.player == player]))): if not world.is_copied_world: # Commented out below, this would be needed for rando implementations where Inverted requires flute activation in bunny territory # kak_region = self.world.get_region('Kakariko Village', player) @@ -382,11 +383,18 @@ def global_rules(world, player): # Start of door rando rules # TODO: Do these need to flag off when door rando is off? - some of them, yes + def is_trapped(entrance): + return world.get_entrance(entrance, player).door.trapped + # Eastern Palace # Eyegore room needs a bow set_rule(world.get_entrance('Eastern Duo Eyegores NE', player), lambda state: state.can_shoot_arrows(player)) set_rule(world.get_entrance('Eastern Single Eyegore NE', player), lambda state: state.can_shoot_arrows(player)) set_rule(world.get_entrance('Eastern Map Balcony Hook Path', player), lambda state: state.has('Hookshot', player)) + if is_trapped('Eastern Single Eyegore ES'): + set_rule(world.get_entrance('Eastern Single Eyegore ES', player), lambda state: state.can_shoot_arrows(player)) + if is_trapped('Eastern Duo Eyegores SE'): + set_rule(world.get_entrance('Eastern Duo Eyegores SE', player), lambda state: state.can_shoot_arrows(player)) # Boss rules. Same as below but no BK or arrow requirement. set_defeat_dungeon_boss_rule(world.get_location('Eastern Palace - Prize', player)) @@ -411,13 +419,18 @@ def global_rules(world, player): set_rule(world.get_entrance('Tower Red Spears WN', player), lambda state: state.can_kill_most_things(player)) set_rule(world.get_entrance('Tower Red Guards EN', player), lambda state: state.can_kill_most_things(player)) set_rule(world.get_entrance('Tower Red Guards SW', player), lambda state: state.can_kill_most_things(player)) + set_rule(world.get_entrance('Tower Circle of Pots NW', player), lambda state: state.can_kill_most_things(player)) + if is_trapped('Tower Circle of Pots ES'): + set_rule(world.get_entrance('Tower Circle of Pots ES', player), + lambda state: state.can_kill_most_things(player)) set_rule(world.get_entrance('Tower Altar NW', player), lambda state: state.has_sword(player)) set_defeat_dungeon_boss_rule(world.get_location('Agahnim 1', player)) - set_rule(world.get_entrance('PoD Arena Landing Bonk Path', player), lambda state: state.has_Boots(player)) set_rule(world.get_entrance('PoD Mimics 1 NW', player), lambda state: state.can_shoot_arrows(player)) set_rule(world.get_entrance('PoD Mimics 2 NW', player), lambda state: state.can_shoot_arrows(player)) + if is_trapped('PoD Mimics 2 SW'): + set_rule(world.get_entrance('PoD Mimics 2 SW', player), lambda state: state.can_shoot_arrows(player)) set_rule(world.get_entrance('PoD Bow Statue Down Ladder', player), lambda state: state.can_shoot_arrows(player)) set_rule(world.get_entrance('PoD Map Balcony Drop Down', player), lambda state: state.has('Hammer', player)) set_rule(world.get_entrance('PoD Dark Pegs Landing to Right', player), lambda state: state.has('Hammer', player)) @@ -466,6 +479,8 @@ def global_rules(world, player): set_rule(world.get_entrance('Skull Big Chest Hookpath', player), lambda state: state.has('Hookshot', player)) set_rule(world.get_entrance('Skull Torch Room WN', player), lambda state: state.has('Fire Rod', player)) + if is_trapped('Skull Torch Room WS'): + set_rule(world.get_entrance('Skull Torch Room WS', player), lambda state: state.has('Fire Rod', player)) set_rule(world.get_entrance('Skull Vines NW', player), lambda state: state.has_sword(player)) hidden_pits_door = world.get_door('Skull Small Hall WS', player) @@ -503,6 +518,8 @@ def global_rules(world, player): set_rule(world.get_location('Thieves\' Town - Prize', player), lambda state: state.has('Maiden Unmasked', player) and world.get_location('Thieves\' Town - Prize', player).parent_region.dungeon.boss.can_defeat(state)) set_rule(world.get_entrance('Ice Lobby WS', player), lambda state: state.can_melt_things(player)) + if is_trapped('Ice Lobby SE'): + set_rule(world.get_entrance('Ice Lobby SE', player), lambda state: state.can_melt_things(player)) set_rule(world.get_entrance('Ice Hammer Block ES', player), lambda state: state.can_lift_rocks(player) and state.has('Hammer', player)) set_rule(world.get_location('Ice Palace - Hammer Block Key Drop', player), lambda state: state.can_lift_rocks(player) and state.has('Hammer', player)) set_rule(world.get_location('Ice Palace - Map Chest', player), lambda state: state.can_lift_rocks(player) and state.has('Hammer', player)) @@ -517,6 +534,12 @@ def global_rules(world, player): set_rule(world.get_entrance('Ice Hookshot Balcony Path', player), lambda state: state.has('Hookshot', player)) if not world.get_door('Ice Switch Room SE', player).entranceFlag: set_rule(world.get_entrance('Ice Switch Room SE', player), lambda state: state.has('Cane of Somaria', player) or state.has('Convenient Block', player)) + if is_trapped('Ice Switch Room ES'): + set_rule(world.get_entrance('Ice Switch Room ES', player), + lambda state: state.has('Cane of Somaria', player) or state.has('Convenient Block', player)) + if is_trapped('Ice Switch Room NE'): + set_rule(world.get_entrance('Ice Switch Room NE', player), + lambda state: state.has('Cane of Somaria', player) or state.has('Convenient Block', player)) set_defeat_dungeon_boss_rule(world.get_location('Ice Palace - Boss', player)) set_defeat_dungeon_boss_rule(world.get_location('Ice Palace - Prize', player)) @@ -537,8 +560,15 @@ def global_rules(world, player): 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)) + if is_trapped('Mire Tile Room SW'): + set_rule(world.get_entrance('Mire Tile Room SW', player), lambda state: state.has_fire_source(player)) + if is_trapped('Mire Tile Room ES'): + set_rule(world.get_entrance('Mire Tile Room ES', player), lambda state: state.has_fire_source(player)) set_rule(world.get_entrance('Mire Attic Hint Hole', player), lambda state: state.has_fire_source(player)) set_rule(world.get_entrance('Mire Dark Shooters SW', player), lambda state: state.has('Cane of Somaria', player)) + if is_trapped('Mire Dark Shooters SE'): + set_rule(world.get_entrance('Mire Dark Shooters SE', player), + lambda state: state.has('Cane of Somaria', player)) set_defeat_dungeon_boss_rule(world.get_location('Misery Mire - Boss', player)) set_defeat_dungeon_boss_rule(world.get_location('Misery Mire - Prize', player)) @@ -554,6 +584,9 @@ def global_rules(world, player): set_rule(world.get_entrance('TR Hub Path', player), lambda state: state.has('Cane of Somaria', player)) set_rule(world.get_entrance('TR Hub Ledges Path', player), lambda state: state.has('Cane of Somaria', player)) set_rule(world.get_entrance('TR Torches NW', player), lambda state: state.has('Cane of Somaria', player) and state.has('Fire Rod', player)) + if is_trapped('TR Torches WN'): + set_rule(world.get_entrance('TR Torches WN', player), + lambda state: state.has('Cane of Somaria', player) and state.has('Fire Rod', player)) set_rule(world.get_entrance('TR Big Chest Entrance Gap', player), lambda state: state.has('Cane of Somaria', player) or state.has('Hookshot', player)) set_rule(world.get_entrance('TR Big Chest Gap', player), lambda state: state.has('Cane of Somaria', player) or state.has_Boots(player)) set_rule(world.get_entrance('TR Dark Ride Up Stairs', player), lambda state: state.has('Cane of Somaria', player)) @@ -573,10 +606,20 @@ def global_rules(world, player): set_rule(world.get_location('Ganons Tower - Bob\'s Torch', player), lambda state: state.has_Boots(player)) set_rule(world.get_entrance('GT Hope Room EN', player), lambda state: state.has('Cane of Somaria', player)) + if is_trapped('GT Hope Room WN'): + set_rule(world.get_entrance('GT Hope Room WN', player), lambda state: state.has('Cane of Somaria', player)) set_rule(world.get_entrance('GT Conveyor Cross Hammer Path', player), lambda state: state.has('Hammer', player)) set_rule(world.get_entrance('GT Conveyor Cross Hookshot Path', player), lambda state: state.has('Hookshot', player)) + if is_trapped('GT Conveyor Cross EN'): + set_rule(world.get_entrance('GT Conveyor Cross EN', player), lambda state: state.has('Hammer', player)) if not world.get_door('GT Speed Torch SE', player).entranceFlag: set_rule(world.get_entrance('GT Speed Torch SE', player), lambda state: state.has('Fire Rod', player)) + if is_trapped('GT Speed Torch NE'): + set_rule(world.get_entrance('GT Speed Torch NE', player), lambda state: state.has('Fire Rod', player)) + if is_trapped('GT Speed Torch WS'): + set_rule(world.get_entrance('GT Speed Torch WS', player), lambda state: state.has('Fire Rod', player)) + if is_trapped('GT Speed Torch WN'): + set_rule(world.get_entrance('GT Speed Torch WN', player), lambda state: state.has('Fire Rod', player)) set_rule(world.get_entrance('GT Hookshot South-Mid Path', player), lambda state: state.has('Hookshot', player)) set_rule(world.get_entrance('GT Hookshot Mid-North Path', player), lambda state: state.has('Hookshot', player)) set_rule(world.get_entrance('GT Hookshot East-Mid Path', player), lambda state: state.has('Hookshot', player) or state.has_Boots(player)) @@ -611,6 +654,8 @@ def global_rules(world, player): set_rule(world.get_entrance('GT Lanmolas 2 ES', player), lambda state: world.get_region('GT Lanmolas 2', player).dungeon.bosses['middle'].can_defeat(state)) set_rule(world.get_entrance('GT Lanmolas 2 NW', player), lambda state: world.get_region('GT Lanmolas 2', player).dungeon.bosses['middle'].can_defeat(state)) set_rule(world.get_entrance('GT Torch Cross ES', player), lambda state: state.has_fire_source(player)) + if is_trapped('GT Torch Cross WN'): + set_rule(world.get_entrance('GT Torch Cross WN', player), lambda state: state.has_fire_source(player)) set_rule(world.get_entrance('GT Falling Torches NE', player), lambda state: state.has_fire_source(player)) # todo: the following only applies to crystal state propagation from this supertile # you can also reset the supertile, but I'm not sure how to model that @@ -870,13 +915,29 @@ def bomb_rules(world, player): ('GT Petting Zoo SE', False), # Dont make anyone do this room with bombs and/or pots. ('GT DMs Room SW', False) # Four red stalfos ] + conditional_kill_traps = [ + ('Hyrule Dungeon Armory Interior Key Door N', True), + ('Desert Compass Key Door WN', True), + ('Thieves Blocked Entry SW', True), + ('TR Tongue Pull WS', True), + ('TR Twin Pokeys NW', False), + ] for killdoor,bombable in easy_kill_rooms: if bombable: add_rule(world.get_entrance(killdoor, player), lambda state: (state.can_use_bombs(player) or state.can_kill_most_things(player))) else: add_rule(world.get_entrance(killdoor, player), lambda state: state.can_kill_most_things(player)) + for kill_door, bombable in conditional_kill_traps: + if world.get_entrance(kill_door, player).door.trapped: + if bombable: + add_rule(world.get_entrance(kill_door, player), + lambda state: (state.can_use_bombs(player) or state.can_kill_most_things(player))) + else: + add_rule(world.get_entrance(kill_door, player), lambda state: state.can_kill_most_things(player)) add_rule(world.get_entrance('Ice Stalfos Hint SE', player), lambda state: state.can_use_bombs(player)) # Need bombs for big stalfos knights - add_rule(world.get_entrance('Mire Cross ES', player), lambda state: state.can_kill_most_things(player)) # 4 Sluggulas. Bombs don't work // or (state.can_use_bombs(player) and state.has('Magic Powder'), player) + add_rule(world.get_entrance('Mire Cross ES', player), lambda state: state.can_kill_most_things(player)) # 4 Sluggulas. Bombs don't work // or (state.can_use_bombs(player) and state.has('Magic Powder'), player) + if world.get_entrance('Mire Cross SW', player).door.trapped: + add_rule(world.get_entrance('Mire Cross SW', player), lambda state: state.can_kill_most_things(player)) enemy_kill_drops = [ # Location, bool-bombable ('Hyrule Castle - Map Guard Key Drop', True), @@ -1399,6 +1460,9 @@ def swordless_rules(world, player): set_rule(world.get_entrance('Tower Altar NW', player), lambda state: True) set_rule(world.get_entrance('Skull Vines NW', player), lambda state: True) set_rule(world.get_entrance('Ice Lobby WS', player), lambda state: state.has('Fire Rod', player) or state.has('Bombos', player)) + if world.get_entrance('Ice Lobby SE', player).door.trapped: + set_rule(world.get_entrance('Ice Lobby SE', player), + lambda state: state.has('Fire Rod', player) or state.has('Bombos', player)) set_rule(world.get_location('Ice Palace - Freezor Chest', player), lambda state: state.has('Fire Rod', player) or state.has('Bombos', player)) set_rule(world.get_location('Ether Tablet', player), lambda state: state.has('Book of Mudora', player) and state.has('Hammer', player)) @@ -1412,7 +1476,7 @@ def swordless_rules(world, player): if not world.is_atgt_swapped(player): set_rule(world.get_entrance('Agahnims Tower', player), lambda state: state.has('Cape', player) or state.has('Hammer', player)) # barrier gets removed after killing agahnim, rule for that added later - +# todo: new traps std_kill_rooms = { 'Hyrule Dungeon Armory Main': ['Hyrule Dungeon Armory S', 'Hyrule Dungeon Armory ES'], # One green guard 'Hyrule Dungeon Armory Boomerang': ['Hyrule Dungeon Armory Boomerang WS'], # One blue guard @@ -1443,6 +1507,18 @@ std_kill_rooms = { 'GT Wizzrobes 2': ['GT Wizzrobes 2 SE', 'GT Wizzrobes 2 NE'] # Wizzrobes. Bombs don't work } # all trap rooms? +std_kill_doors_if_trapped = { + 'Hyrule Dungeon Armory Main': 'Hyrule Dungeon Armory Interior Key Door N', + # 'Eastern Single Eyegore ES', # arrow rule is sufficient + # 'Eastern Duo Eyegores S', # arrow rule is sufficient + 'TR Twin Pokeys': 'TR Twin Pokeys NW', + 'Thieves Basement Block': 'Thieves Blocked Entry SW', + 'Desert Compass Room': 'Desert Compass Key Door WN', + 'Mire Cross': 'Mire Cross SW', + 'Tower Circle of Pots': 'Tower Circle of Pots ES', + # 'Ice Lobby S' # can melt rule is sufficient +} + def add_connection(parent_name, target_name, entrance_name, world, player): parent = world.get_region(parent_name, player) target = world.get_region(target_name, player) @@ -1495,6 +1571,10 @@ def standard_rules(world, player): if region.name in std_kill_rooms: for ent in std_kill_rooms[region.name]: add_rule(world.get_entrance(ent, player), lambda state: standard_escape_rule(state)) + if region.name in std_kill_doors_if_trapped: + ent = world.get_entrance(std_kill_doors_if_trapped[region.name], player) + if ent.door.trapped: + add_rule(ent, lambda state: standard_escape_rule(state)) set_rule(world.get_location('Zelda Pickup', player), lambda state: state.has('Big Key (Escape)', player)) set_rule(world.get_entrance('Hyrule Castle Throne Room Tapestry', player), lambda state: state.has('Zelda Herself', player)) @@ -1685,6 +1765,11 @@ def set_bunny_rules(world, player, inverted): if bunny_exit.connected_region and is_bunny(bunny_exit.parent_region): add_rule(bunny_exit, get_rule_to_add(bunny_exit.parent_region)) + for ent_name in bunny_impassible_if_trapped: + bunny_exit = world.get_entrance(ent_name, player) + if bunny_exit.door.trapped and is_bunny(bunny_exit.parent_region): + add_rule(bunny_exit, get_rule_to_add(bunny_exit.parent_region)) + doors_to_check = [x for x in world.doors if x.player == player and x not in bunny_impassible_doors] doors_to_check = [x for x in doors_to_check if x.type in [DoorType.Normal, DoorType.Interior] and not x.blocked] for door in doors_to_check: @@ -1816,6 +1901,17 @@ bunny_impassible_doors = { 'GT Validation Block Path' } +bunny_impassible_if_trapped = { + 'Hyrule Dungeon Armory Interior Key Door N', 'Eastern Pot Switch WN', 'Eastern Lobby NW', + 'Eastern Lobby NE', 'Desert Compass Key Door WN', 'Tower Circle of Pots ES', 'PoD Mimics 2 SW', + 'PoD Middle Cage S', 'Swamp Push Statue S', 'Skull 2 East Lobby WS', 'Skull Torch Room WS', + 'Thieves Conveyor Maze WN', 'Thieves Conveyor Maze SW', 'Thieves Blocked Entry SW', 'Ice Bomb Jump NW', + 'Ice Tall Hint EN', 'Ice Switch Room ES', 'Ice Switch Room NE', 'Mire Cross SW', + 'Mire Tile Room SW', 'Mire Tile Room ES', 'TR Twin Pokeys NW', 'TR Torches WN', 'GT Hope Room WN', + 'GT Speed Torch NE', 'GT Speed Torch WS', 'GT Torch Cross WN', 'GT Hidden Spikes SE', 'GT Conveyor Cross EN', + 'GT Speed Torch WN', 'Ice Lobby SE' +} + def add_key_logic_rules(world, player): key_logic = world.key_logic[player] diff --git a/Utils.py b/Utils.py index a208efe1..1a2a1c1c 100644 --- a/Utils.py +++ b/Utils.py @@ -737,6 +737,13 @@ class bidict(dict): super(bidict, self).__delitem__(key) +class HexInt(int): pass + +def hex_representer(dumper, data): + import yaml + return yaml.ScalarNode('tag:yaml.org,2002:int', f"{data:#0{4}x}") + + if __name__ == '__main__': # make_new_base2current() # read_entrance_data(old_rom=sys.argv[1]) diff --git a/data/base2current.bps b/data/base2current.bps index e6c9ca6b..eaa22112 100644 Binary files a/data/base2current.bps and b/data/base2current.bps differ diff --git a/docs/customizer_example.yaml b/docs/customizer_example.yaml index cd70004c..4c31cb7e 100644 --- a/docs/customizer_example.yaml +++ b/docs/customizer_example.yaml @@ -1,8 +1,9 @@ meta: algorithm: balanced players: 1 - seed: 42 + seed: 41 # note to self: seed 42 had an interesting Swamp Palace problem names: Lonk + notes: "Some notes specified by the user" settings: 1: door_shuffle: basic diff --git a/mystery_example.yml b/mystery_example.yml index 50ced9ab..3e65a483 100644 --- a/mystery_example.yml +++ b/mystery_example.yml @@ -52,6 +52,9 @@ partial: 0 strict: 0 decoupledoors: off + door_self_loops: + on: 1 + off: 1 dropshuffle: on: 1 off: 1 @@ -134,6 +137,7 @@ triforce_pool_min: 20 triforce_pool_max: 40 triforce_min_difference: 10 + triforce_max_difference: 15 algorithm: balanced: 12 vanilla_fill: 1 diff --git a/mystery_testsuite.yml b/mystery_testsuite.yml index 7210fca6..d2355f4f 100644 --- a/mystery_testsuite.yml +++ b/mystery_testsuite.yml @@ -31,6 +31,9 @@ key_logic_algorithm: decoupledoors: off: 9 # more strict on: 1 +door_self_loops: + on: 1 + off: 1 dropshuffle: on: 1 off: 1 @@ -86,6 +89,7 @@ triforce_goal_max: 30 triforce_pool_min: 30 triforce_pool_max: 40 triforce_min_difference: 10 +triforce_max_difference: 12 map_shuffle: on: 1 off: 1 diff --git a/resources/app/cli/args.json b/resources/app/cli/args.json index 8dca326f..7ab56184 100644 --- a/resources/app/cli/args.json +++ b/resources/app/cli/args.json @@ -259,6 +259,10 @@ "action": "store_true", "type": "bool" }, + "door_self_loops": { + "action": "store_true", + "type": "bool" + }, "experimental": { "action": "store_true", "type": "bool" @@ -402,6 +406,7 @@ "triforce_goal_min": {}, "triforce_goal_max": {}, "triforce_min_difference": {}, + "triforce_max_difference": {}, "custom": { "type": "bool", "help": "suppress" @@ -575,5 +580,6 @@ ] }, "outputname": {}, + "notes": {}, "code": {} } \ No newline at end of file diff --git a/resources/app/cli/lang/en.json b/resources/app/cli/lang/en.json index 26190a46..20dce34c 100644 --- a/resources/app/cli/lang/en.json +++ b/resources/app/cli/lang/en.json @@ -266,6 +266,7 @@ "door_shuffle": [ "Select Door Shuffling Algorithm. (default: %(default)s)", "Basic: Doors are mixed within a single dungeon.", + "Paired Dungeon are paired (with one trio) and only mixed in those groups", "Partitioned Doors are mixed in 3 partitions: L1-3+HC+AT, D1-4, D5-8", "Crossed: Doors are mixed between all dungeons.", "Vanilla: All doors are connected the same way they were in the", @@ -299,6 +300,7 @@ "strict: Ensure small keys are available" ], "decoupledoors" : [ "Door entrances and exits are decoupled" ], + "door_self_loops" : [ "Spiral stairs are allowed to self-loop" ], "experimental": [ "Enable experimental features. (default: %(default)s)" ], "dungeon_counters": [ "Enable dungeon chest counters. (default: %(default)s)" ], "crystals_ganon": [ diff --git a/resources/app/gui/lang/en.json b/resources/app/gui/lang/en.json index 8dd28803..187f3c53 100644 --- a/resources/app/gui/lang/en.json +++ b/resources/app/gui/lang/en.json @@ -58,10 +58,12 @@ "randomizer.dungeon.smallkeyshuffle.universal": "Universal", "randomizer.dungeon.bigkeyshuffle": "Big Keys", "randomizer.dungeon.decoupledoors": "Decouple Doors", + "randomizer.dungeon.door_self_loops": "Allow Self-Looping Spiral Stairs", "randomizer.dungeon.dungeondoorshuffle": "Dungeon Door Shuffle", "randomizer.dungeon.dungeondoorshuffle.vanilla": "Vanilla", "randomizer.dungeon.dungeondoorshuffle.basic": "Basic", + "randomizer.dungeon.dungeondoorshuffle.paired": "Paired", "randomizer.dungeon.dungeondoorshuffle.partitioned": "Partitioned", "randomizer.dungeon.dungeondoorshuffle.crossed": "Crossed", @@ -269,7 +271,7 @@ "randomizer.item.goal.triforcehunt": "Triforce Hunt", "randomizer.item.goal.trinity": "Trinity", "randomizer.item.goal.crystals": "Crystals", - "randomizer.item.goal.ganonhunt": "Triforce Hunt + Ganon", + "randomizer.item.goal.ganonhunt": "Ganonhunt", "randomizer.item.goal.completionist": "Completionist", "randomizer.item.crystals_gt": "Crystals to open GT", diff --git a/resources/app/gui/randomize/dungeon/widgets.json b/resources/app/gui/randomize/dungeon/widgets.json index 3f0abe94..b02dfa6f 100644 --- a/resources/app/gui/randomize/dungeon/widgets.json +++ b/resources/app/gui/randomize/dungeon/widgets.json @@ -66,6 +66,12 @@ } }, "decoupledoors": { + "type": "checkbox", + "config": { + "padx": [20,0] + } + }, + "door_self_loops": { "type": "checkbox", "config": { "padx": [20,0], diff --git a/source/classes/CustomSettings.py b/source/classes/CustomSettings.py index 3e90e5b3..a9d84948 100644 --- a/source/classes/CustomSettings.py +++ b/source/classes/CustomSettings.py @@ -4,6 +4,7 @@ import urllib.parse import yaml from typing import Any from yaml.representer import Representer +from Utils import HexInt, hex_representer from collections import defaultdict from pathlib import Path @@ -63,6 +64,7 @@ class CustomSettings(object): args.suppress_rom = get_setting(meta['suppress_rom'], args.suppress_rom) args.names = get_setting(meta['names'], args.names) args.race = get_setting(meta['race'], args.race) + args.notes = get_setting(meta['user_notes'], args.notes) self.player_range = range(1, args.multi + 1) if 'settings' in self.file_source: for p in self.player_range: @@ -121,6 +123,7 @@ class CustomSettings(object): args.trap_door_mode[p] = get_setting(settings['trap_door_mode'], args.trap_door_mode[p]) args.key_logic_algorithm[p] = get_setting(settings['key_logic_algorithm'], args.key_logic_algorithm[p]) args.decoupledoors[p] = get_setting(settings['decoupledoors'], args.decoupledoors[p]) + args.door_self_loops[p] = get_setting(settings['door_self_loops'], args.door_self_loops[p]) args.dungeon_counters[p] = get_setting(settings['dungeon_counters'], args.dungeon_counters[p]) args.crystals_gt[p] = get_setting(settings['crystals_gt'], args.crystals_gt[p]) args.crystals_ganon[p] = get_setting(settings['crystals_ganon'], args.crystals_ganon[p]) @@ -152,6 +155,12 @@ class CustomSettings(object): args.pseudoboots[p] = get_setting(settings['pseudoboots'], args.pseudoboots[p]) args.triforce_goal[p] = get_setting(settings['triforce_goal'], args.triforce_goal[p]) args.triforce_pool[p] = get_setting(settings['triforce_pool'], args.triforce_pool[p]) + args.triforce_goal_min[p] = get_setting(settings['triforce_goal_min'], args.triforce_goal_min[p]) + args.triforce_goal_max[p] = get_setting(settings['triforce_goal_max'], args.triforce_goal_max[p]) + args.triforce_pool_min[p] = get_setting(settings['triforce_pool_min'], args.triforce_pool_min[p]) + args.triforce_pool_max[p] = get_setting(settings['triforce_pool_max'], args.triforce_pool_max[p]) + args.triforce_min_difference[p] = get_setting(settings['triforce_min_difference'], args.triforce_min_difference[p]) + args.triforce_max_difference[p] = get_setting(settings['triforce_max_difference'], args.triforce_max_difference[p]) args.beemizer[p] = get_setting(settings['beemizer'], args.beemizer[p]) # mystery usage @@ -221,14 +230,15 @@ class CustomSettings(object): return self.file_source['drops'] return None - def create_from_world(self, world, race): + def create_from_world(self, world, settings): self.player_range = range(1, world.players + 1) settings_dict, meta_dict = {}, {} self.world_rep['meta'] = meta_dict meta_dict['players'] = world.players meta_dict['algorithm'] = world.algorithm meta_dict['seed'] = world.seed - meta_dict['race'] = race + meta_dict['race'] = settings.race + meta_dict['user_notes'] = settings.notes self.world_rep['settings'] = settings_dict for p in self.player_range: settings_dict[p] = {} @@ -247,6 +257,7 @@ class CustomSettings(object): settings_dict[p]['trap_door_mode'] = world.trap_door_mode[p] settings_dict[p]['key_logic_algorithm'] = world.key_logic_algorithm[p] settings_dict[p]['decoupledoors'] = world.decoupledoors[p] + settings_dict[p]['door_self_loops'] = world.door_self_loops[p] settings_dict[p]['logic'] = world.logic[p] settings_dict[p]['mode'] = world.mode[p] settings_dict[p]['swords'] = world.swords[p] @@ -342,7 +353,7 @@ class CustomSettings(object): for p in self.player_range: if p in world.owswaps and len(world.owswaps[p][0]) > 0: flips[p] = {} - flips[p]['force_flip'] = list(f for f in world.owswaps[p][0] if f < 0x40 or f >= 0x80) + flips[p]['force_flip'] = list(HexInt(f) for f in world.owswaps[p][0] if f < 0x40 or f >= 0x80) flips[p]['force_flip'].sort() flips[p]['undefined_chance'] = 0 @@ -408,6 +419,7 @@ class CustomSettings(object): def write_to_file(self, destination): yaml.add_representer(defaultdict, Representer.represent_dict) + yaml.add_representer(HexInt, hex_representer) with open(destination, 'w') as file: yaml.dump(self.world_rep, file) diff --git a/source/classes/constants.py b/source/classes/constants.py index 3a7ac2cc..69831169 100644 --- a/source/classes/constants.py +++ b/source/classes/constants.py @@ -116,6 +116,7 @@ SETTINGSTOPROCESS = { "door_type_mode": "door_type_mode", "trap_door_mode": "trap_door_mode", "decoupledoors": "decoupledoors", + "door_self_loops": "door_self_loops", "experimental": "experimental", "dungeon_counters": "dungeon_counters", "mixed_travel": "mixed_travel", diff --git a/source/dungeon/DungeonStitcher.py b/source/dungeon/DungeonStitcher.py index 1eac3fb1..504fc03b 100644 --- a/source/dungeon/DungeonStitcher.py +++ b/source/dungeon/DungeonStitcher.py @@ -22,7 +22,7 @@ def generate_dungeon(builder, entrance_region_names, split_dungeon, world, playe queue = collections.deque(proposed_map.items()) while len(queue) > 0: a, b = queue.popleft() - if world.decoupledoors[player]: + if a == b or world.decoupledoors[player]: connect_doors_one_way(a, b) else: connect_doors(a, b) @@ -128,14 +128,14 @@ def create_random_proposal(doors_to_connect, world, player): next_hook = random.choice(hooks_left) primary_door = random.choice(primary_bucket[next_hook]) opp_hook, secondary_door = type_map[next_hook], None - while (secondary_door is None or secondary_door == primary_door + while (secondary_door is None or (secondary_door == primary_door and not world.door_self_loops[player]) or decouple_check(primary_bucket[next_hook], secondary_bucket[opp_hook], primary_door, secondary_door, world, player)): secondary_door = random.choice(secondary_bucket[opp_hook]) proposal[primary_door] = secondary_door primary_bucket[next_hook].remove(primary_door) secondary_bucket[opp_hook].remove(secondary_door) - if not world.decoupledoors[player]: + if primary_door != secondary_door and not world.decoupledoors[player]: proposal[secondary_door] = primary_door primary_bucket[opp_hook].remove(secondary_door) secondary_bucket[next_hook].remove(primary_door) @@ -200,11 +200,19 @@ def modify_proposal(proposed_map, explored_state, doors_to_connect, hash_code_se unvisted_bucket[opp_hook].sort(key=lambda d: d.name) new_door = random.choice(unvisted_bucket[opp_hook]) old_target = proposed_map[attempt] - proposed_map[attempt] = new_door if not world.decoupledoors[player]: old_attempt = proposed_map[new_door] else: old_attempt = next(x for x in proposed_map if proposed_map[x] == new_door) + # ensure nothing gets messed up when something loops with itself + if attempt == old_target and old_attempt == new_door: + old_attempt = new_door + old_target = attempt + elif attempt == old_target: + old_target = old_attempt + elif old_attempt == new_door: + old_attempt = old_target + proposed_map[attempt] = new_door proposed_map[old_attempt] = old_target if not world.decoupledoors[player]: proposed_map[old_target] = old_attempt diff --git a/source/overworld/EntranceShuffle2.py b/source/overworld/EntranceShuffle2.py index f11d0081..16ce2dfc 100644 --- a/source/overworld/EntranceShuffle2.py +++ b/source/overworld/EntranceShuffle2.py @@ -289,6 +289,7 @@ def do_main_shuffle(entrances, exits, avail, mode_def): bomb_shop_options = [x for x in bomb_shop_options if x not in ['Spectacle Rock Cave', 'Spectacle Rock Cave (Bottom)']] if avail.swapped and len(bomb_shop_options) > 1: bomb_shop_options = [x for x in bomb_shop_options if x != 'Big Bomb Shop'] + bomb_shop_choice = random.choice(bomb_shop_options) connect_entrance(bomb_shop_choice, bomb_shop, avail) rem_entrances.remove(bomb_shop_choice) @@ -433,6 +434,8 @@ def do_blacksmith(entrances, exits, avail): if avail.swapped: blacksmith_options = [e for e in blacksmith_options if e not in Forbidden_Swap_Entrances] blacksmith_options = [x for x in blacksmith_options if x in entrances] + + assert len(blacksmith_options), 'No available entrances left to place Blacksmith' blacksmith_choice = random.choice(blacksmith_options) connect_entrance(blacksmith_choice, 'Blacksmiths Hut', avail) entrances.remove(blacksmith_choice) @@ -563,6 +566,7 @@ def do_dark_sanc(entrances, exits, avail): choices = [e for e in avail.world.districts[avail.player]['Northwest Dark World'].entrances if e not in forbidden and e in entrances] else: choices = [e for e in get_starting_entrances(avail) if e not in forbidden and e in entrances] + choice = random.choice(choices) entrances.remove(choice) exits.remove('Dark Sanctuary Hint') @@ -596,6 +600,10 @@ def do_links_house(entrances, exits, avail, cross_world): if avail.inverted: dark_sanc_region = avail.world.get_entrance('Dark Sanctuary Hint Exit', avail.player).connected_region.name forbidden.extend(get_nearby_entrances(avail, dark_sanc_region)) + else: + if (avail.world.doorShuffle[avail.player] != 'vanilla' and avail.world.intensity[avail.player] > 2 + and not avail.world.is_tile_swapped(0x1b, avail.player)): + forbidden.append('Hyrule Castle Entrance (South)') if avail.swapped: forbidden.append(links_house_vanilla) forbidden.extend(Forbidden_Swap_Entrances) @@ -1381,13 +1389,17 @@ def do_mandatory_connections(avail, entrances, cave_options, must_exit): elif cave[-1] == 'Spectacle Rock Cave Exit': # Spectacle rock only has one exit cave_entrances = [] for cave_exit in rnd_cave[:-1]: - entrance = next(e for e in entrances[::-1] if e not in invalid_connections[exit] and e not in must_exit) - cave_entrances.append(entrance) - entrances.remove(entrance) - connect_two_way(entrance, cave_exit, avail) - if avail.swapped and combine_map[entrance] != cave_exit: - swap_ent, _ = connect_cave_swap(entrance, cave_exit, cave) - entrances.remove(swap_ent) + if avail.swapped and cave_exit not in avail.exits: + entrance = avail.world.get_entrance(cave_exit, avail.player).parent_region.entrances[0].name + cave_entrances.append(entrance) + else: + entrance = next(e for e in entrances[::-1] if e not in invalid_connections[exit] and e not in must_exit) + cave_entrances.append(entrance) + entrances.remove(entrance) + connect_two_way(entrance, cave_exit, avail) + if avail.swapped and combine_map[entrance] != cave_exit: + swap_ent, _ = connect_cave_swap(entrance, cave_exit, cave) + entrances.remove(swap_ent) if entrance not in invalid_connections: invalid_connections[exit] = set() if all(entrance in invalid_connections for entrance in cave_entrances): @@ -1408,13 +1420,16 @@ def do_mandatory_connections(avail, entrances, cave_options, must_exit): for cave in used_caves: if cave in cave_options: # check if we placed multiple entrances from this 3 or 4 exit for cave_exit in cave: - entrance = next(e for e in entrances[::-1] if e not in invalid_cave_connections[tuple(cave)]) - invalid_cave_connections[tuple(cave)] = set() - entrances.remove(entrance) - connect_two_way(entrance, cave_exit, avail) - if avail.swapped and combine_map[entrance] != cave_exit: - swap_ent, _ = connect_cave_swap(entrance, cave_exit, cave) - entrances.remove(swap_ent) + if avail.swapped and cave_exit not in avail.exits: + continue + else: + entrance = next(e for e in entrances[::-1] if e not in invalid_cave_connections[tuple(cave)]) + invalid_cave_connections[tuple(cave)] = set() + entrances.remove(entrance) + connect_two_way(entrance, cave_exit, avail) + if avail.swapped and combine_map[entrance] != cave_exit: + swap_ent, _ = connect_cave_swap(entrance, cave_exit, cave) + entrances.remove(swap_ent) cave_options.remove(cave) if avail.swapped: entrances.extend(swap_forbidden) diff --git a/source/tools/MysteryUtils.py b/source/tools/MysteryUtils.py index a5681a11..0473e2c1 100644 --- a/source/tools/MysteryUtils.py +++ b/source/tools/MysteryUtils.py @@ -98,6 +98,7 @@ def roll_settings(weights): ret.trap_door_mode = get_choice('trap_door_mode') ret.key_logic_algorithm = get_choice('key_logic_algorithm') ret.decoupledoors = get_choice('decoupledoors') == 'on' + ret.door_self_loops = get_choice('door_self_loops') == 'on' ret.experimental = get_choice('experimental') == 'on' ret.collection_rate = get_choice('collection_rate') == 'on' @@ -138,15 +139,14 @@ def roll_settings(weights): ret.crystals_gt = get_choice('tower_open') ret.crystals_ganon = get_choice('ganon_open') - from ItemList import set_default_triforce - default_tf_goal, default_tf_pool = set_default_triforce(ret.goal, 0, 0) - goal_min = get_choice_default('triforce_goal_min', default=default_tf_goal) - goal_max = get_choice_default('triforce_goal_max', default=default_tf_goal) - pool_min = get_choice_default('triforce_pool_min', default=default_tf_pool) - pool_max = get_choice_default('triforce_pool_max', default=default_tf_pool) - ret.triforce_goal = random.randint(int(goal_min), int(goal_max)) - min_diff = get_choice_default('triforce_min_difference', default=(default_tf_pool-default_tf_goal)) - ret.triforce_pool = random.randint(max(int(pool_min), ret.triforce_goal + int(min_diff)), int(pool_max)) + ret.triforce_pool = get_choice_default('triforce_pool', default=0) + ret.triforce_goal = get_choice_default('triforce_goal', default=0) + ret.triforce_pool_min = get_choice_default('triforce_pool_min', default=0) + ret.triforce_pool_max = get_choice_default('triforce_pool_max', default=0) + ret.triforce_goal_min = get_choice_default('triforce_goal_min', default=0) + ret.triforce_goal_max = get_choice_default('triforce_goal_max', default=0) + ret.triforce_min_difference = get_choice_default('triforce_min_difference', default=0) + ret.triforce_max_difference = get_choice_default('triforce_max_difference', default=10000) ret.mode = get_choice('world_state') if ret.mode == 'retro': diff --git a/test/dungeons/trap_test.yaml b/test/dungeons/trap_test.yaml new file mode 100644 index 00000000..d82a8ade --- /dev/null +++ b/test/dungeons/trap_test.yaml @@ -0,0 +1,125 @@ +meta: + players: 1 +settings: + 1: + door_shuffle: basic + intensity: 3 + door_type_mode: all +doors: + 1: + doors: + PoD Mimics 2 SW: + type: Trap Door +# TR Twin Pokeys NW: # not possible due to trap flags +# type: Trap Door + Thieves Blocked Entry SW: + type: Trap Door + Hyrule Dungeon Armory Interior Key Door N: + type: Trap Door + Desert Compass Key Door WN: + type: Trap Door + TR Tile Room SE: + type: Trap Door +# Mire Cross SW: # not possible due to trap flags +# type: Trap Door + Tower Circle of Pots ES: + type: Trap Door + Eastern Single Eyegore ES: + type: Trap Door + Eastern Duo Eyegores SE: + type: Trap Door + Swamp Push Statue S: + type: Trap Door +# Skull 2 East Lobby WS: # currently not possible due to trap flags +# type: Trap Door + GT Hope Room WN : + type: Trap Door + +# Eastern Courtyard Ledge S: # currently not possible due to trap flags +# type: Trap Door + Ice Switch Room ES : + type: Trap Door + Ice Switch Room NE : + type: Trap Door + Skull Torch Room WS : + type: Trap Door + GT Speed Torch NE : + type: Trap Door + GT Speed Torch WS : + type: Trap Door + GT Torch Cross WN : + type: Trap Door + Mire Tile Room SW : + type: Trap Door + Mire Tile Room ES : + type: Trap Door + TR Torches WN : + type: Trap Door + PoD Lobby N: + type: Trap Door + PoD Middle Cage S: + type: Trap Door + Ice Bomb Jump NW: + type: Trap Door + GT Hidden Spikes SE: + type: Trap Door + Ice Tall Hint EN: + type: Trap Door + GT Conveyor Cross EN: + type: Trap Door + Eastern Pot Switch WN: + type: Trap Door + Thieves Conveyor Maze WN: + type: Trap Door +# Thieves Conveyor Maze SW: #not possible due to 4 door limit +# type: Trap Door + Eastern Dark Square Key Door WN: + type: Trap Door + Eastern Lobby NW: + type: Trap Door + Eastern Lobby NE: + type: Trap Door +# Ice Cross Bottom SE: # not possible due to trap flags +# type: Trap Door + Desert Back Lobby S: + type: Trap Door +# Desert West S: need enough lobbies for basic, should otherwise work +# type: Trap Door + Desert West Lobby ES: + type: Trap Door +# Mire Hidden Shooters SE: # not possible due to trap flags +# type: Trap Door +# Mire Hidden Shooters ES: # not possible due to trap flags +# type: Trap Door + Mire Hidden Shooters WS: + type: Trap Door + Tower Dark Pits EN: + type: Trap Door + Tower Dark Maze ES: + type: Trap Door + TR Tongue Pull WS: + type: Trap Door + +# Lower layer: not valid + # Sewers Pull Switch N: + # type: Trap Door + # PoD Sexy Statue W: # not possible due to trap flags and low layer too, so likely not an exception + # type: Trap Door + +# Not valid due to disappearing somaria block +# Mire Dark Shooters SE: +# type: Trap Door + + # These triggers don't open doors +# Ice Compass Room NE: +# type: Trap Door +# Hera Torches NE: +# type: Trap Door +# Mire Spikes WS: +# type: Trap Door +# Mire Spikes SW: +# type: Trap Door +# Mire Spikes NW: +# type: Trap Door +# Tower Room 03 WN: +# type: Trap Door