diff --git a/worlds/witness/__init__.py b/worlds/witness/__init__.py index 254064098db9..b228842019cf 100644 --- a/worlds/witness/__init__.py +++ b/worlds/witness/__init__.py @@ -15,7 +15,7 @@ from .data import static_logic as static_witness_logic from .data.item_definition_classes import DoorItemDefinition, ItemData from .data.utils import get_audio_logs -from .hints import CompactItemData, create_all_hints, make_compact_hint_data, make_laser_hints +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 .player_items import WitnessItem, WitnessPlayerItems @@ -68,12 +68,14 @@ class WitnessWorld(World): player_items: WitnessPlayerItems player_regions: WitnessPlayerRegions - log_ids_to_hints: Dict[int, CompactItemData] - laser_ids_to_hints: Dict[int, CompactItemData] + log_ids_to_hints: Dict[int, CompactHintData] + laser_ids_to_hints: Dict[int, CompactHintData] items_placed_early: List[str] own_itempool: List[WitnessItem] + panel_hunt_required_count: int + def _get_slot_data(self) -> Dict[str, Any]: return { "seed": self.random.randrange(0, 1000000), @@ -83,12 +85,14 @@ def _get_slot_data(self) -> Dict[str, Any]: "door_hexes_in_the_pool": self.player_items.get_door_ids_in_pool(), "symbols_not_in_the_game": self.player_items.get_symbol_ids_not_in_pool(), "disabled_entities": [int(h, 16) for h in self.player_logic.COMPLETELY_DISABLED_ENTITIES], + "hunt_entities": [int(h, 16) for h in self.player_logic.HUNT_ENTITIES], "log_ids_to_hints": self.log_ids_to_hints, "laser_ids_to_hints": self.laser_ids_to_hints, "progressive_item_lists": self.player_items.get_progressive_item_ids_in_pool(), "obelisk_side_id_to_EPs": static_witness_logic.OBELISK_SIDE_ID_TO_EP_HEXES, "precompleted_puzzles": [int(h, 16) for h in self.player_logic.EXCLUDED_LOCATIONS], "entity_to_name": static_witness_logic.ENTITY_ID_TO_NAME, + "panel_hunt_required_absolute": self.panel_hunt_required_count } def determine_sufficient_progression(self) -> None: @@ -151,6 +155,13 @@ def generate_early(self) -> None: if self.options.shuffle_lasers == "local": self.options.local_items.value |= self.item_name_groups["Lasers"] + if self.options.victory_condition == "panel_hunt": + total_panels = self.options.panel_hunt_total + required_percentage = self.options.panel_hunt_required_percentage + self.panel_hunt_required_count = round(total_panels * required_percentage / 100) + else: + self.panel_hunt_required_count = 0 + def create_regions(self) -> None: self.player_regions.create_regions(self, self.player_logic) @@ -169,7 +180,7 @@ def create_regions(self) -> None: for event_location in self.player_locations.EVENT_LOCATION_TABLE: item_obj = self.create_item( - self.player_logic.EVENT_ITEM_PAIRS[event_location] + self.player_logic.EVENT_ITEM_PAIRS[event_location][0] ) location_obj = self.get_location(event_location) location_obj.place_locked_item(item_obj) @@ -192,7 +203,7 @@ def create_regions(self) -> None: ] if early_items: random_early_item = self.random.choice(early_items) - if self.options.puzzle_randomization == "sigma_expert": + if self.options.puzzle_randomization == "sigma_expert" or self.options.victory_condition == "panel_hunt": # In Expert, only tag the item as early, rather than forcing it onto the gate. self.multiworld.local_early_items[self.player][random_early_item] = 1 else: @@ -305,8 +316,8 @@ def create_items(self) -> None: self.options.local_items.value.add(item_name) def fill_slot_data(self) -> Dict[str, Any]: - self.log_ids_to_hints: Dict[int, CompactItemData] = {} - self.laser_ids_to_hints: Dict[int, CompactItemData] = {} + self.log_ids_to_hints: Dict[int, CompactHintData] = {} + self.laser_ids_to_hints: Dict[int, CompactHintData] = {} already_hinted_locations = set() diff --git a/worlds/witness/data/settings/Entity_Hunt.txt b/worlds/witness/data/settings/Entity_Hunt.txt new file mode 100644 index 000000000000..4135dbd842f7 --- /dev/null +++ b/worlds/witness/data/settings/Entity_Hunt.txt @@ -0,0 +1,6 @@ +Requirement Changes: +0x03629 - Entity Hunt - True +0x03505 - 0x03629 - True + +New Connections: +Tutorial - Outside Tutorial - True diff --git a/worlds/witness/data/static_locations.py b/worlds/witness/data/static_locations.py index de321d20c0f9..d9566080a04c 100644 --- a/worlds/witness/data/static_locations.py +++ b/worlds/witness/data/static_locations.py @@ -406,6 +406,10 @@ "Mountain Bottom Floor Discard", } +GENERAL_LOCATION_HEXES = { + static_witness_logic.ENTITIES_BY_NAME[entity_name]["entity_hex"] for entity_name in GENERAL_LOCATIONS +} + OBELISK_SIDES = { "Desert Obelisk Side 1", "Desert Obelisk Side 2", diff --git a/worlds/witness/data/static_logic.py b/worlds/witness/data/static_logic.py index a9175c0c30b3..b61b0f9d2f92 100644 --- a/worlds/witness/data/static_logic.py +++ b/worlds/witness/data/static_logic.py @@ -103,6 +103,7 @@ def read_logic_file(self, lines: List[str]) -> None: "region": None, "id": None, "entityType": location_id, + "locationType": None, "area": current_area, } @@ -127,19 +128,30 @@ def read_logic_file(self, lines: List[str]) -> None: "Laser Hedges", "Laser Pressure Plates", } - is_vault_or_video = "Vault" in entity_name or "Video" in entity_name if "Discard" in entity_name: + entity_type = "Panel" location_type = "Discard" - elif is_vault_or_video or entity_name == "Tutorial Gate Close": + elif "Vault" in entity_name: + entity_type = "Panel" location_type = "Vault" elif entity_name in laser_names: - location_type = "Laser" + entity_type = "Laser" + location_type = None elif "Obelisk Side" in entity_name: + entity_type = "Obelisk Side" location_type = "Obelisk Side" + elif "Obelisk" in entity_name: + entity_type = "Obelisk" + location_type = None elif "EP" in entity_name: + entity_type = "EP" location_type = "EP" + elif entity_hex.startswith("0xFF"): + entity_type = "Event" + location_type = None else: + entity_type = "Panel" location_type = "General" required_items = parse_lambda(required_item_lambda) @@ -152,7 +164,7 @@ def read_logic_file(self, lines: List[str]) -> None: "items": required_items } - if location_type == "Obelisk Side": + if entity_type == "Obelisk Side": eps = set(next(iter(required_panels))) eps -= {"Theater to Tunnels"} @@ -167,7 +179,8 @@ def read_logic_file(self, lines: List[str]) -> None: "entity_hex": entity_hex, "region": current_region, "id": int(location_id), - "entityType": location_type, + "entityType": entity_type, + "locationType": location_type, "area": current_area, } diff --git a/worlds/witness/data/utils.py b/worlds/witness/data/utils.py index f89aaf7d3e18..11f905b18a56 100644 --- a/worlds/witness/data/utils.py +++ b/worlds/witness/data/utils.py @@ -203,6 +203,10 @@ def get_elevators_come_to_you() -> List[str]: return get_adjustment_file("settings/Door_Shuffle/Elevators_Come_To_You.txt") +def get_entity_hunt() -> List[str]: + return get_adjustment_file("settings/Entity_Hunt.txt") + + def get_sigma_normal_logic() -> List[str]: return get_adjustment_file("WitnessLogic.txt") diff --git a/worlds/witness/entity_hunt.py b/worlds/witness/entity_hunt.py new file mode 100644 index 000000000000..29b914799fdb --- /dev/null +++ b/worlds/witness/entity_hunt.py @@ -0,0 +1,234 @@ +from collections import defaultdict +from logging import debug +from pprint import pformat +from typing import TYPE_CHECKING, Dict, List, Set, Tuple + +from .data import static_logic as static_witness_logic + +if TYPE_CHECKING: + from . import WitnessWorld + from .player_logic import WitnessPlayerLogic + +DISALLOWED_ENTITIES_FOR_PANEL_HUNT = { + "0x03629", # Tutorial Gate Open, which is the panel that is locked by panel hunt + "0x03505", # Tutorial Gate Close (same thing) + "0x3352F", # Gate EP (same thing) + "0x09F7F", # Mountaintop Box Short. This is reserved for panel_hunt_postgame. + "0x00CDB", # Challenge Reallocating + "0x0051F", # Challenge Reallocating + "0x00524", # Challenge Reallocating + "0x00CD4", # Challenge Reallocating + "0x00CB9", # Challenge May Be Unsolvable + "0x00CA1", # Challenge May Be Unsolvable + "0x00C80", # Challenge May Be Unsolvable + "0x00C68", # Challenge May Be Unsolvable + "0x00C59", # Challenge May Be Unsolvable + "0x00C22", # Challenge May Be Unsolvable + "0x0A3A8", # Reset PP + "0x0A3B9", # Reset PP + "0x0A3BB", # Reset PP + "0x0A3AD", # Reset PP +} + +ALL_HUNTABLE_PANELS = [ + entity_hex + for entity_hex, entity_obj in static_witness_logic.ENTITIES_BY_HEX.items() + if entity_obj["entityType"] == "Panel" and entity_hex not in DISALLOWED_ENTITIES_FOR_PANEL_HUNT +] + + +class EntityHuntPicker: + def __init__(self, player_logic: "WitnessPlayerLogic", world: "WitnessWorld", + pre_picked_entities: Set[str]) -> None: + self.player_logic = player_logic + self.player_options = world.options + self.player_name = world.player_name + self.random = world.random + + self.PRE_PICKED_HUNT_ENTITIES = pre_picked_entities.copy() + self.HUNT_ENTITIES: Set[str] = set() + + self.ALL_ELIGIBLE_ENTITIES, self.ELIGIBLE_ENTITIES_PER_AREA = self._get_eligible_panels() + + def pick_panel_hunt_panels(self, total_amount: int) -> Set[str]: + """ + The process of picking all hunt entities is: + + 1. Add pre-defined hunt entities + 2. Pick random hunt entities to fill out the rest + 3. Replace unfair entities with fair entities + + Each of these is its own function. + """ + + self.HUNT_ENTITIES = self.PRE_PICKED_HUNT_ENTITIES.copy() + + self._pick_all_hunt_entities(total_amount) + self._replace_unfair_hunt_entities_with_good_hunt_entities() + self._log_results() + + return self.HUNT_ENTITIES + + def _entity_is_eligible(self, panel_hex: str) -> bool: + """ + Determine whether an entity is eligible for entity hunt based on player options. + """ + panel_obj = static_witness_logic.ENTITIES_BY_HEX[panel_hex] + + return ( + self.player_logic.solvability_guaranteed(panel_hex) + and not ( + # Due to an edge case, Discards have to be on in disable_non_randomized even if Discard Shuffle is off. + # However, I don't think they should be hunt panels in this case. + self.player_options.disable_non_randomized_puzzles + and not self.player_options.shuffle_discarded_panels + and panel_obj["locationType"] == "Discard" + ) + ) + + def _get_eligible_panels(self) -> Tuple[List[str], Dict[str, Set[str]]]: + """ + There are some entities that are not allowed for panel hunt for various technical of gameplay reasons. + Make a list of all the ones that *are* eligible, plus a lookup of eligible panels per area. + """ + + all_eligible_panels = [ + panel for panel in ALL_HUNTABLE_PANELS + if self._entity_is_eligible(panel) + ] + + 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"] + eligible_panels_by_area[associated_area].add(eligible_panel) + + return all_eligible_panels, eligible_panels_by_area + + def _get_percentage_of_hunt_entities_by_area(self) -> Dict[str, float]: + hunt_entities_picked_so_far_prevent_div_0 = max(len(self.HUNT_ENTITIES), 1) + + contributing_percentage_per_area = {} + for area, eligible_entities in self.ELIGIBLE_ENTITIES_PER_AREA.items(): + amount_of_already_chosen_entities = len(self.ELIGIBLE_ENTITIES_PER_AREA[area] & self.HUNT_ENTITIES) + current_percentage = amount_of_already_chosen_entities / hunt_entities_picked_so_far_prevent_div_0 + contributing_percentage_per_area[area] = current_percentage + + return contributing_percentage_per_area + + def _get_next_random_batch(self, amount: int, same_area_discouragement: float) -> List[str]: + """ + Pick the next batch of hunt entities. + Areas that already have a lot of hunt entities in them will be discouraged from getting more. + The strength of this effect is controlled by the same_area_discouragement factor from the player's options. + """ + + percentage_of_hunt_entities_by_area = self._get_percentage_of_hunt_entities_by_area() + + max_percentage = max(percentage_of_hunt_entities_by_area.values()) + if max_percentage == 0: + allowance_per_area = {area: 1.0 for area in percentage_of_hunt_entities_by_area} + else: + allowance_per_area = { + area: (max_percentage - current_percentage) / max_percentage + for area, current_percentage in percentage_of_hunt_entities_by_area.items() + } + # use same_area_discouragement as lerp factor + allowance_per_area = { + area: (1.0 - same_area_discouragement) + (weight * same_area_discouragement) + for area, weight in allowance_per_area.items() + } + + assert min(allowance_per_area.values()) >= 0, ( + f"Somehow, an area had a negative weight when picking hunt entities: {allowance_per_area}" + ) + + remaining_entities, remaining_entity_weights = [], [] + for area, eligible_entities in self.ELIGIBLE_ENTITIES_PER_AREA.items(): + for panel in eligible_entities - self.HUNT_ENTITIES: + remaining_entities.append(panel) + remaining_entity_weights.append(allowance_per_area[area]) + + # I don't think this can ever happen, but let's be safe + if sum(remaining_entity_weights) == 0: + remaining_entity_weights = [1] * len(remaining_entity_weights) + + return self.random.choices(remaining_entities, weights=remaining_entity_weights, k=amount) + + def _pick_all_hunt_entities(self, total_amount: int) -> None: + """ + The core function of the EntityHuntPicker in which all Hunt Entities are picked, + respecting the player's choices for total amount and same area discouragement. + """ + same_area_discouragement = self.player_options.panel_hunt_discourage_same_area_factor / 100 + + # If we're using random picking, just choose all the entities now and return + if not same_area_discouragement: + hunt_entities = self.random.sample( + [entity for entity in self.ALL_ELIGIBLE_ENTITIES if entity not in self.HUNT_ENTITIES], + k=total_amount - len(self.HUNT_ENTITIES), + ) + self.HUNT_ENTITIES.update(hunt_entities) + return + + # If we're discouraging entities from the same area being picked, we have to pick entities one at a time + # For higher total counts, we do them in small batches for performance + batch_size = max(1, total_amount // 20) + + while len(self.HUNT_ENTITIES) < total_amount: + actual_amount_to_pick = min(batch_size, total_amount - len(self.HUNT_ENTITIES)) + + self.HUNT_ENTITIES.update(self._get_next_random_batch(actual_amount_to_pick, same_area_discouragement)) + + def _replace_unfair_hunt_entities_with_good_hunt_entities(self) -> None: + """ + For connected entities that "solve together", make sure that the one you're guaranteed + to be able to see and interact with first is the one that is chosen, so you don't get "surprise entities". + """ + + replacements = { + "0x18488": "0x00609", # Replace Swamp Sliding Bridge Underwater -> Swamp Sliding Bridge Above Water + "0x03676": "0x03678", # Replace Quarry Upper Ramp Control -> Lower Ramp Control + "0x03675": "0x03679", # Replace Quarry Upper Lift Control -> Lower Lift Control + + "0x03702": "0x15ADD", # Jungle Vault Box -> Jungle Vault Panel + "0x03542": "0x002A6", # Mountainside Vault Box -> Mountainside Vault Panel + "0x03481": "0x033D4", # Tutorial Vault Box -> Tutorial Vault Panel + "0x0339E": "0x0CC7B", # Desert Vault Box -> Desert Vault Panel + "0x03535": "0x00AFB", # Shipwreck Vault Box -> Shipwreck Vault Panel + } + + if self.player_options.shuffle_doors < 2: + replacements.update( + { + "0x334DC": "0x334DB", # In door shuffle, the Shadows Timer Panels are disconnected + "0x17CBC": "0x2700B", # In door shuffle, the Laser Timer Panels are disconnected + } + ) + + for bad_entitiy, good_entity in replacements.items(): + # If the bad entity was picked as a hunt entity ... + if bad_entitiy not in self.HUNT_ENTITIES: + continue + + # ... and the good entity was not ... + if good_entity in self.HUNT_ENTITIES or good_entity not in self.ALL_ELIGIBLE_ENTITIES: + continue + + # ... replace the bad entity with the good entity. + self.HUNT_ENTITIES.remove(bad_entitiy) + self.HUNT_ENTITIES.add(good_entity) + + def _log_results(self) -> None: + final_percentage_by_area = self._get_percentage_of_hunt_entities_by_area() + + sorted_area_percentages_dict = dict(sorted(final_percentage_by_area.items(), key=lambda x: x[1])) + sorted_area_percentages_dict_pretty_print = { + area: str(percentage) + (" (maxed)" if self.ELIGIBLE_ENTITIES_PER_AREA[area] <= self.HUNT_ENTITIES else "") + for area, percentage in sorted_area_percentages_dict.items() + } + player_name = self.player_name + discouragemenet_factor = self.player_options.panel_hunt_discourage_same_area_factor + debug( + f'Final area percentages for player "{player_name}" ({discouragemenet_factor} discouragement):\n' + f"{pformat(sorted_area_percentages_dict_pretty_print)}" + ) diff --git a/worlds/witness/generate_data_file.py b/worlds/witness/generate_data_file.py new file mode 100644 index 000000000000..50a63a374619 --- /dev/null +++ b/worlds/witness/generate_data_file.py @@ -0,0 +1,45 @@ +from collections import defaultdict + +from data import static_logic as static_witness_logic + +if __name__ == "__main__": + with open("data/APWitnessData.h", "w") as datafile: + datafile.write("""# pragma once + +# include +# include +# include + +""") + + area_to_location_ids = defaultdict(list) + area_to_entity_ids = defaultdict(list) + + for entity_id, entity_object in static_witness_logic.ENTITIES_BY_HEX.items(): + location_id = entity_object["id"] + + area = entity_object["area"]["name"] + area_to_entity_ids[area].append(entity_id) + + if location_id is None: + continue + + area_to_location_ids[area].append(str(location_id)) + + datafile.write("inline std::map> areaNameToLocationIDs = {\n") + datafile.write( + "\n".join( + '\t{"' + area + '", { ' + ", ".join(location_ids) + " }}," + for area, location_ids in area_to_location_ids.items() + ) + ) + datafile.write("\n};\n\n") + + datafile.write("inline std::map> areaNameToEntityIDs = {\n") + datafile.write( + "\n".join( + '\t{"' + area + '", { ' + ", ".join(entity_ids) + " }}," + for area, entity_ids in area_to_entity_ids.items() + ) + ) + datafile.write("\n};\n\n") diff --git a/worlds/witness/hints.py b/worlds/witness/hints.py index a1ca1b081d3c..248c567b97ce 100644 --- a/worlds/witness/hints.py +++ b/worlds/witness/hints.py @@ -11,7 +11,8 @@ if TYPE_CHECKING: from . import WitnessWorld -CompactItemData = Tuple[str, Union[str, int], int] +CompactHintArgs = Tuple[Union[str, int], int] +CompactHintData = Tuple[str, Union[str, int], int] @dataclass @@ -35,6 +36,7 @@ class WitnessWordedHint: location: Optional[Location] = None area: Optional[str] = None area_amount: Optional[int] = None + area_hunt_panels: Optional[int] = None def get_always_hint_items(world: "WitnessWorld") -> List[str]: @@ -391,22 +393,22 @@ def get_hintable_areas(world: "WitnessWorld") -> Tuple[Dict[str, List[Location]] return locations_per_area, items_per_area -def word_area_hint(world: "WitnessWorld", hinted_area: str, corresponding_items: List[Item]) -> Tuple[str, int]: +def word_area_hint(world: "WitnessWorld", hinted_area: str, area_items: List[Item]) -> Tuple[str, int, Optional[int]]: """ Word the hint for an area using natural sounding language. This takes into account how much progression there is, how much of it is local/non-local, and whether there are any local lasers to be found in this area. """ - local_progression = sum(item.player == world.player and item.advancement for item in corresponding_items) - non_local_progression = sum(item.player != world.player and item.advancement for item in corresponding_items) + local_progression = sum(item.player == world.player and item.advancement for item in area_items) + non_local_progression = sum(item.player != world.player and item.advancement for item in area_items) laser_names = {"Symmetry Laser", "Desert Laser", "Quarry Laser", "Shadows Laser", "Town Laser", "Monastery Laser", "Jungle Laser", "Bunker Laser", "Swamp Laser", "Treehouse Laser", "Keep Laser", } local_lasers = sum( item.player == world.player and item.name in laser_names - for item in corresponding_items + for item in area_items ) total_progression = non_local_progression + local_progression @@ -415,11 +417,29 @@ def word_area_hint(world: "WitnessWorld", hinted_area: str, corresponding_items: area_progression_word = "Both" if total_progression == 2 else "All" + hint_string = f"In the {hinted_area} area, you will find " + + hunt_panels = None + if world.options.victory_condition == "panel_hunt": + hunt_panels = sum( + static_witness_logic.ENTITIES_BY_HEX[hunt_entity]["area"]["name"] == hinted_area + for hunt_entity in world.player_logic.HUNT_ENTITIES + ) + + if not hunt_panels: + hint_string += "no Hunt Panels and " + + elif hunt_panels == 1: + hint_string += "1 Hunt Panel and " + + else: + hint_string += f"{hunt_panels} Hunt Panels and " + if not total_progression: - hint_string = f"In the {hinted_area} area, you will find no progression items." + hint_string += "no progression items." elif total_progression == 1: - hint_string = f"In the {hinted_area} area, you will find 1 progression item." + hint_string += "1 progression item." if player_count > 1: if local_lasers: @@ -434,7 +454,7 @@ def word_area_hint(world: "WitnessWorld", hinted_area: str, corresponding_items: hint_string += "\nThis item is a laser." else: - hint_string = f"In the {hinted_area} area, you will find {total_progression} progression items." + hint_string += f"{total_progression} progression items." if local_lasers == total_progression: sentence_end = (" for this world." if player_count > 1 else ".") @@ -471,7 +491,7 @@ def word_area_hint(world: "WitnessWorld", hinted_area: str, corresponding_items: elif local_lasers: hint_string += f"\n{local_lasers} of them are lasers." - return hint_string, total_progression + return hint_string, total_progression, hunt_panels def make_area_hints(world: "WitnessWorld", amount: int, already_hinted_locations: Set[Location] @@ -483,9 +503,9 @@ def make_area_hints(world: "WitnessWorld", amount: int, already_hinted_locations hints = [] for hinted_area in hinted_areas: - hint_string, prog_amount = word_area_hint(world, hinted_area, items_per_area[hinted_area]) + hint_string, prog_amount, hunt_panels = word_area_hint(world, hinted_area, items_per_area[hinted_area]) - hints.append(WitnessWordedHint(hint_string, None, f"hinted_area:{hinted_area}", prog_amount)) + hints.append(WitnessWordedHint(hint_string, None, f"hinted_area:{hinted_area}", prog_amount, hunt_panels)) if len(hinted_areas) < amount: player_name = world.multiworld.get_player_name(world.player) @@ -585,29 +605,42 @@ def create_all_hints(world: "WitnessWorld", hint_amount: int, area_hints: int, return generated_hints -def make_compact_hint_data(hint: WitnessWordedHint, local_player_number: int) -> CompactItemData: +def get_compact_hint_args(hint: WitnessWordedHint, local_player_number: int) -> CompactHintArgs: + """ + Arg reference: + + Area Hint: 1st Arg is the amount of area progression and hunt panels. 2nd Arg is the name of the area. + Location Hint: 1st Arg is the location's address, second arg is the player number the location belongs to. + Junk Hint: 1st Arg is -1, second arg is this slot's player number. + """ + + # Is Area Hint + if hint.area is not None: + assert hint.area_amount is not None, "Area hint had an undefined progression amount." + + area_amount = hint.area_amount + hunt_panels = hint.area_hunt_panels + + area_and_hunt_panels = area_amount + # Encode amounts together + if hunt_panels: + area_and_hunt_panels += 0x100 * hunt_panels + + return hint.area, area_and_hunt_panels + location = hint.location - area_amount = hint.area_amount - # -1 if junk hint, address if location hint, area string if area hint - arg_1: Union[str, int] + # Is location hint if location and location.address is not None: - arg_1 = location.address - elif hint.area is not None: - arg_1 = hint.area - else: - arg_1 = -1 - - # self.player if junk hint, player if location hint, progression amount if area hint - arg_2: int - if area_amount is not None: - arg_2 = area_amount - elif location is not None: - arg_2 = location.player - else: - arg_2 = local_player_number + return location.address, location.player + + # Is junk / undefined hint + return -1, local_player_number + - return hint.wording, arg_1, arg_2 +def make_compact_hint_data(hint: WitnessWordedHint, local_player_number: int) -> CompactHintData: + compact_arg_1, compact_arg_2 = get_compact_hint_args(hint, local_player_number) + return hint.wording, compact_arg_1, compact_arg_2 def make_laser_hints(world: "WitnessWorld", laser_names: List[str]) -> Dict[str, WitnessWordedHint]: diff --git a/worlds/witness/locations.py b/worlds/witness/locations.py index 1796f051b896..f1c16550399a 100644 --- a/worlds/witness/locations.py +++ b/worlds/witness/locations.py @@ -50,7 +50,7 @@ def __init__(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic) -> N self.CHECK_PANELHEX_TO_ID = { static_witness_logic.ENTITIES_BY_NAME[ch]["entity_hex"]: static_witness_locations.ALL_LOCATIONS_TO_ID[ch] for ch in self.CHECK_LOCATIONS - if static_witness_logic.ENTITIES_BY_NAME[ch]["entityType"] in self.PANEL_TYPES_TO_SHUFFLE + if static_witness_logic.ENTITIES_BY_NAME[ch]["locationType"] in self.PANEL_TYPES_TO_SHUFFLE } dog_hex = static_witness_logic.ENTITIES_BY_NAME["Town Pet the Dog"]["entity_hex"] @@ -61,11 +61,9 @@ def __init__(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic) -> N sorted(self.CHECK_PANELHEX_TO_ID.items(), key=lambda item: item[1]) ) - event_locations = set(player_logic.USED_EVENT_NAMES_BY_HEX) - self.EVENT_LOCATION_TABLE = { - static_witness_locations.get_event_name(entity_hex): None - for entity_hex in event_locations + event_location: None + for event_location in player_logic.EVENT_ITEM_PAIRS } check_dict = { diff --git a/worlds/witness/options.py b/worlds/witness/options.py index 4855fc715933..bdeccfe3b2db 100644 --- a/worlds/witness/options.py +++ b/worlds/witness/options.py @@ -173,6 +173,7 @@ class VictoryCondition(Choice): - Challenge: Beat the secret Challenge (requires Challenge Lasers). - Mountain Box Short: Input the short solution to the Mountaintop Box (requires Mountain Lasers). - Mountain Box Long: Input the long solution to the Mountaintop Box (requires Challenge Lasers). + - Panel Hunt: Solve a specific number of randomly selected panels before going to the secret ending in Tutorial. It is important to note that while the Mountain Box requires Desert Laser to be redirected in Town for that laser to count, the laser locks on the Elevator and Challenge Timer panels do not. @@ -182,6 +183,62 @@ class VictoryCondition(Choice): option_challenge = 1 option_mountain_box_short = 2 option_mountain_box_long = 3 + option_panel_hunt = 4 + + +class PanelHuntTotal(Range): + """ + Sets the number of random panels that will get marked as "Panel Hunt" panels in the "Panel Hunt" game mode. + """ + display_name = "Total Panel Hunt panels" + range_start = 5 + range_end = 100 + default = 40 + + +class PanelHuntRequiredPercentage(Range): + """ + Determines the percentage of "Panel Hunt" panels that need to be solved to win. + """ + display_name = "Percentage of required Panel Hunt panels" + range_start = 20 + range_end = 100 + default = 63 + + +class PanelHuntPostgame(Choice): + """ + In panel hunt, there are technically no postgame locations. + Depending on your options, this can leave Mountain and Caves as two huge areas with Hunt Panels in them that cannot be reached until you get enough lasers to go through the very linear Mountain descent. + Panel Hunt tends to be more fun when the world is open. + This option lets you force anything locked by lasers to be disabled, and thus ineligible for Hunt Panels. + To compensate, the respective mountain box solution (short box / long box) will be forced to be a Hunt Panel. + Does nothing if Panel Hunt is not your victory condition. + + Note: The "Mountain Lasers" option may also affect locations locked by challenge lasers if the only path to those locations leads through the Mountain Entry. + """ + + display_name = "Force postgame in Panel Hunt" + + option_everything_is_eligible = 0 + option_disable_mountain_lasers_locations = 1 + option_disable_challenge_lasers_locations = 2 + option_disable_anything_locked_by_lasers = 3 + default = 3 + + +class PanelHuntDiscourageSameAreaFactor(Range): + """ + The greater this value, the less likely it is that many Hunt Panels show up in the same area. + + At 0, Hunt Panels will be selected randomly. + At 100, Hunt Panels will be almost completely evenly distributed between areas. + """ + display_name = "Panel Hunt Discourage Same Area Factor" + + range_start = 0 + range_end = 100 + default = 40 class PuzzleRandomization(Choice): @@ -332,6 +389,10 @@ class TheWitnessOptions(PerGameCommonOptions): victory_condition: VictoryCondition mountain_lasers: MountainLasers challenge_lasers: ChallengeLasers + panel_hunt_total: PanelHuntTotal + panel_hunt_required_percentage: PanelHuntRequiredPercentage + panel_hunt_postgame: PanelHuntPostgame + panel_hunt_discourage_same_area_factor: PanelHuntDiscourageSameAreaFactor early_caves: EarlyCaves early_symbol_item: EarlySymbolItem elevators_come_to_you: ElevatorsComeToYou @@ -352,6 +413,12 @@ class TheWitnessOptions(PerGameCommonOptions): MountainLasers, ChallengeLasers, ]), + OptionGroup("Panel Hunt Settings", [ + PanelHuntRequiredPercentage, + PanelHuntTotal, + PanelHuntPostgame, + PanelHuntDiscourageSameAreaFactor, + ], start_collapsed=True), OptionGroup("Locations", [ ShuffleDiscardedPanels, ShuffleVaultBoxes, diff --git a/worlds/witness/player_items.py b/worlds/witness/player_items.py index 718fd7d172ba..44a959f2b428 100644 --- a/worlds/witness/player_items.py +++ b/worlds/witness/player_items.py @@ -97,7 +97,7 @@ def __init__(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic, # Add event items to the item definition list for later lookup. for event_location in self._locations.EVENT_LOCATION_TABLE: - location_name = player_logic.EVENT_ITEM_PAIRS[event_location] + location_name = player_logic.EVENT_ITEM_PAIRS[event_location][0] self.item_data[location_name] = ItemData(None, ItemDefinition(0, ItemCategory.EVENT), ItemClassification.progression, False) diff --git a/worlds/witness/player_logic.py b/worlds/witness/player_logic.py index e8d11f43f51c..5125dfef0aa1 100644 --- a/worlds/witness/player_logic.py +++ b/worlds/witness/player_logic.py @@ -17,7 +17,6 @@ import copy from collections import defaultdict -from logging import warning from typing import TYPE_CHECKING, Dict, List, Set, Tuple, cast from .data import static_logic as static_witness_logic @@ -36,6 +35,7 @@ get_early_caves_list, get_early_caves_start_list, get_elevators_come_to_you, + get_entity_hunt, get_ep_all_individual, get_ep_easy, get_ep_no_eclipse, @@ -51,6 +51,7 @@ logical_or_witness_rules, parse_lambda, ) +from .entity_hunt import EntityHuntPicker if TYPE_CHECKING: from . import WitnessWorld @@ -60,7 +61,7 @@ class WitnessPlayerLogic: """WITNESS LOGIC CLASS""" VICTORY_LOCATION: str - + def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_inv: Dict[str, int]) -> None: self.YAML_DISABLED_LOCATIONS: Set[str] = disabled_locations self.YAML_ADDED_ITEMS: Dict[str, int] = start_inv @@ -104,7 +105,7 @@ def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_in ) self.REQUIREMENTS_BY_HEX: Dict[str, WitnessRule] = {} - self.EVENT_ITEM_PAIRS: Dict[str, str] = {} + self.EVENT_ITEM_PAIRS: Dict[str, Tuple[str, str]] = {} self.COMPLETELY_DISABLED_ENTITIES: Set[str] = set() self.DISABLE_EVERYTHING_BEHIND: Set[str] = set() self.PRECOMPLETED_LOCATIONS: Set[str] = set() @@ -112,6 +113,9 @@ def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_in self.ADDED_CHECKS: Set[str] = set() self.VICTORY_LOCATION = "0x0356B" + self.PRE_PICKED_HUNT_ENTITIES: Set[str] = set() + self.HUNT_ENTITIES: Set[str] = set() + self.ALWAYS_EVENT_NAMES_BY_HEX = { "0x00509": "+1 Laser (Symmetry Laser)", "0x012FB": "+1 Laser (Desert Laser)", @@ -129,7 +133,7 @@ def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_in "0xFFF00": "Bottom Floor Discard Turns On", } - self.USED_EVENT_NAMES_BY_HEX: Dict[str, str] = {} + self.USED_EVENT_NAMES_BY_HEX: Dict[str, List[str]] = {} self.CONDITIONAL_EVENTS: Dict[Tuple[str, str], str] = {} # The basic requirements to solve each entity come from StaticWitnessLogic. @@ -142,6 +146,10 @@ def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_in # This will make the access conditions way faster, instead of recursively checking dependent entities each time. self.make_dependency_reduced_checklist() + if world.options.victory_condition == "panel_hunt": + picker = EntityHuntPicker(self, world, self.PRE_PICKED_HUNT_ENTITIES) + self.HUNT_ENTITIES = picker.pick_panel_hunt_panels(world.options.panel_hunt_total.value) + # Finalize which items actually exist in the MultiWorld and which get grouped into progressive items. self.finalize_items() @@ -226,7 +234,7 @@ def reduce_req_within_region(self, entity_hex: str) -> WitnessRule: dep_obj = self.REFERENCE_LOGIC.ENTITIES_BY_HEX.get(option_entity, {}) if option_entity in {"7 Lasers", "11 Lasers", "7 Lasers + Redirect", "11 Lasers + Redirect", - "PP2 Weirdness", "Theater to Tunnels"}: + "PP2 Weirdness", "Theater to Tunnels", "Entity Hunt"}: new_items = frozenset({frozenset([option_entity])}) elif option_entity in self.DISABLE_EVERYTHING_BEHIND: new_items = frozenset() @@ -241,12 +249,12 @@ def reduce_req_within_region(self, entity_hex: str) -> WitnessRule: # If the dependent entity is unsolvable and is NOT an EP, this requirement option is invalid. new_items = frozenset() elif option_entity in self.ALWAYS_EVENT_NAMES_BY_HEX: - new_items = frozenset({frozenset([option_entity])}) + new_items = frozenset({frozenset([self.ALWAYS_EVENT_NAMES_BY_HEX[option_entity]])}) elif (entity_hex, option_entity) in self.CONDITIONAL_EVENTS: - new_items = frozenset({frozenset([option_entity])}) - self.USED_EVENT_NAMES_BY_HEX[option_entity] = self.CONDITIONAL_EVENTS[ - (entity_hex, option_entity) - ] + new_items = frozenset({frozenset([self.CONDITIONAL_EVENTS[(entity_hex, option_entity)]])}) + self.USED_EVENT_NAMES_BY_HEX[option_entity].append( + self.CONDITIONAL_EVENTS[(entity_hex, option_entity)] + ) else: new_items = theoretical_new_items if dep_obj["region"] and entity_obj["region"] != dep_obj["region"]: @@ -404,7 +412,7 @@ def make_single_adjustment(self, adj_type: str, line: str) -> None: line = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[line]["checkName"] self.ADDED_CHECKS.add(line) - def handle_postgame(self, world: "WitnessWorld") -> List[List[str]]: + def handle_regular_postgame(self, world: "WitnessWorld") -> List[List[str]]: """ In shuffle_postgame, panels that become accessible "after or at the same time as the goal" are disabled. This mostly involves the disabling of key panels (e.g. long box when the goal is short box). @@ -435,6 +443,7 @@ def handle_postgame(self, world: "WitnessWorld") -> List[List[str]]: # If we have a long box goal, Challenge is behind the amount of lasers required to just win. # This is technically slightly incorrect as the Challenge Vault Box could contain a *symbol* that is required # to open Mountain Entry (Stars 2). However, since there is a very easy sphere 1 snipe, this is not considered. + if victory == "mountain_box_long": postgame_adjustments.append(["Disabled Locations:", "0x0A332 (Challenge Timer Start)"]) @@ -479,6 +488,42 @@ def handle_postgame(self, world: "WitnessWorld") -> List[List[str]]: return postgame_adjustments + def handle_panelhunt_postgame(self, world: "WitnessWorld") -> List[List[str]]: + postgame_adjustments = [] + + # Make some quick references to some options + panel_hunt_postgame = world.options.panel_hunt_postgame + chal_lasers = world.options.challenge_lasers + + disable_mountain_lasers = ( + panel_hunt_postgame == "disable_mountain_lasers_locations" + or panel_hunt_postgame == "disable_anything_locked_by_lasers" + ) + + disable_challenge_lasers = ( + panel_hunt_postgame == "disable_challenge_lasers_locations" + or panel_hunt_postgame == "disable_anything_locked_by_lasers" + ) + + if disable_mountain_lasers: + self.DISABLE_EVERYTHING_BEHIND.add("0x09F7F") # Short box + self.PRE_PICKED_HUNT_ENTITIES.add("0x09F7F") + self.COMPLETELY_DISABLED_ENTITIES.add("0x3D9A9") # Elevator Start + + # If mountain lasers are disabled, and challenge lasers > 7, the box will need to be rotated + if chal_lasers > 7: + postgame_adjustments.append([ + "Requirement Changes:", + "0xFFF00 - 11 Lasers - True", + ]) + + if disable_challenge_lasers: + self.DISABLE_EVERYTHING_BEHIND.add("0xFFF00") # Long box + self.PRE_PICKED_HUNT_ENTITIES.add("0xFFF00") + self.COMPLETELY_DISABLED_ENTITIES.add("0x0A332") # Challenge Timer + + return postgame_adjustments + def make_options_adjustments(self, world: "WitnessWorld") -> None: """Makes logic adjustments based on options""" adjustment_linesets_in_order = [] @@ -500,10 +545,17 @@ def make_options_adjustments(self, world: "WitnessWorld") -> None: self.VICTORY_LOCATION = "0x09F7F" elif victory == "mountain_box_long": self.VICTORY_LOCATION = "0xFFF00" + elif victory == "panel_hunt": + self.VICTORY_LOCATION = "0x03629" + self.COMPLETELY_DISABLED_ENTITIES.add("0x3352F") # Exclude panels from the post-game if shuffle_postgame is false. - if not world.options.shuffle_postgame: - adjustment_linesets_in_order += self.handle_postgame(world) + if not world.options.shuffle_postgame and victory != "panel_hunt": + adjustment_linesets_in_order += self.handle_regular_postgame(world) + + # Exclude panels from the post-game if shuffle_postgame is false. + if victory == "panel_hunt" and world.options.panel_hunt_postgame: + adjustment_linesets_in_order += self.handle_panelhunt_postgame(world) # Exclude Discards / Vaults if not world.options.shuffle_discarded_panels: @@ -570,6 +622,9 @@ def make_options_adjustments(self, world: "WitnessWorld") -> None: if world.options.elevators_come_to_you: adjustment_linesets_in_order.append(get_elevators_come_to_you()) + if world.options.victory_condition == "panel_hunt": + adjustment_linesets_in_order.append(get_entity_hunt()) + for item in self.YAML_ADDED_ITEMS: adjustment_linesets_in_order.append(["Items:", item]) @@ -603,7 +658,7 @@ def make_options_adjustments(self, world: "WitnessWorld") -> None: if loc_obj["entityType"] == "EP": self.COMPLETELY_DISABLED_ENTITIES.add(loc_obj["entity_hex"]) - elif loc_obj["entityType"] in {"General", "Vault", "Discard"}: + elif loc_obj["entityType"] == "Panel": self.EXCLUDED_LOCATIONS.add(loc_obj["entity_hex"]) for adjustment_lineset in adjustment_linesets_in_order: @@ -686,6 +741,7 @@ def find_unsolvable_entities(self, world: "WitnessWorld") -> None: # Check if any regions have become unreachable. reachable_regions = self.discover_reachable_regions() new_unreachable_regions = all_regions - reachable_regions - self.UNREACHABLE_REGIONS + if new_unreachable_regions: self.UNREACHABLE_REGIONS.update(new_unreachable_regions) @@ -741,9 +797,12 @@ def reduce_connection_requirement(self, connection: Tuple[str, WitnessRule]) -> if not self.solvability_guaranteed(entity) or entity in self.DISABLE_EVERYTHING_BEHIND: individual_entity_requirements.append(frozenset()) # If a connection requires acquiring an event, add that event to its requirements. - elif (entity in self.ALWAYS_EVENT_NAMES_BY_HEX - or entity not in self.REFERENCE_LOGIC.ENTITIES_BY_HEX): + elif entity not in self.REFERENCE_LOGIC.ENTITIES_BY_HEX: individual_entity_requirements.append(frozenset({frozenset({entity})})) + elif entity in self.ALWAYS_EVENT_NAMES_BY_HEX: + individual_entity_requirements.append( + frozenset({frozenset({self.ALWAYS_EVENT_NAMES_BY_HEX[entity]})}) + ) # If a connection requires entities, use their newly calculated independent requirements. else: entity_req = self.get_entity_requirement(entity) @@ -778,7 +837,7 @@ def make_dependency_reduced_checklist(self) -> None: # We also clear any data structures that we might have filled in a previous dependency reduction self.REQUIREMENTS_BY_HEX = {} - self.USED_EVENT_NAMES_BY_HEX = {} + self.USED_EVENT_NAMES_BY_HEX = defaultdict(list) self.CONNECTIONS_BY_REGION_NAME = {} self.PROG_ITEMS_ACTUALLY_IN_THE_GAME_NO_MULTI = set() @@ -868,7 +927,6 @@ def determine_unrequired_entities(self, world: "WitnessWorld") -> None: "0x17CC4": come_to_you or eps_shuffled, # Quarry Elevator Panel "0x17E2B": come_to_you and boat_shuffled or eps_shuffled, # Swamp Long Bridge "0x0CF2A": False, # Jungle Monastery Garden Shortcut - "0x17CAA": remote_doors, # Jungle Monastery Garden Shortcut Panel "0x0364E": False, # Monastery Laser Shortcut Door "0x03713": remote_doors, # Monastery Laser Shortcut Panel "0x03313": False, # Orchard Second Gate @@ -884,23 +942,17 @@ def determine_unrequired_entities(self, world: "WitnessWorld") -> None: # Jungle Popup Wall Panel } + # In panel hunt, all panels are game, so all panels need to be reachable (unless disabled) + if goal == "panel_hunt": + for entity_hex in is_item_required_dict: + if static_witness_logic.ENTITIES_BY_HEX[entity_hex]["entityType"] == "Panel": + is_item_required_dict[entity_hex] = True + # Now, return the keys of the dict entries where the result is False to get unrequired major items self.ENTITIES_WITHOUT_ENSURED_SOLVABILITY |= { item_name for item_name, is_required in is_item_required_dict.items() if not is_required } - def make_event_item_pair(self, entity_hex: str) -> Tuple[str, str]: - """ - Makes a pair of an event panel and its event item - """ - action = " Opened" if self.REFERENCE_LOGIC.ENTITIES_BY_HEX[entity_hex]["entityType"] == "Door" else " Solved" - - name = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[entity_hex]["checkName"] + action - if entity_hex not in self.USED_EVENT_NAMES_BY_HEX: - warning(f'Entity "{name}" does not have an associated event name.') - self.USED_EVENT_NAMES_BY_HEX[entity_hex] = name + " Event" - return (name, self.USED_EVENT_NAMES_BY_HEX[entity_hex]) - def make_event_panel_lists(self) -> None: """ Makes event-item pairs for entities with associated events, unless these entities are disabled. @@ -908,13 +960,36 @@ def make_event_panel_lists(self) -> None: self.ALWAYS_EVENT_NAMES_BY_HEX[self.VICTORY_LOCATION] = "Victory" - self.USED_EVENT_NAMES_BY_HEX.update(self.ALWAYS_EVENT_NAMES_BY_HEX) + for event_hex, event_name in self.ALWAYS_EVENT_NAMES_BY_HEX.items(): + self.USED_EVENT_NAMES_BY_HEX[event_hex].append(event_name) self.USED_EVENT_NAMES_BY_HEX = { - event_hex: event_name for event_hex, event_name in self.USED_EVENT_NAMES_BY_HEX.items() + event_hex: event_list for event_hex, event_list in self.USED_EVENT_NAMES_BY_HEX.items() if self.solvability_guaranteed(event_hex) } - for panel in self.USED_EVENT_NAMES_BY_HEX: - pair = self.make_event_item_pair(panel) - self.EVENT_ITEM_PAIRS[pair[0]] = pair[1] + for entity_hex, event_names in self.USED_EVENT_NAMES_BY_HEX.items(): + entity_obj = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[entity_hex] + entity_name = entity_obj["checkName"] + entity_type = entity_obj["entityType"] + + if entity_type == "Door": + action = " Opened" + elif entity_type == "Laser": + action = " Activated" + else: + action = " Solved" + + for i, event_name in enumerate(event_names): + if i == 0: + self.EVENT_ITEM_PAIRS[entity_name + action] = (event_name, entity_hex) + else: + self.EVENT_ITEM_PAIRS[entity_name + action + f" (Effect {i + 1})"] = (event_name, entity_hex) + + # Make Panel Hunt Events + for entity_hex in self.HUNT_ENTITIES: + entity_obj = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[entity_hex] + entity_name = entity_obj["checkName"] + self.EVENT_ITEM_PAIRS[entity_name + " (Panel Hunt)"] = ("+1 Panel Hunt", entity_hex) + + return diff --git a/worlds/witness/regions.py b/worlds/witness/regions.py index 2528c8abe22b..6d1f8093af85 100644 --- a/worlds/witness/regions.py +++ b/worlds/witness/regions.py @@ -9,7 +9,6 @@ from worlds.generic.Rules import CollectionRule -from .data import static_locations as static_witness_locations from .data import static_logic as static_witness_logic from .data.static_logic import StaticWitnessLogicObj from .data.utils import WitnessRule, optimize_witness_rule @@ -111,16 +110,24 @@ def create_regions(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic if k not in player_logic.UNREACHABLE_REGIONS } + event_locations_per_region = defaultdict(list) + + for event_location, event_item_and_entity in player_logic.EVENT_ITEM_PAIRS.items(): + region = static_witness_logic.ENTITIES_BY_HEX[event_item_and_entity[1]]["region"] + if region is None: + region_name = "Entry" + else: + region_name = region["name"] + event_locations_per_region[region_name].append(event_location) + for region_name, region in regions_to_create.items(): locations_for_this_region = [ self.reference_logic.ENTITIES_BY_HEX[panel]["checkName"] for panel in region["entities"] if self.reference_logic.ENTITIES_BY_HEX[panel]["checkName"] in self.player_locations.CHECK_LOCATION_TABLE ] - locations_for_this_region += [ - static_witness_locations.get_event_name(panel) for panel in region["entities"] - if static_witness_locations.get_event_name(panel) in self.player_locations.EVENT_LOCATION_TABLE - ] + + locations_for_this_region += event_locations_per_region[region_name] all_locations = all_locations | set(locations_for_this_region) diff --git a/worlds/witness/rules.py b/worlds/witness/rules.py index fc4e638e36c3..eecea8f30bf0 100644 --- a/worlds/witness/rules.py +++ b/worlds/witness/rules.py @@ -10,7 +10,6 @@ from .data import static_logic as static_witness_logic from .data.utils import WitnessRule -from .locations import WitnessPlayerLocations from .player_logic import WitnessPlayerLogic if TYPE_CHECKING: @@ -31,42 +30,37 @@ ] -def _has_laser(laser_hex: str, world: "WitnessWorld", player: int, redirect_required: bool) -> CollectionRule: +def _can_do_panel_hunt(world: "WitnessWorld") -> CollectionRule: + required = world.panel_hunt_required_count + player = world.player + return lambda state: state.has("+1 Panel Hunt", player, required) + + +def _has_laser(laser_hex: str, world: "WitnessWorld", redirect_required: bool) -> CollectionRule: + player = world.player + laser_name = static_witness_logic.ENTITIES_BY_HEX[laser_hex]["checkName"] + + # Workaround for intentional naming inconsistency + if laser_name == "Symmetry Island Laser": + laser_name = "Symmetry Laser" + if laser_hex == "0x012FB" and redirect_required: - return lambda state: ( - _can_solve_panel(laser_hex, world, world.player, world.player_logic, world.player_locations)(state) - and state.has("Desert Laser Redirection", player) - ) + return lambda state: state.has_all([f"+1 Laser ({laser_name})", "Desert Laser Redirection"], player) - return _can_solve_panel(laser_hex, world, world.player, world.player_logic, world.player_locations) + return lambda state: state.has(f"+1 Laser ({laser_name})", player) def _has_lasers(amount: int, world: "WitnessWorld", redirect_required: bool) -> CollectionRule: laser_lambdas = [] for laser_hex in laser_hexes: - has_laser_lambda = _has_laser(laser_hex, world, world.player, redirect_required) + has_laser_lambda = _has_laser(laser_hex, world, redirect_required) laser_lambdas.append(has_laser_lambda) return lambda state: sum(laser_lambda(state) for laser_lambda in laser_lambdas) >= amount -def _can_solve_panel(panel: str, world: "WitnessWorld", player: int, player_logic: WitnessPlayerLogic, - player_locations: WitnessPlayerLocations) -> CollectionRule: - """ - Determines whether a panel can be solved - """ - - panel_obj = player_logic.REFERENCE_LOGIC.ENTITIES_BY_HEX[panel] - entity_name = panel_obj["checkName"] - - if entity_name + " Solved" in player_locations.EVENT_LOCATION_TABLE: - return lambda state: state.has(player_logic.EVENT_ITEM_PAIRS[entity_name + " Solved"], player) - - return make_lambda(panel, world) - - def _can_do_expert_pp2(state: CollectionState, world: "WitnessWorld") -> bool: """ For Expert PP2, you need a way to access PP2 from the front, and a separate way from the back. @@ -202,8 +196,9 @@ def _can_do_theater_to_tunnels(state: CollectionState, world: "WitnessWorld") -> ) -def _has_item(item: str, world: "WitnessWorld", player: int, - player_logic: WitnessPlayerLogic, player_locations: WitnessPlayerLocations) -> CollectionRule: +def _has_item(item: str, world: "WitnessWorld", player: int, player_logic: WitnessPlayerLogic) -> CollectionRule: + assert item not in static_witness_logic.ENTITIES_BY_HEX, "Requirements can no longer contain entity hexes directly." + if item in player_logic.REFERENCE_LOGIC.ALL_REGIONS_BY_NAME: region = world.get_region(item) return region.can_reach @@ -219,12 +214,13 @@ def _has_item(item: str, world: "WitnessWorld", player: int, if item == "11 Lasers + Redirect": laser_req = world.options.challenge_lasers.value return _has_lasers(laser_req, world, True) + if item == "Entity Hunt": + # Right now, panel hunt is the only type of entity hunt. This may need to be changed later + return _can_do_panel_hunt(world) if item == "PP2 Weirdness": return lambda state: _can_do_expert_pp2(state, world) if item == "Theater to Tunnels": return lambda state: _can_do_theater_to_tunnels(state, world) - if item in player_logic.USED_EVENT_NAMES_BY_HEX: - return _can_solve_panel(item, world, player, player_logic, player_locations) prog_item = static_witness_logic.get_parent_progressive_item(item) return lambda state: state.has(prog_item, player, player_logic.MULTI_AMOUNTS[item]) @@ -237,7 +233,7 @@ def _meets_item_requirements(requirements: WitnessRule, world: "WitnessWorld") - """ lambda_conversion = [ - [_has_item(item, world, world.player, world.player_logic, world.player_locations) for item in subset] + [_has_item(item, world, world.player, world.player_logic) for item in subset] for subset in requirements ] @@ -265,7 +261,8 @@ def set_rules(world: "WitnessWorld") -> None: real_location = location if location in world.player_locations.EVENT_LOCATION_TABLE: - real_location = location[:-7] + entity_hex = world.player_logic.EVENT_ITEM_PAIRS[location][1] + real_location = static_witness_logic.ENTITIES_BY_HEX[entity_hex]["checkName"] associated_entity = world.player_logic.REFERENCE_LOGIC.ENTITIES_BY_NAME[real_location] entity_hex = associated_entity["entity_hex"] diff --git a/worlds/witness/test/__init__.py b/worlds/witness/test/__init__.py index 0a24467feab2..d1b90ca47d9e 100644 --- a/worlds/witness/test/__init__.py +++ b/worlds/witness/test/__init__.py @@ -159,3 +159,36 @@ def get_items_by_name(self, item_names: Union[str, Iterable[str]], player: int) if isinstance(item_names, str): item_names = (item_names,) return [item for item in self.multiworld.itempool if item.name in item_names and item.player == player] + + def assert_location_exists(self, location_name: str, player: int, strict_check: bool = True) -> None: + """ + Assert that a location exists in this world. + If strict_check, also make sure that this (non-event) location COULD exist. + """ + + world = self.multiworld.worlds[player] + + if strict_check: + self.assertIn(location_name, world.location_name_to_id, f"Location {location_name} can never exist") + + try: + world.get_location(location_name) + except KeyError: + self.fail(f"Location {location_name} does not exist.") + + def assert_location_does_not_exist(self, location_name: str, player: int, strict_check: bool = True) -> None: + """ + Assert that a location exists in this world. + If strict_check, be explicit about whether the location could exist in the first place. + """ + + world = self.multiworld.worlds[player] + + if strict_check: + self.assertIn(location_name, world.location_name_to_id, f"Location {location_name} can never exist") + + self.assertRaises( + KeyError, + lambda _: world.get_location(location_name), + f"Location {location_name} exists, but is not supposed to.", + ) diff --git a/worlds/witness/test/test_panel_hunt.py b/worlds/witness/test/test_panel_hunt.py new file mode 100644 index 000000000000..7b405f29ec1d --- /dev/null +++ b/worlds/witness/test/test_panel_hunt.py @@ -0,0 +1,107 @@ +from BaseClasses import CollectionState, Item +from worlds.witness.test import WitnessTestBase, WitnessMultiworldTestBase + + +class TestMaxPanelHuntMinChecks(WitnessTestBase): + options = { + "victory_condition": "panel_hunt", + "panel_hunt_total": 100, + "panel_hunt_required_percentage": 100, + "panel_hunt_postgame": "disable_anything_locked_by_lasers", + "disable_non_randomized_puzzles": True, + "shuffle_discarded_panels": False, + "shuffle_vault_boxes": False, + } + + def test_correct_panels_were_picked(self): + with self.subTest("Check that 100 Hunt Panels were actually picked."): + self.assertEqual(len(self.multiworld.find_item_locations("+1 Panel Hunt", self.player)), 100) + + with self.subTest("Check that 100 Hunt Panels are enough"): + state_100 = CollectionState(self.multiworld) + panel_hunt_item = self.get_item_by_name("+1 Panel Hunt") + + for _ in range(100): + state_100.collect(panel_hunt_item, True) + state_100.sweep_for_events(False, [self.world.get_location("Tutorial Gate Open Solved")]) + + self.assertTrue(self.multiworld.completion_condition[self.player](state_100)) + + with self.subTest("Check that 99 Hunt Panels are not enough"): + state_99 = CollectionState(self.multiworld) + panel_hunt_item = self.get_item_by_name("+1 Panel Hunt") + + for _ in range(99): + state_99.collect(panel_hunt_item, True) + state_99.sweep_for_events(False, [self.world.get_location("Tutorial Gate Open Solved")]) + + self.assertFalse(self.multiworld.completion_condition[self.player](state_99)) + + +class TestPanelHuntPostgame(WitnessMultiworldTestBase): + options_per_world = [ + { + "panel_hunt_postgame": "everything_is_eligible" + }, + { + "panel_hunt_postgame": "disable_mountain_lasers_locations" + }, + { + "panel_hunt_postgame": "disable_challenge_lasers_locations" + }, + { + "panel_hunt_postgame": "disable_anything_locked_by_lasers" + }, + ] + + common_options = { + "victory_condition": "panel_hunt", + "panel_hunt_total": 40, + + # Make sure we can check for Short vs Long Lasers locations by making Mountain Bottom Floor Discard accessible. + "shuffle_doors": "doors", + "shuffle_discarded_panels": True, + } + + def test_panel_hunt_postgame(self): + for player_minus_one, options in enumerate(self.options_per_world): + player = player_minus_one + 1 + postgame_option = options["panel_hunt_postgame"] + with self.subTest(f"Test that \"{postgame_option}\" results in 40 Hunt Panels."): + self.assertEqual(len(self.multiworld.find_item_locations("+1 Panel Hunt", player)), 40) + + # Test that the box gets extra checks from panel_hunt_postgame + + with self.subTest("Test that \"everything_is_eligible\" has no Mountaintop Box Hunt Panels."): + self.assert_location_does_not_exist("Mountaintop Box Short (Panel Hunt)", 1, strict_check=False) + self.assert_location_does_not_exist("Mountaintop Box Long (Panel Hunt)", 1, strict_check=False) + + with self.subTest("Test that \"disable_mountain_lasers_locations\" has a Hunt Panel for Short, but not Long."): + self.assert_location_exists("Mountaintop Box Short (Panel Hunt)", 2, strict_check=False) + self.assert_location_does_not_exist("Mountaintop Box Long (Panel Hunt)", 2, strict_check=False) + + with self.subTest("Test that \"disable_challenge_lasers_locations\" has a Hunt Panel for Long, but not Short."): + self.assert_location_does_not_exist("Mountaintop Box Short (Panel Hunt)", 3, strict_check=False) + self.assert_location_exists("Mountaintop Box Long (Panel Hunt)", 3, strict_check=False) + + with self.subTest("Test that \"disable_anything_locked_by_lasers\" has both Mountaintop Box Hunt Panels."): + self.assert_location_exists("Mountaintop Box Short (Panel Hunt)", 4, strict_check=False) + self.assert_location_exists("Mountaintop Box Long (Panel Hunt)", 4, strict_check=False) + + # Check panel_hunt_postgame locations get disabled + + with self.subTest("Test that \"everything_is_eligible\" does not disable any locked-by-lasers panels."): + self.assert_location_exists("Mountain Floor 1 Right Row 5", 1) + self.assert_location_exists("Mountain Bottom Floor Discard", 1) + + with self.subTest("Test that \"disable_mountain_lasers_locations\" disables only Shortbox-Locked panels."): + self.assert_location_does_not_exist("Mountain Floor 1 Right Row 5", 2) + self.assert_location_exists("Mountain Bottom Floor Discard", 2) + + with self.subTest("Test that \"disable_challenge_lasers_locations\" disables only Longbox-Locked panels."): + self.assert_location_exists("Mountain Floor 1 Right Row 5", 3) + self.assert_location_does_not_exist("Mountain Bottom Floor Discard", 3) + + with self.subTest("Test that \"everything_is_eligible\" disables only Shortbox-Locked panels."): + self.assert_location_does_not_exist("Mountain Floor 1 Right Row 5", 4) + self.assert_location_does_not_exist("Mountain Bottom Floor Discard", 4)