diff --git a/worlds/witness/__init__.py b/worlds/witness/__init__.py index 3d80fd2458..66229600a5 100644 --- a/worlds/witness/__init__.py +++ b/worlds/witness/__init__.py @@ -2,10 +2,10 @@ Archipelago init file for The Witness """ import dataclasses -from logging import error, warning +from logging import error, info, warning from typing import Any, Dict, List, Optional, cast -from BaseClasses import CollectionState, Entrance, Location, LocationProgressType, Region, Tutorial +from BaseClasses import CollectionState, Entrance, Item, Location, LocationProgressType, Region, Tutorial from Options import OptionError, PerGameCommonOptions, Toggle from worlds.AutoWorld import WebWorld, World @@ -18,6 +18,7 @@ from .data.utils import cast_not_none, get_audio_logs from .hints import CompactHintData, create_all_hints, make_compact_hint_data, make_laser_hints from .locations import WitnessPlayerLocations from .options import TheWitnessOptions, witness_option_groups +from .place_early_item import place_early_items from .player_items import WitnessItem, WitnessPlayerItems from .player_logic import WitnessPlayerLogic from .presets import witness_option_presets @@ -94,6 +95,7 @@ class WitnessWorld(World): items_placed_early: List[str] own_itempool: List[WitnessItem] + reachable_early_locations: List[str] panel_hunt_required_count: int def _get_slot_data(self) -> Dict[str, Any]: @@ -218,25 +220,6 @@ class WitnessWorld(World): self.own_itempool.append(dog_puzzle_skip) self.items_placed_early.append("Puzzle Skip") - if self.options.early_symbol_item: - # Pick an early item to place on the tutorial gate. - early_items = [ - item for item in self.player_items.get_early_items() if item in self.player_items.get_mandatory_items() - ] - if early_items: - random_early_item = self.random.choice(early_items) - mode = self.options.puzzle_randomization - if mode == "sigma_expert" or mode == "umbra_variety" or self.options.victory_condition == "panel_hunt": - # In Expert and Variety, only tag the item as early, rather than forcing it onto the gate. - # Same with panel hunt, since the Tutorial Gate Open panel is used for something else - self.multiworld.local_early_items[self.player][random_early_item] = 1 - else: - # Force the item onto the tutorial gate check and remove it from our random pool. - gate_item = self.create_item(random_early_item) - self.get_location("Tutorial Gate Open").place_locked_item(gate_item) - self.own_itempool.append(gate_item) - self.items_placed_early.append(random_early_item) - # There are some really restrictive options in The Witness. # They are rarely played, but when they are, we add some extra sphere 1 locations. # This is done both to prevent generation failures, but also to make the early game less linear. @@ -245,20 +228,39 @@ class WitnessWorld(World): state = CollectionState(self.multiworld) state.sweep_for_advancements(locations=event_locations) - num_early_locs = sum( - 1 for loc in self.multiworld.get_reachable_locations(state, self.player) - if loc.address and not loc.item + # Adjust the needed size for sphere 1 based on how restrictive the options are in terms of items + + early_locations = [ + location for location in self.multiworld.get_reachable_locations(state, self.player) + if not location.is_event and not location.item + ] + self.reachable_early_locations = [location.name for location in early_locations] + + num_reachable_tutorial_locations = sum( + static_witness_logic.ALL_REGIONS_BY_NAME[ + cast(Region, location.parent_region).name + ].area.name == "Tutorial (Inside)" + for location in early_locations ) # Adjust the needed size for sphere 1 based on how restrictive the options are in terms of items - needed_size = 2 - needed_size += self.options.puzzle_randomization == "sigma_expert" - needed_size += self.options.shuffle_symbols - needed_size += self.options.shuffle_doors != "off" + needed_size_overall = 2 + needed_size_overall += self.options.puzzle_randomization == "sigma_expert" + needed_size_overall += self.options.shuffle_symbols + needed_size_overall += self.options.shuffle_doors != "off" + + needed_size_to_hold_tutorial_items = len( + self.player_items.get_early_items(set(self.player_items.get_mandatory_items())) + ) # Then, add checks in order until the required amount of sphere 1 checks is met. + extra_tutorial_checks = [ + ("Tutorial First Hallway Room", "Tutorial First Hallway Bend"), + ("Tutorial First Hallway", "Tutorial First Hallway Straight") + ] + extra_checks = [ ("Tutorial First Hallway Room", "Tutorial First Hallway Bend"), ("Tutorial First Hallway", "Tutorial First Hallway Straight"), @@ -266,13 +268,30 @@ class WitnessWorld(World): ("Desert Outside", "Desert Surface 2"), ] - for i in range(num_early_locs, needed_size): + for _ in range(num_reachable_tutorial_locations, needed_size_to_hold_tutorial_items): + if not extra_tutorial_checks: + break + + region, loc = extra_tutorial_checks.pop(0) + extra_checks.pop(0) + self.player_locations.add_location_late(loc) + self.get_region(region).add_locations({loc: self.location_name_to_id[loc]}, WitnessLocation) + self.reachable_early_locations.append(loc) + + player = self.player_name + + info( + f"""Location "{loc}" had to be added to {player}'s world to hold the requested early good items.""" + ) + + for _ in range(len(self.reachable_early_locations), needed_size_overall): if not extra_checks: break region, loc = extra_checks.pop(0) self.player_locations.add_location_late(loc) - self.get_region(region).add_locations({loc: self.location_name_to_id[loc]}) + self.get_region(region).add_locations({loc: self.location_name_to_id[loc]}, WitnessLocation) + self.reachable_early_locations.append(loc) warning( f"""Location "{loc}" had to be added to {self.player_name}'s world @@ -341,6 +360,10 @@ class WitnessWorld(World): self.own_itempool += new_items self.multiworld.itempool += new_items + def fill_hook(self, progitempool: List[Item], _: List[Item], _2: List[Item], + fill_locations: List[Location]) -> None: + place_early_items(self, progitempool, fill_locations) + def fill_slot_data(self) -> Dict[str, Any]: already_hinted_locations = set() diff --git a/worlds/witness/locations.py b/worlds/witness/locations.py index e7f6f94d65..f4416b72ce 100644 --- a/worlds/witness/locations.py +++ b/worlds/witness/locations.py @@ -72,5 +72,5 @@ class WitnessPlayerLocations: def add_location_late(self, entity_name: str) -> None: entity_hex = static_witness_logic.ENTITIES_BY_NAME[entity_name]["entity_hex"] - self.CHECK_LOCATION_TABLE[entity_hex] = static_witness_locations.get_id(entity_hex) + self.CHECK_LOCATION_TABLE[entity_name] = static_witness_locations.get_id(entity_hex) self.CHECK_PANELHEX_TO_ID[entity_hex] = static_witness_locations.get_id(entity_hex) diff --git a/worlds/witness/options.py b/worlds/witness/options.py index 6a64fdb3d8..546f2a5ae2 100644 --- a/worlds/witness/options.py +++ b/worlds/witness/options.py @@ -13,6 +13,7 @@ from Options import ( OptionSet, PerGameCommonOptions, Range, + Removed, Toggle, Visibility, ) @@ -50,12 +51,19 @@ class EarlyCaves(Choice): alias_on = 2 -class EarlySymbolItem(DefaultOnToggle): +class EarlyGoodItems(OptionSet): """ - Put a random helpful symbol item on an early check, specifically Tutorial Gate Open if it is available early. + Put one random helpful item of each of the chosen types on an early check, specifically a sphere 1 Tutorial location. + If a type is chosen, but no items of that type exist in the itempool, it is skipped. + The possible types are: "Symbol", "Door / Door Panel", "Obelisk Key". + + If there aren't enough sphere 1 Tutorial locations, Tutorial First Hallway Straight and Tutorial First Hallway Bend may be added as locations. + If there still aren't enough sphere 1 Tutorial locations, a random local sphere 1 location is picked. + If no local sphere 1 locations are available, there are no further attempts to place the item. """ - visibility = Visibility.none + valid_keys = {"Symbol", "Door / Door Panel", "Obelisk Key"} + default = frozenset({"Symbol"}) class ShuffleSymbols(DefaultOnToggle): @@ -550,7 +558,7 @@ class TheWitnessOptions(PerGameCommonOptions): panel_hunt_discourage_same_area_factor: PanelHuntDiscourageSameAreaFactor panel_hunt_plando: PanelHuntPlando early_caves: EarlyCaves - early_symbol_item: EarlySymbolItem + early_good_items: EarlyGoodItems elevators_come_to_you: ElevatorsComeToYou trap_percentage: TrapPercentage trap_weights: TrapWeights @@ -565,6 +573,8 @@ class TheWitnessOptions(PerGameCommonOptions): shuffle_dog: ShuffleDog easter_egg_hunt: EasterEggHunt + early_symbol_item: Removed + witness_option_groups = [ OptionGroup("Puzzles & Goal", [ @@ -611,6 +621,7 @@ witness_option_groups = [ LaserHints ]), OptionGroup("Misc", [ + EarlyGoodItems, EarlyCaves, ElevatorsComeToYou, DeathLink, diff --git a/worlds/witness/place_early_item.py b/worlds/witness/place_early_item.py new file mode 100644 index 0000000000..0490dbebf1 --- /dev/null +++ b/worlds/witness/place_early_item.py @@ -0,0 +1,151 @@ +from logging import debug, error +from typing import TYPE_CHECKING, Dict, List, Set, Tuple + +from BaseClasses import CollectionState, Item, Location, LocationProgressType + +from .data import static_logic as static_witness_logic +from .data.utils import cast_not_none + +if TYPE_CHECKING: + from . import WitnessWorld + + +def get_available_early_locations(world: "WitnessWorld") -> List[Location]: + # Pick an early item to put on Tutorial Gate Open. + # Done after plando to avoid conflicting with it. + # Done in fill_hook because multiworld itempool manipulation is not allowed in pre_fill. + + # Prioritize Tutorial locations in a specific order + tutorial_checks_in_order = [ + "Tutorial Gate Open", + "Tutorial Back Left", + "Tutorial Back Right", + "Tutorial Front Left", + "Tutorial First Hallway Straight", + "Tutorial First Hallway Bend", + "Tutorial Patio Floor", + "Tutorial First Hallway EP", + "Tutorial Cloud EP", + "Tutorial Patio Flowers EP", + ] + available_locations = [ + world.get_location(location_name) for location_name in tutorial_checks_in_order + if location_name in world.reachable_early_locations # May not actually be sphere 1 (e.g. Obelisk Keys for EPs) + ] + + # Then, add the rest of sphere 1 in "game order" + available_locations += sorted( + ( + world.get_location(location_name) for location_name in world.reachable_early_locations + if location_name not in tutorial_checks_in_order + ), + key=lambda location_object: static_witness_logic.ENTITIES_BY_NAME[location_object.name]["order"] + ) + + return [ + location for location in available_locations + if not location.item and location.progress_type != LocationProgressType.EXCLUDED + ] + + +def get_eligible_items_by_type_in_random_order(world: "WitnessWorld") -> Dict[str, List[str]]: + eligible_early_items_by_type = world.player_items.get_early_items({item.name for item in world.own_itempool}) + + for item_list in eligible_early_items_by_type.values(): + world.random.shuffle(item_list) + + return eligible_early_items_by_type + + +def grab_own_items_from_itempool(world: "WitnessWorld", itempool: List[Item], ids_to_find: Set[int]) -> List[Item]: + found_early_items = [] + + def keep_or_take_out(item: Item) -> bool: + if item.code not in ids_to_find: + return True # Keep + ids_to_find.remove(item.code) + found_early_items.append(item) + return False # Take out + + local_player = world.player + itempool[:] = [item for item in itempool if item.player != local_player or keep_or_take_out(item)] + + return found_early_items + + +def place_items_onto_locations(world: "WitnessWorld", items: List[Item], + locations: List[Location]) -> Tuple[List[Item], List[Item]]: + fake_state = CollectionState(world.multiworld) + + placed_items = [] + unplaced_items = [] + + for item in items: + location = next( + (location for location in locations if location.can_fill(fake_state, item, check_access=False)), + None, + ) + if location is not None: + location.place_locked_item(item) + placed_items.append(item) + locations.remove(location) + else: + unplaced_items.append(item) + + return placed_items, unplaced_items + + +def place_early_items(world: "WitnessWorld", prog_itempool: List[Item], fill_locations: List[Location]) -> None: + if not world.options.early_good_items.value: + return + + # Get a list of good early locations in a determinstic order + eligible_early_locations = get_available_early_locations(world) + # Get a list of good early items of each desired item type + eligible_early_items_by_type = get_eligible_items_by_type_in_random_order(world) + + if not eligible_early_items_by_type: + return + + while any(eligible_early_items_by_type.values()) and eligible_early_locations: + # Get one item of each type + next_findable_items_dict = { + item_list.pop(): item_type + for item_type, item_list in eligible_early_items_by_type.items() + if item_list + } + + # Get their IDs as a set + next_findable_item_ids = {world.item_name_to_id[item_name] for item_name in next_findable_items_dict} + + # Grab items from itempool + found_early_items = grab_own_items_from_itempool(world, prog_itempool, next_findable_item_ids) + + # Bring found items back into Symbol -> Door -> Obelisk Key order + # The intent is that the Symbol is always on Tutorial Gate Open / generally that the order is predictable + correct_order = {item_name: i for i, item_name in enumerate(next_findable_items_dict)} + found_early_items.sort(key=lambda item: correct_order[item.name]) + + # Place found early items on eligible early locations. + placed_items, unplaced_items = place_items_onto_locations(world, found_early_items, eligible_early_locations) + + for item in placed_items: + debug(f"Placed early good item {item} on early location {item.location}.") + # Item type is satisfied + del eligible_early_items_by_type[next_findable_items_dict[item.name]] + fill_locations.remove(cast_not_none(item.location)) + for item in unplaced_items: + debug(f"Could not find a suitable placement for item {item}.") + + unfilled_types = list(eligible_early_items_by_type) + if unfilled_types: + if not eligible_early_locations: + error( + f'Could not find a suitable location for "early good items" of types {unfilled_types} in ' + f"{world.player_name}'s world. They are excluded or already contain plandoed items.\n" + ) + else: + error( + f"Could not find any \"early good item\" of types {unfilled_types} in {world.player_name}'s world, " + "they were all plandoed elsewhere." + ) diff --git a/worlds/witness/player_items.py b/worlds/witness/player_items.py index d13ebcafdc..1be2285304 100644 --- a/worlds/witness/player_items.py +++ b/worlds/witness/player_items.py @@ -198,36 +198,118 @@ class WitnessPlayerItems: return output - def get_early_items(self) -> List[str]: + def get_early_items(self, existing_items: Set[str]) -> Dict[str, List[str]]: """ Returns items that are ideal for placing on extremely early checks, like the tutorial gate. """ - output: Set[str] = set() - if self._world.options.shuffle_symbols: - discards_on = self._world.options.shuffle_discarded_panels - mode = self._world.options.puzzle_randomization.current_key + output: Dict[str, List[str]] = {} - output = static_witness_items.ALWAYS_GOOD_SYMBOL_ITEMS | static_witness_items.MODE_SPECIFIC_GOOD_ITEMS[mode] - if discards_on: - output |= static_witness_items.MODE_SPECIFIC_GOOD_DISCARD_ITEMS[mode] + if "Symbol" in self._world.options.early_good_items.value: + good_symbols = ["Dots", "Black/White Squares", "Symmetry", "Shapers", "Stars"] - # Remove items that are mentioned in any plando options. (Hopefully, in the future, plando will get resolved - # before create_items so that we'll be able to check placed items instead of just removing all items mentioned - # regardless of whether or not they actually wind up being manually placed. - for plando_setting in self._world.options.plando_items: - if plando_setting.from_pool: - if isinstance(plando_setting.items, dict): - output -= {item for item, weight in plando_setting.items.items() if weight} + if self._world.options.shuffle_discarded_panels: + if self._world.options.puzzle_randomization == "sigma_expert": + good_symbols.append("Arrows") else: - # Assume this is some other kind of iterable. - for inner_item in plando_setting.items: - if isinstance(inner_item, str): - output -= {inner_item} - elif isinstance(inner_item, dict): - output -= {item for item, weight in inner_item.items() if weight} + good_symbols.append("Triangles") - # Sort the output for consistency across versions if the implementation changes but the logic does not. - return sorted(output) + # Replace progressive items with their parents. + good_symbols = [ + static_witness_logic.get_parent_progressive_item(item) for item in good_symbols + ] + + output["Symbol"] = [symbol for symbol in good_symbols if symbol in existing_items] + + if "Door / Door Panel" in self._world.options.early_good_items.value: + good_doors = [ + "Desert Doors & Elevator", "Keep Hedge Maze Doors", "Keep Pressure Plates Doors", + "Shadows Lower Doors", "Tunnels Doors", "Quarry Stoneworks Doors", + + "Keep Tower Shortcut (Door)", "Shadows Timed Door", "Tunnels Town Shortcut (Door)", + "Quarry Stoneworks Roof Exit (Door)", + + "Desert Panels", "Keep Hedge Maze Panels", + + "Shadows Door Timer (Panel)", + ] + + # While Caves Shortcuts don't unlock anything in symbol shuffle, you'd still rather have them early. + # But, we need to do some special handling for them. + + if self._world.options.shuffle_doors in ("doors", "mixed"): + # Caves Shortcuts might exist in vanilla/panel doors because of "early caves: add to pool". + # But if the player wanted them early, they would have chosen "early caves: starting inventory". + # This is why we make sure these are "natural" Caves Shortcuts. + good_doors.append("Caves Shortcuts") + + # These two doors are logically equivalent, so we choose a random one as to not give them twice the chance. + good_doors.append( + self._world.random.choice(["Caves Mountain Shortcut (Door)", "Caves Swamp Shortcut (Door)"]) + ) + + if self._world.options.shuffle_vault_boxes and not self._world.options.disable_non_randomized_puzzles: + good_doors.append("Windmill & Theater Doors") + if not self._world.options.shuffle_symbols: + good_doors += [ + "Windmill & Theater Panels", + + "Windmill & Theater Control Panels", + ] + + if self._world.options.shuffle_doors == "doors": # It's not as good in mixed doors because of Light Control + good_doors.append("Desert Light Room Entry (Door)") + + if not self._world.options.shuffle_symbols: + good_doors += [ + "Bunker Doors", "Swamp Doors", "Glass Factory Doors", "Town Doors", + + "Bunker Entry (Door)", "Glass Factory Entry (Door)", "Symmetry Island Lower (Door)", + + "Bunker Panels", "Swamp Panels", "Quarry Outside Panels", "Glass Factory Panels" + + "Glass Factory Entry (Panel)", + ] + + existing_doors = [door for door in good_doors if door in existing_items] + + # On some options combinations with doors, there just aren't a lot of doors that unlock much early. + # In this case, we add some doors that aren't great, but are at least guaranteed to unlock 1 location. + + fallback_doors = [ + "Keep Shadows Shortcut (Door)", # Always Keep Shadows Shortcut Panel + "Keep Shortcuts", # -"- + "Keep Pressure Plates Doors", # -"- + "Keep Hedge Maze 1 (Panel)", # Always Hedge 1 + "Town Maze Stairs (Panel)", # Always Maze Panel + "Shadows Laser Room Doors", # Always Shadows Laser Panel + "Swamp Laser Shortcut (Door)", # Always Swamp Laser + "Town Maze Panels", # Always Town Maze Panel + "Town Doors", # Always Town Church Lattice + "Town Church Entry (Door)", # -"- + "Town Tower Doors", # Always Town Laser + ] + self._world.random.shuffle(fallback_doors) + + while len(existing_doors) < 4 and fallback_doors: + fallback_door = fallback_doors.pop() + if fallback_door in existing_items and fallback_door not in existing_doors: + existing_doors.append(fallback_door) + + output["Door"] = existing_doors + + if "Obelisk Key" in self._world.options.early_good_items.value: + obelisk_keys = [ + "Desert Obelisk Key", "Town Obelisk Key", "Quarry Obelisk Key", + "Treehouse Obelisk Key", "Monastery Obelisk Key", "Mountainside Obelisk Key" + ] + output["Obelisk Key"] = [key for key in obelisk_keys if key in existing_items] + + assert all(item in self._world.item_names for sublist in output.values() for item in sublist), ( + [item for sublist in output.values() for item in sublist if item not in self._world.item_names] + ) + + # Cull empty lists + return {item_type: item_list for item_type, item_list in output.items() if item_list} def get_door_item_ids_in_pool(self) -> List[int]: """ diff --git a/worlds/witness/presets.py b/worlds/witness/presets.py index 81dd28d68d..934f55b685 100644 --- a/worlds/witness/presets.py +++ b/worlds/witness/presets.py @@ -35,7 +35,7 @@ witness_option_presets: Dict[str, Dict[str, Any]] = { "challenge_lasers": 11, "early_caves": EarlyCaves.option_off, - + "early_good_items": {"Door / Door Panel"}, "elevators_come_to_you": ElevatorsComeToYou.default, "trap_percentage": TrapPercentage.default, @@ -76,7 +76,7 @@ witness_option_presets: Dict[str, Dict[str, Any]] = { "challenge_lasers": 9, "early_caves": EarlyCaves.option_off, - + "early_good_items": {"Symbol", "Door / Door Panel"}, # Not Obelisk Key bc I want EPs to open slowly in this one "elevators_come_to_you": ElevatorsComeToYou.default, "trap_percentage": TrapPercentage.default, @@ -117,7 +117,7 @@ witness_option_presets: Dict[str, Dict[str, Any]] = { "challenge_lasers": 9, "early_caves": EarlyCaves.option_off, - + "early_good_items": {"Symbol", "Door / Door Panel", "Obelisk Key"}, "elevators_come_to_you": ElevatorsComeToYou.valid_keys, "trap_percentage": TrapPercentage.default, diff --git a/worlds/witness/test/test_disable_non_randomized.py b/worlds/witness/test/test_disable_non_randomized.py index 00071ec5f6..e999fb452d 100644 --- a/worlds/witness/test/test_disable_non_randomized.py +++ b/worlds/witness/test/test_disable_non_randomized.py @@ -8,7 +8,7 @@ class TestDisableNonRandomized(WitnessTestBase): options = { "disable_non_randomized_puzzles": True, "shuffle_doors": "panels", - "early_symbol_item": False, + "early_good_items": {}, } def test_locations_got_disabled_and_alternate_activation_triggers_work(self) -> None: diff --git a/worlds/witness/test/test_early_good_item.py b/worlds/witness/test/test_early_good_item.py new file mode 100644 index 0000000000..1641b0f1b0 --- /dev/null +++ b/worlds/witness/test/test_early_good_item.py @@ -0,0 +1,109 @@ +from BaseClasses import ItemClassification, LocationProgressType +from Fill import distribute_items_restrictive + +from ..data.utils import cast_not_none +from ..test.bases import WitnessTestBase + + +class TestEarlySymbolItemFalse(WitnessTestBase): + options = { + "early_good_items": {}, + + "shuffle_symbols": True, + "shuffle_doors": "off", + "shuffle_boat": False, + "shuffle_lasers": False, + "obelisk_keys": False, + } + + def setUp(self) -> None: + if self.auto_construct: + self.world_setup(seed=1) # Magic seed to prevent false positive + + def test_early_good_item(self) -> None: + distribute_items_restrictive(self.multiworld) + + gate_open = self.multiworld.get_location("Tutorial Gate Open", 1) + + self.assertFalse( + cast_not_none(gate_open.item).classification & ItemClassification.progression, + "Early Good Item was off, yet a Symbol item ended up on Tutorial Gate Open.", + ) + + +class TestEarlySymbolItemTrue(WitnessTestBase): + options = { + "early_good_items": {"Symbol", "Door / Door Panel", "Obelisk Key"}, + + "shuffle_symbols": True, + "shuffle_doors": "panels", + "shuffle_EPs": "individual", + "obelisk_keys": True, + } + + def setUp(self) -> None: + super().setUp() + + def test_early_good_item(self) -> None: + """ + The items should be in the order: + Symbol item on Tutorial Gate Open + Door item on Tutorial Back Left + Obelisk Key on Tutorial Back Right + """ + + distribute_items_restrictive(self.multiworld) + + gate_open = self.multiworld.get_location("Tutorial Gate Open", 1) + + self.assertTrue(gate_open.item is not None, "Somehow, no item got placed on Tutorial Gate Open.") + + self.assertTrue( + cast_not_none(gate_open.item).name in self.world.item_name_groups["Symbols"], + "Early Good Item was on, yet no Symbol item ended up on Tutorial Gate Open.", + ) + + back_left = self.multiworld.get_location("Tutorial Back Left", 1) + + self.assertTrue(back_left.item is not None, "Somehow, no item got placed on Tutorial Back Left.") + + doors_and_panel_keys = self.world.item_name_groups["Doors"] | self.world.item_name_groups["Panel Keys"] + self.assertTrue( + cast_not_none(back_left.item).name in doors_and_panel_keys, + "Early Good Item was on, yet no Door item ended up on Tutorial Back Left.", + ) + + back_right = self.multiworld.get_location("Tutorial Back Right", 1) + + self.assertTrue(back_right.item is not None, "Somehow, no item got placed on Tutorial Back Right.") + + self.assertTrue( + cast_not_none(back_right.item).name in self.world.item_name_groups["Obelisk Keys"], + "Early Good Item was on, yet no Obelisk Key item ended up on Tutorial Back Right.", + ) + + +class TestEarlySymbolItemTrueButExcluded(WitnessTestBase): + options = { + "shuffle_symbols": True, + "shuffle_doors": "off", + "shuffle_boat": False, + "shuffle_lasers": False, + "obelisk_keys": False, + } + + def setUp(self) -> None: + super().setUp() + self.multiworld.get_location("Tutorial Gate Open", 1).progress_type = LocationProgressType.EXCLUDED + + def test_early_good_item(self) -> None: + distribute_items_restrictive(self.multiworld) + + gate_open = self.multiworld.get_location("Tutorial Gate Open", 1) + + self.assertTrue(gate_open.item is not None, "Somehow, no item got placed on Tutorial Gate Open.") + + self.assertFalse( + cast_not_none(gate_open.item).classification & ItemClassification.progression, + "Tutorial Gate Open was excluded, yet it still received an early Symbol item.", + ) diff --git a/worlds/witness/test/test_lasers.py b/worlds/witness/test/test_lasers.py index 4a71c0d433..be71979458 100644 --- a/worlds/witness/test/test_lasers.py +++ b/worlds/witness/test/test_lasers.py @@ -7,7 +7,7 @@ class TestSymbolsRequiredToWinElevatorNormal(WitnessTestBase): "puzzle_randomization": "sigma_normal", "mountain_lasers": 1, "victory_condition": "elevator", - "early_symbol_item": False, + "early_good_items": {}, } def test_symbols_to_win(self) -> None: @@ -37,7 +37,7 @@ class TestSymbolsRequiredToWinElevatorExpert(WitnessTestBase): "shuffle_lasers": True, "mountain_lasers": 1, "victory_condition": "elevator", - "early_symbol_item": False, + "early_good_items": {}, "puzzle_randomization": "sigma_expert", } @@ -70,7 +70,7 @@ class TestSymbolsRequiredToWinElevatorVanilla(WitnessTestBase): "shuffle_lasers": True, "mountain_lasers": 1, "victory_condition": "elevator", - "early_symbol_item": False, + "early_good_items": {}, "puzzle_randomization": "none", } @@ -101,7 +101,6 @@ class TestSymbolsRequiredToWinElevatorVariety(WitnessTestBase): "shuffle_lasers": True, "mountain_lasers": 1, "victory_condition": "elevator", - "early_symbol_item": False, "puzzle_randomization": "umbra_variety", } @@ -134,7 +133,7 @@ class TestPanelsRequiredToWinElevator(WitnessTestBase): "shuffle_lasers": True, "mountain_lasers": 1, "victory_condition": "elevator", - "early_symbol_item": False, + "early_good_items": {}, "shuffle_symbols": False, "shuffle_doors": "panels", "door_groupings": "off", @@ -163,7 +162,7 @@ class TestDoorsRequiredToWinElevator(WitnessTestBase): "shuffle_lasers": True, "mountain_lasers": 1, "victory_condition": "elevator", - "early_symbol_item": False, + "early_good_items": {}, "shuffle_symbols": False, "shuffle_doors": "doors", "door_groupings": "off", diff --git a/worlds/witness/test/test_symbol_shuffle.py b/worlds/witness/test/test_symbol_shuffle.py index fb1d820815..836b0e327f 100644 --- a/worlds/witness/test/test_symbol_shuffle.py +++ b/worlds/witness/test/test_symbol_shuffle.py @@ -3,7 +3,7 @@ from ..test.bases import WitnessMultiworldTestBase, WitnessTestBase class TestSymbols(WitnessTestBase): options = { - "early_symbol_item": False, + "early_good_items": {}, } def test_progressive_symbols(self) -> None: @@ -53,7 +53,7 @@ class TestSymbolRequirementsMultiworld(WitnessMultiworldTestBase): common_options = { "shuffle_discarded_panels": True, - "early_symbol_item": False, + "early_good_items": {}, } def test_arrows_exist_and_are_required_in_expert_seeds_only(self) -> None: