diff --git a/EntranceShuffle.py b/EntranceShuffle.py index d56900e164..1c08afcfe0 100644 --- a/EntranceShuffle.py +++ b/EntranceShuffle.py @@ -87,8 +87,8 @@ def build_one_way_targets(world: World, types_to_include: Iterable[str], exclude # ZR Zora's River entrance_shuffle_table = [ - ('Dungeon', ('KF Outside Deku Tree -> Deku Tree Lobby', { 'index': 0x0000 }), - ('Deku Tree Lobby -> KF Outside Deku Tree', { 'index': 0x0209 })), + ('Dungeon', ('KF Outside Deku Tree -> Deku Tree Lobby', { 'index': 0x0000, 'forest': True, 'deku': True }), + ('Deku Tree Lobby -> KF Outside Deku Tree', { 'index': 0x0209, 'forest': True, 'deku': True })), ('Dungeon', ('Death Mountain -> Dodongos Cavern Beginning', { 'index': 0x0004 }), ('Dodongos Cavern Beginning -> Death Mountain', { 'index': 0x0242 })), ('Dungeon', ('Zoras Fountain -> Jabu Jabus Belly Beginning', { 'index': 0x0028 }), @@ -113,8 +113,8 @@ def build_one_way_targets(world: World, types_to_include: Iterable[str], exclude ('DungeonSpecial', ('Ganons Castle Grounds -> Ganons Castle Lobby', { 'index': 0x0467 }), ('Ganons Castle Lobby -> Castle Grounds From Ganons Castle', { 'index': 0x023D })), - ('ChildBoss', ('Deku Tree Before Boss -> Queen Gohma Boss Room', { 'index': 0x040f, 'savewarp_addresses': [ 0xB06292, 0xBC6162, 0xBC60AE ] }), - ('Queen Gohma Boss Room -> Deku Tree Before Boss', { 'index': 0x0252 })), + ('ChildBoss', ('Deku Tree Before Boss -> Queen Gohma Boss Room', { 'index': 0x040f, 'savewarp_addresses': [ 0xB06292, 0xBC6162, 0xBC60AE ], 'forest': True, 'deku': True }), + ('Queen Gohma Boss Room -> Deku Tree Before Boss', { 'index': 0x0252, 'forest': True, 'deku': True })), ('ChildBoss', ('Dodongos Cavern Before Boss -> King Dodongo Boss Room', { 'index': 0x040b, 'savewarp_addresses': [ 0xB062B6, 0xBC616E ] }), ('King Dodongo Boss Room -> Dodongos Cavern Mouth', { 'index': 0x00c5 })), ('ChildBoss', ('Jabu Jabus Belly Before Boss -> Barinade Boss Room', { 'index': 0x0301, 'savewarp_addresses': [ 0xB062C2, 0xBC60C2 ] }), @@ -133,16 +133,16 @@ def build_one_way_targets(world: World, types_to_include: Iterable[str], exclude ('SpecialBoss', ('Ganons Castle Main -> Ganons Castle Tower', { 'index': 0x041B }), ('Ganons Castle Tower -> Ganons Castle Main', { 'index': 0x0534 })), - ('Interior', ('Kokiri Forest -> KF Midos House', { 'index': 0x0433 }), - ('KF Midos House -> Kokiri Forest', { 'index': 0x0443 })), - ('Interior', ('Kokiri Forest -> KF Sarias House', { 'index': 0x0437 }), - ('KF Sarias House -> Kokiri Forest', { 'index': 0x0447 })), - ('Interior', ('Kokiri Forest -> KF House of Twins', { 'index': 0x009C }), - ('KF House of Twins -> Kokiri Forest', { 'index': 0x033C })), - ('Interior', ('Kokiri Forest -> KF Know It All House', { 'index': 0x00C9 }), - ('KF Know It All House -> Kokiri Forest', { 'index': 0x026A })), - ('Interior', ('Kokiri Forest -> KF Kokiri Shop', { 'index': 0x00C1 }), - ('KF Kokiri Shop -> Kokiri Forest', { 'index': 0x0266 })), + ('Interior', ('Kokiri Forest -> KF Midos House', { 'index': 0x0433, 'forest': True }), + ('KF Midos House -> Kokiri Forest', { 'index': 0x0443, 'forest': True })), + ('Interior', ('Kokiri Forest -> KF Sarias House', { 'index': 0x0437, 'forest': True }), + ('KF Sarias House -> Kokiri Forest', { 'index': 0x0447, 'forest': True })), + ('Interior', ('Kokiri Forest -> KF House of Twins', { 'index': 0x009C, 'forest': True }), + ('KF House of Twins -> Kokiri Forest', { 'index': 0x033C, 'forest': True })), + ('Interior', ('Kokiri Forest -> KF Know It All House', { 'index': 0x00C9, 'forest': True }), + ('KF Know It All House -> Kokiri Forest', { 'index': 0x026A, 'forest': True })), + ('Interior', ('Kokiri Forest -> KF Kokiri Shop', { 'index': 0x00C1, 'forest': True }), + ('KF Kokiri Shop -> Kokiri Forest', { 'index': 0x0266, 'forest': True })), ('Interior', ('Lake Hylia -> LH Lab', { 'index': 0x0043 }), ('LH Lab -> Lake Hylia', { 'index': 0x03CC })), ('Interior', ('LH Fishing Island -> LH Fishing Hole', { 'index': 0x045F }), @@ -206,8 +206,8 @@ def build_one_way_targets(world: World, types_to_include: Iterable[str], exclude ('Interior', ('Zoras Fountain -> ZF Great Fairy Fountain', { 'index': 0x0371 }), ('ZF Great Fairy Fountain -> Zoras Fountain', { 'index': 0x0394, 'addresses': [0xBEFD7E] })), - ('SpecialInterior', ('Kokiri Forest -> KF Links House', { 'index': 0x0272 }), - ('KF Links House -> Kokiri Forest', { 'index': 0x0211 })), + ('SpecialInterior', ('Kokiri Forest -> KF Links House', { 'index': 0x0272, 'forest': True }), + ('KF Links House -> Kokiri Forest', { 'index': 0x0211, 'forest': True })), ('SpecialInterior', ('ToT Entrance -> Temple of Time', { 'index': 0x0053 }), ('Temple of Time -> ToT Entrance', { 'index': 0x0472 })), ('SpecialInterior', ('Kakariko Village -> Kak Windmill', { 'index': 0x0453 }), @@ -290,16 +290,16 @@ def build_one_way_targets(world: World, types_to_include: Iterable[str], exclude ('LLR Grotto -> Lon Lon Ranch', { 'grotto_id': 0x15, 'savewarp_fallback': 0x05D4 })), ('Grotto', ('SFM Entryway -> SFM Wolfos Grotto', { 'grotto_id': 0x16, 'entrance': 0x05B4, 'content': 0xED, 'scene': 0x56 }), ('SFM Wolfos Grotto -> SFM Entryway', { 'grotto_id': 0x16, 'savewarp_fallback': 0x00FC })), - ('Grotto', ('Sacred Forest Meadow -> SFM Storms Grotto', { 'grotto_id': 0x17, 'entrance': 0x05BC, 'content': 0xEE, 'scene': 0x56 }), - ('SFM Storms Grotto -> Sacred Forest Meadow', { 'grotto_id': 0x17, 'savewarp_fallback': 0x0600 })), - ('Grotto', ('Sacred Forest Meadow -> SFM Fairy Grotto', { 'grotto_id': 0x18, 'entrance': 0x036D, 'content': 0xFF, 'scene': 0x56 }), - ('SFM Fairy Grotto -> Sacred Forest Meadow', { 'grotto_id': 0x18, 'savewarp_fallback': 0x0600 })), + ('Grotto', ('Sacred Forest Meadow -> SFM Storms Grotto', { 'grotto_id': 0x17, 'entrance': 0x05BC, 'content': 0xEE, 'scene': 0x56, 'forest': True }), + ('SFM Storms Grotto -> Sacred Forest Meadow', { 'grotto_id': 0x17, 'savewarp_fallback': 0x0600, 'forest': True })), + ('Grotto', ('Sacred Forest Meadow -> SFM Fairy Grotto', { 'grotto_id': 0x18, 'entrance': 0x036D, 'content': 0xFF, 'scene': 0x56, 'forest': True }), + ('SFM Fairy Grotto -> Sacred Forest Meadow', { 'grotto_id': 0x18, 'savewarp_fallback': 0x0600, 'forest': True })), ('Grotto', ('LW Beyond Mido -> LW Scrubs Grotto', { 'grotto_id': 0x19, 'entrance': 0x05B0, 'content': 0xF5, 'scene': 0x5B }), ('LW Scrubs Grotto -> LW Beyond Mido', { 'grotto_id': 0x19, 'savewarp_fallback': 0x01A9 })), ('Grotto', ('Lost Woods -> LW Near Shortcuts Grotto', { 'grotto_id': 0x1A, 'entrance': 0x003F, 'content': 0x14, 'scene': 0x5B }), ('LW Near Shortcuts Grotto -> Lost Woods', { 'grotto_id': 0x1A, 'savewarp_fallback': 0x04D6 })), - ('Grotto', ('Kokiri Forest -> KF Storms Grotto', { 'grotto_id': 0x1B, 'entrance': 0x003F, 'content': 0x2C, 'scene': 0x55 }), - ('KF Storms Grotto -> Kokiri Forest', { 'grotto_id': 0x1B, 'savewarp_fallback': 0x0286 })), + ('Grotto', ('Kokiri Forest -> KF Storms Grotto', { 'grotto_id': 0x1B, 'entrance': 0x003F, 'content': 0x2C, 'scene': 0x55, 'forest': True }), + ('KF Storms Grotto -> Kokiri Forest', { 'grotto_id': 0x1B, 'savewarp_fallback': 0x0286, 'forest': True })), ('Grotto', ('Zoras Domain -> ZD Storms Grotto', { 'grotto_id': 0x1C, 'entrance': 0x036D, 'content': 0xFF, 'scene': 0x58 }), ('ZD Storms Grotto -> Zoras Domain', { 'grotto_id': 0x1C, 'savewarp_fallback': 0x0108 })), ('Grotto', ('GF Entrances Behind Crates -> GF Storms Grotto', { 'grotto_id': 0x1D, 'entrance': 0x036D, 'content': 0xFF, 'scene': 0x5D }), @@ -308,8 +308,8 @@ def build_one_way_targets(world: World, types_to_include: Iterable[str], exclude ('GV Storms Grotto -> GV Fortress Side', { 'grotto_id': 0x1E, 'savewarp_fallback': 0x022D })), ('Grotto', ('GV Grotto Ledge -> GV Octorok Grotto', { 'grotto_id': 0x1F, 'entrance': 0x05AC, 'content': 0xF2, 'scene': 0x5A }), ('GV Octorok Grotto -> GV Grotto Ledge', { 'grotto_id': 0x1F, 'savewarp_fallback': 0x0117 })), #TODO (out-of-logic access to Gerudo Valley) - ('Grotto', ('LW Beyond Mido -> Deku Theater', { 'grotto_id': 0x20, 'entrance': 0x05C4, 'content': 0xF3, 'scene': 0x5B }), - ('Deku Theater -> LW Beyond Mido', { 'grotto_id': 0x20, 'savewarp_fallback': 0x01A9 })), + ('Grotto', ('LW Beyond Mido -> Deku Theater', { 'grotto_id': 0x20, 'entrance': 0x05C4, 'content': 0xF3, 'scene': 0x5B, 'forest': True }), + ('Deku Theater -> LW Beyond Mido', { 'grotto_id': 0x20, 'savewarp_fallback': 0x01A9, 'forest': True })), ('Grave', ('Graveyard -> Graveyard Shield Grave', { 'index': 0x004B }), ('Graveyard Shield Grave -> Graveyard', { 'index': 0x035D })), @@ -322,14 +322,14 @@ def build_one_way_targets(world: World, types_to_include: Iterable[str], exclude ('Overworld', ('Kokiri Forest -> LW Bridge From Forest', { 'index': 0x05E0 }), ('LW Bridge -> Kokiri Forest', { 'index': 0x020D })), - ('Overworld', ('Kokiri Forest -> Lost Woods', { 'index': 0x011E }), - ('LW Forest Exit -> Kokiri Forest', { 'index': 0x0286 })), - ('Overworld', ('Lost Woods -> GC Woods Warp', { 'index': 0x04E2 }), - ('GC Woods Warp -> Lost Woods', { 'index': 0x04D6 })), + ('Overworld', ('Kokiri Forest -> Lost Woods', { 'index': 0x011E, 'forest': True }), + ('LW Forest Exit -> Kokiri Forest', { 'index': 0x0286, 'forest': True })), + ('Overworld', ('Lost Woods -> GC Woods Warp', { 'index': 0x04E2, 'forest': True }), + ('GC Woods Warp -> Lost Woods', { 'index': 0x04D6, 'forest': True })), ('Overworld', ('Lost Woods -> Zora River', { 'index': 0x01DD }), ('Zora River -> LW Underwater Entrance', { 'index': 0x04DA })), - ('Overworld', ('LW Beyond Mido -> SFM Entryway', { 'index': 0x00FC }), - ('SFM Entryway -> LW Beyond Mido', { 'index': 0x01A9 })), + ('Overworld', ('LW Beyond Mido -> SFM Entryway', { 'index': 0x00FC, 'forest': True }), + ('SFM Entryway -> LW Beyond Mido', { 'index': 0x01A9, 'forest': True })), ('Overworld', ('LW Bridge -> Hyrule Field', { 'index': 0x0185 }), ('Hyrule Field -> LW Bridge', { 'index': 0x04DE })), ('Overworld', ('Hyrule Field -> Lake Hylia', { 'index': 0x0102 }), @@ -378,17 +378,17 @@ def build_one_way_targets(world: World, types_to_include: Iterable[str], exclude ('OwlDrop', ('LH Owl Flight -> Hyrule Field', { 'index': 0x027E, 'addresses': [0xAC9F26] })), ('OwlDrop', ('DMT Owl Flight -> Kak Impas Rooftop', { 'index': 0x0554, 'addresses': [0xAC9EF2] })), - ('Spawn', ('Child Spawn -> KF Links House', { 'index': 0x00BB, 'addresses': [0xB06342] })), + ('Spawn', ('Child Spawn -> KF Links House', { 'index': 0x00BB, 'addresses': [0xB06342], 'forest': True })), ('Spawn', ('Adult Spawn -> Temple of Time', { 'index': 0x05F4, 'addresses': [0xB06332] })), - ('WarpSong', ('Minuet of Forest Warp -> Sacred Forest Meadow', { 'index': 0x0600, 'addresses': [0xBF023C] })), + ('WarpSong', ('Minuet of Forest Warp -> Sacred Forest Meadow', { 'index': 0x0600, 'addresses': [0xBF023C], 'forest': True })), ('WarpSong', ('Bolero of Fire Warp -> DMC Central Local', { 'index': 0x04F6, 'addresses': [0xBF023E] })), ('WarpSong', ('Serenade of Water Warp -> Lake Hylia', { 'index': 0x0604, 'addresses': [0xBF0240] })), ('WarpSong', ('Requiem of Spirit Warp -> Desert Colossus', { 'index': 0x01F1, 'addresses': [0xBF0242] })), ('WarpSong', ('Nocturne of Shadow Warp -> Graveyard Warp Pad Region', { 'index': 0x0568, 'addresses': [0xBF0244] })), ('WarpSong', ('Prelude of Light Warp -> Temple of Time', { 'index': 0x05F4, 'addresses': [0xBF0246] })), - ('BlueWarp', ('Queen Gohma Boss Room -> KF Outside Deku Tree', { 'index': 0x0457, 'addresses': [0xAC93A2, 0xCA3142, 0xCA316A] })), + ('BlueWarp', ('Queen Gohma Boss Room -> KF Outside Deku Tree', { 'index': 0x0457, 'addresses': [0xAC93A2, 0xCA3142, 0xCA316A], 'forest': True, 'deku': True })), ('BlueWarp', ('King Dodongo Boss Room -> Death Mountain', { 'index': 0x047A, 'addresses': [0xAC9336, 0xCA30CA, 0xCA30EA] })), ('BlueWarp', ('Barinade Boss Room -> Zoras Fountain', { 'index': 0x010E, 'addresses': [0xAC936A, 0xCA31B2, 0xCA3702] })), ('BlueWarp', ('Phantom Ganon Boss Room -> Sacred Forest Meadow', { 'index': 0x0608, 'addresses': [0xAC9F96, 0xCA3D66, 0xCA3D5A, 0xCA3D32], 'child_index': 0x0600 })), @@ -531,7 +531,7 @@ def shuffle_random_entrances(worlds: list[World]) -> None: entrance_pools['Dungeon'] = world.get_shufflable_entrances(type='Dungeon', only_primary=True) # The fill algorithm will already make sure gohma is reachable, however it can end up putting # a forest escape via the hands of spirit on Deku leading to Deku on spirit in logic. This is - # not really a closed forest anymore, so specifically remove Deku Tree from closed forest. + # not really a closed forest anymore, so specifically place Deku Tree in its vanilla location. if worlds[0].settings.open_forest == 'closed': entrance_pools['Dungeon'].remove(world.get_entrance('KF Outside Deku Tree -> Deku Tree Lobby')) if worlds[0].shuffle_special_dungeon_entrances: @@ -619,7 +619,18 @@ def shuffle_random_entrances(worlds: list[World]) -> None: # Shuffle all entrances among the pools to shuffle for pool_type, entrance_pool in one_way_entrance_pools.items(): - placed_one_way_entrances += shuffle_entrance_pool(world, worlds, entrance_pool, one_way_target_entrance_pools[pool_type], locations_to_ensure_reachable, check_all=True, placed_one_way_entrances=placed_one_way_entrances) + if world.settings.open_forest == 'closed' and pool_type not in ('OverworldOneWay', 'OwlDrop'): + # These entrance pools can potentially be accessed from inside the forest. + # To prevent a forest escape, shuffle entrances of this type inside and outside the forest separately. + forest_entrance_pool = list(filter(lambda entrance: entrance.data.get('forest', False), entrance_pool)) + outside_entrance_pool = list(filter(lambda entrance: not entrance.data.get('forest', False), entrance_pool)) + # Additionally, we ensure one-way entrances can't skip Mido to enter the Deku Tree, since closed forest implies closed Deku. + forest_target_pool = list(filter(lambda entrance: entrance.replaces.data.get('forest', False) and not entrance.replaces.data.get('deku', False), one_way_target_entrance_pools[pool_type])) + outside_target_pool = list(filter(lambda entrance: not entrance.replaces.data.get('forest', False) or entrance.replaces.data.get('deku', False), one_way_target_entrance_pools[pool_type])) + placed_one_way_entrances += shuffle_entrance_pool(world, worlds, forest_entrance_pool, forest_target_pool, locations_to_ensure_reachable, check_all=True, placed_one_way_entrances=placed_one_way_entrances) + placed_one_way_entrances += shuffle_entrance_pool(world, worlds, outside_entrance_pool, outside_target_pool, locations_to_ensure_reachable, check_all=True, placed_one_way_entrances=placed_one_way_entrances) + else: + placed_one_way_entrances += shuffle_entrance_pool(world, worlds, entrance_pool, one_way_target_entrance_pools[pool_type], locations_to_ensure_reachable, check_all=True, placed_one_way_entrances=placed_one_way_entrances) # Delete all targets that we just placed from other one way target pools so multiple one way entrances don't use the same target replaced_entrances = [entrance.replaces for entrance in entrance_pool] for remaining_target in chain.from_iterable(one_way_target_entrance_pools.values()): @@ -630,7 +641,26 @@ def shuffle_random_entrances(worlds: list[World]) -> None: delete_target_entrance(unused_target) for pool_type, entrance_pool in entrance_pools.items(): - shuffle_entrance_pool(world, worlds, entrance_pool, target_entrance_pools[pool_type], locations_to_ensure_reachable, placed_one_way_entrances=placed_one_way_entrances) + if world.settings.open_forest == 'closed' and ( + pool_type in ('Dungeon', 'ChildBoss', 'Boss', 'Overworld') + or (pool_type == 'Interior' and ( + world.shuffle_special_interior_entrances + or (world.shuffle_interior_entrances and ( + 'child' in world.settings.spawn_positions # to avoid spawning in a forest interior that has been placed outside the forest + or world.settings.warp_songs # to avoid Minuet leading inside a forest interior that has been placed outside the forest + )) + )) + ): + # These entrance pools can potentially be accessed from inside the forest. + # To prevent a forest escape, shuffle entrances of this type inside and outside the forest separately. + forest_entrance_pool = list(filter(lambda entrance: entrance.data.get('forest', False), entrance_pool)) + outside_entrance_pool = list(filter(lambda entrance: not entrance.data.get('forest', False), entrance_pool)) + forest_target_pool = list(filter(lambda entrance: entrance.replaces.data.get('forest', False), target_entrance_pools[pool_type])) + outside_target_pool = list(filter(lambda entrance: not entrance.replaces.data.get('forest', False), target_entrance_pools[pool_type])) + shuffle_entrance_pool(world, worlds, forest_entrance_pool, forest_target_pool, locations_to_ensure_reachable, placed_one_way_entrances=placed_one_way_entrances) + shuffle_entrance_pool(world, worlds, outside_entrance_pool, outside_target_pool, locations_to_ensure_reachable, placed_one_way_entrances=placed_one_way_entrances) + else: + shuffle_entrance_pool(world, worlds, entrance_pool, target_entrance_pools[pool_type], locations_to_ensure_reachable, placed_one_way_entrances=placed_one_way_entrances) # Determine blue warp targets # if a boss room is inside a boss door, make the blue warp go outside the dungeon's entrance @@ -842,7 +872,10 @@ def place_one_way_priority_entrance(worlds: list[World], world: World, priority_ # Shuffle this list. # Pick the first one not already set, not adult spawn, that has a valid target entrance. # Assemble then clear entrances from the pool and target pools as appropriate. - avail_pool = list(chain.from_iterable(one_way_entrance_pools[t] for t in allowed_types if t in one_way_entrance_pools)) + avail_pool = chain.from_iterable(one_way_entrance_pools[t] for t in allowed_types if t in one_way_entrance_pools) + if world.settings.open_forest == 'closed': + avail_pool = filter(lambda entrance: not entrance.data.get('forest', False), avail_pool) + avail_pool = list(avail_pool) random.shuffle(avail_pool) for entrance in avail_pool: if entrance.replaces: @@ -996,8 +1029,8 @@ def validate_world(world: World, worlds: list[World], entrance_placed: Optional[ if not any(no_items_search.can_reach(world.get_region(region)) for region in valid_starting_regions): raise EntranceShuffleError('Invalid starting area') - # Check that a region where time passes is always reachable as both ages without having collected any items - time_travel_search = Search.with_items([w.state for w in worlds], [ItemFactory('Time Travel', world=w) for w in worlds]) + # Check that after leaving the forest, a region where time passes is always reachable as both ages without having collected any items + time_travel_search = Search.with_items([w.state for w in worlds], [ItemFactory('Time Travel', world=w) for w in worlds] + [ItemFactory('Deku Tree Clear', world=w, event=True) for w in worlds]) if not (any(region for region in time_travel_search.reachable_regions('child') if region.time_passes and region.world == world) and any(region for region in time_travel_search.reachable_regions('adult') if region.time_passes and region.world == world)): @@ -1015,7 +1048,8 @@ def validate_world(world: World, worlds: list[World], entrance_placed: Optional[ # The Big Poe Shop should always be accessible as adult without the need to use any bottles # This is important to ensure that players can never lock their only bottles by filling them with Big Poes they can't sell # We can use starting items in this check as long as there are no exits requiring the use of a bottle without refills - time_travel_search = Search.with_items([w.state for w in worlds], [ItemFactory('Time Travel', world=w) for w in worlds]) + # We can assume forest exit since Hyrule Field is not in the forest and Bottle with Big Poe is not a logical bottle + time_travel_search = Search.with_items([w.state for w in worlds], [ItemFactory('Time Travel', world=w) for w in worlds] + [ItemFactory('Deku Tree Clear', world=w, event=True) for w in worlds]) if not time_travel_search.can_reach(world.get_region('Market Guard House'), age='adult'): raise EntranceShuffleError('Big Poe Shop access is not guaranteed as adult') diff --git a/HintList.py b/HintList.py index fd5cfe69a4..772fca5a42 100644 --- a/HintList.py +++ b/HintList.py @@ -331,6 +331,7 @@ def rainbow_bridge_hint_kind(world: World) -> str: 'Magic Meter': (["mystic training", "pixie dust", "a green rectangle"], "a Magic Meter", 'item'), 'Double Defense': (["a white outline", "damage decrease", "strengthened love"], "Double Defense", 'item'), 'Slingshot': (["a seed shooter", "a rubberband", "a child's catapult"], "a Slingshot", 'item'), + 'Deku Seed Bag': (["a seed shooter", "a rubberband", "a child's catapult"], "a Slingshot", 'item'), 'Boomerang': (["a banana", "a stun stick"], "the Boomerang", 'item'), 'Bow': (["an archery enabler", "a danger dart launcher"], "a Bow", 'item'), 'Bomb Bag': (["an explosive container", "a blast bag"], "a Bomb Bag", 'item'), diff --git a/ItemList.py b/ItemList.py index 0d13f44c92..a492d1a11b 100644 --- a/ItemList.py +++ b/ItemList.py @@ -422,6 +422,7 @@ class GetItemId(IntEnum): 'Bomb Bag': ('Item', True, GetItemId.GI_PROGRESSIVE_BOMB_BAG, None), 'Bow': ('Item', True, GetItemId.GI_PROGRESSIVE_BOW, None), 'Slingshot': ('Item', True, GetItemId.GI_PROGRESSIVE_SLINGSHOT, None), + 'Deku Seed Bag': ('Item', True, GetItemId.GI_PROGRESSIVE_SLINGSHOT, {'alias': ('Slingshot', 1)}), 'Progressive Wallet': ('Item', True, GetItemId.GI_PROGRESSIVE_WALLET, {'progressive': 3}), 'Progressive Scale': ('Item', True, GetItemId.GI_PROGRESSIVE_SCALE, {'progressive': 2}), 'Deku Nut Capacity': ('Item', None, GetItemId.GI_PROGRESSIVE_NUT_CAPACITY, None), @@ -549,6 +550,7 @@ class GetItemId(IntEnum): # Event items otherwise generated by generic event logic # can be defined here to enforce their appearance in playthroughs. + 'Deku Tree Clear': ('Event', True, None, None), 'Water Temple Clear': ('Event', True, None, None), 'Forest Trial Clear': ('Event', True, None, None), 'Fire Trial Clear': ('Event', True, None, None), diff --git a/ItemPool.py b/ItemPool.py index be6b7f8237..1f858d749f 100644 --- a/ItemPool.py +++ b/ItemPool.py @@ -13,6 +13,21 @@ from World import World +closed_forest_restricted_items: tuple[str, ...] = ( + 'Bomb Bag', + 'Bombchus (5)', + 'Bombchus (10)', + 'Bombchus (20)', + 'Bombchus', + 'Dins Fire', + 'Progressive Scale', + 'Bolero of Fire', + 'Serenade of Water', + 'Nocturne of Shadow', + 'Requiem of Spirit', + 'Prelude of Light', +) + plentiful_items: list[str] = [ 'Biggoron Sword', 'Boomerang', @@ -34,7 +49,6 @@ 'Deku Stick Capacity', 'Deku Nut Capacity', 'Bow', - 'Slingshot', 'Bomb Bag', 'Double Defense', ] @@ -64,7 +78,6 @@ 'Progressive Wallet', 'Magic Meter', 'Bow', - 'Slingshot', 'Bomb Bag', 'Bombchus (10)', 'Lens of Truth', @@ -103,6 +116,7 @@ 'Requiem of Spirit', 'Ocarina', 'Kokiri Sword', + 'Deku Seed Bag', 'Boss Key (Ganons Castle)', 'Boss Key (Forest Temple)', 'Boss Key (Fire Temple)', @@ -226,6 +240,7 @@ 'Deku Nut Capacity': 1, 'Bow': 2, 'Slingshot': 2, + 'Deku Seed Bag': 1, 'Bomb Bag': 2, 'Heart Container': 0, }, @@ -240,6 +255,7 @@ 'Deku Nut Capacity': 0, 'Bow': 1, 'Slingshot': 1, + 'Deku Seed Bag': 0, 'Bomb Bag': 1, 'Heart Container': 0, 'Piece of Heart': 0, @@ -467,6 +483,10 @@ def get_pool_core(world: World) -> tuple[list[str], dict[str, Item]]: if world.settings.item_pool_value == 'plentiful': pending_junk_pool.extend(plentiful_items) + if world.settings.open_forest == 'closed' and world.settings.world_count > 1: + pending_junk_pool.append('Deku Seed Bag') + else: + pending_junk_pool.append('Slingshot') if world.settings.shuffle_child_trade: pending_junk_pool.extend(world.settings.shuffle_child_trade) # Weird Egg is always chosen if both Egg and Chicken are selected to be shuffled. @@ -822,6 +842,17 @@ def get_pool_core(world: World) -> tuple[list[str], dict[str, Item]]: dungeon = [dungeon for dungeon in world.dungeons if dungeon.name == 'Ganons Castle'][0] dungeon.boss_key.append(ItemFactory(item, world)) + # Slingshots + elif location.vanilla_item == 'Deku Seed Bag': + # In multiworld with closed forest, one slingshot for each player is forced to be placed in the forest area, + # to reduce the likelihood that players are locked inside the forest area for a long time. + # We call the slingshots that can be placed freely “Deku seed bags” to allow the fill algorithm to distinguish between them. + # However, in other settings, this distinction is not necessary, so to make spoiler logs easier to, we call all slingshots slingshots. + # (To simplify the ludicrous implementation, we always use the Deku seed bag name there as well.) + if world.settings.item_pool_value != 'ludicrous' and (world.settings.open_forest != 'closed' or world.settings.world_count == 1): + item = 'Slingshot' + shuffle_item = True + # Dungeon Items elif location.dungeon is not None: dungeon = location.dungeon diff --git a/LocationList.py b/LocationList.py index 34635d50b7..c3051e9077 100644 --- a/LocationList.py +++ b/LocationList.py @@ -164,7 +164,7 @@ def shop_address(shop_id: int, shelf_id: int) -> int: # Lost Woods ("LW Gift from Saria", ("Cutscene", 0xFF, 0x02, None, 'Ocarina', ("Lost Woods", "Forest Area", "NPCs",))), ("LW Ocarina Memory Game", ("NPC", 0x5B, 0x76, None, 'Piece of Heart', ("Lost Woods", "Forest Area", "Minigames",))), - ("LW Target in Woods", ("NPC", 0x5B, 0x60, None, 'Slingshot', ("Lost Woods", "Forest Area", "NPCs",))), + ("LW Target in Woods", ("NPC", 0x5B, 0x60, None, 'Deku Seed Bag', ("Lost Woods", "Forest Area", "NPCs",))), ("LW Near Shortcuts Grotto Chest", ("Chest", 0x3E, 0x14, None, 'Rupees (5)', ("Lost Woods", "Forest Area", "Grottos", "Chests",))), ("LW Trade Cojiro", ("NPC", 0x5B, 0x1F, None, 'Odd Mushroom', ("Lost Woods", "Forest",))), ("LW Trade Odd Potion", ("NPC", 0x5B, 0x21, None, 'Poachers Saw', ("Lost Woods", "Forest",))), @@ -248,7 +248,7 @@ def shop_address(shop_id: int, shelf_id: int) -> int: ("HF Child Above Drawbridge Wonderitem 2", ("Wonderitem", 0x51, [(0,0,54),(0,1,52)], None, 'Rupees (20)', ("Hyrule Field", "Wonderitem"))), ("HF Child Above Drawbridge Wonderitem 3", ("Wonderitem", 0x51, [(0,0,55),(0,1,53)], None, 'Rupees (20)', ("Hyrule Field", "Wonderitem"))), - ("Market Shooting Gallery Reward", ("NPC", 0x42, 0x60, None, 'Slingshot', ("Market", "Minigames",))), + ("Market Shooting Gallery Reward", ("NPC", 0x42, 0x60, None, 'Deku Seed Bag', ("Market", "Minigames",))), ("Market Bombchu Bowling First Prize", ("NPC", 0x4B, 0x34, None, 'Bomb Bag', ("Market", "Minigames",))), ("Market Bombchu Bowling Second Prize", ("NPC", 0x4B, 0x3E, None, 'Piece of Heart', ("Market", "Minigames",))), ("Market Bombchu Bowling Bombchus", ("NPC", 0x4B, 0x03, None, 'Bombchus (10)', None)), diff --git a/Region.py b/Region.py index 799b76928b..e0cb8a5264 100644 --- a/Region.py +++ b/Region.py @@ -93,6 +93,7 @@ def alt_hint(self) -> Optional[HintArea]: def can_fill(self, item: Item, manual: bool = False) -> bool: from Hints import HintArea + from ItemPool import closed_forest_restricted_items if not manual and self.world.settings.empty_dungeons_mode != 'none' and item.dungeonitem: # An empty dungeon can only store its own dungeon items @@ -103,6 +104,18 @@ def can_fill(self, item: Item, manual: bool = False) -> bool: if item.world.empty_dungeons[dungeon.name].empty and dungeon.is_dungeon_item(item): return False + if not manual and self.world.settings.open_forest == 'closed' and item.name in (*closed_forest_restricted_items, 'Slingshot'): + hint_area = HintArea.at(self) + if hint_area.color == 'Green' and hint_area != HintArea.FOREST_TEMPLE and self.name != 'Queen Gohma Boss Room': + # Don't place items that can be used to escape the forest in Forest areas of worlds with Require Gohma + if item.name in closed_forest_restricted_items: + return False + else: + # Place at least one slingshot for each player in the Forest area, to avoid requiring one player to leave the forest to get another player's slingshot. + # This is still not a 100% guarantee because the slingshot could be behind an item that's not in the forest, such as in a bombable grotto entrance in the Lost Woods. + if item.name == 'Slingshot': + return False + is_self_dungeon_restricted = False is_self_region_restricted = None is_hint_color_restricted = None diff --git a/Rules.py b/Rules.py index f6a736bff2..2ef30d89bd 100644 --- a/Rules.py +++ b/Rules.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Collection, Iterable from typing import TYPE_CHECKING, Optional -from ItemPool import song_list +from ItemPool import closed_forest_restricted_items, song_list from Location import Location, DisableType from RulesCommon import AccessRule from Search import Search @@ -79,6 +79,9 @@ def set_rules(world: World) -> None: if location.name in world.always_hints: location.add_rule(guarantee_hint) + if world.settings.open_forest == 'closed' and location in world.distribution.skipped_locations: + add_item_rule(location, lambda location, item: item.name not in closed_forest_restricted_items) + for location in world.settings.disabled_locations: try: world.get_location(location).disabled = DisableType.PENDING diff --git a/SaveContext.py b/SaveContext.py index 12b6a8aea0..f8e70b4de9 100644 --- a/SaveContext.py +++ b/SaveContext.py @@ -1135,6 +1135,10 @@ def get_save_context_addresses() -> AddressesDict: 'item_slot.slingshot' : 'slingshot', 'upgrades.bullet_bag' : None, }, + "Deku Seed Bag" : { + 'item_slot.slingshot' : 'slingshot', + 'upgrades.bullet_bag' : None, + }, "Deku Seeds" : { 'ammo.slingshot' : None, }, diff --git a/SettingsList.py b/SettingsList.py index f69a270ac2..93c4599979 100644 --- a/SettingsList.py +++ b/SettingsList.py @@ -1235,21 +1235,24 @@ class SettingInfos: Deku Tree, requiring Kokiri Sword and Deku Shield to access the Deku Tree. - 'Closed Forest': Beating Deku Tree is logically required - to leave the forest area (Kokiri Forest/Lost Woods/Sacred Forest - Meadow/Deku Tree), while the Kokiri Sword and a Deku Shield are - required to access the Deku Tree. Items needed for this will be - guaranteed inside the forest area. This setting is incompatible - with starting as adult, and so Starting Age will be locked to Child. - With either "Shuffle Interior Entrances" set to "All", "Shuffle - Overworld Entrances" on, "Randomize Warp Song Destinations" on - or "Randomize Overworld Spawns" on, Closed Forest will instead - be treated as Closed Deku with starting age Child and WILL NOT - guarantee that these items are available in the forest area. + 'Closed Forest': Defeating Queen Gohma is required to leave + the forest area (Kokiri Forest/Lost Woods/Sacred Forest + Meadow/Deku Tree). Items needed for this will be guaranteed + inside the forest area, and items that could be used to + escape the forest without defeating Queen Gohma (such as + explosives to enter Goron City) will be prevented from + appearing inside the forest area. This setting is + incompatible with starting as adult, and so Starting Age will + be locked to Child. If entrances are shuffled, entrances + inside and outside the forest area will be shuffled + separately. For example, "Shuffle Dungeon Entrances" and + "Shuffle Boss Entrances" don't affect the Deku Tree. As an + exception, grottos are not shuffled separately, and neither + are interiors if only simple interiors are shuffled. ''', shared = True, disable = { - 'closed': {'settings': ['starting_age']} + 'closed': {'settings': ['starting_age']}, }, gui_params = { 'randomize_key': 'randomize_settings', diff --git a/World.py b/World.py index 759c30de46..322a9607d8 100644 --- a/World.py +++ b/World.py @@ -99,11 +99,6 @@ def __init__(self, world_id: int, settings: Settings, resolve_randomized_setting self.selected_adult_trade_item = plando_adult_trade[0][1].item # ugly but functional, see the loop in Plandomizer.WorldDistribution.fill for how this is indexed self.adult_trade_starting_inventory: str = '' - if (settings.open_forest == 'closed' - and (self.shuffle_special_interior_entrances or settings.shuffle_hideout_entrances or settings.shuffle_overworld_entrances - or settings.warp_songs or settings.spawn_positions)): - self.settings.open_forest = 'closed_deku' - if settings.triforce_goal_per_world > settings.triforce_count_per_world: raise ValueError("Triforces required cannot be more than the triforce count.") self.triforce_goal: int = settings.triforce_goal_per_world * settings.world_count diff --git a/data/Glitched World/Deku Tree MQ.json b/data/Glitched World/Deku Tree MQ.json index 9bb8e77697..bf60ad70f2 100644 --- a/data/Glitched World/Deku Tree MQ.json +++ b/data/Glitched World/Deku Tree MQ.json @@ -31,7 +31,9 @@ "region_name": "Deku Tree Boss Room", "dungeon": "Deku Tree", "events": { - "Deku Tree Clear": "Deku_Shield and (Kokiri_Sword or Sticks)" + "Defeat Queen Gohma": "Deku_Shield and (Kokiri_Sword or Sticks)", + # separate event which appears in the playthrough only if it's required to open the forest exit + "Deku Tree Clear": "'Defeat Queen Gohma'" }, "locations": { "Deku Tree MQ Before Spinning Log Chest": "True", @@ -39,8 +41,8 @@ "Deku Tree MQ GS Basement Graves Room": "Boomerang and can_play(Song_of_Time)", "Deku Tree MQ GS Basement Back Room": "Boomerang", "Deku Tree MQ Deku Scrub": "True", - "Deku Tree Queen Gohma Heart": "Deku_Shield and (Kokiri_Sword or Sticks)", - "Queen Gohma": "Deku_Shield and (Kokiri_Sword or Sticks)" + "Deku Tree Queen Gohma Heart": "'Defeat Queen Gohma'", + "Queen Gohma": "'Defeat Queen Gohma'" }, "exits": { "Deku Tree Lobby": "True" diff --git a/data/Glitched World/Deku Tree.json b/data/Glitched World/Deku Tree.json index 6647b39a4d..a5747bfe33 100644 --- a/data/Glitched World/Deku Tree.json +++ b/data/Glitched World/Deku Tree.json @@ -50,7 +50,9 @@ "dungeon": "Deku Tree", "events": { "Defeat Queen Gohma": "(Nuts or can_use(Slingshot) or has_bombchus or can_use(Hookshot) or can_use(Bow) or can_use(Boomerang)) and - ((here(has_shield or can_use(Megaton_Hammer)) and (is_adult or Kokiri_Sword or Sticks)) or is_adult)" + ((here(has_shield or can_use(Megaton_Hammer)) and (is_adult or Kokiri_Sword or Sticks)) or is_adult)", + # separate event which appears in the playthrough only if it's required to open the forest exit + "Deku Tree Clear": "'Defeat Queen Gohma'" }, "locations": { "Deku Tree Queen Gohma Heart": "'Defeat Queen Gohma'", diff --git a/data/LogicHelpers.json b/data/LogicHelpers.json index b9a9ac19c4..275d0813fa 100644 --- a/data/LogicHelpers.json +++ b/data/LogicHelpers.json @@ -43,7 +43,7 @@ "can_child_damage": "is_child and (Slingshot or Sticks or Kokiri_Sword or has_explosives or can_use(Dins_Fire))", "can_cut_shrubs": "is_adult or Sticks or Kokiri_Sword or Boomerang or has_explosives", "can_dive": "Progressive_Scale", - "can_leave_forest": "open_forest != 'closed' or is_adult or is_glitched or 'Defeat Queen Gohma'", + "can_leave_forest": "open_forest != 'closed' or is_adult or is_glitched or 'Deku Tree Clear'", "can_plant_bugs": "is_child and Bugs", "can_ride_epona": "is_adult and Epona and (can_play(Eponas_Song) or (is_glitched and can_hover))", "can_stun_deku": "is_adult or (Slingshot or Boomerang or Sticks or Kokiri_Sword or has_explosives or can_use(Dins_Fire) or Nuts or Deku_Shield)", diff --git a/data/World/Bosses.json b/data/World/Bosses.json index f21134d43f..1e7b78efa8 100644 --- a/data/World/Bosses.json +++ b/data/World/Bosses.json @@ -7,7 +7,9 @@ "scene": "Deku Tree Boss", "is_boss_room": true, "events": { - "Defeat Queen Gohma": "(Nuts or can_use(Slingshot)) and can_jumpslash" + "Defeat Queen Gohma": "(Nuts or can_use(Slingshot)) and can_jumpslash", + # separate event which appears in the playthrough only if it's required to open the forest exit + "Deku Tree Clear": "'Defeat Queen Gohma'" }, "locations": { "Deku Tree Queen Gohma Heart": "'Defeat Queen Gohma'",