diff --git a/worlds/tww/Items.py b/worlds/tww/Items.py index 1e310bf2315d..f0009e020ef6 100644 --- a/worlds/tww/Items.py +++ b/worlds/tww/Items.py @@ -267,6 +267,8 @@ def dungeon_item(self) -> Optional[str]: "WT Compass": TWWItemData("Compass", IC.filler, 153, 1, 0x85), "Victory": TWWItemData("Event", IC.progression, None, 1, None), + # Universal Tracker Item, keep at the bottom + "Glitched": TWWItemData("Event", IC.progression, None, 1, None), } ISLAND_NUMBER_TO_CHART_NAME = { diff --git a/worlds/tww/Rules.py b/worlds/tww/Rules.py index d4c743783c09..7ac838b27098 100644 --- a/worlds/tww/Rules.py +++ b/worlds/tww/Rules.py @@ -3,7 +3,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING -from BaseClasses import MultiWorld +from BaseClasses import CollectionState, MultiWorld from worlds.AutoWorld import LogicMixin from worlds.generic.Rules import set_rule @@ -74,6 +74,49 @@ def _tww_precise_3(self, player: int) -> bool: def _tww_tuner_logic_enabled(self, player: int) -> bool: return self.multiworld.worlds[player].logic_tuner_logic_enabled +def mix_in_universal_tracker_logic() -> None: + """ + Mix in Universal Tracker-specific logic methods that include glitched item checks. + This replaces the normal logic difficulty methods with UT versions on the CollectionState class. + """ + # If already mixed in (CollectionState methods have been replaced), return early + if getattr(CollectionState, "_tww_ut_logic_mixed_in", False): + return + + def _tww_obscure_1(self, player: int) -> bool: + return self.multiworld.worlds[player].logic_obscure_1 or self.has("Glitched", player) + + def _tww_obscure_2(self, player: int) -> bool: + return self.multiworld.worlds[player].logic_obscure_2 or self.has("Glitched", player) + + def _tww_obscure_3(self, player: int) -> bool: + return self.multiworld.worlds[player].logic_obscure_3 or self.has("Glitched", player) + + def _tww_precise_1(self, player: int) -> bool: + return self.multiworld.worlds[player].logic_precise_1 or self.has("Glitched", player) + + def _tww_precise_2(self, player: int) -> bool: + return self.multiworld.worlds[player].logic_precise_2 or self.has("Glitched", player) + + def _tww_precise_3(self, player: int) -> bool: + return self.multiworld.worlds[player].logic_precise_3 or self.has("Glitched", player) + + mix_in_methods = {k: v for k, v in locals().items() if k.startswith("_tww_")} + + if len(mix_in_methods) != 6: + raise Exception(f"Expected 6 mix-in methods to replace, but got {len(mix_in_methods)}") + + for k, v in mix_in_methods.items(): + if not hasattr(TWWLogic, k): + raise Exception(f"{k} must be present on TWWLogic") + if not hasattr(CollectionState, k): + raise Exception(f"{k} must be present on CollectionState") + # Replace the normal mixed-in method with the Universal Tracker version. + setattr(CollectionState, k, v) + + # Mark that mixing has been completed + CollectionState._tww_ut_logic_mixed_in = True + def set_rules(world: "TWWWorld") -> None: # noqa: F405 """ diff --git a/worlds/tww/TWWClient.py b/worlds/tww/TWWClient.py index cb4c25685d92..4c53682014d5 100644 --- a/worlds/tww/TWWClient.py +++ b/worlds/tww/TWWClient.py @@ -86,7 +86,7 @@ CLIFF_PLATEAU_ISLES_HIGHEST_ISLE_DUMMY_STAGE_NAME = "CliPlaH" # Data storage key -AP_VISITED_STAGE_NAMES_KEY_FORMAT = "tww_visited_stages_%i" +AP_VISITED_STAGE_NAMES_KEY_FORMAT = "tww_visited_stages_%i_%i" class TWWCommandProcessor(ClientCommandProcessor): @@ -207,13 +207,13 @@ def on_package(self, cmd: str, args: dict[str, Any]) -> None: if "death_link" in args["slot_data"]: Utils.async_start(self.update_death_link(bool(args["slot_data"]["death_link"]))) # Request the connected slot's dictionary (used as a set) of visited stages. - visited_stages_key = AP_VISITED_STAGE_NAMES_KEY_FORMAT % self.slot + visited_stages_key = AP_VISITED_STAGE_NAMES_KEY_FORMAT % (self.slot, self.team) Utils.async_start(self.send_msgs([{"cmd": "Get", "keys": [visited_stages_key]}])) elif cmd == "Retrieved": requested_keys_dict = args["keys"] # Read the connected slot's dictionary (used as a set) of visited stages. if self.slot is not None: - visited_stages_key = AP_VISITED_STAGE_NAMES_KEY_FORMAT % self.slot + visited_stages_key = AP_VISITED_STAGE_NAMES_KEY_FORMAT % (self.slot, self.team) if visited_stages_key in requested_keys_dict: visited_stages = requested_keys_dict[visited_stages_key] # If it has not been set before, the value in the response will be `None`. @@ -249,21 +249,38 @@ async def update_visited_stages(self, newly_visited_stage_name: str) -> None: """ Update the server's data storage of the visited stage names to include the newly visited stage name. + This sends two types of messages: + 1. A dict-based update to tww_visited_stages_ for PopTracker compatibility + 2. Individual SET messages for tww___ to trigger server-side reconnection + :param newly_visited_stage_name: The name of the stage recently visited. """ if self.slot is not None: - visited_stages_key = AP_VISITED_STAGE_NAMES_KEY_FORMAT % self.slot - await self.send_msgs( - [ - { - "cmd": "Set", - "key": visited_stages_key, - "default": {}, - "want_reply": False, - "operations": [{"operation": "update", "value": {newly_visited_stage_name: True}}], - } - ] - ) + messages_to_send = [] + + # Message 1: Update the visited_stages dict (for PopTracker) + visited_stages_key = AP_VISITED_STAGE_NAMES_KEY_FORMAT % (self.slot, self.team) + messages_to_send.append({ + "cmd": "Set", + "key": visited_stages_key, + "default": {}, + "want_reply": False, + "operations": [{"operation": "update", "value": {newly_visited_stage_name: True}}], + }) + + # Message 2: Set individual stage key to trigger server-side world callback + # This matches the format expected by reconnect_found_entrances(): ___ + if self.team is not None: + stage_key = f"tww_{self.team}_{self.slot}_{newly_visited_stage_name}" + messages_to_send.append({ + "cmd": "Set", + "key": stage_key, + "default": 0, + "want_reply": False, + "operations": [{"operation": "replace", "value": 1}], + }) + + await self.send_msgs(messages_to_send) def update_salvage_locations_map(self) -> None: """ diff --git a/worlds/tww/Tracker.py b/worlds/tww/Tracker.py new file mode 100644 index 000000000000..e4ecb86d23c2 --- /dev/null +++ b/worlds/tww/Tracker.py @@ -0,0 +1,90 @@ +""" +Universal Tracker support for The Wind Waker randomizer. + +This module contains all Universal Tracker-specific logic including: +- Deferred entrance tracking and reconnection +- UT generation detection and handling +- Option restoration from UT slot_data +""" + +from typing import Any, NamedTuple + +# Universal Tracker multiworld attribute names +UT_RE_GEN_PASSTHROUGH_ATTR = "re_gen_passthrough" +UT_GENERATION_IS_FAKE_ATTR = "generation_is_fake" +UT_ENFORCE_DEFERRED_CONNECTIONS_ATTR = "enforce_deferred_connections" + +# Wind Waker game name for UT identification +WIND_WAKER_GAME_NAME = "The Wind Waker" + +class DatastorageParsed(NamedTuple): + """Parsed datastorage key for deferred entrance tracking.""" + team: int + player: int + stage_name: str + +def is_ut_generation(multiworld: Any) -> bool: + """ + Check if the current generation is a Universal Tracker generation. + + :param multiworld: The MultiWorld object. + :return: True if UT is active, False otherwise. + """ + return hasattr(multiworld, UT_GENERATION_IS_FAKE_ATTR) and getattr(multiworld, UT_GENERATION_IS_FAKE_ATTR) + +def get_ut_slot_data(multiworld: Any, game_name: str = WIND_WAKER_GAME_NAME) -> dict[str, Any] | None: + """ + Get the slot_data from re_gen_passthrough for the given game. + + :param multiworld: The MultiWorld object. + :param game_name: The game name to look up (defaults to "The Wind Waker"). + :return: The slot_data dict if available, None otherwise. + """ + re_gen_passthrough = getattr(multiworld, UT_RE_GEN_PASSTHROUGH_ATTR, {}) + if not re_gen_passthrough or game_name not in re_gen_passthrough: + return None + return re_gen_passthrough[game_name] + +def should_defer_entrances(multiworld: Any, has_randomized_entrances: bool) -> bool: + """ + Check if entrances should be deferred based on UT and server settings. + + :param multiworld: The MultiWorld object. + :param has_randomized_entrances: Whether the world has randomized entrances. + :return: True if entrances should be deferred, False otherwise. + """ + # Only defer entrances during UT regeneration + if not is_ut_generation(multiworld): + return False + + # Check if server has deferred connections enabled + deferred_enabled = getattr( + multiworld, + UT_ENFORCE_DEFERRED_CONNECTIONS_ATTR, + None + ) in ("on", "default") + + # Only defer if entrances are actually randomized + return deferred_enabled and has_randomized_entrances + + +def parse_datastorage_key(key: str) -> DatastorageParsed | None: + """ + Parse a datastorage key and return parsed components or None if invalid. + + Expected format: tww___ + + :param key: The datastorage key to parse. + :return: DatastorageParsed with team, player, stage_name, or None if invalid. + """ + parts = key.split("_") + min_parts = 4 + if len(parts) < min_parts or parts[0] != "tww": + return None + try: + team = int(parts[1]) + player = int(parts[2]) + stage_name = "_".join(parts[3:]) + return DatastorageParsed(team=team, player=player, stage_name=stage_name) + except (ValueError, IndexError): + return None diff --git a/worlds/tww/__init__.py b/worlds/tww/__init__.py index 58e752b5c9ad..9baf11154497 100644 --- a/worlds/tww/__init__.py +++ b/worlds/tww/__init__.py @@ -6,28 +6,27 @@ import yaml -from BaseClasses import Item -from BaseClasses import ItemClassification as IC -from BaseClasses import MultiWorld, Region, Tutorial +from BaseClasses import Item, ItemClassification as IC, MultiWorld, Region, Tutorial from Options import Toggle from worlds.AutoWorld import WebWorld, World from worlds.Files import APPlayerContainer -from worlds.generic.Rules import add_item_rule from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components, icon_paths, launch_subprocess +from worlds.generic.Rules import add_item_rule from .Items import ISLAND_NUMBER_TO_CHART_NAME, ITEM_TABLE, TWWItem, item_name_groups from .Locations import LOCATION_TABLE, TWWFlag, TWWLocation from .Options import TWWOptions, tww_option_groups from .Presets import tww_options_presets +from .Rules import mix_in_universal_tracker_logic, set_rules +from .Tracker import DatastorageParsed, parse_datastorage_key, should_defer_entrances from .randomizers.Charts import ISLAND_NUMBER_TO_NAME, ChartRandomizer from .randomizers.Dungeons import Dungeon, create_dungeons from .randomizers.Entrances import ALL_EXITS, BOSS_EXIT_TO_DUNGEON, MINIBOSS_EXIT_TO_DUNGEON, EntranceRandomizer from .randomizers.ItemPool import generate_itempool from .randomizers.RequiredBosses import RequiredBossesRandomizer -from .Rules import set_rules -VERSION: tuple[int, int, int] = (3, 0, 0) +VERSION: tuple[int, int, int] = (3, 0, 0) def run_client() -> None: """ @@ -149,6 +148,75 @@ class TWWWorld(World): logic_precise_3: bool logic_tuner_logic_enabled: bool + # Universal Tracker stuff, does not do anything in normal gen + using_ut: bool # A way to check if Universal Tracker is active via early setting and forgetting + glitches_item_name = "Glitched" + ut_can_gen_without_yaml = True # class var that tells it to ignore the player yaml + + # Deferred entrances support for Universal Tracker + # Mapping from game stage names (from memory) to region/exit names + # This is used for deferred entrance tracking - when the client sends a visited stage, + # we can map it to the corresponding entrance exit using this mapping. + # Only includes stages that are actual entrance exit destinations from the 44 randomized entrances. + # Based on stage names from https://github.com/LagoLunatic/wwrando/blob/master/data/stage_names.txt + stage_name_to_exit: ClassVar[dict[str, str]] = { + # Boss arenas (6 boss entrances) + "M_DragB": "Gohma Boss Arena", + "kinBOSS": "Kalle Demos Boss Arena", + "SirenB": "Gohdan Boss Arena", + "M2tower": "Helmaroc King Boss Arena", + "M_DaiB": "Jalhalla Boss Arena", + "kazeB": "Molgera Boss Arena", + # Dungeon entrances (5) + "M_NewD2": "Dragon Roost Cavern", + "kindan": "Forbidden Woods", + "Siren": "Tower of the Gods", + "M_Dai": "Earth Temple", + "kaze": "Wind Temple", + # Miniboss arenas (5 miniboss entrances) + "kinMB": "Forbidden Woods Miniboss Arena", + "SirenMB": "Tower of the Gods Miniboss Arena", + "M_DaiMB": "Earth Temple Miniboss Arena", + "kazeMB": "Wind Temple Miniboss Arena", + "kenroom": "Master Sword Chamber", + # Secret cave entrances (20) + "Cave09": "Savage Labyrinth", + "Cave01": "Bomb Island Secret Cave", + "Cave02": "Star Island Secret Cave", + "Cave03": "Cliff Plateau Isles Secret Cave", + "Cave04": "Rock Spire Isle Secret Cave", + "Cave05": "Horseshoe Island Secret Cave", + "Cave07": "Pawprint Isle Wizzrobe Cave", + "TyuTyu": "Pawprint Isle Chuchu Cave", + "SubD42": "Needle Rock Isle Secret Cave", + "SubD43": "Angular Isles Secret Cave", + "SubD71": "Boating Course Secret Cave", + "TF_01": "Stone Watcher Island Secret Cave", + "TF_02": "Overlook Island Secret Cave", + "TF_03": "Bird's Peak Rock Secret Cave", + "TF_06": "Dragon Roost Island Secret Cave", + "MiniKaz": "Fire Mountain Secret Cave", + "MiniHyo": "Ice Ring Isle Secret Cave", + "ITest63": "Shark Island Secret Cave", + "Cave03": "Cliff Plateau Isles Secret Cave", + "WarpD": "Diamond Steppe Island Warp Maze Cave", + # Secret cave inner entrances (2) + "ITest62": "Ice Ring Isle Inner Cave", + "CliPlaH": "Cliff Plateau Isles Inner Cave", + # Fairy fountain entrances (6) + "Fairy01": "Northern Fairy Fountain", + "Fairy02": "Eastern Fairy Fountain", + "Fairy03": "Western Fairy Fountain", + "Fairy04": "Outset Fairy Fountain", + "Fairy05": "Thorned Fairy Fountain", + "Fairy06": "Southern Fairy Fountain", + } + + # List of datastorage keys that UT will track for deferred entrance reconnection + # Format: tww_{team}_{player}_{stagename} + # This is populated dynamically when entrances are disconnected + found_entrances_datastorage_key: list[str] = [] + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -168,6 +236,14 @@ def __init__(self, *args, **kwargs): self.entrances = EntranceRandomizer(self) self.boss_reqs = RequiredBossesRandomizer(self) + # Deferred entrance tracking + self.disconnected_entrances: dict[Any, Any] = {} + + # Track whether charts/entrances/required bosses were restored from UT slot_data + self._charts_restored_from_ut = False + self._entrances_restored_from_ut = False + self._required_bosses_restored_from_ut = False + def _determine_item_classification_overrides(self) -> None: """ Determine item classification overrides. The classification of an item may be affected by which options are @@ -277,10 +353,152 @@ def _get_classification_name(classification: IC) -> str: else: return "filler" + def _restore_options_from_ut_slot_data(self, slot_data: dict[str, Any]) -> None: + """ + Restore all seed-affecting options from slot_data during UT regeneration. + + This method is called during UT's internal regeneration to restore the options + that were used in the original seed generation. This must happen before any + option-dependent logic runs in generate_early(). + + NOTE: This method restores both OPTIONS and chart mappings from slot_data. + The entrances and required_bosses will be recalculated during the current regeneration + since UT's random state differs from the original generation. + + :param slot_data: The slot_data dictionary from re_gen_passthrough. + """ + # NOTE: required_bosses, charts, and entrances must be restored from the original generation + # to keep UT in sync with the server's world state. + + if "entrances" in slot_data: + self._restore_entrance_mappings_from_slot_data(slot_data["entrances"]) + + if "charts" in slot_data: + self._restore_chart_mappings_from_slot_data(slot_data["charts"]) + + if "required_boss_item_locations" in slot_data: + self._restore_required_bosses_from_slot_data(slot_data["required_boss_item_locations"]) + + options_restored = [] + options_failed = [] + + for key, value in slot_data.items(): + if key == "charts" or key == "entrances" or key == "required_boss_item_locations": + continue + + try: + # Get the option from the options dataclass + opt: Any | None = getattr(self.options, key, None) + if opt is not None: + # Use from_any() to properly deserialize the value + # This works for both regular options and OptionSets + setattr(self.options, key, opt.from_any(value)) + options_restored.append(key) + else: + options_failed.append(key) + except Exception as e: + options_failed.append(key) + + def _restore_entrance_mappings_from_slot_data(self, entrances_mapping: dict[str, str]) -> None: + """ + Restore entrance mappings from the server's original generation. + + This prevents UT from re-randomizing entrances with its own random state, which would + cause the tracker to show different entrance destinations than the actual game world. + + The entrances_mapping dict maps entrance names to exit names from the server's generation. + We apply these to done_entrances_to_exits before create_regions() randomizes them. + + :param entrances_mapping: Dict from slot_data["entrances"] + """ + try: + from .randomizers.Entrances import ZoneEntrance, ZoneExit + + # Apply the server's entrance mappings to override the default mappings + for entrance_name, exit_name in entrances_mapping.items(): + zone_entrance = ZoneEntrance.all.get(entrance_name) + zone_exit = ZoneExit.all.get(exit_name) + + if zone_entrance and zone_exit: + self.entrances.done_entrances_to_exits[zone_entrance] = zone_exit + self.entrances.done_exits_to_entrances[zone_exit] = zone_entrance + + # Mark that entrances have been pre-loaded from UT so we won't re-randomize them + self._entrances_restored_from_ut = True + self.entrances.skip_randomization = True + except Exception as e: + pass + + def _restore_chart_mappings_from_slot_data(self, charts_mapping: list[int]) -> None: + """ + Restore chart mappings from the server's original generation. + + This prevents UT from re-randomizing charts with its own random state, which would + cause the tracker to show different locations than the actual game world. + + The charts_mapping is a list where charts_mapping[i-1] is the island that the chart + originally at island i now points to (after the server's randomization). + + We invert this to set island_number_to_chart_name correctly. + + :param charts_mapping: List from slot_data["charts"] + """ + try: + # Reset to default state first + self.charts.island_number_to_chart_name = ISLAND_NUMBER_TO_CHART_NAME.copy() + + # For each original island position, find which island its chart ended up at + for original_island in range(1, 50): # Islands 1-49 + original_chart_name = ISLAND_NUMBER_TO_CHART_NAME[original_island] + new_island = charts_mapping[original_island - 1] + + # Restore the mapping: the chart originally at original_island is now at new_island + self.charts.island_number_to_chart_name[new_island] = original_chart_name + + # Mark that charts were restored from UT so we skip randomization + self._charts_restored_from_ut = True + self.charts.skip_randomization = True + except Exception as e: + pass + + def _restore_required_bosses_from_slot_data(self, required_boss_item_locations: dict[str, str]) -> None: + """ + Restore required boss item locations from the server's original generation. + + This prevents UT from re-randomizing required bosses with its own random state, which would + cause the tracker to show different boss locations than the actual game world. + + :param required_boss_item_locations: Dict from slot_data["required_bosses"] + """ + try: + # Directly restore the required boss locations from the server + self.boss_reqs.required_boss_item_locations = required_boss_item_locations + + # Mark that required bosses have been pre-loaded from UT so we won't re-randomize them + self._required_bosses_restored_from_ut = True + self.boss_reqs.skip_randomization = True + except Exception as e: + pass + def generate_early(self) -> None: """ Run before any general steps of the MultiWorld other than options. """ + # Handle Universal Tracker Support FIRST - before any option-dependent logic + re_gen_passthrough = getattr(self.multiworld, "re_gen_passthrough", {}) + self.using_ut = re_gen_passthrough and self.game in re_gen_passthrough + + if self.using_ut: + # Restore options from slot_data during UT regeneration + slot_data: dict[str, Any] = re_gen_passthrough[self.game] + self._restore_options_from_ut_slot_data(slot_data) + + # Mix in Universal Tracker-specific logic that includes glitched item checks + if not getattr(self.__class__, "_tww_ut_logic_mixed_in", False): + mix_in_universal_tracker_logic() + self.__class__._tww_ut_logic_mixed_in = True + + # Now continue with normal generation using the restored (or default) options options = self.options # Only randomize secret cave inner entrances if both puzzle secret caves and combat secret caves are enabled. @@ -381,6 +599,9 @@ def create_regions(self) -> None: # Connect the regions in the multiworld. Randomize entrances to exits if the option is set. self.entrances.randomize_entrances() + # Handle deferred entrance disconnection for Universal Tracker if enabled + self.connect_entrances() + def set_rules(self) -> None: """ Set access and item rules on locations. @@ -592,15 +813,292 @@ def fill_slot_data(self) -> Mapping[str, Any]: This is a way the generator can give custom data to the client. The client will receive this as JSON in the `Connected` response. + For Universal Tracker integration, this method includes: + - All seed-affecting options via self.options.get_slot_data_dict() + - Entrance mappings for entrance randomization + - Deferred entrance datastorage keys (for Universal Tracker to hide unrevealed entrances) + - Chart mappings for sunken treasure location tracking + - Required boss locations for required bosses mode + :return: A dictionary to be sent to the client when it connects to the server. """ slot_data = self.options.get_slot_data_dict() # Add entrances to `slot_data`. This is the same data that is written to the .aptww file. + # For deferred entrances, exclude them from slot_data so Universal Tracker doesn't + # display them initially. + deferred_entrance_names = set() + if hasattr(self, 'disconnected_entrances'): + # Build set of entrance names that are deferred + # Extract deferred entrance names from disconnected_entrances + disconnected_entrance_names_only = { + entrance.name.split(" -> ")[0] if " -> " in entrance.name else entrance.name + for entrance in self.disconnected_entrances.keys() + } + + deferred_entrance_names = { + zone_entrance.entrance_name + for zone_entrance in self.entrances.done_entrances_to_exits.keys() + if zone_entrance.entrance_name in disconnected_entrance_names_only + } + entrances = { zone_entrance.entrance_name: zone_exit.unique_name for zone_entrance, zone_exit in self.entrances.done_entrances_to_exits.items() + if zone_entrance.entrance_name not in deferred_entrance_names } slot_data["entrances"] = entrances + # Add deferred entrance tracking keys for Universal Tracker + # These keys should be initially hidden from the tracker until the datastorage key is revealed + if hasattr(self, 'found_entrances_datastorage_key') and self.found_entrances_datastorage_key: + slot_data["deferred_entrance_keys"] = self.found_entrances_datastorage_key + + # Add chart mapping for sunken treasure randomization. + # Create a list where the original island number is the index, and the value is the new island number. + # This is needed for UT to correctly track sunken treasure locations. + chart_name_to_island_number = { + chart_name: island_number for island_number, chart_name in self.charts.island_number_to_chart_name.items() + } + charts_mapping: list[int] = [] + for i in range(1, 49 + 1): + original_chart_name = ISLAND_NUMBER_TO_CHART_NAME[i] + new_island_number = chart_name_to_island_number[original_chart_name] + charts_mapping.append(new_island_number) + slot_data["charts"] = charts_mapping + + # Add required bosses information. + slot_data["required_boss_item_locations"] = self.boss_reqs.required_boss_item_locations return slot_data + + def interpret_slot_data(self, slot_data: dict[str, Any]) -> dict[str, Any]: + """ + Interpret the slot data from the server and return it for UT regeneration. + + This method is called when UT connects to the server and receives slot data. + By returning the slot_data dict, we tell UT to do a regeneration, which will then + call generate_early() with the slot_data available in re_gen_passthrough. + + :param slot_data: The slot data dictionary from the server. + :return: The slot_data unchanged, for UT's regeneration. + """ + return slot_data + + @staticmethod + def _extract_entrance_name(full_entrance_name: str) -> str: + """ + Extract just the entrance name, removing the '-> Region' part. + + :param full_entrance_name: The full entrance name (e.g., "Entrance -> Region"). + :return: Just the entrance name part (e.g., "Entrance"). + """ + separator = " -> " + return full_entrance_name.split(separator)[0] if separator in full_entrance_name else full_entrance_name + + @staticmethod + def _format_datastorage_key(team: int, player: int, stage_name: str) -> str: + """ + Format a datastorage key for deferred entrance tracking. + + :param team: The team number. + :param player: The player number. + :param stage_name: The stage name visited by the player. + :return: The formatted datastorage key. + """ + return f"tww_{team}_{player}_{stage_name}" + + @staticmethod + def _parse_datastorage_key(key: str) -> DatastorageParsed | None: + """ + Parse a datastorage key and return parsed components or None if invalid. + + Expected format: tww___ + + :param key: The datastorage key to parse. + :return: DatastorageParsed with team, player, stage_name, or None if invalid. + """ + return parse_datastorage_key(key) + + def connect_entrances(self) -> None: + """ + Connect entrances with deferred entrance support for Universal Tracker. + + During UT regeneration with enforce_deferred_connections enabled on the server, + entrances are disconnected so they're not revealed until the player visits areas. + """ + if self._should_defer_entrances(): + self._disconnect_entrances() + + def _should_defer_entrances(self) -> bool: + """ + Check if entrances should be deferred based on UT and server settings. + + :return: True if entrances should be deferred, False otherwise. + """ + has_randomized_entrances = bool(self.entrances.done_entrances_to_exits) + return should_defer_entrances(self.multiworld, has_randomized_entrances) + + def _disconnect_entrances(self) -> None: + """ + Disconnect all randomized entrances to enable deferred spoilers. + + When entrances are disconnected, existing trackers will either display nothing or a fallback + to indicate that an entrance has not been found yet. The use of reconnect_found_entrances + will restore these connections. + + Only entrances from enabled entrance pools will be disconnected. If a pool is not shuffled + (e.g., secret caves when only dungeons are randomized), those entrances remain connected + and fully visible in the tracker. + + This method populates: + - self.disconnected_entrances: Dict mapping entrance objects to their original regions + - self.found_entrances_datastorage_key: List of datastorage keys for UT tracking + """ + from .randomizers.Entrances import ( + DUNGEON_ENTRANCES, MINIBOSS_ENTRANCES, BOSS_ENTRANCES, + SECRET_CAVE_ENTRANCES, SECRET_CAVE_INNER_ENTRANCES, FAIRY_FOUNTAIN_ENTRANCES + ) + + # Only initialize on first call; if already disconnected, don't reinitialize + if not hasattr(self, 'disconnected_entrances'): + self.disconnected_entrances = {} + if not hasattr(self, 'found_entrances_datastorage_key'): + self.found_entrances_datastorage_key = [] + # If we've already disconnected entrances in this generation, skip re-disconnecting + if len(self.disconnected_entrances) > 0: + return + + # Determine which entrance pools are enabled based on options + # Use DRY approach to avoid code repetition + entrance_pool_config = [ + (self.options.randomize_dungeon_entrances, DUNGEON_ENTRANCES), + (self.options.randomize_miniboss_entrances, MINIBOSS_ENTRANCES), + (self.options.randomize_boss_entrances, BOSS_ENTRANCES), + (self.options.randomize_secret_cave_entrances, SECRET_CAVE_ENTRANCES), + (self.options.randomize_secret_cave_inner_entrances, SECRET_CAVE_INNER_ENTRANCES), + (self.options.randomize_fairy_fountain_entrances, FAIRY_FOUNTAIN_ENTRANCES), + ] + enabled_pools = { + e.entrance_name + for option_enabled, entrance_pool in entrance_pool_config + if option_enabled + for e in entrance_pool + } + + # Build a set of randomized entrance names that are in ENABLED pools only + randomized_entrance_names = {zone_entrance.entrance_name + for zone_entrance in self.entrances.done_entrances_to_exits.keys() + if zone_entrance.entrance_name in enabled_pools} + + # Get all actual entrances + all_entrances = list(self.get_entrances()) + if len(all_entrances) == 0: + return + + # Get team and player info for datastorage keys + team = getattr(self.multiworld, "team", 0) + player = self.player + + # Disconnect matching entrances and store mappings + for entrance in all_entrances: + # Extract just the entrance name (before the "->") for matching + # Entrance objects have names like "Dungeon Entrance on Dragon Roost Island -> Forbidden Woods" + entrance_name_only = self._extract_entrance_name(entrance.name) + + if entrance.connected_region and entrance_name_only in randomized_entrance_names: + original_region = entrance.connected_region + self.disconnected_entrances[entrance] = original_region + + # Extract stage name from entrance's destination region + # We need to find which stage_name maps to this destination + destination_region_name = original_region.name + stage_name = None + + try: + for stage, region_name in self.stage_name_to_exit.items(): + if region_name == destination_region_name: + stage_name = stage + break + + if stage_name: + # Format datastorage key via standard means: "game_team_player_stagename" + datastorage_key = self._format_datastorage_key(team, player, stage_name) + self.found_entrances_datastorage_key.append(datastorage_key) + except (KeyError, AttributeError): + # Skip if stage mapping fails + pass + + # Disconnect the entrance + entrance.connected_region = None + + # Update the entrance name to remove the "-> Region" part so Universal Tracker + # doesn't display the connection prematurely, keeping spoilers hidden until + # the entrance is reconnected + entrance.name = entrance_name_only + + + def reconnect_found_entrances(self, key: str, value: Any) -> None: + """ + Reconnect entrances when the player physically enters a stage in the game. + + This is called by UT when a datastorage key is triggered, indicating the player has visited + a specific stage. The key format is: tww_team_player_stagename. We extract the stage name + and reconnect all entrances that lead to that stage. + + :param key: The datastorage key (format: tww_team_player_stagename). + :param value: The value associated with the datastorage key. + """ + if not value: + return + + parsed = self._parse_datastorage_key(key) + if parsed is None: + return + + self._reconnect_entrance_for_stage(parsed.stage_name) + + def _reconnect_entrance_for_stage(self, stage_name: str) -> int: + """ + Reconnect all entrances leading to the given stage. + + :param stage_name: The stage name from the datastorage key. + :return: The number of entrances reconnected. + """ + exit_region_name = self.stage_name_to_exit.get(stage_name) + if exit_region_name is None: + return 0 + + exit_region = self.get_region(exit_region_name) + if exit_region is None: + return 0 + + return self._reconnect_matching_entrances(exit_region, exit_region_name) + + def _reconnect_matching_entrances(self, target_region: Region, region_name: str) -> int: + """ + Reconnect all entrances that lead to the target region. + + :param target_region: The region to reconnect entrances to. + :param region_name: The display name of the region. + :return: The number of entrances reconnected. + """ + if not hasattr(self, 'disconnected_entrances'): + return 0 + + count = 0 + for entrance, original_region in self.disconnected_entrances.items(): + if original_region == target_region: + self._reconnect_entrance(entrance, region_name) + count += 1 + return count + + def _reconnect_entrance(self, entrance: Any, region_name: str) -> None: + """ + Reconnect a single entrance to its region, restoring the connection display. + + :param entrance: The entrance object to reconnect. + :param region_name: The name of the region being reconnected to. + """ + entrance.connected_region = self.get_region(region_name) + entrance_name = self._extract_entrance_name(entrance.name) + entrance.name = f"{entrance_name} -> {region_name}" diff --git a/worlds/tww/randomizers/Charts.py b/worlds/tww/randomizers/Charts.py index 695f2dc0d292..c858ff5459cb 100644 --- a/worlds/tww/randomizers/Charts.py +++ b/worlds/tww/randomizers/Charts.py @@ -72,6 +72,7 @@ def __init__(self, world: "TWWWorld") -> None: self.multiworld = world.multiworld self.island_number_to_chart_name = ISLAND_NUMBER_TO_CHART_NAME.copy() + self.skip_randomization = False def setup_progress_sunken_treasure_locations(self) -> None: """ @@ -84,8 +85,9 @@ def setup_progress_sunken_treasure_locations(self) -> None: # Shuffles the list of island numbers if charts are randomized. # The shuffled island numbers determine which sector each chart points to. + # However, if skip_randomization is True, the charts remain in their pre-loaded state. shuffled_island_numbers = list(self.island_number_to_chart_name.keys()) - if options.randomize_charts: + if options.randomize_charts and not self.skip_randomization: self.world.random.shuffle(shuffled_island_numbers) for original_item_name in reversed(original_item_names): diff --git a/worlds/tww/randomizers/Entrances.py b/worlds/tww/randomizers/Entrances.py index 6a0683972a7b..5f97feb4a59e 100644 --- a/worlds/tww/randomizers/Entrances.py +++ b/worlds/tww/randomizers/Entrances.py @@ -342,16 +342,24 @@ def __init__(self, world: "TWWWorld"): self.banned_exits: list[ZoneExit] = [] self.islands_with_a_banned_dungeon: set[str] = set() + self.skip_randomization = False def randomize_entrances(self) -> None: """ Randomize entrances for The Wind Waker. + + If skip_randomization is True, entrances will not be randomized but will still be + finalized to connect regions. This is used when entrances were pre-loaded from + UT slot_data to preserve the server's original mappings. """ - self.init_banned_exits() + # If entrances were not pre-loaded from server, randomize them + if not self.skip_randomization: + self.init_banned_exits() - for relevant_entrances, relevant_exits in self.get_all_entrance_sets_to_be_randomized(): - self.randomize_one_set_of_entrances(relevant_entrances, relevant_exits) + for relevant_entrances, relevant_exits in self.get_all_entrance_sets_to_be_randomized(): + self.randomize_one_set_of_entrances(relevant_entrances, relevant_exits) + # Always finalize to connect regions, whether randomized or pre-loaded self.finalize_all_randomized_sets_of_entrances() def init_banned_exits(self) -> None: diff --git a/worlds/tww/randomizers/RequiredBosses.py b/worlds/tww/randomizers/RequiredBosses.py index 04c4d7c5bb40..097e773766a9 100644 --- a/worlds/tww/randomizers/RequiredBosses.py +++ b/worlds/tww/randomizers/RequiredBosses.py @@ -29,6 +29,7 @@ def __init__(self, world: "TWWWorld"): self.banned_locations: set[str] = set() self.banned_dungeons: set[str] = set() self.banned_bosses: list[str] = [] + self.skip_randomization = False def validate_boss_options(self, options: TWWOptions) -> None: """ @@ -51,6 +52,10 @@ def randomize_required_bosses(self) -> None: :raises OptionError: If the randomization fails to meet user-defined constraints. """ + # Skip randomization if required bosses were already restored from Universal Tracker + if self.skip_randomization: + return + options = self.world.options # Validate constraints on required bosses options. diff --git a/worlds/tww/test/Bases.py b/worlds/tww/test/Bases.py new file mode 100644 index 000000000000..811219cab6ee --- /dev/null +++ b/worlds/tww/test/Bases.py @@ -0,0 +1,20 @@ +from test.bases import WorldTestBase + +from ..Rules import mix_in_universal_tracker_logic + +class WindWakerTestBase(WorldTestBase): + game = "The Wind Waker" + glitches_item_name = "Glitched" + + def enable_glitched_item(self): + """ + Enable the use of the glitched/sequence breaking item for unit test purposes. + + Also automatically award the item for convenience sake. + """ + # Mix in Universal Tracker logic so glitched item checks work + if not getattr(self.__class__, "_tww_ut_logic_mixed_in", False): + mix_in_universal_tracker_logic() + self.__class__._tww_ut_logic_mixed_in = True + + self.collect(self.world.create_item("Glitched")) diff --git a/worlds/tww/test/TestBattleGanondorf.py b/worlds/tww/test/TestBattleGanondorf.py new file mode 100644 index 000000000000..3c62955e5382 --- /dev/null +++ b/worlds/tww/test/TestBattleGanondorf.py @@ -0,0 +1,52 @@ +from .. import TWWWorld +from ..Macros import can_defeat_ganondorf, has_heros_sword +from .Bases import WindWakerTestBase + +class TestBattleGanondorfStartWithSword(WindWakerTestBase): + options = {"sword_mode": 0} + world: TWWWorld + + def test_battle_ganondorf_sword_not_enough(self) -> None: + self.assertFalse(can_defeat_ganondorf(self.multiworld.state, self.player)) + + def test_battle_ganondorf_sword_shield_victory(self) -> None: + self.collect_by_name("Progressive Shield") + self.assertTrue(can_defeat_ganondorf(self.multiworld.state, self.player)) + +class TestBattleGanondorfNoStartingSword(WindWakerTestBase): + options = {"sword_mode": 1} + world: TWWWorld + + def test_battle_ganondorf_sword_not_enough(self) -> None: + self.collect_by_name("Progressive Sword") + self.assertFalse(can_defeat_ganondorf(self.multiworld.state, self.player)) + + def test_battle_ganondorf_shield_not_enough(self) -> None: + self.collect_by_name("Progressive Shield") + self.assertFalse(has_heros_sword(self.multiworld.state, self.player)) + self.assertFalse(can_defeat_ganondorf(self.multiworld.state, self.player)) + + def test_battle_ganondorf_sword_shield_victory(self) -> None: + self.collect_by_name("Progressive Sword") + self.collect_by_name("Progressive Shield") + self.assertTrue(can_defeat_ganondorf(self.multiworld.state, self.player)) + +class TestBattleGanondorfSwordlessMode(WindWakerTestBase): + options = {"sword_mode": 3} + world: TWWWorld + + def test_battle_ganondorf_wits_not_enough(self) -> None: + self.assertFalse(can_defeat_ganondorf(self.multiworld.state, self.player)) + + def test_battle_ganondorf_shield_victory(self) -> None: + self.collect_by_name("Progressive Shield") + self.assertTrue(can_defeat_ganondorf(self.multiworld.state, self.player)) + + def test_battle_ganondorf_hammer_not_enough_usually(self) -> None: + self.collect_by_name("Skull Hammer") + self.assertFalse(can_defeat_ganondorf(self.multiworld.state, self.player)) + + def test_battle_ganondorf_hammer_obscure_tech(self) -> None: + self.collect_by_name("Skull Hammer") + self.enable_glitched_item() + self.assertTrue(can_defeat_ganondorf(self.multiworld.state, self.player)) diff --git a/worlds/tww/test/TestUtMixin.py b/worlds/tww/test/TestUtMixin.py new file mode 100644 index 000000000000..603d764bd14b --- /dev/null +++ b/worlds/tww/test/TestUtMixin.py @@ -0,0 +1,39 @@ +""" +Test the Universal Tracker mixin implementation. +""" +import unittest + +from worlds.tww.Rules import TWWLogic, mix_in_universal_tracker_logic +from BaseClasses import CollectionState + + +class TestUniversalTrackerMixin(unittest.TestCase): + """Test that the mix_in_universal_tracker_logic function works correctly.""" + + def test_mix_in_logic_methods_exist(self) -> None: + """Verify that the methods exist on TWWLogic before mixing in.""" + self.assertTrue(hasattr(TWWLogic, "_tww_obscure_1")) + self.assertTrue(hasattr(TWWLogic, "_tww_obscure_2")) + self.assertTrue(hasattr(TWWLogic, "_tww_obscure_3")) + self.assertTrue(hasattr(TWWLogic, "_tww_precise_1")) + self.assertTrue(hasattr(TWWLogic, "_tww_precise_2")) + self.assertTrue(hasattr(TWWLogic, "_tww_precise_3")) + + def test_mix_in_logic_methods_applied(self) -> None: + """Verify that the mix-in function applies glitched item checks to methods.""" + # The mixin should have been called during module import, ensuring methods exist + # and that glitched checks have been applied to CollectionState + self.assertTrue(hasattr(CollectionState, "_tww_obscure_1")) + self.assertTrue(hasattr(CollectionState, "_tww_obscure_2")) + self.assertTrue(hasattr(CollectionState, "_tww_obscure_3")) + self.assertTrue(hasattr(CollectionState, "_tww_precise_1")) + self.assertTrue(hasattr(CollectionState, "_tww_precise_2")) + self.assertTrue(hasattr(CollectionState, "_tww_precise_3")) + + # The methods should be callable + self.assertTrue(callable(getattr(CollectionState, "_tww_obscure_1"))) + self.assertTrue(callable(getattr(CollectionState, "_tww_obscure_2"))) + self.assertTrue(callable(getattr(CollectionState, "_tww_obscure_3"))) + self.assertTrue(callable(getattr(CollectionState, "_tww_precise_1"))) + self.assertTrue(callable(getattr(CollectionState, "_tww_precise_2"))) + self.assertTrue(callable(getattr(CollectionState, "_tww_precise_3"))) diff --git a/worlds/tww/test/__init__.py b/worlds/tww/test/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1