From 4882366ffcff6ed4186fc1f22ea0df2806054f5f Mon Sep 17 00:00:00 2001 From: Alchav <59858495+Alchav@users.noreply.github.com> Date: Mon, 10 Mar 2025 10:56:05 -0400 Subject: [PATCH 01/38] LTTP: Fix TR Big Key Door Entrance Logic (#4712) --- worlds/alttp/Rules.py | 73 ++++++++++--------- .../test/inverted/TestInvertedTurtleRock.py | 30 ++++---- .../TestInvertedTurtleRock.py | 30 ++++---- .../alttp/test/inverted_owg/TestDungeons.py | 2 +- worlds/alttp/test/owg/TestDungeons.py | 4 +- 5 files changed, 70 insertions(+), 69 deletions(-) diff --git a/worlds/alttp/Rules.py b/worlds/alttp/Rules.py index f13178c6c5..47992947ac 100644 --- a/worlds/alttp/Rules.py +++ b/worlds/alttp/Rules.py @@ -1120,28 +1120,28 @@ def toss_junk_item(world, player): raise Exception("Unable to find a junk item to toss to make room for a TR small key") -def set_trock_key_rules(world, player): +def set_trock_key_rules(multiworld, player): # First set all relevant locked doors to impassible. for entrance in ['Turtle Rock Dark Room Staircase', 'Turtle Rock (Chain Chomp Room) (North)', 'Turtle Rock (Chain Chomp Room) (South)', 'Turtle Rock Entrance to Pokey Room', 'Turtle Rock (Pokey Room) (South)', 'Turtle Rock (Pokey Room) (North)', 'Turtle Rock Big Key Door']: - set_rule(world.get_entrance(entrance, player), lambda state: False) + set_rule(multiworld.get_entrance(entrance, player), lambda state: False) - all_state = world.get_all_state(use_cache=False, allow_partial_entrances=True) + all_state = multiworld.get_all_state(use_cache=False, allow_partial_entrances=True) all_state.reachable_regions[player] = set() # wipe reachable regions so that the locked doors actually work all_state.stale[player] = True # Check if each of the four main regions of the dungoen can be reached. The previous code section prevents key-costing moves within the dungeon. - can_reach_back = all_state.can_reach(world.get_region('Turtle Rock (Eye Bridge)', player)) - can_reach_front = all_state.can_reach(world.get_region('Turtle Rock (Entrance)', player)) - can_reach_big_chest = all_state.can_reach(world.get_region('Turtle Rock (Big Chest)', player)) - can_reach_middle = all_state.can_reach(world.get_region('Turtle Rock (Second Section)', player)) + can_reach_back = all_state.can_reach(multiworld.get_region('Turtle Rock (Eye Bridge)', player)) + can_reach_front = all_state.can_reach(multiworld.get_region('Turtle Rock (Entrance)', player)) + can_reach_big_chest = all_state.can_reach(multiworld.get_region('Turtle Rock (Big Chest)', player)) + can_reach_middle = all_state.can_reach(multiworld.get_region('Turtle Rock (Second Section)', player)) # If you can't enter from the back, the door to the front of TR requires only 2 small keys if the big key is in one of these chests since 2 key doors are locked behind the big key door. # If you can only enter from the middle, this includes all locations that can only be reached by exiting the front. This can include Laser Bridge and Crystaroller if the front and back connect via Dark DM Ledge! front_locked_locations = {('Turtle Rock - Compass Chest', player), ('Turtle Rock - Roller Room - Left', player), ('Turtle Rock - Roller Room - Right', player)} if can_reach_middle and not can_reach_back and not can_reach_front: normal_regions = all_state.reachable_regions[player].copy() - set_rule(world.get_entrance('Turtle Rock (Chain Chomp Room) (South)', player), lambda state: True) - set_rule(world.get_entrance('Turtle Rock (Pokey Room) (South)', player), lambda state: True) + set_rule(multiworld.get_entrance('Turtle Rock (Chain Chomp Room) (South)', player), lambda state: True) + set_rule(multiworld.get_entrance('Turtle Rock (Pokey Room) (South)', player), lambda state: True) all_state.update_reachable_regions(player) front_locked_regions = all_state.reachable_regions[player].difference(normal_regions) front_locked_locations = set((location.name, player) for region in front_locked_regions for location in region.locations) @@ -1151,37 +1151,38 @@ def set_trock_key_rules(world, player): # Big key door requires the big key, obviously. We removed this rule in the previous section to flag front_locked_locations correctly, # otherwise crystaroller room might not be properly marked as reachable through the back. - set_rule(world.get_entrance('Turtle Rock Big Key Door', player), lambda state: state.has('Big Key (Turtle Rock)', player)) + set_rule(multiworld.get_entrance('Turtle Rock Big Key Door', player), lambda state: state.has('Big Key (Turtle Rock)', player) and can_kill_most_things(state, player, 10) and can_bomb_or_bonk(state, player)) + # No matter what, the key requirement for going from the middle to the bottom should be five keys. - set_rule(world.get_entrance('Turtle Rock Dark Room Staircase', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 5)) + set_rule(multiworld.get_entrance('Turtle Rock Dark Room Staircase', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 5)) # Now we need to set rules based on which entrances we have access to. The most important point is whether we have back access. If we have back access, we # might open all the locked doors in any order, so we need maximally restrictive rules. if can_reach_back: - set_rule(world.get_location('Turtle Rock - Big Key Chest', player), lambda state: (state._lttp_has_key('Small Key (Turtle Rock)', player, 6) or location_item_name(state, 'Turtle Rock - Big Key Chest', player) == ('Small Key (Turtle Rock)', player))) - set_rule(world.get_entrance('Turtle Rock (Chain Chomp Room) (South)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 5)) - set_rule(world.get_entrance('Turtle Rock (Pokey Room) (South)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 6)) + set_rule(multiworld.get_location('Turtle Rock - Big Key Chest', player), lambda state: (state._lttp_has_key('Small Key (Turtle Rock)', player, 6) or location_item_name(state, 'Turtle Rock - Big Key Chest', player) == ('Small Key (Turtle Rock)', player))) + set_rule(multiworld.get_entrance('Turtle Rock (Chain Chomp Room) (South)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 5)) + set_rule(multiworld.get_entrance('Turtle Rock (Pokey Room) (South)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 6)) - set_rule(world.get_entrance('Turtle Rock (Chain Chomp Room) (North)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 6)) - set_rule(world.get_entrance('Turtle Rock (Pokey Room) (North)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 6)) - set_rule(world.get_entrance('Turtle Rock Entrance to Pokey Room', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 5)) + set_rule(multiworld.get_entrance('Turtle Rock (Chain Chomp Room) (North)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 6)) + set_rule(multiworld.get_entrance('Turtle Rock (Pokey Room) (North)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 6)) + set_rule(multiworld.get_entrance('Turtle Rock Entrance to Pokey Room', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 5)) else: # Middle to front requires 3 keys if the back is locked by this door, otherwise 5 - set_rule(world.get_entrance('Turtle Rock (Chain Chomp Room) (South)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 3) + set_rule(multiworld.get_entrance('Turtle Rock (Chain Chomp Room) (South)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 3) if item_name_in_location_names(state, 'Big Key (Turtle Rock)', player, front_locked_locations.union({('Turtle Rock - Pokey 1 Key Drop', player)})) else state._lttp_has_key('Small Key (Turtle Rock)', player, 5)) # Middle to front requires 4 keys if the back is locked by this door, otherwise 6 - set_rule(world.get_entrance('Turtle Rock (Pokey Room) (South)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 4) + set_rule(multiworld.get_entrance('Turtle Rock (Pokey Room) (South)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 4) if item_name_in_location_names(state, 'Big Key (Turtle Rock)', player, front_locked_locations) else state._lttp_has_key('Small Key (Turtle Rock)', player, 6)) # Front to middle requires 3 keys (if the middle is accessible then these doors can be avoided, otherwise no keys can be wasted) - set_rule(world.get_entrance('Turtle Rock (Chain Chomp Room) (North)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 3)) - set_rule(world.get_entrance('Turtle Rock (Pokey Room) (North)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 2)) - set_rule(world.get_entrance('Turtle Rock Entrance to Pokey Room', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 1)) + set_rule(multiworld.get_entrance('Turtle Rock (Chain Chomp Room) (North)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 3)) + set_rule(multiworld.get_entrance('Turtle Rock (Pokey Room) (North)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 2)) + set_rule(multiworld.get_entrance('Turtle Rock Entrance to Pokey Room', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 1)) - set_rule(world.get_location('Turtle Rock - Big Key Chest', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, tr_big_key_chest_keys_needed(state))) + set_rule(multiworld.get_location('Turtle Rock - Big Key Chest', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, tr_big_key_chest_keys_needed(state))) def tr_big_key_chest_keys_needed(state): # This function handles the key requirements for the TR Big Chest in the situations it having the Big Key should logically require 2 keys, small key @@ -1194,30 +1195,30 @@ def set_trock_key_rules(world, player): return 6 # If TR is only accessible from the middle, the big key must be further restricted to prevent softlock potential - if not can_reach_front and not world.small_key_shuffle[player]: + if not can_reach_front and not multiworld.small_key_shuffle[player]: # Must not go in the Big Key Chest - only 1 other chest available and 2+ keys required for all other chests - forbid_item(world.get_location('Turtle Rock - Big Key Chest', player), 'Big Key (Turtle Rock)', player) + forbid_item(multiworld.get_location('Turtle Rock - Big Key Chest', player), 'Big Key (Turtle Rock)', player) if not can_reach_big_chest: # Must not go in the Chain Chomps chest - only 2 other chests available and 3+ keys required for all other chests - forbid_item(world.get_location('Turtle Rock - Chain Chomps', player), 'Big Key (Turtle Rock)', player) - forbid_item(world.get_location('Turtle Rock - Pokey 2 Key Drop', player), 'Big Key (Turtle Rock)', player) - if world.accessibility[player] == 'full': - if world.big_key_shuffle[player] and can_reach_big_chest: + forbid_item(multiworld.get_location('Turtle Rock - Chain Chomps', player), 'Big Key (Turtle Rock)', player) + forbid_item(multiworld.get_location('Turtle Rock - Pokey 2 Key Drop', player), 'Big Key (Turtle Rock)', player) + if multiworld.accessibility[player] == 'full': + if multiworld.big_key_shuffle[player] and can_reach_big_chest: # Must not go in the dungeon - all 3 available chests (Chomps, Big Chest, Crystaroller) must be keys to access laser bridge, and the big key is required first for location in ['Turtle Rock - Chain Chomps', 'Turtle Rock - Compass Chest', 'Turtle Rock - Pokey 1 Key Drop', 'Turtle Rock - Pokey 2 Key Drop', 'Turtle Rock - Roller Room - Left', 'Turtle Rock - Roller Room - Right']: - forbid_item(world.get_location(location, player), 'Big Key (Turtle Rock)', player) + forbid_item(multiworld.get_location(location, player), 'Big Key (Turtle Rock)', player) else: # A key is required in the Big Key Chest to prevent a possible softlock. Place an extra key to ensure 100% locations still works - item = item_factory('Small Key (Turtle Rock)', world.worlds[player]) - location = world.get_location('Turtle Rock - Big Key Chest', player) + item = item_factory('Small Key (Turtle Rock)', multiworld.worlds[player]) + location = multiworld.get_location('Turtle Rock - Big Key Chest', player) location.place_locked_item(item) - toss_junk_item(world, player) + toss_junk_item(multiworld, player) - if world.accessibility[player] != 'full': - set_always_allow(world.get_location('Turtle Rock - Big Key Chest', player), lambda state, item: item.name == 'Small Key (Turtle Rock)' and item.player == player - and state.can_reach(state.multiworld.get_region('Turtle Rock (Second Section)', player))) + if multiworld.accessibility[player] != 'full': + set_always_allow(multiworld.get_location('Turtle Rock - Big Key Chest', player), lambda state, item: item.name == 'Small Key (Turtle Rock)' and item.player == player + and state.can_reach(state.multiworld.get_region('Turtle Rock (Second Section)', player))) def set_big_bomb_rules(world, player): diff --git a/worlds/alttp/test/inverted/TestInvertedTurtleRock.py b/worlds/alttp/test/inverted/TestInvertedTurtleRock.py index db3084b02a..21bcf70964 100644 --- a/worlds/alttp/test/inverted/TestInvertedTurtleRock.py +++ b/worlds/alttp/test/inverted/TestInvertedTurtleRock.py @@ -79,12 +79,12 @@ class TestInvertedTurtleRock(TestInverted): ["Turtle Rock - Crystaroller Room", False, [], ['Big Key (Turtle Rock)', 'Lamp']], ["Turtle Rock - Crystaroller Room", False, [], ['Magic Mirror', 'Cane of Somaria']], ["Turtle Rock - Crystaroller Room", False, ['Small Key (Turtle Rock)', 'Small Key (Turtle Rock)'], ['Magic Mirror', 'Small Key (Turtle Rock)']], - ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], - ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], - ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove']], - ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Moon Pearl', 'Hookshot']], - ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Moon Pearl', 'Flute', 'Magic Mirror', 'Hookshot']], - ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Flute', 'Progressive Glove', 'Progressive Glove', 'Magic Mirror']], + ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], + ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], + ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove']], + ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Moon Pearl', 'Hookshot']], + ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Moon Pearl', 'Flute', 'Magic Mirror', 'Hookshot']], + ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Flute', 'Progressive Glove', 'Progressive Glove', 'Magic Mirror']], ["Turtle Rock - Crystaroller Room", True, ['Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove', 'Cane of Somaria']], ["Turtle Rock - Crystaroller Room", True, ['Lamp', 'Magic Mirror', 'Progressive Glove', 'Moon Pearl', 'Hookshot', 'Cane of Somaria']], ["Turtle Rock - Crystaroller Room", True, ['Lamp', 'Moon Pearl', 'Flute', 'Magic Mirror', 'Hookshot', 'Cane of Somaria']], @@ -97,9 +97,9 @@ class TestInvertedTurtleRock(TestInverted): ["Turtle Rock - Boss", False, [], ['Big Key (Turtle Rock)']], ["Turtle Rock - Boss", False, [], ['Magic Mirror', 'Lamp']], ["Turtle Rock - Boss", False, ['Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)'], ['Small Key (Turtle Rock)']], - ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Flute', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']], - ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']], - ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Magic Upgrade (1/2)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)','Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']], + ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Flute', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Big Key (Turtle Rock)']], + ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Big Key (Turtle Rock)']], + ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Magic Upgrade (1/2)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)','Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Big Key (Turtle Rock)']], ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove', 'Hammer', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']], ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Flute', 'Magic Mirror', 'Moon Pearl', 'Hookshot', 'Hammer', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']] @@ -117,12 +117,12 @@ class TestInvertedTurtleRock(TestInverted): [location, False, [], ['Magic Mirror', 'Cane of Somaria']], [location, False, [], ['Magic Mirror', 'Lamp']], [location, False, ['Small Key (Turtle Rock)', 'Small Key (Turtle Rock)'], ['Magic Mirror', 'Small Key (Turtle Rock)']], - [location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cane of Byrna']], - [location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cane of Byrna']], - [location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cape']], - [location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cape']], - [location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']], - [location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']], + [location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cane of Byrna']], + [location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cane of Byrna']], + [location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cape']], + [location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cape']], + [location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']], + [location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']], # Mirroring into Eye Bridge does not require Cane of Somaria [location, True, ['Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove', 'Cane of Byrna']], diff --git a/worlds/alttp/test/inverted_minor_glitches/TestInvertedTurtleRock.py b/worlds/alttp/test/inverted_minor_glitches/TestInvertedTurtleRock.py index a416e1b35d..343cf3f8b1 100644 --- a/worlds/alttp/test/inverted_minor_glitches/TestInvertedTurtleRock.py +++ b/worlds/alttp/test/inverted_minor_glitches/TestInvertedTurtleRock.py @@ -80,12 +80,12 @@ class TestInvertedTurtleRock(TestInvertedMinor): ["Turtle Rock - Crystaroller Room", False, [], ['Big Key (Turtle Rock)', 'Lamp']], ["Turtle Rock - Crystaroller Room", False, [], ['Magic Mirror', 'Cane of Somaria']], ["Turtle Rock - Crystaroller Room", False, ['Small Key (Turtle Rock)', 'Small Key (Turtle Rock)'], ['Magic Mirror', 'Small Key (Turtle Rock)']], - ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], - ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], - ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove']], - ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Moon Pearl', 'Hookshot']], - ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Moon Pearl', 'Flute', 'Magic Mirror', 'Hookshot']], - ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Flute', 'Progressive Glove', 'Progressive Glove', 'Magic Mirror']], + ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], + ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']], + ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove']], + ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Moon Pearl', 'Hookshot']], + ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Moon Pearl', 'Flute', 'Magic Mirror', 'Hookshot']], + ["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Flute', 'Progressive Glove', 'Progressive Glove', 'Magic Mirror']], ["Turtle Rock - Crystaroller Room", True, ['Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove', 'Cane of Somaria']], ["Turtle Rock - Crystaroller Room", True, ['Lamp', 'Magic Mirror', 'Progressive Glove', 'Moon Pearl', 'Hookshot', 'Cane of Somaria']], ["Turtle Rock - Crystaroller Room", True, ['Lamp', 'Moon Pearl', 'Flute', 'Magic Mirror', 'Hookshot', 'Cane of Somaria']], @@ -98,9 +98,9 @@ class TestInvertedTurtleRock(TestInvertedMinor): ["Turtle Rock - Boss", False, [], ['Big Key (Turtle Rock)']], ["Turtle Rock - Boss", False, [], ['Magic Mirror', 'Lamp']], ["Turtle Rock - Boss", False, ['Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)'], ['Small Key (Turtle Rock)']], - ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Flute', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']], - ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']], - ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Magic Upgrade (1/2)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)','Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']], + ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Flute', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Big Key (Turtle Rock)']], + ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Big Key (Turtle Rock)']], + ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Magic Upgrade (1/2)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)','Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Big Key (Turtle Rock)']], ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove', 'Hammer', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']], ["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Flute', 'Magic Mirror', 'Moon Pearl', 'Hookshot', 'Hammer', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']] ]) @@ -116,12 +116,12 @@ class TestInvertedTurtleRock(TestInvertedMinor): [location, False, [], ['Magic Mirror', 'Cane of Somaria']], [location, False, [], ['Magic Mirror', 'Lamp']], [location, False, ['Small Key (Turtle Rock)', 'Small Key (Turtle Rock)'], ['Magic Mirror', 'Small Key (Turtle Rock)']], - [location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cane of Byrna']], - [location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cane of Byrna']], - [location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cape']], - [location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cape']], - [location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']], - [location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']], + [location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cane of Byrna']], + [location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cane of Byrna']], + [location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cape']], + [location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cape']], + [location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']], + [location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']], # Mirroring into Eye Bridge does not require Cane of Somaria [location, True, ['Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove', 'Cane of Byrna']], diff --git a/worlds/alttp/test/inverted_owg/TestDungeons.py b/worlds/alttp/test/inverted_owg/TestDungeons.py index ada1b92fca..595587f006 100644 --- a/worlds/alttp/test/inverted_owg/TestDungeons.py +++ b/worlds/alttp/test/inverted_owg/TestDungeons.py @@ -102,7 +102,7 @@ class TestDungeons(TestInvertedOWG): ["Turtle Rock - Chain Chomps", True, ['Progressive Sword', 'Progressive Sword', 'Pegasus Boots']], ["Turtle Rock - Crystaroller Room", False, []], - ["Turtle Rock - Crystaroller Room", True, ['Pegasus Boots', 'Magic Mirror', 'Moon Pearl', 'Big Key (Turtle Rock)']], + ["Turtle Rock - Crystaroller Room", True, ['Pegasus Boots', 'Magic Mirror', 'Moon Pearl', 'Big Key (Turtle Rock)', 'Bomb Upgrade (50)']], ["Turtle Rock - Crystaroller Room", True, ['Pegasus Boots', 'Magic Mirror', 'Moon Pearl', 'Lamp', 'Cane of Somaria']], ["Ganons Tower - Hope Room - Left", False, []], diff --git a/worlds/alttp/test/owg/TestDungeons.py b/worlds/alttp/test/owg/TestDungeons.py index 2e55b308d3..e9b1fde285 100644 --- a/worlds/alttp/test/owg/TestDungeons.py +++ b/worlds/alttp/test/owg/TestDungeons.py @@ -120,8 +120,8 @@ class TestDungeons(TestVanillaOWG): #todo: does clip require sword? #["Turtle Rock - Crystaroller Room", True, ['Moon Pearl', 'Pegasus Boots', 'Big Key (Turtle Rock)']], ["Turtle Rock - Crystaroller Room", True, ['Moon Pearl', 'Pegasus Boots', 'Big Key (Turtle Rock)', 'Progressive Sword']], - ["Turtle Rock - Crystaroller Room", True, ['Moon Pearl', 'Pegasus Boots', 'Big Key (Turtle Rock)', 'Hookshot']], - ["Turtle Rock - Crystaroller Room", True, ['Pegasus Boots', 'Magic Mirror', 'Big Key (Turtle Rock)']], + ["Turtle Rock - Crystaroller Room", True, ['Moon Pearl', 'Pegasus Boots', 'Big Key (Turtle Rock)', 'Hookshot', 'Bomb Upgrade (50)']], + ["Turtle Rock - Crystaroller Room", True, ['Pegasus Boots', 'Magic Mirror', 'Big Key (Turtle Rock)', 'Bomb Upgrade (50)']], ["Ganons Tower - Hope Room - Left", False, []], ["Ganons Tower - Hope Room - Left", False, ['Moon Pearl', 'Crystal 1']], From 7c30c4a16952a3a6b325bdc6cb497b9fa0591714 Mon Sep 17 00:00:00 2001 From: Aaron Wagener Date: Mon, 10 Mar 2025 10:16:09 -0500 Subject: [PATCH 02/38] The Messenger: Transition Shuffle (#4402) * The Messenger: transition rando * remove unused import * always link both directions for plando when using coupled transitions * er_type was renamed to randomization_type * use frozenset for things that shouldn't change * review suggestions * do portal and transition shuffle in `connect_entrances` * remove some unnecessary connections that were causing entrance caching collisions * add test for strictest possible ER settings * use unittest.skip on the skipped test, so we don't waste time doing setUp and tearDown * use the world helpers * make the plando connection description more verbose * always add searing crags portal if portal shuffle is disabled * guarantee an arbitrary number of locations with first connection * make the constraints more lenient for a bit more variety --- worlds/messenger/__init__.py | 82 ++++++++++++++--- worlds/messenger/connections.py | 74 ++++++++------- worlds/messenger/options.py | 33 ++++--- worlds/messenger/portals.py | 10 +- worlds/messenger/subclasses.py | 23 ++++- .../test/test_entrance_randomization.py | 19 ++++ worlds/messenger/transitions.py | 92 +++++++++++++++++++ 7 files changed, 258 insertions(+), 75 deletions(-) create mode 100644 worlds/messenger/test/test_entrance_randomization.py create mode 100644 worlds/messenger/transitions.py diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index a6effc31d5..8bde3bbc7a 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -1,7 +1,7 @@ import logging from typing import Any, ClassVar, TextIO -from BaseClasses import CollectionState, Entrance, Item, ItemClassification, MultiWorld, Tutorial +from BaseClasses import CollectionState, Entrance, EntranceType, Item, ItemClassification, MultiWorld, Tutorial from Options import Accessibility from Utils import output_path from settings import FilePath, Group @@ -17,6 +17,7 @@ from .regions import LEVELS, MEGA_SHARDS, LOCATIONS, REGION_CONNECTIONS from .rules import MessengerHardRules, MessengerOOBRules, MessengerRules from .shop import FIGURINES, PROG_SHOP_ITEMS, SHOP_ITEMS, USEFUL_SHOP_ITEMS, shuffle_shop_prices from .subclasses import MessengerEntrance, MessengerItem, MessengerRegion, MessengerShopLocation +from .transitions import shuffle_transitions components.append( Component("The Messenger", component_type=Type.CLIENT, func=launch_game, game_name="The Messenger", supports_uri=True) @@ -128,7 +129,7 @@ class MessengerWorld(World): spoiler_portal_mapping: dict[str, str] portal_mapping: list[int] transitions: list[Entrance] - reachable_locs: int = 0 + reachable_locs: bool = False filler: dict[str, int] def generate_early(self) -> None: @@ -145,13 +146,13 @@ class MessengerWorld(World): self.shop_prices, self.figurine_prices = shuffle_shop_prices(self) - starting_portals = ["Autumn Hills", "Howling Grotto", "Glacial Peak", "Riviere Turquoise", "Sunken Shrine", "Searing Crags"] + starting_portals = ["Autumn Hills", "Howling Grotto", "Glacial Peak", "Riviere Turquoise", "Sunken Shrine", + "Searing Crags"] self.starting_portals = [f"{portal} Portal" for portal in starting_portals[:3] + self.random.sample(starting_portals[3:], k=self.options.available_portals - 3)] # super complicated method for adding searing crags to starting portals if it wasn't chosen - # TODO add a check for transition shuffle when that gets added back in if not self.options.shuffle_portals and "Searing Crags Portal" not in self.starting_portals: self.starting_portals.append("Searing Crags Portal") portals_to_strip = [portal for portal in ["Riviere Turquoise Portal", "Sunken Shrine Portal"] @@ -181,7 +182,7 @@ class MessengerWorld(World): region_name = region.name.removeprefix(f"{region.parent} - ") connection_data = CONNECTIONS[region.parent][region_name] for exit_region in connection_data: - region.connect(self.multiworld.get_region(exit_region, self.player)) + region.connect(self.get_region(exit_region)) # all regions need to be created before i can do these connections so we create and connect the complex first for region in [level for level in simple_regions if level.name in REGION_CONNECTIONS]: @@ -256,6 +257,7 @@ class MessengerWorld(World): f" {logic} for {self.multiworld.get_player_name(self.player)}") # MessengerOOBRules(self).set_messenger_rules() + def connect_entrances(self) -> None: add_closed_portal_reqs(self) # i need portal shuffle to happen after rules exist so i can validate it attempts = 5 @@ -271,6 +273,9 @@ class MessengerWorld(World): else: raise RuntimeError("Unable to generate valid portal output.") + if self.options.shuffle_transitions: + shuffle_transitions(self) + def write_spoiler_header(self, spoiler_handle: TextIO) -> None: if self.options.available_portals < 6: spoiler_handle.write(f"\nStarting Portals:\n\n") @@ -286,9 +291,54 @@ class MessengerWorld(World): key=lambda portal: ["Autumn Hills", "Riviere Turquoise", "Howling Grotto", "Sunken Shrine", - "Searing Crags", "Glacial Peak"].index(portal[0])) + "Searing Crags", "Glacial Peak"].index(portal[0]) + ) for portal, output in portal_info: - spoiler.set_entrance(f"{portal} Portal", output, "I can write anything I want here lmao", self.player) + spoiler.set_entrance(f"{portal} Portal", output, "", self.player) + + if self.options.shuffle_transitions: + for transition in self.transitions: + if (transition.randomization_type == EntranceType.TWO_WAY + and (transition.connected_region.name, "both", self.player) in spoiler.entrances): + continue + spoiler.set_entrance( + transition.name if "->" not in transition.name else transition.parent_region.name, + transition.connected_region.name, + "both" if transition.randomization_type == EntranceType.TWO_WAY + and self.options.shuffle_transitions == ShuffleTransitions.option_coupled else "", + self.player + ) + + def extend_hint_information(self, hint_data: dict[int, dict[int, str]]) -> None: + if not self.options.shuffle_transitions: + return + + hint_data.update({self.player: {}}) + + all_state = self.multiworld.get_all_state(True) + # sometimes some of my regions aren't in path for some reason? + all_state.update_reachable_regions(self.player) + paths = all_state.path + start = self.get_region("Tower HQ") + start_connections = [entrance.name for entrance in start.exits if entrance not in {"Home", "Shrink Down"}] + transition_names = [transition.name for transition in self.transitions] + start_connections + for loc in self.get_locations(): + if (loc.parent_region.name in {"Tower HQ", "The Shop", "Music Box", "The Craftsman's Corner"} + or loc.address is None): + continue + path_to_loc: list[str] = [] + name, connection = paths.get(loc.parent_region, (None, None)) + while connection != ("Menu", None) and name is not None: + name, connection = connection + if name in transition_names: + if name in start_connections: + name = f"{name} -> {self.get_entrance(name).connected_region.name}" + path_to_loc.append(name) + + text = " => ".join(reversed(path_to_loc)) + if not text: + continue + hint_data[self.player][loc.address] = text def fill_slot_data(self) -> dict[str, Any]: slot_data = { @@ -308,11 +358,13 @@ class MessengerWorld(World): def get_filler_item_name(self) -> str: if not getattr(self, "_filler_items", None): - self._filler_items = [name for name in self.random.choices( - list(self.filler), - weights=list(self.filler.values()), - k=20 - )] + self._filler_items = [ + name for name in self.random.choices( + list(self.filler), + weights=list(self.filler.values()), + k=20 + ) + ] return self._filler_items.pop(0) def create_item(self, name: str) -> MessengerItem: @@ -331,7 +383,7 @@ class MessengerWorld(World): self.total_shards += count return ItemClassification.progression_skip_balancing if count else ItemClassification.filler - if name == "Windmill Shuriken" and getattr(self, "multiworld", None) is not None: + if name == "Windmill Shuriken": return ItemClassification.progression if self.options.logic_level else ItemClassification.filler if name == "Power Seal": @@ -344,7 +396,7 @@ class MessengerWorld(World): if name in {*USEFUL_ITEMS, *USEFUL_SHOP_ITEMS}: return ItemClassification.useful - + if name in TRAPS: return ItemClassification.trap @@ -354,7 +406,7 @@ class MessengerWorld(World): def create_group(cls, multiworld: "MultiWorld", new_player_id: int, players: set[int]) -> World: group = super().create_group(multiworld, new_player_id, players) assert isinstance(group, MessengerWorld) - + group.filler = FILLER.copy() group.options.traps.value = all(multiworld.worlds[player].options.traps for player in players) if group.options.traps: diff --git a/worlds/messenger/connections.py b/worlds/messenger/connections.py index 79912a5688..84f7f9b242 100644 --- a/worlds/messenger/connections.py +++ b/worlds/messenger/connections.py @@ -244,14 +244,12 @@ CONNECTIONS: dict[str, dict[str, list[str]]] = { "Bottom Left": [ "Howling Grotto - Top", "Quillshroom Marsh - Sand Trap Shop", - "Quillshroom Marsh - Bottom Right", ], "Top Right": [ "Quillshroom Marsh - Queen of Quills Shop", "Searing Crags - Left", ], "Bottom Right": [ - "Quillshroom Marsh - Bottom Left", "Quillshroom Marsh - Sand Trap Shop", "Searing Crags - Bottom", ], @@ -639,43 +637,43 @@ CONNECTIONS: dict[str, dict[str, list[str]]] = { } RANDOMIZED_CONNECTIONS: dict[str, str] = { - "Ninja Village - Right": "Autumn Hills - Left", - "Autumn Hills - Left": "Ninja Village - Right", - "Autumn Hills - Right": "Forlorn Temple - Left", - "Autumn Hills - Bottom": "Catacombs - Bottom Left", - "Forlorn Temple - Left": "Autumn Hills - Right", - "Forlorn Temple - Right": "Bamboo Creek - Top Left", - "Forlorn Temple - Bottom": "Catacombs - Top Left", - "Catacombs - Top Left": "Forlorn Temple - Bottom", - "Catacombs - Bottom Left": "Autumn Hills - Bottom", - "Catacombs - Bottom": "Dark Cave - Right", - "Catacombs - Right": "Bamboo Creek - Bottom Left", - "Bamboo Creek - Bottom Left": "Catacombs - Right", - "Bamboo Creek - Right": "Howling Grotto - Left", - "Bamboo Creek - Top Left": "Forlorn Temple - Right", - "Howling Grotto - Left": "Bamboo Creek - Right", - "Howling Grotto - Top": "Quillshroom Marsh - Bottom Left", - "Howling Grotto - Right": "Quillshroom Marsh - Top Left", - "Howling Grotto - Bottom": "Sunken Shrine - Left", - "Quillshroom Marsh - Top Left": "Howling Grotto - Right", - "Quillshroom Marsh - Bottom Left": "Howling Grotto - Top", - "Quillshroom Marsh - Top Right": "Searing Crags - Left", + "Ninja Village - Right": "Autumn Hills - Left", + "Autumn Hills - Left": "Ninja Village - Right", + "Autumn Hills - Right": "Forlorn Temple - Left", + "Autumn Hills - Bottom": "Catacombs - Bottom Left", + "Forlorn Temple - Left": "Autumn Hills - Right", + "Forlorn Temple - Right": "Bamboo Creek - Top Left", + "Forlorn Temple - Bottom": "Catacombs - Top Left", + "Catacombs - Top Left": "Forlorn Temple - Bottom", + "Catacombs - Bottom Left": "Autumn Hills - Bottom", + "Catacombs - Bottom": "Dark Cave - Right", + "Catacombs - Right": "Bamboo Creek - Bottom Left", + "Bamboo Creek - Bottom Left": "Catacombs - Right", + "Bamboo Creek - Right": "Howling Grotto - Left", + "Bamboo Creek - Top Left": "Forlorn Temple - Right", + "Howling Grotto - Left": "Bamboo Creek - Right", + "Howling Grotto - Top": "Quillshroom Marsh - Bottom Left", + "Howling Grotto - Right": "Quillshroom Marsh - Top Left", + "Howling Grotto - Bottom": "Sunken Shrine - Left", + "Quillshroom Marsh - Top Left": "Howling Grotto - Right", + "Quillshroom Marsh - Bottom Left": "Howling Grotto - Top", + "Quillshroom Marsh - Top Right": "Searing Crags - Left", "Quillshroom Marsh - Bottom Right": "Searing Crags - Bottom", - "Searing Crags - Left": "Quillshroom Marsh - Top Right", - "Searing Crags - Top": "Glacial Peak - Bottom", - "Searing Crags - Bottom": "Quillshroom Marsh - Bottom Right", - "Searing Crags - Right": "Underworld - Left", - "Glacial Peak - Bottom": "Searing Crags - Top", - "Glacial Peak - Top": "Cloud Ruins - Left", - "Glacial Peak - Left": "Elemental Skylands - Air Shmup", - "Cloud Ruins - Left": "Glacial Peak - Top", - "Elemental Skylands - Right": "Glacial Peak - Left", - "Tower HQ": "Tower of Time - Left", - "Artificer": "Corrupted Future", - "Underworld - Left": "Searing Crags - Right", - "Dark Cave - Right": "Catacombs - Bottom", - "Dark Cave - Left": "Riviere Turquoise - Right", - "Sunken Shrine - Left": "Howling Grotto - Bottom", + "Searing Crags - Left": "Quillshroom Marsh - Top Right", + "Searing Crags - Top": "Glacial Peak - Bottom", + "Searing Crags - Bottom": "Quillshroom Marsh - Bottom Right", + "Searing Crags - Right": "Underworld - Left", + "Glacial Peak - Bottom": "Searing Crags - Top", + "Glacial Peak - Top": "Cloud Ruins - Left", + "Glacial Peak - Left": "Elemental Skylands - Air Shmup", + "Cloud Ruins - Left": "Glacial Peak - Top", + "Elemental Skylands - Right": "Glacial Peak - Left", + "Tower HQ": "Tower of Time - Left", + "Artificer": "Corrupted Future", + "Underworld - Left": "Searing Crags - Right", + "Dark Cave - Right": "Catacombs - Bottom", + "Dark Cave - Left": "Riviere Turquoise - Right", + "Sunken Shrine - Left": "Howling Grotto - Bottom", } TRANSITIONS: list[str] = [ diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py index 8b61a94354..9ee04d26a6 100644 --- a/worlds/messenger/options.py +++ b/worlds/messenger/options.py @@ -3,7 +3,8 @@ from dataclasses import dataclass from schema import And, Optional, Or, Schema from Options import Choice, DeathLinkMixin, DefaultOnToggle, ItemsAccessibility, OptionDict, PerGameCommonOptions, \ - PlandoConnections, Range, StartInventoryPool, Toggle, Visibility + PlandoConnections, Range, StartInventoryPool, Toggle +from . import RANDOMIZED_CONNECTIONS from .portals import CHECKPOINTS, PORTALS, SHOP_POINTS @@ -30,17 +31,25 @@ class PortalPlando(PlandoConnections): portals = [f"{portal} Portal" for portal in PORTALS] shop_points = [point for points in SHOP_POINTS.values() for point in points] checkpoints = [point for points in CHECKPOINTS.values() for point in points] - portal_entrances = PORTALS - portal_exits = portals + shop_points + checkpoints - entrances = portal_entrances - exits = portal_exits + + entrances = frozenset(PORTALS) + exits = frozenset(portals + shop_points + checkpoints) -# for back compatibility. To later be replaced with transition plando -class HiddenPortalPlando(PortalPlando): - visibility = Visibility.none - entrances = PortalPlando.entrances - exits = PortalPlando.exits +class TransitionPlando(PlandoConnections): + """ + Plando connections to be used with transition shuffle. + List of valid connections can be found at https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/messenger/connections.py#L641. + Dictionary keys (left) are entrances and values (right) are exits. If transition shuffle is on coupled all plando + connections will be coupled. If on decoupled, "entrance" and "exit" will be treated the same, simply making the + plando connection one-way from entrance to exit. + Example: + - entrance: Searing Crags - Top + exit: Dark Cave - Right + direction: both + """ + entrances = frozenset(RANDOMIZED_CONNECTIONS.keys()) + exits = frozenset(RANDOMIZED_CONNECTIONS.values()) class Logic(Choice): @@ -226,7 +235,7 @@ class MessengerOptions(DeathLinkMixin, PerGameCommonOptions): early_meditation: EarlyMed available_portals: AvailablePortals shuffle_portals: ShufflePortals - # shuffle_transitions: ShuffleTransitions + shuffle_transitions: ShuffleTransitions goal: Goal music_box: MusicBox notes_needed: NotesNeeded @@ -236,4 +245,4 @@ class MessengerOptions(DeathLinkMixin, PerGameCommonOptions): shop_price: ShopPrices shop_price_plan: PlannedShopPrices portal_plando: PortalPlando - plando_connections: HiddenPortalPlando + plando_connections: TransitionPlando diff --git a/worlds/messenger/portals.py b/worlds/messenger/portals.py index 896fefa686..704285896c 100644 --- a/worlds/messenger/portals.py +++ b/worlds/messenger/portals.py @@ -1,7 +1,7 @@ from copy import deepcopy from typing import TYPE_CHECKING -from BaseClasses import CollectionState, PlandoOptions +from BaseClasses import CollectionState from Options import PlandoConnection if TYPE_CHECKING: @@ -252,9 +252,7 @@ def shuffle_portals(world: "MessengerWorld") -> None: world.random.shuffle(available_portals) plando = world.options.portal_plando.value - if not plando: - plando = world.options.plando_connections.value - if plando and world.multiworld.plando_options & PlandoOptions.connections and not world.plando_portals: + if plando and not world.plando_portals: try: handle_planned_portals(plando) # any failure i expect will trigger on available_portals.remove @@ -294,8 +292,8 @@ def disconnect_portals(world: "MessengerWorld") -> None: def validate_portals(world: "MessengerWorld") -> bool: - # if world.options.shuffle_transitions: - # return True + if world.options.shuffle_transitions: + return True new_state = CollectionState(world.multiworld) new_state.update_reachable_regions(world.player) reachable_locs = 0 diff --git a/worlds/messenger/subclasses.py b/worlds/messenger/subclasses.py index 29e3ea8953..0138a3f074 100644 --- a/worlds/messenger/subclasses.py +++ b/worlds/messenger/subclasses.py @@ -1,7 +1,8 @@ from functools import cached_property from typing import TYPE_CHECKING -from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Region +from BaseClasses import CollectionState, Entrance, EntranceType, Item, ItemClassification, Location, Region +from entrance_rando import ERPlacementState from .regions import LOCATIONS, MEGA_SHARDS from .shop import FIGURINES, SHOP_ITEMS @@ -12,9 +13,21 @@ if TYPE_CHECKING: class MessengerEntrance(Entrance): world: "MessengerWorld | None" = None + def can_connect_to(self, other: Entrance, dead_end: bool, state: "ERPlacementState") -> bool: + can_connect = super().can_connect_to(other, dead_end, state) + world: MessengerWorld = getattr(self, "world", None) + if not world or world.reachable_locs or not can_connect: + return can_connect + empty_state = CollectionState(world.multiworld, True) + self.connected_region = other.connected_region + empty_state.update_reachable_regions(world.player) + world.reachable_locs = any(loc.can_reach(empty_state) and not loc.is_event for loc in world.get_locations()) + self.connected_region = None + return world.reachable_locs and (not state.coupled or self.name != other.name) + class MessengerRegion(Region): - parent: str + parent: str | None entrance_type = MessengerEntrance def __init__(self, name: str, world: "MessengerWorld", parent: str | None = None) -> None: @@ -32,8 +45,9 @@ class MessengerRegion(Region): for shop_loc in SHOP_ITEMS} self.add_locations(shop_locations, MessengerShopLocation) elif name == "The Craftsman's Corner": - self.add_locations({figurine: world.location_name_to_id[figurine] for figurine in FIGURINES}, - MessengerLocation) + self.add_locations( + {figurine: world.location_name_to_id[figurine] for figurine in FIGURINES}, + MessengerLocation) elif name == "Tower HQ": locations.append("Money Wrench") @@ -57,6 +71,7 @@ class MessengerLocation(Location): class MessengerShopLocation(MessengerLocation): + @cached_property def cost(self) -> int: name = self.name.removeprefix("The Shop - ") diff --git a/worlds/messenger/test/test_entrance_randomization.py b/worlds/messenger/test/test_entrance_randomization.py new file mode 100644 index 0000000000..2a06a2e034 --- /dev/null +++ b/worlds/messenger/test/test_entrance_randomization.py @@ -0,0 +1,19 @@ +import unittest + +from . import MessengerTestBase + + +class StrictEntranceRandoTest(MessengerTestBase): + """Bare-bones world that tests the strictest possible settings to ensure it doesn't crash""" + auto_construct = True + options = { + "limited_movement": 1, + "available_portals": 3, + "shuffle_portals": 1, + "shuffle_transitions": 1, + } + + @unittest.skip + def test_all_state_can_reach_everything(self) -> None: + """It's not possible to reach everything with these options so skip this test.""" + pass diff --git a/worlds/messenger/transitions.py b/worlds/messenger/transitions.py new file mode 100644 index 0000000000..1db975b3cd --- /dev/null +++ b/worlds/messenger/transitions.py @@ -0,0 +1,92 @@ +from typing import TYPE_CHECKING + +from BaseClasses import Region +from entrance_rando import EntranceType, randomize_entrances +from .connections import RANDOMIZED_CONNECTIONS, TRANSITIONS +from .options import ShuffleTransitions, TransitionPlando + +if TYPE_CHECKING: + from . import MessengerWorld + + +def connect_plando(world: "MessengerWorld", plando_connections: TransitionPlando) -> None: + def remove_dangling_exit(region: Region) -> None: + # find the disconnected exit and remove references to it + for _exit in region.exits: + if not _exit.connected_region: + break + else: + raise ValueError(f"Unable to find randomized transition for {plando_connection}") + region.exits.remove(_exit) + + def remove_dangling_entrance(region: Region) -> None: + # find the disconnected entrance and remove references to it + for _entrance in region.entrances: + if not _entrance.parent_region: + break + else: + raise ValueError(f"Invalid target region for {plando_connection}") + region.entrances.remove(_entrance) + + for plando_connection in plando_connections: + # get the connecting regions + reg1 = world.get_region(plando_connection.entrance) + reg2 = world.get_region(plando_connection.exit) + + remove_dangling_exit(reg1) + remove_dangling_entrance(reg2) + # connect the regions + reg1.connect(reg2) + + # pretend the user set the plando direction as "both" regardless of what they actually put on coupled + if ((world.options.shuffle_transitions == ShuffleTransitions.option_coupled + or plando_connection.direction == "both") + and plando_connection.exit in RANDOMIZED_CONNECTIONS): + remove_dangling_exit(reg2) + remove_dangling_entrance(reg1) + reg2.connect(reg1) + + +def shuffle_transitions(world: "MessengerWorld") -> None: + coupled = world.options.shuffle_transitions == ShuffleTransitions.option_coupled + + def disconnect_entrance() -> None: + child_region.entrances.remove(entrance) + entrance.connected_region = None + + er_type = EntranceType.ONE_WAY if child == "Glacial Peak - Left" else \ + EntranceType.TWO_WAY if child in RANDOMIZED_CONNECTIONS else EntranceType.ONE_WAY + if er_type == EntranceType.TWO_WAY: + mock_entrance = parent_region.create_er_target(entrance.name) + else: + mock_entrance = child_region.create_er_target(child) + + entrance.randomization_type = er_type + mock_entrance.randomization_type = er_type + + for parent, child in RANDOMIZED_CONNECTIONS.items(): + if child == "Corrupted Future": + entrance = world.get_entrance("Artificer's Portal") + elif child == "Tower of Time - Left": + entrance = world.get_entrance("Artificer's Challenge") + else: + entrance = world.get_entrance(f"{parent} -> {child}") + parent_region = entrance.parent_region + child_region = entrance.connected_region + entrance.world = world + disconnect_entrance() + + plando = world.options.plando_connections + if plando: + connect_plando(world, plando) + + result = randomize_entrances(world, coupled, {0: [0]}) + + world.transitions = sorted(result.placements, key=lambda entrance: TRANSITIONS.index(entrance.parent_region.name)) + + for transition in world.transitions: + if "->" not in transition.name: + continue + transition.parent_region.exits.remove(transition) + transition.name = f"{transition.parent_region.name} -> {transition.connected_region.name}" + transition.parent_region.exits.append(transition) From e267714d441c56e6a8bd1496138dc17ff0861035 Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Mon, 10 Mar 2025 15:34:10 +0000 Subject: [PATCH 03/38] AHiT: Rework Subcon Forest Boss Arena, Boss Firewall and YCHE logic (#4494) A new `Subcon Forest - Behind Boss Firewall` region is added for `Subcon Village - Snatcher Statue Chest`. `Subcon Forest Area` connects to this new region, requiring either the first `Progressive Painting Unlock`, or Expert logic + `NoPaintingSkips: false`. A new `Subcon Forest Boss Arena` region is added for `Subcon Forest - Boss Arena Chest` because this is immediately accessible from YCHE. There are connections to this region from `Your Contract has Expired` (no requirements) and from `Subcon Forest - Behind Boss Firewall` (requiring either Hard logic or `Hookshot Badge` + `TOD Access`). A reverse connection is also added to Expert logic, for `Subcon Forest Boss Arena` -> `Subcon Forest - Behind Boss Firewall`. This could be extended to include Hard logic if there is a reasonable Cherry Bridge setup. A reverse connection is also added to Expert logic, for `Subcon Forest - Behind Boss Firewall` -> `Subcon Forest Area`, so long as `NoPaintingSkips: false` because it is impossible to burn the paintings to remove the firewall, from behind the firewall. A new `Your Contract has Expired - Post Fight` region is added for the Snatcher post fight cutscene to prevent the Snatcher Hover trick giving access to YCHE, which would otherwise also give access to the new `Subcon Forest Boss Arena` Region. The paintings and boss arena gap logic for `Snatcher Statue Chest` and `Boss Arena Chest` are now handled using the connections to/from these new regions rather than being on the locations themselves. The logic for `Act Completion (Toilet of Doom)` remains unchanged because it has to be in the `Toilet of Doom` region. In Expert logic, with `NoPaintingSkips: false`, YCHE is added as a rift access region to Subcon Forest Time Rift entrances. The `YCHE Access` event is no longer used and has been removed. - Fixes painting skips logic for Subcon Village - Snatcher Statue Chest - Fixes Subcon Forest - Boss Arena Chest being inaccessible from YCHE - Adds Expert logic to reach `Snatcher Statue Chest` from YCHE - Adds Expert logic to skip the boss firewall in reverse from YCHE so long as painting skips are not removed from logic - Adds Expert logic to access Subcon Forest Time Rift entrances from YCHE so long as painting skips are not removed from logic --- worlds/ahit/Locations.py | 7 ++-- worlds/ahit/Regions.py | 25 +++++++++++++- worlds/ahit/Rules.py | 75 ++++++++++++++++++++++++++++++---------- 3 files changed, 84 insertions(+), 23 deletions(-) diff --git a/worlds/ahit/Locations.py b/worlds/ahit/Locations.py index b34e6bb4a7..713113e691 100644 --- a/worlds/ahit/Locations.py +++ b/worlds/ahit/Locations.py @@ -206,7 +206,7 @@ ahit_locations = { "Subcon Village - Graveyard Ice Cube": LocData(2000325077, "Subcon Forest Area"), "Subcon Village - House Top": LocData(2000325471, "Subcon Forest Area"), "Subcon Village - Ice Cube House": LocData(2000325469, "Subcon Forest Area"), - "Subcon Village - Snatcher Statue Chest": LocData(2000323730, "Subcon Forest Area", paintings=1), + "Subcon Village - Snatcher Statue Chest": LocData(2000323730, "Subcon Forest Behind Boss Firewall"), "Subcon Village - Stump Platform Chest": LocData(2000323729, "Subcon Forest Area"), "Subcon Forest - Giant Tree Climb": LocData(2000325470, "Subcon Forest Area"), @@ -233,7 +233,7 @@ ahit_locations = { "Subcon Forest - Long Tree Climb Chest": LocData(2000323734, "Subcon Forest Area", required_hats=[HatType.DWELLER], paintings=2), - "Subcon Forest - Boss Arena Chest": LocData(2000323735, "Subcon Forest Area"), + "Subcon Forest - Boss Arena Chest": LocData(2000323735, "Subcon Forest Boss Arena"), "Subcon Forest - Manor Rooftop": LocData(2000325466, "Subcon Forest Area", hit_type=HitType.dweller_bell, paintings=1), @@ -411,7 +411,7 @@ act_completions = { "Act Completion (Mail Delivery Service)": LocData(2000312032, "Mail Delivery Service", required_hats=[HatType.SPRINT]), - "Act Completion (Your Contract has Expired)": LocData(2000311390, "Your Contract has Expired", + "Act Completion (Your Contract has Expired)": LocData(2000311390, "Your Contract has Expired - Post Fight", hit_type=HitType.umbrella), "Act Completion (Time Rift - Pipe)": LocData(2000313069, "Time Rift - Pipe", hookshot=True), @@ -976,7 +976,6 @@ event_locs = { **snatcher_coins, "HUMT Access": LocData(0, "Heating Up Mafia Town"), "TOD Access": LocData(0, "Toilet of Doom"), - "YCHE Access": LocData(0, "Your Contract has Expired"), "AFR Access": LocData(0, "Alpine Free Roam"), "TIHS Access": LocData(0, "The Illness has Spread"), diff --git a/worlds/ahit/Regions.py b/worlds/ahit/Regions.py index 31edf1d0b0..857c04f1d7 100644 --- a/worlds/ahit/Regions.py +++ b/worlds/ahit/Regions.py @@ -347,7 +347,7 @@ def create_regions(world: "HatInTimeWorld"): sf_act3 = create_region_and_connect(world, "Toilet of Doom", "Subcon Forest - Act 3", subcon_forest) sf_act4 = create_region_and_connect(world, "Queen Vanessa's Manor", "Subcon Forest - Act 4", subcon_forest) sf_act5 = create_region_and_connect(world, "Mail Delivery Service", "Subcon Forest - Act 5", subcon_forest) - create_region_and_connect(world, "Your Contract has Expired", "Subcon Forest - Finale", subcon_forest) + sf_finale = create_region_and_connect(world, "Your Contract has Expired", "Subcon Forest - Finale", subcon_forest) # ------------------------------------------- ALPINE SKYLINE ------------------------------------------ # alpine_skyline = create_region_and_connect(world, "Alpine Skyline", "Telescope -> Alpine Skyline", spaceship) @@ -386,11 +386,24 @@ def create_regions(world: "HatInTimeWorld"): create_rift_connections(world, create_region(world, "Time Rift - Bazaar")) sf_area: Region = create_region(world, "Subcon Forest Area") + sf_behind_boss_firewall: Region = create_region(world, "Subcon Forest Behind Boss Firewall") + sf_boss_arena: Region = create_region(world, "Subcon Forest Boss Arena") + sf_area.connect(sf_behind_boss_firewall, "SF Area -> SF Behind Boss Firewall") + sf_behind_boss_firewall.connect(sf_boss_arena, "SF Behind Boss Firewall -> SF Boss Arena") sf_act1.connect(sf_area, "Subcon Forest Entrance CO") sf_act2.connect(sf_area, "Subcon Forest Entrance SW") sf_act3.connect(sf_area, "Subcon Forest Entrance TOD") sf_act4.connect(sf_area, "Subcon Forest Entrance QVM") sf_act5.connect(sf_area, "Subcon Forest Entrance MDS") + # YCHE puts the player directly in the boss arena, with no access to the rest of Subcon Forest by default. + sf_finale.connect(sf_boss_arena, "Subcon Forest Entrance YCHE") + # To support the Snatcher Hover expert logic for Act Completion (Your Contract has Expired), the act completion has + # to go in a separate region because the Snatcher Hover gives direct access to the Act Completion, but does not + # give access to the act itself. + sf_finale_post_fight: Region = create_region(world, "Your Contract has Expired - Post Fight") + # This connection must never have any rules placed on it because they will not be inherited when setting up act + # connections, only the rules for the entrances to the act and the rules for the Act Completion are inherited. + sf_finale.connect(sf_finale_post_fight, "YCHE -> YCHE - Post Fight") create_rift_connections(world, create_region(world, "Time Rift - Sleepy Subcon")) create_rift_connections(world, create_region(world, "Time Rift - Pipe")) @@ -947,6 +960,16 @@ def get_shuffled_region(world: "HatInTimeWorld", region: str) -> str: return name +def get_region_shuffled_to(world: "HatInTimeWorld", region: str) -> str: + if world.options.ActRandomizer: + original_ci: str = chapter_act_info[region] + shuffled_ci = world.act_connections[original_ci] + return next(act_name for act_name, ci in chapter_act_info.items() + if ci == shuffled_ci) + else: + return region + + def get_region_location_count(world: "HatInTimeWorld", region_name: str, included_only: bool = True) -> int: count = 0 region = world.multiworld.get_region(region_name, world.player) diff --git a/worlds/ahit/Rules.py b/worlds/ahit/Rules.py index 6753b8eb81..2ca0628a68 100644 --- a/worlds/ahit/Rules.py +++ b/worlds/ahit/Rules.py @@ -481,9 +481,8 @@ def set_hard_rules(world: "HatInTimeWorld"): set_rule(world.multiworld.get_location("Subcon Forest - Dweller Platforming Tree B", world.player), lambda state: has_paintings(state, world, 3)) - # Cherry bridge over boss arena gap (painting still expected) - set_rule(world.multiworld.get_location("Subcon Forest - Boss Arena Chest", world.player), - lambda state: has_paintings(state, world, 1, False) or state.has("YCHE Access", world.player)) + # Cherry bridge over boss arena gap + set_rule(world.get_entrance("SF Behind Boss Firewall -> SF Boss Arena"), lambda state: True) set_rule(world.multiworld.get_location("Subcon Forest - Noose Treehouse", world.player), lambda state: has_paintings(state, world, 2, True)) @@ -566,27 +565,61 @@ def set_expert_rules(world: "HatInTimeWorld"): lambda state: True) # Expert: Cherry Hovering - subcon_area = world.multiworld.get_region("Subcon Forest Area", world.player) - yche = world.multiworld.get_region("Your Contract has Expired", world.player) - entrance = yche.connect(subcon_area, "Subcon Forest Entrance YCHE") + # Skipping the boss firewall is possible with a Cherry Hover. + set_rule(world.get_entrance("SF Area -> SF Behind Boss Firewall"), + lambda state: has_paintings(state, world, 1, True)) + # The boss arena gap can be crossed in reverse with a Cherry Hover. + subcon_boss_arena = world.get_region("Subcon Forest Boss Arena") + subcon_behind_boss_firewall = world.get_region("Subcon Forest Behind Boss Firewall") + subcon_boss_arena.connect(subcon_behind_boss_firewall, "SF Boss Arena -> SF Behind Boss Firewall") - if world.options.NoPaintingSkips: - add_rule(entrance, lambda state: has_paintings(state, world, 1)) + subcon_area = world.get_region("Subcon Forest Area") + + # The boss firewall can be skipped in reverse with a Cherry Hover, but it is not possible to remove the boss + # firewall from reverse because the paintings to burn to remove the firewall are on the other side of the firewall. + # Therefore, a painting skip is required. The paintings could be burned by already having access to + # "Subcon Forest Area" through another entrance, but making a new connection to "Subcon Forest Area" in that case + # would be pointless. + if not world.options.NoPaintingSkips: + # The import cannot be done at the module-level because it would cause a circular import. + from .Regions import get_region_shuffled_to + + subcon_behind_boss_firewall.connect(subcon_area, "SF Behind Boss Firewall -> SF Area") + + # Because the Your Contract has Expired entrance can now reach "Subcon Forest Area", it needs to be connected to + # each of the Subcon Forest Time Rift entrances, like the other Subcon Forest Acts. + yche = world.get_region("Your Contract has Expired") + + def connect_to_shuffled_act_at(original_act_name): + region_name = get_region_shuffled_to(world, original_act_name) + return yche.connect(world.get_region(region_name), f"{original_act_name} Portal - Entrance YCHE") + + # Rules copied from `Rules.set_rift_rules()` with painting logic removed because painting skips must be + # available. + entrance = connect_to_shuffled_act_at("Time Rift - Pipe") + add_rule(entrance, lambda state: can_clear_required_act(state, world, "Subcon Forest - Act 2")) + reg_act_connection(world, world.get_entrance("Subcon Forest - Act 2").connected_region, entrance) + + entrance = connect_to_shuffled_act_at("Time Rift - Village") + add_rule(entrance, lambda state: can_clear_required_act(state, world, "Subcon Forest - Act 4")) + reg_act_connection(world, world.get_entrance("Subcon Forest - Act 4").connected_region, entrance) + + entrance = connect_to_shuffled_act_at("Time Rift - Sleepy Subcon") + add_rule(entrance, lambda state: has_relic_combo(state, world, "UFO")) set_rule(world.multiworld.get_location("Act Completion (Toilet of Doom)", world.player), lambda state: can_use_hookshot(state, world) and can_hit(state, world) and has_paintings(state, world, 1, True)) # Set painting rules only. Skipping paintings is determined in has_paintings - set_rule(world.multiworld.get_location("Subcon Forest - Boss Arena Chest", world.player), - lambda state: has_paintings(state, world, 1, True)) set_rule(world.multiworld.get_location("Subcon Forest - Magnet Badge Bush", world.player), lambda state: has_paintings(state, world, 3, True)) # You can cherry hover to Snatcher's post-fight cutscene, which completes the level without having to fight him - subcon_area.connect(yche, "Snatcher Hover") - set_rule(world.multiworld.get_location("Act Completion (Your Contract has Expired)", world.player), - lambda state: True) + yche_post_fight = world.get_region("Your Contract has Expired - Post Fight") + subcon_area.connect(yche_post_fight, "Snatcher Hover") + # Cherry Hover from YCHE also works, so there are no requirements for the Act Completion. + set_rule(world.get_location("Act Completion (Your Contract has Expired)"), lambda state: True) if world.is_dlc2(): # Expert: clear Rush Hour with nothing @@ -681,12 +714,18 @@ def set_subcon_rules(world: "HatInTimeWorld"): lambda state: can_use_hat(state, world, HatType.BREWING) or state.has("Umbrella", world.player) or can_use_hat(state, world, HatType.DWELLER)) - # You can't skip over the boss arena wall without cherry hover, so these two need to be set this way - set_rule(world.multiworld.get_location("Subcon Forest - Boss Arena Chest", world.player), - lambda state: state.has("TOD Access", world.player) and can_use_hookshot(state, world) - and has_paintings(state, world, 1, False) or state.has("YCHE Access", world.player)) + # You can't skip over the boss arena wall without cherry hover. + set_rule(world.get_entrance("SF Area -> SF Behind Boss Firewall"), + lambda state: has_paintings(state, world, 1, False)) - # The painting wall can't be skipped without cherry hover, which is Expert + # The hookpoints to cross the boss arena gap are only present in Toilet of Doom. + set_rule(world.get_entrance("SF Behind Boss Firewall -> SF Boss Arena"), + lambda state: state.has("TOD Access", world.player) + and can_use_hookshot(state, world)) + + # The Act Completion is in the Toilet of Doom region, so the same rules as passing the boss firewall and crossing + # the boss arena gap are required. "TOD Access" is implied from the region so does not need to be included in the + # rule. set_rule(world.multiworld.get_location("Act Completion (Toilet of Doom)", world.player), lambda state: can_use_hookshot(state, world) and can_hit(state, world) and has_paintings(state, world, 1, False)) From dd554092095e51104205bb70c9d4aebf78c2efdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20L=C3=BCbcke?= <49335240+PaddiLu@users.noreply.github.com> Date: Mon, 10 Mar 2025 16:35:40 +0100 Subject: [PATCH 04/38] =?UTF-8?q?Pok=C3=A9mon=20R/B:=20Fix=20Rock=20Tunnel?= =?UTF-8?q?=20B1F=20randomization=20(#4670)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bottom to central path sealed off * Bottom-to-left-path to right path sealed off * Central opening (r4444): Left unsealed, paths seperated * Top right half rocks fixed * Middle to top opening sealed * Right hallway seal correctly positioned * Top right ladder: Fixed overlapping walls --- worlds/pokemon_rb/rock_tunnel.py | 42 +++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/worlds/pokemon_rb/rock_tunnel.py b/worlds/pokemon_rb/rock_tunnel.py index 3a70709eb0..46b2be3040 100644 --- a/worlds/pokemon_rb/rock_tunnel.py +++ b/worlds/pokemon_rb/rock_tunnel.py @@ -177,7 +177,11 @@ def randomize_rock_tunnel(data, random): if random.randint(0, 1): floor(10, 7) floor(11, 7) - tall(random.randint(12, 17), 8) + if current_map[10][13]==1: + # (13,10) is floor + tall(random.randint(14, 16), 8) + else: + tall(random.randint(12, 16), 8) else: floor(12, 5) floor(12, 6) @@ -185,8 +189,10 @@ def randomize_rock_tunnel(data, random): wide(17, random.randint(3, 5)) r = random.choice([1, 3]) floor(12, r) - floor(12, + 1) - + floor(12, r + 1) + if current_map[4][12] + current_map[5][12] == 2: + # (12,4) and (12,5) are floor + wide(11,4) elif c == 2: r = random.randint(0, 6) if r == 0: @@ -221,6 +227,9 @@ def randomize_rock_tunnel(data, random): #early block wide(13, random.randint(2, 5)) tall(random.randint(14, 15), 1) + if not 1 in (current_map[1][14],current_map[2][13]): + # wide(13,2) and tall(14,1) overlap + single(13,2) elif r == 1: if random.randint(0, 1): tall(16, 5) @@ -243,19 +252,34 @@ def randomize_rock_tunnel(data, random): r = random.randint(r, 6) if r == 6: #late open - r2 = random.randint(0, 2) - floor(1 + (r2 * 2), 14) - floor(2 + (r2 * 2), 14) + if random.randint(0, 1): + floor(1, 14) + floor(2, 14) + else: + floor(3, 14) + floor(4, 14) elif r == 5: - floor(6, 12) - floor(6, 13) + if random.randint(0,1): + floor(6, 12) + floor(6, 13) + else: + floor(5, 14) + floor(6, 14) elif r == 4: if random.randint(0, 1): floor(6, 11) floor(7, 11) else: floor(8, 11) - floor(9, 11) + if current_map[12][10]==32: + # (10,12) is wide + single(9, 11) + else: + floor(9, 11) + if 31 in (current_map[8][6],current_map[8][7]): + # (6,7) or (7,7) are tall + floor(6, 10) + wide(7, 9) elif r == 3: floor(9, 9) floor(9, 10) From be550ff6fb6d8ca4832e0ba7401d069f6c88b3d4 Mon Sep 17 00:00:00 2001 From: Dinopony Date: Mon, 10 Mar 2025 16:35:58 +0100 Subject: [PATCH 05/38] Landstalker: Several small fixes (#4675) * Landstalker: Fixed duplicate entrance names when using the "No teleport tree requirements" option * Landstalker: Fixed more cases of duplicate entrance names when using "Shuffle Trees" with open trees * Landstalker: Fixed endgame locations being present in "Reach Kazalt" goal * Landstalker: Fixed Lithograph hint pointing at the wrong player * Landstalker: Updated docs to remove the link to Steam since game got delisted * Landstalker: Fixed high value hint_count rarely failing at generation * Landstalker: Fixed dynamic shop prices being potentially invalid in case of a progression balancing (changes by ExemptMedic) --- worlds/landstalker/Hints.py | 2 +- worlds/landstalker/__init__.py | 19 ++++++------------- .../landstalker/docs/landstalker_setup_en.md | 2 +- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/worlds/landstalker/Hints.py b/worlds/landstalker/Hints.py index 4211e0ef3b..366925c64d 100644 --- a/worlds/landstalker/Hints.py +++ b/worlds/landstalker/Hints.py @@ -131,7 +131,7 @@ def generate_random_hints(world: "LandstalkerWorld"): hint_texts = list(set(hint_texts)) random.shuffle(hint_texts) - hint_count = world.options.hint_count.value + hint_count = min(world.options.hint_count.value, len(hint_texts)) del hint_texts[hint_count:] hint_source_names = [source["description"] for source in HINT_SOURCES_JSON if diff --git a/worlds/landstalker/__init__.py b/worlds/landstalker/__init__.py index cfdc335c48..98172eb6a7 100644 --- a/worlds/landstalker/__init__.py +++ b/worlds/landstalker/__init__.py @@ -39,7 +39,7 @@ class LandstalkerWorld(World): item_name_to_id = build_item_name_to_id_table() location_name_to_id = build_location_name_to_id_table() - cached_spheres: List[Set[Location]] + cached_spheres: List[Set[Location]] = [] def __init__(self, multiworld, player): super().__init__(multiworld, player) @@ -48,9 +48,11 @@ class LandstalkerWorld(World): self.dark_region_ids = [] self.teleport_tree_pairs = [] self.jewel_items = [] - self.cached_spheres = [] def fill_slot_data(self) -> dict: + if not LandstalkerWorld.cached_spheres: + LandstalkerWorld.cached_spheres = list(self.multiworld.get_spheres()) + # Generate hints. self.adjust_shop_prices() hints = Hints.generate_random_hints(self) @@ -232,18 +234,9 @@ class LandstalkerWorld(World): else: return 4 - @classmethod - def stage_post_fill(cls, multiworld: MultiWorld): - # Cache spheres for hint calculation after fill completes. - cached_spheres = list(multiworld.get_spheres()) - for world in multiworld.get_game_worlds(cls.game): - world.cached_spheres = cached_spheres - @classmethod def stage_modify_multidata(cls, multiworld: MultiWorld, *_): - # Clean up all references in cached spheres after generation completes. - for world in multiworld.get_game_worlds(cls.game): - world.cached_spheres = [] + LandstalkerWorld.cached_spheres = [] def adjust_shop_prices(self): # Calculate prices for items in shops once all items have their final position @@ -254,7 +247,7 @@ class LandstalkerWorld(World): global_price_factor = self.options.shop_prices_factor / 100.0 - spheres = self.cached_spheres + spheres = LandstalkerWorld.cached_spheres sphere_count = len(spheres) for sphere_id, sphere in enumerate(spheres): location: LandstalkerLocation # after conditional, we guarantee it's this kind of location. diff --git a/worlds/landstalker/docs/landstalker_setup_en.md b/worlds/landstalker/docs/landstalker_setup_en.md index 30f85dd8f1..05cf35f8b0 100644 --- a/worlds/landstalker/docs/landstalker_setup_en.md +++ b/worlds/landstalker/docs/landstalker_setup_en.md @@ -6,7 +6,7 @@ - A compatible emulator to run the game - [RetroArch](https://retroarch.com?page=platforms) with the Genesis Plus GX core - [Bizhawk 2.9.1 (x64)](https://tasvideos.org/BizHawk/ReleaseHistory) with the Genesis Plus GX core -- Your legally obtained Landstalker US ROM file (which can be acquired on [Steam](https://store.steampowered.com/app/71118/Landstalker_The_Treasures_of_King_Nole/)) +- A Landstalker US ROM file dumped from the original cartridge ## Installation Instructions From d83294efa7a52170f4a7f71f1e6e2a53056cf368 Mon Sep 17 00:00:00 2001 From: agilbert1412 Date: Mon, 10 Mar 2025 18:39:35 +0300 Subject: [PATCH 06/38] Stardew valley: Fix Aurora Vineyard Tablet logic (#4512) * - Add requirement on Aurora Vineyard tablet to start the quest * - Add rule for using the aurora vineyard staircase * - Added a test for the tablet * - Add a few missing items to the test * - Introduce a new item to split the quest from the door and avoir ER issues * - Optimize imports * - Forgot to generate the item * fix Aurora mess # Conflicts: # worlds/stardew_valley/rules.py # worlds/stardew_valley/test/mods/TestMods.py * fix a couple errors in the cherry picked commit, added a method to improve readability and reduce chance of human error on story quest conditions * - remove blank line * - Code review comments * - fixed weird assert name * - fixed accidentally surviving line * - Fixed imports --------- Co-authored-by: Jouramie <16137441+Jouramie@users.noreply.github.com> --- worlds/stardew_valley/data/items.csv | 1 + worlds/stardew_valley/early_items.py | 2 +- worlds/stardew_valley/items.py | 6 ++-- worlds/stardew_valley/locations.py | 4 +-- worlds/stardew_valley/logic/bundle_logic.py | 2 +- worlds/stardew_valley/logic/crafting_logic.py | 2 +- worlds/stardew_valley/logic/quest_logic.py | 31 +++++++++---------- .../logic/relationship_logic.py | 12 +++++-- .../stardew_valley/mods/logic/quests_logic.py | 10 +++++- worlds/stardew_valley/mods/logic/sve_logic.py | 24 +++++++------- worlds/stardew_valley/options/options.py | 6 ++++ worlds/stardew_valley/rules.py | 9 +++--- .../strings/ap_names/mods/mod_items.py | 15 ++++++--- worlds/stardew_valley/test/mods/TestMods.py | 19 ++++++++++-- worlds/stardew_valley/test/mods/TestSVE.py | 29 +++++++++++++++++ 15 files changed, 120 insertions(+), 52 deletions(-) create mode 100644 worlds/stardew_valley/test/mods/TestSVE.py diff --git a/worlds/stardew_valley/data/items.csv b/worlds/stardew_valley/data/items.csv index 05af275ba4..36e048100c 100644 --- a/worlds/stardew_valley/data/items.csv +++ b/worlds/stardew_valley/data/items.csv @@ -928,6 +928,7 @@ id,name,classification,groups,mod_name 10518,Aurora Vineyard Tablet,progression,,Stardew Valley Expanded 10519,Scarlett's Job Offer,progression,,Stardew Valley Expanded 10520,Morgan's Schooling,progression,,Stardew Valley Expanded +10521,Aurora Vineyard Reclamation,progression,,Stardew Valley Expanded 10601,Magic Elixir Recipe,progression,"CHEFSANITY,CHEFSANITY_PURCHASE",Magic 10602,Travel Core Recipe,progression,CRAFTSANITY,Magic 10603,Haste Elixir Recipe,progression,CRAFTSANITY,Stardew Valley Expanded diff --git a/worlds/stardew_valley/early_items.py b/worlds/stardew_valley/early_items.py index 5ad48912a2..1457c5c7c5 100644 --- a/worlds/stardew_valley/early_items.py +++ b/worlds/stardew_valley/early_items.py @@ -41,7 +41,7 @@ def setup_early_items(multiworld, options: stardew_options.StardewValleyOptions, if fishing is not None and content.features.skill_progression.is_progressive: early_forced.append(fishing.level_name) - if options.quest_locations >= 0: + if options.quest_locations.has_story_quests(): early_candidates.append(Wallet.magnifying_glass) if options.special_order_locations & stardew_options.SpecialOrderLocations.option_board: diff --git a/worlds/stardew_valley/items.py b/worlds/stardew_valley/items.py index 1fbe012e27..dcb37a8f41 100644 --- a/worlds/stardew_valley/items.py +++ b/worlds/stardew_valley/items.py @@ -264,7 +264,7 @@ def create_unique_items(item_factory: StardewItemFactory, options: StardewValley def create_raccoons(item_factory: StardewItemFactory, options: StardewValleyOptions, items: List[Item]): number_progressive_raccoons = 9 - if options.quest_locations < 0: + if options.quest_locations.has_no_story_quests(): number_progressive_raccoons = number_progressive_raccoons - 1 items.extend(item_factory(item) for item in [CommunityUpgrade.raccoon] * number_progressive_raccoons) @@ -387,7 +387,7 @@ def create_quest_rewards(item_factory: StardewItemFactory, options: StardewValle def create_special_quest_rewards(item_factory: StardewItemFactory, options: StardewValleyOptions, items: List[Item]): - if options.quest_locations < 0: + if options.quest_locations.has_no_story_quests(): return # items.append(item_factory("Adventurer's Guild")) # Now unlocked always! items.append(item_factory(Wallet.club_card)) @@ -698,7 +698,7 @@ def create_quest_rewards_sve(item_factory: StardewItemFactory, options: StardewV if not exclude_ginger_island: items.extend([item_factory(item) for item in SVEQuestItem.sve_always_quest_items_ginger_island]) - if options.quest_locations < 0: + if options.quest_locations.has_no_story_quests(): return items.extend([item_factory(item) for item in SVEQuestItem.sve_quest_items]) diff --git a/worlds/stardew_valley/locations.py b/worlds/stardew_valley/locations.py index df86e08125..c7d787e55d 100644 --- a/worlds/stardew_valley/locations.py +++ b/worlds/stardew_valley/locations.py @@ -191,7 +191,7 @@ def extend_cropsanity_locations(randomized_locations: List[LocationData], conten def extend_quests_locations(randomized_locations: List[LocationData], options: StardewValleyOptions, content: StardewContent): - if options.quest_locations < 0: + if options.quest_locations.has_no_story_quests(): return story_quest_locations = locations_by_tag[LocationTags.STORY_QUEST] @@ -317,7 +317,7 @@ def extend_mandatory_locations(randomized_locations: List[LocationData], options def extend_situational_quest_locations(randomized_locations: List[LocationData], options: StardewValleyOptions): - if options.quest_locations < 0: + if options.quest_locations.has_no_story_quests(): return if ModNames.distant_lands in options.mods: if ModNames.alecto in options.mods: diff --git a/worlds/stardew_valley/logic/bundle_logic.py b/worlds/stardew_valley/logic/bundle_logic.py index 98fda1c73c..8ede4de5e7 100644 --- a/worlds/stardew_valley/logic/bundle_logic.py +++ b/worlds/stardew_valley/logic/bundle_logic.py @@ -76,7 +76,7 @@ SkillLogicMixin, QuestLogicMixin]]): self.logic.region.can_reach_location("Complete Boiler Room")) def can_access_raccoon_bundles(self) -> StardewRule: - if self.options.quest_locations < 0: + if self.options.quest_locations.has_no_story_quests(): return self.logic.received(CommunityUpgrade.raccoon, 1) & self.logic.quest.can_complete_quest(Quest.giant_stump) # 1 - Break the tree diff --git a/worlds/stardew_valley/logic/crafting_logic.py b/worlds/stardew_valley/logic/crafting_logic.py index 28bf0d2af2..bd839707ef 100644 --- a/worlds/stardew_valley/logic/crafting_logic.py +++ b/worlds/stardew_valley/logic/crafting_logic.py @@ -48,7 +48,7 @@ SkillLogicMixin, SpecialOrderLogicMixin, CraftingLogicMixin, QuestLogicMixin]]): else: return self.logic.crafting.received_recipe(recipe.item) if isinstance(recipe.source, QuestSource): - if self.options.quest_locations < 0: + if self.options.quest_locations.has_no_story_quests(): return self.logic.crafting.can_learn_recipe(recipe) else: return self.logic.crafting.received_recipe(recipe.item) diff --git a/worlds/stardew_valley/logic/quest_logic.py b/worlds/stardew_valley/logic/quest_logic.py index 42f401b960..8779848fed 100644 --- a/worlds/stardew_valley/logic/quest_logic.py +++ b/worlds/stardew_valley/logic/quest_logic.py @@ -118,25 +118,24 @@ class QuestLogic(BaseLogic[Union[HasLogicMixin, ReceivedLogicMixin, MoneyLogicMi return Has(quest, self.registry.quest_rules, "quest") def has_club_card(self) -> StardewRule: - if self.options.quest_locations < 0: - return self.logic.quest.can_complete_quest(Quest.the_mysterious_qi) - return self.logic.received(Wallet.club_card) + if self.options.quest_locations.has_story_quests(): + return self.logic.received(Wallet.club_card) + return self.logic.quest.can_complete_quest(Quest.the_mysterious_qi) def has_magnifying_glass(self) -> StardewRule: - if self.options.quest_locations < 0: - return self.logic.quest.can_complete_quest(Quest.a_winter_mystery) - return self.logic.received(Wallet.magnifying_glass) + if self.options.quest_locations.has_story_quests(): + return self.logic.received(Wallet.magnifying_glass) + return self.logic.quest.can_complete_quest(Quest.a_winter_mystery) def has_dark_talisman(self) -> StardewRule: - if self.options.quest_locations < 0: - return self.logic.quest.can_complete_quest(Quest.dark_talisman) - return self.logic.received(Wallet.dark_talisman) + if self.options.quest_locations.has_story_quests(): + return self.logic.received(Wallet.dark_talisman) + return self.logic.quest.can_complete_quest(Quest.dark_talisman) def has_raccoon_shop(self) -> StardewRule: - if self.options.quest_locations < 0: - return self.logic.received(CommunityUpgrade.raccoon, 2) & self.logic.quest.can_complete_quest(Quest.giant_stump) - - # 1 - Break the tree - # 2 - Build the house, which summons the bundle racoon. This one is done manually if quests are turned off - # 3 - Raccoon's wife opens the shop - return self.logic.received(CommunityUpgrade.raccoon, 3) + if self.options.quest_locations.has_story_quests(): + # 1 - Break the tree + # 2 - Build the house, which summons the bundle racoon. This one is done manually if quests are turned off + # 3 - Raccoon's wife opens the shop + return self.logic.received(CommunityUpgrade.raccoon, 3) + return self.logic.received(CommunityUpgrade.raccoon, 2) & self.logic.quest.can_complete_quest(Quest.giant_stump) diff --git a/worlds/stardew_valley/logic/relationship_logic.py b/worlds/stardew_valley/logic/relationship_logic.py index 61e63a90c8..b74bdc5645 100644 --- a/worlds/stardew_valley/logic/relationship_logic.py +++ b/worlds/stardew_valley/logic/relationship_logic.py @@ -1,4 +1,5 @@ import math +import typing from typing import Union from Utils import cache_self1 @@ -14,13 +15,18 @@ from ..content.feature import friendsanity from ..data.villagers_data import Villager from ..stardew_rule import StardewRule, True_, false_, true_ from ..strings.ap_names.mods.mod_items import SVEQuestItem -from ..strings.crop_names import Fruit from ..strings.generic_names import Generic from ..strings.gift_names import Gift +from ..strings.quest_names import ModQuest from ..strings.region_names import Region from ..strings.season_names import Season from ..strings.villager_names import NPC, ModNPC +if typing.TYPE_CHECKING: + from ..mods.logic.mod_logic import ModLogicMixin +else: + ModLogicMixin = object + possible_kids = ("Cute Baby", "Ugly Baby") @@ -38,7 +44,7 @@ class RelationshipLogicMixin(BaseLogicMixin): class RelationshipLogic(BaseLogic[Union[RelationshipLogicMixin, BuildingLogicMixin, SeasonLogicMixin, TimeLogicMixin, GiftLogicMixin, RegionLogicMixin, -ReceivedLogicMixin, HasLogicMixin]]): +ReceivedLogicMixin, HasLogicMixin, ModLogicMixin]]): def can_date(self, npc: str) -> StardewRule: return self.logic.relationship.has_hearts(npc, 8) & self.logic.has(Gift.bouquet) @@ -141,7 +147,7 @@ ReceivedLogicMixin, HasLogicMixin]]): rules.append(self.logic.region.can_reach(Region.volcano_floor_10)) elif npc == ModNPC.apples: - rules.append(self.logic.has(Fruit.starfruit)) + rules.append(self.logic.mod.quest.has_completed_aurora_vineyard_bundle()) elif npc == ModNPC.scarlett: scarlett_job = self.logic.received(SVEQuestItem.scarlett_job_offer) diff --git a/worlds/stardew_valley/mods/logic/quests_logic.py b/worlds/stardew_valley/mods/logic/quests_logic.py index 2ff7452394..ef96982661 100644 --- a/worlds/stardew_valley/mods/logic/quests_logic.py +++ b/worlds/stardew_valley/mods/logic/quests_logic.py @@ -12,6 +12,7 @@ from ...logic.season_logic import SeasonLogicMixin from ...logic.time_logic import TimeLogicMixin from ...stardew_rule import StardewRule from ...strings.animal_product_names import AnimalProduct +from ...strings.ap_names.mods.mod_items import SVEQuestItem from ...strings.artisan_good_names import ArtisanGood from ...strings.crop_names import Fruit, SVEFruit, SVEVegetable, Vegetable from ...strings.fertilizer_names import Fertilizer @@ -83,7 +84,8 @@ TimeLogicMixin, SeasonLogicMixin, RelationshipLogicMixin, MonsterLogicMixin]]): self.logic.region.can_reach(SVERegion.grandpas_shed), ModQuest.MarlonsBoat: self.logic.has_all(*(Loot.void_essence, Loot.solar_essence, Loot.slime, Loot.bat_wing, Loot.bug_meat)) & self.logic.relationship.can_meet(ModNPC.lance) & self.logic.region.can_reach(SVERegion.guild_summit), - ModQuest.AuroraVineyard: self.logic.has(Fruit.starfruit) & self.logic.region.can_reach(SVERegion.aurora_vineyard), + ModQuest.AuroraVineyard: self.logic.region.can_reach(SVERegion.aurora_vineyard) & self.logic.received(SVEQuestItem.aurora_vineyard_tablet) & + self.logic.has(Fruit.starfruit) & self.logic.region.can_reach(Region.forest), ModQuest.MonsterCrops: self.logic.has_all(*(SVEVegetable.monster_mushroom, SVEFruit.slime_berry, SVEFruit.monster_fruit, SVEVegetable.void_root)), ModQuest.VoidSoul: self.logic.has(ModLoot.void_soul) & self.logic.region.can_reach(Region.farm) & self.logic.season.has_any_not_winter() & self.logic.region.can_reach(SVERegion.badlands_entrance) & @@ -91,6 +93,12 @@ TimeLogicMixin, SeasonLogicMixin, RelationshipLogicMixin, MonsterLogicMixin]]): self.logic.monster.can_kill_any((Monster.shadow_brute, Monster.shadow_shaman, Monster.shadow_sniper)), } + def has_completed_aurora_vineyard_bundle(self): + if self.options.quest_locations.has_story_quests(): + return self.logic.received(SVEQuestItem.aurora_vineyard_reclamation) + return self.logic.quest.can_complete_quest(ModQuest.AuroraVineyard) + + def _get_distant_lands_quest_rules(self): if ModNames.distant_lands not in self.options.mods: return {} diff --git a/worlds/stardew_valley/mods/logic/sve_logic.py b/worlds/stardew_valley/mods/logic/sve_logic.py index fc093554d8..faca8d332d 100644 --- a/worlds/stardew_valley/mods/logic/sve_logic.py +++ b/worlds/stardew_valley/mods/logic/sve_logic.py @@ -41,24 +41,24 @@ class SVELogic(BaseLogic[Union[HasLogicMixin, ReceivedLogicMixin, QuestLogicMixi return self.logic.or_(*(self.logic.received(rune) for rune in rune_list)) def has_iridium_bomb(self): - if self.options.quest_locations < 0: - return self.logic.quest.can_complete_quest(ModQuest.RailroadBoulder) - return self.logic.received(SVEQuestItem.iridium_bomb) + if self.options.quest_locations.has_story_quests(): + return self.logic.received(SVEQuestItem.iridium_bomb) + return self.logic.quest.can_complete_quest(ModQuest.RailroadBoulder) def has_marlon_boat(self): - if self.options.quest_locations < 0: - return self.logic.quest.can_complete_quest(ModQuest.MarlonsBoat) - return self.logic.received(SVEQuestItem.marlon_boat_paddle) + if self.options.quest_locations.has_story_quests(): + return self.logic.received(SVEQuestItem.marlon_boat_paddle) + return self.logic.quest.can_complete_quest(ModQuest.MarlonsBoat) def has_grandpa_shed_repaired(self): - if self.options.quest_locations < 0: - return self.logic.quest.can_complete_quest(ModQuest.GrandpasShed) - return self.logic.received(SVEQuestItem.grandpa_shed) + if self.options.quest_locations.has_story_quests(): + return self.logic.received(SVEQuestItem.grandpa_shed) + return self.logic.quest.can_complete_quest(ModQuest.GrandpasShed) def has_bear_knowledge(self): - if self.options.quest_locations < 0: - return self.logic.quest.can_complete_quest(Quest.strange_note) - return self.logic.received(Wallet.bears_knowledge) + if self.options.quest_locations.has_story_quests(): + return self.logic.received(Wallet.bears_knowledge) + return self.logic.quest.can_complete_quest(Quest.strange_note) def can_buy_bear_recipe(self): access_rule = (self.logic.quest.can_complete_quest(Quest.strange_note) & self.logic.tool.has_tool(Tool.axe, ToolMaterial.basic) & diff --git a/worlds/stardew_valley/options/options.py b/worlds/stardew_valley/options/options.py index 5cfdfcf9c7..bc76c617b3 100644 --- a/worlds/stardew_valley/options/options.py +++ b/worlds/stardew_valley/options/options.py @@ -384,6 +384,12 @@ class QuestLocations(NamedRange): "maximum": 56, } + def has_story_quests(self) -> bool: + return self.value >= 0 + + def has_no_story_quests(self) -> bool: + return not self.has_story_quests() + class Fishsanity(Choice): """Locations for catching each fish the first time? diff --git a/worlds/stardew_valley/rules.py b/worlds/stardew_valley/rules.py index 01acc7b822..dc63018697 100644 --- a/worlds/stardew_valley/rules.py +++ b/worlds/stardew_valley/rules.py @@ -149,7 +149,7 @@ def set_bundle_rules(bundle_rooms: List[BundleRoom], logic: StardewLogic, multiw bundle_rules = logic.bundle.can_complete_bundle(bundle) if bundle_room.name == CCRoom.raccoon_requests: num = int(bundle.name[-1]) - extra_raccoons = 1 if world_options.quest_locations >= 0 else 0 + extra_raccoons = 1 if world_options.quest_locations.has_story_quests() else 0 extra_raccoons = extra_raccoons + num bundle_rules = logic.received(CommunityUpgrade.raccoon, extra_raccoons) & bundle_rules if num > 1: @@ -505,7 +505,7 @@ def set_cropsanity_rules(logic: StardewLogic, multiworld, player, world_content: def set_story_quests_rules(all_location_names: Set[str], logic: StardewLogic, multiworld, player, world_options: StardewValleyOptions): - if world_options.quest_locations < 0: + if world_options.quest_locations.has_no_story_quests(): return for quest in locations.locations_by_tag[LocationTags.STORY_QUEST]: if quest.name in all_location_names and (quest.mod_name is None or quest.mod_name in world_options.mods): @@ -540,9 +540,9 @@ slay_monsters = "Slay Monsters" def set_help_wanted_quests_rules(logic: StardewLogic, multiworld, player, world_options: StardewValleyOptions): - help_wanted_number = world_options.quest_locations.value - if help_wanted_number < 0: + if world_options.quest_locations.has_no_story_quests(): return + help_wanted_number = world_options.quest_locations.value for i in range(0, help_wanted_number): set_number = i // 7 month_rule = logic.time.has_lived_months(set_number) @@ -973,6 +973,7 @@ def set_sve_rules(logic: StardewLogic, multiworld: MultiWorld, player: int, worl set_entrance_rule(multiworld, player, SVEEntrance.use_bear_shop, (logic.mod.sve.can_buy_bear_recipe())) set_entrance_rule(multiworld, player, SVEEntrance.railroad_to_grampleton_station, logic.received(SVEQuestItem.scarlett_job_offer)) set_entrance_rule(multiworld, player, SVEEntrance.museum_to_gunther_bedroom, logic.relationship.has_hearts(ModNPC.gunther, 2)) + set_entrance_rule(multiworld, player, SVEEntrance.to_aurora_basement, logic.mod.quest.has_completed_aurora_vineyard_bundle()) logic.mod.sve.initialize_rules() for location in logic.registry.sve_location_rules: MultiWorldRules.set_rule(multiworld.get_location(location, player), diff --git a/worlds/stardew_valley/strings/ap_names/mods/mod_items.py b/worlds/stardew_valley/strings/ap_names/mods/mod_items.py index 58371aebe7..d87a81f5e5 100644 --- a/worlds/stardew_valley/strings/ap_names/mods/mod_items.py +++ b/worlds/stardew_valley/strings/ap_names/mods/mod_items.py @@ -19,6 +19,12 @@ class SkillLevel: class SVEQuestItem: aurora_vineyard_tablet = "Aurora Vineyard Tablet" + """Triggers the apparition of the bundle tablet in the Aurora Vineyard, so you can do the Aurora Vineyard quest. + This aim to break dependencies on completing the Community Center. + """ + aurora_vineyard_reclamation = "Aurora Vineyard Reclamation" + """Triggers the unlock of the Aurora Vineyard basement, so you can meet Apples. + """ iridium_bomb = "Iridium Bomb" void_soul = "Void Spirit Peace Agreement" kittyfish_spell = "Kittyfish Spell" @@ -29,10 +35,10 @@ class SVEQuestItem: fable_reef_portal = "Fable Reef Portal" grandpa_shed = "Grandpa's Shed" - sve_always_quest_items: List[str] = [kittyfish_spell, scarlett_job_offer, morgan_schooling] - sve_always_quest_items_ginger_island: List[str] = [fable_reef_portal] - sve_quest_items: List[str] = [aurora_vineyard_tablet, iridium_bomb, void_soul, grandpa_shed] - sve_quest_items_ginger_island: List[str] = [marlon_boat_paddle] + sve_always_quest_items: list[str] = [kittyfish_spell, scarlett_job_offer, morgan_schooling, aurora_vineyard_tablet, ] + sve_always_quest_items_ginger_island: list[str] = [fable_reef_portal, ] + sve_quest_items: list[str] = [iridium_bomb, void_soul, grandpa_shed, aurora_vineyard_reclamation, ] + sve_quest_items_ginger_island: list[str] = [marlon_boat_paddle, ] class SVELocation: @@ -53,4 +59,3 @@ class SVERunes: nexus_wizard = "Nexus: Wizard Runes" nexus_items: List[str] = [nexus_farm, nexus_wizard, nexus_spring, nexus_aurora, nexus_guild, nexus_junimo, nexus_outpost] - diff --git a/worlds/stardew_valley/test/mods/TestMods.py b/worlds/stardew_valley/test/mods/TestMods.py index 1dd2ab4902..dc958652e1 100644 --- a/worlds/stardew_valley/test/mods/TestMods.py +++ b/worlds/stardew_valley/test/mods/TestMods.py @@ -1,10 +1,9 @@ import random -from BaseClasses import get_seed +from BaseClasses import get_seed, ItemClassification from .. import SVTestBase, SVTestCase, allsanity_mods_6_x_x, fill_dataclass_with_default from ..assertion import ModAssertMixin, WorldAssertMixin -from ... import items, Group, ItemClassification, create_content -from ... import options +from ... import options, items, Group, create_content from ...mods.mod_data import ModNames from ...options import SkillProgression, Walnutsanity from ...options.options import all_mods @@ -188,3 +187,17 @@ class TestModEntranceRando(SVTestCase): self.assertEqual(len(set(randomized_connections.values())), len(randomized_connections.values()), f"Connections are duplicated in randomization.") + + +class TestVanillaLogicAlternativeWhenQuestsAreNotRandomized(WorldAssertMixin, SVTestBase): + """We often forget to add an alternative rule that works when quests are not randomized. When this happens, some + Location are not reachable because they depend on items that are only added to the pool when quests are randomized. + """ + options = allsanity_mods_6_x_x() | { + options.QuestLocations.internal_name: options.QuestLocations.special_range_names["none"], + options.Goal.internal_name: options.Goal.option_perfection, + } + + def test_given_no_quest_all_mods_when_generate_then_can_reach_everything(self): + self.collect_everything() + self.assert_can_reach_everything(self.multiworld) diff --git a/worlds/stardew_valley/test/mods/TestSVE.py b/worlds/stardew_valley/test/mods/TestSVE.py new file mode 100644 index 0000000000..ca63dcb351 --- /dev/null +++ b/worlds/stardew_valley/test/mods/TestSVE.py @@ -0,0 +1,29 @@ +from .. import SVTestBase +from ... import options +from ...mods.mod_data import ModNames +from ...strings.ap_names.mods.mod_items import SVEQuestItem +from ...strings.quest_names import ModQuest +from ...strings.region_names import SVERegion + + +class TestAuroraVineyard(SVTestBase): + options = { + options.Cropsanity.internal_name: options.Cropsanity.option_enabled, + options.Mods.internal_name: frozenset({ModNames.sve}) + } + + def test_need_tablet_to_do_quest(self): + self.collect("Starfruit Seeds") + self.collect("Bus Repair") + self.collect("Shipping Bin") + self.collect("Summer") + location_name = ModQuest.AuroraVineyard + self.assert_cannot_reach_location(location_name, self.multiworld.state) + self.collect(SVEQuestItem.aurora_vineyard_tablet) + self.assert_can_reach_location(location_name, self.multiworld.state) + + def test_need_reclamation_to_go_downstairs(self): + region_name = SVERegion.aurora_vineyard_basement + self.assert_cannot_reach_region(region_name, self.multiworld.state) + self.collect(SVEQuestItem.aurora_vineyard_reclamation, 1) + self.assert_can_reach_region(region_name, self.multiworld.state) From 06111ac6cf30de7d8cfdf52f1f96baea9da7e9ea Mon Sep 17 00:00:00 2001 From: justinspatz <164453633+justinspatz@users.noreply.github.com> Date: Mon, 10 Mar 2025 12:39:45 -0400 Subject: [PATCH 07/38] OOT: Have beehives that only appear as a child not be in logic if only adult can break beehives (#4646) * Change the logic for the 3 Zora's Domain Beehives to support new rule Implement new logic changes to these 3 locations * Update LogicHelpers.json with new rule for beehives that only appear for child link Added below the "can_break_upper_beehive" a new helper called "can_break_upper_beehive_child" which removes the requirement for hookshot to avoid a logic error in the Zora Domain Beehives where it checks whether child or adult can break beehives, even though these beehives do not appear as an adult. * Update LogicHelpers.json moving the call for is_child As is_child is already called for can_use (Boomerang), it's a bit redundant to include the check for using the Boomerang, so it's being moved to be with the Bombchu check to ensure that it's not expected if the Bombchu Logic Rule is turned on that Adult can use bombchus to break the beehives. This effectively does the same thing, but should be better on performance. --- worlds/oot/data/LogicHelpers.json | 1 + worlds/oot/data/World/Overworld.json | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/worlds/oot/data/LogicHelpers.json b/worlds/oot/data/LogicHelpers.json index 7f3641062a..a05fe66066 100644 --- a/worlds/oot/data/LogicHelpers.json +++ b/worlds/oot/data/LogicHelpers.json @@ -66,6 +66,7 @@ "can_break_heated_crate": "deadly_bonks != 'ohko' or (Fairy and (can_use(Goron_Tunic) or damage_multiplier != 'ohko')) or can_use(Nayrus_Love) or can_blast_or_smash", "can_break_lower_beehive": "can_use(Boomerang) or can_use(Hookshot) or Bombs or (logic_beehives_bombchus and has_bombchus)", "can_break_upper_beehive": "can_use(Boomerang) or can_use(Hookshot) or (logic_beehives_bombchus and has_bombchus)", + "can_break_upper_beehive_child": "can_use(Boomerang) or (logic_beehives_bombchus and has_bombchus and is_child)", # can_use and helpers # The parser reduces this to smallest form based on item category. # Note that can_use(item) is False for any item not covered here. diff --git a/worlds/oot/data/World/Overworld.json b/worlds/oot/data/World/Overworld.json index de2b4a61dc..87b24a6e57 100644 --- a/worlds/oot/data/World/Overworld.json +++ b/worlds/oot/data/World/Overworld.json @@ -2233,8 +2233,8 @@ "ZD Pot 3": "True", "ZD Pot 4": "True", "ZD Pot 5": "True", - "ZD In Front of King Zora Beehive 1": "is_child and can_break_upper_beehive", - "ZD In Front of King Zora Beehive 2": "is_child and can_break_upper_beehive", + "ZD In Front of King Zora Beehive 1": "can_break_upper_beehive_child", + "ZD In Front of King Zora Beehive 2": "can_break_upper_beehive_child", "ZD GS Frozen Waterfall": " is_adult and at_night and (Hookshot or Bow or Magic_Meter or logic_domain_gs)", @@ -2259,7 +2259,7 @@ "scene": "Zoras Domain", "hint": "ZORAS_DOMAIN", "locations": { - "ZD Behind King Zora Beehive": "is_child and can_break_upper_beehive" + "ZD Behind King Zora Beehive": "can_break_upper_beehive_child" }, "exits": { "Zoras Domain": " From 2c8dded52f31485f6d9e9876a07dd667ddc38577 Mon Sep 17 00:00:00 2001 From: Aaron Wagener Date: Mon, 10 Mar 2025 21:13:49 -0500 Subject: [PATCH 08/38] The Messenger: Fix some transition plando issues (#4720) * don't allow one-way and two-way entrances to be connected to each other * add special handling for the tower hq nodes since they share the same parent region --- worlds/messenger/options.py | 6 ++++++ worlds/messenger/transitions.py | 15 ++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py index 9ee04d26a6..85c746aae7 100644 --- a/worlds/messenger/options.py +++ b/worlds/messenger/options.py @@ -51,6 +51,12 @@ class TransitionPlando(PlandoConnections): entrances = frozenset(RANDOMIZED_CONNECTIONS.keys()) exits = frozenset(RANDOMIZED_CONNECTIONS.values()) + @classmethod + def can_connect(cls, entrance: str, exit: str) -> bool: + if entrance != "Glacial Peak - Left" and entrance.lower() in cls.exits: + return exit.lower() in cls.entrances + return exit.lower() not in cls.entrances + class Logic(Choice): """ diff --git a/worlds/messenger/transitions.py b/worlds/messenger/transitions.py index 1db975b3cd..53cfd836d5 100644 --- a/worlds/messenger/transitions.py +++ b/worlds/messenger/transitions.py @@ -30,10 +30,19 @@ def connect_plando(world: "MessengerWorld", plando_connections: TransitionPlando for plando_connection in plando_connections: # get the connecting regions - reg1 = world.get_region(plando_connection.entrance) + # need to handle these special because the names are unique but have the same parent region + if plando_connection.entrance in ("Artificer", "Tower HQ"): + reg1 = world.get_region("Tower HQ") + if plando_connection.entrance == "Artificer": + dangling_exit = world.get_entrance("Artificer's Portal") + else: + dangling_exit = world.get_entrance("Artificer's Challenge") + reg1.exits.remove(dangling_exit) + else: + reg1 = world.get_region(plando_connection.entrance) + remove_dangling_exit(reg1) + reg2 = world.get_region(plando_connection.exit) - - remove_dangling_exit(reg1) remove_dangling_entrance(reg2) # connect the regions reg1.connect(reg2) From 3192799bbf65259d8cdca73112081d13f1c491f3 Mon Sep 17 00:00:00 2001 From: LiquidCat64 <74896918+LiquidCat64@users.noreply.github.com> Date: Wed, 12 Mar 2025 17:21:09 -0600 Subject: [PATCH 09/38] CVCotM: Clarify the Wii U VC version is unsupported (#4734) * Comment out VC ROM hash usages and clarify that it's unsupported. * Update worlds/cvcotm/docs/en_Castlevania - Circle of the Moon.md Co-authored-by: Scipio Wright * Update worlds/cvcotm/docs/setup_en.md Co-authored-by: Scipio Wright --------- Co-authored-by: Scipio Wright --- worlds/cvcotm/__init__.py | 7 ++++--- .../docs/en_Castlevania - Circle of the Moon.md | 7 +++---- worlds/cvcotm/docs/setup_en.md | 2 +- worlds/cvcotm/rom.py | 12 ++++++------ 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/worlds/cvcotm/__init__.py b/worlds/cvcotm/__init__.py index 4466ed79bd..0f5077e709 100644 --- a/worlds/cvcotm/__init__.py +++ b/worlds/cvcotm/__init__.py @@ -19,8 +19,8 @@ from worlds.AutoWorld import WebWorld, World from .aesthetics import shuffle_sub_weapons, get_location_data, get_countdown_flags, populate_enemy_drops, \ get_start_inventory_data -from .rom import RomData, patch_rom, get_base_rom_path, CVCotMProcedurePatch, CVCOTM_CT_US_HASH, CVCOTM_AC_US_HASH, \ - CVCOTM_VC_US_HASH +from .rom import RomData, patch_rom, get_base_rom_path, CVCotMProcedurePatch, CVCOTM_CT_US_HASH, CVCOTM_AC_US_HASH + # CVCOTM_VC_US_HASH from .client import CastlevaniaCotMClient @@ -29,7 +29,8 @@ class CVCotMSettings(settings.Group): """File name of the Castlevania CotM US rom""" copy_to = "Castlevania - Circle of the Moon (USA).gba" description = "Castlevania CotM (US) ROM File" - md5s = [CVCOTM_CT_US_HASH, CVCOTM_AC_US_HASH, CVCOTM_VC_US_HASH] + # md5s = [CVCOTM_CT_US_HASH, CVCOTM_AC_US_HASH, CVCOTM_VC_US_HASH] + md5s = [CVCOTM_CT_US_HASH, CVCOTM_AC_US_HASH] rom_file: RomFile = RomFile(RomFile.copy_to) diff --git a/worlds/cvcotm/docs/en_Castlevania - Circle of the Moon.md b/worlds/cvcotm/docs/en_Castlevania - Circle of the Moon.md index e81b79bf20..695c5f0ff9 100644 --- a/worlds/cvcotm/docs/en_Castlevania - Circle of the Moon.md +++ b/worlds/cvcotm/docs/en_Castlevania - Circle of the Moon.md @@ -153,11 +153,10 @@ Advance Collection ROM; most notably the fact that the audio does not function w which is currently a requirement to connect to a multiworld. This happens because all audio code was stripped from the ROM, and all sound is instead played by the collection through external means. -For this reason, it is most recommended to obtain the ROM by dumping it from an original cartridge of the game that you legally own. -Though, the Advance Collection *can* still technically be an option if you cannot do that and don't mind the lack of sound. +The Wii U Virtual Console version does not work due to changes in the code in that version. -The Wii U Virtual Console version is currently untested. If you happen to have purchased it before the Wii U eShop shut down, you can try -dumping and playing with it. However, at the moment, we cannot guarantee that it will work well due to it being untested. +Due to the reasons mentioned above, it is most recommended to obtain the ROM by dumping it from an original cartridge of the +game that you legally own. However, the Advance Collection *is* an option if you cannot do that and don't mind the lack of sound. Regardless of which released ROM you intend to try playing with, the US version of the game is required. diff --git a/worlds/cvcotm/docs/setup_en.md b/worlds/cvcotm/docs/setup_en.md index 7899ac9973..459e0d6afb 100644 --- a/worlds/cvcotm/docs/setup_en.md +++ b/worlds/cvcotm/docs/setup_en.md @@ -4,7 +4,7 @@ - [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest). - A Castlevania: Circle of the Moon ROM of the US version specifically. The Archipelago community cannot provide this. -The Castlevania Advance Collection ROM can technically be used, but it has no audio. The Wii U Virtual Console ROM is untested. +The Castlevania Advance Collection ROM can be used, but it has no audio. The Wii U Virtual Console ROM does not work. - [BizHawk](https://tasvideos.org/BizHawk/ReleaseHistory) 2.7 or later. ### Configuring BizHawk diff --git a/worlds/cvcotm/rom.py b/worlds/cvcotm/rom.py index e7b0710d13..6ae0b6e438 100644 --- a/worlds/cvcotm/rom.py +++ b/worlds/cvcotm/rom.py @@ -22,11 +22,9 @@ if TYPE_CHECKING: CVCOTM_CT_US_HASH = "50a1089600603a94e15ecf287f8d5a1f" # Original GBA cartridge ROM CVCOTM_AC_US_HASH = "87a1bd6577b6702f97a60fc55772ad74" # Castlevania Advance Collection ROM -CVCOTM_VC_US_HASH = "2cc38305f62b337281663bad8c901cf9" # Wii U Virtual Console ROM +# CVCOTM_VC_US_HASH = "2cc38305f62b337281663bad8c901cf9" # Wii U Virtual Console ROM -# NOTE: The Wii U VC version is untested as of when this comment was written. I am only including its hash in case it -# does work. If someone who has it can confirm it does indeed work, this comment should be removed. If it doesn't, the -# hash should be removed in addition. See the Game Page for more information about supported versions. +# The Wii U VC version is not currently supported. See the Game Page for more info. ARCHIPELAGO_IDENTIFIER_START = 0x7FFF00 ARCHIPELAGO_IDENTIFIER = "ARCHIPELAG03" @@ -518,7 +516,8 @@ class CVCotMPatchExtensions(APPatchExtension): class CVCotMProcedurePatch(APProcedurePatch, APTokenMixin): - hash = [CVCOTM_CT_US_HASH, CVCOTM_AC_US_HASH, CVCOTM_VC_US_HASH] + # hash = [CVCOTM_CT_US_HASH, CVCOTM_AC_US_HASH, CVCOTM_VC_US_HASH] + hash = [CVCOTM_CT_US_HASH, CVCOTM_AC_US_HASH] patch_file_ending: str = ".apcvcotm" result_file_ending: str = ".gba" @@ -585,7 +584,8 @@ def get_base_rom_bytes(file_name: str = "") -> bytes: basemd5 = hashlib.md5() basemd5.update(base_rom_bytes) - if basemd5.hexdigest() not in [CVCOTM_CT_US_HASH, CVCOTM_AC_US_HASH, CVCOTM_VC_US_HASH]: + # if basemd5.hexdigest() not in [CVCOTM_CT_US_HASH, CVCOTM_AC_US_HASH, CVCOTM_VC_US_HASH]: + if basemd5.hexdigest() not in [CVCOTM_CT_US_HASH, CVCOTM_AC_US_HASH]: raise Exception("Supplied Base ROM does not match known MD5s for Castlevania: Circle of the Moon USA." "Get the correct game and version, then dump it.") setattr(get_base_rom_bytes, "base_rom_bytes", base_rom_bytes) From 1de411ec894e5667ea4d850e1949f9f79822e909 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Thu, 13 Mar 2025 23:59:09 +0100 Subject: [PATCH 10/38] The Witness: Change Regions, Areas and Connections from Dict[str, Any] to dataclasses&NamedTuples (#4415) * Change Regions, Areas and Connections to dataclasses/NamedTuples * Move to new file * we do a little renaming * Purge the 'lambda' naming in favor of 'rule' or 'WitnessRule' * missed one * unnecessary change * omega oops * NOOOOOOOO * Merge error * mypy thing --- worlds/witness/data/definition_classes.py | 33 +++++++ worlds/witness/data/static_locations.py | 2 +- worlds/witness/data/static_logic.py | 109 +++++++++++----------- worlds/witness/data/utils.py | 42 +++------ worlds/witness/entity_hunt.py | 2 +- worlds/witness/generate_data_file.py | 2 +- worlds/witness/hints.py | 12 +-- worlds/witness/options.py | 1 - worlds/witness/player_logic.py | 59 ++++++------ worlds/witness/regions.py | 9 +- worlds/witness/rules.py | 2 +- 11 files changed, 148 insertions(+), 125 deletions(-) create mode 100644 worlds/witness/data/definition_classes.py diff --git a/worlds/witness/data/definition_classes.py b/worlds/witness/data/definition_classes.py new file mode 100644 index 0000000000..281fbfcdff --- /dev/null +++ b/worlds/witness/data/definition_classes.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass, field +from typing import FrozenSet, List, NamedTuple + +# A WitnessRule is just an or-chain of and-conditions. +# It represents the set of all options that could fulfill this requirement. +# E.g. if something requires "Dots or (Shapers and Stars)", it'd be represented as: {{"Dots"}, {"Shapers, "Stars"}} +# {} is an unusable requirement. +# {{}} is an always usable requirement. +WitnessRule = FrozenSet[FrozenSet[str]] + + +@dataclass +class AreaDefinition: + name: str + regions: List[str] = field(default_factory=list) + + +@dataclass +class RegionDefinition: + name: str + short_name: str + area: AreaDefinition + logical_entities: List[str] = field(default_factory=list) + physical_entities: List[str] = field(default_factory=list) + + +class ConnectionDefinition(NamedTuple): + target_region: str + traversal_rule: WitnessRule + + @property + def can_be_traversed(self) -> bool: + return bool(self.traversal_rule) diff --git a/worlds/witness/data/static_locations.py b/worlds/witness/data/static_locations.py index 5c5ad554dd..a5cfc3b49f 100644 --- a/worlds/witness/data/static_locations.py +++ b/worlds/witness/data/static_locations.py @@ -486,5 +486,5 @@ for key, item in ALL_LOCATIONS_TO_IDS.items(): ALL_LOCATIONS_TO_ID[key] = item for loc in ALL_LOCATIONS_TO_IDS: - area = static_witness_logic.ENTITIES_BY_NAME[loc]["area"]["name"] + area = static_witness_logic.ENTITIES_BY_NAME[loc]["area"].name AREA_LOCATION_GROUPS.setdefault(area, set()).add(loc) diff --git a/worlds/witness/data/static_logic.py b/worlds/witness/data/static_logic.py index 4f4786a38b..bfe92467fb 100644 --- a/worlds/witness/data/static_logic.py +++ b/worlds/witness/data/static_logic.py @@ -1,8 +1,9 @@ from collections import Counter, defaultdict -from typing import Any, Dict, List, Optional, Set, Tuple +from typing import Any, Dict, FrozenSet, List, Optional, Set from Utils import cache_argsless +from .definition_classes import AreaDefinition, ConnectionDefinition, RegionDefinition, WitnessRule from .item_definition_classes import ( CATEGORY_NAME_MAPPINGS, DoorItemDefinition, @@ -13,7 +14,6 @@ from .item_definition_classes import ( ) from .settings.easter_eggs import EASTER_EGGS from .utils import ( - WitnessRule, define_new_region, get_items, get_sigma_expert_logic, @@ -21,7 +21,7 @@ from .utils import ( get_umbra_variety_logic, get_vanilla_logic, logical_or_witness_rules, - parse_lambda, + parse_witness_rule, ) @@ -31,10 +31,10 @@ class StaticWitnessLogicObj: lines = get_sigma_normal_logic() # All regions with a list of panels in them and the connections to other regions, before logic adjustments - self.ALL_REGIONS_BY_NAME: Dict[str, Dict[str, Any]] = {} - self.ALL_AREAS_BY_NAME: Dict[str, Dict[str, Any]] = {} - self.CONNECTIONS_WITH_DUPLICATES: Dict[str, Dict[str, Set[WitnessRule]]] = defaultdict(lambda: defaultdict(set)) - self.STATIC_CONNECTIONS_BY_REGION_NAME: Dict[str, Set[Tuple[str, WitnessRule]]] = {} + self.ALL_REGIONS_BY_NAME: Dict[str, RegionDefinition] = {} + self.ALL_AREAS_BY_NAME: Dict[str, AreaDefinition] = {} + self.CONNECTIONS_WITH_DUPLICATES: Dict[str, List[ConnectionDefinition]] = defaultdict(list) + self.STATIC_CONNECTIONS_BY_REGION_NAME: Dict[str, List[ConnectionDefinition]] = {} self.ENTITIES_BY_HEX: Dict[str, Dict[str, Any]] = {} self.ENTITIES_BY_NAME: Dict[str, Dict[str, Any]] = {} @@ -55,15 +55,15 @@ class StaticWitnessLogicObj: area_counts: Dict[str, int] = Counter() for region_name, entity_amount in EASTER_EGGS.items(): region_object = self.ALL_REGIONS_BY_NAME[region_name] - correct_area = region_object["area"] + correct_area = region_object.area for _ in range(entity_amount): location_id = 160200 + egg_counter entity_hex = hex(0xEE000 + egg_counter) egg_counter += 1 - area_counts[correct_area["name"]] += 1 - full_entity_name = f"{correct_area['name']} Easter Egg {area_counts[correct_area['name']]}" + area_counts[correct_area.name] += 1 + full_entity_name = f"{correct_area.name} Easter Egg {area_counts[correct_area.name]}" self.ENTITIES_BY_HEX[entity_hex] = { "checkName": full_entity_name, @@ -81,11 +81,11 @@ class StaticWitnessLogicObj: self.STATIC_DEPENDENT_REQUIREMENTS_BY_HEX[entity_hex] = { "entities": frozenset({frozenset({})}) } - region_object["entities"].append(entity_hex) - region_object["physical_entities"].append(entity_hex) + region_object.logical_entities.append(entity_hex) + region_object.physical_entities.append(entity_hex) easter_egg_region = self.ALL_REGIONS_BY_NAME["Easter Eggs"] - easter_egg_area = easter_egg_region["area"] + easter_egg_area = easter_egg_region.area for i in range(sum(EASTER_EGGS.values())): location_id = 160000 + i entity_hex = hex(0xEE200 + i) @@ -111,19 +111,15 @@ class StaticWitnessLogicObj: self.STATIC_DEPENDENT_REQUIREMENTS_BY_HEX[entity_hex] = { "entities": frozenset({frozenset({})}) } - easter_egg_region["entities"].append(entity_hex) - easter_egg_region["physical_entities"].append(entity_hex) + easter_egg_region.logical_entities.append(entity_hex) + easter_egg_region.physical_entities.append(entity_hex) def read_logic_file(self, lines: List[str]) -> None: """ Reads the logic file and does the initial population of data structures """ - - current_region = {} - current_area: Dict[str, Any] = { - "name": "Misc", - "regions": [], - } + current_area = AreaDefinition("Misc") + current_region = RegionDefinition("Fake", "Fake", current_area) # Unused, but makes PyCharm & mypy shut up self.ALL_AREAS_BY_NAME["Misc"] = current_area for line in lines: @@ -133,19 +129,16 @@ class StaticWitnessLogicObj: if line[-1] == ":": new_region_and_connections = define_new_region(line, current_area) current_region = new_region_and_connections[0] - region_name = current_region["name"] + region_name = current_region.name self.ALL_REGIONS_BY_NAME[region_name] = current_region for connection in new_region_and_connections[1]: - self.CONNECTIONS_WITH_DUPLICATES[region_name][connection[0]].add(connection[1]) - current_area["regions"].append(region_name) + self.CONNECTIONS_WITH_DUPLICATES[region_name].append(connection) + current_area.regions.append(region_name) continue if line[0] == "=": area_name = line[2:-2] - current_area = { - "name": area_name, - "regions": [], - } + current_area = AreaDefinition(area_name, []) self.ALL_AREAS_BY_NAME[area_name] = current_area continue @@ -158,9 +151,9 @@ class StaticWitnessLogicObj: entity_hex = entity_name_full[0:7] entity_name = entity_name_full[9:-1] - required_panel_lambda = line_split.pop(0) + entity_requirement_string = line_split.pop(0) - full_entity_name = current_region["shortName"] + " " + entity_name + full_entity_name = current_region.short_name + " " + entity_name if location_id == "Door" or location_id == "Laser": self.ENTITIES_BY_HEX[entity_hex] = { @@ -177,18 +170,18 @@ class StaticWitnessLogicObj: self.ENTITIES_BY_NAME[self.ENTITIES_BY_HEX[entity_hex]["checkName"]] = self.ENTITIES_BY_HEX[entity_hex] self.STATIC_DEPENDENT_REQUIREMENTS_BY_HEX[entity_hex] = { - "entities": parse_lambda(required_panel_lambda) + "entities": parse_witness_rule(entity_requirement_string) } # Lasers and Doors exist in a region, but don't have a regional *requirement* # If a laser is activated, you don't need to physically walk up to it for it to count # As such, logically, they behave more as if they were part of the "Entry" region - self.ALL_REGIONS_BY_NAME["Entry"]["entities"].append(entity_hex) + self.ALL_REGIONS_BY_NAME["Entry"].logical_entities.append(entity_hex) # However, it will also be important to keep track of their physical location for postgame purposes. - current_region["physical_entities"].append(entity_hex) + current_region.physical_entities.append(entity_hex) continue - required_item_lambda = line_split.pop(0) + item_requirement_string = line_split.pop(0) laser_names = { "Laser", @@ -224,18 +217,18 @@ class StaticWitnessLogicObj: entity_type = "Panel" location_type = "General" - required_items = parse_lambda(required_item_lambda) - required_panels = parse_lambda(required_panel_lambda) + required_items = parse_witness_rule(item_requirement_string) + required_entities = parse_witness_rule(entity_requirement_string) required_items = frozenset(required_items) requirement = { - "entities": required_panels, + "entities": required_entities, "items": required_items } if entity_type == "Obelisk Side": - eps = set(next(iter(required_panels))) + eps = set(next(iter(required_entities))) eps -= {"Theater to Tunnels"} eps_ints = {int(h, 16) for h in eps} @@ -260,39 +253,43 @@ class StaticWitnessLogicObj: self.ENTITIES_BY_NAME[self.ENTITIES_BY_HEX[entity_hex]["checkName"]] = self.ENTITIES_BY_HEX[entity_hex] self.STATIC_DEPENDENT_REQUIREMENTS_BY_HEX[entity_hex] = requirement - current_region["entities"].append(entity_hex) - current_region["physical_entities"].append(entity_hex) + current_region.logical_entities.append(entity_hex) + current_region.physical_entities.append(entity_hex) self.add_easter_eggs() - def reverse_connection(self, source_region: str, connection: Tuple[str, Set[WitnessRule]]) -> None: - target = connection[0] - traversal_options = connection[1] - + def reverse_connection(self, source_region: str, connection: ConnectionDefinition) -> None: # Reverse this connection with all its possibilities, except the ones marked as "OneWay". - for requirement in traversal_options: - remaining_options = set() - for option in requirement: - if not any(req == "TrueOneWay" for req in option): - remaining_options.add(option) + remaining_options: Set[FrozenSet[str]] = set() + for sub_option in connection.traversal_rule: + if not any(req == "TrueOneWay" for req in sub_option): + remaining_options.add(sub_option) - if remaining_options: - self.CONNECTIONS_WITH_DUPLICATES[target][source_region].add(frozenset(remaining_options)) + reversed_connection = ConnectionDefinition(source_region, frozenset(remaining_options)) + if reversed_connection.can_be_traversed: + self.CONNECTIONS_WITH_DUPLICATES[connection.target_region].append(reversed_connection) def reverse_connections(self) -> None: # Iterate all connections for region_name, connections in list(self.CONNECTIONS_WITH_DUPLICATES.items()): - for connection in connections.items(): + for connection in connections: self.reverse_connection(region_name, connection) def combine_connections(self) -> None: # All regions need to be present, and this dict is copied later - Thus, defaultdict is not the correct choice. - self.STATIC_CONNECTIONS_BY_REGION_NAME = {region_name: set() for region_name in self.ALL_REGIONS_BY_NAME} + self.STATIC_CONNECTIONS_BY_REGION_NAME = {region_name: [] for region_name in self.ALL_REGIONS_BY_NAME} for source, connections in self.CONNECTIONS_WITH_DUPLICATES.items(): - for target, requirement in connections.items(): - combined_req = logical_or_witness_rules(requirement) - self.STATIC_CONNECTIONS_BY_REGION_NAME[source].add((target, combined_req)) + # Organize rules by target region + traversal_options_by_target_region = defaultdict(list) + for target_region, traversal_option in connections: + traversal_options_by_target_region[target_region].append(traversal_option) + + # Combine connections to the same target region into one connection + for target, traversal_rules in traversal_options_by_target_region.items(): + combined_rule = logical_or_witness_rules(traversal_rules) + combined_connection = ConnectionDefinition(target, combined_rule) + self.STATIC_CONNECTIONS_BY_REGION_NAME[source].append(combined_connection) # Item data parsed from WitnessItems.txt diff --git a/worlds/witness/data/utils.py b/worlds/witness/data/utils.py index aca4573806..5f5622819d 100644 --- a/worlds/witness/data/utils.py +++ b/worlds/witness/data/utils.py @@ -2,17 +2,12 @@ from datetime import date from math import floor from pkgutil import get_data from random import Random -from typing import Any, Collection, Dict, FrozenSet, Iterable, List, Optional, Set, Tuple, TypeVar +from typing import Collection, FrozenSet, Iterable, List, Optional, Set, Tuple, TypeVar + +from .definition_classes import AreaDefinition, ConnectionDefinition, RegionDefinition, WitnessRule T = TypeVar("T") -# A WitnessRule is just an or-chain of and-conditions. -# It represents the set of all options that could fulfill this requirement. -# E.g. if something requires "Dots or (Shapers and Stars)", it'd be represented as: {{"Dots"}, {"Shapers, "Stars"}} -# {} is an unusable requirement. -# {{}} is an always usable requirement. -WitnessRule = FrozenSet[FrozenSet[str]] - def cast_not_none(value: Optional[T]) -> T: assert value is not None @@ -62,7 +57,7 @@ def build_weighted_int_list(inputs: Collection[float], total: int) -> List[int]: return rounded_output -def define_new_region(region_string: str, area: dict[str, Any]) -> Tuple[Dict[str, Any], Set[Tuple[str, WitnessRule]]]: +def define_new_region(region_string: str, area: AreaDefinition) -> Tuple[RegionDefinition, List[ConnectionDefinition]]: """ Returns a region object by parsing a line in the logic file """ @@ -77,35 +72,28 @@ def define_new_region(region_string: str, area: dict[str, Any]) -> Tuple[Dict[st region_name = region_name_split[0] region_name_simple = region_name_split[1][:-1] - options = set() + options = [] for _ in range(len(line_split) // 2): connected_region = line_split.pop(0) - corresponding_lambda = line_split.pop(0) + traversal_rule_string = line_split.pop(0) - options.add( - (connected_region, parse_lambda(corresponding_lambda)) - ) + options.append(ConnectionDefinition(connected_region, parse_witness_rule(traversal_rule_string))) + + region_obj = RegionDefinition(region_name, region_name_simple, area) - region_obj = { - "name": region_name, - "shortName": region_name_simple, - "entities": [], - "physical_entities": [], - "area": area, - } return region_obj, options -def parse_lambda(lambda_string: str) -> WitnessRule: +def parse_witness_rule(rule_string: str) -> WitnessRule: """ - Turns a lambda String literal like this: a | b & c - into a set of sets like this: {{a}, {b, c}} - The lambda has to be in DNF. + Turns a rule string literal like this: a | b & c + into a set of sets (called "WitnessRule") like this: {{a}, {b, c}} + The rule string has to be in DNF. """ - if lambda_string == "True": + if rule_string == "True": return frozenset([frozenset()]) - split_ands = set(lambda_string.split(" | ")) + split_ands = set(rule_string.split(" | ")) return frozenset({frozenset(a.split(" & ")) for a in split_ands}) diff --git a/worlds/witness/entity_hunt.py b/worlds/witness/entity_hunt.py index 9549246ce4..de2f7dd68d 100644 --- a/worlds/witness/entity_hunt.py +++ b/worlds/witness/entity_hunt.py @@ -129,7 +129,7 @@ class EntityHuntPicker: eligible_panels_by_area = defaultdict(set) for eligible_panel in all_eligible_panels: - associated_area = static_witness_logic.ENTITIES_BY_HEX[eligible_panel]["area"]["name"] + associated_area = static_witness_logic.ENTITIES_BY_HEX[eligible_panel]["area"].name eligible_panels_by_area[associated_area].add(eligible_panel) return all_eligible_panels, eligible_panels_by_area diff --git a/worlds/witness/generate_data_file.py b/worlds/witness/generate_data_file.py index cc05015cd8..679aa80b28 100644 --- a/worlds/witness/generate_data_file.py +++ b/worlds/witness/generate_data_file.py @@ -18,7 +18,7 @@ if __name__ == "__main__": for entity_id, entity_object in static_witness_logic.ENTITIES_BY_HEX.items(): location_id = entity_object["id"] - area = entity_object["area"]["name"] + area = entity_object["area"].name area_to_entity_ids[area].append(entity_id) if location_id is None: diff --git a/worlds/witness/hints.py b/worlds/witness/hints.py index 6f274f5e2c..c82024cc12 100644 --- a/worlds/witness/hints.py +++ b/worlds/witness/hints.py @@ -464,7 +464,7 @@ def choose_areas(world: "WitnessWorld", amount: int, locations_per_area: Dict[st def get_hintable_areas(world: "WitnessWorld") -> Tuple[Dict[str, List[Location]], Dict[str, List[Item]]]: - potential_areas = list(static_witness_logic.ALL_AREAS_BY_NAME.keys()) + potential_areas = list(static_witness_logic.ALL_AREAS_BY_NAME.values()) locations_per_area = {} items_per_area = {} @@ -472,14 +472,14 @@ def get_hintable_areas(world: "WitnessWorld") -> Tuple[Dict[str, List[Location]] for area in potential_areas: regions = [ world.get_region(region) - for region in static_witness_logic.ALL_AREAS_BY_NAME[area]["regions"] + for region in area.regions if region in world.player_regions.created_region_names ] locations = [location for region in regions for location in region.get_locations() if not location.is_event] if locations: - locations_per_area[area] = locations - items_per_area[area] = [location.item for location in locations] + locations_per_area[area.name] = locations + items_per_area[area.name] = [location.item for location in locations] return locations_per_area, items_per_area @@ -516,7 +516,7 @@ def word_area_hint(world: "WitnessWorld", hinted_area: str, area_items: List[Ite hunt_panels = None if world.options.victory_condition == "panel_hunt" and hinted_area != "Easter Eggs": hunt_panels = sum( - static_witness_logic.ENTITIES_BY_HEX[hunt_entity]["area"]["name"] == hinted_area + static_witness_logic.ENTITIES_BY_HEX[hunt_entity]["area"].name == hinted_area for hunt_entity in world.player_logic.HUNT_ENTITIES ) @@ -620,7 +620,7 @@ def create_all_hints(world: "WitnessWorld", hint_amount: int, area_hints: int, already_hinted_locations |= { loc for loc in world.multiworld.get_reachable_locations(state, world.player) - if loc.address and static_witness_logic.ENTITIES_BY_NAME[loc.name]["area"]["name"] == "Tutorial (Inside)" + if loc.address and static_witness_logic.ENTITIES_BY_NAME[loc.name]["area"].name == "Tutorial (Inside)" } intended_location_hints = hint_amount - area_hints diff --git a/worlds/witness/options.py b/worlds/witness/options.py index c56209b226..1c2bc9324f 100644 --- a/worlds/witness/options.py +++ b/worlds/witness/options.py @@ -1,5 +1,4 @@ from dataclasses import dataclass -from datetime import datetime from typing import Tuple from schema import And, Schema diff --git a/worlds/witness/player_logic.py b/worlds/witness/player_logic.py index 1276d55dce..52bddde17e 100644 --- a/worlds/witness/player_logic.py +++ b/worlds/witness/player_logic.py @@ -20,10 +20,10 @@ from collections import defaultdict from typing import TYPE_CHECKING, Dict, List, Set, Tuple, cast from .data import static_logic as static_witness_logic +from .data.definition_classes import ConnectionDefinition, WitnessRule from .data.item_definition_classes import DoorItemDefinition, ItemCategory, ProgressiveItemDefinition from .data.static_logic import StaticWitnessLogicObj from .data.utils import ( - WitnessRule, get_boat, get_caves_except_path_to_challenge_exclusion_list, get_complex_additional_panels, @@ -47,7 +47,7 @@ from .data.utils import ( get_vault_exclusion_list, logical_and_witness_rules, logical_or_witness_rules, - parse_lambda, + parse_witness_rule, ) from .entity_hunt import EntityHuntPicker @@ -97,10 +97,10 @@ class WitnessPlayerLogic: elif self.DIFFICULTY == "none": self.REFERENCE_LOGIC = static_witness_logic.vanilla - self.CONNECTIONS_BY_REGION_NAME_THEORETICAL: Dict[str, Set[Tuple[str, WitnessRule]]] = copy.deepcopy( + self.CONNECTIONS_BY_REGION_NAME_THEORETICAL: Dict[str, List[ConnectionDefinition]] = copy.deepcopy( self.REFERENCE_LOGIC.STATIC_CONNECTIONS_BY_REGION_NAME ) - self.CONNECTIONS_BY_REGION_NAME: Dict[str, Set[Tuple[str, WitnessRule]]] = copy.deepcopy( + self.CONNECTIONS_BY_REGION_NAME: Dict[str, List[ConnectionDefinition]] = copy.deepcopy( self.REFERENCE_LOGIC.STATIC_CONNECTIONS_BY_REGION_NAME ) self.DEPENDENT_REQUIREMENTS_BY_HEX: Dict[str, Dict[str, WitnessRule]] = copy.deepcopy( @@ -178,7 +178,7 @@ class WitnessPlayerLogic: entity_obj = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[entity_hex] - if entity_obj["region"] is not None and entity_obj["region"]["name"] in self.UNREACHABLE_REGIONS: + if entity_obj["region"] is not None and entity_obj["region"].name in self.UNREACHABLE_REGIONS: return frozenset() # For the requirement of an entity, we consider two things: @@ -270,7 +270,7 @@ class WitnessPlayerLogic: new_items = theoretical_new_items if dep_obj["region"] and entity_obj["region"] != dep_obj["region"]: new_items = frozenset( - frozenset(possibility | {dep_obj["region"]["name"]}) + frozenset(possibility | {dep_obj["region"].name}) for possibility in new_items ) @@ -359,11 +359,11 @@ class WitnessPlayerLogic: line_split = line.split(" - ") requirement = { - "entities": parse_lambda(line_split[1]), + "entities": parse_witness_rule(line_split[1]), } if len(line_split) > 2: - required_items = parse_lambda(line_split[2]) + required_items = parse_witness_rule(line_split[2]) items_actually_in_the_game = [ item_name for item_name, item_definition in static_witness_logic.ALL_ITEMS.items() if item_definition.category is ItemCategory.SYMBOL @@ -394,26 +394,31 @@ class WitnessPlayerLogic: return if adj_type == "New Connections": + # This adjustment type does not actually reverse the connection if it could be reversed. + # If needed, this might be added later line_split = line.split(" - ") source_region = line_split[0] target_region = line_split[1] panel_set_string = line_split[2] for connection in self.CONNECTIONS_BY_REGION_NAME_THEORETICAL[source_region]: - if connection[0] == target_region: + if connection.target_region == target_region: self.CONNECTIONS_BY_REGION_NAME_THEORETICAL[source_region].remove(connection) if panel_set_string == "TrueOneWay": - self.CONNECTIONS_BY_REGION_NAME_THEORETICAL[source_region].add( - (target_region, frozenset({frozenset(["TrueOneWay"])})) - ) + # This means the connection can be completely replaced + only_connection = ConnectionDefinition(target_region, frozenset({frozenset(["TrueOneWay"])})) + self.CONNECTIONS_BY_REGION_NAME_THEORETICAL[source_region].append(only_connection) else: - new_lambda = logical_or_witness_rules([connection[1], parse_lambda(panel_set_string)]) - self.CONNECTIONS_BY_REGION_NAME_THEORETICAL[source_region].add((target_region, new_lambda)) + combined_rule = logical_or_witness_rules( + [connection.traversal_rule, parse_witness_rule(panel_set_string)] + ) + combined_connection = ConnectionDefinition(target_region, combined_rule) + self.CONNECTIONS_BY_REGION_NAME_THEORETICAL[source_region].append(combined_connection) break else: - new_conn = (target_region, parse_lambda(panel_set_string)) - self.CONNECTIONS_BY_REGION_NAME_THEORETICAL[source_region].add(new_conn) + new_connection = ConnectionDefinition(target_region, parse_witness_rule(panel_set_string)) + self.CONNECTIONS_BY_REGION_NAME_THEORETICAL[source_region].append(new_connection) if adj_type == "Added Locations": if "0x" in line: @@ -558,7 +563,7 @@ class WitnessPlayerLogic: self.AVAILABLE_EASTER_EGGS_PER_REGION = defaultdict(int) for entity_hex in self.AVAILABLE_EASTER_EGGS: - region_name = static_witness_logic.ENTITIES_BY_HEX[entity_hex]["region"]["name"] + region_name = static_witness_logic.ENTITIES_BY_HEX[entity_hex]["region"].name self.AVAILABLE_EASTER_EGGS_PER_REGION[region_name] += 1 eggs_per_check, logically_required_eggs_per_check = world.options.easter_egg_hunt.get_step_and_logical_step() @@ -796,7 +801,7 @@ class WitnessPlayerLogic: next_region = regions_to_check.pop() for region_exit in self.CONNECTIONS_BY_REGION_NAME[next_region]: - target = region_exit[0] + target = region_exit.target_region if target in reachable_regions: continue @@ -844,7 +849,7 @@ class WitnessPlayerLogic: # First, entities in unreachable regions are obviously themselves unreachable. for region in new_unreachable_regions: - for entity in static_witness_logic.ALL_REGIONS_BY_NAME[region]["physical_entities"]: + for entity in static_witness_logic.ALL_REGIONS_BY_NAME[region].physical_entities: # Never disable the Victory Location. if entity == self.VICTORY_LOCATION: continue @@ -879,11 +884,11 @@ class WitnessPlayerLogic: if not new_unreachable_regions and not newly_discovered_disabled_entities: return - def reduce_connection_requirement(self, connection: Tuple[str, WitnessRule]) -> WitnessRule: + def reduce_connection_requirement(self, connection: ConnectionDefinition) -> ConnectionDefinition: all_possibilities = [] # Check each traversal option individually - for option in connection[1]: + for option in connection.traversal_rule: individual_entity_requirements: List[WitnessRule] = [] for entity in option: # If a connection requires solving a disabled entity, it is not valid. @@ -901,7 +906,7 @@ class WitnessPlayerLogic: entity_req = self.get_entity_requirement(entity) if self.REFERENCE_LOGIC.ENTITIES_BY_HEX[entity]["region"]: - region_name = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[entity]["region"]["name"] + region_name = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[entity]["region"].name entity_req = logical_and_witness_rules([entity_req, frozenset({frozenset({region_name})})]) individual_entity_requirements.append(entity_req) @@ -909,7 +914,7 @@ class WitnessPlayerLogic: # Merge all possible requirements into one DNF condition. all_possibilities.append(logical_and_witness_rules(individual_entity_requirements)) - return logical_or_witness_rules(all_possibilities) + return ConnectionDefinition(connection.target_region, logical_or_witness_rules(all_possibilities)) def make_dependency_reduced_checklist(self) -> None: """ @@ -942,14 +947,14 @@ class WitnessPlayerLogic: # Make independent region connection requirements based on the entities they require for region, connections in self.CONNECTIONS_BY_REGION_NAME_THEORETICAL.items(): - new_connections = set() + new_connections = [] for connection in connections: - overall_requirement = self.reduce_connection_requirement(connection) + reduced_connection = self.reduce_connection_requirement(connection) # If there is a way to use this connection, add it. - if overall_requirement: - new_connections.add((connection[0], overall_requirement)) + if reduced_connection.can_be_traversed: + new_connections.append(reduced_connection) self.CONNECTIONS_BY_REGION_NAME[region] = new_connections diff --git a/worlds/witness/regions.py b/worlds/witness/regions.py index 8cb3678ab6..c057134adb 100644 --- a/worlds/witness/regions.py +++ b/worlds/witness/regions.py @@ -10,8 +10,9 @@ from BaseClasses import Entrance, Region from worlds.generic.Rules import CollectionRule from .data import static_logic as static_witness_logic +from .data.definition_classes import WitnessRule from .data.static_logic import StaticWitnessLogicObj -from .data.utils import WitnessRule, optimize_witness_rule +from .data.utils import optimize_witness_rule from .locations import WitnessPlayerLocations from .player_logic import WitnessPlayerLogic @@ -114,7 +115,7 @@ class WitnessPlayerRegions: if k not in player_logic.UNREACHABLE_REGIONS } - event_locations_per_region = defaultdict(dict) + event_locations_per_region: Dict[str, Dict[str, int]] = defaultdict(dict) for event_location, event_item_and_entity in player_logic.EVENT_ITEM_PAIRS.items(): entity_or_region = event_item_and_entity[1] @@ -126,13 +127,13 @@ class WitnessPlayerRegions: if region is None: region_name = "Entry" else: - region_name = region["name"] + region_name = region.name order = self.reference_logic.ENTITIES_BY_HEX[entity_or_region]["order"] event_locations_per_region[region_name][event_location] = order for region_name, region in regions_to_create.items(): location_entities_for_this_region = [ - self.reference_logic.ENTITIES_BY_HEX[entity] for entity in region["entities"] + self.reference_logic.ENTITIES_BY_HEX[entity] for entity in region.logical_entities ] locations_for_this_region = { entity["checkName"]: entity["order"] for entity in location_entities_for_this_region diff --git a/worlds/witness/rules.py b/worlds/witness/rules.py index 866f4690f5..545c3e7dd0 100644 --- a/worlds/witness/rules.py +++ b/worlds/witness/rules.py @@ -10,7 +10,7 @@ from BaseClasses import CollectionState from worlds.generic.Rules import CollectionRule, set_rule from .data import static_logic as static_witness_logic -from .data.utils import WitnessRule +from .data.definition_classes import WitnessRule from .player_logic import WitnessPlayerLogic if TYPE_CHECKING: From 9b3ee018e9866dd2a35649b130da6b9e6a9041f9 Mon Sep 17 00:00:00 2001 From: Benny D <78334662+benny-dreamly@users.noreply.github.com> Date: Fri, 14 Mar 2025 01:24:37 -0600 Subject: [PATCH 11/38] Core/Various Worlds: Fix crash/freeze with unicode characters (#4671) replace colorama.init with just_fix_windows_console --- AdventureClient.py | 2 +- CommonClient.py | 2 +- FF1Client.py | 2 +- LinksAwakeningClient.py | 2 +- MMBN3Client.py | 2 +- MultiServer.py | 2 +- OoTClient.py | 2 +- SNIClient.py | 2 +- UndertaleClient.py | 2 +- WargrooveClient.py | 2 +- Zelda1Client.py | 2 +- worlds/_bizhawk/context.py | 2 +- worlds/ahit/Client.py | 2 +- worlds/factorio/Client.py | 2 +- worlds/kh1/Client.py | 2 +- worlds/kh2/Client.py | 2 +- worlds/sc2/Client.py | 2 +- worlds/zillion/client.py | 2 +- worlds/zork_grand_inquisitor/client.py | 2 +- 19 files changed, 19 insertions(+), 19 deletions(-) diff --git a/AdventureClient.py b/AdventureClient.py index 24c6a4c4fc..91567fc0a0 100644 --- a/AdventureClient.py +++ b/AdventureClient.py @@ -511,7 +511,7 @@ if __name__ == '__main__': import colorama - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main()) colorama.deinit() diff --git a/CommonClient.py b/CommonClient.py index 33792f0ed2..ae411838d8 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -1128,7 +1128,7 @@ def run_as_textclient(*args): args = handle_url_arg(args, parser=parser) # use colorama to display colored text highlighting on windows - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main(args)) colorama.deinit() diff --git a/FF1Client.py b/FF1Client.py index b7c58e2061..748a95b72c 100644 --- a/FF1Client.py +++ b/FF1Client.py @@ -261,7 +261,7 @@ if __name__ == '__main__': parser = get_base_parser() args = parser.parse_args() - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main(args)) colorama.deinit() diff --git a/LinksAwakeningClient.py b/LinksAwakeningClient.py index ff932e7c76..aac6c2f214 100644 --- a/LinksAwakeningClient.py +++ b/LinksAwakeningClient.py @@ -803,6 +803,6 @@ async def main(): await ctx.shutdown() if __name__ == '__main__': - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main()) colorama.deinit() diff --git a/MMBN3Client.py b/MMBN3Client.py index 140a98745c..4945d49221 100644 --- a/MMBN3Client.py +++ b/MMBN3Client.py @@ -370,7 +370,7 @@ if __name__ == "__main__": import colorama - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main()) colorama.deinit() diff --git a/MultiServer.py b/MultiServer.py index a310808b3a..f9ed34e2f7 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -47,7 +47,7 @@ from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, Networ from BaseClasses import ItemClassification min_client_version = Version(0, 1, 6) -colorama.init() +colorama.just_fix_windows_console() def remove_from_list(container, value): diff --git a/OoTClient.py b/OoTClient.py index 1154904173..6a87b9e722 100644 --- a/OoTClient.py +++ b/OoTClient.py @@ -346,7 +346,7 @@ if __name__ == '__main__': import colorama - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main()) colorama.deinit() diff --git a/SNIClient.py b/SNIClient.py index 9140c73c14..1156bf6040 100644 --- a/SNIClient.py +++ b/SNIClient.py @@ -735,6 +735,6 @@ async def main() -> None: if __name__ == '__main__': - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main()) colorama.deinit() diff --git a/UndertaleClient.py b/UndertaleClient.py index dfacee148a..1c522fac92 100644 --- a/UndertaleClient.py +++ b/UndertaleClient.py @@ -500,7 +500,7 @@ def main(): import colorama - colorama.init() + colorama.just_fix_windows_console() asyncio.run(_main()) colorama.deinit() diff --git a/WargrooveClient.py b/WargrooveClient.py index f9971f7a6c..f900e05e3f 100644 --- a/WargrooveClient.py +++ b/WargrooveClient.py @@ -446,6 +446,6 @@ if __name__ == '__main__': parser = get_base_parser(description="Wargroove Client, for text interfacing.") args, rest = parser.parse_known_args() - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main(args)) colorama.deinit() diff --git a/Zelda1Client.py b/Zelda1Client.py index 1154804fbf..4473b3f3c7 100644 --- a/Zelda1Client.py +++ b/Zelda1Client.py @@ -386,7 +386,7 @@ if __name__ == '__main__': parser.add_argument('diff_file', default="", type=str, nargs="?", help='Path to a Archipelago Binary Patch file') args = parser.parse_args() - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main(args)) colorama.deinit() diff --git a/worlds/_bizhawk/context.py b/worlds/_bizhawk/context.py index 21c54d30c7..c9b1076644 100644 --- a/worlds/_bizhawk/context.py +++ b/worlds/_bizhawk/context.py @@ -276,6 +276,6 @@ def launch(*launch_args: str) -> None: Utils.init_logging("BizHawkClient", exception_logger="Client") import colorama - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main()) colorama.deinit() diff --git a/worlds/ahit/Client.py b/worlds/ahit/Client.py index cbb5f2a13d..0a9d8d6042 100644 --- a/worlds/ahit/Client.py +++ b/worlds/ahit/Client.py @@ -261,6 +261,6 @@ def launch(): # options = Utils.get_options() import colorama - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main()) colorama.deinit() diff --git a/worlds/factorio/Client.py b/worlds/factorio/Client.py index ac58339c5e..ff1de17f0b 100644 --- a/worlds/factorio/Client.py +++ b/worlds/factorio/Client.py @@ -530,7 +530,7 @@ server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password) def launch(): import colorama global executable, server_settings, server_args - colorama.init() + colorama.just_fix_windows_console() if server_settings: server_settings = os.path.abspath(server_settings) diff --git a/worlds/kh1/Client.py b/worlds/kh1/Client.py index 33fba85f6c..b98f215312 100644 --- a/worlds/kh1/Client.py +++ b/worlds/kh1/Client.py @@ -295,6 +295,6 @@ def launch(): parser = get_base_parser(description="KH1 Client, for text interfacing.") args, rest = parser.parse_known_args() - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main(args)) colorama.deinit() diff --git a/worlds/kh2/Client.py b/worlds/kh2/Client.py index 15a103c2a1..96b406c72f 100644 --- a/worlds/kh2/Client.py +++ b/worlds/kh2/Client.py @@ -981,6 +981,6 @@ def launch(): parser = get_base_parser(description="KH2 Client, for text interfacing.") args, rest = parser.parse_known_args() - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main(args)) colorama.deinit() diff --git a/worlds/sc2/Client.py b/worlds/sc2/Client.py index 813cf28845..77b13a5acb 100644 --- a/worlds/sc2/Client.py +++ b/worlds/sc2/Client.py @@ -1625,6 +1625,6 @@ def get_location_offset(mission_id): def launch(): - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main()) colorama.deinit() diff --git a/worlds/zillion/client.py b/worlds/zillion/client.py index d629df583a..71f0615d32 100644 --- a/worlds/zillion/client.py +++ b/worlds/zillion/client.py @@ -516,6 +516,6 @@ async def main() -> None: def launch() -> None: - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main()) colorama.deinit() diff --git a/worlds/zork_grand_inquisitor/client.py b/worlds/zork_grand_inquisitor/client.py index 11d6b7f8f1..8b8d7d3ebf 100644 --- a/worlds/zork_grand_inquisitor/client.py +++ b/worlds/zork_grand_inquisitor/client.py @@ -177,7 +177,7 @@ def main() -> None: import colorama - colorama.init() + colorama.just_fix_windows_console() asyncio.run(_main()) From 0d1935e7572b7fdaef624086bffc87a987ea88bc Mon Sep 17 00:00:00 2001 From: neocerber <140952826+neocerber@users.noreply.github.com> Date: Fri, 14 Mar 2025 11:35:58 -0400 Subject: [PATCH 12/38] SC2: Add a description of mission order and the impact of collect on a SC2 world (#4398) * Added mission order to randomized stuff, added a mention to the default option collect on goal, added an issue about mission order progress vs AP collect * Remove false menion of collect being note modifyable after the mworld was gen * Simplification of some sentences * American spelling, header newline, and other * Revert gray to grey, corrected some colors * Forgot a gray -> grey * Replace how the faction color option is described to side-step difference within yaml and client. Both fr/en. --- worlds/sc2/docs/en_Starcraft 2.md | 40 ++++++++++++++++++++++----- worlds/sc2/docs/fr_Starcraft 2.md | 45 +++++++++++++++++++++++++------ worlds/sc2/docs/setup_en.md | 1 + worlds/sc2/docs/setup_fr.md | 1 + 4 files changed, 73 insertions(+), 14 deletions(-) diff --git a/worlds/sc2/docs/en_Starcraft 2.md b/worlds/sc2/docs/en_Starcraft 2.md index 813fdb5f4a..e860e8a6b6 100644 --- a/worlds/sc2/docs/en_Starcraft 2.md +++ b/worlds/sc2/docs/en_Starcraft 2.md @@ -1,10 +1,13 @@ # StarCraft 2 ## Game page in other languages: + * [Français](/games/Starcraft%202/info/fr) ## What does randomization do to this game? +### Items and locations + The following unlocks are randomized as items: 1. Your ability to build any non-worker unit. 2. Unit specific upgrades including some combinations not available in the vanilla campaigns, such as both strain @@ -34,18 +37,28 @@ When you receive items, they will immediately become available, even during a mi notified via a text box in the top-right corner of the game screen. Item unlocks are also logged in the Archipelago client. +### Mission order + +The missions and the order in which they need to be completed, referred to as the mission order, can also be randomized. +The four StarCraft 2 campaigns can be used to populate the mission order. +Note that the evolution missions from Heart of the Swarm are not included in the randomizer. +The default mission order follows the structure of the selected campaigns but several other options are available, +e.g., blitz, grid, etc. + Missions are launched through the StarCraft 2 Archipelago client, through the StarCraft 2 Launcher tab. The between mission segments on the Hyperion, the Leviathan, and the Spear of Adun are not included. Additionally, metaprogression currencies such as credits and Solarite are not used. +Available missions are in blue; missions where all locations were collected are in white. +If you move your mouse over a mission, the uncollected locations will be displayed, categorized by type. +Unavailable missions are in grey; their requirements will also be shown there. ## What is the goal of this game when randomized? The goal is to beat the final mission in the mission order. -The yaml configuration file controls the mission order (e.g. blitz, grid, etc.), which combination of the four -StarCraft 2 campaigns can be used to populate the mission order and how missions are shuffled. +The yaml configuration file controls the mission order, which combination of the four StarCraft 2 campaigns can be +used, and how missions are shuffled. Since the first two options determine the number of missions in a StarCraft 2 world, they can be used to customize the expected time to complete the world. -Note that the evolution missions from Heart of the Swarm are not included in the randomizer. ## What non-randomized changes are there from vanilla StarCraft 2? @@ -78,9 +91,7 @@ Will overwrite existing files * `/game_speed [game_speed]` Overrides the game speed for the world * Options: default, slower, slow, normal, fast, faster * `/color [faction] [color]` Changes your color for one of your playable factions. - * Faction options: raynor, kerrigan, primal, protoss, nova - * Color options: white, red, blue, teal, purple, yellow, orange, green, lightpink, violet, lightgrey, darkgreen, - brown, lightgreen, darkgrey, pink, rainbow, random, default + * Run without arguments to list all factions and colors that are available. * `/option [option_name] [option_value]` Sets an option normally controlled by your yaml after generation. * Run without arguments to list all options. * Options pertain to automatic cutscene skipping, Kerrigan presence, Spear of Adun presence, starting resource @@ -100,6 +111,19 @@ Additionally, upgrades are grouped beneath their corresponding units or building A filter parameter can be provided, e.g., `/received Thor`, to limit the number of items shown. Every item whose name, race, or group name contains the provided parameter will be shown. +## Particularities in a multiworld + +### Collect on goal completion + +One of the default options of multiworlds is that once a world has achieved its goal, it collects its items from all +other worlds. +If you do not want this to happen, you should ask the person generating the multiworld to set the `Collect Permission` +option to something else, e.g., manual. +If the generation is not done via the website, the person that does the generation should modify the `collect_mode` +option in their `host.yaml` file prior to generation. +If the multiworld has already been generated, the host can use the command `/option collect_mode [value]` to change +this option. + ## Known issues - StarCraft 2 Archipelago does not support loading a saved game. @@ -108,3 +132,7 @@ For this reason, it is recommended to play on a difficulty level lower than what To restart a mission, use the StarCraft 2 Client. - A crash report is often generated when a mission is closed. This does not affect the game and can be ignored. +- Currently, the StarCraft 2 client uses the Victory locations to determine which missions have been completed. +As a result, the Archipelago collect feature can sometime grant access to missions that are connected to a mission that +you did not complete. + diff --git a/worlds/sc2/docs/fr_Starcraft 2.md b/worlds/sc2/docs/fr_Starcraft 2.md index 092835c8e3..190802e91b 100644 --- a/worlds/sc2/docs/fr_Starcraft 2.md +++ b/worlds/sc2/docs/fr_Starcraft 2.md @@ -2,6 +2,8 @@ ## Quel est l'effet de la *randomization* sur ce jeu ? +### *Items* et *locations* + Les éléments qui suivent sont les *items* qui sont *randomized* et qui doivent être débloqués pour être utilisés dans le jeu: 1. La capacité de produire des unités, excepté les drones/probes/scv. @@ -37,21 +39,33 @@ Quand vous recevez un *item*, il devient immédiatement disponible, même pendan la boîte de texte situé dans le coin en haut à droite de *StarCraft 2*. L'acquisition d'un *item* est aussi indiquée dans le client d'Archipelago. +### *Mission order* + +Les missions et l'ordre dans lequel elles doivent être complétées, dénoté *mission order*, peuvent également être +*randomized*. +Les quatre campagnes de *StarCraft 2* peuvent être utilisées pour remplir le *mission order*. +Notez que les missions d'évolution de *Heart of the Swarm* ne sont pas incluses dans le *randomizer*. +Par défaut, le *mission order* suit la structure des campagnes sélectionnées, mais plusieurs autres options sont +disponibles, comme *blitz*, *grid*, etc. + Les missions peuvent être lancées par le client *StarCraft 2 Archipelago*, via l'interface graphique de l'onglet *StarCraft 2 Launcher*. Les segments qui se passent sur l'*Hyperion*, un Léviathan et la *Spear of Adun* ne sont pas inclus. -De plus, les points de progression tels que les crédits ou la Solarite ne sont pas utilisés dans *StarCraft 2 +De plus, les points de progression, tels que les crédits ou la Solarite, ne sont pas utilisés dans *StarCraft 2 Archipelago*. +Les missions accessibles ont leur nom en bleu, tandis que celles où toutes les *locations* ont été collectées +apparaissent en blanc. +En plaçant votre souris sur une mission, les *locations* non collectées s’affichent, classées par catégorie. +Les missions qui ne sont pas accessibles ont leur nom en gris et leurs prérequis seront également affichés à cet endroit. + ## Quel est le but de ce jeu quand il est *randomized*? Le but est de réussir la mission finale du *mission order* (e.g. *blitz*, *grid*, etc.). -Le fichier de configuration yaml permet de spécifier le *mission order*, lesquelles des quatre campagnes de -*StarCraft 2* peuvent être utilisées pour remplir le *mission order* et comment les missions sont distribuées dans le -*mission order*. +Le fichier de configuration yaml permet de spécifier le *mission order*, quelle combinaison des quatre campagnes de +*StarCraft 2* peuvent être utilisée et comment les missions sont distribuées dans le *mission order*. Étant donné que les deux premières options déterminent le nombre de missions dans un monde de *StarCraft 2*, elles peuvent être utilisées pour moduler le temps nécessaire pour terminer le monde. -Notez que les missions d'évolution de Heart of the Swarm ne sont pas incluses dans le *randomizer*. ## Quelles sont les modifications non aléatoires comparativement à la version de base de *StarCraft 2* @@ -89,9 +103,7 @@ Les fichiers existants vont être écrasés. * `/game_speed [game_speed]` Remplace la vitesse du jeu pour le monde. * Les options sont *default*, *slower*, *slow*, *normal*, *fast*, and *faster*. * `/color [faction] [color]` Remplace la couleur d'une des *factions* qui est jouable. - * Les options de *faction*: raynor, kerrigan, primal, protoss, nova. - * Les options de couleur: *white*, *red*, *blue*, *teal*, *purple*, *yellow*, *orange*, *green*, *lightpink*, -*violet*, *lightgrey*, *darkgreen*, *brown*, *lightgreen*, *darkgrey*, *pink*, *rainbow*, *random*, *default*. + * Si la commande est lancée sans option, la liste des *factions* et des couleurs disponibles sera affichée. * `/option [option_name] [option_value]` Permet de changer un option normalement définit dans le *yaml*. * Si la commande est lancée sans option, la liste des options qui sont modifiables va être affichée. * Les options qui peuvent être changées avec cette commande incluent sauter les cinématiques automatiquement, la @@ -114,6 +126,19 @@ De plus, les améliorations sont regroupées sous leurs unités/bâtiments corre Un paramètre de filtrage peut aussi être fourni, e.g., `/received Thor`, pour limiter le nombre d'*items* affichés. Tous les *items* dont le nom, la race ou le nom de groupe contient le paramètre fourni seront affichés. +## Particularités dans un multiworld + +### *Collect on goal completion* + +L'une des options par défaut des *multiworlds* est qu'une fois qu'un monde a atteint son objectif final, il collecte +tous ses *items*, incluant ceux dans les autres mondes. +Si vous ne souhaitez pas que cela se produise, vous devez demander à la personne générant le *multiworld* de changer +l'option *Collect Permission*. +Si la génération n'est pas effectuée via le site web, la personne qui effectue la génération doit modifier l'option +`collect_mode` dans son fichier *host.yaml* avant la génération. +Si le *multiworld* a déjà été généré, l'hôte peut utiliser la commande `/option collect_mode [valeur]` pour modifier +cette option. + ## Problèmes connus - *StarCraft 2 Archipelago* ne supporte pas le chargement d'une sauvegarde. @@ -123,3 +148,7 @@ normalement à l'aise. Pour redémarrer une mission, utilisez le client de *StarCraft 2 Archipelago*. - Un rapport d'erreur est souvent généré lorsqu'une mission est fermée. Cela n'affecte pas le jeu et peut être ignoré. +- Actuellement, le client de *StarCraft 2* utilise la *location* associée à la victoire d'une mission pour déterminer +si celle-ci a été complétée. +En conséquence, la fonctionnalité *collect* d'*Archipelago* peut rendre accessible des missions connectées à une +mission que vous n'avez pas terminée. \ No newline at end of file diff --git a/worlds/sc2/docs/setup_en.md b/worlds/sc2/docs/setup_en.md index 5b378873f4..4364008b58 100644 --- a/worlds/sc2/docs/setup_en.md +++ b/worlds/sc2/docs/setup_en.md @@ -41,6 +41,7 @@ Remember the name you enter in the options page or in the yaml file, you'll need Check out [Creating a YAML](/tutorial/Archipelago/setup/en#creating-a-yaml) for more game-agnostic information. ### Common yaml questions + #### How do I know I set my yaml up correctly? The simplest way to check is to use the website [validator](/check). diff --git a/worlds/sc2/docs/setup_fr.md b/worlds/sc2/docs/setup_fr.md index d9b754572a..7cdb7225b4 100644 --- a/worlds/sc2/docs/setup_fr.md +++ b/worlds/sc2/docs/setup_fr.md @@ -49,6 +49,7 @@ Si vous désirez des informations et/ou instructions générales sur l'utilisati veuillez consulter [*Creating a YAML*](/tutorial/Archipelago/setup/en#creating-a-yaml). ### Questions récurrentes à propos du fichier *yaml* + #### Comment est-ce que je sais que mon *yaml* est bien défini? La manière la plus simple de valider votre *yaml* est d'utiliser le From 7e32feeea373daca8a4a1045c2afadabe61c7429 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Sat, 15 Mar 2025 07:09:04 -0400 Subject: [PATCH 13/38] Webhost: Update random option wording on webhost (#4555) * Update random option wording on webhost * Update WebHostLib/templates/playerOptions/macros.html Co-authored-by: Jouramie <16137441+Jouramie@users.noreply.github.com> --- WebHostLib/templates/playerOptions/macros.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WebHostLib/templates/playerOptions/macros.html b/WebHostLib/templates/playerOptions/macros.html index 64f0f140de..972f03175d 100644 --- a/WebHostLib/templates/playerOptions/macros.html +++ b/WebHostLib/templates/playerOptions/macros.html @@ -213,7 +213,7 @@ {% endmacro %} {% macro RandomizeButton(option_name, option) %} -
+