diff --git a/worlds/witness/__init__.py b/worlds/witness/__init__.py index ac9197bd92bb..c485c0b91686 100644 --- a/worlds/witness/__init__.py +++ b/worlds/witness/__init__.py @@ -5,7 +5,7 @@ from logging import error, warning from typing import Any, Dict, List, Optional, cast -from BaseClasses import CollectionState, Entrance, Location, Region, Tutorial +from BaseClasses import CollectionState, Entrance, Location, LocationProgressType, Region, Tutorial from Options import OptionError, PerGameCommonOptions, Toggle from worlds.AutoWorld import WebWorld, World @@ -14,6 +14,7 @@ from .data import static_locations as static_witness_locations from .data import static_logic as static_witness_logic from .data.item_definition_classes import DoorItemDefinition, ItemData +from .data.static_locations import POSSIBLE_LOCATIONS 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 @@ -241,10 +242,8 @@ def create_regions(self) -> None: # Then, add checks in order until the required amount of sphere 1 checks is met. extra_checks = [ - ("Tutorial First Hallway Room", "Tutorial First Hallway Bend"), - ("Tutorial First Hallway", "Tutorial First Hallway Straight"), - ("Desert Outside", "Desert Surface 1"), - ("Desert Outside", "Desert Surface 2"), + (location, static_witness_logic.ENTITIES_BY_NAME[location]["region"]["name"]) + for location in static_witness_locations.EXTRA_LOCATIONS ] for i in range(num_early_locs, needed_size): @@ -260,6 +259,13 @@ def create_regions(self) -> None: due to insufficient sphere 1 size.""" ) + # Ensure that POSSIBLE_LOCATIONS is exhaustive by failing on dev if any locations aren't in it + assert all( + location.name in POSSIBLE_LOCATIONS + for location in self.multiworld.get_locations(self.player) + if not location.is_event + ), "POSSIBLE_LOCATIONS is not exhaustive." + def create_items(self) -> None: # Determine pool size. pool_size = len(self.player_locations.CHECK_LOCATION_TABLE) - len(self.player_locations.EVENT_LOCATION_TABLE) @@ -330,6 +336,13 @@ def fill_slot_data(self) -> Dict[str, Any]: already_hinted_locations = set() + # Excluded locations should never be hinted + + already_hinted_locations |= { + location.name for location in self.multiworld.get_locations(self.player) + if location.progress_type == LocationProgressType.EXCLUDED + } + # Laser hints if self.options.laser_hints: diff --git a/worlds/witness/data/static_items.py b/worlds/witness/data/static_items.py index c64df741982e..ccf00408f8d8 100644 --- a/worlds/witness/data/static_items.py +++ b/worlds/witness/data/static_items.py @@ -8,11 +8,22 @@ ITEM_DATA: Dict[str, ItemData] = {} ITEM_GROUPS: Dict[str, Set[str]] = {} +POSSIBLE_ITEMS: Set[str] = set() # Useful items that are treated specially at generation time and should not be automatically added to the player's # item list during get_progression_items. _special_usefuls: List[str] = ["Puzzle Skip"] +_impossible_items: Set[str] = { + "Dots", + "Full Dots", + "Symmetry", + "Colored Dots", + "Stars", + "Stars + Same Colored Symbol", + "Invisible Dots" +} + ALWAYS_GOOD_SYMBOL_ITEMS: Set[str] = {"Dots", "Black/White Squares", "Symmetry", "Shapers", "Stars"} MODE_SPECIFIC_GOOD_ITEMS: Dict[str, Set[str]] = { @@ -71,6 +82,9 @@ def populate_items() -> None: ITEM_DATA[item_name] = ItemData(ap_item_code, definition, classification, local_only) + if item_name not in _impossible_items: + POSSIBLE_ITEMS.add(item_name) + def get_item_to_door_mappings() -> Dict[int, List[int]]: output: Dict[int, List[int]] = {} diff --git a/worlds/witness/data/static_locations.py b/worlds/witness/data/static_locations.py index 5c5ad554ddab..e92c9dc21172 100644 --- a/worlds/witness/data/static_locations.py +++ b/worlds/witness/data/static_locations.py @@ -1,6 +1,7 @@ -from typing import Dict, Set, cast +from typing import Dict, List, Set, cast from . import static_logic as static_witness_logic +from . import utils ID_START = 158000 @@ -453,6 +454,15 @@ AREA_LOCATION_GROUPS: Dict[str, Set[str]] = {} +POSSIBLE_LOCATIONS: Set[str] = set() + +EXTRA_LOCATIONS: List[str] = [ + "Tutorial First Hallway Bend", + "Tutorial First Hallway Straight", + "Desert Surface 1", + "Desert Surface 2", +] + def get_id(entity_hex: str) -> int: """ @@ -488,3 +498,13 @@ def get_event_name(entity_hex: str) -> str: for loc in ALL_LOCATIONS_TO_IDS: area = static_witness_logic.ENTITIES_BY_NAME[loc]["area"]["name"] AREA_LOCATION_GROUPS.setdefault(area, set()).add(loc) + +POSSIBLE_LOCATIONS |= GENERAL_LOCATIONS +POSSIBLE_LOCATIONS.update(EXTRA_LOCATIONS) + +door_shuffle_line_gen = (line for line in utils.get_complex_doors()) +for line in door_shuffle_line_gen: + if line.startswith("Added Locations"): + break + +POSSIBLE_LOCATIONS.update([line.strip() for line in door_shuffle_line_gen]) diff --git a/worlds/witness/hints.py b/worlds/witness/hints.py index 82837aed0686..bd6cfc00e783 100644 --- a/worlds/witness/hints.py +++ b/worlds/witness/hints.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union, cast -from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region +from BaseClasses import CollectionState, Item, Location, MultiWorld, Region from .data import static_logic as static_witness_logic from .data.utils import weighted_sample @@ -42,142 +42,111 @@ class WitnessWordedHint: def get_always_hint_items(world: "WitnessWorld") -> List[str]: - always = [ - "Boat", - "Caves Shortcuts", - "Progressive Dots", - ] + always = world.options.always_hint_items.value.copy() difficulty = world.options.puzzle_randomization discards = world.options.shuffle_discarded_panels wincon = world.options.victory_condition - if discards: - if difficulty == "sigma_expert" or difficulty == "umbra_variety": - always.append("Arrows") - else: - always.append("Triangles") + if discards and world.options.discard_symbol_hint == "always_hint": + if difficulty in ("sigma_expert", "umbra_variety"): + always.add("Arrows") + if difficulty in ("none", "sigma_normal", "umbra_variety"): + always.add("Triangles") - if wincon == "elevator": - always += ["Mountain Bottom Floor Pillars Room Entry (Door)", "Mountain Bottom Floor Doors"] + if world.options.final_door_hint == "priority_hint": + if wincon == "elevator": + always |= {"Mountain Bottom Floor Pillars Room Entry (Door)", "Mountain Bottom Floor Doors"} - if wincon == "challenge": - always += ["Challenge Entry (Panel)", "Caves Panels"] + if wincon == "challenge": + always |= {"Challenge Entry (Panel)", "Caves Panels", "Challenge Entry (Door)", "Caves Doors"} - return always + return sorted(always) def get_always_hint_locations(world: "WitnessWorld") -> List[str]: - always = [ - "Challenge Vault Box", - "Mountain Bottom Floor Discard", - "Theater Eclipse EP", - "Shipwreck Couch EP", - "Mountainside Cloud Cycle EP", - ] + always = sorted(world.options.always_hint_locations.value) - # Add Obelisk Sides that contain EPs that are meant to be hinted, if they are necessary to complete the Obelisk Side - if "0x339B6" not in world.player_logic.COMPLETELY_DISABLED_ENTITIES: - always.append("Town Obelisk Side 6") # Eclipse EP - - if "0x3388F" not in world.player_logic.COMPLETELY_DISABLED_ENTITIES: - always.append("Treehouse Obelisk Side 4") # Couch EP + # For EPs, also make their obelisk side an always hint + for location_name in always: + location_obj = static_witness_logic.ENTITIES_BY_NAME[location_name] + if location_obj["entityType"] != "EP": + continue + if location_obj["entity_hex"] in world.player_logic.COMPLETELY_DISABLED_ENTITIES: + continue - if "0x335AE" not in world.player_logic.COMPLETELY_DISABLED_ENTITIES: - always.append("Mountainside Obelisk Side 1") # Cloud Cycle EP. + corresponding_obelisk_side = static_witness_logic.EP_TO_OBELISK_SIDE[location_obj["entity_hex"]] + always.append(static_witness_logic.ENTITIES_BY_ID[corresponding_obelisk_side]["checkName"]) return always def get_priority_hint_items(world: "WitnessWorld") -> List[str]: - priority = { - "Caves Mountain Shortcut (Door)", - "Caves Swamp Shortcut (Door)", - "Swamp Entry (Panel)", - "Swamp Laser Shortcut (Door)", - } + priority = world.options.priority_hint_items.value.copy() - if world.options.shuffle_symbols: - symbols = [ - "Progressive Dots", - "Progressive Stars", - "Shapers", - "Rotated Shapers", - "Negative Shapers", - "Arrows", - "Triangles", - "Eraser", - "Black/White Squares", - "Colored Squares", - "Sound Dots", - "Progressive Symmetry" - ] + difficulty = world.options.puzzle_randomization + discards = world.options.shuffle_discarded_panels + wincon = world.options.victory_condition - priority.update(world.random.sample(symbols, 5)) - - if world.options.shuffle_lasers: - lasers = [ - "Symmetry Laser", - "Town Laser", - "Keep Laser", - "Swamp Laser", - "Treehouse Laser", - "Monastery Laser", - "Jungle Laser", - "Quarry Laser", - "Bunker Laser", - "Shadows Laser", - ] + existing_items_lookup = {item.name for item in world.own_itempool} - if world.options.shuffle_doors >= 2: - priority.add("Desert Laser") - priority.update(world.random.sample(lasers, 5)) + if discards and world.options.discard_symbol_hint == "always_hint": + if difficulty in ("sigma_expert", "umbra_variety"): + priority.add("Arrows") + if difficulty in ("none", "sigma_normal", "umbra_variety"): + priority.add("Triangles") - else: - lasers.append("Desert Laser") - priority.update(world.random.sample(lasers, 6)) + if world.options.final_door_hint == "priority_hint": + if wincon == "elevator": + priority |= {"Mountain Bottom Floor Pillars Room Entry (Door)", "Mountain Bottom Floor Doors"} + + if wincon == "challenge": + priority |= {"Challenge Entry (Panel)", "Caves Panels", "Challenge Entry (Door)", "Caves Doors"} + + # Add symbols and lasers in accordance with Priority Symbols and Priority Lasers options + + number_of_symbols = sum(item in world.item_name_groups["Symbols"] for item in priority) + number_of_lasers = sum(item in world.item_name_groups["Lasers"] for item in priority) + + needed_symbols = world.options.priority_symbols - number_of_symbols + needed_lasers = world.options.priority_lasers - number_of_lasers + + possible_symbols = sorted(world.item_name_groups["Symbols"] & existing_items_lookup - priority) + possible_lasers = sorted(world.item_name_groups["Lasers"] & existing_items_lookup - priority) + + if needed_symbols > 0: + priority.update(world.random.sample(possible_symbols, min(len(possible_symbols), needed_symbols))) + + if needed_lasers > 0: + priority.update(world.random.sample(possible_lasers, min(len(possible_lasers), needed_lasers))) return sorted(priority) def get_priority_hint_locations(world: "WitnessWorld") -> List[str]: - priority = [ - "Tutorial Patio Floor", - "Tutorial Patio Flowers EP", - "Swamp Purple Underwater", - "Shipwreck Vault Box", - "Town RGB House Upstairs Left", - "Town RGB House Upstairs Right", - "Treehouse Green Bridge 7", - "Treehouse Green Bridge Discard", - "Shipwreck Discard", - "Desert Vault Box", - "Mountainside Vault Box", - "Mountainside Discard", - "Tunnels Theater Flowers EP", - "Boat Shipwreck Green EP", - "Quarry Stoneworks Control Room Left", - ] - - # Add Obelisk Sides that contain EPs that are meant to be hinted, if they are necessary to complete the Obelisk Side - if "0x33A20" not in world.player_logic.COMPLETELY_DISABLED_ENTITIES: - priority.append("Town Obelisk Side 6") # Theater Flowers EP + priority = sorted(world.options.priority_hint_locations.value) - if "0x28B29" not in world.player_logic.COMPLETELY_DISABLED_ENTITIES: - priority.append("Treehouse Obelisk Side 4") # Shipwreck Green EP + # For EPs, also make their obelisk side a priority hint + for location_name in priority: + location_obj = static_witness_logic.ENTITIES_BY_NAME[location_name] + if location_obj["entityType"] != "EP": + continue + if location_obj["entity_hex"] in world.player_logic.COMPLETELY_DISABLED_ENTITIES: + continue - if "0x33600" not in world.player_logic.COMPLETELY_DISABLED_ENTITIES: - priority.append("Town Obelisk Side 2") # Tutorial Patio Flowers EP. + corresponding_obelisk_side = static_witness_logic.EP_TO_OBELISK_SIDE[location_obj["entity_hex"]] + priority.append(static_witness_logic.ENTITIES_BY_ID[corresponding_obelisk_side]["checkName"]) return priority def try_getting_location_group_for_location(world: "WitnessWorld", hint_loc: Location) -> Tuple[str, str]: allow_regions = world.options.vague_hints == "experimental" + containing_world = world.multiworld.worlds[hint_loc.player] possible_location_groups = { group_name: group_locations - for group_name, group_locations in world.multiworld.worlds[hint_loc.player].location_name_groups.items() + for group_name, group_locations in containing_world.location_name_groups.items() if hint_loc.name in group_locations } @@ -206,14 +175,24 @@ def try_getting_location_group_for_location(world: "WitnessWorld", hint_loc: Loc for location_group, x in valid_location_groups.items() } + logging.debug( + f"Eligible location groups for location " + f'"{hint_loc}" ({containing_world.game}): {location_groups_with_weights}.' + ) + location_groups = list(location_groups_with_weights.keys()) weights = list(location_groups_with_weights.values()) return world.random.choices(location_groups, weights, k=1)[0], "Group" + logging.debug( + f"Couldn't find suitable location group for location \"{hint_loc}\" ({containing_world.game})." + ) if allow_regions: + logging.debug(f'Falling back on parent region "{hint_loc.parent_region}".') return cast(Region, hint_loc.parent_region).name, "Region" + logging.debug('Falling back on hinting the "Everywhere" group.') return "Everywhere", "Everywhere" @@ -237,6 +216,10 @@ def word_direct_hint(world: "WitnessWorld", hint: WitnessLocationHint) -> Witnes if world.options.vague_hints: chosen_group, group_type = try_getting_location_group_for_location(world, hint.location) + logging.debug( + f'Vague hints: Chose group "{chosen_group}" of type "{group_type}" for location "{hint.location}".' + ) + if hint.location.player == world.player: area = chosen_group @@ -309,7 +292,7 @@ def get_item_and_location_names_in_random_order(world: "WitnessWorld", locations_in_this_world = [ location for location in world.multiworld.get_locations(world.player) - if location.item and not location.is_event and location.progress_type != LocationProgressType.EXCLUDED + if location.item and not location.is_event ] world.random.shuffle(locations_in_this_world) @@ -338,8 +321,13 @@ def make_always_and_priority_hints(world: "WitnessWorld", own_itempool: List["Wi if item in progression_items_in_this_world ] + logging.debug(f"Always item hints: {always_items}") + logging.debug(f"Priority item hints: {priority_items}") + if world.options.vague_hints: always_locations, priority_locations = [], [] + + logging.debug("No always / priority location hints because this world wants vague hints.") else: always_locations = [ location for location in get_always_hint_locations(world) @@ -350,6 +338,9 @@ def make_always_and_priority_hints(world: "WitnessWorld", own_itempool: List["Wi if location in locations_in_this_world ] + logging.debug(f"Always location hints: {always_locations}") + logging.debug(f"Priority location hints: {priority_locations}") + # Get always and priority location/item hints always_location_hints = {hint_from_location(world, location) for location in always_locations} always_item_hints = {hint_from_item(world, item, own_itempool) for item in always_items} @@ -372,6 +363,15 @@ def make_always_and_priority_hints(world: "WitnessWorld", own_itempool: List["Wi world.random.shuffle(always_hints) world.random.shuffle(priority_hints) + logging.debug( + f"Finalized always hints: " + f"{[f'{hint.location.item} on {hint.location}' for hint in always_hints]}" + ) + logging.debug( + f"Finalized priority hint candidates: " + f"{[f'{hint.location.item} on {hint.location}' for hint in priority_hints]}" + ) + return always_hints, priority_hints @@ -382,7 +382,7 @@ def make_extra_location_hints(world: "WitnessWorld", hint_amount: int, own_itemp world, own_itempool ) - next_random_hint_is_location = world.random.randrange(0, 2) + next_random_hint_is_location = world.random.randrange(0, 100) >= world.options.random_hints_are_items_weight hints: List[WitnessWordedHint] = [] @@ -428,6 +428,11 @@ def make_extra_location_hints(world: "WitnessWorld", hint_amount: int, own_itemp next_random_hint_is_location = not next_random_hint_is_location + logging.debug( + f"Remaining hints: " + f"{[f'{hint.location.item} on {hint.location}' for hint in hints]}" + ) + return hints @@ -453,10 +458,14 @@ def choose_areas(world: "WitnessWorld", amount: int, locations_per_area: Dict[st areas = sorted(area for area in items_per_area if unhinted_location_percentage_per_area[area]) weights = [unhinted_location_percentage_per_area[area] for area in areas] + logging.debug(f"Area weights: {unhinted_location_percentage_per_area}") + amount = min(amount, len(weights)) hinted_areas = weighted_sample(world.random, areas, weights, amount) + logging.debug(f"Chosen area hints ({len(hinted_areas)}): {hinted_areas}") + return hinted_areas, unhinted_locations_per_area @@ -579,6 +588,8 @@ def word_area_hint(world: "WitnessWorld", hinted_area: str, area_items: List[Ite elif local_lasers: hint_string += f"\n{local_lasers} of them are lasers." + logging.debug(f'Wording area hint for {hinted_area} as: "{hint_string}"') + return hint_string, total_progression, hunt_panels @@ -606,6 +617,10 @@ def make_area_hints(world: "WitnessWorld", amount: int, already_hinted_locations def create_all_hints(world: "WitnessWorld", hint_amount: int, area_hints: int, already_hinted_locations: Set[Location]) -> List[WitnessWordedHint]: + start_line = f"Witness hints: {world.player_name} start" + dashes = "-" * len(start_line) + logging.debug(f"{dashes}\n{start_line}\n{dashes}") + generated_hints: List[WitnessWordedHint] = [] state = CollectionState(world.multiworld) @@ -633,7 +648,22 @@ def create_all_hints(world: "WitnessWorld", hint_amount: int, area_hints: int, # Make up to half of the rest of the location hints priority hints, using up to half of the possibly priority hints remaining_location_hints = intended_location_hints - always_hints_to_use - priority_hints_to_use = int(max(0.0, min(possible_priority_hints / 2, remaining_location_hints / 2))) + + priority_cap_possible = possible_priority_hints * world.options.priority_hints_percentage_out_of_possible / 100 + priority_cap_remain = remaining_location_hints * world.options.priority_hints_percentage_out_of_remaining / 100 + + priority_hints_to_use = int(max(0.0, min(priority_cap_possible, priority_cap_remain))) + + amount_of_priority_hint_candidates = len(priority_hints) + + logging.debug( + f"Using {priority_hints_to_use} priority out of {amount_of_priority_hint_candidates} candidates. " + f"This is the floor of the lower number out of\n" + f"1. {world.options.priority_hints_percentage_out_of_possible}% of {possible_priority_hints} " + f"possible priority hints, which is {priority_cap_possible}.\n" + f"2. {world.options.priority_hints_percentage_out_of_remaining}% of {remaining_location_hints} " + f"remaining hint slots after area and always hints, which is {priority_cap_remain}." + ) for _ in range(always_hints_to_use): location_hint = always_hints.pop() @@ -666,19 +696,53 @@ def create_all_hints(world: "WitnessWorld", hint_amount: int, area_hints: int, intended_location_hints = remaining_needed_location_hints + location_hints_created_in_round_1 always_hints_to_use = min(intended_location_hints, generated_always_hints) - priority_hints_to_use = int(max(0.0, min(possible_priority_hints / 2, remaining_location_hints / 2))) + remaining_location_hints = intended_location_hints - always_hints_to_use + + priority_cap_possible = possible_priority_hints * world.options.priority_hints_percentage_out_of_possible / 100 + priority_cap_remain = remaining_location_hints * world.options.priority_hints_percentage_out_of_remaining / 100 + + priority_hints_to_use = int(max(0.0, min(priority_cap_possible, priority_cap_remain))) # If we now need more always hints and priority hints than we thought previously, make some more. more_always_hints = always_hints_to_use - amt_of_used_always_hints more_priority_hints = priority_hints_to_use - amt_of_used_priority_hints + if more_always_hints or more_priority_hints: + logging.debug( + f"Reusing always and priority hints as fallback after not enough area hints could be made. " + f"There are {remaining_needed_location_hints} more hints to make now." + ) + + logging.debug( + f"Now, we will actually use {priority_hints_to_use} out of {amount_of_priority_hint_candidates} " + f"priority candidates. This is the floor of the lower number out of\n" + f"1. {world.options.priority_hints_percentage_out_of_possible}% of {possible_priority_hints} " + f"possible priority hints, which is {priority_cap_possible}.\n" + f"2. {world.options.priority_hints_percentage_out_of_remaining}% of {remaining_location_hints} " + f"remaining hint slots after area and always hints, which is {priority_cap_remain}." + ) + extra_always_and_priority_hints: List[WitnessLocationHint] = [] for _ in range(more_always_hints): - extra_always_and_priority_hints.append(always_hints.pop()) + extra_always_hint = always_hints.pop() + + logging.debug( + f"Adding late always hint: " + f"{extra_always_hint.location.item} on {extra_always_hint.location}" + ) + + extra_always_and_priority_hints.append(extra_always_hint) for _ in range(more_priority_hints): - extra_always_and_priority_hints.append(priority_hints.pop()) + extra_priority_hint = priority_hints.pop() + + logging.debug( + f"Adding late priority hint: " + f"{extra_priority_hint.location.item} on {extra_priority_hint.location}" + ) + + extra_always_and_priority_hints.append(extra_priority_hint) generated_hints += make_extra_location_hints( world, hint_amount - len(generated_hints), world.own_itempool, already_hinted_locations, @@ -690,6 +754,10 @@ def create_all_hints(world: "WitnessWorld", hint_amount: int, area_hints: int, logging.warning(f"Couldn't generate {hint_amount} hints for player {world.player_name}. " f"Generated {len(generated_hints)} instead.") + end_line = f"Witness hints: {world.player_name} end" + dashes = "-" * len(end_line) + logging.debug(f"{dashes}\n{end_line}\n{dashes}") + return generated_hints diff --git a/worlds/witness/options.py b/worlds/witness/options.py index d739517870a5..c6ff2394cf21 100644 --- a/worlds/witness/options.py +++ b/worlds/witness/options.py @@ -5,6 +5,7 @@ from Options import ( Choice, DefaultOnToggle, + ItemSet, LocationSet, OptionDict, OptionError, @@ -16,6 +17,8 @@ Visibility, ) +from .data import static_items as static_witness_items +from .data import static_locations as static_witness_locations from .data import static_logic as static_witness_logic from .data.item_definition_classes import ItemCategory, WeightedItemDefinition from .entity_hunt import ALL_HUNTABLE_PANELS @@ -466,6 +469,182 @@ class PuzzleRandomizationSeed(Range): default = "random" +class DiscardSymbolHint(Choice): + """ + Controls whether the symbol(s) required to solve Discarded Panels have elevated priority for Audio Log hints. + In vanilla and sigma_normal, this is Triangles. + In sigma_expert, this is Arrows. + In umbra_variety, this is both Triangles and Arrows. + """ + display_name = "Discarded Panel Symbol Hint" + + option_no_elevated_status = 0 + option_priority_hint = 1 + option_always_hint = 2 + default = 2 + + +class FinalDoorHint(Choice): + """ + Controls whether the door required to enter the final room (dependent on Victory Condition option and Shuffle Doors option) has elevated priority for Audio Log hints. + + For Mountain Box goals, this has no effect. + For Elevator goal, this will only have an effect if playing Remote Door Shuffle, in which case it hints Mountain Bottom Floor Pillars Room Entry (individual) or Mountain Bottom Floor Doors (regional) + For Challenge goal, this will hint Challenge Entry Panel (individual) or Caves Panels (regional) in Panel Door Shuffle, and it will hint Challenge Entry Door (individual) or Caves Doors (regional) in Remote Door Shuffle. + """ + display_name = "Final Door Item Hint" + + option_no_elevated_status = 0 + option_priority_hint = 1 + option_always_hint = 2 + default = 2 + + +class AlwaysHintItems(ItemSet): + """ + Items that will always get an audio log hint if they exist in the itempool. + """ + display_name = "Always Hint Items" + default = { + "Boat", + "Caves Shortcuts", + "Progressive Dots", + } + + valid_keys = static_witness_items.POSSIBLE_ITEMS + + +class AlwaysHintLocations(LocationSet): + """ + Locations that will always get an audio log hint if they exist in the world. + + If an individual EP is set, its corresponding Obelisk Side will become an Always Hint as well, if the EP is not disabled (e.g. through the EP Difficulty setting). + """ + display_name = "Always Hint Locations" + default = { + "Challenge Vault Box", + "Mountain Bottom Floor Discard", + "Theater Eclipse EP", + "Shipwreck Couch EP", + "Mountainside Cloud Cycle EP", + } + + valid_keys = static_witness_locations.POSSIBLE_LOCATIONS + + +class PriorityHintItems(ItemSet): + """ + Items that will have increased priority to be hinted if they exist in the itempool. + """ + display_name = "Priority Hint Items" + default = { + "Black/White Squares", + } + + valid_keys = static_witness_items.POSSIBLE_ITEMS + + +class PriorityHintLocations(LocationSet): + """ + Locations that will have increased priority to be hinted if they exist in the world. + + If an individual EP is set, its corresponding Obelisk Side will become a Priority Hint as well, if the EP is not disabled (e.g. through the EP Difficulty setting). + """ + + display_name = "Priority Hint Locations" + default = { + "Tutorial Patio Floor", + "Tutorial Patio Flowers EP", + "Swamp Purple Underwater", + "Shipwreck Vault Box", + "Town RGB House Upstairs Left", + "Town RGB House Upstairs Right", + "Treehouse Green Bridge 7", + "Treehouse Green Bridge Discard", + "Shipwreck Discard", + "Desert Vault Box", + "Mountainside Vault Box", + "Mountainside Discard", + "Tunnels Theater Flowers EP", + "Boat Shipwreck Green EP", + "Quarry Stoneworks Control Room Left", + } + + valid_keys = static_witness_locations.POSSIBLE_LOCATIONS + + +class PrioritySymbols(Range): + """ + Amount of random Symbol items to add to Priority Hints. + If Symbol items have been manually set as Priority Hints elsewhere, e.g. via the Priority Hint Items option, they are counted for this number, and only as many symbols as are necessary are added to meet this value. + Also, if a priority Symbol item is already an Always Hint, it will just be "eaten", this option will not try to dodge them. + """ + display_name = "Priority Symbols" + + range_start = 0 + range_end = len(static_witness_items.ITEM_GROUPS["Symbols"]) + default = 5 + + +class PriorityLasers(Range): + """ + Amount of random Laser items to add to Priority Hints. + If Laser items have been manually set as Priority Hints elsewhere, e.g. via the Priority Hint Items option, they are counted for this number, and only as many symbols as are necessary are added to meet this value. + Also, if a priority Laser item is already an Always Hint, it will just be "eaten", this option will not try to dodge them. + """ + display_name = "Priority Lasers" + + range_start = 0 + range_end = len(static_witness_items.ITEM_GROUPS["Lasers"]) + default = 6 + + +class PriorityHintsPercentageOutOfRemaining(Range): + """ + Maximum percentage of Priority Hints to make. + This percentage refers to the amount of remaining hints after Always and Area hints. + + Example: + The total hint amount is 15. + There are 3 Area Hints and 2 Always Hints. + This option is set to 50%. + This means that the amount of Priority Hints is capped at 5, because the amount of remaining hints after Area Hints and Always Hints is 10, and 50% of 10 is equal to 5. + """ + display_name = "Maximum Priority Hints Percentage (out of Remaining Hints)" + + range_start = 0 + range_end = 100 + default = 50 + + +class PriorityHintsPercentageOutOfPossible(Range): + """ + Maximum percentage of possible Priority Hints to use. + + Example: + The amount of remaining hints after Area Hints and Always Hints is 20. + After evaluating all the Priority Hint options, there are 30 available Priority Hints. + This option is set to 50%. + This means that the amount of Priority Hints is capped at 15, because only 50% of available Priority Hints are allowed to be used. + """ + display_name = "Maximum Priortiy Hints (out of possible Priority Hints)" + + range_start = 0 + range_end = 100 + default = 50 + + +class RandomHintsAreItemHintsWeight(Range): + """ + Controls the chance of Random Hints (after Area Hints, Always Hints and Priority Hints) to be for Items from this world's itempool, rather than Locations in this world. + """ + display_name = "Remaining Hints Are Items Weight" + + range_start = 0 + range_end = 100 + default = 50 + + @dataclass class TheWitnessOptions(PerGameCommonOptions): puzzle_randomization: PuzzleRandomization @@ -505,6 +684,18 @@ class TheWitnessOptions(PerGameCommonOptions): puzzle_randomization_seed: PuzzleRandomizationSeed shuffle_dog: ShuffleDog + discard_symbol_hint: DiscardSymbolHint + final_door_hint: FinalDoorHint + always_hint_items: AlwaysHintItems + always_hint_locations: AlwaysHintLocations + priority_hint_items: PriorityHintItems + priority_hint_locations: PriorityHintLocations + priority_symbols: PrioritySymbols + priority_lasers: PriorityLasers + priority_hints_percentage_out_of_remaining: PriorityHintsPercentageOutOfRemaining + priority_hints_percentage_out_of_possible: PriorityHintsPercentageOutOfPossible + random_hints_are_items_weight: RandomHintsAreItemHintsWeight + witness_option_groups = [ OptionGroup("Puzzles & Goal", [ @@ -559,5 +750,18 @@ class TheWitnessOptions(PerGameCommonOptions): ]), OptionGroup("Silly Options", [ ShuffleDog, - ]) + ]), + OptionGroup("Advanced Hint Options", [ + DiscardSymbolHint, + FinalDoorHint, + AlwaysHintItems, + AlwaysHintLocations, + PriorityHintItems, + PriorityHintLocations, + PrioritySymbols, + PriorityLasers, + PriorityHintsPercentageOutOfRemaining, + PriorityHintsPercentageOutOfPossible, + RandomHintsAreItemHintsWeight, + ], start_collapsed=True), ] diff --git a/worlds/witness/test/test_roll_other_options.py b/worlds/witness/test/test_roll_other_options.py index 05f3235a1f4d..bc62b6b57634 100644 --- a/worlds/witness/test/test_roll_other_options.py +++ b/worlds/witness/test/test_roll_other_options.py @@ -1,3 +1,5 @@ +from Fill import distribute_items_restrictive + from ..options import ElevatorsComeToYou from ..test import WitnessTestBase @@ -36,10 +38,16 @@ class TestMiscOptions(WitnessTestBase): "death_link_amnesty": 3, "laser_hints": True, "hint_amount": 40, - "area_hint_percentage": 100, + "area_hint_percentage": 75, "vague_hints": "experimental", } + run_default_tests = False + + def test_hints(self): + distribute_items_restrictive(self.multiworld) + self.world.fill_slot_data() + class TestMaxEntityShuffle(WitnessTestBase): options = {