diff --git a/options/wwrando_options.py b/options/wwrando_options.py index 49d510d5..c7a3324e 100644 --- a/options/wwrando_options.py +++ b/options/wwrando_options.py @@ -316,6 +316,11 @@ class Options(BaseOptions): default=False, description="When this option is selected, certain locations that are out of the way and time-consuming to complete will take precedence over normal location hints.", ) + hint_importance: bool = option( + default=False, + description="When this option is selected, item and location hints will also indicate if the hinted item is required, possibly required, or not required.
" + "Only progress items will have these additions; non-progress items are trivially not required." + ) #endregion #region Tweaks diff --git a/randomizer.py b/randomizer.py index ae01c0cd..3a633fa3 100644 --- a/randomizer.py +++ b/randomizer.py @@ -72,6 +72,7 @@ "num_item_hints", "cryptic_hints", "prioritize_remote_hints", + "hint_importance", "do_not_generate_spoiler_log", ] diff --git a/randomizers/hints.py b/randomizers/hints.py index f0a4bb3b..f1e5bc1f 100644 --- a/randomizers/hints.py +++ b/randomizers/hints.py @@ -22,14 +22,21 @@ class HintType(Enum): FIXED_LOCATION = 4 +class ItemImportance(Enum): + REQUIRED = 0 + POSSIBLY_REQUIRED = 1 + NOT_REQUIRED = 2 + + class Hint: - def __init__(self, type: HintType, place, reward=None): + def __init__(self, type: HintType, place, reward=None, importance=None): assert place is not None if type == HintType.BARREN: assert reward is None if type != HintType.BARREN: assert reward is not None self.type = type self.place = place self.reward = reward + self.importance = importance def formatted_place(self, is_cryptic: bool): if not is_cryptic: @@ -58,18 +65,33 @@ def formatted_reward(self, is_cryptic: bool): case _: raise NotImplementedError + def formatted_importance(self): + match self.importance: + case None: + return "" + case ItemImportance.REQUIRED: + return "required" + case ItemImportance.POSSIBLY_REQUIRED: + return "possibly required" + case ItemImportance.NOT_REQUIRED: + return "not required" + case _: + raise NotImplementedError + def __str__(self): - return "" % ( + return "" % ( self.type.name, self.formatted_place(False), self.formatted_reward(False), + self.formatted_importance(), ) def __repr__(self): - return "Hint(%s, %s, %s)" % ( + return "Hint(%s, %s, %s, %s)" % ( str(self.type), repr(self.place), repr(self.reward), + repr(self.importance), ) @@ -136,6 +158,10 @@ def __init__(self, rando): self.cryptic_hints = self.options.cryptic_hints self.prioritize_remote_hints = self.options.prioritize_remote_hints + self.hint_importance = self.options.hint_importance + + self.path_locations: set[str] = None + self.barren_locations: set[str] = None self.floor_30_hint: Hint = None self.floor_50_hint: Hint = None @@ -183,6 +209,9 @@ def _randomize(self): self.path_logic = Logic(self.rando) self.path_logic_initial_state = self.path_logic.save_simulated_playthrough_state() + # Generate the hints that will be distributed over the hint placement options + hints = self.generate_hints() + self.floor_30_hint = self.generate_savage_labyrinth_hint("Outset Island - Savage Labyrinth - Floor 30") self.floor_50_hint = self.generate_savage_labyrinth_hint("Outset Island - Savage Labyrinth - Floor 50") @@ -204,9 +233,6 @@ def _randomize(self): if self.total_num_hints == 0 or len(hint_placement_options) == 0: return - # Generate the hints that will be distributed over the hint placement options - hints = self.generate_hints() - # If there are less hints than placement options, duplicate the hints so that all selected # placement options have at least one hint. duplicated_hints = [] @@ -255,7 +281,7 @@ def distribute_hoho_hints(self, hints: list[Hint]): hint_index += 1 def _save(self): - self.update_savage_labyrinth_hint_tablet(self.floor_30_hint, self.floor_50_hint) + self.update_savage_labyrinth_hint_tablet(self.floor_30_hint, self.floor_50_hint, self.hint_importance) if not any(self.check_item_can_be_hinted_at(item_name) for item_name in self.rando.all_randomized_progress_items): # See above. @@ -263,7 +289,7 @@ def _save(self): patcher.apply_patch(self.rando, "flexible_hint_locations") - self.update_big_octo_great_fairy_item_name_hint(self.octo_fairy_hint) + self.update_big_octo_great_fairy_item_name_hint(self.octo_fairy_hint, self.hint_importance) # Send the list of hints for each hint placement option to its respective distribution function. # Each hint placement option will handle how to place the hints in-game in their own way. @@ -302,26 +328,46 @@ def write_to_spoiler_log(self) -> str: #region Saving - def update_savage_labyrinth_hint_tablet(self, floor_30_hint: Hint, floor_50_hint: Hint): + def update_savage_labyrinth_hint_tablet(self, floor_30_hint: Hint, floor_50_hint: Hint, importance: bool): # Update the tablet on the first floor of savage labyrinth to give hints as to the items inside the labyrinth. floor_30_is_valid = self.check_item_can_be_hinted_at(floor_30_hint.reward) floor_50_is_valid = self.check_item_can_be_hinted_at(floor_50_hint.reward) + if importance: + floor_30_item_importance = floor_30_hint.formatted_importance() + if floor_30_hint.importance == ItemImportance.REQUIRED: + floor_30_item_importance = " (\\{1A 06 FF 00 00 02}%s\\{1A 06 FF 00 00 00})" % floor_30_item_importance + elif floor_30_hint.importance == ItemImportance.POSSIBLY_REQUIRED: + floor_30_item_importance = " (\\{1A 06 FF 00 00 04}%s\\{1A 06 FF 00 00 00})" % floor_30_item_importance + elif floor_30_hint.importance == ItemImportance.NOT_REQUIRED: + floor_30_item_importance = " (\\{1A 06 FF 00 00 07}%s\\{1A 06 FF 00 00 00})" % floor_30_item_importance + + floor_50_item_importance = floor_50_hint.formatted_importance() + if floor_50_hint.importance == ItemImportance.REQUIRED: + floor_50_item_importance = " (\\{1A 06 FF 00 00 02}%s\\{1A 06 FF 00 00 00})" % floor_50_item_importance + elif floor_50_hint.importance == ItemImportance.POSSIBLY_REQUIRED: + floor_50_item_importance = " (\\{1A 06 FF 00 00 04}%s\\{1A 06 FF 00 00 00})" % floor_50_item_importance + elif floor_50_hint.importance == ItemImportance.NOT_REQUIRED: + floor_50_item_importance = " (\\{1A 06 FF 00 00 07}%s\\{1A 06 FF 00 00 00})" % floor_50_item_importance + else: + floor_30_item_importance = "" + floor_50_item_importance = "" + if floor_30_is_valid and floor_50_is_valid: - hint = "\\{1A 06 FF 00 00 01}%s\\{1A 06 FF 00 00 00}" % floor_30_hint.formatted_reward(self.cryptic_hints) + hint = "\\{1A 06 FF 00 00 01}%s\\{1A 06 FF 00 00 00}%s" % (floor_30_hint.formatted_reward(self.cryptic_hints), floor_30_item_importance) hint += " and " - hint += "\\{1A 06 FF 00 00 01}%s\\{1A 06 FF 00 00 00}" % floor_50_hint.formatted_reward(self.cryptic_hints) + hint += "\\{1A 06 FF 00 00 01}%s\\{1A 06 FF 00 00 00}%s" % (floor_50_hint.formatted_reward(self.cryptic_hints), floor_50_item_importance) hint += " await" elif floor_30_is_valid: - hint = "\\{1A 06 FF 00 00 01}%s\\{1A 06 FF 00 00 00}" % floor_30_hint.formatted_reward(self.cryptic_hints) + hint = "\\{1A 06 FF 00 00 01}%s\\{1A 06 FF 00 00 00}%s" % (floor_30_hint.formatted_reward(self.cryptic_hints), floor_30_item_importance) hint += " and " hint += "challenge" hint += " await" elif floor_50_is_valid: hint = "challenge" hint += " and " - hint += "\\{1A 06 FF 00 00 01}%s\\{1A 06 FF 00 00 00}" % floor_50_hint.formatted_reward(self.cryptic_hints) + hint += "\\{1A 06 FF 00 00 01}%s\\{1A 06 FF 00 00 00}%s" % (floor_50_hint.formatted_reward(self.cryptic_hints), floor_50_item_importance) hint += " await" else: hint = "challenge" @@ -332,21 +378,45 @@ def update_savage_labyrinth_hint_tablet(self, floor_30_hint: Hint, floor_50_hint msg.string += "\\{1A 06 FF 00 00 00}Deep in the never-ending darkness, the way to %s." % hint msg.word_wrap_string(self.rando.bfn) - def update_big_octo_great_fairy_item_name_hint(self, hint: Hint): + def update_big_octo_great_fairy_item_name_hint(self, hint: Hint, importance: bool): + # The Octo Great Fairy must hint at a progress item + assert hint.importance is not None + msg = self.rando.bmg.messages_by_id[12015] msg.string = "\\{1A 06 FF 00 00 05}In \\{1A 06 FF 00 00 01}%s\\{1A 06 FF 00 00 05}, you will find an item." % hint.formatted_place(self.cryptic_hints) msg.word_wrap_string(self.rando.bfn) + msg = self.rando.bmg.messages_by_id[12016] - msg.string = "\\{1A 06 FF 00 00 05}...\\{1A 06 FF 00 00 01}%s\\{1A 06 FF 00 00 05}, which may help you on your quest." % tweaks.upper_first_letter(hint.formatted_reward(self.cryptic_hints)) + reward = tweaks.upper_first_letter(hint.formatted_reward(self.cryptic_hints)) + if importance: + copula = "is" + if hint.reward in ["Power Bracelets", "Iron Boots", "Bombs"]: + copula = "are" + + if hint.importance == ItemImportance.REQUIRED: + item_importance = "\\{1A 06 FF 00 00 02}%s\\{1A 06 FF 00 00 05}" % hint.formatted_importance() + elif hint.importance == ItemImportance.POSSIBLY_REQUIRED: + item_importance = "\\{1A 06 FF 00 00 04}%s\\{1A 06 FF 00 00 05}" % hint.formatted_importance() + elif hint.importance == ItemImportance.NOT_REQUIRED: + item_importance = "\\{1A 06 FF 00 00 07}%s\\{1A 06 FF 00 00 05}" % hint.formatted_importance() + + msg.string = "\\{1A 06 FF 00 00 05}...\\{1A 06 FF 00 00 01}%s\\{1A 06 FF 00 00 05}, which %s %s in your quest." % (reward, copula, item_importance) + else: + msg.string = "\\{1A 06 FF 00 00 05}...\\{1A 06 FF 00 00 01}%s\\{1A 06 FF 00 00 05}, which may help you on your quest." % reward msg.word_wrap_string(self.rando.bfn) + msg = self.rando.bmg.messages_by_id[12017] - msg.string = "\\{1A 06 FF 00 00 05}When you find you have need of such an item, you must journey to that place." + if importance and hint.importance == ItemImportance.NOT_REQUIRED: + # If the item is not required and the player is told so, change the post-hint message to be more appropriate. + msg.string = "\\{1A 06 FF 00 00 05}Though if you still desire such an item, you must journey to that place." + else: + msg.string = "\\{1A 06 FF 00 00 05}When you find you have need of such an item, you must journey to that place." msg.word_wrap_string(self.rando.bfn) def update_fishmen_hints(self): for fishman_island_number, hint in self.island_to_fishman_hint.items(): hint_lines = [] - hint_lines.append(HintsRandomizer.get_formatted_hint_text(hint, self.cryptic_hints, prefix="I've heard from my sources that ", suffix=".", delay=60)) + hint_lines.append(HintsRandomizer.get_formatted_hint_text(hint, self.cryptic_hints, self.hint_importance, prefix="I've heard from my sources that ", suffix=".", delay=60)) if self.cryptic_hints and (hint.type == HintType.ITEM or hint.type == HintType.LOCATION): hint_lines.append("Could be worth a try checking that place out. If you know where it is, of course.") @@ -367,7 +437,7 @@ def update_hoho_hints(self): hint_prefix = "\\{1A 05 01 01 03}Ho ho! To think that " if i == 0 else "and that " hint_suffix = "..." if i == len(hints_for_hoho) - 1 else "," - hint_lines.append(HintsRandomizer.get_formatted_hint_text(hint, self.cryptic_hints, prefix=hint_prefix, suffix=hint_suffix)) + hint_lines.append(HintsRandomizer.get_formatted_hint_text(hint, self.cryptic_hints, self.hint_importance, prefix=hint_prefix, suffix=hint_suffix)) if self.options.instant_text_boxes and i > 0: # If instant text mode is on, we need to reset the text speed to instant after the wait command messed it up. @@ -428,7 +498,7 @@ def update_korl_hints(self, hints: list[Hint]): # Have no delay with KoRL text since he potentially has a lot of textboxes hint_prefix = "They say that " if i == 0 else "and that " hint_suffix = "." if i == len(hints) - 1 else "," - hint_lines.append(HintsRandomizer.get_formatted_hint_text(hint, self.cryptic_hints, prefix=hint_prefix, suffix=hint_suffix, delay=0)) + hint_lines.append(HintsRandomizer.get_formatted_hint_text(hint, self.cryptic_hints, self.hint_importance, prefix=hint_prefix, suffix=hint_suffix, delay=0)) for msg_id in (1502, 3443, 3444, 3445, 3446, 3447, 3448): msg = self.rando.bmg.messages_by_id[msg_id] @@ -465,7 +535,7 @@ def get_hint_item_name(item_name): return item_name @staticmethod - def get_formatted_hint_text(hint: Hint, cryptic: bool, prefix="They say that ", suffix=".", delay=30): + def get_formatted_hint_text(hint: Hint, cryptic: bool, importance: bool, prefix="They say that ", suffix=".", delay=30): place = hint.formatted_place(cryptic) if place == "Mailbox": place = "the mail" @@ -476,6 +546,17 @@ def get_formatted_hint_text(hint: Hint, cryptic: bool, prefix="They say that ", reward = hint.formatted_reward(cryptic) + if importance: + item_importance = hint.formatted_importance() + if hint.importance == ItemImportance.REQUIRED: + item_importance = " (\\{1A 06 FF 00 00 02}%s\\{1A 06 FF 00 00 00})" % item_importance + elif hint.importance == ItemImportance.POSSIBLY_REQUIRED: + item_importance = " (\\{1A 06 FF 00 00 04}%s\\{1A 06 FF 00 00 00})" % item_importance + elif hint.importance == ItemImportance.NOT_REQUIRED: + item_importance = " (\\{1A 06 FF 00 00 07}%s\\{1A 06 FF 00 00 00})" % item_importance + else: + item_importance = "" + if hint.type == HintType.PATH: place_preposition = "at" if place in ["the mail", "the Tower of the Gods sector", "the Forsaken Fortress sector"]: @@ -496,16 +577,16 @@ def get_formatted_hint_text(hint: Hint, cryptic: bool, prefix="They say that ", ) elif hint.type == HintType.LOCATION: hint_string = ( - "%s\\{1A 06 FF 00 00 01}%s\\{1A 06 FF 00 00 00} rewards \\{1A 06 FF 00 00 01}%s\\{1A 06 FF 00 00 00}%s" - % (prefix, place, reward, suffix) + "%s\\{1A 06 FF 00 00 01}%s\\{1A 06 FF 00 00 00} rewards \\{1A 06 FF 00 00 01}%s\\{1A 06 FF 00 00 00}%s%s" + % (prefix, place, reward, item_importance, suffix) ) elif hint.type == HintType.ITEM: copula = "is" if reward in ["Power Bracelets", "Iron Boots", "Bombs"]: copula = "are" hint_string = ( - "%s\\{1A 06 FF 00 00 01}%s\\{1A 06 FF 00 00 00} %s located in \\{1A 06 FF 00 00 01}%s\\{1A 06 FF 00 00 00}%s" - % (prefix, reward, copula, place, suffix) + "%s\\{1A 06 FF 00 00 01}%s\\{1A 06 FF 00 00 00}%s %s located in \\{1A 06 FF 00 00 01}%s\\{1A 06 FF 00 00 00}%s" + % (prefix, reward, item_importance, copula, place, suffix) ) else: hint_string = "" @@ -643,10 +724,6 @@ def get_required_locations_for_paths(self): if item_name not in self.logic.all_progress_items: continue - # Keys are only considered in key-lunacy. - if item_name.endswith(" Key") and not self.options.keylunacy: - continue - # Determine the item name for the given location. zone_name, specific_location_name = self.logic.split_location_name_by_zone(location_name) entrance_zone = self.rando.entrances.get_entrance_zone_for_item_location(location_name) @@ -750,7 +827,7 @@ def get_barren_zones(self, progress_locations, hinted_remote_locations): useful_locations |= set(item_locations) # Subtracting the set of useful locations from the set of progress locations gives us our set of barren locations. - barren_locations = set(progress_locations) - useful_locations + self.barren_locations = set(progress_locations) - useful_locations # Since we hint at zones as barren, we next construct a set of zones which contain at least one useful item. zones_with_useful_locations = set() @@ -759,7 +836,7 @@ def get_barren_zones(self, progress_locations, hinted_remote_locations): # Now, we do the same with barren locations, identifying which zones have barren locations. zones_with_barren_locations = set() - for location_name in sorted(barren_locations): + for location_name in sorted(self.barren_locations): # Don't consider locations hinted through remote location hints, as those are explicity barren. if location_name in hinted_remote_locations: continue @@ -812,6 +889,23 @@ def check_item_can_be_hinted_at(self, item_name: str): return True + def get_importance_for_location(self, location_name): + if self.path_locations is None: + raise Exception("Path locations have not yet been initialized.") + if self.barren_locations is None: + raise Exception("Barren locations have not yet been initialized.") + + item_name = self.logic.done_item_locations[location_name] + if item_name not in self.logic.all_progress_items: + return None + + if location_name in self.path_locations: + return ItemImportance.REQUIRED + elif location_name in self.barren_locations: + return ItemImportance.NOT_REQUIRED + else: + return ItemImportance.POSSIBLY_REQUIRED + def check_is_legal_item_hint(self, location_name, progress_locations, previously_hinted_locations): item_name = self.logic.done_item_locations[location_name] @@ -856,7 +950,9 @@ def get_item_hint(self, hintable_locations): item_name = self.logic.done_item_locations[location_name] entrance_zone = self.rando.entrances.get_entrance_zone_for_item_location(location_name) - item_hint = Hint(HintType.ITEM, entrance_zone, item_name) + item_importance = self.get_importance_for_location(location_name) + + item_hint = Hint(HintType.ITEM, entrance_zone, item_name, item_importance) return item_hint, location_name @@ -892,7 +988,7 @@ def get_legal_location_hints(self, progress_locations, hinted_barren_zones, prev return remote_hintable_locations, standard_hintable_locations - def get_location_hint(self, hintable_locations): + def get_location_hint(self, hintable_locations, ignore_importance=False): if len(hintable_locations) == 0: return None @@ -901,8 +997,12 @@ def get_location_hint(self, hintable_locations): hintable_locations.remove(location_name) item_name = self.logic.done_item_locations[location_name] + if ignore_importance: + item_importance = None + else: + item_importance = self.get_importance_for_location(location_name) - location_hint = Hint(HintType.LOCATION, location_name, item_name) + location_hint = Hint(HintType.LOCATION, location_name, item_name, item_importance) return location_hint, location_name @@ -925,7 +1025,8 @@ def generate_octo_fairy_hint(self): def generate_savage_labyrinth_hint(self, location_name): # Get an item hint for one of the two checks in Savage Labyrinth. item_name = self.logic.done_item_locations[location_name] - hint = Hint(HintType.FIXED_LOCATION, location_name, item_name) + item_importance = self.get_importance_for_location(location_name) + hint = Hint(HintType.FIXED_LOCATION, location_name, item_name, item_importance) return hint def generate_hints(self): @@ -957,7 +1058,8 @@ def generate_hints(self): if self.prioritize_remote_hints: remote_hintable_locations, standard_hintable_locations = self.get_legal_location_hints(progress_locations, [], []) while len(remote_hintable_locations) > 0 and len(hinted_remote_locations) < self.max_location_hints: - location_hint, location_name = self.get_location_hint(remote_hintable_locations) + # Temporarily ignore item importance until later. + location_hint, location_name = self.get_location_hint(remote_hintable_locations, ignore_importance=True) hinted_remote_locations.append(location_hint) previously_hinted_locations.append(location_name) @@ -967,8 +1069,18 @@ def generate_hints(self): # small keys). Basically, we remove the item from that location and see if the path goal is still achievable. If # not, then we consider the item as required. required_locations_for_paths = {} - if self.max_path_hints > 0: - required_locations_for_paths = self.get_required_locations_for_paths() + required_locations_for_paths = self.get_required_locations_for_paths() + + # Flatten the list of path locations for reference for hint importance + self.path_locations = set() + for goal_name, path_locations in required_locations_for_paths.items(): + for zone_name, entrance_zone, specific_location_name, item_name in path_locations: + self.path_locations.add("%s - %s" % (zone_name, specific_location_name)) + + # Filter out the locations of dungeon keys as being path when key-lunacy is disabled + if not self.options.keylunacy: + for dungeon_name, dungeon_paths in required_locations_for_paths.items(): + required_locations_for_paths[dungeon_name] = [item_tuple for item_tuple in dungeon_paths if not item_tuple[3].endswith(" Key")] # Generate path hints. # We hint at max `self.max_path_hints` zones at random. @@ -1044,6 +1156,10 @@ def generate_hints(self): if barren_hint is not None: hinted_barren_zones.append(barren_hint) + # Update remote location hints with importance information + for hint in hinted_remote_locations: + hint.importance = self.get_importance_for_location(hint.place) + # Generate item hints. # We select at most `self.max_item_hints` items at random to hint at. We do not want to hint at items already # covered by the path hints, nor do we want to hint at items in barren-hinted locations. diff --git a/test/test_helpers.py b/test/test_helpers.py index 0ca7cf87..7fa82bf2 100644 --- a/test/test_helpers.py +++ b/test/test_helpers.py @@ -88,6 +88,7 @@ def enable_all_options(options: Options): options.num_item_hints = 15 options.cryptic_hints = True options.prioritize_remote_hints = True + options.hint_importance = True options.do_not_generate_spoiler_log = False diff --git a/wwr_ui/randomizer_window.ui b/wwr_ui/randomizer_window.ui index dd4a03f2..720518a4 100644 --- a/wwr_ui/randomizer_window.ui +++ b/wwr_ui/randomizer_window.ui @@ -1168,6 +1168,13 @@ + + + + Hint Importance + + + diff --git a/wwr_ui/uic/ui_randomizer_window.py b/wwr_ui/uic/ui_randomizer_window.py index 792d19e0..7d3a7d2e 100644 --- a/wwr_ui/uic/ui_randomizer_window.py +++ b/wwr_ui/uic/ui_randomizer_window.py @@ -819,6 +819,11 @@ def setupUi(self, MainWindow): self.gridLayout_7.addLayout(self.horizontalLayout_15, 5, 3, 1, 1) + self.hint_importance = QCheckBox(self.groupBox_5) + self.hint_importance.setObjectName(u"hint_importance") + + self.gridLayout_7.addWidget(self.hint_importance, 6, 2, 1, 1) + self.verticalLayout_8.addWidget(self.groupBox_5) @@ -1121,6 +1126,7 @@ def retranslateUi(self, MainWindow): self.label_for_num_location_hints.setText(QCoreApplication.translate("MainWindow", u"Location Hints", None)) self.label_for_num_item_hints.setText(QCoreApplication.translate("MainWindow", u"Item Hints", None)) self.label_for_num_path_hints.setText(QCoreApplication.translate("MainWindow", u"Path Hints", None)) + self.hint_importance.setText(QCoreApplication.translate("MainWindow", u"Hint Importance", None)) self.groupBox_6.setTitle(QCoreApplication.translate("MainWindow", u"Additional Advanced Options", None)) self.do_not_generate_spoiler_log.setText(QCoreApplication.translate("MainWindow", u"Do Not Generate Spoiler Log", None)) self.dry_run.setText(QCoreApplication.translate("MainWindow", u"Dry Run", None))