Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions worlds/tww/Items.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
45 changes: 44 additions & 1 deletion worlds/tww/Rules.py
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really like slowing down regular generation just to add Universal Tracker support.

I'm not sure if there is a good solution to this, but it might be possible to, when generating with UT, replace the _tww_obscure/precise_1/2/3 methods, that would normally get added onto CollectionState through the TWWLogic LogicMixin's AutoLogicRegister metaclass, with UT-specific versions that include the or self.has("Glitched", player, 1) checks (which can just be or self.has("Glitched", player) because 1 is the default count).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did that pattern similar to what was done for Ship of Harkinian, and I was explicitly suggested to place it in the trick section. https://github.com/lonegraywolf2000/Archipelago-SoH/blob/oot-soh/worlds/oot_soh/LogicHelpers.py#L387

Copy link
Contributor

@Mysteryem Mysteryem Jan 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If they want to put up with their regular generation being slower just to support UT glitched logic, then that's up to them.

Ideally, there would be a better way where UT glitched logic is only checked when generation is occurring under UT. Depending on how worlds have set up their logic, separating out the UT glitched logic functions could be easier or more difficult.

If there is a way to support UT glitched logic without slowing down regular generations, and without being highly complicated, then I think it would be a good thing to do.

This is similar to the idea of making logic rules that never needs to check what Options are enabled because different logic is set depending on what the enabled Options are.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on earlier discord discussions, I may want to see how you handle things in the future with Banjo Tooie before bringing it over.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Banjo Tooie isn't really comparable to Wind Waker because they set up their logic differently. In Banjo Tooie's case, their logic is all defined through methods on a specific class that the world creates an instance of. This means that a subclass of this class can exist for UT generation which overrides the methods that check logic difficulty to additionally check for the UT 'glitched' item. This means that generation with UT can just swap out the normal class for the UT-specific class. jjjj12212#3

Wind Waker, however, does not define its logic on a class, and the specific part that checks the logic difficulty is done through a LogicMixin, which is a special class that has its methods 'mixed into' the CollectionState class of Core AP. The actual class itself isn't used during generation, so subclassing it is useless.

What I think can be done for Wind Waker with Universal Tracker, is to define a new LogicMixin class when generation with Universal Tracker starts, to replace some of the methods in Wind Waker's normal LogicMixin to versions that check for UT's 'glitched' item. The important thing is that this UT-specific LogicMixin class must never be defined unless it is known that generation is occurring with Universal Tracker.

An alternative to defining a new LogicMixin class at runtime could be manually setting replacement methods onto the CollectionState class (basically manually implementing the behaviour of defining a LogicMixin class), but I feel like that would be slightly more prone to breaking if AP were to change how LogicMixins work in the future.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I understand what you are saying here. I may as well confirm something. Are you allowing a refactor of the rules to go to a fully class approach here that may remove LogicMixins, or do you still wish to keep the LogicMixin approach? I don't want to jump the gun again with programming.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh? I'm not saying any refactor should be done. Banjo Tooie and TWW set up their logic differently to one another, and that doesn't need to change.

I'm saying that when generation is occurring under Universal Tracker, I think it should work for the TWW world to define a new, UT-specific, LogicMixin subclass that causes the _tww_obscure/precise_# methods to be replaced with new methods that additionally check for the UT 'glitched' item.

In generate_early, or maybe even __init__ if it is possible to detect that generation is occurring under UT at that point, the TWW world would define a new subclass of LogicMixin with each of the _tww_obscure/precise_# methods that include checking the UT 'glitched' item, causing the pre-existing mixed-in methods with the same names from TWW's normal LogicMixin subclass to be replaced with the new methods.

That way, TWW would use the methods from its normal LogicMixin in normal generation, and only when generating with Universal Tracker would the Universal Tracker versions of _tww_obscure/precise_# get defined and replace the normal versions of those methods.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, damn, I looked at LogicMixin's metaclass and it will error when attempting to define a new LogicMixin with some of the same methods defined, so I guess the replacing of the mixed-in methods on the CollectionState would have to be done manually.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't tested it, but my thought was something like this. The main problem I can think of is that your test that enables the glitched item won't work any more, and I wouldn't recommend calling mix_in_universal_tracker_logic() from within tests because it makes permanent changes to the CollectionState class for the currently running Python process.

A small test that the glitched logic is working could probably instead be added within mix_in_universal_tracker_logic(), if mix_in_universal_tracker_logic() is updated to take the TWWWorld instance as an argument, so that a CollectionState can be created and tested against. The logic_obscure/precise_1/2/3 attributes on the TWWWorld class would probably also need to be changed to default to False for a test to work because they are currently only defined on the instance at the end of generate_early(), so testing against the mixed-in methods would raise an AttributeError due to the attributes not being defined on the TWWWorld instance yet.

Index: worlds/tww/Rules.py
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/worlds/tww/Rules.py b/worlds/tww/Rules.py
--- a/worlds/tww/Rules.py	(revision db56e26df9f4aa0ebe3a708f7115d8e898e5b72a)
+++ b/worlds/tww/Rules.py	(date 1767929704424)
@@ -75,6 +75,41 @@
         return self.multiworld.worlds[player].logic_tuner_logic_enabled
 
 
+def mix_in_universal_tracker_logic():
+    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} was must be present on TWWLogic")
+        if not hasattr(CollectionState, k):
+            raise Exception(f"{k} must be present on CollectionState")
+        if getattr(CollectionState, k) is not getattr(TWWLogic, k):
+            raise Exception(f"CollectionState.{k} should be TWWLogic.{k}")
+        # Replace the normal mixed-in method with the Universal Tracker version.
+        setattr(CollectionState, k, v)
+
+
 def set_rules(world: "TWWWorld") -> None:  # noqa: F405
     """
     Define the logic rules for locations in The Wind Waker.
Index: worlds/tww/__init__.py
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/worlds/tww/__init__.py b/worlds/tww/__init__.py
--- a/worlds/tww/__init__.py	(revision db56e26df9f4aa0ebe3a708f7115d8e898e5b72a)
+++ b/worlds/tww/__init__.py	(date 1767929704430)
@@ -24,7 +24,7 @@
 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
+from .Rules import set_rules, mix_in_universal_tracker_logic
 
 VERSION: tuple[int, int, int] = (3, 0, 0)
 
@@ -281,6 +281,12 @@
         """
         Run before any general steps of the MultiWorld other than options.
         """
+        if hasattr(self.multiworld, "generation_is_fake") and not getattr(TWWWorld, "_ut_logic_mixed_in", False):
+            # Replace normal LogicMixin methods with
+            mix_in_universal_tracker_logic()
+            # Prevent re-replacing the mixin methods if they have already been replaced.
+            setattr(TWWWorld, "_ut_logic_mixed_in", True)
+
         options = self.options
 
         # Only randomize secret cave inner entrances if both puzzle secret caves and combat secret caves are enabled.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented.

Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
"""
Expand Down
47 changes: 32 additions & 15 deletions worlds/tww/TWWClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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_<slot> for PopTracker compatibility
2. Individual SET messages for tww_<team>_<slot>_<stagename> 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(): <GAME_ABBR>_<team>_<slot>_<stagename>
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}],
})
Comment on lines +271 to +281
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PopTracker pack can already connect entrances using just the list of visited stages, why are all these new datastorage keys needed?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That doesn't really answer my question. Is there some technical requirement of Universal Tracker for these new datastorage keys to be required?

Either way, the client shouldn't be duplicating the same visited stages data across both the existing list datastorage key and all the new individual datastorage keys, so one of the two should be removed, though it is not clear to me which one should be removed currently.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on earlier Discord discussion, we can hopefully resolve this one if the original message sent also contains room for the (mostly unused by all except devs) team parameter. How easy is it to update poptracker to account for that?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updating the PopTracker pack to change the datastorage key to include the team is like a 3 line change or so, it's very little work.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless I hear otherwise, I'll presume you will go for tww_visited_stages_%i_%i then.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ultimately, the tracker pack has to follow the client, so decide on the new datastorage key's format, and the poptracker pack will follow.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented on latest commit.


await self.send_msgs(messages_to_send)

def update_salvage_locations_map(self) -> None:
"""
Expand Down
90 changes: 90 additions & 0 deletions worlds/tww/Tracker.py
Original file line number Diff line number Diff line change
@@ -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_<team>_<player>_<stagename>

: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
Loading